Spaces:
Runtime error
Runtime error
| import { useEffect, useState, useMemo } from 'react'; | |
| import { API_URL } from '../config'; | |
| import { ROUTES } from '../lib/routes'; | |
| import { useLanguage } from '../i18n'; | |
| import MarketingHeader from './layout/MarketingHeader'; | |
| import MarketingFooter from './layout/MarketingFooter'; | |
| function categoryKey(type) { | |
| const t = (type || '').toLowerCase(); | |
| if (t.includes('restaurant') || t.includes('cafe') || t.includes('food')) return 'restaurant'; | |
| if (t.includes('hotel') || t.includes('riad') || t.includes('kasbah')) return 'hotel'; | |
| if (t.includes('service') || t.includes('salon') || t.includes('garage') || t.includes('cabinet') || t.includes('studio') || t.includes('agence')) return 'service'; | |
| if (t.includes('retail') || t.includes('boutique') || t.includes('commerce') || t.includes('épicerie') || t.includes('shop')) return 'retail'; | |
| return 'autre'; | |
| } | |
| const CATEGORY_COVERS = { | |
| restaurant: 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?auto=format&fit=crop&q=80&w=600&h=400', | |
| hotel: 'https://images.unsplash.com/photo-1540541338287-41700207dee6?auto=format&fit=crop&q=80&w=600&h=400', | |
| service: 'https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&q=80&w=600&h=400', | |
| retail: 'https://images.unsplash.com/photo-1441986300917-64674bd600d8?auto=format&fit=crop&q=80&w=600&h=400', | |
| autre: 'https://images.unsplash.com/photo-1504384308090-c894fdcc538d?auto=format&fit=crop&q=80&w=600&h=400', | |
| }; | |
| function coverFor(business) { | |
| if (business.cover_image_url) return business.cover_image_url; | |
| const key = categoryKey(business.business_type); | |
| return CATEGORY_COVERS[key] || CATEGORY_COVERS.autre; | |
| } | |
| export default function ShowcasePage() { | |
| const { t } = useLanguage(); | |
| const [businesses, setBusinesses] = useState([]); | |
| const [loading, setLoading] = useState(true); | |
| const [error, setError] = useState(null); | |
| const [activeCategory, setActiveCategory] = useState('all'); | |
| const CATEGORIES = useMemo(() => [ | |
| { key: 'all', label: t('showcase.all'), icon: 'apps' }, | |
| { key: 'restaurant', label: t('showcase.restaurants'), icon: 'restaurant' }, | |
| { key: 'hotel', label: t('showcase.hotels'), icon: 'bed' }, | |
| { key: 'service', label: t('showcase.services'), icon: 'build' }, | |
| { key: 'retail', label: t('showcase.shops'), icon: 'storefront' }, | |
| { key: 'autre', label: t('showcase.other'), icon: 'category' }, | |
| ], [t]); | |
| useEffect(() => { | |
| setLoading(true); | |
| setError(null); | |
| const controller = new AbortController(); | |
| fetch(`${API_URL}/businesses`, { signal: controller.signal }) | |
| .then((res) => { | |
| if (!res.ok) throw new Error('error'); | |
| return res.json(); | |
| }) | |
| .then((data) => { | |
| setBusinesses(data.businesses || []); | |
| setLoading(false); | |
| }) | |
| .catch((err) => { | |
| if (err.name === 'AbortError') return; | |
| setLoading(false); | |
| setError(true); | |
| }); | |
| return () => controller.abort(); | |
| }, []); | |
| const grouped = {}; | |
| businesses.forEach((b) => { | |
| const cat = categoryKey(b.business_type); | |
| if (!grouped[cat]) grouped[cat] = []; | |
| grouped[cat].push(b); | |
| }); | |
| const filtered = activeCategory === 'all' | |
| ? businesses | |
| : (grouped[activeCategory] || []); | |
| const hasBusinesses = businesses.length > 0; | |
| return ( | |
| <> | |
| <div className="bg-warm-bg text-on-background min-h-screen"> | |
| <MarketingHeader /> | |
| <section className="pt-32 pb-lg px-margin"> | |
| <div className="max-w-7xl mx-auto"> | |
| <div className="text-center mb-lg"> | |
| <h1 className="font-display-lg text-display-lg m-0 leading-tight"> | |
| {t('showcase.title')} | |
| </h1> | |
| <p className="font-body-lg text-body-lg text-on-surface-variant max-w-2xl mx-auto mt-md"> | |
| {t('showcase.subtitle')} | |
| </p> | |
| </div> | |
| {/* Category tabs */} | |
| <div className="flex flex-wrap justify-center gap-2 mb-xl"> | |
| {CATEGORIES.map((cat) => { | |
| const count = cat.key === 'all' ? businesses.length : (grouped[cat.key] || []).length; | |
| const isActive = activeCategory === cat.key; | |
| return ( | |
| <button | |
| key={cat.key} | |
| type="button" | |
| onClick={() => setActiveCategory(cat.key)} | |
| className={`inline-flex items-center gap-1.5 px-4 py-2 rounded-xl font-label-md text-label-md border transition-all cursor-pointer ${isActive | |
| ? 'bg-primary text-on-primary border-primary shadow-md' | |
| : 'bg-white text-on-surface-variant border-hairline-border hover:border-primary/30 hover:text-primary' | |
| }`} | |
| > | |
| <span className="material-symbols-outlined text-[16px]">{cat.icon}</span> | |
| <span>{cat.label}</span> | |
| <span className={`text-xs ml-0.5 ${isActive ? 'text-on-primary/70' : 'text-outline'}`}>({count})</span> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| {loading ? ( | |
| <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-md"> | |
| {Array.from({ length: 6 }).map((_, i) => ( | |
| <div key={i} className="bg-white border border-hairline-border rounded-xl overflow-hidden animate-pulse"> | |
| <div className="h-48 bg-outline/10" /> | |
| <div className="p-md space-y-2"> | |
| <div className="h-5 w-3/4 bg-outline/10 rounded" /> | |
| <div className="h-3 w-1/2 bg-outline/10 rounded" /> | |
| <div className="h-8 w-full bg-outline/10 rounded" /> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| ) : error ? ( | |
| <div className="text-center py-2xl"> | |
| <span className="material-symbols-outlined text-6xl text-warning mb-md">cloud_off</span> | |
| <p className="font-body-lg text-body-lg text-on-surface-variant">{t('common.error')}</p> | |
| <button onClick={() => window.location.reload()} className="mt-md bg-primary text-white px-md py-xs rounded no-underline border-0 cursor-pointer">{t('common.retry')}</button> | |
| </div> | |
| ) : !hasBusinesses ? ( | |
| <div className="text-center py-2xl"> | |
| <span className="material-symbols-outlined text-6xl text-outline-variant mb-md">storefront</span> | |
| <p className="font-body-lg text-body-lg text-on-surface-variant">{t('showcase.empty')}</p> | |
| </div> | |
| ) : filtered.length === 0 ? ( | |
| <div className="text-center py-2xl"> | |
| <span className="material-symbols-outlined text-6xl text-outline-variant mb-md">search_off</span> | |
| <p className="font-body-lg text-body-lg text-on-surface-variant">{t('showcase.emptyFilter')}</p> | |
| </div> | |
| ) : ( | |
| <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-md"> | |
| {filtered.map((b) => ( | |
| <a | |
| key={b.id} | |
| href={ROUTES.miniSite(b.slug)} | |
| className="group bg-white border border-hairline-border rounded-xl overflow-hidden no-underline transition-all duration-300 hover:shadow-xl hover:-translate-y-1 hover:border-primary/30" | |
| > | |
| <div className="h-48 bg-surface-variant relative overflow-hidden"> | |
| <img | |
| src={coverFor(b)} | |
| alt="" | |
| className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" | |
| /> | |
| <div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" /> | |
| {b.city && ( | |
| <div className="absolute bottom-3 left-3"> | |
| <span className="px-2 py-0.5 bg-white/90 backdrop-blur-sm text-on-surface-variant font-label-sm text-label-sm rounded-full border border-hairline-border inline-flex items-center gap-1"> | |
| <span className="material-symbols-outlined text-[12px]">location_on</span> | |
| {b.city} | |
| </span> | |
| </div> | |
| )} | |
| <div className="absolute top-3 right-3"> | |
| <span className="px-2 py-0.5 bg-white/90 backdrop-blur-sm text-on-surface font-label-sm text-label-sm rounded-full border border-hairline-border"> | |
| {categoryKey(b.business_type) === 'restaurant' ? t('onboarding.restaurant') : | |
| categoryKey(b.business_type) === 'hotel' ? t('onboarding.hotel') : | |
| categoryKey(b.business_type) === 'service' ? t('onboarding.services') : | |
| categoryKey(b.business_type) === 'retail' ? t('onboarding.shop') : t('showcase.other')} | |
| </span> | |
| </div> | |
| </div> | |
| <div className="p-md"> | |
| <h3 className="font-headline-sm text-headline-sm m-0 text-on-background group-hover:text-primary transition-colors"> | |
| {b.name} | |
| </h3> | |
| {b.description && ( | |
| <p className="font-body-md text-body-md text-on-surface-variant mt-1 line-clamp-2 m-0"> | |
| {b.description} | |
| </p> | |
| )} | |
| {(() => { | |
| try { | |
| const sm = JSON.parse(b.social_media || '{}'); | |
| const entries = Object.entries(sm).filter(([, v]) => v && v.trim()).slice(0, 4); | |
| if (entries.length === 0) return null; | |
| return ( | |
| <div className="flex flex-wrap gap-1.5 mt-2" onClick={(e) => e.stopPropagation()}> | |
| {entries.map(([platform, url]) => ( | |
| <a key={platform} href={url} target="_blank" rel="noopener noreferrer" | |
| className="w-6 h-6 rounded-full bg-surface-container-high flex items-center justify-center hover:bg-primary/10 transition-colors" | |
| title={platform}> | |
| <span className="material-symbols-outlined text-[14px] text-on-surface-variant"> | |
| {platform === 'instagram' ? 'camera_alt' : platform === 'facebook' ? 'facebook' : platform === 'linkedin' ? 'work' : platform === 'twitter' ? 'alternate_email' : platform === 'tiktok' ? 'music_note' : platform === 'youtube' ? 'play_circle' : 'link'} | |
| </span> | |
| </a> | |
| ))} | |
| </div> | |
| ); | |
| } catch { return null; } | |
| })()} | |
| <div className="mt-3 flex items-center gap-1 text-primary font-label-md text-label-md"> | |
| <span>{t('showcase.visit')}</span> | |
| <span className="material-symbols-outlined text-[16px] group-hover:translate-x-1 transition-transform">arrow_forward</span> | |
| </div> | |
| </div> | |
| </a> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </section> | |
| <MarketingFooter /> | |
| </div> | |
| </> | |
| ); | |
| } | |