Initial commit

This commit is contained in:
2026-03-10 21:30:52 -03:00
commit 72a4f0be26
145 changed files with 14881 additions and 0 deletions

3
apps/web/.env.example Normal file
View File

@@ -0,0 +1,3 @@
NEXT_PUBLIC_API_URL=http://localhost:3001
BETTER_AUTH_URL=http://localhost:3001
BETTER_AUTH_SECRET="change-me-in-production-use-openssl-rand-base64-32"

View File

@@ -0,0 +1,24 @@
import type { StorybookConfig } from '@storybook/nextjs-vite';
import { resolve } from 'path';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(ts|tsx)', '../../packages/ui/src/**/*.stories.@(ts|tsx)'],
addons: ['@storybook/addon-docs'],
framework: {
name: '@storybook/nextjs-vite',
options: {},
},
docs: {
autodocs: 'tag',
},
viteFinal(config) {
config.resolve ??= {};
config.resolve.alias = {
...config.resolve.alias,
'@': resolve(import.meta.dirname, '../src'),
};
return config;
},
};
export default config;

View File

@@ -0,0 +1,18 @@
import type { Preview } from '@storybook/react';
import '../src/styles/globals.css';
const preview: Preview = {
parameters: {
layout: 'centered',
backgrounds: {
default: 'page',
values: [
{ name: 'page', value: '#f8f9fa' },
{ name: 'surface', value: '#ffffff' },
{ name: 'dark', value: '#212529' },
],
},
},
};
export default preview;

2
apps/web/.stylelintrc.js Normal file
View File

@@ -0,0 +1,2 @@
import config from '@dwellops/config/stylelint';
export default config;

View File

@@ -0,0 +1,30 @@
import { test, expect } from '@playwright/test';
/**
* Smoke tests for the dashboard shell.
* These tests verify the foundational page renders and is accessible.
*/
test.describe('Dashboard', () => {
test.beforeEach(async ({ page }) => {
// Navigate to the dashboard — locale redirect happens automatically.
await page.goto('/en/dashboard');
});
test('renders the dashboard page title', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Dashboard', level: 1 })).toBeVisible();
});
test('renders the stats grid section', async ({ page }) => {
await expect(page.getByRole('region', { name: 'Summary statistics' })).toBeVisible();
});
test('page has correct document title', async ({ page }) => {
await expect(page).toHaveTitle(/DwellOps/);
});
test('page has no critical accessibility violations', async ({ page }) => {
// Basic landmark checks — full axe integration should be added in CI.
await expect(page.getByRole('main')).toBeVisible();
await expect(page.getByRole('banner')).toBeVisible();
});
});

View File

@@ -0,0 +1,3 @@
import { react } from '@dwellops/config/eslint';
export default react;

View File

@@ -0,0 +1,8 @@
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';
/**
* Type-safe locale-aware navigation helpers.
* Use these instead of the plain Next.js Link and useRouter.
*/
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing);

16
apps/web/i18n/request.ts Normal file
View File

@@ -0,0 +1,16 @@
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
/**
* Per-request next-intl configuration.
* Loads the aggregated messages file for the resolved locale.
*/
export default getRequestConfig(async ({ requestLocale }) => {
const locale = (await requestLocale) ?? routing.defaultLocale;
return {
locale,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
messages: (await import(`../messages/${locale}.json`)).default,
};
});

12
apps/web/i18n/routing.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineRouting } from 'next-intl/routing';
import { locales, defaultLocale } from '@dwellops/i18n';
/**
* next-intl routing configuration.
* Locale-prefixed routes: /en/dashboard, etc.
*/
export const routing = defineRouting({
locales,
defaultLocale,
localePrefix: 'always',
});

45
apps/web/messages/en.json Normal file
View File

@@ -0,0 +1,45 @@
{
"common": {
"appName": "DwellOps",
"loading": "Loading…",
"error": "Something went wrong.",
"retry": "Try again",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"close": "Close",
"back": "Back",
"next": "Next",
"submit": "Submit"
},
"nav": {
"dashboard": "Dashboard",
"units": "Units",
"residents": "Residents",
"documents": "Documents",
"settings": "Settings",
"signOut": "Sign out"
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome back, {name}",
"stats": {
"totalUnits": "Total units",
"activeResidents": "Active residents",
"pendingRequests": "Pending requests",
"openIssues": "Open issues"
},
"recentActivity": "Recent activity",
"noActivity": "No recent activity to display."
},
"auth": {
"signIn": "Sign in",
"signInWithMagicLink": "Sign in with magic link",
"signInWithPasskey": "Sign in with passkey",
"enterEmail": "Enter your email",
"emailPlaceholder": "you@example.com",
"magicLinkSent": "Check your email — we sent a magic link.",
"passkeyPrompt": "Use your passkey to authenticate."
}
}

