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 { IsIn, IsNotEmpty } from 'class-validator';
import { STOREFRONT_THEME_PREFERENCES } from '../../../consts/storefrontThemePreferences';
import type { StorefrontThemePreference } from '../types/StorefrontThemePreference'; import type { StorefrontThemePreference } from '../types/StorefrontThemePreference';
export class SetThemePreferenceDto { export class SetThemePreferenceDto {
@IsNotEmpty() @IsNotEmpty()
@IsIn(['light', 'dark']) @IsIn(STOREFRONT_THEME_PREFERENCES)
theme: StorefrontThemePreference; theme: StorefrontThemePreference;
} }
@@ -8,39 +8,21 @@ import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieServi
import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService'; import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService';
import { StorefrontThemeCookieService } from './StorefrontThemeCookieService'; import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput'; import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput';
import type { StorefrontShopViewServiceOverrides } from '../types/StorefrontShopViewServiceTestTypes';
import { PaymentMethod } from '../../payment/types/PaymentMethod'; import { PaymentMethod } from '../../payment/types/PaymentMethod';
describe('StorefrontShopViewService', () => { 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 = { const defaultPage: StorefrontPageMetaInput = {
title: 'Cart', title: 'Cart',
metaDescription: 'Review your cart.' metaDescription: 'Review your cart.'
}; };
const createService = (overrides: ServiceOverrides = {}) => { const createService = (overrides: StorefrontShopViewServiceOverrides = {}) => {
const { const {
cart = [], cart = [],
feedback, feedback,
authorizedOrderIds = [], authorizedOrderIds = [],
theme, theme = 'system',
branding = { branding = {
logoUrl: null, logoUrl: null,
faviconUrl: null, faviconUrl: null,
@@ -117,6 +99,7 @@ describe('StorefrontShopViewService', () => {
ogImageUrl: null, ogImageUrl: null,
productJsonLd: null productJsonLd: null
}); });
expect(locals.themePreference).toBe('system');
}); });
it('exposes shop settings, cart qty, feedback, branding, theme, and request context', async () => { 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); service = new StorefrontThemeCookieService(signedCookies as unknown as StorefrontSignedCookieService);
}); });
it('returns undefined for invalid theme values', () => { it('returns system when no cookie is set', () => {
signedCookies.getSignedCookie.mockReturnValue({ theme: 'system' }); 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', () => { it('returns a supported theme preference', () => {
@@ -25,4 +31,10 @@ describe('StorefrontThemeCookieService', () => {
expect(service.getTheme({} as never, {} as never)).toBe('dark'); 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 { Injectable } from '@nestjs/common';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { STOREFRONT_THEME_PREFERENCES } from '../../../consts/storefrontThemePreferences';
import type { StorefrontThemePreference } from '../types/StorefrontThemePreference'; import type { StorefrontThemePreference } from '../types/StorefrontThemePreference';
import { StorefrontSignedCookieService } from './StorefrontSignedCookieService'; import { StorefrontSignedCookieService } from './StorefrontSignedCookieService';
@@ -7,15 +8,15 @@ import { StorefrontSignedCookieService } from './StorefrontSignedCookieService';
export class StorefrontThemeCookieService { export class StorefrontThemeCookieService {
constructor(private readonly signedCookies: StorefrontSignedCookieService) {} constructor(private readonly signedCookies: StorefrontSignedCookieService) {}
getTheme(req: Request, res: Response): StorefrontThemePreference | undefined { getTheme(req: Request, res: Response): StorefrontThemePreference {
const payload = this.signedCookies.getSignedCookie<{ theme: string }>(req, res, 'theme'); const payload = this.signedCookies.getSignedCookie<{ theme: StorefrontThemePreference }>(req, res, 'theme');
const theme = payload?.theme; const theme = payload?.theme;
if (theme === 'light' || theme === 'dark') { if (theme && STOREFRONT_THEME_PREFERENCES.includes(theme)) {
return theme; return theme;
} }
return undefined; return 'system';
} }
setTheme(req: Request, res: Response, theme: StorefrontThemePreference): void { setTheme(req: Request, res: Response, theme: StorefrontThemePreference): void {
@@ -19,6 +19,6 @@ export type ShopRenderLocals = {
faviconUrl: string | null; faviconUrl: string | null;
simplexLink: string | null; simplexLink: string | null;
shippingNote: string | null; shippingNote: string | null;
themePreference: StorefrontThemePreference | undefined; themePreference: StorefrontThemePreference;
pageMeta: StorefrontPageMeta; 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: 'shop-footer', file: 'shop-footer.hbs' },
{ name: 'storefront-image', file: 'storefront-image.hbs' }, { name: 'storefront-image', file: 'storefront-image.hbs' },
{ name: 'theme-switcher', file: 'theme-switcher.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: 'page-back-link', file: 'page-back-link.hbs' },
{ name: 'product-card', file: 'product-card.hbs' }, { name: 'product-card', file: 'product-card.hbs' },
{ name: 'order-data-retention-notice', file: 'order-data-retention-notice.hbs' }, { name: 'order-data-retention-notice', file: 'order-data-retention-notice.hbs' },
@@ -1,8 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html <html
lang='en' lang='en'
{{#ifeq themePreference 'light'}}data-theme='light'{{/ifeq}} {{> html-theme-attributes}}
{{#ifeq themePreference 'dark'}}data-theme='dark'{{/ifeq}}
> >
<head> <head>
{{> shop-head}} {{> shop-head}}
@@ -1,8 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html <html
lang='en' lang='en'
{{#ifeq themePreference 'light'}}data-theme='light'{{/ifeq}} {{> html-theme-attributes}}
{{#ifeq themePreference 'dark'}}data-theme='dark'{{/ifeq}}
> >
<head> <head>
{{> shop-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}}' class='sf-btn sf-btn--sm{{#ifeq themePreference 'dark'}} sf-btn--primary{{/ifeq}}'
{{#ifeq themePreference 'dark'}}aria-current='true'{{/ifeq}} {{#ifeq themePreference 'dark'}}aria-current='true'{{/ifeq}}
>Dark</button> >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> </form>
</div> </div>