From ffc9a0bbe5c0432b80e2d08081d08fcf3f79e203 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Fri, 11 Sep 2026 19:15:46 +0200 Subject: [PATCH] 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. --- .../src/consts/storefrontThemePreferences.ts | 1 + .../dto/SetThemePreferenceDto.ts | 3 ++- .../StorefrontShopViewService.spec.ts | 25 +++---------------- .../StorefrontThemeCookieService.spec.ts | 18 ++++++++++--- .../services/StorefrontThemeCookieService.ts | 9 ++++--- .../storefrontCore/types/ShopRenderLocals.ts | 2 +- .../StorefrontShopViewServiceTestTypes.ts | 22 ++++++++++++++++ .../types/StorefrontThemePreference.ts | 4 ++- .../utils/registerStorefrontPartials.ts | 1 + .../views/layouts/shop-minimal.hbs | 3 +-- .../storefrontCore/views/layouts/shop.hbs | 3 +-- .../views/partials/html-theme-attributes.hbs | 2 ++ .../views/partials/theme-switcher.hbs | 7 ++++++ 13 files changed, 65 insertions(+), 35 deletions(-) create mode 100644 backend/src/consts/storefrontThemePreferences.ts create mode 100644 backend/src/modules/storefrontCore/types/StorefrontShopViewServiceTestTypes.ts create mode 100644 backend/src/modules/storefrontCore/views/partials/html-theme-attributes.hbs diff --git a/backend/src/consts/storefrontThemePreferences.ts b/backend/src/consts/storefrontThemePreferences.ts new file mode 100644 index 0000000..cd08960 --- /dev/null +++ b/backend/src/consts/storefrontThemePreferences.ts @@ -0,0 +1 @@ +export const STOREFRONT_THEME_PREFERENCES = ['light', 'dark', 'system'] as const; diff --git a/backend/src/modules/storefrontCore/dto/SetThemePreferenceDto.ts b/backend/src/modules/storefrontCore/dto/SetThemePreferenceDto.ts index f9c3b72..a78a629 100644 --- a/backend/src/modules/storefrontCore/dto/SetThemePreferenceDto.ts +++ b/backend/src/modules/storefrontCore/dto/SetThemePreferenceDto.ts @@ -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; } diff --git a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts index 52ecc67..27a6846 100644 --- a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts +++ b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts @@ -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> & { - 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 () => { diff --git a/backend/src/modules/storefrontCore/services/StorefrontThemeCookieService.spec.ts b/backend/src/modules/storefrontCore/services/StorefrontThemeCookieService.spec.ts index c96f26d..305fa58 100644 --- a/backend/src/modules/storefrontCore/services/StorefrontThemeCookieService.spec.ts +++ b/backend/src/modules/storefrontCore/services/StorefrontThemeCookieService.spec.ts @@ -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'); + }); }); diff --git a/backend/src/modules/storefrontCore/services/StorefrontThemeCookieService.ts b/backend/src/modules/storefrontCore/services/StorefrontThemeCookieService.ts index f2de8f6..6708dae 100644 --- a/backend/src/modules/storefrontCore/services/StorefrontThemeCookieService.ts +++ b/backend/src/modules/storefrontCore/services/StorefrontThemeCookieService.ts @@ -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 { diff --git a/backend/src/modules/storefrontCore/types/ShopRenderLocals.ts b/backend/src/modules/storefrontCore/types/ShopRenderLocals.ts index 49742f9..6b3e1dc 100644 --- a/backend/src/modules/storefrontCore/types/ShopRenderLocals.ts +++ b/backend/src/modules/storefrontCore/types/ShopRenderLocals.ts @@ -19,6 +19,6 @@ export type ShopRenderLocals = { faviconUrl: string | null; simplexLink: string | null; shippingNote: string | null; - themePreference: StorefrontThemePreference | undefined; + themePreference: StorefrontThemePreference; pageMeta: StorefrontPageMeta; }; diff --git a/backend/src/modules/storefrontCore/types/StorefrontShopViewServiceTestTypes.ts b/backend/src/modules/storefrontCore/types/StorefrontShopViewServiceTestTypes.ts new file mode 100644 index 0000000..3780bdf --- /dev/null +++ b/backend/src/modules/storefrontCore/types/StorefrontShopViewServiceTestTypes.ts @@ -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> & { + host?: string; + }; +}; diff --git a/backend/src/modules/storefrontCore/types/StorefrontThemePreference.ts b/backend/src/modules/storefrontCore/types/StorefrontThemePreference.ts index 06b14c2..557d744 100644 --- a/backend/src/modules/storefrontCore/types/StorefrontThemePreference.ts +++ b/backend/src/modules/storefrontCore/types/StorefrontThemePreference.ts @@ -1 +1,3 @@ -export type StorefrontThemePreference = 'light' | 'dark'; +import { STOREFRONT_THEME_PREFERENCES } from '../../../consts/storefrontThemePreferences'; + +export type StorefrontThemePreference = (typeof STOREFRONT_THEME_PREFERENCES)[number]; diff --git a/backend/src/modules/storefrontCore/utils/registerStorefrontPartials.ts b/backend/src/modules/storefrontCore/utils/registerStorefrontPartials.ts index 2c40008..78d1fbf 100644 --- a/backend/src/modules/storefrontCore/utils/registerStorefrontPartials.ts +++ b/backend/src/modules/storefrontCore/utils/registerStorefrontPartials.ts @@ -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' }, diff --git a/backend/src/modules/storefrontCore/views/layouts/shop-minimal.hbs b/backend/src/modules/storefrontCore/views/layouts/shop-minimal.hbs index 5bd9662..f44f3ec 100644 --- a/backend/src/modules/storefrontCore/views/layouts/shop-minimal.hbs +++ b/backend/src/modules/storefrontCore/views/layouts/shop-minimal.hbs @@ -1,8 +1,7 @@ html-theme-attributes}} > {{> shop-head}} diff --git a/backend/src/modules/storefrontCore/views/layouts/shop.hbs b/backend/src/modules/storefrontCore/views/layouts/shop.hbs index 0bbd707..fd20b11 100644 --- a/backend/src/modules/storefrontCore/views/layouts/shop.hbs +++ b/backend/src/modules/storefrontCore/views/layouts/shop.hbs @@ -1,8 +1,7 @@ html-theme-attributes}} > {{> shop-head}} diff --git a/backend/src/modules/storefrontCore/views/partials/html-theme-attributes.hbs b/backend/src/modules/storefrontCore/views/partials/html-theme-attributes.hbs new file mode 100644 index 0000000..3c92932 --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/html-theme-attributes.hbs @@ -0,0 +1,2 @@ +{{#ifeq themePreference 'light'}}data-theme='light'{{/ifeq}} +{{#ifeq themePreference 'dark'}}data-theme='dark'{{/ifeq}} diff --git a/backend/src/modules/storefrontCore/views/partials/theme-switcher.hbs b/backend/src/modules/storefrontCore/views/partials/theme-switcher.hbs index fde9ba2..7139940 100644 --- a/backend/src/modules/storefrontCore/views/partials/theme-switcher.hbs +++ b/backend/src/modules/storefrontCore/views/partials/theme-switcher.hbs @@ -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 +