fix all languages homepage
Build & Deploy / Build & Push Docker Image (push) Failing after 48s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m3s
Test / Marketplace Unit Tests (push) Failing after 4m56s
Test / Admin Unit Tests (push) Successful in 9m36s
Test / Dashboard Unit Tests (push) Successful in 9m38s
Test / API Integration Tests (push) Successful in 9m54s
@@ -2,6 +2,7 @@ import { StatusBadge } from '@/components/foundation/StatusBadge';
|
|||||||
import { accountCreateUrl } from '@/lib/account-urls';
|
import { accountCreateUrl } from '@/lib/account-urls';
|
||||||
import { localizedPath, type Locale } from '@/lib/localization/config';
|
import { localizedPath, type Locale } from '@/lib/localization/config';
|
||||||
import type { ShellMessages } from '@/lib/localization/messages';
|
import type { ShellMessages } from '@/lib/localization/messages';
|
||||||
|
import Image from 'next/image';
|
||||||
import styles from './SiteFooter.module.css';
|
import styles from './SiteFooter.module.css';
|
||||||
|
|
||||||
function signInUrl(locale: Locale): string {
|
function signInUrl(locale: Locale): string {
|
||||||
@@ -54,9 +55,14 @@ export function SiteFooter({ locale, messages }: SiteFooterProps) {
|
|||||||
<div className={styles.inner}>
|
<div className={styles.inner}>
|
||||||
<div className={styles.brandColumn}>
|
<div className={styles.brandColumn}>
|
||||||
<div className={styles.brand}>
|
<div className={styles.brand}>
|
||||||
<span aria-hidden="true" className={styles.mark}>
|
<Image
|
||||||
RDG
|
src="/rentaldrivego.png"
|
||||||
</span>
|
alt="RentalDriveGo"
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
unoptimized
|
||||||
|
style={{ borderRadius: '0.5rem', objectFit: 'contain', flexShrink: 0 }}
|
||||||
|
/>
|
||||||
<span>RentalDriveGo</span>
|
<span>RentalDriveGo</span>
|
||||||
</div>
|
</div>
|
||||||
<p className={styles.tagline}>{footer.tagline}</p>
|
<p className={styles.tagline}>{footer.tagline}</p>
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
|
||||||
import nextVitals from 'eslint-config-next/core-web-vitals';
|
|
||||||
import nextTypeScript from 'eslint-config-next/typescript';
|
|
||||||
import prettier from 'eslint-config-prettier';
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
...nextVitals,
|
|
||||||
...nextTypeScript,
|
|
||||||
prettier,
|
|
||||||
{
|
|
||||||
rules: {
|
|
||||||
'no-console': ['error', { allow: ['log', 'warn', 'error'] }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
projectService: true,
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'@typescript-eslint/consistent-type-imports': [
|
|
||||||
'error',
|
|
||||||
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
|
||||||
],
|
|
||||||
'@typescript-eslint/no-floating-promises': 'error',
|
|
||||||
'@typescript-eslint/switch-exhaustiveness-check': 'error',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
globalIgnores([
|
|
||||||
'.next/**',
|
|
||||||
'coverage/**',
|
|
||||||
'playwright-report/**',
|
|
||||||
'test-results/**',
|
|
||||||
'contracts/**',
|
|
||||||
'next-env.d.ts',
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
import "./.next/dev/types/routes.d.ts";
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import type { NextConfig } from 'next';
|
|
||||||
|
|
||||||
const securityHeaders = [
|
|
||||||
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
|
|
||||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
|
||||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
|
||||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
|
||||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
allowedDevOrigins: ['192.168.3.3'],
|
|
||||||
compress: true,
|
|
||||||
poweredByHeader: false,
|
|
||||||
reactStrictMode: true,
|
|
||||||
|
|
||||||
transpilePackages: ['@rentaldrivego/types'],
|
|
||||||
|
|
||||||
images: {
|
|
||||||
remotePatterns: [
|
|
||||||
{
|
|
||||||
protocol: 'https',
|
|
||||||
hostname: 'res.cloudinary.com',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
async headers() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: '/:path*',
|
|
||||||
headers: securityHeaders,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
async redirects() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: '/dashboard/dashboard',
|
|
||||||
destination: '/dashboard',
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: '/dashboard/dashboard/:path*',
|
|
||||||
destination: '/dashboard/:path*',
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
async rewrites() {
|
|
||||||
const dashboardOrigin =
|
|
||||||
process.env.DASHBOARD_INTERNAL_URL ?? 'http://dashboard:3001';
|
|
||||||
const adminOrigin =
|
|
||||||
process.env.ADMIN_INTERNAL_URL ?? 'http://admin:3002';
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: '/dashboard',
|
|
||||||
destination: `${dashboardOrigin}/dashboard`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: '/dashboard/:path*',
|
|
||||||
destination: `${dashboardOrigin}/dashboard/:path*`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: '/admin',
|
|
||||||
destination: `${adminOrigin}/admin`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: '/admin/:path*',
|
|
||||||
destination: `${adminOrigin}/admin/:path*`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@rentaldrivego/homepage",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"predev": "npm run build --workspace @rentaldrivego/types",
|
|
||||||
"dev": "next dev -H 0.0.0.0 -p 3000",
|
|
||||||
"prebuild": "npm run build --workspace @rentaldrivego/types",
|
|
||||||
"build": "next build",
|
|
||||||
"prestart": "npm run build --workspace @rentaldrivego/types",
|
|
||||||
"pretype-check": "npm run build --workspace @rentaldrivego/types",
|
|
||||||
"start": "next start -H 0.0.0.0 -p 3000",
|
|
||||||
"type-check": "tsc --noEmit",
|
|
||||||
"test": "vitest run",
|
|
||||||
"test:watch": "vitest",
|
|
||||||
"lint": "eslint . --max-warnings=0",
|
|
||||||
"format": "prettier --write .",
|
|
||||||
"format:check": "prettier --check ."
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
|
||||||
"@fontsource-variable/noto-sans-arabic": "^5.2.10",
|
|
||||||
"@rentaldrivego/types": "*",
|
|
||||||
"next": "^16.2.7",
|
|
||||||
"react": "^19.2.0",
|
|
||||||
"react-dom": "^19.2.0",
|
|
||||||
"zod": "^4.4.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^9.0.0",
|
|
||||||
"@playwright/test": "^1.61.0",
|
|
||||||
"@testing-library/jest-dom": "^6.9.0",
|
|
||||||
"@testing-library/react": "^16.3.0",
|
|
||||||
"@testing-library/user-event": "^14.6.0",
|
|
||||||
"@types/node": "^20.12.0",
|
|
||||||
"@types/react": "^19.2.0",
|
|
||||||
"@types/react-dom": "^19.2.0",
|
|
||||||
"@vitest/coverage-v8": "^4.1.0",
|
|
||||||
"eslint": "^9.0.0",
|
|
||||||
"eslint-config-next": "^16.2.0",
|
|
||||||
"eslint-config-prettier": "^10.1.0",
|
|
||||||
"globals": "^17.0.0",
|
|
||||||
"jsdom": "^29.0.0",
|
|
||||||
"prettier": "^3.8.0",
|
|
||||||
"typescript": "^5.4.0",
|
|
||||||
"typescript-eslint": "^8.0.0",
|
|
||||||
"vitest": "^4.1.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
|
||||||
|
|
||||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:3000';
|
|
||||||
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
|
||||||
const serverCommand = process.env.PLAYWRIGHT_SERVER_COMMAND ?? 'pnpm start';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: './tests',
|
|
||||||
testMatch: ['browser/**/*.spec.ts', 'a11y/**/*.spec.ts', 'visual/**/*.spec.ts'],
|
|
||||||
fullyParallel: true,
|
|
||||||
forbidOnly: Boolean(process.env.CI),
|
|
||||||
retries: process.env.CI ? 2 : 0,
|
|
||||||
...(process.env.CI ? { workers: 2 } : {}),
|
|
||||||
reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : 'list',
|
|
||||||
expect: {
|
|
||||||
timeout: 5_000,
|
|
||||||
toHaveScreenshot: {
|
|
||||||
animations: 'disabled',
|
|
||||||
caret: 'hide',
|
|
||||||
maxDiffPixelRatio: 0.005,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
use: {
|
|
||||||
baseURL,
|
|
||||||
locale: 'en-US',
|
|
||||||
timezoneId: 'UTC',
|
|
||||||
trace: 'retain-on-failure',
|
|
||||||
screenshot: 'only-on-failure',
|
|
||||||
video:
|
|
||||||
process.env.PLAYWRIGHT_VIDEO === 'off' ? 'off' : process.env.CI ? 'retain-on-failure' : 'off',
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: 'chromium',
|
|
||||||
use: {
|
|
||||||
...devices['Desktop Chrome'],
|
|
||||||
...(executablePath ? { launchOptions: { executablePath } } : {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
|
||||||
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
|
||||||
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
|
|
||||||
{ name: 'mobile-safari', use: { ...devices['iPhone 15'] } },
|
|
||||||
],
|
|
||||||
webServer: {
|
|
||||||
command: serverCommand,
|
|
||||||
url: baseURL,
|
|
||||||
reuseExistingServer: !process.env.CI,
|
|
||||||
timeout: 120_000,
|
|
||||||
env: {
|
|
||||||
SITE_ORIGIN: baseURL,
|
|
||||||
PUBLIC_RELEASE_APPROVED: 'false',
|
|
||||||
COMPONENT_FIXTURES_ENABLED: 'true',
|
|
||||||
NEXT_PUBLIC_DEMO_ENABLED: 'true',
|
|
||||||
DEMO_SUBMISSION_MODE: 'local',
|
|
||||||
DEMO_LOCAL_TEST_MODE: 'true',
|
|
||||||
DEMO_TEST_SCENARIOS_ENABLED: 'true',
|
|
||||||
NEXT_PUBLIC_ANALYTICS_MODE: 'disabled',
|
|
||||||
HOSTNAME: '127.0.0.1',
|
|
||||||
PORT: '3000',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
/** @type {import("prettier").Config} */
|
|
||||||
const config = {
|
|
||||||
printWidth: 100,
|
|
||||||
singleQuote: true,
|
|
||||||
trailingComma: 'all',
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 784 KiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
@@ -1,89 +0,0 @@
|
|||||||
import { StateAction, StateActions, StateShell } from '@/components/app-shell/StateShell';
|
|
||||||
import { SignInForm } from '@/components/auth/SignInForm';
|
|
||||||
import { ForgotPasswordForm } from '@/components/auth/ForgotPasswordForm';
|
|
||||||
import { ResetPasswordForm } from '@/components/auth/ResetPasswordForm';
|
|
||||||
import {
|
|
||||||
isLocale,
|
|
||||||
localizedPath,
|
|
||||||
routeIdFromSlug,
|
|
||||||
type Locale,
|
|
||||||
type RouteId,
|
|
||||||
} from '@/lib/localization/config';
|
|
||||||
import { getMessages, type ShellMessages } from '@/lib/localization/messages';
|
|
||||||
import { buildLocalizedMetadata } from '@/lib/metadata/build-metadata';
|
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
|
|
||||||
interface PendingRouteProps {
|
|
||||||
params: Promise<{ locale: string; slug: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function routeLabel(messages: ShellMessages, routeId: RouteId): string {
|
|
||||||
const labels = messages.footer.links;
|
|
||||||
switch (routeId) {
|
|
||||||
case 'sign-in':
|
|
||||||
return labels.login;
|
|
||||||
case 'forgot-password':
|
|
||||||
return 'Forgot password';
|
|
||||||
case 'reset-password':
|
|
||||||
return 'Reset password';
|
|
||||||
case 'privacy':
|
|
||||||
return labels.privacy;
|
|
||||||
case 'terms':
|
|
||||||
return labels.terms;
|
|
||||||
case 'accessibility':
|
|
||||||
return labels.accessibility;
|
|
||||||
case 'home':
|
|
||||||
return 'RentalDriveGo';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveParams(params: PendingRouteProps['params']) {
|
|
||||||
const { locale: localeValue, slug } = await params;
|
|
||||||
if (!isLocale(localeValue)) notFound();
|
|
||||||
const routeId = routeIdFromSlug(localeValue, slug);
|
|
||||||
if (!routeId || routeId === 'home') notFound();
|
|
||||||
return { locale: localeValue as Locale, routeId };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: PendingRouteProps): Promise<Metadata> {
|
|
||||||
const { locale, routeId } = await resolveParams(params);
|
|
||||||
const messages = getMessages(locale).shell;
|
|
||||||
const label = routeLabel(messages, routeId);
|
|
||||||
return buildLocalizedMetadata({
|
|
||||||
locale,
|
|
||||||
routeId,
|
|
||||||
title: `${label} | ${messages.metadata.pendingTitle}`,
|
|
||||||
description: messages.metadata.pendingDescription,
|
|
||||||
indexable: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function PendingRoute({ params }: PendingRouteProps) {
|
|
||||||
const { locale, routeId } = await resolveParams(params);
|
|
||||||
const messages = getMessages(locale).shell;
|
|
||||||
const label = routeLabel(messages, routeId);
|
|
||||||
|
|
||||||
if (routeId === 'sign-in') {
|
|
||||||
return <SignInForm locale={locale} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (routeId === 'forgot-password') {
|
|
||||||
return <ForgotPasswordForm locale={locale} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (routeId === 'reset-password') {
|
|
||||||
return <ResetPasswordForm locale={locale} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StateShell
|
|
||||||
title={`${label}: ${messages.states.pendingTitle}`}
|
|
||||||
body={messages.states.pendingBody}
|
|
||||||
>
|
|
||||||
<StateActions>
|
|
||||||
<StateAction href={localizedPath('home', locale)}>{messages.states.backHome}</StateAction>
|
|
||||||
</StateActions>
|
|
||||||
</StateShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Button } from '@/components/actions/Button';
|
|
||||||
import { Accordion } from '@/components/controls/Accordion';
|
|
||||||
import { SegmentedControl } from '@/components/controls/SegmentedControl';
|
|
||||||
import { Tabs } from '@/components/controls/Tabs';
|
|
||||||
import { Alert } from '@/components/feedback/Alert';
|
|
||||||
import { Checkbox, Switch } from '@/components/forms/ChoiceControls';
|
|
||||||
import { FormField } from '@/components/forms/FormField';
|
|
||||||
import { TextInput } from '@/components/forms/TextInput';
|
|
||||||
import { DropdownMenu } from '@/components/overlays/DropdownMenu';
|
|
||||||
import { Dialog } from '@/components/overlays/Dialog';
|
|
||||||
import type { ComponentLabCopy } from '@/content/development/component-lab';
|
|
||||||
import { useRef, useState } from 'react';
|
|
||||||
import styles from './page.module.css';
|
|
||||||
|
|
||||||
export function ComponentLabInteractive({ copy }: { copy: ComponentLabCopy }) {
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const [segment, setSegment] = useState('comfortable');
|
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
||||||
return (
|
|
||||||
<div className={styles.interactiveGrid}>
|
|
||||||
<section className={styles.group} aria-labelledby="lab-actions">
|
|
||||||
<h2 id="lab-actions">Actions</h2>
|
|
||||||
<div className={styles.cluster}>
|
|
||||||
<Button>{copy.actions.primary}</Button>
|
|
||||||
<Button intent="conversion">{copy.actions.conversion}</Button>
|
|
||||||
<Button loading>{copy.actions.loading}</Button>
|
|
||||||
<Button disabled>{copy.actions.disabled}</Button>
|
|
||||||
<Button ref={triggerRef} intent="secondary" onClick={() => setDialogOpen(true)}>
|
|
||||||
{copy.dialog.open}
|
|
||||||
</Button>
|
|
||||||
<DropdownMenu
|
|
||||||
label={copy.menu.label}
|
|
||||||
items={[
|
|
||||||
{ id: 'review', label: copy.menu.first, onSelect: () => undefined },
|
|
||||||
{
|
|
||||||
id: 'archive',
|
|
||||||
label: copy.menu.second,
|
|
||||||
onSelect: () => undefined,
|
|
||||||
destructive: true,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className={styles.group} aria-labelledby="lab-form">
|
|
||||||
<h2 id="lab-form">Form controls</h2>
|
|
||||||
<FormField
|
|
||||||
id="fixture-email"
|
|
||||||
label={copy.form.label}
|
|
||||||
supportingText={copy.form.supporting}
|
|
||||||
error={copy.form.error}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<TextInput
|
|
||||||
id="fixture-email"
|
|
||||||
type="email"
|
|
||||||
placeholder={copy.form.placeholder}
|
|
||||||
invalid
|
|
||||||
describedBy="fixture-email-help fixture-email-error"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<Checkbox label={copy.form.checkbox} />
|
|
||||||
<Switch label={copy.form.switch} />
|
|
||||||
<SegmentedControl
|
|
||||||
label="Density"
|
|
||||||
value={segment}
|
|
||||||
onChange={setSegment}
|
|
||||||
options={[
|
|
||||||
{ value: 'comfortable', label: 'Comfortable' },
|
|
||||||
{ value: 'compact', label: 'Compact' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
<section className={styles.group} aria-labelledby="lab-controls">
|
|
||||||
<h2 id="lab-controls">Selection and disclosure</h2>
|
|
||||||
<Tabs label={copy.tabs.label} items={copy.tabs.items} />
|
|
||||||
<Accordion items={copy.accordion.items} />
|
|
||||||
</section>
|
|
||||||
<Alert tone="warning" title={copy.alert.title}>
|
|
||||||
{copy.alert.body}
|
|
||||||
</Alert>
|
|
||||||
<Dialog
|
|
||||||
open={dialogOpen}
|
|
||||||
onOpenChange={setDialogOpen}
|
|
||||||
title={copy.dialog.title}
|
|
||||||
description={copy.dialog.description}
|
|
||||||
closeLabel={copy.dialog.close}
|
|
||||||
returnFocusRef={triggerRef}
|
|
||||||
>
|
|
||||||
<p>{copy.dialog.content}</p>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
.main {
|
|
||||||
min-block-size: 100dvh;
|
|
||||||
}
|
|
||||||
.interactiveGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: var(--space-6);
|
|
||||||
}
|
|
||||||
.group {
|
|
||||||
display: grid;
|
|
||||||
align-content: start;
|
|
||||||
gap: var(--space-4);
|
|
||||||
min-inline-size: 0;
|
|
||||||
padding: var(--space-6);
|
|
||||||
border: 1px solid var(--border-standard);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--surface-elevated);
|
|
||||||
}
|
|
||||||
.group h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--type-heading-3-size);
|
|
||||||
}
|
|
||||||
.cluster {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.interactiveGrid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives';
|
|
||||||
import { Comparison } from '@/components/marketing/Comparison';
|
|
||||||
import { CTA } from '@/components/marketing/CTA';
|
|
||||||
import { Metric } from '@/components/marketing/Evidence';
|
|
||||||
import { ProductPreview } from '@/components/marketing/ProductPreview';
|
|
||||||
import { SectionHeader } from '@/components/marketing/SectionHeader';
|
|
||||||
import { Workflow } from '@/components/marketing/Workflow';
|
|
||||||
import { getComponentLabCopy } from '@/content/development/component-lab';
|
|
||||||
import { isLocale, type Locale } from '@/lib/localization/config';
|
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import { ComponentLabInteractive } from './ComponentLabInteractive';
|
|
||||||
import styles from './page.module.css';
|
|
||||||
|
|
||||||
export const metadata = { robots: { index: false, follow: false } };
|
|
||||||
|
|
||||||
export default async function ComponentLabPage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ locale: string }>;
|
|
||||||
}) {
|
|
||||||
if (process.env.COMPONENT_FIXTURES_ENABLED !== 'true') notFound();
|
|
||||||
const { locale } = await params;
|
|
||||||
if (!isLocale(locale)) notFound();
|
|
||||||
const resolvedLocale: Locale = locale;
|
|
||||||
const copy = getComponentLabCopy(resolvedLocale);
|
|
||||||
return (
|
|
||||||
<main id="main-content" className={styles.main}>
|
|
||||||
<Section>
|
|
||||||
<Container>
|
|
||||||
<Stack gap="large">
|
|
||||||
<SectionHeader
|
|
||||||
eyebrow={copy.developmentLabel}
|
|
||||||
title={copy.title}
|
|
||||||
body={copy.body}
|
|
||||||
headingLevel={1}
|
|
||||||
/>
|
|
||||||
<ComponentLabInteractive copy={copy} />
|
|
||||||
<Workflow label={copy.title} items={copy.workflow} />
|
|
||||||
<Comparison
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
title: copy.comparison.beforeTitle,
|
|
||||||
items: copy.comparison.before,
|
|
||||||
tone: 'problem',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: copy.comparison.afterTitle,
|
|
||||||
items: copy.comparison.after,
|
|
||||||
tone: 'solution',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<ProductPreview
|
|
||||||
title={copy.preview.title}
|
|
||||||
caption={copy.preview.caption}
|
|
||||||
illustrativeLabel={copy.preview.illustrative}
|
|
||||||
rows={copy.preview.rows.map((row, index) => ({
|
|
||||||
...row,
|
|
||||||
statusTone: index === 0 ? 'success' : 'warning',
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
<Metric
|
|
||||||
content={{
|
|
||||||
value: copy.metric.value,
|
|
||||||
label: copy.metric.label,
|
|
||||||
qualification: copy.metric.qualification,
|
|
||||||
status: 'research-only',
|
|
||||||
}}
|
|
||||||
statusLabel={copy.metric.status}
|
|
||||||
/>
|
|
||||||
<CTA
|
|
||||||
title={copy.cta.title}
|
|
||||||
body={copy.cta.body}
|
|
||||||
primary={{ label: copy.cta.primary, disabledReason: copy.cta.unavailable }}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
</Section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { StateButton, StateActions, StateShell } from '@/components/app-shell/StateShell';
|
|
||||||
import { getClientShellMessages } from '@/lib/localization/client-messages';
|
|
||||||
import { useParams } from 'next/navigation';
|
|
||||||
|
|
||||||
export default function ErrorPage({
|
|
||||||
reset,
|
|
||||||
}: {
|
|
||||||
error: Error & { digest?: string };
|
|
||||||
reset: () => void;
|
|
||||||
}) {
|
|
||||||
const params = useParams<{ locale?: string }>();
|
|
||||||
const messages = getClientShellMessages(params?.locale);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StateShell title={messages.states.errorTitle} body={messages.states.errorBody}>
|
|
||||||
<StateActions>
|
|
||||||
<StateButton onClick={reset}>{messages.states.retry}</StateButton>
|
|
||||||
</StateActions>
|
|
||||||
</StateShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import '@fontsource-variable/inter/index.css';
|
|
||||||
import '@fontsource-variable/noto-sans-arabic/index.css';
|
|
||||||
import '@/styles/tokens.css';
|
|
||||||
import '@/styles/component-tokens.css';
|
|
||||||
import '@/styles/globals.css';
|
|
||||||
|
|
||||||
import { SiteFooter } from '@/components/app-shell/SiteFooter';
|
|
||||||
import { DemoDialogHost } from '@/components/integrations/DemoDialogHost';
|
|
||||||
import { SiteHeader } from '@/components/app-shell/SiteHeader';
|
|
||||||
import { ThemeController } from '@/components/app-shell/ThemeController';
|
|
||||||
import { getPublicIntegrationConfig } from '@/lib/integrations/environment';
|
|
||||||
import { getDirection, isLocale, type Locale } from '@/lib/localization/config';
|
|
||||||
import { getMessages } from '@/lib/localization/messages';
|
|
||||||
import { themeBootstrapScript } from '@/lib/theme/bootstrap-script';
|
|
||||||
import {
|
|
||||||
isThemePreference,
|
|
||||||
serverResolvedTheme,
|
|
||||||
themeCookie,
|
|
||||||
type ThemePreference,
|
|
||||||
} from '@/lib/theme/config';
|
|
||||||
import { cookies, headers } from 'next/headers';
|
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
|
||||||
const { locale } = await params;
|
|
||||||
return {
|
|
||||||
icons: {
|
|
||||||
icon: '/rentaldrivego.png',
|
|
||||||
apple: '/rentaldrivego.png',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
interface LocaleLayoutProps {
|
|
||||||
children: ReactNode;
|
|
||||||
params: Promise<{ locale: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
|
|
||||||
const { locale: localeValue } = await params;
|
|
||||||
if (!isLocale(localeValue)) notFound();
|
|
||||||
const locale: Locale = localeValue;
|
|
||||||
const messages = getMessages(locale);
|
|
||||||
const integrationConfig = getPublicIntegrationConfig();
|
|
||||||
const requestHeaders = await headers();
|
|
||||||
const nonce = requestHeaders.get('x-nonce') ?? undefined;
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const cookiePreference = cookieStore.get(themeCookie)?.value;
|
|
||||||
const preference: ThemePreference = isThemePreference(cookiePreference)
|
|
||||||
? cookiePreference
|
|
||||||
: 'system';
|
|
||||||
const resolvedTheme = serverResolvedTheme(preference);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<html
|
|
||||||
lang={locale}
|
|
||||||
dir={getDirection(locale)}
|
|
||||||
data-theme-preference={preference}
|
|
||||||
data-theme={resolvedTheme}
|
|
||||||
suppressHydrationWarning
|
|
||||||
>
|
|
||||||
<head>
|
|
||||||
<script
|
|
||||||
id="theme-bootstrap"
|
|
||||||
nonce={nonce}
|
|
||||||
suppressHydrationWarning
|
|
||||||
dangerouslySetInnerHTML={{ __html: themeBootstrapScript }}
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<ThemeController />
|
|
||||||
<a className="skip-link" href="#main-content">
|
|
||||||
{messages.shell.skipToContent}
|
|
||||||
</a>
|
|
||||||
<SiteHeader
|
|
||||||
locale={locale}
|
|
||||||
messages={messages.shell}
|
|
||||||
themePreference={preference}
|
|
||||||
demoEnabled={integrationConfig.demoSubmissionEnabled}
|
|
||||||
/>
|
|
||||||
{children}
|
|
||||||
<DemoDialogHost
|
|
||||||
locale={locale}
|
|
||||||
messages={messages.homepage.form}
|
|
||||||
enabled={integrationConfig.demoSubmissionEnabled}
|
|
||||||
/>
|
|
||||||
<SiteFooter
|
|
||||||
locale={locale}
|
|
||||||
messages={messages.shell}
|
|
||||||
demoEnabled={integrationConfig.demoSubmissionEnabled}
|
|
||||||
/>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { LocalizedLoading } from '@/components/app-shell/LocalizedLoading';
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <LocalizedLoading />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { LocalizedNotFound } from '@/components/app-shell/LocalizedNotFound';
|
|
||||||
|
|
||||||
export default function NotFound() {
|
|
||||||
return <LocalizedNotFound />;
|
|
||||||
}
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
import { ActionLink } from '@/components/actions/ActionLink';
|
|
||||||
import { Accordion } from '@/components/controls/Accordion';
|
|
||||||
import { Icon } from '@/components/foundation/Icon';
|
|
||||||
import { DemoTrigger } from '@/components/integrations/DemoTrigger';
|
|
||||||
import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives';
|
|
||||||
import { CTA } from '@/components/marketing/CTA';
|
|
||||||
import { Comparison } from '@/components/marketing/Comparison';
|
|
||||||
import { FeatureCard } from '@/components/marketing/FeatureCard';
|
|
||||||
import { ProductPreview } from '@/components/marketing/ProductPreview';
|
|
||||||
import { SectionHeader } from '@/components/marketing/SectionHeader';
|
|
||||||
import { FeatureGrid } from '@/components/marketing/SectionPatterns';
|
|
||||||
import { Workflow } from '@/components/marketing/Workflow';
|
|
||||||
import { Callout } from '@/components/surfaces/Callout';
|
|
||||||
import { Card } from '@/components/surfaces/Card';
|
|
||||||
import { Eyebrow, Heading, Text } from '@/components/typography/Typography';
|
|
||||||
import { buildHomepageContent } from '@/content/homepage-model';
|
|
||||||
import { getPublicIntegrationConfig } from '@/lib/integrations/environment';
|
|
||||||
import { isLocale, localizedPath, type Locale } from '@/lib/localization/config';
|
|
||||||
import { getMessages } from '@/lib/localization/messages';
|
|
||||||
import { buildLocalizedMetadata } from '@/lib/metadata/build-metadata';
|
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import styles from './page.module.css';
|
|
||||||
|
|
||||||
interface LocalePageProps {
|
|
||||||
params: Promise<{ locale: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: LocalePageProps): Promise<Metadata> {
|
|
||||||
const { locale: localeValue } = await params;
|
|
||||||
if (!isLocale(localeValue)) notFound();
|
|
||||||
const messages = getMessages(localeValue);
|
|
||||||
return buildLocalizedMetadata({
|
|
||||||
locale: localeValue,
|
|
||||||
routeId: 'home',
|
|
||||||
title: messages.homepage.meta.title,
|
|
||||||
description: messages.homepage.meta.description,
|
|
||||||
indexable: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function LocaleHomePage({ params }: LocalePageProps) {
|
|
||||||
const { locale: localeValue } = await params;
|
|
||||||
if (!isLocale(localeValue)) notFound();
|
|
||||||
const locale: Locale = localeValue;
|
|
||||||
const messages = getMessages(locale);
|
|
||||||
const content = buildHomepageContent(messages.homepage, messages.shell);
|
|
||||||
const integrationConfig = getPublicIntegrationConfig();
|
|
||||||
const homePath = localizedPath('home', locale);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main id="main-content" className={styles.main} tabIndex={-1}>
|
|
||||||
<Section id="product" className={styles.hero} aria-label={content.hero.title}>
|
|
||||||
<Container>
|
|
||||||
<div className={styles.heroGrid}>
|
|
||||||
<div className={styles.heroCopy}>
|
|
||||||
<Eyebrow>{content.hero.eyebrow}</Eyebrow>
|
|
||||||
<Heading level={1} appearance="display">
|
|
||||||
{content.hero.title}
|
|
||||||
</Heading>
|
|
||||||
<Text variant="lead">{content.hero.body}</Text>
|
|
||||||
<div className={styles.heroActions} role="group" aria-label={content.hero.eyebrow}>
|
|
||||||
<DemoTrigger
|
|
||||||
label={content.hero.primary.label}
|
|
||||||
source="hero"
|
|
||||||
size="large"
|
|
||||||
disabledReason={
|
|
||||||
integrationConfig.demoSubmissionEnabled
|
|
||||||
? undefined
|
|
||||||
: content.hero.primary.disabledReason
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ActionLink
|
|
||||||
href={homePath}
|
|
||||||
variant="button-primary"
|
|
||||||
disabledReason={content.hero.secondary.disabledReason}
|
|
||||||
>
|
|
||||||
{content.hero.secondary.label}
|
|
||||||
</ActionLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.previewStage}>
|
|
||||||
<ProductPreview {...content.hero.preview} headingLevel={2} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section className={styles.sectionBorder} aria-label={content.trust.title}>
|
|
||||||
<Container>
|
|
||||||
<Stack gap="large">
|
|
||||||
<SectionHeader
|
|
||||||
eyebrow={content.trust.eyebrow}
|
|
||||||
title={content.trust.title}
|
|
||||||
body={content.trust.body}
|
|
||||||
alignment="center"
|
|
||||||
/>
|
|
||||||
<FeatureGrid>
|
|
||||||
{content.trust.items.map((item) => (
|
|
||||||
<FeatureCard
|
|
||||||
key={item.id}
|
|
||||||
title={item.title}
|
|
||||||
description={item.description}
|
|
||||||
{...(item.icon ? { icon: item.icon } : {})}
|
|
||||||
emphasis="muted"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</FeatureGrid>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section tone="muted" aria-label={content.comparison.title}>
|
|
||||||
<Container>
|
|
||||||
<Stack gap="large">
|
|
||||||
<SectionHeader
|
|
||||||
eyebrow={content.comparison.eyebrow}
|
|
||||||
title={content.comparison.title}
|
|
||||||
body={content.comparison.body}
|
|
||||||
/>
|
|
||||||
<Comparison
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
title: content.comparison.beforeTitle,
|
|
||||||
items: content.comparison.beforeItems,
|
|
||||||
tone: 'problem',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: content.comparison.afterTitle,
|
|
||||||
items: content.comparison.afterItems,
|
|
||||||
tone: 'solution',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section id="workflow" aria-label={content.workflow.title}>
|
|
||||||
<Container>
|
|
||||||
<Stack gap="large">
|
|
||||||
<SectionHeader
|
|
||||||
eyebrow={content.workflow.eyebrow}
|
|
||||||
title={content.workflow.title}
|
|
||||||
body={content.workflow.body}
|
|
||||||
/>
|
|
||||||
<Workflow items={content.workflow.steps} label={content.workflow.title} />
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section tone="strong" aria-label={content.roles.title}>
|
|
||||||
<Container>
|
|
||||||
<Stack gap="large">
|
|
||||||
<SectionHeader
|
|
||||||
eyebrow={content.roles.eyebrow}
|
|
||||||
title={content.roles.title}
|
|
||||||
alignment="center"
|
|
||||||
/>
|
|
||||||
<FeatureGrid>
|
|
||||||
{content.roles.items.map((item) => (
|
|
||||||
<FeatureCard
|
|
||||||
key={item.id}
|
|
||||||
title={item.title}
|
|
||||||
description={item.description}
|
|
||||||
{...(item.icon ? { icon: item.icon } : {})}
|
|
||||||
emphasis="primary"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</FeatureGrid>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section id="modules" aria-label={content.modules.title}>
|
|
||||||
<Container>
|
|
||||||
<Stack gap="large">
|
|
||||||
<SectionHeader
|
|
||||||
eyebrow={content.modules.eyebrow}
|
|
||||||
title={content.modules.title}
|
|
||||||
body={content.modules.body}
|
|
||||||
/>
|
|
||||||
<FeatureGrid>
|
|
||||||
{content.modules.items.map((item) => (
|
|
||||||
<FeatureCard
|
|
||||||
key={item.id}
|
|
||||||
title={item.title}
|
|
||||||
description={item.description}
|
|
||||||
{...(item.icon ? { icon: item.icon } : {})}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</FeatureGrid>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section tone="muted" aria-label={content.results.title}>
|
|
||||||
<Container>
|
|
||||||
<Stack gap="large">
|
|
||||||
<SectionHeader
|
|
||||||
eyebrow={content.results.eyebrow}
|
|
||||||
title={content.results.title}
|
|
||||||
body={content.results.body}
|
|
||||||
/>
|
|
||||||
<FeatureGrid>
|
|
||||||
{content.results.metrics.map((metric) => (
|
|
||||||
<FeatureCard
|
|
||||||
key={metric.id}
|
|
||||||
title={metric.title}
|
|
||||||
description={metric.description}
|
|
||||||
{...(metric.icon ? { icon: metric.icon } : {})}
|
|
||||||
emphasis="muted"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</FeatureGrid>
|
|
||||||
<Callout icon="info" title={content.results.methodTitle}>
|
|
||||||
<ol className={styles.methodList}>
|
|
||||||
{content.results.method.map((item) => (
|
|
||||||
<li key={item}>{item}</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</Callout>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section aria-label={content.integrations.title}>
|
|
||||||
<Container>
|
|
||||||
<div className={styles.splitSection}>
|
|
||||||
<SectionHeader
|
|
||||||
eyebrow={content.integrations.eyebrow}
|
|
||||||
title={content.integrations.title}
|
|
||||||
body={content.integrations.body}
|
|
||||||
/>
|
|
||||||
<Card as="div" tone="elevated" padding="spacious">
|
|
||||||
<ul className={styles.capabilityList}>
|
|
||||||
{content.integrations.items.map((item) => (
|
|
||||||
<li key={item}>
|
|
||||||
<Icon name="plus" size="sm" />
|
|
||||||
<span>{item}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section tone="strong" aria-label={content.security.title}>
|
|
||||||
<Container>
|
|
||||||
<Stack gap="large">
|
|
||||||
<SectionHeader
|
|
||||||
eyebrow={content.security.eyebrow}
|
|
||||||
title={content.security.title}
|
|
||||||
body={content.security.body}
|
|
||||||
/>
|
|
||||||
<FeatureGrid>
|
|
||||||
{content.security.checks.map((check) => (
|
|
||||||
<FeatureCard
|
|
||||||
key={check.id}
|
|
||||||
title={check.title}
|
|
||||||
description={check.description}
|
|
||||||
{...(check.icon ? { icon: check.icon } : {})}
|
|
||||||
emphasis="primary"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</FeatureGrid>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section id="pricing" aria-label={content.pricing.title}>
|
|
||||||
<Container>
|
|
||||||
<div className={styles.pricingGrid}>
|
|
||||||
<div className={styles.pricingCopy}>
|
|
||||||
<SectionHeader
|
|
||||||
eyebrow={content.pricing.eyebrow}
|
|
||||||
title={content.pricing.title}
|
|
||||||
body={content.pricing.body}
|
|
||||||
/>
|
|
||||||
<ul className={styles.factorList}>
|
|
||||||
{content.pricing.factors.map((factor) => (
|
|
||||||
<li key={factor}>
|
|
||||||
<Icon name="check" size="sm" />
|
|
||||||
<span>{factor}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<aside>
|
|
||||||
<Card as="div" tone="elevated" padding="spacious" className={styles.pricingCard}>
|
|
||||||
<Icon name="info" size="lg" />
|
|
||||||
<Text variant="supporting">{content.pricing.note}</Text>
|
|
||||||
<ActionLink
|
|
||||||
href={homePath}
|
|
||||||
variant="button-conversion"
|
|
||||||
disabledReason={content.pricing.action.disabledReason}
|
|
||||||
>
|
|
||||||
{content.pricing.action.label}
|
|
||||||
</ActionLink>
|
|
||||||
</Card>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section id="faq" tone="muted" aria-label={content.faq.title}>
|
|
||||||
<Container width="content">
|
|
||||||
<Stack gap="large">
|
|
||||||
<SectionHeader
|
|
||||||
eyebrow={content.faq.eyebrow}
|
|
||||||
title={content.faq.title}
|
|
||||||
alignment="center"
|
|
||||||
/>
|
|
||||||
<Accordion
|
|
||||||
items={content.faq.items.map((item, index) => ({
|
|
||||||
id: item.id,
|
|
||||||
title: item.title,
|
|
||||||
content: <Text variant="supporting">{item.body}</Text>,
|
|
||||||
open: index === 0,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section className={styles.finalSection} aria-label={content.final.title}>
|
|
||||||
<Container>
|
|
||||||
<CTA
|
|
||||||
eyebrow={content.final.eyebrow}
|
|
||||||
title={content.final.title}
|
|
||||||
body={content.final.body}
|
|
||||||
primary={content.final.primary}
|
|
||||||
secondary={content.final.secondary}
|
|
||||||
primaryNode={
|
|
||||||
<DemoTrigger
|
|
||||||
label={content.final.primary.label}
|
|
||||||
source="final-cta"
|
|
||||||
size="large"
|
|
||||||
disabledReason={
|
|
||||||
integrationConfig.demoSubmissionEnabled
|
|
||||||
? undefined
|
|
||||||
: content.final.primary.disabledReason
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Container>
|
|
||||||
</Section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import {
|
|
||||||
demoSubmissionEnvelopeSchema,
|
|
||||||
zodFieldErrors,
|
|
||||||
type DemoSubmissionResult,
|
|
||||||
} from '@/lib/demo/schema';
|
|
||||||
import { submitDemoLead } from '@/lib/demo/service';
|
|
||||||
import { getIntegrationEnvironment } from '@/lib/integrations/environment';
|
|
||||||
import { isApplicationJson } from '@/lib/security/content-type';
|
|
||||||
import { NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
const maximumBodyBytes = 16_384;
|
|
||||||
const responseHeaders = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' };
|
|
||||||
|
|
||||||
function failure(
|
|
||||||
status: number,
|
|
||||||
result: Extract<DemoSubmissionResult, { ok: false }>,
|
|
||||||
): NextResponse<DemoSubmissionResult> {
|
|
||||||
return NextResponse.json(result, { status, headers: responseHeaders });
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAllowedOrigin(
|
|
||||||
request: Request,
|
|
||||||
approvedOrigin: string | undefined,
|
|
||||||
production: boolean,
|
|
||||||
) {
|
|
||||||
const origin = request.headers.get('origin');
|
|
||||||
if (!origin) return !production;
|
|
||||||
const requestOrigin = new URL(request.url).origin;
|
|
||||||
if (origin === requestOrigin) return true;
|
|
||||||
return approvedOrigin ? origin === new URL(approvedOrigin).origin : false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request): Promise<NextResponse<DemoSubmissionResult>> {
|
|
||||||
const correlationId = randomUUID();
|
|
||||||
let environment;
|
|
||||||
try {
|
|
||||||
environment = getIntegrationEnvironment();
|
|
||||||
} catch {
|
|
||||||
return failure(503, {
|
|
||||||
ok: false,
|
|
||||||
category: 'configuration',
|
|
||||||
formErrorCode: 'integration_environment_invalid',
|
|
||||||
retryable: false,
|
|
||||||
correlationId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.headers.get('x-rdg-form') !== 'demo-v1') {
|
|
||||||
return failure(403, {
|
|
||||||
ok: false,
|
|
||||||
category: 'validation',
|
|
||||||
formErrorCode: 'invalid_request_marker',
|
|
||||||
retryable: false,
|
|
||||||
correlationId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAllowedOrigin(request, environment.SITE_ORIGIN, environment.NODE_ENV === 'production')) {
|
|
||||||
return failure(403, {
|
|
||||||
ok: false,
|
|
||||||
category: 'validation',
|
|
||||||
formErrorCode: 'invalid_origin',
|
|
||||||
retryable: false,
|
|
||||||
correlationId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = request.headers.get('content-type');
|
|
||||||
if (!isApplicationJson(contentType)) {
|
|
||||||
return failure(415, {
|
|
||||||
ok: false,
|
|
||||||
category: 'validation',
|
|
||||||
formErrorCode: 'unsupported_content_type',
|
|
||||||
retryable: false,
|
|
||||||
correlationId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const declaredLength = Number(request.headers.get('content-length') ?? '0');
|
|
||||||
if (Number.isFinite(declaredLength) && declaredLength > maximumBodyBytes) {
|
|
||||||
return failure(413, {
|
|
||||||
ok: false,
|
|
||||||
category: 'validation',
|
|
||||||
formErrorCode: 'request_too_large',
|
|
||||||
retryable: false,
|
|
||||||
correlationId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let body: unknown;
|
|
||||||
try {
|
|
||||||
const raw = await request.text();
|
|
||||||
if (new TextEncoder().encode(raw).byteLength > maximumBodyBytes) {
|
|
||||||
return failure(413, {
|
|
||||||
ok: false,
|
|
||||||
category: 'validation',
|
|
||||||
formErrorCode: 'request_too_large',
|
|
||||||
retryable: false,
|
|
||||||
correlationId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
body = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return failure(400, {
|
|
||||||
ok: false,
|
|
||||||
category: 'validation',
|
|
||||||
formErrorCode: 'malformed_json',
|
|
||||||
retryable: false,
|
|
||||||
correlationId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = demoSubmissionEnvelopeSchema.safeParse(body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return failure(400, {
|
|
||||||
ok: false,
|
|
||||||
category: 'validation',
|
|
||||||
fieldErrors: zodFieldErrors(parsed.error),
|
|
||||||
formErrorCode: 'validation_failed',
|
|
||||||
retryable: false,
|
|
||||||
correlationId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.data.guard.honeypot.length > 0) {
|
|
||||||
return failure(400, {
|
|
||||||
ok: false,
|
|
||||||
category: 'abuse',
|
|
||||||
formErrorCode: 'request_rejected',
|
|
||||||
retryable: false,
|
|
||||||
correlationId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const scenarioHeader = request.headers.get('x-rdg-test-scenario');
|
|
||||||
const testScenario =
|
|
||||||
environment.NODE_ENV !== 'production' &&
|
|
||||||
environment.DEMO_TEST_SCENARIOS_ENABLED &&
|
|
||||||
['success', 'duplicate', 'timeout', 'integration-error'].includes(scenarioHeader ?? '')
|
|
||||||
? (scenarioHeader as 'success' | 'duplicate' | 'timeout' | 'integration-error')
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const result = await submitDemoLead(parsed.data.lead, {
|
|
||||||
environment,
|
|
||||||
...(testScenario ? { testScenario } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const status = result.ok
|
|
||||||
? 202
|
|
||||||
: result.category === 'timeout'
|
|
||||||
? 504
|
|
||||||
: result.category === 'configuration'
|
|
||||||
? 503
|
|
||||||
: result.category === 'duplicate'
|
|
||||||
? 409
|
|
||||||
: 502;
|
|
||||||
return NextResponse.json(result, { status, headers: responseHeaders });
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { MetadataRoute } from 'next';
|
|
||||||
import { getSiteOrigin } from '@/lib/localization/site-origin';
|
|
||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
|
||||||
const origin = getSiteOrigin();
|
|
||||||
const approved = process.env.PUBLIC_RELEASE_APPROVED === 'true';
|
|
||||||
return {
|
|
||||||
rules: approved ? { userAgent: '*', allow: '/' } : { userAgent: '*', disallow: '/' },
|
|
||||||
host: origin.href,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
.link {
|
|
||||||
display: inline-flex;
|
|
||||||
min-block-size: 2.75rem;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
font-weight: 650;
|
|
||||||
text-underline-offset: 0.18em;
|
|
||||||
}
|
|
||||||
.inline {
|
|
||||||
min-block-size: auto;
|
|
||||||
font-weight: inherit;
|
|
||||||
text-decoration-thickness: 0.08em;
|
|
||||||
}
|
|
||||||
.navigation {
|
|
||||||
color: var(--text-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.navigation:hover {
|
|
||||||
color: var(--link-hover);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
.standalone {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.standalone:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
.button-primary,
|
|
||||||
.button-conversion {
|
|
||||||
justify-content: center;
|
|
||||||
padding-block: var(--space-3);
|
|
||||||
padding-inline: var(--space-5);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-inverse);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.button-primary {
|
|
||||||
background: var(--interactive-primary);
|
|
||||||
}
|
|
||||||
.button-primary:hover {
|
|
||||||
background: var(--interactive-primary-hover);
|
|
||||||
color: var(--text-inverse);
|
|
||||||
}
|
|
||||||
.button-conversion {
|
|
||||||
background: var(--interactive-conversion);
|
|
||||||
}
|
|
||||||
.button-conversion:hover {
|
|
||||||
background: var(--interactive-conversion-hover);
|
|
||||||
color: var(--text-inverse);
|
|
||||||
}
|
|
||||||
.muted {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--type-label-size);
|
|
||||||
}
|
|
||||||
.disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
color: var(--control-disabled-text);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { Icon, type ApprovedIconName } from '@/components/foundation/Icon';
|
|
||||||
import { classNames } from '@/lib/components/classNames';
|
|
||||||
import type { AnchorHTMLAttributes, ReactNode } from 'react';
|
|
||||||
import styles from './ActionLink.module.css';
|
|
||||||
|
|
||||||
type LinkVariant =
|
|
||||||
| 'inline'
|
|
||||||
| 'navigation'
|
|
||||||
| 'standalone'
|
|
||||||
| 'button-primary'
|
|
||||||
| 'button-conversion'
|
|
||||||
| 'muted';
|
|
||||||
|
|
||||||
interface ActionLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
|
|
||||||
href: string;
|
|
||||||
children: ReactNode;
|
|
||||||
variant?: LinkVariant;
|
|
||||||
icon?: ApprovedIconName;
|
|
||||||
external?: boolean;
|
|
||||||
newTab?: boolean;
|
|
||||||
disabledReason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ActionLink({
|
|
||||||
href,
|
|
||||||
children,
|
|
||||||
variant = 'inline',
|
|
||||||
icon,
|
|
||||||
external = false,
|
|
||||||
newTab = false,
|
|
||||||
disabledReason,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ActionLinkProps) {
|
|
||||||
if (disabledReason) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={classNames(styles.link, styles[variant], styles.disabled, className)}
|
|
||||||
aria-disabled="true"
|
|
||||||
title={disabledReason}
|
|
||||||
>
|
|
||||||
<span>{children}</span>
|
|
||||||
<span className="visually-hidden">: {disabledReason}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const isExternal = external || /^https?:\/\//.test(href);
|
|
||||||
const trailingIcon = icon ?? (isExternal ? 'external' : undefined);
|
|
||||||
const content = (
|
|
||||||
<>
|
|
||||||
<span>{children}</span>
|
|
||||||
{trailingIcon ? (
|
|
||||||
<Icon
|
|
||||||
name={trailingIcon}
|
|
||||||
size="sm"
|
|
||||||
directionBehavior={
|
|
||||||
trailingIcon === 'chevron-forward' || trailingIcon === 'arrow-forward'
|
|
||||||
? 'mirror'
|
|
||||||
: 'fixed'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{newTab ? <span className="visually-hidden"> (opens in a new tab)</span> : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
const shared = {
|
|
||||||
className: classNames(styles.link, styles[variant], className),
|
|
||||||
...(newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {}),
|
|
||||||
...props,
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<a href={href} {...shared}>
|
|
||||||
{content}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
.button {
|
|
||||||
display: inline-flex;
|
|
||||||
min-block-size: 2.75rem;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 750;
|
|
||||||
line-height: 1.25;
|
|
||||||
text-align: center;
|
|
||||||
text-decoration: none;
|
|
||||||
transition:
|
|
||||||
background-color var(--duration-fast) var(--easing-productive),
|
|
||||||
border-color var(--duration-fast) var(--easing-productive),
|
|
||||||
color var(--duration-fast) var(--easing-productive),
|
|
||||||
transform var(--duration-fast) var(--easing-productive);
|
|
||||||
}
|
|
||||||
.button:active:not(:disabled) {
|
|
||||||
transform: translateY(1px);
|
|
||||||
}
|
|
||||||
.button:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: var(--opacity-disabled);
|
|
||||||
}
|
|
||||||
.small {
|
|
||||||
min-block-size: 2.5rem;
|
|
||||||
padding-block: var(--space-2);
|
|
||||||
padding-inline: var(--space-3);
|
|
||||||
font-size: var(--type-label-size);
|
|
||||||
}
|
|
||||||
.medium {
|
|
||||||
padding-block: var(--space-3);
|
|
||||||
padding-inline: var(--space-5);
|
|
||||||
}
|
|
||||||
.large {
|
|
||||||
min-block-size: 3.25rem;
|
|
||||||
padding-block: var(--space-3);
|
|
||||||
padding-inline: var(--space-6);
|
|
||||||
font-size: var(--type-body-large-size);
|
|
||||||
}
|
|
||||||
.primary {
|
|
||||||
background: var(--interactive-primary);
|
|
||||||
color: var(--text-inverse);
|
|
||||||
}
|
|
||||||
.primary:hover:not(:disabled) {
|
|
||||||
background: var(--interactive-primary-hover);
|
|
||||||
}
|
|
||||||
.conversion {
|
|
||||||
background: var(--interactive-conversion);
|
|
||||||
color: var(--text-inverse);
|
|
||||||
}
|
|
||||||
.conversion:hover:not(:disabled) {
|
|
||||||
background: var(--interactive-conversion-hover);
|
|
||||||
}
|
|
||||||
.secondary {
|
|
||||||
border-color: var(--interactive-primary);
|
|
||||||
background: var(--surface-primary);
|
|
||||||
color: var(--interactive-primary);
|
|
||||||
}
|
|
||||||
.secondary:hover:not(:disabled) {
|
|
||||||
background: var(--status-info-surface);
|
|
||||||
}
|
|
||||||
.tertiary {
|
|
||||||
border-color: var(--border-standard);
|
|
||||||
background: var(--surface-elevated);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
.tertiary:hover:not(:disabled) {
|
|
||||||
border-color: var(--border-strong);
|
|
||||||
background: var(--surface-muted);
|
|
||||||
}
|
|
||||||
.ghost {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
.ghost:hover:not(:disabled) {
|
|
||||||
background: var(--surface-muted);
|
|
||||||
}
|
|
||||||
.destructive {
|
|
||||||
background: var(--interactive-danger);
|
|
||||||
color: var(--text-inverse);
|
|
||||||
}
|
|
||||||
.destructive:hover:not(:disabled) {
|
|
||||||
filter: brightness(0.92);
|
|
||||||
}
|
|
||||||
.link {
|
|
||||||
min-block-size: 2.75rem;
|
|
||||||
padding-inline: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--link-default);
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 0.18em;
|
|
||||||
}
|
|
||||||
.link:hover:not(:disabled) {
|
|
||||||
color: var(--link-hover);
|
|
||||||
}
|
|
||||||
.iconOnly {
|
|
||||||
aspect-ratio: 1;
|
|
||||||
padding: var(--space-3);
|
|
||||||
}
|
|
||||||
.fullWidth {
|
|
||||||
inline-size: 100%;
|
|
||||||
}
|
|
||||||
.loading {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.spinner {
|
|
||||||
position: absolute;
|
|
||||||
animation: spin 0.9s linear infinite;
|
|
||||||
}
|
|
||||||
.loadingContent {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
.label {
|
|
||||||
min-inline-size: 0;
|
|
||||||
}
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.button {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
.spinner {
|
|
||||||
animation-duration: 1.8s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (forced-colors: active) {
|
|
||||||
.button {
|
|
||||||
border-color: ButtonText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Icon, type ApprovedIconName } from '@/components/foundation/Icon';
|
|
||||||
import { classNames } from '@/lib/components/classNames';
|
|
||||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
|
|
||||||
import styles from './Button.module.css';
|
|
||||||
|
|
||||||
export type ButtonIntent =
|
|
||||||
| 'primary'
|
|
||||||
| 'conversion'
|
|
||||||
| 'secondary'
|
|
||||||
| 'tertiary'
|
|
||||||
| 'ghost'
|
|
||||||
| 'destructive'
|
|
||||||
| 'link';
|
|
||||||
export type ButtonSize = 'small' | 'medium' | 'large';
|
|
||||||
|
|
||||||
interface CommonButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
|
||||||
intent?: ButtonIntent;
|
|
||||||
size?: ButtonSize;
|
|
||||||
loading?: boolean;
|
|
||||||
fullWidth?: boolean;
|
|
||||||
leadingIcon?: ApprovedIconName;
|
|
||||||
trailingIcon?: ApprovedIconName;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IconOnlyButtonProps extends Omit<
|
|
||||||
CommonButtonProps,
|
|
||||||
'children' | 'leadingIcon' | 'trailingIcon'
|
|
||||||
> {
|
|
||||||
icon: ApprovedIconName;
|
|
||||||
'aria-label': string;
|
|
||||||
children?: never;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ButtonProps = CommonButtonProps | IconOnlyButtonProps;
|
|
||||||
|
|
||||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(props, ref) {
|
|
||||||
const {
|
|
||||||
intent = 'primary',
|
|
||||||
size = 'medium',
|
|
||||||
loading = false,
|
|
||||||
fullWidth = false,
|
|
||||||
disabled = false,
|
|
||||||
className,
|
|
||||||
type = 'button',
|
|
||||||
...rest
|
|
||||||
} = props;
|
|
||||||
const iconOnly = 'icon' in props;
|
|
||||||
const content = iconOnly ? (
|
|
||||||
<Icon name={props.icon} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{props.leadingIcon ? <Icon name={props.leadingIcon} /> : null}
|
|
||||||
<span className={styles.label}>{props.children}</span>
|
|
||||||
{props.trailingIcon ? (
|
|
||||||
<Icon
|
|
||||||
name={props.trailingIcon}
|
|
||||||
directionBehavior={
|
|
||||||
props.trailingIcon === 'arrow-forward' || props.trailingIcon === 'chevron-forward'
|
|
||||||
? 'mirror'
|
|
||||||
: 'fixed'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const nativeProps = { ...rest } as ButtonHTMLAttributes<HTMLButtonElement>;
|
|
||||||
delete (nativeProps as Partial<CommonButtonProps>).leadingIcon;
|
|
||||||
delete (nativeProps as Partial<CommonButtonProps>).trailingIcon;
|
|
||||||
delete (nativeProps as Partial<IconOnlyButtonProps>).icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
ref={ref}
|
|
||||||
type={type}
|
|
||||||
className={classNames(
|
|
||||||
styles.button,
|
|
||||||
styles[intent],
|
|
||||||
styles[size],
|
|
||||||
iconOnly && styles.iconOnly,
|
|
||||||
fullWidth && styles.fullWidth,
|
|
||||||
loading && styles.loading,
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
aria-busy={loading || undefined}
|
|
||||||
data-loading={loading || undefined}
|
|
||||||
{...nativeProps}
|
|
||||||
>
|
|
||||||
{loading ? <Icon name="spinner" className={styles.spinner} /> : null}
|
|
||||||
<span className={loading ? styles.loadingContent : undefined}>{content}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { localizedPath, routeIdFromPathname, type Locale } from '@/lib/localization/config';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import styles from './SiteHeader.module.css';
|
|
||||||
|
|
||||||
export function BrandLink({ locale, label }: { locale: Locale; label: string }) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
className={styles.brand}
|
|
||||||
href={localizedPath('home', locale)}
|
|
||||||
aria-label={label}
|
|
||||||
aria-current={routeIdFromPathname(pathname) === 'home' ? 'page' : undefined}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src="/rentaldrivego.png"
|
|
||||||
alt=""
|
|
||||||
width={36}
|
|
||||||
height={36}
|
|
||||||
className={styles.mark}
|
|
||||||
priority
|
|
||||||
unoptimized
|
|
||||||
/>
|
|
||||||
<span className={styles.brandName}>RentalDriveGo</span>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
.field {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--type-caption-size);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select {
|
|
||||||
min-block-size: var(--size-touch-min);
|
|
||||||
min-inline-size: 7.25rem;
|
|
||||||
max-inline-size: 100%;
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding-block: var(--space-2);
|
|
||||||
padding-inline: var(--space-3) var(--space-8);
|
|
||||||
background: var(--surface-elevated);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select:hover {
|
|
||||||
border-color: var(--interactive-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.compactLabel {
|
|
||||||
position: absolute;
|
|
||||||
inline-size: 1px;
|
|
||||||
block-size: 1px;
|
|
||||||
margin: -1px;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 0;
|
|
||||||
clip-path: inset(50%);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { StatusBadge } from '@/components/foundation/StatusBadge';
|
|
||||||
import styles from './SiteHeader.module.css';
|
|
||||||
|
|
||||||
interface DisabledActionProps {
|
|
||||||
label: string;
|
|
||||||
reason: string;
|
|
||||||
pendingLabel?: string;
|
|
||||||
conversion?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DisabledAction({
|
|
||||||
label,
|
|
||||||
reason,
|
|
||||||
pendingLabel,
|
|
||||||
conversion = false,
|
|
||||||
}: DisabledActionProps) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={conversion ? styles.conversionDisabled : styles.actionDisabled}
|
|
||||||
role="link"
|
|
||||||
aria-disabled="true"
|
|
||||||
aria-label={`${label}. ${reason}`}
|
|
||||||
title={reason}
|
|
||||||
>
|
|
||||||
<span>{label}</span>
|
|
||||||
{pendingLabel ? <StatusBadge>{pendingLabel}</StatusBadge> : null}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { localizedPath, routeIdFromPathname, type Locale } from '@/lib/localization/config';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import styles from './SiteHeader.module.css';
|
|
||||||
|
|
||||||
export type HeaderNavId = 'product' | 'workflow' | 'modules' | 'pricing' | 'faq';
|
|
||||||
|
|
||||||
interface HeaderNavigationProps {
|
|
||||||
locale: Locale;
|
|
||||||
label: string;
|
|
||||||
labels: Record<HeaderNavId, string>;
|
|
||||||
onNavigate?: () => void;
|
|
||||||
mobile?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigationIds: HeaderNavId[] = ['product', 'workflow', 'modules', 'pricing', 'faq'];
|
|
||||||
|
|
||||||
export function HeaderNavigation({
|
|
||||||
locale,
|
|
||||||
label,
|
|
||||||
labels,
|
|
||||||
onNavigate,
|
|
||||||
mobile = false,
|
|
||||||
}: HeaderNavigationProps) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const currentRoute = routeIdFromPathname(pathname);
|
|
||||||
const [currentHash, setCurrentHash] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updateHash = () => setCurrentHash(window.location.hash.replace(/^#/, ''));
|
|
||||||
updateHash();
|
|
||||||
window.addEventListener('hashchange', updateHash);
|
|
||||||
return () => window.removeEventListener('hashchange', updateHash);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const home = localizedPath('home', locale);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav aria-label={label} className={mobile ? styles.drawerNavigation : styles.navigation}>
|
|
||||||
<ul>
|
|
||||||
{navigationIds.map((id) => (
|
|
||||||
<li key={id}>
|
|
||||||
<a
|
|
||||||
href={`${home}#${id}`}
|
|
||||||
aria-current={currentRoute === 'home' && currentHash === id ? 'location' : undefined}
|
|
||||||
onClick={onNavigate}
|
|
||||||
>
|
|
||||||
{labels[id]}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { persistLocale } from '@/lib/localization/client';
|
|
||||||
import {
|
|
||||||
localeSwitchUrl,
|
|
||||||
locales,
|
|
||||||
routeIdFromPathname,
|
|
||||||
type Locale,
|
|
||||||
} from '@/lib/localization/config';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import styles from './Controls.module.css';
|
|
||||||
|
|
||||||
interface LocaleSelectorProps {
|
|
||||||
currentLocale: Locale;
|
|
||||||
label: string;
|
|
||||||
localeNames: Record<Locale, string>;
|
|
||||||
compact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LocaleSelector({
|
|
||||||
currentLocale,
|
|
||||||
label,
|
|
||||||
localeNames,
|
|
||||||
compact = false,
|
|
||||||
}: LocaleSelectorProps) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const routeId = routeIdFromPathname(pathname) ?? 'home';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label className={styles.field}>
|
|
||||||
<span className={compact ? styles.compactLabel : styles.label}>{label}</span>
|
|
||||||
<select
|
|
||||||
className={styles.select}
|
|
||||||
aria-label={label}
|
|
||||||
value={currentLocale}
|
|
||||||
onChange={(event) => {
|
|
||||||
const targetLocale = event.target.value as Locale;
|
|
||||||
persistLocale(targetLocale);
|
|
||||||
window.location.assign(
|
|
||||||
localeSwitchUrl(new URL(window.location.href), targetLocale, routeId),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{locales.map((locale) => (
|
|
||||||
<option key={locale} value={locale}>
|
|
||||||
{localeNames[locale]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { getClientShellMessages } from '@/lib/localization/client-messages';
|
|
||||||
import { useParams } from 'next/navigation';
|
|
||||||
import { LoadingSkeleton, StateShell } from './StateShell';
|
|
||||||
|
|
||||||
export function LocalizedLoading() {
|
|
||||||
const params = useParams<{ locale?: string }>();
|
|
||||||
const messages = getClientShellMessages(params?.locale);
|
|
||||||
return (
|
|
||||||
<StateShell title={messages.states.loadingTitle} body={messages.states.loadingBody} status>
|
|
||||||
<LoadingSkeleton />
|
|
||||||
</StateShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { getClientShellMessages } from '@/lib/localization/client-messages';
|
|
||||||
import { isLocale, localizedPath } from '@/lib/localization/config';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import { StateAction, StateActions, StateShell } from './StateShell';
|
|
||||||
|
|
||||||
export function LocalizedNotFound() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const localeValue = pathname.split('/').filter(Boolean)[0];
|
|
||||||
const locale = isLocale(localeValue) ? localeValue : 'en';
|
|
||||||
const messages = getClientShellMessages(locale);
|
|
||||||
return (
|
|
||||||
<StateShell title={messages.states.notFoundTitle} body={messages.states.notFoundBody}>
|
|
||||||
<StateActions>
|
|
||||||
<StateAction href={localizedPath('home', locale)}>{messages.states.backHome}</StateAction>
|
|
||||||
</StateActions>
|
|
||||||
</StateShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ActionLink } from '@/components/actions/ActionLink';
|
|
||||||
import { DemoTrigger } from '@/components/integrations/DemoTrigger';
|
|
||||||
import type { ShellMessages } from '@/lib/localization/messages';
|
|
||||||
import { localizedPath, type Locale } from '@/lib/localization/config';
|
|
||||||
import type { ThemePreference } from '@/lib/theme/config';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { HeaderNavigation } from './HeaderNavigation';
|
|
||||||
import { LocaleSelector } from './LocaleSelector';
|
|
||||||
import styles from './SiteHeader.module.css';
|
|
||||||
import { ThemeSelector } from './ThemeSelector';
|
|
||||||
|
|
||||||
function signInUrl(locale: Locale): string {
|
|
||||||
return localizedPath('sign-in', locale);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MobileNavigationProps {
|
|
||||||
locale: Locale;
|
|
||||||
messages: ShellMessages;
|
|
||||||
themePreference: ThemePreference;
|
|
||||||
demoEnabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MobileNavigation({
|
|
||||||
locale,
|
|
||||||
messages,
|
|
||||||
themePreference,
|
|
||||||
demoEnabled,
|
|
||||||
}: MobileNavigationProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const closeRef = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const dialog = dialogRef.current;
|
|
||||||
if (!dialog) return;
|
|
||||||
|
|
||||||
if (open && !dialog.open) {
|
|
||||||
dialog.showModal();
|
|
||||||
document.body.dataset.navigationOpen = 'true';
|
|
||||||
closeRef.current?.focus();
|
|
||||||
} else if (!open && dialog.open) {
|
|
||||||
dialog.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
delete document.body.dataset.navigationOpen;
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
dialogRef.current?.close();
|
|
||||||
setOpen(false);
|
|
||||||
delete document.body.dataset.navigationOpen;
|
|
||||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.mobileNavigation}>
|
|
||||||
<button
|
|
||||||
ref={triggerRef}
|
|
||||||
type="button"
|
|
||||||
className={styles.menuButton}
|
|
||||||
aria-label={messages.header.menuOpen}
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
aria-expanded={open}
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
>
|
|
||||||
<span aria-hidden="true" className={styles.menuIcon}>
|
|
||||||
<span />
|
|
||||||
<span />
|
|
||||||
<span />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<dialog
|
|
||||||
ref={dialogRef}
|
|
||||||
className={styles.drawer}
|
|
||||||
aria-label={messages.header.navigationLabel}
|
|
||||||
onClose={() => {
|
|
||||||
setOpen(false);
|
|
||||||
delete document.body.dataset.navigationOpen;
|
|
||||||
}}
|
|
||||||
onCancel={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
close();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={styles.drawerPanel}>
|
|
||||||
<div className={styles.drawerHeader}>
|
|
||||||
<strong>RentalDriveGo</strong>
|
|
||||||
<button
|
|
||||||
ref={closeRef}
|
|
||||||
type="button"
|
|
||||||
className={styles.closeButton}
|
|
||||||
aria-label={messages.header.menuClose}
|
|
||||||
onClick={close}
|
|
||||||
>
|
|
||||||
<span aria-hidden="true">×</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<HeaderNavigation
|
|
||||||
locale={locale}
|
|
||||||
label={messages.header.navigationLabel}
|
|
||||||
labels={messages.header.nav}
|
|
||||||
mobile
|
|
||||||
onNavigate={close}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={styles.drawerControls}
|
|
||||||
role="group"
|
|
||||||
aria-label={messages.header.controlsLabel}
|
|
||||||
>
|
|
||||||
<LocaleSelector
|
|
||||||
currentLocale={locale}
|
|
||||||
label={messages.controls.language}
|
|
||||||
localeNames={messages.controls.localeNames}
|
|
||||||
/>
|
|
||||||
<ThemeSelector
|
|
||||||
initialPreference={themePreference}
|
|
||||||
label={messages.controls.theme}
|
|
||||||
statusTemplate={messages.controls.themeStatus}
|
|
||||||
optionLabels={messages.controls.themes}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.drawerActions}>
|
|
||||||
<ActionLink href={signInUrl(locale)} variant="button-primary">
|
|
||||||
{messages.header.login}
|
|
||||||
</ActionLink>
|
|
||||||
<DemoTrigger
|
|
||||||
label={messages.header.demo}
|
|
||||||
source="mobile-header"
|
|
||||||
fullWidth
|
|
||||||
disabledReason={demoEnabled ? undefined : messages.header.demoUnavailable}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
.footer {
|
|
||||||
border-block-start: 1px solid var(--border-standard);
|
|
||||||
background: var(--surface-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inner {
|
|
||||||
display: grid;
|
|
||||||
inline-size: min(100%, var(--container-wide));
|
|
||||||
margin-inline: auto;
|
|
||||||
padding-block: var(--space-12) var(--space-6);
|
|
||||||
padding-inline: var(--layout-gutter);
|
|
||||||
gap: var(--space-10);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brandColumn {
|
|
||||||
display: grid;
|
|
||||||
align-content: start;
|
|
||||||
gap: var(--space-4);
|
|
||||||
max-inline-size: 32rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mark {
|
|
||||||
display: inline-grid;
|
|
||||||
min-inline-size: var(--size-touch-min);
|
|
||||||
min-block-size: var(--size-touch-min);
|
|
||||||
padding-inline: var(--space-2);
|
|
||||||
place-items: center;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--interactive-primary);
|
|
||||||
color: var(--text-inverse);
|
|
||||||
font-size: var(--type-caption-size);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagline,
|
|
||||||
.releaseNotice {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.groups {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr));
|
|
||||||
gap: var(--space-8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.group h2 {
|
|
||||||
margin-block-end: var(--space-3);
|
|
||||||
font-size: var(--type-label-size);
|
|
||||||
letter-spacing: var(--tracking-label);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[dir='rtl'] .group h2 {
|
|
||||||
letter-spacing: normal;
|
|
||||||
text-transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group ul {
|
|
||||||
display: grid;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
gap: var(--space-2);
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group a,
|
|
||||||
.pendingItem {
|
|
||||||
display: inline-flex;
|
|
||||||
min-block-size: var(--size-touch-min);
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group a:hover {
|
|
||||||
color: var(--interactive-primary);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pendingItem {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
opacity: var(--opacity-disabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legalLink {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-4);
|
|
||||||
padding-block-start: var(--space-6);
|
|
||||||
border-block-start: 1px solid var(--border-standard);
|
|
||||||
color: var(--text-subdued);
|
|
||||||
font-size: var(--type-caption-size);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.inner {
|
|
||||||
grid-template-columns: minmax(14rem, 0.8fr) minmax(0, 2fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { StatusBadge } from '@/components/foundation/StatusBadge';
|
|
||||||
import { DemoTrigger } from '@/components/integrations/DemoTrigger';
|
|
||||||
import { localizedPath, type Locale } from '@/lib/localization/config';
|
|
||||||
import type { ShellMessages } from '@/lib/localization/messages';
|
|
||||||
import styles from './SiteFooter.module.css';
|
|
||||||
|
|
||||||
function signInUrl(locale: Locale): string {
|
|
||||||
return localizedPath('sign-in', locale);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SiteFooterProps {
|
|
||||||
locale: Locale;
|
|
||||||
messages: ShellMessages;
|
|
||||||
demoEnabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HashLink({ locale, hash, children }: { locale: Locale; hash: string; children: string }) {
|
|
||||||
return <a href={`${localizedPath('home', locale)}#${hash}`}>{children}</a>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PendingRouteLink({
|
|
||||||
locale,
|
|
||||||
routeId,
|
|
||||||
label,
|
|
||||||
pending,
|
|
||||||
}: {
|
|
||||||
locale: Locale;
|
|
||||||
routeId: 'sign-in' | 'forgot-password' | 'privacy' | 'terms' | 'accessibility';
|
|
||||||
label: string;
|
|
||||||
pending: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<a className={styles.legalLink} href={localizedPath(routeId, locale)}>
|
|
||||||
<span>{label}</span>
|
|
||||||
<StatusBadge>{pending}</StatusBadge>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PendingItem({ label, pending }: { label: string; pending: string }) {
|
|
||||||
return (
|
|
||||||
<span className={styles.pendingItem} aria-disabled="true">
|
|
||||||
<span>{label}</span>
|
|
||||||
<StatusBadge>{pending}</StatusBadge>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SiteFooter({ locale, messages, demoEnabled }: SiteFooterProps) {
|
|
||||||
const footer = messages.footer;
|
|
||||||
const year = new Date().getUTCFullYear();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className={styles.footer} aria-label={footer.landmarkLabel}>
|
|
||||||
<div className={styles.inner}>
|
|
||||||
<div className={styles.brandColumn}>
|
|
||||||
<div className={styles.brand}>
|
|
||||||
<span aria-hidden="true" className={styles.mark}>
|
|
||||||
RDG
|
|
||||||
</span>
|
|
||||||
<span>RentalDriveGo</span>
|
|
||||||
</div>
|
|
||||||
<p className={styles.tagline}>{footer.tagline}</p>
|
|
||||||
<p className={styles.releaseNotice}>{footer.releaseNotice}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.groups}>
|
|
||||||
<section className={styles.group} aria-labelledby="footer-product">
|
|
||||||
<h2 id="footer-product">{footer.groups.product}</h2>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<HashLink locale={locale} hash="workflow">
|
|
||||||
{footer.links.workflow}
|
|
||||||
</HashLink>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<HashLink locale={locale} hash="modules">
|
|
||||||
{footer.links.modules}
|
|
||||||
</HashLink>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<HashLink locale={locale} hash="pricing">
|
|
||||||
{footer.links.pricing}
|
|
||||||
</HashLink>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<HashLink locale={locale} hash="faq">
|
|
||||||
{footer.links.faq}
|
|
||||||
</HashLink>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className={styles.group} aria-labelledby="footer-company">
|
|
||||||
<h2 id="footer-company">{footer.groups.company}</h2>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<PendingItem label={footer.links.contact} pending={footer.pending} />
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<DemoTrigger
|
|
||||||
label={footer.links.demo}
|
|
||||||
source="footer"
|
|
||||||
size="small"
|
|
||||||
disabledReason={demoEnabled ? undefined : messages.header.demoUnavailable}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className={styles.group} aria-labelledby="footer-account">
|
|
||||||
<h2 id="footer-account">{footer.groups.account}</h2>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href={signInUrl(locale)}>
|
|
||||||
{footer.links.login}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className={styles.group} aria-labelledby="footer-legal">
|
|
||||||
<h2 id="footer-legal">{footer.groups.legal}</h2>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<PendingRouteLink
|
|
||||||
locale={locale}
|
|
||||||
routeId="privacy"
|
|
||||||
label={footer.links.privacy}
|
|
||||||
pending={footer.pending}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<PendingRouteLink
|
|
||||||
locale={locale}
|
|
||||||
routeId="terms"
|
|
||||||
label={footer.links.terms}
|
|
||||||
pending={footer.pending}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<PendingRouteLink
|
|
||||||
locale={locale}
|
|
||||||
routeId="accessibility"
|
|
||||||
label={footer.links.accessibility}
|
|
||||||
pending={footer.pending}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.bottom}>
|
|
||||||
<span>
|
|
||||||
{footer.copyrightPrefix} {year} RentalDriveGo
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
.header {
|
|
||||||
position: sticky;
|
|
||||||
z-index: var(--layer-sticky);
|
|
||||||
inset-block-start: 0;
|
|
||||||
border-block-end: 1px solid var(--border-standard);
|
|
||||||
background: color-mix(in srgb, var(--surface-primary) 94%, transparent);
|
|
||||||
box-shadow: var(--shadow-subtle);
|
|
||||||
backdrop-filter: blur(var(--space-3));
|
|
||||||
}
|
|
||||||
|
|
||||||
.inner {
|
|
||||||
display: grid;
|
|
||||||
min-block-size: var(--header-min-block-size);
|
|
||||||
inline-size: min(100%, var(--container-wide));
|
|
||||||
margin-inline: auto;
|
|
||||||
padding-inline: var(--layout-gutter);
|
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand {
|
|
||||||
display: inline-flex;
|
|
||||||
min-block-size: var(--size-touch-min);
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 800;
|
|
||||||
text-decoration: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mark {
|
|
||||||
inline-size: 36px;
|
|
||||||
block-size: 36px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
object-fit: cover;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.navigation ul,
|
|
||||||
.drawerNavigation ul {
|
|
||||||
display: flex;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navigation ul {
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.navigation a,
|
|
||||||
.drawerNavigation a {
|
|
||||||
display: inline-flex;
|
|
||||||
min-block-size: var(--size-touch-min);
|
|
||||||
align-items: center;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 650;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navigation a {
|
|
||||||
padding-inline: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.navigation a:hover,
|
|
||||||
.navigation a[aria-current],
|
|
||||||
.drawerNavigation a:hover,
|
|
||||||
.drawerNavigation a[aria-current] {
|
|
||||||
background: var(--surface-muted);
|
|
||||||
color: var(--interactive-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.desktopControls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionDisabled,
|
|
||||||
.conversionDisabled {
|
|
||||||
display: inline-flex;
|
|
||||||
min-block-size: var(--size-touch-min);
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding-block: var(--space-2);
|
|
||||||
padding-inline: var(--space-3);
|
|
||||||
background: var(--control-disabled-surface);
|
|
||||||
color: var(--control-disabled-text);
|
|
||||||
font-weight: 700;
|
|
||||||
opacity: var(--opacity-disabled);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionLink {
|
|
||||||
display: inline-flex;
|
|
||||||
min-block-size: var(--size-touch-min);
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding-block: var(--space-2);
|
|
||||||
padding-inline: var(--space-3);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionLink:hover {
|
|
||||||
background: var(--interactive-primary);
|
|
||||||
color: var(--text-inverse);
|
|
||||||
border-color: var(--interactive-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.conversionDisabled {
|
|
||||||
border-color: var(--interactive-conversion);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobileActions,
|
|
||||||
.mobileNavigation {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menuButton,
|
|
||||||
.closeButton {
|
|
||||||
display: inline-grid;
|
|
||||||
min-inline-size: var(--size-touch-min);
|
|
||||||
min-block-size: var(--size-touch-min);
|
|
||||||
place-items: center;
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--surface-elevated);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.menuIcon {
|
|
||||||
display: grid;
|
|
||||||
inline-size: var(--space-5);
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.menuIcon span {
|
|
||||||
display: block;
|
|
||||||
block-size: 2px;
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
background: currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer {
|
|
||||||
inline-size: min(88vw, 384px);
|
|
||||||
max-inline-size: none;
|
|
||||||
block-size: 100dvh;
|
|
||||||
max-block-size: none;
|
|
||||||
margin-block: 0;
|
|
||||||
margin-inline: 0 auto;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[dir='rtl'] .drawer {
|
|
||||||
margin-inline: auto 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer::backdrop {
|
|
||||||
background: var(--overlay-scrim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawerPanel {
|
|
||||||
display: flex;
|
|
||||||
block-size: 100%;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-6);
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: var(--space-5);
|
|
||||||
background: var(--surface-elevated);
|
|
||||||
box-shadow: var(--shadow-overlay);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawerHeader {
|
|
||||||
display: flex;
|
|
||||||
min-block-size: var(--size-touch-min);
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.closeButton {
|
|
||||||
font-size: var(--type-heading-3-size);
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawerNavigation ul {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawerNavigation a {
|
|
||||||
inline-size: 100%;
|
|
||||||
padding-inline: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawerControls,
|
|
||||||
.drawerActions {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawerActions {
|
|
||||||
margin-block-start: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1120px) and (max-width: 1279px) {
|
|
||||||
.brandName {
|
|
||||||
position: absolute;
|
|
||||||
inline-size: 1px;
|
|
||||||
block-size: 1px;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip-path: inset(50%);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1119px) {
|
|
||||||
.inner {
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desktopNavigation,
|
|
||||||
.desktopControls {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobileActions,
|
|
||||||
.mobileNavigation {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 559px) {
|
|
||||||
.brandName {
|
|
||||||
position: absolute;
|
|
||||||
inline-size: 1px;
|
|
||||||
block-size: 1px;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip-path: inset(50%);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobileActions > .conversionDisabled {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (forced-colors: active) {
|
|
||||||
.header {
|
|
||||||
backdrop-filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mark,
|
|
||||||
.actionDisabled,
|
|
||||||
.conversionDisabled,
|
|
||||||
.menuButton,
|
|
||||||
.closeButton {
|
|
||||||
border: 1px solid CanvasText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 559px) {
|
|
||||||
.mobileDemoTrigger {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { localizedPath, type Locale } from '@/lib/localization/config';
|
|
||||||
import type { ShellMessages } from '@/lib/localization/messages';
|
|
||||||
import type { ThemePreference } from '@/lib/theme/config';
|
|
||||||
import { ActionLink } from '@/components/actions/ActionLink';
|
|
||||||
import { DemoTrigger } from '@/components/integrations/DemoTrigger';
|
|
||||||
import { BrandLink } from './BrandLink';
|
|
||||||
import { HeaderNavigation } from './HeaderNavigation';
|
|
||||||
import { LocaleSelector } from './LocaleSelector';
|
|
||||||
import { MobileNavigation } from './MobileNavigation';
|
|
||||||
import styles from './SiteHeader.module.css';
|
|
||||||
import { ThemeSelector } from './ThemeSelector';
|
|
||||||
|
|
||||||
function signInUrl(locale: Locale): string {
|
|
||||||
return localizedPath('sign-in', locale);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SiteHeaderProps {
|
|
||||||
locale: Locale;
|
|
||||||
messages: ShellMessages;
|
|
||||||
themePreference: ThemePreference;
|
|
||||||
demoEnabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SiteHeader({ locale, messages, themePreference, demoEnabled }: SiteHeaderProps) {
|
|
||||||
return (
|
|
||||||
<header className={styles.header}>
|
|
||||||
<div className={styles.inner}>
|
|
||||||
<BrandLink locale={locale} label={messages.brandLabel} />
|
|
||||||
|
|
||||||
<div className={styles.desktopNavigation}>
|
|
||||||
<HeaderNavigation
|
|
||||||
locale={locale}
|
|
||||||
label={messages.header.navigationLabel}
|
|
||||||
labels={messages.header.nav}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={styles.desktopControls}
|
|
||||||
role="group"
|
|
||||||
aria-label={messages.header.controlsLabel}
|
|
||||||
>
|
|
||||||
<LocaleSelector
|
|
||||||
currentLocale={locale}
|
|
||||||
label={messages.controls.language}
|
|
||||||
localeNames={messages.controls.localeNames}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
<ThemeSelector
|
|
||||||
initialPreference={themePreference}
|
|
||||||
label={messages.controls.theme}
|
|
||||||
statusTemplate={messages.controls.themeStatus}
|
|
||||||
optionLabels={messages.controls.themes}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
<ActionLink href={signInUrl(locale)} variant="button-primary">
|
|
||||||
{messages.header.login}
|
|
||||||
</ActionLink>
|
|
||||||
<DemoTrigger
|
|
||||||
label={messages.header.demo}
|
|
||||||
source="header"
|
|
||||||
size="small"
|
|
||||||
disabledReason={demoEnabled ? undefined : messages.header.demoUnavailable}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.mobileActions}>
|
|
||||||
<DemoTrigger
|
|
||||||
label={messages.header.demo}
|
|
||||||
source="mobile-header"
|
|
||||||
size="small"
|
|
||||||
className={styles.mobileDemoTrigger}
|
|
||||||
disabledReason={demoEnabled ? undefined : messages.header.demoUnavailable}
|
|
||||||
/>
|
|
||||||
<MobileNavigation
|
|
||||||
locale={locale}
|
|
||||||
messages={messages}
|
|
||||||
themePreference={themePreference}
|
|
||||||
demoEnabled={demoEnabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
.main {
|
|
||||||
display: grid;
|
|
||||||
min-block-size: calc(100dvh - var(--header-min-block-size));
|
|
||||||
place-items: center;
|
|
||||||
padding-block: var(--section-space);
|
|
||||||
padding-inline: var(--layout-gutter);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel {
|
|
||||||
display: grid;
|
|
||||||
inline-size: min(100%, var(--container-reading));
|
|
||||||
gap: var(--space-5);
|
|
||||||
padding: clamp(var(--space-6), 6vw, var(--space-12));
|
|
||||||
border: 1px solid var(--border-standard);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
background: var(--surface-elevated);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
text-align: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--type-heading-1-size);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--type-body-large-size);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action {
|
|
||||||
display: inline-flex;
|
|
||||||
min-block-size: var(--size-touch-min);
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: 1px solid var(--interactive-primary);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding-block: var(--space-2);
|
|
||||||
padding-inline: var(--space-4);
|
|
||||||
background: var(--interactive-primary);
|
|
||||||
color: var(--text-inverse);
|
|
||||||
font-weight: 750;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action:hover {
|
|
||||||
background: var(--interactive-primary-hover);
|
|
||||||
color: var(--text-inverse);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton span {
|
|
||||||
display: block;
|
|
||||||
block-size: var(--space-4);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
var(--skeleton-base),
|
|
||||||
var(--skeleton-highlight),
|
|
||||||
var(--skeleton-base)
|
|
||||||
);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: skeleton-shift 1.4s var(--easing-standard) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton span:first-child {
|
|
||||||
inline-size: 64%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton span:last-child {
|
|
||||||
inline-size: 82%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes skeleton-shift {
|
|
||||||
from {
|
|
||||||
background-position: 100% 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
background-position: -100% 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.skeleton span {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
|
|
||||||
import styles from './StateShell.module.css';
|
|
||||||
|
|
||||||
interface StateShellProps {
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
children?: ReactNode;
|
|
||||||
status?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StateShell({ title, body, children, status = false }: StateShellProps) {
|
|
||||||
return (
|
|
||||||
<main id="main-content" className={styles.main} tabIndex={-1}>
|
|
||||||
<section
|
|
||||||
className={styles.panel}
|
|
||||||
role={status ? 'status' : undefined}
|
|
||||||
aria-live={status ? 'polite' : undefined}
|
|
||||||
>
|
|
||||||
<h1>{title}</h1>
|
|
||||||
<p>{body}</p>
|
|
||||||
{children ? <div className={styles.content}>{children}</div> : null}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StateActions({ children }: { children: ReactNode }) {
|
|
||||||
return <div className={styles.actions}>{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StateAction({ children, ...props }: ComponentPropsWithoutRef<'a'>) {
|
|
||||||
return (
|
|
||||||
<a className={styles.action} {...props}>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StateButton({ children, ...props }: ComponentPropsWithoutRef<'button'>) {
|
|
||||||
return (
|
|
||||||
<button className={styles.action} type="button" {...props}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LoadingSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className={styles.skeleton} aria-hidden="true">
|
|
||||||
<span />
|
|
||||||
<span />
|
|
||||||
<span />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { applyTheme, currentThemePreference } from '@/lib/theme/client';
|
|
||||||
import { themeStorageKey } from '@/lib/theme/config';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
export function ThemeController() {
|
|
||||||
useEffect(() => {
|
|
||||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
const handleSystemChange = () => {
|
|
||||||
if (currentThemePreference() === 'system') applyTheme('system');
|
|
||||||
};
|
|
||||||
const handleStorage = (event: StorageEvent) => {
|
|
||||||
if (event.key === themeStorageKey && event.newValue) {
|
|
||||||
const preference = event.newValue;
|
|
||||||
if (preference === 'light' || preference === 'dark' || preference === 'system') {
|
|
||||||
applyTheme(preference);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
media.addEventListener('change', handleSystemChange);
|
|
||||||
window.addEventListener('storage', handleStorage);
|
|
||||||
return () => {
|
|
||||||
media.removeEventListener('change', handleSystemChange);
|
|
||||||
window.removeEventListener('storage', handleStorage);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { persistTheme, themePreferenceEvent } from '@/lib/theme/client';
|
|
||||||
import { themePreferences, themeStorageKey, type ThemePreference } from '@/lib/theme/config';
|
|
||||||
import { useEffect, useId, useState } from 'react';
|
|
||||||
import styles from './Controls.module.css';
|
|
||||||
|
|
||||||
interface ThemeSelectorProps {
|
|
||||||
initialPreference: ThemePreference;
|
|
||||||
label: string;
|
|
||||||
statusTemplate: string;
|
|
||||||
optionLabels: Record<ThemePreference, string>;
|
|
||||||
compact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThemeSelector({
|
|
||||||
initialPreference,
|
|
||||||
label,
|
|
||||||
statusTemplate,
|
|
||||||
optionLabels,
|
|
||||||
compact = false,
|
|
||||||
}: ThemeSelectorProps) {
|
|
||||||
const [preference, setPreference] = useState<ThemePreference>(initialPreference);
|
|
||||||
const statusId = useId();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleStorage = (event: StorageEvent) => {
|
|
||||||
if (
|
|
||||||
event.key === themeStorageKey &&
|
|
||||||
(event.newValue === 'light' || event.newValue === 'dark' || event.newValue === 'system')
|
|
||||||
) {
|
|
||||||
setPreference(event.newValue);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handlePreference = (event: Event) => {
|
|
||||||
const preferenceEvent = event as CustomEvent<ThemePreference>;
|
|
||||||
setPreference(preferenceEvent.detail);
|
|
||||||
};
|
|
||||||
window.addEventListener('storage', handleStorage);
|
|
||||||
window.addEventListener(themePreferenceEvent, handlePreference);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('storage', handleStorage);
|
|
||||||
window.removeEventListener(themePreferenceEvent, handlePreference);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const status = statusTemplate.replace('{value}', optionLabels[preference]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label className={styles.field}>
|
|
||||||
<span className={compact ? styles.compactLabel : styles.label}>{label}</span>
|
|
||||||
<select
|
|
||||||
className={styles.select}
|
|
||||||
aria-label={label}
|
|
||||||
aria-describedby={statusId}
|
|
||||||
value={preference}
|
|
||||||
onChange={(event) => {
|
|
||||||
const nextPreference = event.target.value as ThemePreference;
|
|
||||||
setPreference(nextPreference);
|
|
||||||
persistTheme(nextPreference);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{themePreferences.map((option) => (
|
|
||||||
<option key={option} value={option}>
|
|
||||||
{optionLabels[option]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<span id={statusId} className="visually-hidden" aria-live="polite">
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { localizedPath, type Locale } from '@/lib/localization/config'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
interface AuthShellProps {
|
|
||||||
locale: Locale
|
|
||||||
title: string
|
|
||||||
subtitle?: string
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthShell({ locale, title, subtitle, children }: AuthShellProps) {
|
|
||||||
return (
|
|
||||||
<main
|
|
||||||
id="main-content"
|
|
||||||
tabIndex={-1}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: '2rem 1rem',
|
|
||||||
minHeight: 'calc(100vh - 80px)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ width: '100%', maxWidth: '28rem' }}>
|
|
||||||
<div style={{ marginBottom: '2rem', textAlign: 'center' }}>
|
|
||||||
<a href={localizedPath('home', locale)}>
|
|
||||||
<Image
|
|
||||||
src="/rentaldrivego.png"
|
|
||||||
alt="RentalDriveGo"
|
|
||||||
width={96}
|
|
||||||
height={96}
|
|
||||||
priority
|
|
||||||
unoptimized
|
|
||||||
style={{
|
|
||||||
borderRadius: '1.5rem',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
padding: '0.375rem',
|
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
marginTop: '1rem',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.28em',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
RentalDriveGo
|
|
||||||
</p>
|
|
||||||
<h1
|
|
||||||
style={{
|
|
||||||
marginTop: '0.5rem',
|
|
||||||
fontSize: '1.75rem',
|
|
||||||
fontWeight: 800,
|
|
||||||
letterSpacing: '-0.03em',
|
|
||||||
color: 'var(--color-heading)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
{subtitle ? (
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
marginTop: '0.5rem',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{subtitle}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section
|
|
||||||
style={{
|
|
||||||
borderRadius: '2rem',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
padding: '2rem',
|
|
||||||
boxShadow: '0 30px 80px rgba(28,25,23,0.06)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Button } from '@/components/actions/Button'
|
|
||||||
import { TextInput } from '@/components/forms/TextInput'
|
|
||||||
import { FormField } from '@/components/forms/FormField'
|
|
||||||
import { AuthShell } from '@/components/auth/AuthShell'
|
|
||||||
import { API_BASE } from '@/lib/api'
|
|
||||||
import { localizedPath, type Locale } from '@/lib/localization/config'
|
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
interface Dict {
|
|
||||||
title: string
|
|
||||||
subtitle: string
|
|
||||||
email: string
|
|
||||||
emailPlaceholder: string
|
|
||||||
submit: string
|
|
||||||
submitting: string
|
|
||||||
backToLogin: string
|
|
||||||
successTitle: string
|
|
||||||
successBody: string
|
|
||||||
error: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const dicts: Record<string, Dict> = {
|
|
||||||
en: {
|
|
||||||
title: 'Forgot your password?',
|
|
||||||
subtitle: "Enter your work email and we'll send you a reset link.",
|
|
||||||
email: 'Email',
|
|
||||||
emailPlaceholder: 'owner@company.com',
|
|
||||||
submit: 'Send reset link',
|
|
||||||
submitting: 'Sending…',
|
|
||||||
backToLogin: 'Back to sign in',
|
|
||||||
successTitle: 'Check your email',
|
|
||||||
successBody: 'If that email is registered, a reset link has been sent. It expires in 60 minutes.',
|
|
||||||
error: 'Something went wrong. Please try again.',
|
|
||||||
},
|
|
||||||
fr: {
|
|
||||||
title: 'Mot de passe oublié ?',
|
|
||||||
subtitle: "Entrez votre email professionnel et nous vous enverrons un lien de réinitialisation.",
|
|
||||||
email: 'Email',
|
|
||||||
emailPlaceholder: 'owner@company.com',
|
|
||||||
submit: 'Envoyer le lien',
|
|
||||||
submitting: 'Envoi…',
|
|
||||||
backToLogin: 'Retour à la connexion',
|
|
||||||
successTitle: 'Vérifiez votre email',
|
|
||||||
successBody: "Si cet email est enregistré, un lien de réinitialisation a été envoyé. Il expire dans 60 minutes.",
|
|
||||||
error: 'Une erreur est survenue. Veuillez réessayer.',
|
|
||||||
},
|
|
||||||
ar: {
|
|
||||||
title: 'نسيت كلمة المرور؟',
|
|
||||||
subtitle: 'أدخل بريدك الإلكتروني وسنرسل لك رابط إعادة التعيين.',
|
|
||||||
email: 'البريد الإلكتروني',
|
|
||||||
emailPlaceholder: 'owner@company.com',
|
|
||||||
submit: 'إرسال رابط إعادة التعيين',
|
|
||||||
submitting: 'جارٍ الإرسال…',
|
|
||||||
backToLogin: 'العودة إلى تسجيل الدخول',
|
|
||||||
successTitle: 'تحقق من بريدك الإلكتروني',
|
|
||||||
successBody: 'إذا كان البريد الإلكتروني مسجلاً، فقد تم إرسال رابط إعادة التعيين. تنتهي صلاحيته خلال 60 دقيقة.',
|
|
||||||
error: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ForgotPasswordForm({ locale }: { locale: Locale }) {
|
|
||||||
const dict = (dicts[locale] ?? dicts.en) as Dict
|
|
||||||
const [email, setEmail] = useState('')
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [sent, setSent] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const [empRes, adminRes] = await Promise.all([
|
|
||||||
fetch(`${API_BASE}/auth/employee/forgot-password`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email }),
|
|
||||||
}),
|
|
||||||
fetch(`${API_BASE}/admin/auth/forgot-password`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email }),
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
if (!empRes.ok && !adminRes.ok) throw new Error()
|
|
||||||
setSent(true)
|
|
||||||
} catch {
|
|
||||||
setError(dict.error)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const signInHref = localizedPath('sign-in', locale)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthShell locale={locale} title={dict.title} subtitle={dict.subtitle}>
|
|
||||||
{sent ? (
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
margin: '0 auto 1rem',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '3.5rem',
|
|
||||||
height: '3.5rem',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: 'var(--color-success-bg, #ecfdf5)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
style={{ width: '1.75rem', height: '1.75rem', color: 'var(--color-success-text, #059669)' }}
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-heading)' }}>
|
|
||||||
{dict.successTitle}
|
|
||||||
</h2>
|
|
||||||
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: 'var(--color-text-secondary)' }}>
|
|
||||||
{dict.successBody}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
|
||||||
{error ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: '1.5rem',
|
|
||||||
border: '1px solid var(--color-error-border, #fecaca)',
|
|
||||||
background: 'var(--color-error-bg, #fef2f2)',
|
|
||||||
padding: '0.75rem 1rem',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
color: 'var(--color-error-text, #b91c1c)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<FormField id="forgot-email" label={dict.email} required>
|
|
||||||
<TextInput
|
|
||||||
id="forgot-email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder={dict.emailPlaceholder}
|
|
||||||
autoComplete="email"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<Button type="submit" intent="primary" fullWidth loading={loading} disabled={loading}>
|
|
||||||
{loading ? dict.submitting : dict.submit}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: '1.5rem',
|
|
||||||
borderTop: '1px solid var(--color-border)',
|
|
||||||
paddingTop: '1.5rem',
|
|
||||||
textAlign: 'center',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={signInHref}
|
|
||||||
style={{
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--color-heading)',
|
|
||||||
textDecoration: 'underline',
|
|
||||||
textUnderlineOffset: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dict.backToLogin}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</AuthShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Button } from '@/components/actions/Button'
|
|
||||||
import { FormField } from '@/components/forms/FormField'
|
|
||||||
import { AuthShell } from '@/components/auth/AuthShell'
|
|
||||||
import { API_BASE } from '@/lib/api'
|
|
||||||
import { localizedPath, type Locale } from '@/lib/localization/config'
|
|
||||||
import { useSearchParams } from 'next/navigation'
|
|
||||||
import { Suspense, useState } from 'react'
|
|
||||||
|
|
||||||
interface Dict {
|
|
||||||
title: string
|
|
||||||
subtitle: string
|
|
||||||
newPassword: string
|
|
||||||
confirmPassword: string
|
|
||||||
submit: string
|
|
||||||
submitting: string
|
|
||||||
backToLogin: string
|
|
||||||
successTitle: string
|
|
||||||
successBody: string
|
|
||||||
signIn: string
|
|
||||||
errorMismatch: string
|
|
||||||
errorShort: string
|
|
||||||
errorInvalidToken: string
|
|
||||||
errorGeneric: string
|
|
||||||
noToken: string
|
|
||||||
requestNew: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const dicts: Record<string, Dict> = {
|
|
||||||
en: {
|
|
||||||
title: 'Set new password',
|
|
||||||
subtitle: 'Choose a strong password for your account.',
|
|
||||||
newPassword: 'New password',
|
|
||||||
confirmPassword: 'Confirm password',
|
|
||||||
submit: 'Reset password',
|
|
||||||
submitting: 'Resetting…',
|
|
||||||
backToLogin: 'Back to sign in',
|
|
||||||
successTitle: 'Password updated',
|
|
||||||
successBody: 'Your password has been reset. You can now sign in with your new password.',
|
|
||||||
signIn: 'Sign in',
|
|
||||||
errorMismatch: 'Passwords do not match.',
|
|
||||||
errorShort: 'Password must be at least 8 characters.',
|
|
||||||
errorInvalidToken: 'This reset link is invalid or has expired. Please request a new one.',
|
|
||||||
errorGeneric: 'Something went wrong. Please try again.',
|
|
||||||
noToken: 'No reset token found. Please request a new password reset.',
|
|
||||||
requestNew: 'Request new reset link',
|
|
||||||
},
|
|
||||||
fr: {
|
|
||||||
title: 'Nouveau mot de passe',
|
|
||||||
subtitle: 'Choisissez un mot de passe fort pour votre compte.',
|
|
||||||
newPassword: 'Nouveau mot de passe',
|
|
||||||
confirmPassword: 'Confirmer le mot de passe',
|
|
||||||
submit: 'Réinitialiser',
|
|
||||||
submitting: 'Traitement…',
|
|
||||||
backToLogin: 'Retour à la connexion',
|
|
||||||
successTitle: 'Mot de passe mis à jour',
|
|
||||||
successBody: 'Votre mot de passe a été réinitialisé. Vous pouvez maintenant vous connecter.',
|
|
||||||
signIn: 'Se connecter',
|
|
||||||
errorMismatch: 'Les mots de passe ne correspondent pas.',
|
|
||||||
errorShort: 'Le mot de passe doit contenir au moins 8 caractères.',
|
|
||||||
errorInvalidToken: 'Ce lien est invalide ou a expiré. Veuillez en demander un nouveau.',
|
|
||||||
errorGeneric: 'Une erreur est survenue. Veuillez réessayer.',
|
|
||||||
noToken: 'Aucun jeton de réinitialisation trouvé. Veuillez demander un nouveau lien.',
|
|
||||||
requestNew: 'Demander un nouveau lien',
|
|
||||||
},
|
|
||||||
ar: {
|
|
||||||
title: 'تعيين كلمة مرور جديدة',
|
|
||||||
subtitle: 'اختر كلمة مرور قوية لحسابك.',
|
|
||||||
newPassword: 'كلمة المرور الجديدة',
|
|
||||||
confirmPassword: 'تأكيد كلمة المرور',
|
|
||||||
submit: 'إعادة تعيين',
|
|
||||||
submitting: 'جارٍ المعالجة…',
|
|
||||||
backToLogin: 'العودة إلى تسجيل الدخول',
|
|
||||||
successTitle: 'تم تحديث كلمة المرور',
|
|
||||||
successBody: 'تم إعادة تعيين كلمة المرور. يمكنك الآن تسجيل الدخول.',
|
|
||||||
signIn: 'تسجيل الدخول',
|
|
||||||
errorMismatch: 'كلمتا المرور غير متطابقتين.',
|
|
||||||
errorShort: 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل.',
|
|
||||||
errorInvalidToken: 'رابط إعادة التعيين غير صالح أو منتهي الصلاحية.',
|
|
||||||
errorGeneric: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
|
||||||
noToken: 'لم يتم العثور على رمز إعادة التعيين. يرجى طلب رابط جديد.',
|
|
||||||
requestNew: 'طلب رابط جديد',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResetPasswordContent({ locale }: { locale: Locale }) {
|
|
||||||
const dict = (dicts[locale] ?? dicts.en) as Dict
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const token = searchParams.get('token')
|
|
||||||
|
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const [confirm, setConfirm] = useState('')
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [done, setDone] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (password.length < 8) { setError(dict.errorShort); return }
|
|
||||||
if (password !== confirm) { setError(dict.errorMismatch); return }
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
// Try employee endpoint first, fall back to admin
|
|
||||||
let res = await fetch(`${API_BASE}/auth/employee/reset-password`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token, password }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
res = await fetch(`${API_BASE}/admin/auth/reset-password`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token, password }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await res.json()
|
|
||||||
if (!res.ok) {
|
|
||||||
if (json?.error === 'invalid_token') throw new Error(dict.errorInvalidToken)
|
|
||||||
throw new Error(dict.errorGeneric)
|
|
||||||
}
|
|
||||||
setDone(true)
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message ?? dict.errorGeneric)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const signInHref = localizedPath('sign-in', locale)
|
|
||||||
const forgotHref = localizedPath('forgot-password', locale)
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return (
|
|
||||||
<AuthShell locale={locale} title={dict.title}>
|
|
||||||
<p style={{ textAlign: 'center', fontSize: '0.875rem', color: 'var(--color-text-secondary)' }}>
|
|
||||||
{dict.noToken}
|
|
||||||
</p>
|
|
||||||
<div style={{ marginTop: '1rem', textAlign: 'center' }}>
|
|
||||||
<a href={forgotHref}>
|
|
||||||
<Button intent="primary">{dict.requestNew}</Button>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</AuthShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (done) {
|
|
||||||
return (
|
|
||||||
<AuthShell locale={locale} title={dict.title}>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
margin: '0 auto 1rem',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '3.5rem',
|
|
||||||
height: '3.5rem',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: 'var(--color-success-bg, #ecfdf5)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
style={{ width: '1.75rem', height: '1.75rem', color: 'var(--color-success-text, #059669)' }}
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-heading)' }}>
|
|
||||||
{dict.successTitle}
|
|
||||||
</h2>
|
|
||||||
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: 'var(--color-text-secondary)' }}>
|
|
||||||
{dict.successBody}
|
|
||||||
</p>
|
|
||||||
<div style={{ marginTop: '1rem' }}>
|
|
||||||
<a href={signInHref}>
|
|
||||||
<Button intent="primary" fullWidth>{dict.signIn}</Button>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AuthShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthShell locale={locale} title={dict.title} subtitle={dict.subtitle}>
|
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
|
||||||
{error ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: '1.5rem',
|
|
||||||
border: '1px solid var(--color-error-border, #fecaca)',
|
|
||||||
background: 'var(--color-error-bg, #fef2f2)',
|
|
||||||
padding: '0.75rem 1rem',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
color: 'var(--color-error-text, #b91c1c)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<FormField id="reset-password" label={dict.newPassword} required>
|
|
||||||
<input
|
|
||||||
id="reset-password"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="••••••••"
|
|
||||||
autoComplete="new-password"
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
minHeight: '2.875rem',
|
|
||||||
padding: 'var(--space-3) var(--space-4)',
|
|
||||||
border: '1px solid var(--border-strong)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
background: 'var(--surface-primary)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField id="reset-confirm" label={dict.confirmPassword} required>
|
|
||||||
<input
|
|
||||||
id="reset-confirm"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
value={confirm}
|
|
||||||
onChange={(e) => setConfirm(e.target.value)}
|
|
||||||
placeholder="••••••••"
|
|
||||||
autoComplete="new-password"
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
minHeight: '2.875rem',
|
|
||||||
padding: 'var(--space-3) var(--space-4)',
|
|
||||||
border: '1px solid var(--border-strong)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
background: 'var(--surface-primary)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<Button type="submit" intent="primary" fullWidth loading={loading} disabled={loading}>
|
|
||||||
{loading ? dict.submitting : dict.submit}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: '1.5rem',
|
|
||||||
borderTop: '1px solid var(--color-border)',
|
|
||||||
paddingTop: '1.5rem',
|
|
||||||
textAlign: 'center',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={signInHref}
|
|
||||||
style={{
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--color-heading)',
|
|
||||||
textDecoration: 'underline',
|
|
||||||
textUnderlineOffset: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dict.backToLogin}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</AuthShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ResetPasswordForm({ locale }: { locale: Locale }) {
|
|
||||||
return (
|
|
||||||
<Suspense>
|
|
||||||
<ResetPasswordContent locale={locale} />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Button } from '@/components/actions/Button'
|
|
||||||
import { TextInput } from '@/components/forms/TextInput'
|
|
||||||
import { FormField } from '@/components/forms/FormField'
|
|
||||||
import { AuthShell } from '@/components/auth/AuthShell'
|
|
||||||
import { API_BASE, EMPLOYEE_PROFILE_KEY } from '@/lib/api'
|
|
||||||
import { localizedPath, type Locale } from '@/lib/localization/config'
|
|
||||||
import { useSearchParams, useRouter } from 'next/navigation'
|
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
interface Dict {
|
|
||||||
title: string
|
|
||||||
subtitle: string
|
|
||||||
email: string
|
|
||||||
emailPlaceholder: string
|
|
||||||
password: string
|
|
||||||
signIn: string
|
|
||||||
signingIn: string
|
|
||||||
verify: string
|
|
||||||
verifying: string
|
|
||||||
authCode: string
|
|
||||||
enterCode: string
|
|
||||||
totpPlaceholder: string
|
|
||||||
back: string
|
|
||||||
forgotPassword: string
|
|
||||||
invalidCredentials: string
|
|
||||||
passwordNotSet: string
|
|
||||||
tooManyRequests: string
|
|
||||||
emailNotVerified: string
|
|
||||||
unexpectedError: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const dicts: Record<string, Dict> = {
|
|
||||||
en: {
|
|
||||||
title: 'Sign in',
|
|
||||||
subtitle: 'Enter your credentials to access your account.',
|
|
||||||
email: 'Email',
|
|
||||||
emailPlaceholder: 'owner@company.com',
|
|
||||||
password: 'Password',
|
|
||||||
signIn: 'Sign in',
|
|
||||||
signingIn: 'Signing in…',
|
|
||||||
verify: 'Verify code',
|
|
||||||
verifying: 'Verifying…',
|
|
||||||
authCode: 'Authentication code',
|
|
||||||
enterCode: 'Enter the 6-digit code from your authenticator app.',
|
|
||||||
totpPlaceholder: '000000 or XXXX-XXXX-XXXX',
|
|
||||||
back: 'Back to credentials',
|
|
||||||
forgotPassword: 'Forgot your password?',
|
|
||||||
invalidCredentials: 'Invalid email or password.',
|
|
||||||
passwordNotSet: 'No password set yet. Check your invitation email or use "Forgot your password?"',
|
|
||||||
tooManyRequests: 'Too many attempts. Please try again later.',
|
|
||||||
emailNotVerified: 'Please verify your email first. Check your inbox for the verification link.',
|
|
||||||
unexpectedError: 'Something went wrong. Please try again.',
|
|
||||||
},
|
|
||||||
fr: {
|
|
||||||
title: 'Connexion',
|
|
||||||
subtitle: 'Saisissez vos identifiants pour accéder à votre compte.',
|
|
||||||
email: 'Email',
|
|
||||||
emailPlaceholder: 'owner@company.com',
|
|
||||||
password: 'Mot de passe',
|
|
||||||
signIn: 'Connexion',
|
|
||||||
signingIn: 'Connexion…',
|
|
||||||
verify: 'Vérifier le code',
|
|
||||||
verifying: 'Vérification…',
|
|
||||||
authCode: "Code d'authentification",
|
|
||||||
enterCode: "Entrez le code à 6 chiffres de votre application d'authentification.",
|
|
||||||
totpPlaceholder: '000000 ou XXXX-XXXX-XXXX',
|
|
||||||
back: 'Retour aux identifiants',
|
|
||||||
forgotPassword: 'Mot de passe oublié ?',
|
|
||||||
invalidCredentials: 'Adresse e-mail ou mot de passe invalide.',
|
|
||||||
passwordNotSet: "Aucun mot de passe défini. Vérifiez votre e-mail d'invitation ou utilisez « Mot de passe oublié ? »",
|
|
||||||
tooManyRequests: 'Trop de tentatives. Veuillez réessayer plus tard.',
|
|
||||||
emailNotVerified: 'Veuillez vérifier votre adresse email. Consultez votre boîte de réception.',
|
|
||||||
unexpectedError: 'Une erreur est survenue. Veuillez réessayer.',
|
|
||||||
},
|
|
||||||
ar: {
|
|
||||||
title: 'تسجيل الدخول',
|
|
||||||
subtitle: 'أدخل بياناتك للوصول إلى حسابك.',
|
|
||||||
email: 'البريد الإلكتروني',
|
|
||||||
emailPlaceholder: 'owner@company.com',
|
|
||||||
password: 'كلمة المرور',
|
|
||||||
signIn: 'تسجيل الدخول',
|
|
||||||
signingIn: 'جارٍ تسجيل الدخول…',
|
|
||||||
verify: 'تحقق من الرمز',
|
|
||||||
verifying: 'جارٍ التحقق…',
|
|
||||||
authCode: 'رمز المصادقة',
|
|
||||||
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
|
|
||||||
totpPlaceholder: '000000 أو XXXX-XXXX-XXXX',
|
|
||||||
back: 'العودة إلى بيانات الدخول',
|
|
||||||
forgotPassword: 'نسيت كلمة المرور؟',
|
|
||||||
invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
|
|
||||||
passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
|
|
||||||
tooManyRequests: 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
|
|
||||||
emailNotVerified: 'يرجى التحقق من بريدك الإلكتروني أولاً. تحقق من صندوق الوارد.',
|
|
||||||
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SignInForm({ locale }: { locale: Locale }) {
|
|
||||||
const dict = (dicts[locale] ?? dicts.en) as Dict
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const [email, setEmail] = useState('')
|
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const [totpCode, setTotpCode] = useState('')
|
|
||||||
const [step, setStep] = useState<'credentials' | 'totp'>('credentials')
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const requestedPortal = searchParams.get('portal')
|
|
||||||
const employeeRedirect = searchParams.get('redirect') || '/dashboard'
|
|
||||||
const preferAdminAuth = requestedPortal === 'admin'
|
|
||||||
|
|
||||||
async function handleCredentials(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tryAdminLogin = async () => {
|
|
||||||
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ email, password }),
|
|
||||||
})
|
|
||||||
const adminJson = await adminRes.json()
|
|
||||||
|
|
||||||
if (adminRes.ok && adminJson?.data?.admin) {
|
|
||||||
window.location.href = `${window.location.origin}/admin/dashboard`
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (adminRes.status === 401 && adminJson?.error === 'totp_required') {
|
|
||||||
setStep('totp')
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (adminRes.status === 429) {
|
|
||||||
setError(dict.tooManyRequests)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const tryEmployeeLogin = async () => {
|
|
||||||
const empRes = await fetch(`${API_BASE}/auth/employee/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ email, password }),
|
|
||||||
})
|
|
||||||
const empJson = await empRes.json()
|
|
||||||
|
|
||||||
if (empRes.ok && empJson?.data?.employee) {
|
|
||||||
if (empJson?.data?.employee) {
|
|
||||||
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee))
|
|
||||||
const prefLang = empJson.data.employee?.preferredLanguage
|
|
||||||
if (prefLang === 'en' || prefLang === 'fr' || prefLang === 'ar') {
|
|
||||||
document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; samesite=lax`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
|
|
||||||
window.location.replace(employeeRedirect)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empJson?.error === 'password_not_set') {
|
|
||||||
setError(dict.passwordNotSet)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empJson?.error === 'email_not_verified') {
|
|
||||||
setError(dict.emailNotVerified)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empRes.status === 429) {
|
|
||||||
setError(dict.tooManyRequests)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preferAdminAuth) {
|
|
||||||
if (await tryAdminLogin()) return
|
|
||||||
setError(dict.invalidCredentials)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await tryEmployeeLogin()) return
|
|
||||||
|
|
||||||
setError(dict.invalidCredentials)
|
|
||||||
} catch {
|
|
||||||
setError(dict.unexpectedError)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleTotp(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const normalizedCode = totpCode.trim().toUpperCase()
|
|
||||||
const secondFactor = /^\d{6}$/.test(normalizedCode)
|
|
||||||
? { totpCode: normalizedCode }
|
|
||||||
: { recoveryCode: normalizedCode }
|
|
||||||
|
|
||||||
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ email, password, ...secondFactor }),
|
|
||||||
})
|
|
||||||
const adminJson = await adminRes.json()
|
|
||||||
|
|
||||||
if (adminRes.ok && adminJson?.data?.admin) {
|
|
||||||
window.location.href = `${window.location.origin}/admin/dashboard`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setError(dict.invalidCredentials)
|
|
||||||
} catch {
|
|
||||||
setError(dict.unexpectedError)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hrefBase = localizedPath('forgot-password', locale)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthShell locale={locale} title={dict.title} subtitle={dict.subtitle}>
|
|
||||||
{error ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: '1.25rem',
|
|
||||||
borderRadius: '1.5rem',
|
|
||||||
border: '1px solid var(--color-error-border, #fecaca)',
|
|
||||||
background: 'var(--color-error-bg, #fef2f2)',
|
|
||||||
padding: '0.75rem 1rem',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
color: 'var(--color-error-text, #b91c1c)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{step === 'credentials' ? (
|
|
||||||
<form onSubmit={handleCredentials} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
|
||||||
<FormField id="signin-email" label={dict.email} required>
|
|
||||||
<TextInput
|
|
||||||
id="signin-email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder={dict.emailPlaceholder}
|
|
||||||
autoComplete="email"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField id="signin-password" label={dict.password} required>
|
|
||||||
<input
|
|
||||||
id="signin-password"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="••••••••"
|
|
||||||
autoComplete="current-password"
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
minHeight: '2.875rem',
|
|
||||||
padding: 'var(--space-3) var(--space-4)',
|
|
||||||
border: '1px solid var(--border-strong)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
background: 'var(--surface-primary)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<Button type="submit" intent="primary" fullWidth loading={loading} disabled={loading}>
|
|
||||||
{loading ? dict.signingIn : dict.signIn}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<a
|
|
||||||
href={hrefBase}
|
|
||||||
style={{
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
color: 'var(--color-text-tertiary)',
|
|
||||||
textDecoration: 'underline',
|
|
||||||
textUnderlineOffset: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dict.forgotPassword}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleTotp} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: '1.5rem',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
background: 'var(--color-surface-secondary)',
|
|
||||||
padding: '1rem',
|
|
||||||
textAlign: 'center',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dict.enterCode}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField id="signin-totp" label={dict.authCode} required>
|
|
||||||
<TextInput
|
|
||||||
id="signin-totp"
|
|
||||||
type="text"
|
|
||||||
inputMode="text"
|
|
||||||
required
|
|
||||||
maxLength={14}
|
|
||||||
value={totpCode}
|
|
||||||
onChange={(e) => setTotpCode(e.target.value.replace(/[^0-9A-Za-z-]/g, '').toUpperCase())}
|
|
||||||
placeholder={dict.totpPlaceholder}
|
|
||||||
style={{ textAlign: 'center', fontSize: '1.25rem', letterSpacing: '0.45em' }}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<Button type="submit" intent="primary" fullWidth loading={loading} disabled={loading}>
|
|
||||||
{loading ? dict.verifying : dict.verify}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setStep('credentials')
|
|
||||||
setTotpCode('')
|
|
||||||
setError(null)
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
color: 'var(--color-text-tertiary)',
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dict.back}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</AuthShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
.accordion {
|
|
||||||
border-block-start: 1px solid var(--border-standard);
|
|
||||||
}
|
|
||||||
.item {
|
|
||||||
border-block-end: 1px solid var(--border-standard);
|
|
||||||
}
|
|
||||||
.summary {
|
|
||||||
display: flex;
|
|
||||||
min-block-size: 3.5rem;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-4);
|
|
||||||
padding-block: var(--space-4);
|
|
||||||
cursor: pointer;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
.summary::-webkit-details-marker {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.heading {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--type-body-large-size);
|
|
||||||
}
|
|
||||||
.icon {
|
|
||||||
transition: transform var(--duration-fast) var(--easing-productive);
|
|
||||||
}
|
|
||||||
.item[open] .icon {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
padding-block-end: var(--space-5);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.content > :last-child {
|
|
||||||
margin-block-end: 0;
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.icon {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { Icon } from '@/components/foundation/Icon';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import styles from './Accordion.module.css';
|
|
||||||
|
|
||||||
export interface AccordionItem {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
content: ReactNode;
|
|
||||||
open?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Accordion({
|
|
||||||
items,
|
|
||||||
headingLevel = 3,
|
|
||||||
}: {
|
|
||||||
items: AccordionItem[];
|
|
||||||
headingLevel?: 2 | 3 | 4;
|
|
||||||
}) {
|
|
||||||
const Heading = `h${headingLevel}` as const;
|
|
||||||
return (
|
|
||||||
<div className={styles.accordion}>
|
|
||||||
{items.map((item) => (
|
|
||||||
<details key={item.id} className={styles.item} open={item.open}>
|
|
||||||
<summary className={styles.summary}>
|
|
||||||
<Heading className={styles.heading}>{item.title}</Heading>
|
|
||||||
<Icon name="chevron-down" className={styles.icon} />
|
|
||||||
</summary>
|
|
||||||
<div className={styles.content}>{item.content}</div>
|
|
||||||
</details>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
.pagination {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto 1fr;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
.pagination > :last-child {
|
|
||||||
justify-self: end;
|
|
||||||
}
|
|
||||||
.pages {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
.page {
|
|
||||||
min-inline-size: 2.75rem;
|
|
||||||
justify-content: center;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.page[aria-current='page'] {
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--interactive-primary);
|
|
||||||
color: var(--text-inverse);
|
|
||||||
}
|
|
||||||
@media (max-width: 479px) {
|
|
||||||
.pages {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { ActionLink } from '@/components/actions/ActionLink';
|
|
||||||
import { Icon } from '@/components/foundation/Icon';
|
|
||||||
import styles from './Pagination.module.css';
|
|
||||||
|
|
||||||
export interface PaginationPage {
|
|
||||||
number: number;
|
|
||||||
href: string;
|
|
||||||
current?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PaginationProps {
|
|
||||||
label: string;
|
|
||||||
pages: PaginationPage[];
|
|
||||||
previous?: { href: string; label: string };
|
|
||||||
next?: { href: string; label: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Pagination({ label, pages, previous, next }: PaginationProps) {
|
|
||||||
return (
|
|
||||||
<nav aria-label={label} className={styles.pagination}>
|
|
||||||
{previous ? (
|
|
||||||
<ActionLink href={previous.href} variant="navigation">
|
|
||||||
<Icon name="arrow-back" directionBehavior="mirror" /> {previous.label}
|
|
||||||
</ActionLink>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
<ol className={styles.pages}>
|
|
||||||
{pages.map((page) => (
|
|
||||||
<li key={page.number}>
|
|
||||||
<ActionLink
|
|
||||||
href={page.href}
|
|
||||||
variant="navigation"
|
|
||||||
aria-current={page.current ? 'page' : undefined}
|
|
||||||
className={styles.page}
|
|
||||||
>
|
|
||||||
{page.number}
|
|
||||||
</ActionLink>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
{next ? (
|
|
||||||
<ActionLink href={next.href} variant="navigation">
|
|
||||||
{next.label} <Icon name="arrow-forward" directionBehavior="mirror" />
|
|
||||||
</ActionLink>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
.control {
|
|
||||||
display: inline-flex;
|
|
||||||
max-inline-size: 100%;
|
|
||||||
gap: var(--space-1);
|
|
||||||
padding: var(--space-1);
|
|
||||||
border: 1px solid var(--border-standard);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--surface-muted);
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
.option {
|
|
||||||
flex: none;
|
|
||||||
min-block-size: 2.5rem;
|
|
||||||
padding-block: var(--space-2);
|
|
||||||
padding-inline: var(--space-4);
|
|
||||||
border: 0;
|
|
||||||
border-radius: calc(var(--radius-sm) - 2px);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.option[aria-checked='true'] {
|
|
||||||
background: var(--surface-elevated);
|
|
||||||
color: var(--text-primary);
|
|
||||||
box-shadow: var(--shadow-subtle);
|
|
||||||
}
|
|
||||||
.option:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: var(--opacity-disabled);
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { classNames } from '@/lib/components/classNames';
|
|
||||||
import styles from './SegmentedControl.module.css';
|
|
||||||
|
|
||||||
export interface SegmentOption {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SegmentedControlProps {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
options: SegmentOption[];
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SegmentedControl({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
options,
|
|
||||||
onChange,
|
|
||||||
className,
|
|
||||||
}: SegmentedControlProps) {
|
|
||||||
return (
|
|
||||||
<div className={classNames(styles.control, className)} role="radiogroup" aria-label={label}>
|
|
||||||
{options.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={option.value === value}
|
|
||||||
disabled={option.disabled}
|
|
||||||
className={styles.option}
|
|
||||||
onClick={() => onChange(option.value)}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
.tabs {
|
|
||||||
min-inline-size: 0;
|
|
||||||
}
|
|
||||||
.list {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-1);
|
|
||||||
overflow-x: auto;
|
|
||||||
border-block-end: 1px solid var(--border-standard);
|
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
|
||||||
.tab {
|
|
||||||
flex: none;
|
|
||||||
min-block-size: 2.75rem;
|
|
||||||
padding-block: var(--space-3);
|
|
||||||
padding-inline: var(--space-4);
|
|
||||||
border: 0;
|
|
||||||
border-block-end: 3px solid transparent;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.tab[aria-selected='true'] {
|
|
||||||
border-block-end-color: var(--interactive-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
.tab:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: var(--opacity-disabled);
|
|
||||||
}
|
|
||||||
.panel {
|
|
||||||
padding-block: var(--space-5);
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { classNames } from '@/lib/components/classNames';
|
|
||||||
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
|
||||||
import styles from './Tabs.module.css';
|
|
||||||
|
|
||||||
export interface TabItem {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
content: ReactNode;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TabsProps {
|
|
||||||
items: TabItem[];
|
|
||||||
label: string;
|
|
||||||
defaultActiveId?: string;
|
|
||||||
activation?: 'automatic' | 'manual';
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Tabs({
|
|
||||||
items,
|
|
||||||
label,
|
|
||||||
defaultActiveId,
|
|
||||||
activation = 'automatic',
|
|
||||||
className,
|
|
||||||
}: TabsProps) {
|
|
||||||
const available = items.filter((item) => !item.disabled);
|
|
||||||
const initial =
|
|
||||||
defaultActiveId && available.some((item) => item.id === defaultActiveId)
|
|
||||||
? defaultActiveId
|
|
||||||
: (available[0]?.id ?? '');
|
|
||||||
const [activeId, setActiveId] = useState(initial);
|
|
||||||
const [focusedId, setFocusedId] = useState(initial);
|
|
||||||
const refs = useRef(new Map<string, HTMLButtonElement>());
|
|
||||||
const prefix = useId().replaceAll(':', '');
|
|
||||||
|
|
||||||
const focusByOffset = (currentId: string, offset: number) => {
|
|
||||||
const currentIndex = available.findIndex((item) => item.id === currentId);
|
|
||||||
if (currentIndex < 0 || available.length === 0) return;
|
|
||||||
const next = available[(currentIndex + offset + available.length) % available.length];
|
|
||||||
if (!next) return;
|
|
||||||
setFocusedId(next.id);
|
|
||||||
if (activation === 'automatic') setActiveId(next.id);
|
|
||||||
refs.current.get(next.id)?.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, id: string) => {
|
|
||||||
if (event.key === 'ArrowRight') {
|
|
||||||
event.preventDefault();
|
|
||||||
focusByOffset(id, document.documentElement.dir === 'rtl' ? -1 : 1);
|
|
||||||
} else if (event.key === 'ArrowLeft') {
|
|
||||||
event.preventDefault();
|
|
||||||
focusByOffset(id, document.documentElement.dir === 'rtl' ? 1 : -1);
|
|
||||||
} else if (event.key === 'Home') {
|
|
||||||
event.preventDefault();
|
|
||||||
const first = available[0];
|
|
||||||
if (first) focusByOffset(first.id, 0);
|
|
||||||
} else if (event.key === 'End') {
|
|
||||||
event.preventDefault();
|
|
||||||
const last = available.at(-1);
|
|
||||||
if (last) focusByOffset(last.id, 0);
|
|
||||||
} else if ((event.key === 'Enter' || event.key === ' ') && activation === 'manual') {
|
|
||||||
event.preventDefault();
|
|
||||||
setActiveId(id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classNames(styles.tabs, className)}>
|
|
||||||
<div className={styles.list} role="tablist" aria-label={label}>
|
|
||||||
{items.map((item) => {
|
|
||||||
const selected = item.id === activeId;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
ref={(node) => {
|
|
||||||
if (node) refs.current.set(item.id, node);
|
|
||||||
else refs.current.delete(item.id);
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
id={`${prefix}-tab-${item.id}`}
|
|
||||||
aria-controls={`${prefix}-panel-${item.id}`}
|
|
||||||
aria-selected={selected}
|
|
||||||
tabIndex={item.id === focusedId ? 0 : -1}
|
|
||||||
disabled={item.disabled}
|
|
||||||
className={styles.tab}
|
|
||||||
onClick={() => {
|
|
||||||
setFocusedId(item.id);
|
|
||||||
setActiveId(item.id);
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => handleKeyDown(event, item.id)}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{items.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
role="tabpanel"
|
|
||||||
id={`${prefix}-panel-${item.id}`}
|
|
||||||
aria-labelledby={`${prefix}-tab-${item.id}`}
|
|
||||||
hidden={item.id !== activeId}
|
|
||||||
tabIndex={0}
|
|
||||||
className={styles.panel}
|
|
||||||
>
|
|
||||||
{item.content}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
.alert {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-4);
|
|
||||||
border: 1px solid currentColor;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
.inline {
|
|
||||||
padding: var(--space-3);
|
|
||||||
}
|
|
||||||
.info {
|
|
||||||
background: var(--status-info-surface);
|
|
||||||
color: var(--status-info-text);
|
|
||||||
}
|
|
||||||
.success {
|
|
||||||
background: var(--status-success-surface);
|
|
||||||
color: var(--status-success-text);
|
|
||||||
}
|
|
||||||
.warning {
|
|
||||||
background: var(--status-warning-surface);
|
|
||||||
color: var(--status-warning-text);
|
|
||||||
}
|
|
||||||
.error {
|
|
||||||
background: var(--status-error-surface);
|
|
||||||
color: var(--status-error-text);
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
display: block;
|
|
||||||
margin-block-end: var(--space-1);
|
|
||||||
}
|
|
||||||
.content > :last-child {
|
|
||||||
margin-block-end: 0;
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { Icon, type ApprovedIconName } from '@/components/foundation/Icon';
|
|
||||||
import { classNames } from '@/lib/components/classNames';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import styles from './Alert.module.css';
|
|
||||||
|
|
||||||
export type AlertTone = 'info' | 'success' | 'warning' | 'error';
|
|
||||||
|
|
||||||
const toneIcon: Record<AlertTone, ApprovedIconName> = {
|
|
||||||
info: 'info',
|
|
||||||
success: 'check',
|
|
||||||
warning: 'warning',
|
|
||||||
error: 'error',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface AlertProps {
|
|
||||||
tone?: AlertTone;
|
|
||||||
title?: string;
|
|
||||||
children: ReactNode;
|
|
||||||
live?: 'off' | 'polite' | 'assertive';
|
|
||||||
inline?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Alert({
|
|
||||||
tone = 'info',
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
live = 'off',
|
|
||||||
inline = false,
|
|
||||||
className,
|
|
||||||
}: AlertProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(styles.alert, styles[tone], inline && styles.inline, className)}
|
|
||||||
role={live === 'assertive' ? 'alert' : live === 'polite' ? 'status' : undefined}
|
|
||||||
aria-live={live === 'off' ? undefined : live}
|
|
||||||
>
|
|
||||||
<Icon name={toneIcon[tone]} />
|
|
||||||
<div>
|
|
||||||
{title ? <strong className={styles.title}>{title}</strong> : null}
|
|
||||||
<div className={styles.content}>{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
.spinnerGroup {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.spinner {
|
|
||||||
animation: spin 0.9s linear infinite;
|
|
||||||
}
|
|
||||||
.skeleton {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
.skeleton span {
|
|
||||||
block-size: 0.9rem;
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
var(--skeleton-base),
|
|
||||||
var(--skeleton-highlight),
|
|
||||||
var(--skeleton-base)
|
|
||||||
);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: shimmer 1.5s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes shimmer {
|
|
||||||
to {
|
|
||||||
background-position: -200% 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.spinner {
|
|
||||||
animation-duration: 1.8s;
|
|
||||||
}
|
|
||||||
.skeleton span {
|
|
||||||
animation: none;
|
|
||||||
background: var(--skeleton-base);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { Icon } from '@/components/foundation/Icon';
|
|
||||||
import { classNames } from '@/lib/components/classNames';
|
|
||||||
import type { HTMLAttributes } from 'react';
|
|
||||||
import styles from './Loading.module.css';
|
|
||||||
|
|
||||||
export function Spinner({ label, className }: { label: string; className?: string }) {
|
|
||||||
return (
|
|
||||||
<span className={classNames(styles.spinnerGroup, className)} role="status">
|
|
||||||
<Icon name="spinner" className={styles.spinner} />
|
|
||||||
<span className="visually-hidden">{label}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SkeletonProps extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
lines?: number;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Skeleton({
|
|
||||||
lines = 3,
|
|
||||||
label = 'Loading content',
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: SkeletonProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(styles.skeleton, className)}
|
|
||||||
aria-label={label}
|
|
||||||
aria-busy="true"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{Array.from({ length: lines }, (_, index) => (
|
|
||||||
<span key={index} style={{ inlineSize: index === lines - 1 ? '68%' : '100%' }} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
.group {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
.labelRow {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-4);
|
|
||||||
font-size: var(--type-label-size);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.progress {
|
|
||||||
inline-size: 100%;
|
|
||||||
block-size: 0.75rem;
|
|
||||||
accent-color: var(--interactive-primary);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { BidiText } from '@/components/foundation/BidiText';
|
|
||||||
import { classNames } from '@/lib/components/classNames';
|
|
||||||
import styles from './Progress.module.css';
|
|
||||||
|
|
||||||
interface ProgressProps {
|
|
||||||
value?: number;
|
|
||||||
max?: number;
|
|
||||||
label: string;
|
|
||||||
showValue?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Progress({ value, max = 100, label, showValue = false, className }: ProgressProps) {
|
|
||||||
const normalized = value === undefined ? undefined : Math.min(max, Math.max(0, value));
|
|
||||||
return (
|
|
||||||
<div className={classNames(styles.group, className)}>
|
|
||||||
<div className={styles.labelRow}>
|
|
||||||
<span>{label}</span>
|
|
||||||
{showValue && normalized !== undefined ? (
|
|
||||||
<BidiText>{`${Math.round((normalized / max) * 100)}%`}</BidiText>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<progress
|
|
||||||
className={styles.progress}
|
|
||||||
max={max}
|
|
||||||
aria-label={label}
|
|
||||||
{...(normalized === undefined ? {} : { value: normalized })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
.state {
|
|
||||||
display: grid;
|
|
||||||
justify-items: start;
|
|
||||||
max-inline-size: var(--container-reading);
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-8);
|
|
||||||
border: 1px dashed var(--border-strong);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--surface-muted);
|
|
||||||
}
|
|
||||||
.state h2,
|
|
||||||
.state p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.state p {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { ActionLink } from '@/components/actions/ActionLink';
|
|
||||||
import { Icon, type ApprovedIconName } from '@/components/foundation/Icon';
|
|
||||||
import styles from './StateMessage.module.css';
|
|
||||||
|
|
||||||
interface StateMessageProps {
|
|
||||||
icon?: ApprovedIconName;
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
action?: { label: string; href: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StateMessage({ icon = 'info', title, body, action }: StateMessageProps) {
|
|
||||||
return (
|
|
||||||
<section className={styles.state}>
|
|
||||||
<Icon name={icon} size="lg" />
|
|
||||||
<h2>{title}</h2>
|
|
||||||
<p>{body}</p>
|
|
||||||
{action ? (
|
|
||||||
<ActionLink href={action.href} variant="standalone" icon="arrow-forward">
|
|
||||||
{action.label}
|
|
||||||
</ActionLink>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
.choice {
|
|
||||||
display: flex;
|
|
||||||
min-block-size: 2.75rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--space-3);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.choice > input:not(.switchInput) {
|
|
||||||
inline-size: 1.25rem;
|
|
||||||
block-size: 1.25rem;
|
|
||||||
margin-block-start: 0.2rem;
|
|
||||||
accent-color: var(--interactive-primary);
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
.label {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.description {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--type-label-size);
|
|
||||||
}
|
|
||||||
.invalid .label {
|
|
||||||
color: var(--status-error-text);
|
|
||||||
}
|
|
||||||
.switchInput {
|
|
||||||
position: absolute;
|
|
||||||
inline-size: 1px;
|
|
||||||
block-size: 1px;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
.switchTrack {
|
|
||||||
position: relative;
|
|
||||||
flex: none;
|
|
||||||
inline-size: 2.75rem;
|
|
||||||
block-size: 1.5rem;
|
|
||||||
margin-block-start: 0.1rem;
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
background: var(--surface-strong);
|
|
||||||
transition: background var(--duration-fast) var(--easing-productive);
|
|
||||||
}
|
|
||||||
.switchTrack span {
|
|
||||||
position: absolute;
|
|
||||||
inset-block-start: 0.18rem;
|
|
||||||
inset-inline-start: 0.2rem;
|
|
||||||
inline-size: 1rem;
|
|
||||||
block-size: 1rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--surface-elevated);
|
|
||||||
box-shadow: var(--shadow-subtle);
|
|
||||||
transition: transform var(--duration-fast) var(--easing-productive);
|
|
||||||
}
|
|
||||||
.switchInput:checked + .switchTrack {
|
|
||||||
background: var(--interactive-primary);
|
|
||||||
}
|
|
||||||
.switchInput:checked + .switchTrack span {
|
|
||||||
transform: translateX(1.2rem);
|
|
||||||
}
|
|
||||||
html[dir='rtl'] .switchInput:checked + .switchTrack span {
|
|
||||||
transform: translateX(-1.2rem);
|
|
||||||
}
|
|
||||||
.switchInput:focus-visible + .switchTrack {
|
|
||||||
outline: 3px solid var(--focus-ring);
|
|
||||||
outline-offset: 3px;
|
|
||||||
}
|
|
||||||
.switchInput:disabled + .switchTrack {
|
|
||||||
opacity: var(--opacity-disabled);
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.switchTrack,
|
|
||||||
.switchTrack span {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { classNames } from '@/lib/components/classNames';
|
|
||||||
import type { InputHTMLAttributes, ReactNode } from 'react';
|
|
||||||
import styles from './ChoiceControls.module.css';
|
|
||||||
|
|
||||||
interface ChoiceProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
|
||||||
label: ReactNode;
|
|
||||||
description?: ReactNode;
|
|
||||||
invalid?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Choice({
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
invalid = false,
|
|
||||||
className,
|
|
||||||
type,
|
|
||||||
...props
|
|
||||||
}: ChoiceProps & { type: 'checkbox' | 'radio' }) {
|
|
||||||
return (
|
|
||||||
<label className={classNames(styles.choice, invalid && styles.invalid, className)}>
|
|
||||||
<input type={type} aria-invalid={invalid || undefined} {...props} />
|
|
||||||
<span className={styles.content}>
|
|
||||||
<span className={styles.label}>{label}</span>
|
|
||||||
{description ? <span className={styles.description}>{description}</span> : null}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Checkbox(props: ChoiceProps) {
|
|
||||||
return <Choice type="checkbox" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Radio(props: ChoiceProps) {
|
|
||||||
return <Choice type="radio" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SwitchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'role'> {
|
|
||||||
label: ReactNode;
|
|
||||||
description?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Switch({ label, description, className, ...props }: SwitchProps) {
|
|
||||||
return (
|
|
||||||
<label className={classNames(styles.choice, className)}>
|
|
||||||
<input type="checkbox" role="switch" className={styles.switchInput} {...props} />
|
|
||||||
<span aria-hidden="true" className={styles.switchTrack}>
|
|
||||||
<span />
|
|
||||||
</span>
|
|
||||||
<span className={styles.content}>
|
|
||||||
<span className={styles.label}>{label}</span>
|
|
||||||
{description ? <span className={styles.description}>{description}</span> : null}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
.summary {
|
|
||||||
padding: var(--space-5);
|
|
||||||
border: 2px solid var(--status-error-text);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--status-error-surface);
|
|
||||||
color: var(--status-error-text);
|
|
||||||
}
|
|
||||||
.heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
.heading h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--type-heading-3-size);
|
|
||||||
}
|
|
||||||
.summary ul {
|
|
||||||
margin-block-end: 0;
|
|
||||||
padding-inline-start: var(--space-6);
|
|
||||||
}
|
|
||||||
.summary a {
|
|
||||||
color: currentColor;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { Icon } from '@/components/foundation/Icon';
|
|
||||||
import styles from './ErrorSummary.module.css';
|
|
||||||
|
|
||||||
export interface FormErrorItem {
|
|
||||||
fieldId: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ErrorSummary({ title, errors }: { title: string; errors: FormErrorItem[] }) {
|
|
||||||
if (errors.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={styles.summary}
|
|
||||||
role="alert"
|
|
||||||
aria-labelledby="form-error-summary-title"
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
|
||||||
<div className={styles.heading}>
|
|
||||||
<Icon name="error" />
|
|
||||||
<h2 id="form-error-summary-title">{title}</h2>
|
|
||||||
</div>
|
|
||||||
<ul>
|
|
||||||
{errors.map((error) => (
|
|
||||||
<li key={error.fieldId}>
|
|
||||||
<a href={`#${error.fieldId}`}>{error.message}</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
.field {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
.label {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: var(--space-2);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.required {
|
|
||||||
color: var(--status-error-text);
|
|
||||||
}
|
|
||||||
.optional {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--type-caption-size);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.help,
|
|
||||||
.error {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--type-label-size);
|
|
||||||
}
|
|
||||||
.help {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.error {
|
|
||||||
color: var(--status-error-text);
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
|
||||||
.invalid .label {
|
|
||||||
color: var(--status-error-text);
|
|
||||||
}
|
|
||||||
.fieldset {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-4);
|
|
||||||
min-inline-size: 0;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
.legend {
|
|
||||||
margin-block-end: var(--space-3);
|
|
||||||
padding: 0;
|
|
||||||
font-weight: 750;
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { classNames } from '@/lib/components/classNames';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import styles from './FormField.module.css';
|
|
||||||
|
|
||||||
interface FormFieldProps {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
required?: boolean;
|
|
||||||
optionalLabel?: string | undefined;
|
|
||||||
supportingText?: string | undefined;
|
|
||||||
error?: string | undefined;
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FormField({
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
required = false,
|
|
||||||
optionalLabel,
|
|
||||||
supportingText,
|
|
||||||
error,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: FormFieldProps) {
|
|
||||||
return (
|
|
||||||
<div className={classNames(styles.field, error && styles.invalid, className)}>
|
|
||||||
<label className={styles.label} htmlFor={id}>
|
|
||||||
<span>{label}</span>
|
|
||||||
{required ? (
|
|
||||||
<span aria-hidden="true" className={styles.required}>
|
|
||||||
*
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{!required && optionalLabel ? (
|
|
||||||
<span className={styles.optional}>{optionalLabel}</span>
|
|
||||||
) : null}
|
|
||||||
</label>
|
|
||||||
{children}
|
|
||||||
{supportingText ? (
|
|
||||||
<p id={`${id}-help`} className={styles.help}>
|
|
||||||
{supportingText}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{error ? (
|
|
||||||
<p id={`${id}-error`} className={styles.error}>
|
|
||||||
<span aria-hidden="true">!</span> {error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Fieldset({
|
|
||||||
legend,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
legend: string;
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string | undefined;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<fieldset className={classNames(styles.fieldset, className)}>
|
|
||||||
<legend className={styles.legend}>{legend}</legend>
|
|
||||||
{children}
|
|
||||||
</fieldset>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
.control {
|
|
||||||
inline-size: 100%;
|
|
||||||
min-block-size: 2.875rem;
|
|
||||||
padding-block: var(--space-3);
|
|
||||||
padding-inline: var(--space-4);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--surface-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
.control::placeholder {
|
|
||||||
color: var(--text-subdued);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
.control:hover:not(:disabled) {
|
|
||||||
border-color: var(--interactive-primary);
|
|
||||||
}
|
|
||||||
.control[aria-invalid='true'] {
|
|
||||||
border-color: var(--status-error-text);
|
|
||||||
box-shadow: 0 0 0 1px var(--status-error-text);
|
|
||||||
}
|
|
||||||
.control:disabled {
|
|
||||||
background: var(--control-disabled-surface);
|
|
||||||
color: var(--control-disabled-text);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.textarea {
|
|
||||||
min-block-size: 8rem;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
.textareaGroup {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
.count {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--type-caption-size);
|
|
||||||
text-align: end;
|
|
||||||
}
|
|
||||||
.select {
|
|
||||||
appearance: auto;
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { BidiText } from '@/components/foundation/BidiText';
|
|
||||||
import { classNames } from '@/lib/components/classNames';
|
|
||||||
import {
|
|
||||||
forwardRef,
|
|
||||||
type InputHTMLAttributes,
|
|
||||||
type SelectHTMLAttributes,
|
|
||||||
type TextareaHTMLAttributes,
|
|
||||||
} from 'react';
|
|
||||||
import styles from './TextInput.module.css';
|
|
||||||
|
|
||||||
export type TextInputType = 'text' | 'email' | 'tel' | 'url' | 'search';
|
|
||||||
|
|
||||||
interface TextInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
|
||||||
type?: TextInputType;
|
|
||||||
invalid?: boolean;
|
|
||||||
describedBy?: string | undefined;
|
|
||||||
bidiValue?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
|
|
||||||
{ type = 'text', invalid = false, describedBy, bidiValue = false, className, ...props },
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
const dir = bidiValue || type === 'email' || type === 'tel' || type === 'url' ? 'ltr' : undefined;
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
ref={ref}
|
|
||||||
type={type}
|
|
||||||
dir={dir}
|
|
||||||
className={classNames(styles.control, className)}
|
|
||||||
aria-invalid={invalid || undefined}
|
|
||||||
aria-describedby={describedBy}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
||||||
invalid?: boolean;
|
|
||||||
describedBy?: string | undefined;
|
|
||||||
characterCount?: { current: number; max: number; label: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TextArea({
|
|
||||||
invalid = false,
|
|
||||||
describedBy,
|
|
||||||
characterCount,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: TextAreaProps) {
|
|
||||||
const countId = characterCount && props.id ? `${props.id}-count` : undefined;
|
|
||||||
const ids = [describedBy, countId].filter(Boolean).join(' ') || undefined;
|
|
||||||
return (
|
|
||||||
<div className={styles.textareaGroup}>
|
|
||||||
<textarea
|
|
||||||
className={classNames(styles.control, styles.textarea, className)}
|
|
||||||
aria-invalid={invalid || undefined}
|
|
||||||
aria-describedby={ids}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
{characterCount ? (
|
|
||||||
<p id={countId} className={styles.count} aria-live="polite">
|
|
||||||
<BidiText>{`${characterCount.current}/${characterCount.max}`}</BidiText>{' '}
|
|
||||||
{characterCount.label}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
|
||||||
invalid?: boolean;
|
|
||||||
describedBy?: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Select({
|
|
||||||
invalid = false,
|
|
||||||
describedBy,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: SelectProps) {
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
className={classNames(styles.control, styles.select, className)}
|
|
||||||
aria-invalid={invalid || undefined}
|
|
||||||
aria-describedby={describedBy}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
|
|
||||||
interface AccessibleIconProps {
|
|
||||||
icon: ReactElement;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AccessibleIcon({ icon, label }: AccessibleIconProps) {
|
|
||||||
if (label) {
|
|
||||||
return (
|
|
||||||
<span role="img" aria-label={label}>
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <span aria-hidden="true">{icon}</span>;
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
.noWrap {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { classNames } from '@/lib/components/classNames';
|
|
||||||
import type { HTMLAttributes, ReactNode } from 'react';
|
|
||||||
import styles from './BidiText.module.css';
|
|
||||||
|
|
||||||
interface BidiTextProps extends Omit<HTMLAttributes<HTMLElement>, 'dir'> {
|
|
||||||
children: ReactNode;
|
|
||||||
direction?: 'ltr' | 'rtl' | 'auto';
|
|
||||||
noWrap?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BidiText({
|
|
||||||
children,
|
|
||||||
direction = 'ltr',
|
|
||||||
noWrap = true,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: BidiTextProps) {
|
|
||||||
return (
|
|
||||||
<bdi dir={direction} className={classNames(noWrap && styles.noWrap, className)} {...props}>
|
|
||||||
{children}
|
|
||||||
</bdi>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
.icon {
|
|
||||||
flex: none;
|
|
||||||
inline-size: 1.25rem;
|
|
||||||
block-size: 1.25rem;
|
|
||||||
}
|
|
||||||
.sm {
|
|
||||||
inline-size: 1rem;
|
|
||||||
block-size: 1rem;
|
|
||||||
}
|
|
||||||
.md {
|
|
||||||
inline-size: 1.25rem;
|
|
||||||
block-size: 1.25rem;
|
|
||||||
}
|
|
||||||
.lg {
|
|
||||||
inline-size: 1.5rem;
|
|
||||||
block-size: 1.5rem;
|
|
||||||
}
|
|
||||||
html[dir='rtl'] .mirror {
|
|
||||||
transform: scaleX(-1);
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.icon {
|
|
||||||
animation: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { classNames } from '@/lib/components/classNames';
|
|
||||||
import type { ReactNode, SVGProps } from 'react';
|
|
||||||
import styles from './Icon.module.css';
|
|
||||||
|
|
||||||
export const approvedIconNames = [
|
|
||||||
'arrow-back',
|
|
||||||
'arrow-forward',
|
|
||||||
'calendar',
|
|
||||||
'car',
|
|
||||||
'check',
|
|
||||||
'chevron-down',
|
|
||||||
'chevron-forward',
|
|
||||||
'close',
|
|
||||||
'error',
|
|
||||||
'external',
|
|
||||||
'globe',
|
|
||||||
'info',
|
|
||||||
'menu',
|
|
||||||
'minus',
|
|
||||||
'plus',
|
|
||||||
'search',
|
|
||||||
'settings',
|
|
||||||
'spinner',
|
|
||||||
'warning',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type ApprovedIconName = (typeof approvedIconNames)[number];
|
|
||||||
export type IconSize = 'sm' | 'md' | 'lg';
|
|
||||||
export type IconDirectionBehavior = 'fixed' | 'mirror';
|
|
||||||
|
|
||||||
interface IconProps extends Omit<SVGProps<SVGSVGElement>, 'name'> {
|
|
||||||
name: ApprovedIconName;
|
|
||||||
size?: IconSize;
|
|
||||||
decorative?: boolean;
|
|
||||||
label?: string;
|
|
||||||
directionBehavior?: IconDirectionBehavior;
|
|
||||||
}
|
|
||||||
|
|
||||||
const paths: Record<ApprovedIconName, ReactNode> = {
|
|
||||||
'arrow-back': <path d="m15 18-6-6 6-6M9 12h10" />,
|
|
||||||
'arrow-forward': <path d="m9 18 6-6-6-6m6 6H5" />,
|
|
||||||
calendar: (
|
|
||||||
<>
|
|
||||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
|
||||||
<path d="M16 3v4M8 3v4M3 10h18" />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
car: (
|
|
||||||
<>
|
|
||||||
<path d="m5 11 2-5h10l2 5" />
|
|
||||||
<path d="M3 11h18v7H3zM6 18v2m12-2v2M7 14h.01M17 14h.01" />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
check: <path d="m5 12 4 4L19 6" />,
|
|
||||||
'chevron-down': <path d="m6 9 6 6 6-6" />,
|
|
||||||
'chevron-forward': <path d="m9 18 6-6-6-6" />,
|
|
||||||
close: <path d="M6 6l12 12M18 6 6 18" />,
|
|
||||||
error: (
|
|
||||||
<>
|
|
||||||
<circle cx="12" cy="12" r="9" />
|
|
||||||
<path d="M12 7v6m0 4h.01" />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
external: (
|
|
||||||
<>
|
|
||||||
<path d="M14 4h6v6M20 4l-9 9" />
|
|
||||||
<path d="M18 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6" />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
globe: (
|
|
||||||
<>
|
|
||||||
<circle cx="12" cy="12" r="9" />
|
|
||||||
<path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18" />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
info: (
|
|
||||||
<>
|
|
||||||
<circle cx="12" cy="12" r="9" />
|
|
||||||
<path d="M12 11v6m0-10h.01" />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
menu: <path d="M4 7h16M4 12h16M4 17h16" />,
|
|
||||||
minus: <path d="M5 12h14" />,
|
|
||||||
plus: <path d="M12 5v14M5 12h14" />,
|
|
||||||
search: (
|
|
||||||
<>
|
|
||||||
<circle cx="11" cy="11" r="7" />
|
|
||||||
<path d="m16 16 5 5" />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
settings: (
|
|
||||||
<>
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3V2.8h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
spinner: <path d="M21 12a9 9 0 1 1-9-9" />,
|
|
||||||
warning: (
|
|
||||||
<>
|
|
||||||
<path d="M12 3 2.5 20h19L12 3Z" />
|
|
||||||
<path d="M12 9v5m0 3h.01" />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Icon({
|
|
||||||
name,
|
|
||||||
size = 'md',
|
|
||||||
decorative = true,
|
|
||||||
label,
|
|
||||||
directionBehavior = 'fixed',
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: IconProps) {
|
|
||||||
const accessibleProps = decorative
|
|
||||||
? ({ 'aria-hidden': true } as const)
|
|
||||||
: ({ role: 'img', 'aria-label': label ?? name } as const);
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
focusable="false"
|
|
||||||
className={classNames(
|
|
||||||
styles.icon,
|
|
||||||
styles[size],
|
|
||||||
directionBehavior === 'mirror' && styles.mirror,
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...accessibleProps}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{paths[name]}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
.region {
|
|
||||||
position: absolute;
|
|
||||||
inline-size: 1px;
|
|
||||||
block-size: 1px;
|
|
||||||
margin: -1px;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 0;
|
|
||||||
clip-path: inset(50%);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import styles from './LiveRegion.module.css';
|
|
||||||
|
|
||||||
interface LiveRegionProps {
|
|
||||||
children: ReactNode;
|
|
||||||
politeness?: 'polite' | 'assertive';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LiveRegion({ children, politeness = 'polite' }: LiveRegionProps) {
|
|
||||||
return (
|
|
||||||
<span className={styles.region} role="status" aria-live={politeness} aria-atomic="true">
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
.badge {
|
|
||||||
display: inline-flex;
|
|
||||||
min-block-size: 1.75rem;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
inline-size: fit-content;
|
|
||||||
padding-block: var(--space-1);
|
|
||||||
padding-inline: var(--space-3);
|
|
||||||
border: 1px solid currentColor;
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
font-size: var(--type-caption-size);
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
.neutral {
|
|
||||||
background: var(--surface-muted);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.info {
|
|
||||||
background: var(--status-info-surface);
|
|
||||||
color: var(--status-info-text);
|
|
||||||
}
|
|
||||||
.success {
|
|
||||||
background: var(--status-success-surface);
|
|
||||||
color: var(--status-success-text);
|
|
||||||
}
|
|
||||||
.warning {
|
|
||||||
background: var(--status-warning-surface);
|
|
||||||
color: var(--status-warning-text);
|
|
||||||
}
|
|
||||||
.error {
|
|
||||||
background: var(--status-error-surface);
|
|
||||||
color: var(--status-error-text);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { Icon, type ApprovedIconName } from './Icon';
|
|
||||||
import styles from './StatusBadge.module.css';
|
|
||||||
|
|
||||||
export type StatusTone = 'neutral' | 'info' | 'success' | 'warning' | 'error';
|
|
||||||
|
|
||||||
const icons: Partial<Record<StatusTone, ApprovedIconName>> = {
|
|
||||||
info: 'info',
|
|
||||||
success: 'check',
|
|
||||||
warning: 'warning',
|
|
||||||
error: 'error',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function StatusBadge({
|
|
||||||
children,
|
|
||||||
tone = 'neutral',
|
|
||||||
showIcon = false,
|
|
||||||
}: {
|
|
||||||
children: string;
|
|
||||||
tone?: StatusTone;
|
|
||||||
showIcon?: boolean;
|
|
||||||
}) {
|
|
||||||
const icon = icons[tone];
|
|
||||||
return (
|
|
||||||
<span className={`${styles.badge} ${styles[tone]}`}>
|
|
||||||
{showIcon && icon ? <Icon name={icon} size="sm" /> : null}
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { HTMLAttributes, ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface VisuallyHiddenProps extends HTMLAttributes<HTMLSpanElement> {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VisuallyHidden({ children, className, ...props }: VisuallyHiddenProps) {
|
|
||||||
const classes = ['visually-hidden', className].filter(Boolean).join(' ');
|
|
||||||
return (
|
|
||||||
<span className={classes} {...props}>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useId, useState, useSyncExternalStore, type RefObject } from 'react';
|
|
||||||
|
|
||||||
export function useAccessibleId(prefix = 'rdg'): string {
|
|
||||||
return `${prefix}-${useId().replaceAll(':', '')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscribeHydration = () => () => undefined;
|
|
||||||
|
|
||||||
export function useHydrated(): boolean {
|
|
||||||
return useSyncExternalStore(
|
|
||||||
subscribeHydration,
|
|
||||||
() => true,
|
|
||||||
() => false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMediaQuery(query: string): boolean {
|
|
||||||
const [matches, setMatches] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
const media = window.matchMedia(query);
|
|
||||||
const update = () => setMatches(media.matches);
|
|
||||||
update();
|
|
||||||
media.addEventListener('change', update);
|
|
||||||
return () => media.removeEventListener('change', update);
|
|
||||||
}, [query]);
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useReducedMotion(): boolean {
|
|
||||||
return useMediaQuery('(prefers-reduced-motion: reduce)');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEscapeKey(onEscape: () => void, enabled = true): void {
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') onEscape();
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [enabled, onEscape]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOutsideClick<T extends HTMLElement>(
|
|
||||||
ref: RefObject<T | null>,
|
|
||||||
onOutside: () => void,
|
|
||||||
enabled = true,
|
|
||||||
): void {
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
const handlePointerDown = (event: PointerEvent) => {
|
|
||||||
if (ref.current && !ref.current.contains(event.target as Node)) onOutside();
|
|
||||||
};
|
|
||||||
document.addEventListener('pointerdown', handlePointerDown);
|
|
||||||
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
|
||||||
}, [enabled, onOutside, ref]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useScrollLock(locked: boolean): void {
|
|
||||||
useEffect(() => {
|
|
||||||
if (!locked) return;
|
|
||||||
const previousOverflow = document.body.style.overflow;
|
|
||||||
const previousPadding = document.body.style.paddingInlineEnd;
|
|
||||||
const scrollbar = window.innerWidth - document.documentElement.clientWidth;
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
if (scrollbar > 0) document.body.style.paddingInlineEnd = `${scrollbar}px`;
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = previousOverflow;
|
|
||||||
document.body.style.paddingInlineEnd = previousPadding;
|
|
||||||
};
|
|
||||||
}, [locked]);
|
|
||||||
}
|
|
||||||