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
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user