From 168c332ceed64e355c13610fe36c71041cf960de Mon Sep 17 00:00:00 2001 From: Vula Builder Date: Tue, 15 Sep 2026 18:02:56 +0000 Subject: [PATCH] Deploy --- src/app/services/page.tsx | 148 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 src/app/services/page.tsx diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx new file mode 100644 index 0000000..0d73b25 --- /dev/null +++ b/src/app/services/page.tsx @@ -0,0 +1,148 @@ +'use client' +import { useState, useMemo } from 'react' +import { useProducts } from '@/hooks/useProducts' +import Image from 'next/image' +import Link from 'next/link' +import { Sparkles, ArrowLeft } from 'lucide-react' + +const CURRENCY_SYMBOLS: Record = { zar: 'R', usd: '$', eur: '€', gbp: '£', kes: 'KSh', ngn: '₦', ghs: '₵' } +const formatPrice = (amount: number, code?: string) => { + const sym = CURRENCY_SYMBOLS[(code ?? '').toLowerCase()] ?? (code?.toUpperCase() ?? '') + return `${sym}${(amount / 100).toFixed(0)}` +} + +export default function ServicesPage() { + const { products: services, loading, error } = useProducts(100) + const [activeCollection, setActiveCollection] = useState('All') + + const collections = useMemo(() => Array.from( + new Map(services.flatMap(s => s.collection ? [[s.collection.id, s.collection]] : [])).values() + ), [services]) + + const filtered = useMemo(() => { + if (activeCollection === 'All') return services + return services.filter(s => s.collection?.id === activeCollection) + }, [services, activeCollection]) + + // Does this business publish prices at all? Read from the services rather + // than configured, so a catalogue with no prices simply never mentions money. + // Computed over ALL services, not the filtered view — otherwise choosing a + // category would make prices appear and vanish. + const anyPriced = useMemo( + () => services.some(s => s.variants?.[0]?.prices?.[0]?.amount !== undefined), + [services], + ) + + if (loading) { + return ( +
+
+
+ ) + } + + if (error) { + return ( +
+

Failed to load services. Please try again.

+
+ ) + } + + return ( +
+ {/* Page header */} +
+
+ + + Back to home + +

Our Services

+ {services.length > 0 && ( +

{services.length} service{services.length !== 1 ? 's' : ''} available

+ )} +
+
+ +
+ {/* Collection filter tabs */} + {collections.length > 0 && ( +
+ {[{ id: 'All', title: 'All' }, ...collections].map(col => ( + + ))} +
+ )} + + {/* Services grid */} + {filtered.length === 0 ? ( +
+ +

+ {activeCollection !== 'All' ? 'No services in this category.' : 'Services coming soon.'} +

+ {activeCollection !== 'All' && ( + + )} +
+ ) : ( +
+ {filtered.map((service) => { + const variant = service.variants?.[0] + const price = variant?.prices?.[0] + const amount = price?.amount + const currency = price?.currency_code + return ( +
+ {service.thumbnail && ( +
+ {service.title} +
+ )} +
+ {service.collection?.title && ( + {service.collection.title} + )} +

{service.title}

+ {service.description && ( +

{service.description}

+ )} +
+ {/* No prices anywhere: the row closes up and no card + mentions money. Some priced: an empty span holds the + column, so a service without one reads as deliberate + rather than as a value that failed to load. Blank on + purpose — any words here belong to the owner. */} + {anyPriced && ( + amount !== undefined ? ( +

{formatPrice(amount, currency)}

+ ) : ( +
+
+
+ ) + })} +
+ )} +
+
+ ) +}