Initial commit
This commit is contained in:
22
apps/web/src/app/[locale]/dashboard/page.tsx
Normal file
22
apps/web/src/app/[locale]/dashboard/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import type { Locale } from '@dwellops/i18n';
|
||||
import { DashboardView } from '@/views/DashboardView/DashboardView';
|
||||
|
||||
interface DashboardPageProps {
|
||||
params: Promise<{ locale: Locale }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: DashboardPageProps) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'dashboard' });
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard page — thin page component that delegates to the DashboardView.
|
||||
* Data fetching and layout composition happen inside the view.
|
||||
*/
|
||||
export default async function DashboardPage({ params }: DashboardPageProps) {
|
||||
const { locale } = await params;
|
||||
return <DashboardView locale={locale} />;
|
||||
}
|
||||
42
apps/web/src/app/[locale]/layout.tsx
Normal file
42
apps/web/src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { routing } from '../../../i18n/routing';
|
||||
import type { Locale } from '@dwellops/i18n';
|
||||
|
||||
interface LocaleLayoutProps {
|
||||
children: ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'DwellOps',
|
||||
};
|
||||
|
||||
/**
|
||||
* Locale-aware root layout.
|
||||
* Sets the HTML lang attribute and provides next-intl messages.
|
||||
*/
|
||||
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
|
||||
const { locale } = await params;
|
||||
|
||||
if (!routing.locales.includes(locale as Locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body>
|
||||
<NextIntlClientProvider messages={messages}>{children}</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
14
apps/web/src/app/[locale]/page.tsx
Normal file
14
apps/web/src/app/[locale]/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import type { Locale } from '@dwellops/i18n';
|
||||
|
||||
interface HomePageProps {
|
||||
params: Promise<{ locale: Locale }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale home page — redirects to the dashboard shell.
|
||||
*/
|
||||
export default async function HomePage({ params }: HomePageProps) {
|
||||
const { locale } = await params;
|
||||
redirect(`/${locale}/dashboard`);
|
||||
}
|
||||
23
apps/web/src/app/layout.tsx
Normal file
23
apps/web/src/app/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import '@/styles/globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: '%s | DwellOps',
|
||||
default: 'DwellOps',
|
||||
},
|
||||
description: 'Modern HOA management platform',
|
||||
};
|
||||
|
||||
interface RootLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Root layout — minimal shell that wraps the locale-specific layout.
|
||||
* Locale-aware layout is in [locale]/layout.tsx.
|
||||
*/
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
return children;
|
||||
}
|
||||
36
apps/web/src/components/PageHeader/PageHeader.module.css
Normal file
36
apps/web/src/components/PageHeader/PageHeader.module.css
Normal file
@@ -0,0 +1,36 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding-block-end: var(--space-6);
|
||||
border-block-end: var(--border-width-default) solid var(--color-border-subtle);
|
||||
margin-block-end: var(--space-6);
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-primary);
|
||||
line-height: var(--line-height-tight);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
39
apps/web/src/components/PageHeader/PageHeader.stories.tsx
Normal file
39
apps/web/src/components/PageHeader/PageHeader.stories.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Button } from '@dwellops/ui/Button';
|
||||
import { PageHeader } from './PageHeader';
|
||||
|
||||
const meta: Meta<typeof PageHeader> = {
|
||||
title: 'Components/PageHeader',
|
||||
component: PageHeader,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Presentational page header for page-level views.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PageHeader>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { title: 'Dashboard' },
|
||||
};
|
||||
|
||||
export const WithSubtitle: Story = {
|
||||
args: {
|
||||
title: 'Dashboard',
|
||||
subtitle: 'Sunrise Ridge HOA',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithActions: Story = {
|
||||
args: {
|
||||
title: 'Units',
|
||||
subtitle: '24 units total',
|
||||
actions: <Button variant="primary">Add unit</Button>,
|
||||
},
|
||||
};
|
||||
25
apps/web/src/components/PageHeader/PageHeader.test.tsx
Normal file
25
apps/web/src/components/PageHeader/PageHeader.test.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@dwellops/test-utils';
|
||||
import { PageHeader } from './PageHeader';
|
||||
|
||||
describe('PageHeader', () => {
|
||||
it('renders the title', () => {
|
||||
render(<PageHeader title="Dashboard" />);
|
||||
expect(screen.getByRole('heading', { name: 'Dashboard', level: 1 })).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders the subtitle when provided', () => {
|
||||
render(<PageHeader title="Dashboard" subtitle="Sunrise Ridge HOA" />);
|
||||
expect(screen.getByText('Sunrise Ridge HOA')).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not render subtitle when omitted', () => {
|
||||
render(<PageHeader title="Dashboard" />);
|
||||
expect(screen.queryByText('Sunrise Ridge HOA')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders actions slot', () => {
|
||||
render(<PageHeader title="Units" actions={<button>Add unit</button>} />);
|
||||
expect(screen.getByRole('button', { name: 'Add unit' })).toBeDefined();
|
||||
});
|
||||
});
|
||||
36
apps/web/src/components/PageHeader/PageHeader.tsx
Normal file
36
apps/web/src/components/PageHeader/PageHeader.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import styles from './PageHeader.module.css';
|
||||
|
||||
export interface PageHeaderProps {
|
||||
/** Primary heading text. */
|
||||
title: string;
|
||||
/** Optional subtitle or breadcrumb text. */
|
||||
subtitle?: string;
|
||||
/** Optional actions rendered in the header trailing area (e.g. buttons). */
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentational page header.
|
||||
*
|
||||
* Used at the top of page-level views. Does not fetch data or
|
||||
* perform navigation — pure presentation only.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PageHeader title="Dashboard" subtitle="Sunrise Ridge HOA" />
|
||||
* ```
|
||||
*/
|
||||
export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<div className={styles.text}>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
{subtitle && <p className={styles.subtitle}>{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className={styles.actions}>{actions}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageHeader;
|
||||
1
apps/web/src/components/PageHeader/translations.json
Normal file
1
apps/web/src/components/PageHeader/translations.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
5
apps/web/src/css.d.ts
vendored
Normal file
5
apps/web/src/css.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/** TypeScript declarations for CSS Module files. */
|
||||
declare module '*.module.css' {
|
||||
const styles: Record<string, string>;
|
||||
export default styles;
|
||||
}
|
||||
8
apps/web/src/lib/test-setup.ts
Normal file
8
apps/web/src/lib/test-setup.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// next-intl requires these globals in test environments.
|
||||
// Provide minimal stubs.
|
||||
Object.defineProperty(globalThis, '__NI18N_LOCALE', {
|
||||
value: 'en',
|
||||
writable: true,
|
||||
});
|
||||
13
apps/web/src/middleware.ts
Normal file
13
apps/web/src/middleware.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import createMiddleware from 'next-intl/middleware';
|
||||
import { routing } from '../i18n/routing';
|
||||
|
||||
/**
|
||||
* next-intl middleware for locale detection and routing.
|
||||
* Redirects / to /en (or detected locale).
|
||||
*/
|
||||
export default createMiddleware(routing);
|
||||
|
||||
export const config = {
|
||||
// Match all routes except Next.js internals and static files.
|
||||
matcher: ['/((?!_next|_vercel|.*\\..*).*)'],
|
||||
};
|
||||
69
apps/web/src/styles/globals.css
Normal file
69
apps/web/src/styles/globals.css
Normal file
@@ -0,0 +1,69 @@
|
||||
/* Design token import — must be first. */
|
||||
@import '@dwellops/ui/tokens';
|
||||
|
||||
/* Viewport-aware base reset */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-family-base);
|
||||
font-size: 100%;
|
||||
line-height: var(--line-height-normal);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
hanging-punctuation: first last;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-bg-page);
|
||||
color: var(--color-text-primary);
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* Smooth focus transitions, preserve reduced motion preference */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:focus-visible {
|
||||
transition: box-shadow var(--transition-fast);
|
||||
}
|
||||
}
|
||||
|
||||
/* Default focus ring — overridable per component */
|
||||
:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
img,
|
||||
svg,
|
||||
video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Screen-reader-only utility */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
26
apps/web/src/views/DashboardView/DashboardView.module.css
Normal file
26
apps/web/src/views/DashboardView/DashboardView.module.css
Normal file
@@ -0,0 +1,26 @@
|
||||
.main {
|
||||
min-height: 100dvh;
|
||||
background-color: var(--color-bg-page);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin-inline: auto;
|
||||
padding: var(--space-8) var(--space-6);
|
||||
}
|
||||
|
||||
.activity {
|
||||
margin-block-start: var(--space-8);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
margin-block-end: var(--space-4);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
45
apps/web/src/views/DashboardView/DashboardView.tsx
Normal file
45
apps/web/src/views/DashboardView/DashboardView.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import type { Locale } from '@dwellops/i18n';
|
||||
import { PageHeader } from '@/components/PageHeader/PageHeader';
|
||||
import { DashboardStats } from '@/widgets/DashboardStats/DashboardStats';
|
||||
import styles from './DashboardView.module.css';
|
||||
|
||||
interface DashboardViewProps {
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard page-level view.
|
||||
*
|
||||
* Views are server components by default. They orchestrate data loading
|
||||
* and compose components and widgets into the full page layout.
|
||||
* Views do not contain reusable primitive logic.
|
||||
*/
|
||||
export async function DashboardView({ locale }: DashboardViewProps) {
|
||||
const t = await getTranslations({ locale, namespace: 'dashboard' });
|
||||
|
||||
// In a real app this data would come from the API layer.
|
||||
const stats = [
|
||||
{ labelKey: 'totalUnits' as const, value: 24 },
|
||||
{ labelKey: 'activeResidents' as const, value: 87 },
|
||||
{ labelKey: 'pendingRequests' as const, value: 3 },
|
||||
{ labelKey: 'openIssues' as const, value: 7 },
|
||||
];
|
||||
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<div className={styles.container}>
|
||||
<PageHeader title={t('title')} />
|
||||
<DashboardStats stats={stats} />
|
||||
<section className={styles.activity} aria-labelledby="activity-heading">
|
||||
<h2 id="activity-heading" className={styles.sectionTitle}>
|
||||
{t('recentActivity')}
|
||||
</h2>
|
||||
<p className={styles.empty}>{t('noActivity')}</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardView;
|
||||
@@ -0,0 +1,25 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-primary);
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { DashboardStats } from './DashboardStats';
|
||||
|
||||
const meta: Meta<typeof DashboardStats> = {
|
||||
title: 'Widgets/DashboardStats',
|
||||
component: DashboardStats,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Widget that renders summary stat cards for the dashboard. Composes the Card primitive.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DashboardStats>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
stats: [
|
||||
{ labelKey: 'totalUnits', value: 24 },
|
||||
{ labelKey: 'activeResidents', value: 87 },
|
||||
{ labelKey: 'pendingRequests', value: 3 },
|
||||
{ labelKey: 'openIssues', value: 7 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { stats: [] },
|
||||
};
|
||||
48
apps/web/src/widgets/DashboardStats/DashboardStats.tsx
Normal file
48
apps/web/src/widgets/DashboardStats/DashboardStats.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Card } from '@dwellops/ui/Card';
|
||||
import styles from './DashboardStats.module.css';
|
||||
|
||||
export interface StatItem {
|
||||
/** i18n key for the stat label, relative to `dashboard.stats`. */
|
||||
labelKey: 'totalUnits' | 'activeResidents' | 'pendingRequests' | 'openIssues';
|
||||
value: number | string;
|
||||
}
|
||||
|
||||
export interface DashboardStatsProps {
|
||||
stats: StatItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget that renders a grid of summary stat cards.
|
||||
*
|
||||
* Widgets compose components and may contain local state.
|
||||
* They do not make API calls — data is passed in as props.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <DashboardStats stats={[
|
||||
* { labelKey: 'totalUnits', value: 24 },
|
||||
* { labelKey: 'activeResidents', value: 87 },
|
||||
* ]} />
|
||||
* ```
|
||||
*/
|
||||
export function DashboardStats({ stats }: DashboardStatsProps) {
|
||||
const t = useTranslations('dashboard.stats');
|
||||
|
||||
return (
|
||||
<section aria-label="Summary statistics" className={styles.grid}>
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.labelKey}>
|
||||
<div className={styles.stat}>
|
||||
<span className={styles.value}>{stat.value}</span>
|
||||
<span className={styles.label}>{t(stat.labelKey)}</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardStats;
|
||||
Reference in New Issue
Block a user