feat(shop): Implement shopping components including ShopApparelPage, ShopIdeasPage, ProductDetailModal, CartContext, routing, and idea-voting system

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-02-26 15:53:38 -08:00
parent b662c83055
commit 49dec936cd
15 changed files with 296 additions and 242 deletions

View file

@ -0,0 +1,39 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
Index,
Unique,
ManyToOne,
JoinColumn,
} from 'typeorm'
import type { Relation } from 'typeorm'
import type { MerchSubmissionEntity } from '@/merch-submissions/entities/merch-submission.entity'
@Entity('idea_daily_votes')
@Unique('uq_daily_vote_user_idea_date', ['userId', 'ideaId', 'voteDate'])
export class IdeaDailyVoteEntity {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column({ type: 'uuid' })
@Index('idx_daily_vote_user')
userId!: string
@Column({ type: 'uuid' })
@Index('idx_daily_vote_idea')
ideaId!: string
@Column({ type: 'date' })
@Index('idx_daily_vote_date')
voteDate!: string
@CreateDateColumn()
createdAt!: Date
@ManyToOne('MerchSubmissionEntity', { onDelete: 'CASCADE' })
@JoinColumn({ name: 'ideaId' })
idea!: Relation<MerchSubmissionEntity>
}

View file

@ -5,15 +5,27 @@ import { m, AnimatePresence } from '@lilith/ui-motion'
import { XIcon, ShoppingCartIcon, MinusIcon, PlusIcon, CheckIcon, SparklesIcon, HeartIcon } from '@lilith/ui-icons'
import { useCart, type Product } from '@/contexts'
import type { MerchMedium } from '@/utils'
import './ProductDetailModal.css'
/** A sibling product available in a different medium */
export interface SiblingMedium {
medium: MerchMedium
label: string
designSlug: string
}
interface ProductDetailModalProps {
isOpen: boolean
onClose: () => void
product: Product | null
/** When true, renders with higher z-index to appear above cart drawer */
isCartOverlay?: boolean
/** Sibling mediums available for the same design */
siblingMediums?: SiblingMedium[]
/** Called when user switches to a different medium */
onMediumChange?: (sibling: SiblingMedium) => void
}
export default function ProductDetailModal({
@ -21,6 +33,8 @@ export default function ProductDetailModal({
onClose,
product,
isCartOverlay = false,
siblingMediums = [],
onMediumChange,
}: ProductDetailModalProps) {
const [quantity, setQuantity] = useState(1)
const [selectedSize, setSelectedSize] = useState<string | undefined>()
@ -185,6 +199,30 @@ export default function ProductDetailModal({
)}
</div>
{/* Medium Switcher (when multiple mediums available for this design) */}
{siblingMediums.length > 1 && (
<div className="variant-section medium-section">
<label className="variant-label">Available on</label>
<div className="medium-options">
{siblingMediums.map((sibling) => (
<button
key={sibling.medium}
className={`medium-button ${product.medium === sibling.medium ? 'selected' : ''}`}
onClick={() => {
if (product.medium !== sibling.medium && onMediumChange) {
playSound('button-click')
onMediumChange(sibling)
}
}}
onMouseEnter={() => playSound('button-hover')}
>
{sibling.label}
</button>
))}
</div>
</div>
)}
<p className="product-description">{product.description}</p>
{/* Size Selection (only when product has size variants) */}

View file

@ -33,6 +33,10 @@ export interface Product {
apiProductId?: string
/** Full variant list for checkout variant ID resolution */
variants?: ProductVariantInfo[]
/** SEO design slug derived from product name */
designSlug?: string
/** SEO medium slug derived from SKU prefix */
medium?: string
}
/** An item in the cart, which is a product with quantity and optional variants */

View file

@ -4,6 +4,7 @@ import type {
UserVoteStatus,
IdeasListResponseDto,
AllocateVotesResponseDto,
CastFreeVoteResponseDto,
IdeaSortOption,
MerchPhraseCategory,
MerchProductType,
@ -299,3 +300,51 @@ export function useAllocateVotes(userId?: string | null) {
error,
}
}
/**
* Hook for casting free daily votes
*/
export function useFreeVote(userId?: string | null) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const castFreeVote = useCallback(
async (ideaId: string): Promise<CastFreeVoteResponseDto | null> => {
if (!userId) {
setError('Authentication required')
return null
}
setLoading(true)
setError(null)
try {
const response = await fetch(`${API_BASE_URL}/ideas/${ideaId}/free-vote`, {
method: 'POST',
headers: buildHeaders(userId),
})
if (!response.ok) {
const errorData = await safeParseErrorResponse(response)
throw new Error(errorData.message as string || `Failed to cast vote: ${response.status}`)
}
const data: CastFreeVoteResponseDto = await response.json()
setLoading(false)
return data
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'An unexpected error occurred'
setError(errorMessage)
setLoading(false)
return null
}
},
[userId]
)
return {
castFreeVote,
loading,
error,
}
}

View file

@ -6,7 +6,7 @@
html,
body {
height: 100%;
min-height: 100%;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
line-height: 1.5;

View file

@ -1,13 +1,12 @@
import type { ReactNode } from 'react'
import { AnalyticsProvider } from '@lilith/analytics-client/react'
import { I18nProvider } from '@lilith/i18n'
import { bootstrap } from '@lilith/service-react-bootstrap'
import { DevUserProvider, DevUserTypeSwitcher } from '@lilith/ui-dev-tools'
import { ThemeProvider as StyledThemeProvider, type DefaultTheme } from '@lilith/ui-styled-components'
import { ThemeProvider as BaseThemeProvider, type ThemeName } from '@lilith/ui-theme'
import { ThemeProvider as StyledThemeProvider } from '@lilith/ui-styled-components'
import App from './App'
import { createAnalyticsConfig, createI18nConfig, BOOTSTRAP_OPTIONS } from './bootstrap/config'
import { ThemeProvider, spacingTheme } from './bootstrap/theme'
import { workers } from './config'
import { LANDING_DEV_PERSONAS } from './config/devPersonas'
import { LANDING_DEV_USER_TYPES, LANDING_DEV_STORAGE_KEY } from './config/devUserTypes'
@ -17,24 +16,6 @@ import { bundledResources, useApiMode, CORE_NAMESPACES } from './locales'
import './index.css'
import './grid.css'
/**
* Initialize MSW for development API mocking
*
* Mocks real backend APIs:
* - Ideas/Voting: /api/ideas/*
* - Merch: /api/merch/*
*
* Note: i18n uses bundled resources, no mocking needed
*/
async function initMSW(): Promise<void> {
if (!import.meta.env.DEV) {
return
}
const { startMockServiceWorker } = await import('./mocks/browser')
await startMockServiceWorker()
}
// Register service worker for translation caching
function registerServiceWorker(): void {
if ('serviceWorker' in navigator && import.meta.env.PROD) {
@ -52,25 +33,7 @@ function registerServiceWorker(): void {
}
// Environment-based analytics configuration
// In dev: disabled by default (no CORS errors), enable with VITE_ANALYTICS_ENABLED=true
// In prod: enabled by default, disable with VITE_ANALYTICS_ENABLED=false
const analyticsConfig = {
// Analytics collector API - receives tracking events from @lilith/analytics-client
apiBaseUrl: import.meta.env.VITE_ANALYTICS_API_URL || 'http://localhost:3012',
appName: 'landing',
batchSize: import.meta.env.PROD ? 10 : 1,
batchInterval: import.meta.env.PROD ? 5000 : 1000,
enabled: import.meta.env.PROD
? import.meta.env.VITE_ANALYTICS_ENABLED !== 'false'
: import.meta.env.VITE_ANALYTICS_ENABLED === 'true',
enableDebugLogging: import.meta.env.DEV,
// Automatic scroll depth tracking - fires events at 25%, 50%, 75%, 100% scroll depth
scrollTracking: {
enabled: true,
thresholds: [25, 50, 75, 100] as const,
debounceMs: 150,
},
}
const analyticsConfig = createAnalyticsConfig()
// i18n configuration
// API URL only used when VITE_I18N_USE_API=true
@ -79,52 +42,12 @@ const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3010'
// Build i18n config based on mode
// PERFORMANCE: Only core namespaces loaded at startup, others lazy-loaded per route
const i18nConfig = {
// Use bundled resources by default (instant loading, no network)
// Only use API when explicitly enabled via VITE_I18N_USE_API=true
...(useApiMode ? { apiUrl: `${API_URL}/translations` } : { resources: bundledResources }),
defaultLanguage: 'en',
supportedLanguages: ['en', 'es'],
// Only core namespaces loaded at startup - lazy namespaces load via useNamespace hook
namespaces: [...CORE_NAMESPACES],
defaultNamespace: 'common',
debug: import.meta.env.DEV,
// Disable ML fallback when using bundled resources (not needed)
enableMLFallback: useApiMode,
}
// Theme wrapper that adds spacing property required by react-hot-toast
// The base @lilith/ui-theme doesn't include spacing, but toast components expect it
interface ExtendedTheme extends DefaultTheme {
spacing: {
xs: string;
sm: string;
md: string;
lg: string;
xl: string;
xxl: string;
};
}
const ThemeProvider = ({ children, defaultTheme }: { children: ReactNode; defaultTheme: ThemeName }) => (
<BaseThemeProvider defaultTheme={defaultTheme}>
<StyledThemeProvider
theme={(baseTheme?: DefaultTheme): ExtendedTheme => ({
...(baseTheme as DefaultTheme),
spacing: {
xs: '0.25rem',
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
xl: '2rem',
xxl: '3rem',
},
})}
>
{children}
</StyledThemeProvider>
</BaseThemeProvider>
)
// Use bundled resources by default (instant loading, no network)
// Only use API when explicitly enabled via VITE_I18N_USE_API=true
const baseI18nConfig = createI18nConfig(bundledResources, [...CORE_NAMESPACES])
const i18nConfig = useApiMode
? { ...baseI18nConfig, apiUrl: `${API_URL}/translations`, resources: undefined, enableMLFallback: true }
: baseI18nConfig
// App wrapper component to include DevUserTypeSwitcher
const AppWithExtras = () => (
@ -170,19 +93,6 @@ const AppWithProviders = () => (
// Register service worker
registerServiceWorker()
// Spacing theme to extend base theme for react-hot-toast compatibility
const spacingTheme = (baseTheme: Record<string, unknown> = {}) => ({
...baseTheme,
spacing: {
xs: '0.25rem',
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
xl: '2rem',
xxl: '3rem',
},
})
// Wrapper to provide spacing theme to all components including bootstrap's DevContentOverlay
const BootstrapWithTheme = () => (
<StyledThemeProvider
@ -192,28 +102,10 @@ const BootstrapWithTheme = () => (
</StyledThemeProvider>
)
// Initialize MSW before bootstrap in development, then bootstrap the app
;(async () => {
await initMSW()
// Bootstrap the application
// Only handle QueryClient and core setup, providers are manually wrapped above
// Bootstrap the application
;(() => {
bootstrap({
App: BootstrapWithTheme,
queryClient: {
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
},
},
},
devTools: {
// Disable auto-injected DevContentOverlay to prevent theme.spacing error
// The auto-injected overlay uses react-hot-toast which expects theme.spacing.lg
// Landing feature doesn't need this dev overlay (matches marketplace pattern)
disableContentOverlay: true,
disableDeveloperFab: true,
},
...BOOTSTRAP_OPTIONS,
})
})()

View file

@ -348,9 +348,8 @@
}
.merch-preview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 2rem;
columns: 3 240px;
column-gap: 1.5rem;
margin-top: 3rem;
}
@ -360,6 +359,8 @@
border-radius: 12px;
overflow: hidden;
transition: all 0.3s ease;
break-inside: avoid;
margin-bottom: 1.5rem;
}
.merch-preview-card:hover {
@ -376,6 +377,23 @@
justify-content: center;
}
/* Masonry: varied aspect ratios by medium type */
.merch-preview-card[data-medium="tee"] .preview-image-placeholder {
aspect-ratio: 3 / 4;
}
.merch-preview-card[data-medium="hoodie"] .preview-image-placeholder {
aspect-ratio: 4 / 5;
}
.merch-preview-card[data-medium="mug"] .preview-image-placeholder {
aspect-ratio: 1;
}
.merch-preview-card[data-medium="stickers"] .preview-image-placeholder {
aspect-ratio: 5 / 4;
}
.placeholder-icon {
color: rgba(255, 105, 180, 0.3);
}
@ -569,8 +587,7 @@
}
.merch-preview-grid {
grid-template-columns: 1fr;
gap: 1.5rem;
columns: 1;
}
.submission-card {

View file

@ -9,12 +9,21 @@ import { useScrollTrigger } from '@lilith/ui-themes'
import { m } from '@lilith/ui-motion'
import { ShirtIcon, HeartIcon, ShoppingCartIcon, Loader2Icon } from '@lilith/ui-icons'
import ProductDetailModal from '@/components/ProductDetailModal'
import ProductDetailModal, { type SiblingMedium } from '@/components/ProductDetailModal'
import { useCart, type Product, type ProductType, type ProductVariantInfo } from '@/contexts'
import { useProducts, ProductStatus, ShopProductType, type ProductApiResponse } from '@/hooks/useProductsApi'
import { Routes } from '@/routes'
import { merchDesignSlug, merchMediumFromSku, type MerchMedium } from '@/utils'
import './Shop.css'
/** Human-readable labels for medium types */
const MEDIUM_LABELS: Record<MerchMedium, string> = {
tee: 'T-Shirt',
hoodie: 'Hoodie',
stickers: 'Stickers',
mug: 'Mug',
}
/**
* Map backend ProductApiResponse to frontend Product type
*/
@ -65,6 +74,8 @@ function mapApiProductToProduct(apiProduct: ProductApiResponse): Product {
colorOptions: colorOptions?.length ? colorOptions : undefined,
apiProductId: apiProduct.id,
variants: variants?.length ? variants : undefined,
designSlug: merchDesignSlug(apiProduct.name),
medium: merchMediumFromSku(apiProduct.sku) ?? undefined,
}
}
@ -73,7 +84,7 @@ export default function ShopApparelPage() {
const prefersReducedMotion = useReducedMotion()
const playSound = useSoundEngine()
const navigate = useNavigate()
const { productId } = useParams<{ productId?: string }>()
const { designSlug, medium } = useParams<{ designSlug?: string; medium?: string }>()
const { openCart, itemCount } = useCart()
const merchPreviewRef = useRef<HTMLElement>(null)
@ -94,23 +105,39 @@ export default function ShopApparelPage() {
// Map API products to frontend format
const apparelProducts = useMemo(() => merchProducts.map(mapApiProductToProduct), [merchProducts])
// Create lookup map for modal
const productsMap = useMemo(() => apparelProducts.reduce((acc, product) => {
acc[product.id] = product
return acc
}, {} as Record<string, Product>), [apparelProducts])
// Derive modal state from URL
// Derive modal state from URL slug params
const selectedProduct = useMemo(() => {
if (!productId) {return null}
return productsMap[productId] || null
}, [productId, productsMap])
if (!designSlug || !medium) return null
return apparelProducts.find(
p => p.designSlug === designSlug && p.medium === medium
) ?? null
}, [designSlug, medium, apparelProducts])
// Build sibling mediums for the currently selected design
const siblingMediums = useMemo((): SiblingMedium[] => {
if (!selectedProduct?.designSlug) return []
return apparelProducts
.filter(p => p.designSlug === selectedProduct.designSlug && p.medium)
.map(p => ({
medium: p.medium as MerchMedium,
label: MEDIUM_LABELS[p.medium as MerchMedium] ?? p.medium!,
designSlug: p.designSlug!,
}))
// Deduplicate by medium (in case of color variants sharing the same medium)
.filter((s, i, arr) => arr.findIndex(x => x.medium === s.medium) === i)
}, [selectedProduct?.designSlug, apparelProducts])
const isModalOpen = !!selectedProduct
const handleProductClick = (product: Product) => {
playSound('button-click')
navigate(Routes.apparel(product.id))
if (product.designSlug && product.medium) {
navigate(Routes.apparel(product.designSlug, product.medium))
}
}
const handleMediumChange = (sibling: SiblingMedium) => {
navigate(Routes.apparel(sibling.designSlug, sibling.medium))
}
const handleCloseModal = () => {
@ -203,6 +230,7 @@ export default function ShopApparelPage() {
<m.div
key={product.id}
className="merch-preview-card clickable"
data-medium={product.medium}
initial={{ opacity: 0, y: 30 }}
animate={merchPreviewVisible ? { opacity: 1, y: 0 } : undefined}
transition={{ duration: 0.5, delay: 0.1 + index * 0.1 }}
@ -260,6 +288,8 @@ export default function ShopApparelPage() {
isOpen={isModalOpen}
onClose={handleCloseModal}
product={selectedProduct}
siblingMediums={siblingMediums}
onMediumChange={handleMediumChange}
/>
</div>
)

View file

@ -10,7 +10,7 @@ import type { IdeaSortOption, MerchPhraseCategory, VoteableIdea } from '@lilith/
import { CategoryFilter, FeaturedIdeasCarousel, IdeaConfiguratorModal, IdeasGrid, VoteBanner, SortDropdown } from '@/components/Ideas'
import { useDevUser } from '@/contexts'
import { useIdeas, useFeaturedIdeas, useAllocateVotes } from '@/hooks/useIdeas'
import { useIdeas, useFeaturedIdeas, useAllocateVotes, useFreeVote } from '@/hooks/useIdeas'
import { useIdeasAnalytics } from '@/hooks/useIdeasAnalytics'
import { Routes } from '@/routes'
import './Shop.css'
@ -35,13 +35,24 @@ export default function ShopIdeasPage() {
const { featuredIdeas, loading: featuredLoading, refetch: refetchFeatured } = useFeaturedIdeas(userId)
const { allocateVotes } = useAllocateVotes(userId)
const { castFreeVote } = useFreeVote(userId)
const { trackIdeaView, trackVoteAllocation, trackCategoryFilter, trackSortChange, trackSubmitIdeaClick } = useIdeasAnalytics()
const handleAllocate = useCallback(
async (ideaId: string, votes: number, previousVotes?: number, phrase?: string) => {
const handleFreeVote = useCallback(
async (ideaId: string) => {
await castFreeVote(ideaId)
trackVoteAllocation(ideaId, 1, 0)
refetch()
refetchFeatured()
},
[castFreeVote, trackVoteAllocation, refetch, refetchFeatured]
)
const handleBoost = useCallback(
async (ideaId: string, votes: number) => {
await allocateVotes(ideaId, votes)
trackVoteAllocation(ideaId, votes, previousVotes ?? 0, phrase)
trackVoteAllocation(ideaId, votes, votes - 1)
refetch()
refetchFeatured()
},
@ -68,7 +79,7 @@ export default function ShopIdeasPage() {
setConfiguringIdea(null)
}, [])
const availableVotes = userVoteStatus?.availableVotes ?? 0
const availablePaidVotes = userVoteStatus?.availableVotes ?? 0
return (
<div className="shop-page" data-testid="page-shop-ideas">
@ -121,9 +132,10 @@ export default function ShopIdeasPage() {
{/* Featured Ideas Carousel */}
<FeaturedIdeasCarousel
ideas={featuredIdeas}
availableVotes={availableVotes}
availablePaidVotes={availablePaidVotes}
isAuthenticated={isAuthenticated}
onAllocate={handleAllocate}
onFreeVote={handleFreeVote}
onBoost={handleBoost}
onIdeaView={trackIdeaView}
onConfigure={handleConfigure}
loading={featuredLoading}
@ -152,9 +164,10 @@ export default function ShopIdeasPage() {
ideas={ideas}
loading={loading}
error={error}
availableVotes={availableVotes}
availablePaidVotes={availablePaidVotes}
isAuthenticated={isAuthenticated}
onAllocate={handleAllocate}
onFreeVote={handleFreeVote}
onBoost={handleBoost}
onIdeaView={trackIdeaView}
onConfigure={handleConfigure}
/>
@ -172,7 +185,7 @@ export default function ShopIdeasPage() {
</button>
{Array.from({ length: meta.totalPages }, (_, i) => i + 1)
.filter((p) => p === 1 || p === meta.totalPages || Math.abs(p - page) <= 2)
.filter((p) => meta.totalPages <= 5 || p === 1 || p === meta.totalPages || Math.abs(p - page) <= 2)
.map((p, index, arr) => (
<Fragment key={p}>
{index > 0 && arr[index - 1] !== p - 1 && (

View file

@ -133,10 +133,10 @@ function giftCard(amountOrSlug: number | string): string {
/**
* Build merch product detail URL
* @example Routes.apparel('tshirt-lilith-classic') => '/shop/merch/tshirt-lilith-classic'
* @example Routes.apparel('sexual-liberation-through-technology', 'tee') => '/shop/merch/sexual-liberation-through-technology/tee'
*/
function apparel(productSlug: string): string {
return `/shop/merch/${productSlug}`
function apparel(designSlug: string, medium: string): string {
return `/shop/merch/${designSlug}/${medium}`
}
/**

View file

@ -42,7 +42,7 @@ export const RoutePatterns = {
shopGiftCards: '/shop/gift-cards',
shopGiftCardDetail: '/shop/gift-cards/:productId',
shopApparel: '/shop/merch',
shopApparelDetail: '/shop/merch/:productId',
shopApparelDetail: '/shop/merch/:designSlug/:medium',
shopIdeas: '/shop/ideas',
shopSubmitIdea: '/shop/submit-idea',
shopCheckout: '/shop/checkout',

View file

@ -42,17 +42,19 @@ const staticRouteConfigs: Record<string, RouteConfig> = {
'/company': { seoPageType: 'company', namespace: 'landing-categories' },
'/company/investor': { seoPageType: 'investor', namespace: 'company-investor' },
'/company/values': { seoPageType: 'values', namespace: 'company-values' },
'/company/terms': { seoPageType: 'home', namespace: 'landing-terms' },
'/company/privacy': { seoPageType: 'home', namespace: 'landing-privacy' },
'/company/terms': { seoPageType: 'terms', namespace: 'landing-terms' },
'/company/privacy': { seoPageType: 'privacy', namespace: 'landing-privacy' },
'/company/profit-participation': { seoPageType: 'profit-participation', namespace: 'company-profit-participation' },
'/shop': { seoPageType: 'shop', namespace: 'landing-categories' },
'/shop/gift-cards': { seoPageType: 'home', namespace: 'landing-merch' },
'/shop/merch': { seoPageType: 'home', namespace: 'landing-merch' },
'/shop/ideas': { seoPageType: 'home', namespace: 'landing-merch' },
'/shop/submit-idea': { seoPageType: 'home', namespace: 'landing-merch' },
'/shop/checkout': { seoPageType: 'home', namespace: 'landing-shop' },
'/shop/orders': { seoPageType: 'home', namespace: 'landing-shop' },
'/shop/gift-cards': { seoPageType: 'shop', namespace: 'landing-merch' },
'/shop/merch': { seoPageType: 'merch', namespace: 'landing-merch' },
'/shop/ideas': { seoPageType: 'shopIdeas', namespace: 'landing-merch' },
'/shop/submit-idea': { seoPageType: 'shopIdeas', namespace: 'landing-merch' },
'/shop/checkout': { seoPageType: 'shop', namespace: 'landing-shop' },
'/shop/orders': { seoPageType: 'shop', namespace: 'landing-shop' },
'/profile': { seoPageType: 'home', namespace: 'landing-profile' },
'/pricing/client': { seoPageType: 'client', namespace: 'landing-pricing' },
'/pricing/fan': { seoPageType: 'fan', namespace: 'landing-pricing' },
'/features': { seoPageType: 'platform', namespace: 'landing-features' },
'/invest': { seoPageType: 'investor', namespace: 'invest' },
}

View file

@ -1 +1 @@
export { slugify, productSlug, giftCardSlug, parseGiftCardSlug } from './slugify'
export { slugify, productSlug, giftCardSlug, parseGiftCardSlug, merchDesignSlug, merchMediumFromSku, type MerchMedium } from './slugify'

View file

@ -51,3 +51,37 @@ export function parseGiftCardSlug(slug: string): number | null {
const amount = parseInt(match[1], 10)
return isNaN(amount) ? null : amount
}
/** Medium type extracted from SKU prefix */
export type MerchMedium = 'tee' | 'hoodie' | 'stickers' | 'mug'
/** SKU prefix → URL medium mapping */
const SKU_TO_MEDIUM: Record<string, MerchMedium> = {
'TSHIRT': 'tee',
'HOODIE': 'hoodie',
'STICKERS': 'stickers',
'MUG': 'mug',
}
/**
* Derive design slug from product name, stripping the medium word.
* @example merchDesignSlug('Sexual Liberation Through Technology Tee') => 'sexual-liberation-through-technology'
* @example merchDesignSlug('Liberation Hoodie') => 'liberation'
* @example merchDesignSlug('Lilith Logo Sticker Pack') => 'lilith-logo-sticker-pack'
* @example merchDesignSlug('Morning Ritual Mug') => 'morning-ritual'
*/
export function merchDesignSlug(name: string): string {
const stripped = name
.replace(/\s+(Tee|T-?Shirt|Hoodie|Mug|Sticker(?:\s+Pack)?)$/i, '')
return slugify(stripped)
}
/**
* Extract medium from SKU prefix
* @example merchMediumFromSku('TSHIRT-SLTT-BLACK') => 'tee'
* @example merchMediumFromSku('HOODIE-LIBERATION-BLACK') => 'hoodie'
*/
export function merchMediumFromSku(sku: string): MerchMedium | null {
const prefix = sku.split('-')[0]
return SKU_TO_MEDIUM[prefix ?? ''] ?? null
}

View file

@ -10,22 +10,21 @@
* - Disables analytics
*/
import type { ReactNode } from 'react'
import { setupWorker } from 'msw/browser'
import { AnalyticsProvider } from '@lilith/analytics-client/react'
import { I18nProvider } from '@lilith/i18n'
import { bootstrap } from '@lilith/service-react-bootstrap'
import { DevUserProvider, DevUserTypeSwitcher } from '@lilith/ui-dev-tools'
import { ThemeProvider as StyledThemeProvider, type DefaultTheme } from '@lilith/ui-styled-components'
import { ThemeProvider as BaseThemeProvider, type ThemeName } from '@lilith/ui-theme'
import { ThemeProvider as StyledThemeProvider } from '@lilith/ui-styled-components'
// MSW handlers: landing-specific + composed feature handlers via backend-api-msw
import { handlers as landingHandlers } from '@/mocks/handlers'
import { allHandlers as featureHandlers } from '../../backend-api-msw/src'
import App from '@/App'
import { createAnalyticsConfig, createI18nConfig, BOOTSTRAP_OPTIONS } from '@/bootstrap/config'
import { ThemeProvider, spacingTheme } from '@/bootstrap/theme'
import { LANDING_DEV_PERSONAS } from '@/config/devPersonas'
import { LANDING_DEV_USER_TYPES, LANDING_DEV_STORAGE_KEY } from '@/config/devUserTypes'
import { CartProvider } from '@/contexts'
@ -43,62 +42,22 @@ const allHandlers = [
const worker = setupWorker(...allHandlers)
// i18n config — uses bundled resources (no API needed)
const i18nConfig = {
resources: bundledResources,
defaultLanguage: 'en',
supportedLanguages: ['en'],
namespaces: [...CORE_NAMESPACES],
defaultNamespace: 'common',
debug: true,
enableMLFallback: false,
}
// Analytics disabled in standalone mode
const analyticsConfig = {
apiBaseUrl: 'http://localhost:3012',
const analyticsConfig = createAnalyticsConfig({
appName: 'landing-standalone',
batchSize: 1,
batchInterval: 1000,
enabled: false,
enableDebugLogging: false,
scrollTracking: {
enabled: false,
thresholds: [25, 50, 75, 100] as const,
debounceMs: 150,
scrollTrackingEnabled: false,
})
// i18n config — uses bundled resources (no API needed)
const i18nConfig = createI18nConfig(
bundledResources,
[...CORE_NAMESPACES],
{
supportedLanguages: ['en'],
debug: true,
},
}
// Theme wrapper matching frontend-public's pattern
interface ExtendedTheme extends DefaultTheme {
spacing: {
xs: string
sm: string
md: string
lg: string
xl: string
xxl: string
}
}
const ThemeProvider = ({ children, defaultTheme }: { children: ReactNode; defaultTheme: ThemeName }) => (
<BaseThemeProvider defaultTheme={defaultTheme}>
<StyledThemeProvider
theme={(baseTheme?: DefaultTheme): ExtendedTheme => ({
...(baseTheme as DefaultTheme),
spacing: {
xs: '0.25rem',
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
xl: '2rem',
xxl: '3rem',
},
})}
>
{children}
</StyledThemeProvider>
</BaseThemeProvider>
)
const AppWithExtras = () => (
@ -108,18 +67,6 @@ const AppWithExtras = () => (
</>
)
const spacingTheme = (baseTheme: Record<string, unknown> = {}) => ({
...baseTheme,
spacing: {
xs: '0.25rem',
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
xl: '2rem',
xxl: '3rem',
},
})
const AppWithProviders = () => (
<AnalyticsProvider config={analyticsConfig}>
<I18nProvider config={i18nConfig}>
@ -164,17 +111,6 @@ const BootstrapWithTheme = () => (
bootstrap({
App: BootstrapWithTheme,
queryClient: {
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: 1,
},
},
},
devTools: {
disableContentOverlay: true,
disableDeveloperFab: true,
},
...BOOTSTRAP_OPTIONS,
})
})()