fix bug#7
Build & Push / Build & Push Docker Image (push) Failing after 2m50s
Test / Type Check (all packages) (push) Failing after 55s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
Build & Push / Build & Push Docker Image (push) Failing after 2m50s
Test / Type Check (all packages) (push) Failing after 55s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import { JetBrains_Mono } from 'next/font/google'
|
||||
import Script from 'next/script'
|
||||
import '@fontsource-variable/inter/index.css'
|
||||
import '@fontsource-variable/noto-sans-arabic/index.css'
|
||||
import { DashboardI18nProvider } from '@/components/I18nProvider'
|
||||
import { SHARED_LANGUAGE_COOKIE } from '@/lib/preferences'
|
||||
import { HOMEPAGE_THEME_COOKIE, SHARED_LANGUAGE_COOKIE, SHARED_THEME_KEY } from '@/lib/preferences'
|
||||
import './globals.css'
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
@@ -33,14 +34,18 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
cookieStore.get(SHARED_LANGUAGE_COOKIE)?.value ?? cookieStore.get('dashboard-language')?.value,
|
||||
)
|
||||
const dir = initialLanguage === 'ar' ? 'rtl' : 'ltr'
|
||||
const cookieTheme = cookieStore.get(SHARED_THEME_KEY)?.value ?? cookieStore.get(HOMEPAGE_THEME_COOKIE)?.value
|
||||
const initialTheme = cookieTheme === 'light' ? 'light' : 'dark'
|
||||
|
||||
return (
|
||||
<html lang={initialLanguage} dir={dir} className="light" suppressHydrationWarning>
|
||||
<html lang={initialLanguage} dir={dir} className={initialTheme} suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
<Script
|
||||
id="dashboard-theme-bootstrap"
|
||||
strategy="beforeInteractive"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('dashboard-theme'));if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}var rootTheme=theme==='light'?'light':'dark';document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(rootTheme);document.documentElement.style.colorScheme=rootTheme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
|
||||
"(function(){try{var q=new URLSearchParams(location.search).get('theme');var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var h=document.cookie.match(/(?:^|; )hpc-theme=([^;]+)/);var theme=(q==='light'||q==='dark')?q:(m?decodeURIComponent(m[1]):(h?decodeURIComponent(h[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('dashboard-theme')||localStorage.getItem('hpc.theme.preference')||'dark')));if(theme!=='light'&&theme!=='dark'){theme='dark'}document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(theme);document.documentElement.style.colorScheme=theme}catch(e){}})();",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { SHARED_LANGUAGE_COOKIE, SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
|
||||
import { HOMEPAGE_THEME_COOKIE, HOMEPAGE_THEME_KEY, SHARED_LANGUAGE_COOKIE, SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
export type DashboardLanguage = 'en' | 'fr' | 'ar'
|
||||
@@ -1427,7 +1427,7 @@ export function DashboardI18nProvider({
|
||||
initialLanguage?: DashboardLanguage
|
||||
}) {
|
||||
const [language, setLanguageState] = useState<DashboardLanguage>(initialLanguage)
|
||||
const [theme, setThemeState] = useState<DashboardTheme>('light')
|
||||
const [theme, setThemeState] = useState<DashboardTheme>('dark')
|
||||
const themeInitialized = useRef(false)
|
||||
// Skip the very first write so we don't overwrite a stored preference with the
|
||||
// server-resolved initialLanguage before the read effect has a chance to apply it.
|
||||
@@ -1455,7 +1455,7 @@ export function DashboardI18nProvider({
|
||||
document.documentElement.classList.add(nextTheme === 'light' ? 'light' : 'dark')
|
||||
document.documentElement.style.colorScheme = nextTheme === 'light' ? 'light' : 'dark'
|
||||
document.body.dataset.theme = nextTheme
|
||||
writeScopedPreference(SHARED_THEME_KEY, nextTheme, ['dashboard-theme'])
|
||||
writeScopedPreference(SHARED_THEME_KEY, nextTheme, ['dashboard-theme', HOMEPAGE_THEME_KEY], [HOMEPAGE_THEME_COOKIE])
|
||||
}
|
||||
|
||||
setThemeState(nextTheme)
|
||||
@@ -1473,15 +1473,14 @@ export function DashboardI18nProvider({
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['dashboard-theme'])
|
||||
const queryTheme = new URLSearchParams(window.location.search).get('theme')
|
||||
const storedTheme = queryTheme === 'light' || queryTheme === 'dark'
|
||||
? queryTheme
|
||||
: readScopedPreference(SHARED_THEME_KEY, ['dashboard-theme', HOMEPAGE_THEME_KEY], [HOMEPAGE_THEME_COOKIE])
|
||||
if (storedTheme === 'light' || storedTheme === 'dark') {
|
||||
if (storedTheme !== theme) setThemeState(storedTheme)
|
||||
return
|
||||
}
|
||||
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches && theme !== 'dark') {
|
||||
setThemeState('dark')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1497,16 +1496,17 @@ export function DashboardI18nProvider({
|
||||
}, [language])
|
||||
|
||||
useEffect(() => {
|
||||
if (!themeInitialized.current) {
|
||||
// Skip first run: inline script already applied the correct class; writing 'light' here would flash and corrupt storage.
|
||||
themeInitialized.current = true
|
||||
return
|
||||
}
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
document.documentElement.classList.add(theme === 'light' ? 'light' : 'dark')
|
||||
document.documentElement.style.colorScheme = theme === 'light' ? 'light' : 'dark'
|
||||
document.body.dataset.theme = theme
|
||||
writeScopedPreference(SHARED_THEME_KEY, theme, ['dashboard-theme'])
|
||||
|
||||
if (!themeInitialized.current) {
|
||||
// Skip first write: inline script already applied the correct class; writing too early can corrupt stored preferences.
|
||||
themeInitialized.current = true
|
||||
return
|
||||
}
|
||||
writeScopedPreference(SHARED_THEME_KEY, theme, ['dashboard-theme', HOMEPAGE_THEME_KEY], [HOMEPAGE_THEME_COOKIE])
|
||||
}, [theme])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1530,7 +1530,7 @@ export function DashboardI18nProvider({
|
||||
if (scopedTheme === 'light' || scopedTheme === 'dark') {
|
||||
if (scopedTheme !== theme) setThemeState(scopedTheme)
|
||||
} else {
|
||||
writeScopedPreference(SHARED_THEME_KEY, theme, ['dashboard-theme'])
|
||||
writeScopedPreference(SHARED_THEME_KEY, theme, ['dashboard-theme', HOMEPAGE_THEME_KEY], [HOMEPAGE_THEME_COOKIE])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,17 @@ describe('dashboard scoped preferences', () => {
|
||||
expect(readScopedPreference('theme', ['legacy-theme'])).toBe('base')
|
||||
})
|
||||
|
||||
it('can read and write legacy cookie names for cross-app preferences', () => {
|
||||
const browser = installBrowser('hpc-theme=dark')
|
||||
|
||||
expect(readScopedPreference('rentaldrivego-theme', [], ['hpc-theme'])).toBe('dark')
|
||||
|
||||
writeScopedPreference('rentaldrivego-theme', 'light', [], ['hpc-theme'])
|
||||
|
||||
expect(browser.cookie).toContain('rentaldrivego-theme=light')
|
||||
expect(browser.cookie).toContain('hpc-theme=light')
|
||||
})
|
||||
|
||||
it('writes shared and legacy preference values without auth-token scoping', () => {
|
||||
const browser = installBrowser()
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
|
||||
export const SHARED_LANGUAGE_KEY = 'rentaldrivego-language'
|
||||
export const SHARED_THEME_KEY = 'rentaldrivego-theme'
|
||||
export const HOMEPAGE_THEME_COOKIE = 'hpc-theme'
|
||||
export const HOMEPAGE_THEME_KEY = 'hpc.theme.preference'
|
||||
|
||||
function readEmployeeToken() {
|
||||
return null
|
||||
@@ -54,7 +56,11 @@ export function readCurrentUserScopedPreference(baseKey: string) {
|
||||
return window.localStorage.getItem(getScopedPreferenceKey(baseKey))
|
||||
}
|
||||
|
||||
export function readScopedPreference(baseKey: string, legacyKeys: string[] = []) {
|
||||
export function readScopedPreference(
|
||||
baseKey: string,
|
||||
legacyKeys: string[] = [],
|
||||
legacyCookieNames: string[] = [],
|
||||
) {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
const scopedCookie = readCookie(getScopedPreferenceCookieName(baseKey))
|
||||
@@ -63,6 +69,11 @@ export function readScopedPreference(baseKey: string, legacyKeys: string[] = [])
|
||||
const sharedCookie = readCookie(baseKey)
|
||||
if (sharedCookie) return sharedCookie
|
||||
|
||||
for (const cookieName of legacyCookieNames) {
|
||||
const legacyCookie = readCookie(cookieName)
|
||||
if (legacyCookie) return legacyCookie
|
||||
}
|
||||
|
||||
const scopedKey = getScopedPreferenceKey(baseKey)
|
||||
const candidates = [scopedKey, baseKey, ...legacyKeys]
|
||||
|
||||
@@ -74,7 +85,12 @@ export function readScopedPreference(baseKey: string, legacyKeys: string[] = [])
|
||||
return null
|
||||
}
|
||||
|
||||
export function writeScopedPreference(baseKey: string, value: string, legacyKeys: string[] = []) {
|
||||
export function writeScopedPreference(
|
||||
baseKey: string,
|
||||
value: string,
|
||||
legacyKeys: string[] = [],
|
||||
legacyCookieNames: string[] = [],
|
||||
) {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const scopedKey = getScopedPreferenceKey(baseKey)
|
||||
@@ -84,6 +100,9 @@ export function writeScopedPreference(baseKey: string, value: string, legacyKeys
|
||||
if (scopedCookie !== baseKey) {
|
||||
writeCookie(scopedCookie, value)
|
||||
}
|
||||
for (const cookieName of legacyCookieNames) {
|
||||
writeCookie(cookieName, value)
|
||||
}
|
||||
|
||||
window.localStorage.setItem(scopedKey, value)
|
||||
window.localStorage.setItem(baseKey, value)
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@/lib/theme/config';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Script from 'next/script';
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
@@ -46,7 +47,6 @@ export default async function LocaleLayout({ children, params }: LocaleLayoutPro
|
||||
const messages = getMessages(locale);
|
||||
const integrationConfig = getPublicIntegrationConfig();
|
||||
const requestHeaders = await headers();
|
||||
const nonce = requestHeaders.get('x-nonce') ?? undefined;
|
||||
const cookieStore = await cookies();
|
||||
const headerPreference = requestHeaders.get('x-theme-preference');
|
||||
const cookiePreference = cookieStore.get(themeCookie)?.value;
|
||||
@@ -54,7 +54,7 @@ export default async function LocaleLayout({ children, params }: LocaleLayoutPro
|
||||
? headerPreference
|
||||
: isThemePreference(cookiePreference)
|
||||
? cookiePreference
|
||||
: 'system';
|
||||
: 'dark';
|
||||
const resolvedTheme = serverResolvedTheme(preference);
|
||||
|
||||
return (
|
||||
@@ -67,9 +67,9 @@ export default async function LocaleLayout({ children, params }: LocaleLayoutPro
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<script
|
||||
<Script
|
||||
id="theme-bootstrap"
|
||||
nonce={nonce || undefined}
|
||||
strategy="beforeInteractive"
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{ __html: themeBootstrapScript }}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import Image from 'next/image';
|
||||
import { LocalePill } from './LocalePill';
|
||||
import { ThemePill } from './ThemePill';
|
||||
import styles from './AuthHeader.module.css';
|
||||
import { useThemePreference } from './useThemePreference';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
@@ -13,7 +14,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export function AuthHeader({ locale, themePreference }: Props) {
|
||||
const homePath = localizedModePath('home', locale, themePreference);
|
||||
const currentPreference = useThemePreference(themePreference);
|
||||
const homePath = localizedModePath('home', locale, currentPreference);
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
@@ -35,7 +37,7 @@ export function AuthHeader({ locale, themePreference }: Props) {
|
||||
|
||||
<div className={styles.controls}>
|
||||
<div className={styles.pill}>
|
||||
<LocalePill locale={locale} />
|
||||
<LocalePill locale={locale} initialPreference={themePreference} />
|
||||
|
||||
<span className={styles.divider} aria-hidden="true" />
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ThemePreference } from '@/lib/theme/config';
|
||||
import Image from 'next/image';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import styles from './SiteHeader.module.css';
|
||||
import { useThemePreference } from './useThemePreference';
|
||||
|
||||
export function BrandLink({
|
||||
locale,
|
||||
@@ -16,10 +17,11 @@ export function BrandLink({
|
||||
label: string;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const currentMode = useThemePreference(mode);
|
||||
return (
|
||||
<a
|
||||
className={styles.brand}
|
||||
href={localizedModePath('home', locale, mode)}
|
||||
href={localizedModePath('home', locale, currentMode)}
|
||||
aria-label={label}
|
||||
aria-current={routeIdFromPathname(pathname) === 'home' ? 'page' : undefined}
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ThemePreference } from '@/lib/theme/config';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import styles from './SiteHeader.module.css';
|
||||
import { useThemePreference } from './useThemePreference';
|
||||
|
||||
export type HeaderNavId = 'product' | 'workflow' | 'modules' | 'pricing' | 'faq';
|
||||
|
||||
@@ -30,6 +31,7 @@ export function HeaderNavigation({
|
||||
const pathname = usePathname();
|
||||
const currentRoute = routeIdFromPathname(pathname);
|
||||
const [currentHash, setCurrentHash] = useState('');
|
||||
const currentMode = useThemePreference(mode);
|
||||
|
||||
useEffect(() => {
|
||||
const updateHash = () => setCurrentHash(window.location.hash.replace(/^#/, ''));
|
||||
@@ -38,7 +40,7 @@ export function HeaderNavigation({
|
||||
return () => window.removeEventListener('hashchange', updateHash);
|
||||
}, []);
|
||||
|
||||
const home = localizedModePath('home', locale, mode);
|
||||
const home = localizedModePath('home', locale, currentMode);
|
||||
|
||||
return (
|
||||
<nav aria-label={label} className={mobile ? styles.drawerNavigation : styles.navigation}>
|
||||
|
||||
@@ -2,27 +2,32 @@
|
||||
|
||||
import { persistLocale } from '@/lib/localization/client';
|
||||
import {
|
||||
localizedModePath,
|
||||
localeSwitchUrl,
|
||||
locales,
|
||||
routeIdFromPathname,
|
||||
type Locale,
|
||||
} from '@/lib/localization/config';
|
||||
import type { ThemePreference } from '@/lib/theme/config';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import styles from './LocalePill.module.css';
|
||||
import { useThemePreference } from './useThemePreference';
|
||||
|
||||
const langLabels: Record<Locale, string> = { en: 'EN', fr: 'FR', ar: 'AR' };
|
||||
const langFlags: Record<Locale, string> = { en: '🇺🇸', fr: '🇫🇷', ar: '🇲🇦' };
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
initialPreference: ThemePreference;
|
||||
}
|
||||
|
||||
export function LocalePill({ locale }: Props) {
|
||||
export function LocalePill({ locale, initialPreference }: Props) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const preference = useThemePreference(initialPreference);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
@@ -37,6 +42,7 @@ export function LocalePill({ locale }: Props) {
|
||||
persistLocale(next);
|
||||
const routeId = routeIdFromPathname(pathname) ?? 'home';
|
||||
const target = localeSwitchUrl(new URL(window.location.href), next, routeId);
|
||||
target.pathname = localizedModePath(routeId, next, preference);
|
||||
router.push(`${target.pathname}${target.search}${target.hash}`);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,16 +2,20 @@
|
||||
|
||||
import { persistLocale } from '@/lib/localization/client';
|
||||
import {
|
||||
localizedModePath,
|
||||
localeSwitchUrl,
|
||||
locales,
|
||||
routeIdFromPathname,
|
||||
type Locale,
|
||||
} from '@/lib/localization/config';
|
||||
import type { ThemePreference } from '@/lib/theme/config';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import styles from './Controls.module.css';
|
||||
import { useThemePreference } from './useThemePreference';
|
||||
|
||||
interface LocaleSelectorProps {
|
||||
currentLocale: Locale;
|
||||
initialPreference: ThemePreference;
|
||||
label: string;
|
||||
localeNames: Record<Locale, string>;
|
||||
compact?: boolean;
|
||||
@@ -19,12 +23,14 @@ interface LocaleSelectorProps {
|
||||
|
||||
export function LocaleSelector({
|
||||
currentLocale,
|
||||
initialPreference,
|
||||
label,
|
||||
localeNames,
|
||||
compact = false,
|
||||
}: LocaleSelectorProps) {
|
||||
const pathname = usePathname();
|
||||
const routeId = routeIdFromPathname(pathname) ?? 'home';
|
||||
const preference = useThemePreference(initialPreference);
|
||||
|
||||
return (
|
||||
<label className={styles.field}>
|
||||
@@ -36,9 +42,9 @@ export function LocaleSelector({
|
||||
onChange={(event) => {
|
||||
const targetLocale = event.target.value as Locale;
|
||||
persistLocale(targetLocale);
|
||||
window.location.assign(
|
||||
localeSwitchUrl(new URL(window.location.href), targetLocale, routeId),
|
||||
);
|
||||
const target = localeSwitchUrl(new URL(window.location.href), targetLocale, routeId);
|
||||
target.pathname = localizedModePath(routeId, targetLocale, preference);
|
||||
window.location.assign(target);
|
||||
}}
|
||||
>
|
||||
{locales.map((locale) => (
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { ActionLink } from '@/components/actions/ActionLink';
|
||||
import type { ShellMessages } from '@/lib/localization/messages';
|
||||
import { localizedModePath, type Locale } from '@/lib/localization/config';
|
||||
import { accountCreateUrl } from '@/lib/account-urls';
|
||||
import { 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 { ThemedAccountCreateLink } from './ThemedAccountCreateLink';
|
||||
import { ThemeSelector } from './ThemeSelector';
|
||||
|
||||
function signInUrl(locale: Locale, mode: ThemePreference): string {
|
||||
return localizedModePath('sign-in', locale, mode);
|
||||
}
|
||||
import { ThemedRouteActionLink } from './ThemedRouteActionLink';
|
||||
|
||||
interface MobileNavigationProps {
|
||||
locale: Locale;
|
||||
@@ -116,6 +112,7 @@ export function MobileNavigation({
|
||||
>
|
||||
<LocaleSelector
|
||||
currentLocale={locale}
|
||||
initialPreference={themePreference}
|
||||
label={messages.controls.language}
|
||||
localeNames={messages.controls.localeNames}
|
||||
/>
|
||||
@@ -128,12 +125,21 @@ export function MobileNavigation({
|
||||
</div>
|
||||
|
||||
<div className={styles.drawerActions}>
|
||||
<ActionLink href={signInUrl(locale, themePreference)} variant="button-primary">
|
||||
<ThemedRouteActionLink
|
||||
locale={locale}
|
||||
routeId="sign-in"
|
||||
initialPreference={themePreference}
|
||||
variant="button-primary"
|
||||
>
|
||||
{messages.header.login}
|
||||
</ActionLink>
|
||||
<ActionLink href={accountCreateUrl(locale)} variant="button-conversion">
|
||||
</ThemedRouteActionLink>
|
||||
<ThemedAccountCreateLink
|
||||
locale={locale}
|
||||
initialPreference={themePreference}
|
||||
variant="button-conversion"
|
||||
>
|
||||
{messages.header.demo}
|
||||
</ActionLink>
|
||||
</ThemedAccountCreateLink>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -117,7 +117,7 @@ export function SiteFooter({ locale, mode, messages }: SiteFooterProps) {
|
||||
<PendingItem label={footer.links.contact} pending={footer.pending} />
|
||||
</li>
|
||||
<li>
|
||||
<a href={accountCreateUrl(locale)}>{footer.links.demo}</a>
|
||||
<a href={accountCreateUrl(locale, mode)}>{footer.links.demo}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { localizedModePath, type Locale } from '@/lib/localization/config';
|
||||
import { 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 { accountCreateUrl } from '@/lib/account-urls';
|
||||
import { BrandLink } from './BrandLink';
|
||||
import { HeaderNavigation } from './HeaderNavigation';
|
||||
import { LocalePill } from './LocalePill';
|
||||
import { MobileNavigation } from './MobileNavigation';
|
||||
import styles from './SiteHeader.module.css';
|
||||
import { ThemedAccountCreateLink } from './ThemedAccountCreateLink';
|
||||
import { ThemePill } from './ThemePill';
|
||||
|
||||
function signInUrl(locale: Locale, mode: ThemePreference): string {
|
||||
return localizedModePath('sign-in', locale, mode);
|
||||
}
|
||||
import { ThemedRouteActionLink } from './ThemedRouteActionLink';
|
||||
|
||||
interface SiteHeaderProps {
|
||||
locale: Locale;
|
||||
@@ -40,24 +36,34 @@ export function SiteHeader({ locale, messages, themePreference }: SiteHeaderProp
|
||||
role="group"
|
||||
aria-label={messages.header.controlsLabel}
|
||||
>
|
||||
<LocalePill locale={locale} />
|
||||
<LocalePill locale={locale} initialPreference={themePreference} />
|
||||
<ThemePill initialPreference={themePreference} />
|
||||
<ActionLink href={signInUrl(locale, themePreference)} variant="button-primary">
|
||||
<ThemedRouteActionLink
|
||||
locale={locale}
|
||||
routeId="sign-in"
|
||||
initialPreference={themePreference}
|
||||
variant="button-primary"
|
||||
>
|
||||
{messages.header.login}
|
||||
</ActionLink>
|
||||
<ActionLink href={accountCreateUrl(locale)} variant="button-conversion">
|
||||
</ThemedRouteActionLink>
|
||||
<ThemedAccountCreateLink
|
||||
locale={locale}
|
||||
initialPreference={themePreference}
|
||||
variant="button-conversion"
|
||||
>
|
||||
{messages.header.demo}
|
||||
</ActionLink>
|
||||
</ThemedAccountCreateLink>
|
||||
</div>
|
||||
|
||||
<div className={styles.mobileActions}>
|
||||
<ActionLink
|
||||
href={accountCreateUrl(locale)}
|
||||
<ThemedAccountCreateLink
|
||||
locale={locale}
|
||||
initialPreference={themePreference}
|
||||
variant="button-conversion"
|
||||
className={styles.mobileDemoTrigger}
|
||||
>
|
||||
{messages.header.demo}
|
||||
</ActionLink>
|
||||
</ThemedAccountCreateLink>
|
||||
<MobileNavigation
|
||||
locale={locale}
|
||||
messages={messages}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { ActionLink } from '@/components/actions/ActionLink';
|
||||
import { accountCreateUrl } from '@/lib/account-urls';
|
||||
import type { Locale } from '@/lib/localization/config';
|
||||
import type { ThemePreference } from '@/lib/theme/config';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useThemePreference } from './useThemePreference';
|
||||
|
||||
interface ThemedAccountCreateLinkProps
|
||||
extends Omit<ComponentProps<typeof ActionLink>, 'href'> {
|
||||
locale: Locale;
|
||||
initialPreference: ThemePreference;
|
||||
}
|
||||
|
||||
export function ThemedAccountCreateLink({
|
||||
locale,
|
||||
initialPreference,
|
||||
...props
|
||||
}: ThemedAccountCreateLinkProps) {
|
||||
const preference = useThemePreference(initialPreference);
|
||||
return <ActionLink href={accountCreateUrl(locale, preference)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { ActionLink } from '@/components/actions/ActionLink';
|
||||
import { localizedModePath, type Locale, type RouteId } from '@/lib/localization/config';
|
||||
import type { ThemePreference } from '@/lib/theme/config';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useThemePreference } from './useThemePreference';
|
||||
|
||||
interface ThemedRouteActionLinkProps
|
||||
extends Omit<ComponentProps<typeof ActionLink>, 'href'> {
|
||||
locale: Locale;
|
||||
routeId: RouteId;
|
||||
initialPreference: ThemePreference;
|
||||
}
|
||||
|
||||
export function ThemedRouteActionLink({
|
||||
locale,
|
||||
routeId,
|
||||
initialPreference,
|
||||
...props
|
||||
}: ThemedRouteActionLinkProps) {
|
||||
const preference = useThemePreference(initialPreference);
|
||||
return <ActionLink href={localizedModePath(routeId, locale, preference)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { currentThemePreference, themePreferenceEvent } from '@/lib/theme/client';
|
||||
import type { ThemePreference } from '@/lib/theme/config';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useThemePreference(initialPreference: ThemePreference): ThemePreference {
|
||||
const [preference, setPreference] = useState<ThemePreference>(initialPreference);
|
||||
|
||||
useEffect(() => {
|
||||
setPreference(currentThemePreference());
|
||||
|
||||
const handlePreference = (event: Event) => {
|
||||
const next = (event as CustomEvent<ThemePreference>).detail;
|
||||
if (next === 'light' || next === 'dark' || next === 'system') {
|
||||
setPreference(next);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(themePreferenceEvent, handlePreference);
|
||||
return () => window.removeEventListener(themePreferenceEvent, handlePreference);
|
||||
}, []);
|
||||
|
||||
return preference;
|
||||
}
|
||||
@@ -140,3 +140,22 @@
|
||||
margin-block-start: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dashboardOrangeButton.dashboardOrangeButton {
|
||||
border-color: transparent;
|
||||
background: #ea580c;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.dashboardOrangeButton.dashboardOrangeButton:hover:not(:disabled) {
|
||||
background: #c2410c;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .dashboardOrangeButton.dashboardOrangeButton {
|
||||
background: #f97316;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .dashboardOrangeButton.dashboardOrangeButton:hover:not(:disabled) {
|
||||
background: #fb923c;
|
||||
}
|
||||
|
||||
@@ -140,7 +140,14 @@ export function ForgotPasswordForm({ locale }: { locale: Locale }) {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" intent="primary" fullWidth loading={loading} disabled={loading}>
|
||||
<Button
|
||||
type="submit"
|
||||
intent="conversion"
|
||||
fullWidth
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
className={styles.dashboardOrangeButton}
|
||||
>
|
||||
{loading ? dict.submitting : dict.submit}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -298,7 +298,14 @@ export function SignInForm({ locale }: { locale: Locale }) {
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" intent="primary" fullWidth loading={loading} disabled={loading}>
|
||||
<Button
|
||||
type="submit"
|
||||
intent="conversion"
|
||||
fullWidth
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
className={styles.dashboardOrangeButton}
|
||||
>
|
||||
{loading ? dict.signingIn : dict.signIn}
|
||||
</Button>
|
||||
|
||||
@@ -336,7 +343,14 @@ export function SignInForm({ locale }: { locale: Locale }) {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" intent="primary" fullWidth loading={loading} disabled={loading}>
|
||||
<Button
|
||||
type="submit"
|
||||
intent="conversion"
|
||||
fullWidth
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
className={styles.dashboardOrangeButton}
|
||||
>
|
||||
{loading ? dict.verifying : dict.verify}
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -274,7 +274,7 @@ export function buildHomepageContent(
|
||||
eyebrow: homepage.hero.eyebrow,
|
||||
title: homepage.hero.title,
|
||||
body: homepage.hero.body,
|
||||
primary: { label: homepage.hero.primary, href: accountCreateUrl(locale) },
|
||||
primary: { label: homepage.hero.primary, href: accountCreateUrl(locale, 'dark') },
|
||||
secondary: { label: homepage.hero.secondary, disabledReason: pendingReason },
|
||||
preview: {
|
||||
title: homepage.preview.title,
|
||||
@@ -436,7 +436,7 @@ export function buildHomepageContent(
|
||||
eyebrow: homepage.final.eyebrow,
|
||||
title: homepage.final.title,
|
||||
body: homepage.final.body,
|
||||
primary: { label: homepage.final.primary, href: accountCreateUrl(locale) },
|
||||
primary: { label: homepage.final.primary, href: accountCreateUrl(locale, 'dark') },
|
||||
secondary: { label: homepage.final.secondary, disabledReason: pendingReason },
|
||||
image: localizedImage(
|
||||
'rentaldrivego',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Locale } from '@/lib/localization/config';
|
||||
import type { ThemePreference } from '@/lib/theme/config';
|
||||
|
||||
export function accountCreateUrl(locale: Locale): string {
|
||||
export function accountCreateUrl(locale: Locale, theme?: ThemePreference): string {
|
||||
const params = new URLSearchParams({ lang: locale });
|
||||
if (theme === 'light' || theme === 'dark') params.set('theme', theme);
|
||||
return `/dashboard/sign-up?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export type Direction = 'ltr' | 'rtl';
|
||||
export type RouteId = 'home' | 'sign-in' | 'forgot-password' | 'reset-password' | 'privacy' | 'terms' | 'accessibility';
|
||||
|
||||
export const defaultLocale: Locale = 'en';
|
||||
export const defaultMode: ThemePreference = 'system';
|
||||
export const defaultMode: ThemePreference = 'dark';
|
||||
export const localeCookie = 'hpc-locale';
|
||||
export const localeStorageKey = 'hpc.locale';
|
||||
export const localeCookieMaxAgeSeconds = 31_536_000;
|
||||
|
||||
@@ -4,7 +4,7 @@ export const themeBootstrapScript = `(function () {
|
||||
var cookieValue = cookieMatch ? decodeURIComponent(cookieMatch[1]) : null;
|
||||
var stored = null;
|
||||
try { stored = localStorage.getItem("hpc.theme.preference"); } catch (_) {}
|
||||
var preference = allowed[cookieValue] ? cookieValue : (allowed[stored] ? stored : "system");
|
||||
var preference = allowed[cookieValue] ? cookieValue : (allowed[stored] ? stored : "dark");
|
||||
var dark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
var resolved = preference === "system" ? (dark ? "dark" : "light") : preference;
|
||||
var root = document.documentElement;
|
||||
|
||||
@@ -40,5 +40,5 @@ export function persistTheme(preference: ThemePreference): void {
|
||||
|
||||
export function currentThemePreference(): ThemePreference {
|
||||
const value = document.documentElement.dataset.themePreference;
|
||||
return isThemePreference(value) ? value : 'system';
|
||||
return isThemePreference(value) ? value : 'dark';
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ export function proxy(request: NextRequest): NextResponse {
|
||||
|
||||
const pathSegments = request.nextUrl.pathname.split('/').filter(Boolean);
|
||||
|
||||
// Redirect locale-less paths (e.g. /sign-in → /en/system/sign-in), but not proxied app paths or files.
|
||||
// Redirect locale-less paths (e.g. /sign-in -> /en/dark/sign-in), but not proxied app paths or files.
|
||||
const firstSegment = request.nextUrl.pathname.split('/')[1];
|
||||
const isFile = /\.\w+$/.test(request.nextUrl.pathname);
|
||||
if (firstSegment && !isFile && !isLocale(firstSegment) && !reservedProxySegments.has(firstSegment)) {
|
||||
|
||||
@@ -41,7 +41,7 @@ test('root locale detection honors Accept-Language', async ({ browser }) => {
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/\/ar\/system$/);
|
||||
await expect(page).toHaveURL(/\/ar\/dark$/);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -18,8 +18,7 @@ test('explicit dark theme is present on the server-rendered root', async ({ brow
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'dark');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
const csp = response?.headers()['content-security-policy'] ?? '';
|
||||
const nonce = await page.locator('#theme-bootstrap').getAttribute('nonce');
|
||||
expect(nonce).toBeTruthy();
|
||||
await expect(page.locator('#theme-bootstrap')).not.toHaveAttribute('nonce');
|
||||
expect(csp).toContain("'unsafe-inline'");
|
||||
expect(csp).not.toContain("'strict-dynamic'");
|
||||
await context.close();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { HeaderNavigation } from '@/components/app-shell/HeaderNavigation';
|
||||
import { ThemedAccountCreateLink } from '@/components/app-shell/ThemedAccountCreateLink';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/en/light',
|
||||
}));
|
||||
|
||||
const labels = {
|
||||
product: 'Product',
|
||||
workflow: 'Workflow',
|
||||
modules: 'Modules',
|
||||
pricing: 'Pricing',
|
||||
faq: 'FAQ',
|
||||
};
|
||||
|
||||
describe('HeaderNavigation', () => {
|
||||
it('updates destinations when the client theme preference changes', async () => {
|
||||
document.documentElement.dataset.themePreference = 'light';
|
||||
|
||||
render(
|
||||
<HeaderNavigation
|
||||
locale="en"
|
||||
mode="light"
|
||||
label="Primary navigation"
|
||||
labels={labels}
|
||||
/>,
|
||||
);
|
||||
|
||||
const product = screen.getByRole('link', { name: 'Product' });
|
||||
expect(product).toHaveAttribute('href', '/en/light#product');
|
||||
|
||||
await act(async () => {
|
||||
document.documentElement.dataset.themePreference = 'dark';
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('rentaldrivego:theme-preference', { detail: 'dark' }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(product).toHaveAttribute('href', '/en/dark#product');
|
||||
});
|
||||
|
||||
it('updates account creation destinations when the client theme preference changes', async () => {
|
||||
document.documentElement.dataset.themePreference = 'light';
|
||||
|
||||
render(
|
||||
<ThemedAccountCreateLink locale="en" initialPreference="light">
|
||||
Create account
|
||||
</ThemedAccountCreateLink>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Create account' });
|
||||
expect(link).toHaveAttribute('href', '/dashboard/sign-up?lang=en&theme=light');
|
||||
|
||||
await act(async () => {
|
||||
document.documentElement.dataset.themePreference = 'dark';
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('rentaldrivego:theme-preference', { detail: 'dark' }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(link).toHaveAttribute('href', '/dashboard/sign-up?lang=en&theme=dark');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import enShell from '@/content/locales/en/shell.json';
|
||||
import frHomepage from '@/content/locales/fr/homepage.json';
|
||||
import frShell from '@/content/locales/fr/shell.json';
|
||||
import { buildHomepageContent } from '@/content/homepage-model';
|
||||
import { accountCreateUrl } from '@/lib/account-urls';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('homepage content model', () => {
|
||||
@@ -28,14 +29,19 @@ describe('homepage content model', () => {
|
||||
it('keeps unresolved conversion destinations disabled', () => {
|
||||
const content = buildHomepageContent(enHomepage, enShell);
|
||||
|
||||
expect(content.hero.primary.href).toBe('/dashboard/sign-up?lang=en');
|
||||
expect(content.hero.primary.href).toBe('/dashboard/sign-up?lang=en&theme=dark');
|
||||
expect(content.hero.primary.disabledReason).toBeUndefined();
|
||||
expect(content.final.primary.href).toBe('/dashboard/sign-up?lang=en');
|
||||
expect(content.final.primary.href).toBe('/dashboard/sign-up?lang=en&theme=dark');
|
||||
expect(content.final.primary.disabledReason).toBeUndefined();
|
||||
expect(content.pricing.actionDisabledReason).toBe(enShell.states.pendingBody);
|
||||
expect(content.final.secondary.disabledReason).toBe(enShell.states.pendingBody);
|
||||
});
|
||||
|
||||
it('includes explicit theme handoff in account creation URLs', () => {
|
||||
expect(accountCreateUrl('en', 'light')).toBe('/dashboard/sign-up?lang=en&theme=light');
|
||||
expect(accountCreateUrl('en', 'dark')).toBe('/dashboard/sign-up?lang=en&theme=dark');
|
||||
});
|
||||
|
||||
it('keeps preview identifiers isolated from translated prose', () => {
|
||||
const content = buildHomepageContent(arHomepage, arShell);
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@ describe('destination registry', () => {
|
||||
expect(
|
||||
resolveDestination('privacy', 'fr', { demoSubmissionEnabled: false, demoMode: 'blocked' })
|
||||
.href,
|
||||
).toBe('/fr/system/confidentialite');
|
||||
).toBe('/fr/dark/confidentialite');
|
||||
expect(
|
||||
resolveDestination('sign-in', 'en', { demoSubmissionEnabled: false, demoMode: 'blocked' }).href,
|
||||
).toBe('/en/system/sign-in');
|
||||
).toBe('/en/dark/sign-in');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,9 +27,9 @@ describe('localization foundations', () => {
|
||||
});
|
||||
|
||||
it('generates and resolves localized stable route paths', () => {
|
||||
expect(localizedPath('home', 'ar')).toBe('/ar/system');
|
||||
expect(localizedPath('privacy', 'fr')).toBe('/fr/system/confidentialite');
|
||||
expect(localizedPath('sign-in', 'ar')).toBe('/ar/system/تسجيل-الدخول');
|
||||
expect(localizedPath('home', 'ar')).toBe('/ar/dark');
|
||||
expect(localizedPath('privacy', 'fr')).toBe('/fr/dark/confidentialite');
|
||||
expect(localizedPath('sign-in', 'ar')).toBe('/ar/dark/تسجيل-الدخول');
|
||||
expect(routeIdFromSlug('fr', 'confidentialite')).toBe('privacy');
|
||||
expect(routeIdFromSlug('fr', 'privacy')).toBeNull();
|
||||
expect(routeIdFromAnyLocaleSlug('privacy')).toBe('privacy');
|
||||
@@ -43,17 +43,17 @@ describe('localization foundations', () => {
|
||||
|
||||
it('generates reciprocal hreflang values', () => {
|
||||
const links = hreflangMap(new URL('https://approved.test'), 'home');
|
||||
expect(links.en).toBe('https://approved.test/en/system');
|
||||
expect(links.ar).toBe('https://approved.test/ar/system');
|
||||
expect(links['x-default']).toBe('https://approved.test/en/system');
|
||||
expect(links.en).toBe('https://approved.test/en/dark');
|
||||
expect(links.ar).toBe('https://approved.test/ar/dark');
|
||||
expect(links['x-default']).toBe('https://approved.test/en/dark');
|
||||
});
|
||||
|
||||
it('preserves only approved campaign query values and valid hashes', () => {
|
||||
const current = new URL(
|
||||
'https://approved.test/en/system?utm_source=campaign&email=private%40approved.test#gibberish',
|
||||
'https://approved.test/en/dark?utm_source=campaign&email=private%40approved.test#gibberish',
|
||||
);
|
||||
const switched = localeSwitchUrl(current, 'fr', 'home');
|
||||
expect(switched.pathname).toBe('/fr/system');
|
||||
expect(switched.pathname).toBe('/fr/dark');
|
||||
expect(switched.search).toBe('?utm_source=campaign');
|
||||
expect(switched.hash).toBe('');
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('homepage proxy app boundaries', () => {
|
||||
|
||||
expect(response).toMatchObject({
|
||||
kind: 'redirect',
|
||||
url: 'https://rentaldrivego.ma/fr/system',
|
||||
url: 'https://rentaldrivego.ma/fr/dark',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('homepage proxy app boundaries', () => {
|
||||
|
||||
expect(response).toMatchObject({
|
||||
kind: 'redirect',
|
||||
url: 'https://rentaldrivego.ma/en/system/privacy',
|
||||
url: 'https://rentaldrivego.ma/en/dark/privacy',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { currentThemePreference } from '@/lib/theme/client';
|
||||
import { isThemePreference, resolveTheme, serverResolvedTheme } from '@/lib/theme/config';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -15,4 +16,9 @@ describe('theme foundations', () => {
|
||||
expect(resolveTheme('light', true)).toBe('light');
|
||||
expect(serverResolvedTheme('system')).toBe('light');
|
||||
});
|
||||
|
||||
it('defaults client theme preference to dark when no choice exists', () => {
|
||||
delete document.documentElement.dataset.themePreference;
|
||||
expect(currentThemePreference()).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user