13
apps/web/next.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { NextConfig } from 'next';
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
const config: NextConfig = {
experimental: {
// Enables importing packages that export CSS directly
optimizePackageImports: ['@dwellops/ui'],
},
};
export default withNextIntl(config);

60
apps/web/package.json Normal file
View File

@@ -0,0 +1,60 @@
{
"name": "@dwellops/web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit",
"lint": "eslint src && stylelint \"src/**/*.module.css\"",
"lint:fix": "eslint src --fix && stylelint \"src/**/*.module.css\" --fix",
"test": "vitest run --coverage",
"test:watch": "vitest",
"test:e2e": "playwright test",
"storybook": "storybook dev -p 6007",
"storybook:build": "storybook build"
},
"dependencies": {
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"next-intl": "catalog:",
"better-auth": "catalog:",
"@dwellops/ui": "workspace:*",
"@dwellops/types": "workspace:*",
"@dwellops/schemas": "workspace:*",
"@dwellops/i18n": "workspace:*"
},
"devDependencies": {
"typescript": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"postcss": "catalog:",
"postcss-preset-env": "catalog:",
"postcss-import": "catalog:",
"stylelint": "catalog:",
"stylelint-config-standard": "catalog:",
"eslint": "catalog:",
"typescript-eslint": "catalog:",
"eslint-plugin-react": "catalog:",
"eslint-plugin-react-hooks": "catalog:",
"eslint-plugin-jsx-a11y": "catalog:",
"vitest": "catalog:",
"@vitest/coverage-v8": "catalog:",
"@vitejs/plugin-react": "catalog:",
"jsdom": "catalog:",
"@testing-library/react": "catalog:",
"@testing-library/user-event": "catalog:",
"@testing-library/jest-dom": "catalog:",
"@playwright/test": "catalog:",
"storybook": "catalog:",
"@storybook/nextjs-vite": "catalog:",
"@storybook/addon-docs": "catalog:",
"@storybook/react": "catalog:",
"@dwellops/config": "workspace:*",
"@dwellops/test-utils": "workspace:*"
}
}

View File

@@ -0,0 +1,43 @@
import { defineConfig, devices } from '@playwright/test';
const baseURL = process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:3000';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env['CI'],
retries: process.env['CI'] ? 2 : 0,
workers: process.env['CI'] ? 1 : undefined,
reporter: [['html', { outputFolder: 'playwright-report' }]],
use: {
baseURL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
],
webServer: process.env['CI']
? undefined
: {
command: 'pnpm dev',
url: baseURL,
reuseExistingServer: true,
timeout: 120_000,
},
});

View File

@@ -0,0 +1,18 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'postcss-import': {},
'postcss-preset-env': {
stage: 1,
features: {
'nesting-rules': true,
'custom-properties': false, // already native in modern browsers
'custom-media-queries': true,
'media-query-ranges': true,
},
browsers: ['last 2 versions', 'not dead', 'not < 0.2%'],
},
},
};
export default config;

View 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} />;
}

View 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>
);
}

View 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`);
}

View 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;
}

View 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;
}

View 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>,
},
};

View 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();
});
});

View 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;

View File

@@ -0,0 +1 @@
{}

5
apps/web/src/css.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/** TypeScript declarations for CSS Module files. */
declare module '*.module.css' {
const styles: Record<string, string>;
export default styles;
}

View 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,
});

View 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|.*\\..*).*)'],
};

View 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;
}

View 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);
}

View 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;

View File

@@ -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);
}

View File

@@ -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: [] },
};

View 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;

10
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "@dwellops/config/tsconfig/nextjs.json",
"compilerOptions": {
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "i18n", "next-env.d.ts", "next.config.ts", ".next/types/**/*.ts"]
}

File diff suppressed because one or more lines are too long

35
apps/web/vitest.config.ts Normal file
View File

@@ -0,0 +1,35 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/lib/test-setup.ts'],
exclude: ['**/node_modules/**', '**/dist/**', '**/e2e/**', '**/*.spec.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
thresholds: {
lines: 85,
functions: 85,
branches: 85,
statements: 85,
},
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/*.d.ts',
'**/*.config.*',
'**/*.stories.*',
],
},
},
resolve: {
alias: {
'@': resolve(import.meta.dirname, './src'),
},
},
});