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:
parent
b662c83055
commit
49dec936cd
15 changed files with 296 additions and 242 deletions
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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) */}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export { slugify, productSlug, giftCardSlug, parseGiftCardSlug } from './slugify'
|
||||
export { slugify, productSlug, giftCardSlug, parseGiftCardSlug, merchDesignSlug, merchMediumFromSku, type MerchMedium } from './slugify'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue