diff --git a/features/landing/backend-api/src/idea-voting/entities/idea-daily-vote.entity.ts b/features/landing/backend-api/src/idea-voting/entities/idea-daily-vote.entity.ts new file mode 100644 index 000000000..3d477bfaf --- /dev/null +++ b/features/landing/backend-api/src/idea-voting/entities/idea-daily-vote.entity.ts @@ -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 +} diff --git a/features/landing/frontend-public/src/components/ProductDetailModal.tsx b/features/landing/frontend-public/src/components/ProductDetailModal.tsx index a92aa2820..2d17a7513 100755 --- a/features/landing/frontend-public/src/components/ProductDetailModal.tsx +++ b/features/landing/frontend-public/src/components/ProductDetailModal.tsx @@ -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() @@ -185,6 +199,30 @@ export default function ProductDetailModal({ )} + {/* Medium Switcher (when multiple mediums available for this design) */} + {siblingMediums.length > 1 && ( +
+ +
+ {siblingMediums.map((sibling) => ( + + ))} +
+
+ )} +

{product.description}

{/* Size Selection (only when product has size variants) */} diff --git a/features/landing/frontend-public/src/contexts/CartContext.tsx b/features/landing/frontend-public/src/contexts/CartContext.tsx index b973614ed..7e7926dbf 100755 --- a/features/landing/frontend-public/src/contexts/CartContext.tsx +++ b/features/landing/frontend-public/src/contexts/CartContext.tsx @@ -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 */ diff --git a/features/landing/frontend-public/src/hooks/useIdeas.ts b/features/landing/frontend-public/src/hooks/useIdeas.ts index 6cfa00544..1380ced39 100755 --- a/features/landing/frontend-public/src/hooks/useIdeas.ts +++ b/features/landing/frontend-public/src/hooks/useIdeas.ts @@ -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(null) + + const castFreeVote = useCallback( + async (ideaId: string): Promise => { + 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, + } +} diff --git a/features/landing/frontend-public/src/index.css b/features/landing/frontend-public/src/index.css index ca2417482..3bd8a9920 100755 --- a/features/landing/frontend-public/src/index.css +++ b/features/landing/frontend-public/src/index.css @@ -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; diff --git a/features/landing/frontend-public/src/main.tsx b/features/landing/frontend-public/src/main.tsx index 3ce709a8f..5204ff14b 100755 --- a/features/landing/frontend-public/src/main.tsx +++ b/features/landing/frontend-public/src/main.tsx @@ -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 { - 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 }) => ( - - ({ - ...(baseTheme as DefaultTheme), - spacing: { - xs: '0.25rem', - sm: '0.5rem', - md: '1rem', - lg: '1.5rem', - xl: '2rem', - xxl: '3rem', - }, - })} - > - {children} - - - ) +// 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 = {}) => ({ - ...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 = () => ( ( ) -// 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, }) })() diff --git a/features/landing/frontend-public/src/pages/shop/Shop.css b/features/landing/frontend-public/src/pages/shop/Shop.css index 0058bcb75..de1af2590 100755 --- a/features/landing/frontend-public/src/pages/shop/Shop.css +++ b/features/landing/frontend-public/src/pages/shop/Shop.css @@ -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 { diff --git a/features/landing/frontend-public/src/pages/shop/ShopApparelPage.tsx b/features/landing/frontend-public/src/pages/shop/ShopApparelPage.tsx index 26de81eb4..df4da0c13 100755 --- a/features/landing/frontend-public/src/pages/shop/ShopApparelPage.tsx +++ b/features/landing/frontend-public/src/pages/shop/ShopApparelPage.tsx @@ -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 = { + 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(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), [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() { ) diff --git a/features/landing/frontend-public/src/pages/shop/ShopIdeasPage.tsx b/features/landing/frontend-public/src/pages/shop/ShopIdeasPage.tsx index 75f1b7fcd..98689f757 100755 --- a/features/landing/frontend-public/src/pages/shop/ShopIdeasPage.tsx +++ b/features/landing/frontend-public/src/pages/shop/ShopIdeasPage.tsx @@ -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 (
@@ -121,9 +132,10 @@ export default function ShopIdeasPage() { {/* Featured Ideas Carousel */} @@ -172,7 +185,7 @@ export default function ShopIdeasPage() { {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) => ( {index > 0 && arr[index - 1] !== p - 1 && ( diff --git a/features/landing/frontend-public/src/routes/paths.ts b/features/landing/frontend-public/src/routes/paths.ts index 0685957a1..419bb2c88 100755 --- a/features/landing/frontend-public/src/routes/paths.ts +++ b/features/landing/frontend-public/src/routes/paths.ts @@ -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}` } /** diff --git a/features/landing/frontend-public/src/routes/patterns.ts b/features/landing/frontend-public/src/routes/patterns.ts index ada6d8dcd..4ec3277f0 100755 --- a/features/landing/frontend-public/src/routes/patterns.ts +++ b/features/landing/frontend-public/src/routes/patterns.ts @@ -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', diff --git a/features/landing/frontend-public/src/routes/route-config.ts b/features/landing/frontend-public/src/routes/route-config.ts index 3eaab1b65..039e1ea12 100644 --- a/features/landing/frontend-public/src/routes/route-config.ts +++ b/features/landing/frontend-public/src/routes/route-config.ts @@ -42,17 +42,19 @@ const staticRouteConfigs: Record = { '/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' }, } diff --git a/features/landing/frontend-public/src/utils/index.ts b/features/landing/frontend-public/src/utils/index.ts index 6af5978a0..0736e7733 100755 --- a/features/landing/frontend-public/src/utils/index.ts +++ b/features/landing/frontend-public/src/utils/index.ts @@ -1 +1 @@ -export { slugify, productSlug, giftCardSlug, parseGiftCardSlug } from './slugify' +export { slugify, productSlug, giftCardSlug, parseGiftCardSlug, merchDesignSlug, merchMediumFromSku, type MerchMedium } from './slugify' diff --git a/features/landing/frontend-public/src/utils/slugify.ts b/features/landing/frontend-public/src/utils/slugify.ts index 7134c849a..f16e87922 100755 --- a/features/landing/frontend-public/src/utils/slugify.ts +++ b/features/landing/frontend-public/src/utils/slugify.ts @@ -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 = { + '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 +} diff --git a/features/landing/frontend-standalone/src/main.tsx b/features/landing/frontend-standalone/src/main.tsx index 305bba59b..fd8f20a32 100644 --- a/features/landing/frontend-standalone/src/main.tsx +++ b/features/landing/frontend-standalone/src/main.tsx @@ -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 }) => ( - - ({ - ...(baseTheme as DefaultTheme), - spacing: { - xs: '0.25rem', - sm: '0.5rem', - md: '1rem', - lg: '1.5rem', - xl: '2rem', - xxl: '3rem', - }, - })} - > - {children} - - ) const AppWithExtras = () => ( @@ -108,18 +67,6 @@ const AppWithExtras = () => ( ) -const spacingTheme = (baseTheme: Record = {}) => ({ - ...baseTheme, - spacing: { - xs: '0.25rem', - sm: '0.5rem', - md: '1rem', - lg: '1.5rem', - xl: '2rem', - xxl: '3rem', - }, -}) - const AppWithProviders = () => ( @@ -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, }) })()