redesign the homepage
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s

This commit is contained in:
root
2026-06-26 16:27:21 -04:00
parent 256ff0814e
commit d7fb7b7a7b
1030 changed files with 107374 additions and 2657 deletions
@@ -0,0 +1,28 @@
'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.jpeg"
alt=""
width={36}
height={36}
className={styles.mark}
priority
/>
<span className={styles.brandName}>RentalDriveGo</span>
</a>
);
}
@@ -0,0 +1,38 @@
.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;
}
@@ -0,0 +1,29 @@
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>
);
}
@@ -0,0 +1,57 @@
'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>
);
}
@@ -0,0 +1,52 @@
'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>
);
}
@@ -0,0 +1,15 @@
'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>
);
}
@@ -0,0 +1,20 @@
'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>
);
}
@@ -0,0 +1,146 @@
'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>
);
}
@@ -0,0 +1,118 @@
.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;
}
}
@@ -0,0 +1,161 @@
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>
);
}
@@ -0,0 +1,303 @@
.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;
}
/*
* Preserve the full navigation on laptop and tablet landscape widths.
* Only the secondary controls collapse into the menu until the viewport
* becomes too narrow for the five primary destinations.
*/
@media (min-width: 900px) and (max-width: 1279px) {
.brandName {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.desktopControls {
display: none;
}
.mobileActions,
.mobileNavigation {
display: flex;
align-items: center;
gap: var(--space-2);
}
}
@media (max-width: 899px) {
.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;
}
}
@@ -0,0 +1,85 @@
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>
);
}
@@ -0,0 +1,104 @@
.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);
}
@@ -0,0 +1,55 @@
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>
);
}
@@ -0,0 +1,31 @@
'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;
}
@@ -0,0 +1,74 @@
'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>
);
}