Initial Commit
This commit is contained in:
130
apps/demo/README.md
Normal file
130
apps/demo/README.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# @suaveplan/demo
|
||||
|
||||
Unified Bun + Hono server with a React 19 SPA front-end and a JSON API
|
||||
in one process. Demonstrates locale-prefix routing across five base
|
||||
locales, `@suaveplan/api-plugin-auth`-driven Better-Auth authentication
|
||||
backed by SurrealDB, and React + Tailwind v4 styling.
|
||||
|
||||
## What's wired up
|
||||
|
||||
- **Server** — single Bun process running a Hono app on port `3300`
|
||||
(override with `PORT=…`). The Hono instance is the one owned by a
|
||||
`SuaveApp` from `@suaveplan/api`. `/api/health` is registered by
|
||||
`registerHealthRoutes`; `/api/auth/*` is mounted by
|
||||
`@suaveplan/api-plugin-auth` (9 OpenAPI routes + the Better-Auth
|
||||
catch-all); `/api/me` is a demo convenience that calls
|
||||
`authService.getSession(headers)`; everything else falls through to
|
||||
the Vite-built client bundle in `dist/client/`, with `index.html`
|
||||
fallback so the client router owns deep links.
|
||||
- **Auth** — the canonical genesis pipeline. The Hono app is assembled
|
||||
via `defineApp()` from `@suaveplan/api`, with `createAuthPlugin(...)`
|
||||
from `@suaveplan/api-plugin-auth` registered as a plugin. The auth
|
||||
plugin's 6-step `onInit` applies the bundled 17-table Better-Auth
|
||||
SurrealQL schema, constructs the real Better-Auth instance backed by
|
||||
`betterAuthSurrealAdapter` (from `@suaveplan/better-auth-surrealdb`,
|
||||
the Better-Auth v1.6 `DBAdapterInstance` factory), registers the
|
||||
typed `authService` on the host `ServiceRegistry`, mounts the 9
|
||||
OpenAPI routes plus the Better-Auth catch-all under `/api/auth/*`,
|
||||
and installs the global session-resolver middleware so every request
|
||||
gets a typed `Principal` on its Hono context. A small inline
|
||||
`demo-db-seed` plugin (in `src/server/auth.ts`) drops the live
|
||||
`SurrealAdapter` into `ServiceRegistry["db"]` during `onRegister` so
|
||||
the auth plugin can resolve it during `onInit`. Backing store is an
|
||||
**external SurrealDB** reached via `SURREAL_URL` (default
|
||||
`ws://localhost:8000`), namespace `SURREAL_NS` (default `demo`),
|
||||
database `SURREAL_DB` (default `demo`), credentials `SURREAL_USER` /
|
||||
`SURREAL_PASS` (default `root` / `root`).
|
||||
- **Client** — React 19 + Vite + `@tailwindcss/vite` for Tailwind v4.
|
||||
Routing via `react-router-dom@7` with a locale-prefix design. The
|
||||
app root composes `<ReactUiProvider>` (from `@suaveplan/react-ui`)
|
||||
around the entire tree, seeded with a singleton `I18nInstance`
|
||||
(`createI18n` from `@suaveplan/i18n`) and the chassis `lightTheme`
|
||||
plugin. `<ReactUiProvider>` internally wraps
|
||||
`<TranslationProvider>` → `<ThemeProvider>` → `<PackRegistryProvider>`
|
||||
and auto-registers the matching chassis locale bundle on mount /
|
||||
language change, so any react-ui primitive that calls
|
||||
`useTranslation(...)` finds a populated catalog. Form pages
|
||||
(`/auth/login`, `/auth/register`, `/auth/forgot-password`,
|
||||
`/user/settings`) compose `<Card>`, `<Field>`, `<FieldLabel>`,
|
||||
`<Input>`, `<Button>`, `<Alert>` from the chassis — no hand-rolled
|
||||
Tailwind on those primitives. Demo-specific copy continues to flow
|
||||
through a typed `LocaleProvider` so the page-level message catalogs
|
||||
stay co-located and statically checked; the i18n instance is driven
|
||||
to the matching BCP47 variant (`en` → `en-GB`, `zh` → `zh-CN`,
|
||||
`ar` → `ar-AE`, `cy` → `cy-GB`, `ru` → `ru-RU`) via
|
||||
`syncDemoLocale(base)` from a `useEffect`.
|
||||
|
||||
## Locale-prefix routing
|
||||
|
||||
Five base locales: `en`, `zh`, `ar`, `cy`, `ru`. Region variants
|
||||
(e.g. `en-GB`, `zh-CN`, `ar-EG`) parse case-insensitively and
|
||||
canonicalise to lowercase-base + UPPERCASE-region. Anything else
|
||||
renders the `NotFoundPage` with a language picker.
|
||||
|
||||
- `/` → `Navigate` to `/<detected>/home` (uses `navigator.languages`).
|
||||
- `/<locale>/home`
|
||||
- `/<locale>/about-us`
|
||||
- `/<locale>/contact-us`
|
||||
- `/<locale>/privacy-policy`
|
||||
- `/<locale>/terms-of-service`
|
||||
- `/<locale>/auth/login`
|
||||
- `/<locale>/auth/register`
|
||||
- `/<locale>/auth/forgot-password`
|
||||
- `/<locale>/auth/logout`
|
||||
- `/<locale>/user/control-panel` *(gated — `RequireAuth`)*
|
||||
- `/<locale>/user/settings` *(gated — `RequireAuth`)*
|
||||
- `/<locale>/admin/control-panel` *(gated — `RequireRole admin`)*
|
||||
- `/<locale>/admin/settings` *(gated — `RequireRole admin`)*
|
||||
|
||||
`ar` flips `<html dir="rtl">`. The `<LocaleSwitcher>` rewrites the URL
|
||||
in place when a user changes language.
|
||||
|
||||
## Running the demo
|
||||
|
||||
The demo requires a reachable SurrealDB instance. The simplest path:
|
||||
|
||||
```bash
|
||||
docker run -d --name demo-surreal -p 8000:8000 \
|
||||
surrealdb/surrealdb:latest start --user root --pass root --bind 0.0.0.0:8000
|
||||
```
|
||||
|
||||
Then, from the repo root:
|
||||
|
||||
```bash
|
||||
bun install # workspace deps
|
||||
bunx turbo build --filter=@suaveplan/demo # build api/auth deps + dist/client
|
||||
bun run --filter=@suaveplan/demo dev # boot the server on :3300
|
||||
```
|
||||
|
||||
Visit `http://localhost:3300` — it should redirect to `/en/home` (or
|
||||
your browser's preferred locale if it matches one of the five).
|
||||
|
||||
Smoke endpoints:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3300/api/health
|
||||
curl http://localhost:3300/api/me
|
||||
curl http://localhost:3300/api/auth/get-session
|
||||
```
|
||||
|
||||
End-to-end sign-up roundtrip — produces a 200 with a Better-Auth
|
||||
session token + user record:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3300/api/auth/sign-up/email \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"alice@example.com","password":"hunter2","name":"Alice"}'
|
||||
```
|
||||
|
||||
Env overrides (all optional):
|
||||
|
||||
| Variable | Default |
|
||||
| ---------------------- | ------------------------- |
|
||||
| `PORT` | `3300` |
|
||||
| `SURREAL_URL` | `ws://localhost:8000` |
|
||||
| `SURREAL_NS` | `demo` |
|
||||
| `SURREAL_DB` | `demo` |
|
||||
| `SURREAL_USER` | `root` |
|
||||
| `SURREAL_PASS` | `root` |
|
||||
| `BETTER_AUTH_URL` | `http://localhost:3300` |
|
||||
| `BETTER_AUTH_SECRET` | demo placeholder — set in prod |
|
||||
86
apps/demo/e2e/smoke.test.ts
Normal file
86
apps/demo/e2e/smoke.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Smoke test — boots the built app and asserts:
|
||||
*
|
||||
* 1. `/api/health` returns 200 JSON with `ok: true`.
|
||||
* 2. `/` redirects to `/<locale>/home`.
|
||||
* 3. Each public page (`/en/home`, `/en/about-us`,
|
||||
* `/en/contact-us`, `/en/privacy-policy`, `/en/terms-of-service`)
|
||||
* returns 200 with an HTML body containing the SPA root element.
|
||||
*
|
||||
* Run with `bun run e2e/smoke.test.ts`. Requires `bun run build`
|
||||
* to have produced `dist/client/index.html` first.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const PORT = 3399;
|
||||
const BASE = `http://localhost:${PORT}`;
|
||||
|
||||
async function waitForReady(): Promise<void> {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/api/health`);
|
||||
if (res.ok) return;
|
||||
} catch {
|
||||
// not up yet
|
||||
}
|
||||
await delay(500);
|
||||
}
|
||||
throw new Error("server failed to start within 15s");
|
||||
}
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(`SMOKE FAIL: ${message}`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const child = spawn("bun", ["run", "src/server/index.ts"], {
|
||||
env: { ...process.env, PORT: String(PORT), NODE_ENV: "test" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForReady();
|
||||
|
||||
const health = await (await fetch(`${BASE}/api/health`)).json();
|
||||
// `@suaveplan/api`'s `registerHealthRoutes` emits
|
||||
// `{ status, version, state, timestamp }` — accept either the new
|
||||
// shape (`status: "healthy"`) or the legacy `{ ok: true }` shape so
|
||||
// the smoke remains forward-compatible if the route is rewritten.
|
||||
const healthy = health.status === "healthy" || health.ok === true;
|
||||
assert(healthy, `health probe not healthy: ${JSON.stringify(health)}`);
|
||||
|
||||
const root = await fetch(`${BASE}/`, { redirect: "manual" });
|
||||
// Server sends the SPA shell, client-side router handles the redirect.
|
||||
// We accept either a 200 (shell) or a 3xx redirect.
|
||||
assert(root.status === 200 || (root.status >= 300 && root.status < 400),
|
||||
`/ expected 200 or 3xx, got ${root.status}`);
|
||||
|
||||
const paths = [
|
||||
"/en/home",
|
||||
"/en/about-us",
|
||||
"/en/contact-us",
|
||||
"/en/privacy-policy",
|
||||
"/en/terms-of-service",
|
||||
];
|
||||
for (const path of paths) {
|
||||
const res = await fetch(`${BASE}${path}`);
|
||||
assert(res.ok, `${path} returned ${res.status}`);
|
||||
const body = await res.text();
|
||||
assert(body.includes('id="root"'), `${path} body missing #root`);
|
||||
}
|
||||
|
||||
// Auth endpoint reachable.
|
||||
const session = await fetch(`${BASE}/api/auth/get-session`);
|
||||
assert(session.status === 200, `/api/auth/get-session status ${session.status}`);
|
||||
|
||||
console.log("SMOKE OK");
|
||||
} finally {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
12
apps/demo/index.html
Normal file
12
apps/demo/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Suaveplan Demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/client/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
37
apps/demo/package.json
Normal file
37
apps/demo/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@suaveplan/demo",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run src/server/index.ts",
|
||||
"build": "vite build",
|
||||
"start": "bun run src/server/index.ts",
|
||||
"smoke": "bun run e2e/smoke.test.ts",
|
||||
"typecheck": "bunx tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/api": "workspace:^",
|
||||
"@suaveplan/api-plugin": "workspace:^",
|
||||
"@suaveplan/api-plugin-auth": "workspace:^",
|
||||
"@suaveplan/better-auth-surrealdb": "workspace:^",
|
||||
"@suaveplan/i18n": "workspace:^",
|
||||
"@suaveplan/react-ui": "workspace:^",
|
||||
"@suaveplan/surrealdb": "workspace:^",
|
||||
"better-auth": "catalog:auth",
|
||||
"hono": "catalog:",
|
||||
"react": "catalog:react",
|
||||
"react-dom": "catalog:react",
|
||||
"react-router-dom": "catalog:react"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "catalog:build",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:react",
|
||||
"@types/react-dom": "catalog:react",
|
||||
"@vitejs/plugin-react": "catalog:build",
|
||||
"tailwindcss": "catalog:build",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build"
|
||||
}
|
||||
}
|
||||
139
apps/demo/src/client/app.tsx
Normal file
139
apps/demo/src/client/app.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Demo app shell.
|
||||
*
|
||||
* Top-level structure:
|
||||
*
|
||||
* - `BrowserRouter`
|
||||
* - `SessionProvider` — polls `/api/auth/get-session`
|
||||
* - `ReactUiProvider` — composes
|
||||
* `<TranslationProvider>` → `<ThemeProvider>` →
|
||||
* `<PackRegistryProvider>` for the entire app tree, seeded with
|
||||
* the demo's singleton `I18nInstance` and the chassis
|
||||
* `lightTheme` plugin from `@suaveplan/react-ui`.
|
||||
* - `Routes`
|
||||
* - `/` → redirect to `/<detected>/home`
|
||||
* - `/:locale/*` → LocaleProvider + Layout
|
||||
* - everything else → NotFoundPage in default-locale context
|
||||
*/
|
||||
import { lightTheme, ReactUiProvider } from "@suaveplan/react-ui";
|
||||
import React from "react";
|
||||
import {
|
||||
BrowserRouter,
|
||||
Navigate,
|
||||
Outlet,
|
||||
Route,
|
||||
Routes,
|
||||
} from "react-router-dom";
|
||||
import { Layout } from "./components/layout.js";
|
||||
import { getDemoI18n } from "./i18n/i18n-instance.js";
|
||||
import { LocaleProvider } from "./i18n/locale-context.js";
|
||||
import { DEFAULT_LOCALE, parseLocaleSegment } from "./i18n/locales.js";
|
||||
import { RequireAuth, RequireRole } from "./lib/guards.js";
|
||||
import { SessionProvider } from "./lib/session-context.js";
|
||||
import {
|
||||
AdminControlPanelPage,
|
||||
AdminSettingsPage,
|
||||
UserControlPanelPage,
|
||||
UserSettingsPage,
|
||||
} from "./pages/account-pages.js";
|
||||
import {
|
||||
ForgotPasswordPage,
|
||||
LoginPage,
|
||||
LogoutPage,
|
||||
RegisterPage,
|
||||
} from "./pages/auth-pages.js";
|
||||
import { NotFoundPage } from "./pages/not-found-page.js";
|
||||
import {
|
||||
AboutPage,
|
||||
ContactPage,
|
||||
HomePage,
|
||||
PrivacyPage,
|
||||
TermsPage,
|
||||
} from "./pages/public-pages.js";
|
||||
|
||||
function detectLocale(): string {
|
||||
if (typeof navigator === "undefined") return DEFAULT_LOCALE;
|
||||
for (const candidate of navigator.languages ?? [navigator.language]) {
|
||||
const parsed = parseLocaleSegment(candidate);
|
||||
if (parsed !== null) return parsed.base;
|
||||
}
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
function RootRedirect(): React.JSX.Element {
|
||||
return <Navigate to={`/${detectLocale()}/home`} replace />;
|
||||
}
|
||||
|
||||
function LocalisedShell(): React.JSX.Element {
|
||||
return (
|
||||
<LocaleProvider>
|
||||
<Outlet />
|
||||
</LocaleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function App(): React.JSX.Element {
|
||||
const i18n = getDemoI18n();
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<SessionProvider>
|
||||
<ReactUiProvider
|
||||
i18n={i18n}
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/:locale" element={<LocalisedShell />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<Navigate to="home" replace />} />
|
||||
<Route path="home" element={<HomePage />} />
|
||||
<Route path="about-us" element={<AboutPage />} />
|
||||
<Route path="contact-us" element={<ContactPage />} />
|
||||
<Route path="privacy-policy" element={<PrivacyPage />} />
|
||||
<Route path="terms-of-service" element={<TermsPage />} />
|
||||
<Route path="auth/login" element={<LoginPage />} />
|
||||
<Route path="auth/register" element={<RegisterPage />} />
|
||||
<Route path="auth/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="auth/logout" element={<LogoutPage />} />
|
||||
<Route
|
||||
path="user/control-panel"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<UserControlPanelPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="user/settings"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<UserSettingsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/control-panel"
|
||||
element={
|
||||
<RequireRole role="admin">
|
||||
<AdminControlPanelPage />
|
||||
</RequireRole>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/settings"
|
||||
element={
|
||||
<RequireRole role="admin">
|
||||
<AdminSettingsPage />
|
||||
</RequireRole>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</ReactUiProvider>
|
||||
</SessionProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
89
apps/demo/src/client/components/layout.tsx
Normal file
89
apps/demo/src/client/components/layout.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Shared layout — top nav with locale picker + auth menu, content
|
||||
* area, footer. Renders an `<Outlet>` for nested routes.
|
||||
*/
|
||||
import React from "react";
|
||||
import { Link, NavLink, Outlet } from "react-router-dom";
|
||||
import { LOCALE_ENTRIES, useLocale } from "../i18n/locale-context.js";
|
||||
import type { BaseLocale } from "../i18n/locales.js";
|
||||
import { useSession } from "../lib/session-context.js";
|
||||
|
||||
function LocaleSwitcher(): React.JSX.Element {
|
||||
const { base, setLocale, availableLocales } = useLocale();
|
||||
return (
|
||||
<select
|
||||
aria-label="Language"
|
||||
value={base}
|
||||
onChange={(e) => setLocale(e.target.value as BaseLocale)}
|
||||
className="rounded-md border border-[color:var(--color-border)] bg-[color:var(--color-surface)] px-2 py-1 text-sm"
|
||||
data-testid="locale-switcher"
|
||||
>
|
||||
{availableLocales.map((loc) => (
|
||||
<option key={loc} value={loc}>
|
||||
{LOCALE_ENTRIES[loc].nativeName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({ to, label }: { readonly to: string; readonly label: string }): React.JSX.Element {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`rounded-md px-3 py-2 text-sm font-medium transition ${
|
||||
isActive
|
||||
? "bg-[color:var(--color-accent)] text-[color:var(--color-accent-fg)]"
|
||||
: "hover:bg-[color:var(--color-surface)]"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
export function Layout(): React.JSX.Element {
|
||||
const { base, messages } = useLocale();
|
||||
const { session } = useSession();
|
||||
const p = (sub: string): string => `/${base}${sub}`;
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="border-b border-[color:var(--color-border)] bg-[color:var(--color-surface)]">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-4 px-4 py-3">
|
||||
<Link to={p("/home")} className="text-lg font-semibold no-underline">
|
||||
{messages.siteName}
|
||||
</Link>
|
||||
<nav className="flex flex-wrap items-center gap-1">
|
||||
<NavItem to={p("/home")} label={messages.nav.home} />
|
||||
<NavItem to={p("/about-us")} label={messages.nav.about} />
|
||||
<NavItem to={p("/contact-us")} label={messages.nav.contact} />
|
||||
<NavItem to={p("/privacy-policy")} label={messages.nav.privacy} />
|
||||
<NavItem to={p("/terms-of-service")} label={messages.nav.terms} />
|
||||
</nav>
|
||||
<div className="ms-auto flex items-center gap-2">
|
||||
<LocaleSwitcher />
|
||||
{session === null ? (
|
||||
<>
|
||||
<NavItem to={p("/auth/login")} label={messages.nav.login} />
|
||||
<NavItem to={p("/auth/register")} label={messages.nav.register} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<NavItem to={p("/user/control-panel")} label={messages.nav.userPanel} />
|
||||
<NavItem to={p("/auth/logout")} label={messages.nav.logout} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
<footer className="border-t border-[color:var(--color-border)] py-4 text-center text-xs text-[color:var(--color-muted)]">
|
||||
{messages.siteName} • {base}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
apps/demo/src/client/components/page-shell.tsx
Normal file
29
apps/demo/src/client/components/page-shell.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Re-usable page shell — heading + lead paragraph block consumed by
|
||||
* every public-information page so titles, headings, and the lead
|
||||
* line are kept consistent.
|
||||
*/
|
||||
import React from "react";
|
||||
import { useDocumentTitle } from "../i18n/locale-context.js";
|
||||
|
||||
export interface PageShellProps {
|
||||
readonly title: string;
|
||||
readonly heading: string;
|
||||
readonly lead: string;
|
||||
readonly children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PageShell({ title, heading, lead, children }: PageShellProps): React.JSX.Element {
|
||||
useDocumentTitle(title);
|
||||
return (
|
||||
<article className="mx-auto max-w-3xl">
|
||||
<h1 className="text-3xl font-semibold" data-testid="page-heading">
|
||||
{heading}
|
||||
</h1>
|
||||
<p className="mt-3 text-base leading-relaxed text-[color:var(--color-muted)]" data-testid="page-lead">
|
||||
{lead}
|
||||
</p>
|
||||
{children !== undefined ? <div className="mt-6">{children}</div> : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
67
apps/demo/src/client/i18n/i18n-instance.ts
Normal file
67
apps/demo/src/client/i18n/i18n-instance.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Build the demo's `I18nInstance`.
|
||||
*
|
||||
* The instance is consumed by `<ReactUiProvider>` and any
|
||||
* `@suaveplan/react-ui` primitive that calls `useTranslation()` (e.g.
|
||||
* `<Button>` for icon-only-button fallback, `<Input>` for placeholder
|
||||
* resolution).
|
||||
*
|
||||
* Locale strategy: the URL carries the language subtag only (`/en/home`,
|
||||
* `/zh/home`, etc.). The chassis bundles in `@suaveplan/react-ui` are
|
||||
* keyed by full BCP47 — `en-GB`, `zh-CN`, `ar-AE`, `cy-GB`, `ru-RU`. We
|
||||
* therefore canonicalise each demo base locale to its closest BCP47
|
||||
* variant and use THAT as the i18n locale. The demo's typed `Messages`
|
||||
* catalog continues to be consumed via `useLocale()` keyed by base, so
|
||||
* the demo never depends on react-i18n's `t(key)` for its own copy.
|
||||
*
|
||||
* The chassis bundles themselves are auto-registered by
|
||||
* `<ReactUiProvider>` (its internal `ReactUiBundleLoader` effect imports
|
||||
* the matching JSON and merges it into the catalog on mount / language
|
||||
* change), so this module deliberately does NOT preload them.
|
||||
*/
|
||||
import { createI18n, type I18nInstance } from "@suaveplan/i18n/plugin";
|
||||
import type { BaseLocale } from "./locales.js";
|
||||
|
||||
export type DemoBcp47 = "en-GB" | "zh-CN" | "ar-AE" | "cy-GB" | "ru-RU";
|
||||
|
||||
/** Map URL base locale -> canonical BCP47 variant fed to the i18n core. */
|
||||
export const BASE_TO_BCP47: Readonly<Record<BaseLocale, DemoBcp47>> = {
|
||||
en: "en-GB",
|
||||
zh: "zh-CN",
|
||||
ar: "ar-AE",
|
||||
cy: "cy-GB",
|
||||
ru: "ru-RU",
|
||||
};
|
||||
|
||||
/**
|
||||
* Singleton i18n instance for the demo. Created lazily so the i18n core
|
||||
* (which is module-side-effect free) does not run during static module
|
||||
* graph evaluation in tests or SSR snapshots.
|
||||
*/
|
||||
let cached: I18nInstance | undefined;
|
||||
|
||||
export function getDemoI18n(): I18nInstance {
|
||||
if (cached !== undefined) return cached;
|
||||
// Use the default `string` generic so the cached singleton matches
|
||||
// `<ReactUiProvider>`'s `i18n: I18nInstance` (without the
|
||||
// narrowed locale generic).
|
||||
const instance: I18nInstance = createI18n({
|
||||
defaultLocale: BASE_TO_BCP47.en as string,
|
||||
fallbackLocale: BASE_TO_BCP47.en as string,
|
||||
});
|
||||
cached = instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the i18n instance to the BCP47 variant matching the URL base.
|
||||
* Safe to call from a `useEffect` — `changeLanguage(...)` short-circuits
|
||||
* when the locale already matches.
|
||||
*/
|
||||
export async function syncDemoLocale(base: BaseLocale): Promise<void> {
|
||||
const i18n = getDemoI18n();
|
||||
const target = BASE_TO_BCP47[base];
|
||||
if (i18n.locale !== target) {
|
||||
await i18n.changeLanguage(target);
|
||||
}
|
||||
}
|
||||
113
apps/demo/src/client/i18n/locale-context.tsx
Normal file
113
apps/demo/src/client/i18n/locale-context.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Typed locale context for the demo app.
|
||||
*
|
||||
* Holds the resolved `BaseLocale`, the canonical BCP47 tag (e.g. `en`,
|
||||
* `en-GB`, `zh-CN`), the active `Messages` catalog, and a `setLocale`
|
||||
* setter that rewrites the URL prefix and updates `<html lang>` +
|
||||
* `<html dir>` so RTL flips correctly for Arabic.
|
||||
*
|
||||
* Kept deliberately small — full @suaveplan/i18n integration (with
|
||||
* backends, formatters, plugin registry) is overkill for a five-page
|
||||
* demo. The provider shape mirrors react-i18n's `useLocale` /
|
||||
* `useI18n` so a future swap is mechanical.
|
||||
*/
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { syncDemoLocale } from "./i18n-instance.js";
|
||||
import {
|
||||
BASE_LOCALES,
|
||||
type BaseLocale,
|
||||
DEFAULT_LOCALE,
|
||||
LOCALE_ENTRIES,
|
||||
parseLocaleSegment,
|
||||
RTL_BASE_LOCALES,
|
||||
} from "./locales.js";
|
||||
import { CATALOGS, type Messages } from "./messages.js";
|
||||
|
||||
export interface LocaleContextValue {
|
||||
readonly base: BaseLocale;
|
||||
readonly canonical: string;
|
||||
readonly direction: "ltr" | "rtl";
|
||||
readonly messages: Messages;
|
||||
readonly setLocale: (next: BaseLocale | string) => void;
|
||||
readonly availableLocales: readonly BaseLocale[];
|
||||
}
|
||||
|
||||
const LocaleContext = createContext<LocaleContextValue | null>(null);
|
||||
|
||||
export function useLocale(): LocaleContextValue {
|
||||
const ctx = useContext(LocaleContext);
|
||||
if (ctx === null) {
|
||||
throw new Error(
|
||||
"useLocale must be used inside <LocaleProvider> — wrap your routes."
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useMessages(): Messages {
|
||||
return useLocale().messages;
|
||||
}
|
||||
|
||||
export interface LocaleProviderProps {
|
||||
readonly children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LocaleProvider({ children }: LocaleProviderProps): React.JSX.Element {
|
||||
const params = useParams<{ locale?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const parsed = parseLocaleSegment(params.locale);
|
||||
const base: BaseLocale = parsed?.base ?? DEFAULT_LOCALE;
|
||||
const canonical = parsed?.canonical ?? base;
|
||||
const direction = RTL_BASE_LOCALES.has(base) ? "rtl" : "ltr";
|
||||
const messages = CATALOGS[base];
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = canonical;
|
||||
document.documentElement.dir = direction;
|
||||
document.title = messages.home.title; // updated per page via useDocumentTitle
|
||||
// Drive the singleton I18nInstance to the BCP47 variant matching the
|
||||
// current URL base so any @suaveplan/react-ui chassis lookup sees a
|
||||
// catalog populated for the active locale.
|
||||
void syncDemoLocale(base);
|
||||
}, [base, canonical, direction, messages]);
|
||||
|
||||
const setLocale = useCallback(
|
||||
(next: BaseLocale | string) => {
|
||||
// Strip the existing locale segment then prepend the new one.
|
||||
const path = location.pathname.replace(/^\/[^/]+/, "");
|
||||
navigate(`/${next}${path === "" ? "/home" : path}${location.search}${location.hash}`);
|
||||
},
|
||||
[location.hash, location.pathname, location.search, navigate]
|
||||
);
|
||||
|
||||
const value = useMemo<LocaleContextValue>(
|
||||
() => ({
|
||||
base,
|
||||
canonical,
|
||||
direction,
|
||||
messages,
|
||||
setLocale,
|
||||
availableLocales: BASE_LOCALES,
|
||||
}),
|
||||
[base, canonical, direction, messages, setLocale]
|
||||
);
|
||||
|
||||
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
|
||||
}
|
||||
|
||||
export function useDocumentTitle(title: string): void {
|
||||
useEffect(() => {
|
||||
document.title = title;
|
||||
}, [title]);
|
||||
}
|
||||
|
||||
export { LOCALE_ENTRIES };
|
||||
49
apps/demo/src/client/i18n/locales.ts
Normal file
49
apps/demo/src/client/i18n/locales.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Locale registry for the demo app.
|
||||
*
|
||||
* Five canonical base locales — `en`, `zh`, `ar`, `cy`, `ru` — plus a
|
||||
* permissive `<base>-<region>` resolver so URLs like `/en-GB/home` or
|
||||
* `/zh-CN/home` collapse onto the base catalog. RTL is tracked on the
|
||||
* base code.
|
||||
*/
|
||||
export const BASE_LOCALES = ["en", "zh", "ar", "cy", "ru"] as const;
|
||||
export type BaseLocale = (typeof BASE_LOCALES)[number];
|
||||
|
||||
export const DEFAULT_LOCALE: BaseLocale = "en";
|
||||
|
||||
export const RTL_BASE_LOCALES: ReadonlySet<BaseLocale> = new Set(["ar"]);
|
||||
|
||||
export interface LocaleEntry {
|
||||
readonly base: BaseLocale;
|
||||
readonly name: string;
|
||||
readonly nativeName: string;
|
||||
readonly direction: "ltr" | "rtl";
|
||||
}
|
||||
|
||||
export const LOCALE_ENTRIES: Readonly<Record<BaseLocale, LocaleEntry>> = {
|
||||
en: { base: "en", name: "English", nativeName: "English", direction: "ltr" },
|
||||
zh: { base: "zh", name: "Chinese", nativeName: "中文", direction: "ltr" },
|
||||
ar: { base: "ar", name: "Arabic", nativeName: "العربية", direction: "rtl" },
|
||||
cy: { base: "cy", name: "Welsh", nativeName: "Cymraeg", direction: "ltr" },
|
||||
ru: { base: "ru", name: "Russian", nativeName: "Русский", direction: "ltr" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalise a raw URL segment into a canonical `<base>` or
|
||||
* `<base>-<REGION>` BCP47 tag, or `null` if the base does not match
|
||||
* one of {@link BASE_LOCALES}.
|
||||
*/
|
||||
export function parseLocaleSegment(segment: string | undefined): {
|
||||
readonly base: BaseLocale;
|
||||
readonly canonical: string;
|
||||
} | null {
|
||||
if (!segment) return null;
|
||||
const [rawBase, rawRegion] = segment.toLowerCase().split("-");
|
||||
const base = (BASE_LOCALES as readonly string[]).includes(rawBase ?? "")
|
||||
? (rawBase as BaseLocale)
|
||||
: null;
|
||||
if (!base) return null;
|
||||
if (!rawRegion) return { base, canonical: base };
|
||||
const region = rawRegion.toUpperCase();
|
||||
return { base, canonical: `${base}-${region}` };
|
||||
}
|
||||
337
apps/demo/src/client/i18n/messages.ts
Normal file
337
apps/demo/src/client/i18n/messages.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Static message catalogs for the demo app.
|
||||
*
|
||||
* `en` is fully translated. The other four locales (`zh`, `ar`, `cy`,
|
||||
* `ru`) ship with canonical translations for page titles, headings,
|
||||
* and the lead paragraph, and fall back to the English copy for
|
||||
* secondary microcopy. Every key has a non-empty value in every
|
||||
* locale so render-time fallback never silently picks up `en`.
|
||||
*/
|
||||
import type { BaseLocale } from "./locales.js";
|
||||
|
||||
export interface Messages {
|
||||
// Site chrome
|
||||
readonly siteName: string;
|
||||
readonly nav: {
|
||||
readonly home: string;
|
||||
readonly about: string;
|
||||
readonly contact: string;
|
||||
readonly privacy: string;
|
||||
readonly terms: string;
|
||||
readonly login: string;
|
||||
readonly register: string;
|
||||
readonly logout: string;
|
||||
readonly userPanel: string;
|
||||
readonly userSettings: string;
|
||||
readonly adminPanel: string;
|
||||
readonly adminSettings: string;
|
||||
};
|
||||
// Pages
|
||||
readonly home: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
readonly about: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
readonly contact: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
readonly privacy: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
readonly terms: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
readonly login: {
|
||||
readonly title: string;
|
||||
readonly heading: string;
|
||||
readonly lead: string;
|
||||
readonly emailLabel: string;
|
||||
readonly passwordLabel: string;
|
||||
readonly submit: string;
|
||||
readonly linkToRegister: string;
|
||||
readonly linkToForgot: string;
|
||||
};
|
||||
readonly register: {
|
||||
readonly title: string;
|
||||
readonly heading: string;
|
||||
readonly lead: string;
|
||||
readonly emailLabel: string;
|
||||
readonly passwordLabel: string;
|
||||
readonly confirmLabel: string;
|
||||
readonly submit: string;
|
||||
readonly linkToLogin: string;
|
||||
};
|
||||
readonly forgot: {
|
||||
readonly title: string;
|
||||
readonly heading: string;
|
||||
readonly lead: string;
|
||||
readonly emailLabel: string;
|
||||
readonly submit: string;
|
||||
readonly sentNotice: string;
|
||||
readonly linkToLogin: string;
|
||||
};
|
||||
readonly logout: {
|
||||
readonly title: string;
|
||||
readonly heading: string;
|
||||
readonly lead: string;
|
||||
};
|
||||
readonly userPanel: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
readonly userSettings: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
readonly adminPanel: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
readonly adminSettings: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
readonly notFound: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
readonly unauthorized: { readonly title: string; readonly heading: string; readonly lead: string };
|
||||
}
|
||||
|
||||
const en: Messages = {
|
||||
siteName: "Suaveplan Demo",
|
||||
nav: {
|
||||
home: "Home",
|
||||
about: "About us",
|
||||
contact: "Contact us",
|
||||
privacy: "Privacy policy",
|
||||
terms: "Terms of service",
|
||||
login: "Log in",
|
||||
register: "Register",
|
||||
logout: "Log out",
|
||||
userPanel: "User panel",
|
||||
userSettings: "User settings",
|
||||
adminPanel: "Admin panel",
|
||||
adminSettings: "Admin settings",
|
||||
},
|
||||
home: {
|
||||
title: "Suaveplan Demo — Home",
|
||||
heading: "Welcome to the Suaveplan Demo",
|
||||
lead: "A unified Bun + Hono server with React-rendered pages and a JSON API in one process, wired up with @suaveplan/react-ui and @suaveplan/react-i18n.",
|
||||
},
|
||||
about: {
|
||||
title: "About us",
|
||||
heading: "About Suaveplan",
|
||||
lead: "Suaveplan is a bottom-up rebuild of an enterprise platform, packaged as a Turborepo monorepo of focused libraries.",
|
||||
},
|
||||
contact: {
|
||||
title: "Contact us",
|
||||
heading: "Get in touch",
|
||||
lead: "Drop us a line through any of the channels listed below. We read every message.",
|
||||
},
|
||||
privacy: {
|
||||
title: "Privacy policy",
|
||||
heading: "Privacy policy",
|
||||
lead: "This demo collects no personal data beyond what the better-auth memory adapter holds for the lifetime of the process.",
|
||||
},
|
||||
terms: {
|
||||
title: "Terms of service",
|
||||
heading: "Terms of service",
|
||||
lead: "This is a demo application provided as-is for illustrative purposes; no warranty is offered or implied.",
|
||||
},
|
||||
login: {
|
||||
title: "Log in",
|
||||
heading: "Log in to your account",
|
||||
lead: "Sign in with your email address and password.",
|
||||
emailLabel: "Email",
|
||||
passwordLabel: "Password",
|
||||
submit: "Log in",
|
||||
linkToRegister: "Need an account? Register",
|
||||
linkToForgot: "Forgot your password?",
|
||||
},
|
||||
register: {
|
||||
title: "Create an account",
|
||||
heading: "Create your account",
|
||||
lead: "Sign up with your email address and a password of at least 6 characters.",
|
||||
emailLabel: "Email",
|
||||
passwordLabel: "Password",
|
||||
confirmLabel: "Confirm password",
|
||||
submit: "Create account",
|
||||
linkToLogin: "Already have an account? Log in",
|
||||
},
|
||||
forgot: {
|
||||
title: "Forgot password",
|
||||
heading: "Reset your password",
|
||||
lead: "Enter your email and we'll send you a password-reset link.",
|
||||
emailLabel: "Email",
|
||||
submit: "Send reset link",
|
||||
sentNotice: "If an account exists for that email, a reset link is on its way.",
|
||||
linkToLogin: "Back to log in",
|
||||
},
|
||||
logout: {
|
||||
title: "Logging out",
|
||||
heading: "Logging you out…",
|
||||
lead: "Clearing your session — you'll be redirected home in a moment.",
|
||||
},
|
||||
userPanel: {
|
||||
title: "User control panel",
|
||||
heading: "Control panel",
|
||||
lead: "An overview of your account, sessions, and recent activity.",
|
||||
},
|
||||
userSettings: {
|
||||
title: "User settings",
|
||||
heading: "Settings",
|
||||
lead: "Manage your profile, locale preference, and security options.",
|
||||
},
|
||||
adminPanel: {
|
||||
title: "Admin control panel",
|
||||
heading: "Admin control panel",
|
||||
lead: "Administrative dashboard — restricted to users with the admin role.",
|
||||
},
|
||||
adminSettings: {
|
||||
title: "Admin settings",
|
||||
heading: "Admin settings",
|
||||
lead: "Configure global application settings — restricted to admins.",
|
||||
},
|
||||
notFound: {
|
||||
title: "Not found",
|
||||
heading: "404 — page not found",
|
||||
lead: "The page you asked for does not exist. Try one of the languages below or return home.",
|
||||
},
|
||||
unauthorized: {
|
||||
title: "Sign in required",
|
||||
heading: "Sign in required",
|
||||
lead: "You need to be signed in to view this page.",
|
||||
},
|
||||
};
|
||||
|
||||
// Lightweight translations: page-title, heading, lead per locale.
|
||||
// Secondary microcopy falls through to English so the demo always
|
||||
// renders intelligibly while making the per-locale shape obvious.
|
||||
const zh: Messages = {
|
||||
...en,
|
||||
nav: {
|
||||
...en.nav,
|
||||
home: "首页",
|
||||
about: "关于我们",
|
||||
contact: "联系我们",
|
||||
privacy: "隐私政策",
|
||||
terms: "服务条款",
|
||||
login: "登录",
|
||||
register: "注册",
|
||||
logout: "退出",
|
||||
userPanel: "用户面板",
|
||||
userSettings: "用户设置",
|
||||
adminPanel: "管理面板",
|
||||
adminSettings: "管理设置",
|
||||
},
|
||||
siteName: "Suaveplan 演示",
|
||||
home: {
|
||||
title: "Suaveplan 演示 — 首页",
|
||||
heading: "欢迎来到 Suaveplan 演示",
|
||||
lead: "在单一进程中提供统一的 Bun + Hono 服务器、React 渲染页面与 JSON API,使用 @suaveplan/react-ui 和 @suaveplan/react-i18n。",
|
||||
},
|
||||
about: { title: "关于我们", heading: "关于 Suaveplan", lead: en.about.lead },
|
||||
contact: { title: "联系我们", heading: "联系我们", lead: en.contact.lead },
|
||||
privacy: { title: "隐私政策", heading: "隐私政策", lead: en.privacy.lead },
|
||||
terms: { title: "服务条款", heading: "服务条款", lead: en.terms.lead },
|
||||
login: { ...en.login, title: "登录", heading: "登录您的帐户", lead: "使用电子邮件地址和密码登录。" },
|
||||
register: { ...en.register, title: "创建帐户", heading: "创建您的帐户", lead: "使用电子邮件和至少 6 个字符的密码注册。" },
|
||||
forgot: { ...en.forgot, title: "忘记密码", heading: "重置密码", lead: "输入您的电子邮件,我们将发送重置链接。" },
|
||||
logout: { title: "退出登录", heading: "正在退出登录...", lead: en.logout.lead },
|
||||
userPanel: { title: "用户控制面板", heading: "控制面板", lead: en.userPanel.lead },
|
||||
userSettings: { title: "用户设置", heading: "设置", lead: en.userSettings.lead },
|
||||
adminPanel: { title: "管理控制面板", heading: "管理控制面板", lead: en.adminPanel.lead },
|
||||
adminSettings: { title: "管理设置", heading: "管理设置", lead: en.adminSettings.lead },
|
||||
notFound: { title: "未找到", heading: "404 — 找不到页面", lead: en.notFound.lead },
|
||||
unauthorized: { title: "需要登录", heading: "需要登录", lead: en.unauthorized.lead },
|
||||
};
|
||||
|
||||
const ar: Messages = {
|
||||
...en,
|
||||
nav: {
|
||||
...en.nav,
|
||||
home: "الرئيسية",
|
||||
about: "من نحن",
|
||||
contact: "اتصل بنا",
|
||||
privacy: "سياسة الخصوصية",
|
||||
terms: "شروط الخدمة",
|
||||
login: "تسجيل الدخول",
|
||||
register: "تسجيل",
|
||||
logout: "تسجيل الخروج",
|
||||
userPanel: "لوحة المستخدم",
|
||||
userSettings: "إعدادات المستخدم",
|
||||
adminPanel: "لوحة الإدارة",
|
||||
adminSettings: "إعدادات الإدارة",
|
||||
},
|
||||
siteName: "عرض Suaveplan التوضيحي",
|
||||
home: { title: "Suaveplan — الرئيسية", heading: "مرحبًا بك في عرض Suaveplan التوضيحي", lead: en.home.lead },
|
||||
about: { title: "من نحن", heading: "حول Suaveplan", lead: en.about.lead },
|
||||
contact: { title: "اتصل بنا", heading: "تواصل معنا", lead: en.contact.lead },
|
||||
privacy: { title: "سياسة الخصوصية", heading: "سياسة الخصوصية", lead: en.privacy.lead },
|
||||
terms: { title: "شروط الخدمة", heading: "شروط الخدمة", lead: en.terms.lead },
|
||||
login: { ...en.login, title: "تسجيل الدخول", heading: "تسجيل الدخول إلى حسابك", lead: "سجّل الدخول باستخدام بريدك الإلكتروني وكلمة المرور." },
|
||||
register: { ...en.register, title: "إنشاء حساب", heading: "إنشاء حسابك", lead: "سجّل ببريدك الإلكتروني وكلمة مرور لا تقل عن 6 أحرف." },
|
||||
forgot: { ...en.forgot, title: "نسيت كلمة المرور", heading: "إعادة تعيين كلمة المرور", lead: en.forgot.lead },
|
||||
logout: { title: "جاري تسجيل الخروج", heading: "جاري تسجيل خروجك…", lead: en.logout.lead },
|
||||
userPanel: { title: "لوحة تحكم المستخدم", heading: "لوحة التحكم", lead: en.userPanel.lead },
|
||||
userSettings: { title: "إعدادات المستخدم", heading: "الإعدادات", lead: en.userSettings.lead },
|
||||
adminPanel: { title: "لوحة تحكم الإدارة", heading: "لوحة تحكم الإدارة", lead: en.adminPanel.lead },
|
||||
adminSettings: { title: "إعدادات الإدارة", heading: "إعدادات الإدارة", lead: en.adminSettings.lead },
|
||||
notFound: { title: "غير موجود", heading: "404 — الصفحة غير موجودة", lead: en.notFound.lead },
|
||||
unauthorized: { title: "تسجيل الدخول مطلوب", heading: "تسجيل الدخول مطلوب", lead: en.unauthorized.lead },
|
||||
};
|
||||
|
||||
const cy: Messages = {
|
||||
...en,
|
||||
nav: {
|
||||
...en.nav,
|
||||
home: "Hafan",
|
||||
about: "Amdanom ni",
|
||||
contact: "Cysylltwch â ni",
|
||||
privacy: "Polisi preifatrwydd",
|
||||
terms: "Telerau gwasanaeth",
|
||||
login: "Mewngofnodi",
|
||||
register: "Cofrestru",
|
||||
logout: "Allgofnodi",
|
||||
userPanel: "Panel defnyddiwr",
|
||||
userSettings: "Gosodiadau defnyddiwr",
|
||||
adminPanel: "Panel gweinyddol",
|
||||
adminSettings: "Gosodiadau gweinyddol",
|
||||
},
|
||||
siteName: "Demo Suaveplan",
|
||||
home: { title: "Suaveplan — Hafan", heading: "Croeso i Demo Suaveplan", lead: en.home.lead },
|
||||
about: { title: "Amdanom ni", heading: "Am Suaveplan", lead: en.about.lead },
|
||||
contact: { title: "Cysylltwch â ni", heading: "Cysylltu â ni", lead: en.contact.lead },
|
||||
privacy: { title: "Polisi preifatrwydd", heading: "Polisi preifatrwydd", lead: en.privacy.lead },
|
||||
terms: { title: "Telerau gwasanaeth", heading: "Telerau gwasanaeth", lead: en.terms.lead },
|
||||
login: { ...en.login, title: "Mewngofnodi", heading: "Mewngofnodi i'ch cyfrif", lead: "Mewngofnodi gyda'ch e-bost a'ch cyfrinair." },
|
||||
register: { ...en.register, title: "Creu cyfrif", heading: "Creu eich cyfrif", lead: "Cofrestrwch gyda'ch e-bost a chyfrinair o leiaf 6 nod." },
|
||||
forgot: { ...en.forgot, title: "Anghofio cyfrinair", heading: "Ailosod eich cyfrinair", lead: en.forgot.lead },
|
||||
logout: { title: "Allgofnodi", heading: "Allgofnodi…", lead: en.logout.lead },
|
||||
userPanel: { title: "Panel rheoli defnyddiwr", heading: "Panel rheoli", lead: en.userPanel.lead },
|
||||
userSettings: { title: "Gosodiadau defnyddiwr", heading: "Gosodiadau", lead: en.userSettings.lead },
|
||||
adminPanel: { title: "Panel rheoli gweinyddol", heading: "Panel rheoli gweinyddol", lead: en.adminPanel.lead },
|
||||
adminSettings: { title: "Gosodiadau gweinyddol", heading: "Gosodiadau gweinyddol", lead: en.adminSettings.lead },
|
||||
notFound: { title: "Heb ddod o hyd", heading: "404 — tudalen heb ei ganfod", lead: en.notFound.lead },
|
||||
unauthorized: { title: "Mewngofnodi sy'n ofynnol", heading: "Mae angen mewngofnodi", lead: en.unauthorized.lead },
|
||||
};
|
||||
|
||||
const ru: Messages = {
|
||||
...en,
|
||||
nav: {
|
||||
...en.nav,
|
||||
home: "Главная",
|
||||
about: "О нас",
|
||||
contact: "Контакты",
|
||||
privacy: "Политика конфиденциальности",
|
||||
terms: "Условия обслуживания",
|
||||
login: "Войти",
|
||||
register: "Регистрация",
|
||||
logout: "Выйти",
|
||||
userPanel: "Панель пользователя",
|
||||
userSettings: "Настройки пользователя",
|
||||
adminPanel: "Панель администратора",
|
||||
adminSettings: "Настройки администратора",
|
||||
},
|
||||
siteName: "Демо Suaveplan",
|
||||
home: { title: "Suaveplan — Главная", heading: "Добро пожаловать в демо Suaveplan", lead: en.home.lead },
|
||||
about: { title: "О нас", heading: "О Suaveplan", lead: en.about.lead },
|
||||
contact: { title: "Контакты", heading: "Свяжитесь с нами", lead: en.contact.lead },
|
||||
privacy: { title: "Политика конфиденциальности", heading: "Политика конфиденциальности", lead: en.privacy.lead },
|
||||
terms: { title: "Условия обслуживания", heading: "Условия обслуживания", lead: en.terms.lead },
|
||||
login: { ...en.login, title: "Войти", heading: "Войти в учетную запись", lead: "Войдите, используя email и пароль." },
|
||||
register: { ...en.register, title: "Создать учетную запись", heading: "Создайте учетную запись", lead: "Зарегистрируйтесь с email и паролем не менее 6 символов." },
|
||||
forgot: { ...en.forgot, title: "Забыли пароль", heading: "Сбросить пароль", lead: en.forgot.lead },
|
||||
logout: { title: "Выход", heading: "Выход из системы…", lead: en.logout.lead },
|
||||
userPanel: { title: "Панель управления пользователя", heading: "Панель управления", lead: en.userPanel.lead },
|
||||
userSettings: { title: "Настройки пользователя", heading: "Настройки", lead: en.userSettings.lead },
|
||||
adminPanel: { title: "Панель управления администратора", heading: "Панель управления администратора", lead: en.adminPanel.lead },
|
||||
adminSettings: { title: "Настройки администратора", heading: "Настройки администратора", lead: en.adminSettings.lead },
|
||||
notFound: { title: "Не найдено", heading: "404 — страница не найдена", lead: en.notFound.lead },
|
||||
unauthorized: { title: "Требуется вход", heading: "Требуется вход", lead: en.unauthorized.lead },
|
||||
};
|
||||
|
||||
export const CATALOGS: Readonly<Record<BaseLocale, Messages>> = {
|
||||
en,
|
||||
zh,
|
||||
ar,
|
||||
cy,
|
||||
ru,
|
||||
};
|
||||
87
apps/demo/src/client/lib/auth-client.ts
Normal file
87
apps/demo/src/client/lib/auth-client.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Tiny browser auth client that talks directly to the better-auth
|
||||
* HTTP endpoints mounted by the server at `/api/auth/*`. Keeps the
|
||||
* demo client free of `better-auth/client` so the React build stays
|
||||
* lean.
|
||||
*/
|
||||
export interface SessionUser {
|
||||
readonly id: string;
|
||||
readonly email: string;
|
||||
readonly name?: string | null;
|
||||
readonly role?: string | null;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
readonly user: SessionUser;
|
||||
readonly expiresAt?: string;
|
||||
}
|
||||
|
||||
export async function fetchSession(): Promise<Session | null> {
|
||||
const res = await fetch("/api/auth/get-session", {
|
||||
credentials: "include",
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { user?: SessionUser; session?: { expiresAt?: string } } | null;
|
||||
if (body === null || body.user === undefined) return null;
|
||||
return {
|
||||
user: body.user,
|
||||
...(body.session?.expiresAt !== undefined ? { expiresAt: body.session.expiresAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function signInEmail(args: {
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const res = await fetch("/api/auth/sign-in/email", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(args),
|
||||
});
|
||||
if (res.ok) return { ok: true };
|
||||
const body = (await res.json().catch(() => ({}))) as { message?: string };
|
||||
return { ok: false, error: body.message ?? `Sign-in failed (${res.status})` };
|
||||
}
|
||||
|
||||
export async function signUpEmail(args: {
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
readonly name?: string;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const res = await fetch("/api/auth/sign-up/email", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...args, name: args.name ?? args.email.split("@")[0] }),
|
||||
});
|
||||
if (res.ok) return { ok: true };
|
||||
const body = (await res.json().catch(() => ({}))) as { message?: string };
|
||||
return { ok: false, error: body.message ?? `Sign-up failed (${res.status})` };
|
||||
}
|
||||
|
||||
export async function signOut(): Promise<void> {
|
||||
await fetch("/api/auth/sign-out", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{}",
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function forgetPassword(args: {
|
||||
readonly email: string;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const res = await fetch("/api/auth/forget-password", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...args, redirectTo: "/auth/login" }),
|
||||
});
|
||||
if (res.ok) return { ok: true };
|
||||
// better-auth returns 200 even for unknown emails to avoid enumeration;
|
||||
// we still surface non-2xx for transport-level errors.
|
||||
const body = (await res.json().catch(() => ({}))) as { message?: string };
|
||||
return { ok: false, error: body.message ?? `Request failed (${res.status})` };
|
||||
}
|
||||
57
apps/demo/src/client/lib/guards.tsx
Normal file
57
apps/demo/src/client/lib/guards.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Route guards — wrap a child element with `<RequireAuth>` or
|
||||
* `<RequireRole role="admin">`. Both rely on `useSession()` so they
|
||||
* react to live sign-in / sign-out without a hard reload.
|
||||
*/
|
||||
import React from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { useLocale } from "../i18n/locale-context.js";
|
||||
import { useSession } from "./session-context.js";
|
||||
|
||||
export function RequireAuth({ children }: { readonly children: React.ReactNode }): React.JSX.Element {
|
||||
const { session, loading } = useSession();
|
||||
const { base } = useLocale();
|
||||
const location = useLocation();
|
||||
if (loading) {
|
||||
return <div className="p-8 text-sm text-muted">Loading session…</div>;
|
||||
}
|
||||
if (session === null) {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/${base}/auth/login?return=${encodeURIComponent(location.pathname)}`}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function RequireRole({
|
||||
role,
|
||||
children,
|
||||
}: {
|
||||
readonly role: string;
|
||||
readonly children: React.ReactNode;
|
||||
}): React.JSX.Element {
|
||||
const { session, loading } = useSession();
|
||||
const { base, messages } = useLocale();
|
||||
if (loading) {
|
||||
return <div className="p-8 text-sm text-muted">Loading session…</div>;
|
||||
}
|
||||
if (session === null) {
|
||||
return <Navigate to={`/${base}/auth/login`} replace />;
|
||||
}
|
||||
if (session.user.role !== role) {
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl p-8">
|
||||
<h1 className="text-2xl font-semibold" data-testid="page-heading">
|
||||
{messages.unauthorized.heading}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Required role: <code>{role}</code>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
53
apps/demo/src/client/lib/session-context.tsx
Normal file
53
apps/demo/src/client/lib/session-context.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Session context — polls `/api/auth/get-session` on mount and exposes
|
||||
* `useSession()` and a `refresh()` to forms.
|
||||
*/
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { fetchSession, type Session } from "./auth-client.js";
|
||||
|
||||
export interface SessionContextValue {
|
||||
readonly session: Session | null;
|
||||
readonly loading: boolean;
|
||||
readonly refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SessionContext = createContext<SessionContextValue | null>(null);
|
||||
|
||||
export function useSession(): SessionContextValue {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (ctx === null) throw new Error("useSession must be inside <SessionProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function SessionProvider({ children }: { children: React.ReactNode }): React.JSX.Element {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const next = await fetchSession();
|
||||
setSession(next);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const value = useMemo<SessionContextValue>(
|
||||
() => ({ session, loading, refresh }),
|
||||
[session, loading, refresh]
|
||||
);
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||
}
|
||||
13
apps/demo/src/client/main.tsx
Normal file
13
apps/demo/src/client/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./styles/globals.css";
|
||||
import { App } from "./app.js";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root === null) throw new Error("No #root element found");
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
143
apps/demo/src/client/pages/account-pages.tsx
Normal file
143
apps/demo/src/client/pages/account-pages.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* User and admin gated pages. The auth gate is enforced by
|
||||
* `<RequireAuth>` / `<RequireRole>` in the route definitions; these
|
||||
* components only need to assume `useSession().session` is non-null
|
||||
* and render the placeholder dashboards.
|
||||
*
|
||||
* The session-card surface and the settings form are composed from
|
||||
* `@suaveplan/react-ui` primitives (`<Card>`, `<Field>`, `<Input>`,
|
||||
* `<FieldDescription>`). The preferred-locale picker keeps a native
|
||||
* `<select>` because the chassis `<Select>` is a Radix-portal popover
|
||||
* with overlay state we do not need for a placeholder form.
|
||||
*/
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
Input,
|
||||
} from "@suaveplan/react-ui";
|
||||
import React from "react";
|
||||
import { PageShell } from "../components/page-shell.js";
|
||||
import { useMessages } from "../i18n/locale-context.js";
|
||||
import { useSession } from "../lib/session-context.js";
|
||||
|
||||
function SessionCard(): React.JSX.Element {
|
||||
const { session } = useSession();
|
||||
if (session === null) return <></>;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Session</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="font-medium">User id</dt>
|
||||
<dd className="font-mono text-xs">{session.user.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium">Email</dt>
|
||||
<dd>{session.user.email}</dd>
|
||||
</div>
|
||||
{session.user.name !== undefined && session.user.name !== null ? (
|
||||
<div>
|
||||
<dt className="font-medium">Name</dt>
|
||||
<dd>{session.user.name}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{session.user.role !== undefined && session.user.role !== null ? (
|
||||
<div>
|
||||
<dt className="font-medium">Role</dt>
|
||||
<dd>{session.user.role}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{session.expiresAt !== undefined ? (
|
||||
<div>
|
||||
<dt className="font-medium">Session expires</dt>
|
||||
<dd>{session.expiresAt}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserControlPanelPage(): React.JSX.Element {
|
||||
const m = useMessages().userPanel;
|
||||
return (
|
||||
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
|
||||
<SessionCard />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserSettingsPage(): React.JSX.Element {
|
||||
const m = useMessages().userSettings;
|
||||
return (
|
||||
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<form>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="displayName">Display name</FieldLabel>
|
||||
<Input id="displayName" name="displayName" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="preferredLocale">Preferred locale</FieldLabel>
|
||||
{/*
|
||||
Native <select> is intentional: the chassis Select is a
|
||||
Radix-portal popover with overlay state we do not need
|
||||
for a placeholder form. Visual treatment matches the
|
||||
chassis Input via the shared input cva class names.
|
||||
*/}
|
||||
<select
|
||||
id="preferredLocale"
|
||||
name="preferredLocale"
|
||||
className="block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="zh">中文</option>
|
||||
<option value="ar">العربية</option>
|
||||
<option value="cy">Cymraeg</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
<FieldDescription>
|
||||
This is a placeholder — settings are not persisted in the demo.
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminControlPanelPage(): React.JSX.Element {
|
||||
const m = useMessages().adminPanel;
|
||||
return (
|
||||
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
|
||||
<SessionCard />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminSettingsPage(): React.JSX.Element {
|
||||
const m = useMessages().adminSettings;
|
||||
return (
|
||||
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm">Admin-only configuration placeholder.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
283
apps/demo/src/client/pages/auth-pages.tsx
Normal file
283
apps/demo/src/client/pages/auth-pages.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Auth pages — Login, Register, ForgotPassword, Logout.
|
||||
*
|
||||
* Each posts to the better-auth HTTP endpoint mounted on the server
|
||||
* under `/api/auth/*` via the thin `auth-client.ts` shim. On success
|
||||
* the session context is refreshed and the user is sent to their
|
||||
* control panel.
|
||||
*
|
||||
* Forms are composed from `@suaveplan/react-ui` primitives:
|
||||
* `<Card>` / `<CardContent>` shell, `<Field>` + `<FieldLabel>` +
|
||||
* `<Input>` + `<FieldError>` per control, and `<Button>` for the
|
||||
* submit action. The chassis owns all visual variant resolution; the
|
||||
* demo carries no hand-rolled Tailwind classes for these primitives.
|
||||
*/
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Field,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
Input,
|
||||
} from "@suaveplan/react-ui";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { PageShell } from "../components/page-shell.js";
|
||||
import { useLocale } from "../i18n/locale-context.js";
|
||||
import {
|
||||
forgetPassword,
|
||||
signInEmail,
|
||||
signOut,
|
||||
signUpEmail,
|
||||
} from "../lib/auth-client.js";
|
||||
import { useSession } from "../lib/session-context.js";
|
||||
|
||||
export function LoginPage(): React.JSX.Element {
|
||||
const { base, messages } = useLocale();
|
||||
const m = messages.login;
|
||||
const { refresh } = useSession();
|
||||
const navigate = useNavigate();
|
||||
const [search] = useSearchParams();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await signInEmail({ email, password });
|
||||
if (result.ok) {
|
||||
await refresh();
|
||||
navigate(search.get("return") ?? `/${base}/user/control-panel`);
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} data-testid="login-form">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">{m.emailLabel}</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password">{m.passwordLabel}</FieldLabel>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{error !== null ? (
|
||||
<Alert variant="destructive" data-testid="login-error">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Button type="submit" disabled={busy}>
|
||||
{m.submit}
|
||||
</Button>
|
||||
<div className="flex flex-wrap justify-between gap-2 text-sm">
|
||||
<Link to={`/${base}/auth/register`}>{m.linkToRegister}</Link>
|
||||
<Link to={`/${base}/auth/forgot-password`}>{m.linkToForgot}</Link>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function RegisterPage(): React.JSX.Element {
|
||||
const { base, messages } = useLocale();
|
||||
const m = messages.register;
|
||||
const { refresh } = useSession();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (password !== confirm) {
|
||||
setError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await signUpEmail({ email, password });
|
||||
if (result.ok) {
|
||||
await refresh();
|
||||
navigate(`/${base}/user/control-panel`);
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
const mismatch = password !== "" && confirm !== "" && password !== confirm;
|
||||
|
||||
return (
|
||||
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} data-testid="register-form">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">{m.emailLabel}</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password">{m.passwordLabel}</FieldLabel>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field invalid={mismatch}>
|
||||
<FieldLabel htmlFor="confirm">{m.confirmLabel}</FieldLabel>
|
||||
<Input
|
||||
id="confirm"
|
||||
name="confirm"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
invalid={mismatch}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
/>
|
||||
{mismatch ? (
|
||||
<FieldError>Passwords do not match.</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
{error !== null ? (
|
||||
<Alert variant="destructive" data-testid="register-error">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Button type="submit" disabled={busy}>
|
||||
{m.submit}
|
||||
</Button>
|
||||
<div className="text-sm">
|
||||
<Link to={`/${base}/auth/login`}>{m.linkToLogin}</Link>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForgotPasswordPage(): React.JSX.Element {
|
||||
const { base, messages } = useLocale();
|
||||
const m = messages.forgot;
|
||||
const [email, setEmail] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
await forgetPassword({ email });
|
||||
setSent(true);
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
{sent ? (
|
||||
<Alert variant="default" data-testid="forgot-sent">
|
||||
<AlertDescription>{m.sentNotice}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} data-testid="forgot-form">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">{m.emailLabel}</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Button type="submit" disabled={busy}>
|
||||
{m.submit}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
)}
|
||||
<div className="mt-4 text-sm">
|
||||
<Link to={`/${base}/auth/login`}>{m.linkToLogin}</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogoutPage(): React.JSX.Element {
|
||||
const { base, messages } = useLocale();
|
||||
const m = messages.logout;
|
||||
const { refresh } = useSession();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await signOut();
|
||||
await refresh();
|
||||
navigate(`/${base}/home`);
|
||||
})();
|
||||
// We want this effect to run exactly once on mount; the locale
|
||||
// base is captured at mount time.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
|
||||
}
|
||||
39
apps/demo/src/client/pages/not-found-page.tsx
Normal file
39
apps/demo/src/client/pages/not-found-page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 404 page — also doubles as the unknown-locale handler. Offers a
|
||||
* language picker so a user landing on `/de/home` can switch to a
|
||||
* supported language without backtracking.
|
||||
*
|
||||
* Each language choice renders as a `<Button asChild variant="outline">`
|
||||
* wrapping a router `<Link>` so the chassis owns the visual treatment
|
||||
* (border, focus ring, hover state, motion tokens) while the navigation
|
||||
* stays SPA-internal.
|
||||
*/
|
||||
import { Button } from "@suaveplan/react-ui";
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { PageShell } from "../components/page-shell.js";
|
||||
import { LOCALE_ENTRIES, useLocale } from "../i18n/locale-context.js";
|
||||
import { CATALOGS } from "../i18n/messages.js";
|
||||
|
||||
export function NotFoundPage(): React.JSX.Element {
|
||||
// We may be rendered outside a valid locale context (e.g. for
|
||||
// `/de/home`). The LocaleProvider falls back to "en" in that case,
|
||||
// so we can safely read messages.
|
||||
const { availableLocales, messages: ctxMessages } = useLocale();
|
||||
const m = ctxMessages.notFound;
|
||||
return (
|
||||
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
|
||||
<ul className="mt-4 flex flex-wrap gap-3">
|
||||
{availableLocales.map((loc) => (
|
||||
<li key={loc}>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`/${loc}/home`}>
|
||||
{LOCALE_ENTRIES[loc].nativeName} — {CATALOGS[loc].nav.home}
|
||||
</Link>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
32
apps/demo/src/client/pages/public-pages.tsx
Normal file
32
apps/demo/src/client/pages/public-pages.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Five public information pages — Home, About, Contact, Privacy,
|
||||
* Terms. Each renders through `<PageShell>` using the typed catalog.
|
||||
*/
|
||||
import React from "react";
|
||||
import { PageShell } from "../components/page-shell.js";
|
||||
import { useMessages } from "../i18n/locale-context.js";
|
||||
|
||||
export function HomePage(): React.JSX.Element {
|
||||
const m = useMessages().home;
|
||||
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
|
||||
}
|
||||
|
||||
export function AboutPage(): React.JSX.Element {
|
||||
const m = useMessages().about;
|
||||
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
|
||||
}
|
||||
|
||||
export function ContactPage(): React.JSX.Element {
|
||||
const m = useMessages().contact;
|
||||
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
|
||||
}
|
||||
|
||||
export function PrivacyPage(): React.JSX.Element {
|
||||
const m = useMessages().privacy;
|
||||
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
|
||||
}
|
||||
|
||||
export function TermsPage(): React.JSX.Element {
|
||||
const m = useMessages().terms;
|
||||
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
|
||||
}
|
||||
57
apps/demo/src/client/styles/globals.css
Normal file
57
apps/demo/src/client/styles/globals.css
Normal file
@@ -0,0 +1,57 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--color-bg: #ffffff;
|
||||
--color-fg: #0a0a0a;
|
||||
--color-muted: #71717a;
|
||||
--color-border: #e4e4e7;
|
||||
--color-accent: #6366f1;
|
||||
--color-accent-fg: #ffffff;
|
||||
--color-surface: #fafafa;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--color-bg: #09090b;
|
||||
--color-fg: #fafafa;
|
||||
--color-muted: #a1a1aa;
|
||||
--color-border: #27272a;
|
||||
--color-accent: #818cf8;
|
||||
--color-accent-fg: #0a0a0a;
|
||||
--color-surface: #18181b;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-fg);
|
||||
font-family:
|
||||
"Inter",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
[dir="rtl"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
185
apps/demo/src/server/auth.ts
Normal file
185
apps/demo/src/server/auth.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Demo authentication boot.
|
||||
*
|
||||
* Composes the canonical genesis plugin pipeline:
|
||||
*
|
||||
* 1. A live `SurrealAdapter` (`@suaveplan/surrealdb`) — connects to
|
||||
* `SURREAL_URL` (default `ws://localhost:8000`) with namespace +
|
||||
* database from env. The driver does not bundle an in-process
|
||||
* engine in this version, so an external SurrealDB must be
|
||||
* reachable.
|
||||
* 2. A `betterAuthSurrealAdapter` (`@suaveplan/better-auth-surrealdb`)
|
||||
* built against that client — this is the persistence backend
|
||||
* Better-Auth itself talks to.
|
||||
* 3. A `SuaveApp` (`@suaveplan/api`) assembled via `defineApp()`. Two
|
||||
* plugins register, in order:
|
||||
* a. A tiny inline `demo-db-seed` plugin that drops the live
|
||||
* `SurrealAdapter` into the `ServiceRegistry["db"]` slot.
|
||||
* b. `createAuthPlugin(...)` from `@suaveplan/api-plugin-auth`
|
||||
* — its 6-step `onInit` then resolves the `db` slot, applies
|
||||
* the canonical 17-table Better-Auth SurrealQL schema,
|
||||
* constructs the Better-Auth instance, registers the
|
||||
* `authService`, mounts the 9 OpenAPI routes (and the
|
||||
* catch-all Better-Auth handler) under `/api/auth/*`, and
|
||||
* installs the global session-resolver middleware.
|
||||
*
|
||||
* The output is `{ app, surreal }` so the HTTP server can mount the
|
||||
* SPA fallback + custom routes onto `app.hono` and shut down the
|
||||
* SurrealDB client cleanly on stop.
|
||||
*/
|
||||
import { defineApp, type SuaveApp } from "@suaveplan/api";
|
||||
import { definePlugin } from "@suaveplan/api-plugin";
|
||||
import type { AuthService } from "@suaveplan/api-plugin-auth";
|
||||
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
|
||||
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb";
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
|
||||
// ServiceRegistry augmentation — re-declared locally because the
|
||||
// vite-plugin-dts emitted package-root `.d.ts` files for
|
||||
// `@suaveplan/api` and `@suaveplan/api-plugin-auth` strip the
|
||||
// side-effect import of the per-package `registry-augmentation.ts`
|
||||
// where these `declare module` blocks live. Without this re-declaration
|
||||
// `ServiceRegistry` resolves to `Record<never, never>` and
|
||||
// `ctx.registerService("db", ...)` / `ctx.getService("authService")`
|
||||
// fail to typecheck. The augmentation is interface-merged with the
|
||||
// package-internal ones at runtime, so the typing matches what the
|
||||
// auth plugin actually populates.
|
||||
declare module "@suaveplan/api-plugin/service-registry" {
|
||||
interface ServiceRegistry {
|
||||
db?: SurrealAdapter;
|
||||
authService: AuthService;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a string env var with a non-empty fallback. */
|
||||
function envOr(name: string, fallback: string): string {
|
||||
const raw = process.env[name];
|
||||
return raw !== undefined && raw.length > 0 ? raw : fallback;
|
||||
}
|
||||
|
||||
/** Resolved SurrealDB connection parameters for the demo. */
|
||||
export interface SurrealConfig {
|
||||
readonly url: string;
|
||||
readonly namespace: string;
|
||||
readonly database: string;
|
||||
readonly username: string;
|
||||
readonly password: string;
|
||||
}
|
||||
|
||||
/** Build the connection config from env vars (with defaults). */
|
||||
export function resolveSurrealConfig(): SurrealConfig {
|
||||
return {
|
||||
url: envOr("SURREAL_URL", "ws://localhost:8000"),
|
||||
namespace: envOr("SURREAL_NS", "demo"),
|
||||
database: envOr("SURREAL_DB", "demo"),
|
||||
username: envOr("SURREAL_USER", "root"),
|
||||
password: envOr("SURREAL_PASS", "root"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Result of {@link bootAuth}. */
|
||||
export interface DemoAuthBoot {
|
||||
readonly app: SuaveApp;
|
||||
readonly surreal: SurrealAdapter;
|
||||
readonly authService: AuthService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct + initialize the demo `SuaveApp` with the auth plugin
|
||||
* wired against a live SurrealDB. Returns the initialized `SuaveApp`
|
||||
* (ready to receive requests via `app.fetch`) plus the underlying
|
||||
* `SurrealAdapter` so the caller can `await surreal.disconnect()` on
|
||||
* shutdown.
|
||||
*/
|
||||
export async function bootAuth(): Promise<DemoAuthBoot> {
|
||||
const cfg = resolveSurrealConfig();
|
||||
const surreal = new SurrealAdapter({
|
||||
url: cfg.url,
|
||||
namespace: cfg.namespace,
|
||||
database: cfg.database,
|
||||
username: cfg.username,
|
||||
password: cfg.password,
|
||||
});
|
||||
await surreal.connect();
|
||||
|
||||
const betterAuthAdapter = betterAuthSurrealAdapter({
|
||||
client: surreal,
|
||||
});
|
||||
|
||||
/**
|
||||
* Inline plugin — seeds the `db` slot on the `ServiceRegistry`
|
||||
* so the auth plugin's `onInit` (which runs after this one in
|
||||
* registration order) can resolve a connected SurrealDB client
|
||||
* for `applyBetterAuthSchema(db)`.
|
||||
*/
|
||||
const dbSeedPlugin = definePlugin<Record<string, never>>({
|
||||
name: "demo-db-seed",
|
||||
version: "0.1.0",
|
||||
description:
|
||||
"Demo-only plugin: seeds the live SurrealAdapter into ServiceRegistry['db'] for the auth plugin to consume. Runs in `onRegister` so the `db` slot is populated before any plugin's `onInit` phase (including the auth plugin's).",
|
||||
defaults: {},
|
||||
onRegister: async (ctx) => {
|
||||
ctx.registerService("db", surreal);
|
||||
},
|
||||
});
|
||||
|
||||
const authPlugin = createAuthPlugin({
|
||||
betterAuth: {
|
||||
database: betterAuthAdapter as unknown as Record<string, unknown>,
|
||||
baseURL: envOr("BETTER_AUTH_URL", "http://localhost:3300"),
|
||||
secret: envOr(
|
||||
"BETTER_AUTH_SECRET",
|
||||
"demo-secret-please-set-BETTER_AUTH_SECRET-in-prod-do-not-use"
|
||||
),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
autoSignIn: true,
|
||||
minPasswordLength: 6,
|
||||
},
|
||||
trustedOrigins: ["http://localhost:3300"],
|
||||
},
|
||||
routePrefix: "/api/auth",
|
||||
applySchemaOnInit: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Inline capture plugin — registered AFTER `authPlugin` so its
|
||||
* `onInit` resolves the typed `authService` slot the auth plugin
|
||||
* just populated, and stashes it on `captured.authService` for the
|
||||
* demo's `/api/me` handler.
|
||||
*/
|
||||
const captured: { authService: AuthService | undefined } = {
|
||||
authService: undefined,
|
||||
};
|
||||
const capturePlugin = definePlugin<Record<string, never>>({
|
||||
name: "demo-auth-capture",
|
||||
version: "0.1.0",
|
||||
description:
|
||||
"Demo-only plugin: captures the resolved authService from the plugin registry for use by /api/me. Runs in `onReady` so the `authService` slot populated by the auth plugin's `onInit` is guaranteed visible.",
|
||||
defaults: {},
|
||||
onReady: async (ctx) => {
|
||||
captured.authService = ctx.getService("authService");
|
||||
},
|
||||
});
|
||||
|
||||
const app = await defineApp()
|
||||
.name("@suaveplan/demo")
|
||||
.version("0.1.0")
|
||||
.skipConfigFile()
|
||||
.openapi({ title: "@suaveplan/demo", version: "0.1.0" })
|
||||
.healthRoutes({ healthPath: "/api/health" })
|
||||
.use(dbSeedPlugin)
|
||||
.use(authPlugin)
|
||||
.use(capturePlugin)
|
||||
.build();
|
||||
await app.initialize();
|
||||
if (captured.authService === undefined) {
|
||||
throw new Error(
|
||||
"demo boot: authService was not populated by api-plugin-auth"
|
||||
);
|
||||
}
|
||||
|
||||
return { app, surreal, authService: captured.authService };
|
||||
}
|
||||
|
||||
export type AppAuth = SuaveApp;
|
||||
55
apps/demo/src/server/index.ts
Normal file
55
apps/demo/src/server/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Unified Bun + Hono server for the demo app.
|
||||
*
|
||||
* Built around `@suaveplan/api`'s `SuaveApp`:
|
||||
*
|
||||
* - `/api/health` — registered by `registerHealthRoutes` on the
|
||||
* `SuaveApp` Hono.
|
||||
* - `/api/auth/*` — mounted by `@suaveplan/api-plugin-auth` (9 OpenAPI
|
||||
* routes + the Better-Auth catch-all).
|
||||
* - `/api/me` — demo convenience JSON view of the current session,
|
||||
* sourced from the captured `authService.getSession(...)`.
|
||||
* - everything else falls through to the Vite-built React SPA in
|
||||
* `dist/client/`, with a wildcard fallback to `index.html` so the
|
||||
* client-side router owns locale-prefixed routes.
|
||||
*/
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { bootAuth } from "./auth.js";
|
||||
|
||||
const { app, surreal, authService } = await bootAuth();
|
||||
const hono = app.hono;
|
||||
|
||||
hono.get("/api/me", async (c) => {
|
||||
const result = await authService.getSession(c.req.raw.headers);
|
||||
return c.json({ session: result });
|
||||
});
|
||||
|
||||
hono.use("/*", serveStatic({ root: "./dist/client" }));
|
||||
hono.get("*", serveStatic({ path: "./dist/client/index.html" }));
|
||||
|
||||
const port = Number(process.env["PORT"] ?? 3300);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[@suaveplan/demo] listening on http://localhost:${port}`);
|
||||
|
||||
const shutdown = async (): Promise<void> => {
|
||||
try {
|
||||
await app.stop();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
await surreal.disconnect();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
};
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdown().then(() => process.exit(0));
|
||||
});
|
||||
process.on("SIGINT", () => {
|
||||
void shutdown().then(() => process.exit(0));
|
||||
});
|
||||
|
||||
export default { port, fetch: app.fetch };
|
||||
12
apps/demo/tsconfig.json
Normal file
12
apps/demo/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"types": ["node", "react", "react-dom"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "e2e/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules", "coverage"]
|
||||
}
|
||||
14
apps/demo/vite.config.ts
Normal file
14
apps/demo/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
outDir: "dist/client",
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
},
|
||||
root: ".",
|
||||
publicDir: "public",
|
||||
});
|
||||
Reference in New Issue
Block a user