Add system theme preference to the storefront.

Default to system when no theme cookie is set so pages follow OS appearance, and expose Light, Dark, and System in the theme switcher.
This commit is contained in:
2026-09-11 19:15:46 +02:00
parent 77cd686347
commit ffc9a0bbe5
13 changed files with 65 additions and 35 deletions
@@ -0,0 +1 @@
export const STOREFRONT_THEME_PREFERENCES = ['light', 'dark', 'system'] as const;
@@ -1,8 +1,9 @@
import { IsIn, IsNotEmpty } from 'class-validator';
import { STOREFRONT_THEME_PREFERENCES } from '../../../consts/storefrontThemePreferences';
import type { StorefrontThemePreference } from '../types/StorefrontThemePreference';
export class SetThemePreferenceDto {
@IsNotEmpty()
@IsIn(['light', 'dark'])
@IsIn(STOREFRONT_THEME_PREFERENCES)
theme: StorefrontThemePreference;
}
@@ -8,39 +8,21 @@ import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieServi
import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService';
import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput';
import type { StorefrontShopViewServiceOverrides } from '../types/StorefrontShopViewServiceTestTypes';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
describe('StorefrontShopViewService', () => {
type ServiceOverrides = {
cart?: { variantId: string; qty: number }[];
feedback?: { type: 'success'; text: string };
authorizedOrderIds?: string[];
theme?: 'light' | 'dark';
branding?: {
logoUrl: string | null;
faviconUrl: string | null;
simplexLink: string | null;
shippingNote: string | null;
};
shopSettings?: { shopName: string; shopFiatCurrency: string; enabledPaymentMethods?: PaymentMethod[] };
fiatPerXmr?: number;
fiatPerBtc?: number | null;
req?: Partial<Pick<Request, 'protocol' | 'path' | 'originalUrl'>> & {
host?: string;
};
};
const defaultPage: StorefrontPageMetaInput = {
title: 'Cart',
metaDescription: 'Review your cart.'
};
const createService = (overrides: ServiceOverrides = {}) => {
const createService = (overrides: StorefrontShopViewServiceOverrides = {}) => {
const {
cart = [],
feedback,
authorizedOrderIds = [],
theme,
theme = 'system',
branding = {
logoUrl: null,
faviconUrl: null,
@@ -117,6 +99,7 @@ describe('StorefrontShopViewService', () => {
ogImageUrl: null,
productJsonLd: null
});
expect(locals.themePreference).toBe('system');
});
it('exposes shop settings, cart qty, feedback, branding, theme, and request context', async () => {
@@ -14,10 +14,16 @@ describe('StorefrontThemeCookieService', () => {
service = new StorefrontThemeCookieService(signedCookies as unknown as StorefrontSignedCookieService);
});
it('returns undefined for invalid theme values', () => {
signedCookies.getSignedCookie.mockReturnValue({ theme: 'system' });
it('returns system when no cookie is set', () => {
signedCookies.getSignedCookie.mockReturnValue(undefined);
expect(service.getTheme({} as never, {} as never)).toBeUndefined();
expect(service.getTheme({} as never, {} as never)).toBe('system');
});
it('returns system for invalid theme values', () => {
signedCookies.getSignedCookie.mockReturnValue({ theme: 'sepia' });
expect(service.getTheme({} as never, {} as never)).toBe('system');
});
it('returns a supported theme preference', () => {
@@ -25,4 +31,10 @@ describe('StorefrontThemeCookieService', () => {
expect(service.getTheme({} as never, {} as never)).toBe('dark');
});
it('returns system when stored in the cookie', () => {
signedCookies.getSignedCookie.mockReturnValue({ theme: 'system' });
expect(service.getTheme({} as never, {} as never)).toBe('system');
});
});
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import type { Request, Response } from 'express';
import { STOREFRONT_THEME_PREFERENCES } from '../../../consts/storefrontThemePreferences';
import type { StorefrontThemePreference } from '../types/StorefrontThemePreference';
import { StorefrontSignedCookieService } from './StorefrontSignedCookieService';
@@ -7,15 +8,15 @@ import { StorefrontSignedCookieService } from './StorefrontSignedCookieService';
export class StorefrontThemeCookieService {
constructor(private readonly signedCookies: StorefrontSignedCookieService) {}
getTheme(req: Request, res: Response): StorefrontThemePreference | undefined {
const payload = this.signedCookies.getSignedCookie<{ theme: string }>(req, res, 'theme');
getTheme(req: Request, res: Response): StorefrontThemePreference {
const payload = this.signedCookies.getSignedCookie<{ theme: StorefrontThemePreference }>(req, res, 'theme');
const theme = payload?.theme;
if (theme === 'light' || theme === 'dark') {
if (theme && STOREFRONT_THEME_PREFERENCES.includes(theme)) {
return theme;
}
return undefined;
return 'system';
}
setTheme(req: Request, res: Response, theme: StorefrontThemePreference): void {
@@ -19,6 +19,6 @@ export type ShopRenderLocals = {
faviconUrl: string | null;
simplexLink: string | null;
shippingNote: string | null;
themePreference: StorefrontThemePreference | undefined;
themePreference: StorefrontThemePreference;
pageMeta: StorefrontPageMeta;
};
@@ -0,0 +1,22 @@
import type { Request } from 'express';
import type { PaymentMethod } from '../../payment/types/PaymentMethod';
import type { StorefrontThemePreference } from './StorefrontThemePreference';
export type StorefrontShopViewServiceOverrides = {
cart?: { variantId: string; qty: number }[];
feedback?: { type: 'success'; text: string };
authorizedOrderIds?: string[];
theme?: StorefrontThemePreference;
branding?: {
logoUrl: string | null;
faviconUrl: string | null;
simplexLink: string | null;
shippingNote: string | null;
};
shopSettings?: { shopName: string; shopFiatCurrency: string; enabledPaymentMethods?: PaymentMethod[] };
fiatPerXmr?: number;
fiatPerBtc?: number | null;
req?: Partial<Pick<Request, 'protocol' | 'path' | 'originalUrl'>> & {
host?: string;
};
};
@@ -1 +1,3 @@
export type StorefrontThemePreference = 'light' | 'dark';
import { STOREFRONT_THEME_PREFERENCES } from '../../../consts/storefrontThemePreferences';
export type StorefrontThemePreference = (typeof STOREFRONT_THEME_PREFERENCES)[number];
@@ -29,6 +29,7 @@ const STOREFRONT_PARTIALS = [
{ name: 'shop-footer', file: 'shop-footer.hbs' },
{ name: 'storefront-image', file: 'storefront-image.hbs' },
{ name: 'theme-switcher', file: 'theme-switcher.hbs' },
{ name: 'html-theme-attributes', file: 'html-theme-attributes.hbs' },
{ name: 'page-back-link', file: 'page-back-link.hbs' },
{ name: 'product-card', file: 'product-card.hbs' },
{ name: 'order-data-retention-notice', file: 'order-data-retention-notice.hbs' },
@@ -1,8 +1,7 @@
<!DOCTYPE html>
<html
lang='en'
{{#ifeq themePreference 'light'}}data-theme='light'{{/ifeq}}
{{#ifeq themePreference 'dark'}}data-theme='dark'{{/ifeq}}
{{> html-theme-attributes}}
>
<head>
{{> shop-head}}
@@ -1,8 +1,7 @@
<!DOCTYPE html>
<html
lang='en'
{{#ifeq themePreference 'light'}}data-theme='light'{{/ifeq}}
{{#ifeq themePreference 'dark'}}data-theme='dark'{{/ifeq}}
{{> html-theme-attributes}}
>
<head>
{{> shop-head}}
@@ -0,0 +1,2 @@
{{#ifeq themePreference 'light'}}data-theme='light'{{/ifeq}}
{{#ifeq themePreference 'dark'}}data-theme='dark'{{/ifeq}}
@@ -15,5 +15,12 @@
class='sf-btn sf-btn--sm{{#ifeq themePreference 'dark'}} sf-btn--primary{{/ifeq}}'
{{#ifeq themePreference 'dark'}}aria-current='true'{{/ifeq}}
>Dark</button>
<button
type='submit'
name='theme'
value='system'
class='sf-btn sf-btn--sm{{#ifeq themePreference 'system'}} sf-btn--primary{{/ifeq}}'
{{#ifeq themePreference 'system'}}aria-current='true'{{/ifeq}}
>System</button>
</form>
</div>