Frontend : « Sur devis », prix dans la devise source, filtre « Fabricants seulement » (kind=fabricant), badges revendeur/collectif, coordonnées et livraison sur la fiche boutique
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7 changed files +136 −19
modified
frontend/src/api.ts
+43 −7
@@ -18,10 +18,13 @@ export interface Product { | ||
| 18 | 18 | tags: string[] |
| 19 | 19 | vendor: string | null |
| 20 | 20 | available: boolean |
| 21 | + price_on_request?: number | |
| 22 | + listing_status?: string | |
| 21 | 23 | store_name: string |
| 22 | 24 | store_region: string | null |
| 23 | 25 | store_city: string | null |
| 24 | 26 | origin_class: OriginClass |
| 27 | + store_kind?: string | null | |
| 25 | 28 | } |
| 26 | 29 | |
| 27 | 30 | export interface ProductDetail extends Product { |
@@ -51,6 +54,18 @@ export interface Store { | ||
| 51 | 54 | logo_url: string | null |
| 52 | 55 | cover_url: string | null |
| 53 | 56 | description_meta: string | null |
| 57 | + store_kind?: string | null | |
| 58 | + email?: string | null | |
| 59 | + phone?: string | null | |
| 60 | + lat?: number | null | |
| 61 | + lng?: number | null | |
| 62 | + shipping_info?: string | null | |
| 63 | +} | |
| 64 | + | |
| 65 | +export const STORE_KIND_LABELS: Record<string, string> = { | |
| 66 | + revendeur: 'Revendeur', | |
| 67 | + collectif: "Collectif d'artisans", | |
| 68 | + hors_mission: 'Hors mission', | |
| 54 | 69 | } |
| 55 | 70 | |
| 56 | 71 | export interface StoreProductStats { |
@@ -357,7 +372,12 @@ export function productFavItem(p: Product): FavoriteItem { | ||
| 357 | 372 | subtitle: [p.store_name, p.store_region || p.category] |
| 358 | 373 | .filter(Boolean) |
| 359 | 374 | .join(' · '), |
| 360 | − price_label: p.price !== null ? formatPrice(p.price) : '', | |
| 375 | + price_label: | |
| 376 | + p.price !== null | |
| 377 | + ? formatPrice(p.price, p.currency) | |
| 378 | + : p.price_on_request | |
| 379 | + ? 'Sur devis' | |
| 380 | + : '', | |
| 361 | 381 | image_url: p.images.length > 0 ? p.images[0] : '', |
| 362 | 382 | url: `https://www.fabri-ka.com${productPath(p)}`, |
| 363 | 383 | } |
@@ -484,6 +504,7 @@ export interface ProductQuery { | ||
| 484 | 504 | origin?: string |
| 485 | 505 | price_min?: string | number |
| 486 | 506 | price_max?: string | number |
| 507 | + kind?: string | |
| 487 | 508 | sort?: SortKey | string |
| 488 | 509 | page?: number |
| 489 | 510 | per_page?: number |
@@ -568,14 +589,29 @@ export function fetchExtendedStats( | ||
| 568 | 589 | // Formatting helpers (fr-CA) |
| 569 | 590 | // --------------------------------------------------------------------------- |
| 570 | 591 | |
| 571 | −const cadFormatter = new Intl.NumberFormat('fr-CA', { | |
| 572 | − style: 'currency', | |
| 573 | − currency: 'CAD', | |
| 574 | −}) | |
| 592 | +const priceFormatters = new Map<string, Intl.NumberFormat>() | |
| 575 | 593 | |
| 576 | −export function formatPrice(value: number | null | undefined): string { | |
| 594 | +function formatterFor(currency: string): Intl.NumberFormat { | |
| 595 | + let f = priceFormatters.get(currency) | |
| 596 | + if (!f) { | |
| 597 | + f = new Intl.NumberFormat('fr-CA', { style: 'currency', currency }) | |
| 598 | + priceFormatters.set(currency, f) | |
| 599 | + } | |
| 600 | + return f | |
| 601 | +} | |
| 602 | + | |
| 603 | +/** Prix formaté dans la devise SOURCE ("24,95 $", "27,50 $ US", "12,00 €"). */ | |
| 604 | +export function formatPrice( | |
| 605 | + value: number | null | undefined, | |
| 606 | + currency: string = 'CAD' | |
| 607 | +): string { | |
| 577 | 608 | if (value === null || value === undefined || Number.isNaN(value)) return '' |
| 578 | − return cadFormatter.format(value) | |
| 609 | + const cur = currency && currency.length === 3 ? currency.toUpperCase() : 'CAD' | |
| 610 | + try { | |
| 611 | + return formatterFor(cur).format(value) | |
| 612 | + } catch { | |
| 613 | + return formatterFor('CAD').format(value) | |
| 614 | + } | |
| 579 | 615 | } |
| 580 | 616 | |
| 581 | 617 | const cadCompactFormatter = new Intl.NumberFormat('fr-CA', { |
modified
frontend/src/components/Filters.tsx
+18 −1
@@ -8,6 +8,8 @@ export interface FilterValues { | ||
| 8 | 8 | origins: string[] |
| 9 | 9 | priceMin: string |
| 10 | 10 | priceMax: string |
| 11 | + /** 'fabricant' = masque les revendeurs (boutiques non fabricantes) */ | |
| 12 | + kind: string | |
| 11 | 13 | sort: string |
| 12 | 14 | } |
| 13 | 15 | |
@@ -37,7 +39,8 @@ export function countActiveFilters(values: FilterValues): number { | ||
| 37 | 39 | (values.region ? 1 : 0) + |
| 38 | 40 | values.origins.length + |
| 39 | 41 | (values.priceMin ? 1 : 0) + |
| 40 | − (values.priceMax ? 1 : 0) | |
| 42 | + (values.priceMax ? 1 : 0) + | |
| 43 | + (values.kind ? 1 : 0) | |
| 41 | 44 | ) |
| 42 | 45 | } |
| 43 | 46 | |
@@ -140,6 +143,20 @@ export function FiltersBody({ facets, values, onChange, idPrefix = '' }: BodyPro | ||
| 140 | 143 | </select> |
| 141 | 144 | </div> |
| 142 | 145 | |
| 146 | + <div className="filter-group"> | |
| 147 | + <span className="filter-label">Boutiques</span> | |
| 148 | + <label className="filter-checkbox"> | |
| 149 | + <input | |
| 150 | + type="checkbox" | |
| 151 | + checked={values.kind === 'fabricant'} | |
| 152 | + onChange={() => | |
| 153 | + onChange({ kind: values.kind === 'fabricant' ? '' : 'fabricant' }) | |
| 154 | + } | |
| 155 | + /> | |
| 156 | + <span>Fabricants seulement</span> | |
| 157 | + </label> | |
| 158 | + </div> | |
| 159 | + | |
| 143 | 160 | <div className="filter-group"> |
| 144 | 161 | <span className="filter-label">Origine</span> |
| 145 | 162 | {ORIGIN_KEYS.map((key) => ( |
modified
frontend/src/components/ProductCard.tsx
+7 −3
@@ -38,15 +38,19 @@ export default function ProductCard({ product }: { product: Product }) { | ||
| 38 | 38 | <h3 className="product-card-title">{product.title}</h3> |
| 39 | 39 | <div className="product-card-price"> |
| 40 | 40 | {product.price !== null ? ( |
| 41 | − <span className="price-chip">{formatPrice(product.price)}</span> | |
| 41 | + <span className="price-chip"> | |
| 42 | + {formatPrice(product.price, product.currency)} | |
| 43 | + </span> | |
| 42 | 44 | ) : ( |
| 43 | − <span className="price-chip price-chip-muted">Prix variable</span> | |
| 45 | + <span className="price-chip price-chip-muted"> | |
| 46 | + {product.price_on_request ? 'Sur devis' : 'Prix variable'} | |
| 47 | + </span> | |
| 44 | 48 | )} |
| 45 | 49 | {product.compare_at_price !== null && |
| 46 | 50 | product.price !== null && |
| 47 | 51 | product.compare_at_price > product.price && ( |
| 48 | 52 | <s className="price-compare"> |
| 49 | − {formatPrice(product.compare_at_price)} | |
| 53 | + {formatPrice(product.compare_at_price, product.currency)} | |
| 50 | 54 | </s> |
| 51 | 55 | )} |
| 52 | 56 | </div> |
modified
frontend/src/components/StoreCard.tsx
+6 −1
@@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { Link } from 'react-router-dom' |
| 2 | −import { formatInt, hostnameOf, Store } from '../api' | |
| 2 | +import { formatInt, hostnameOf, Store, STORE_KIND_LABELS } from '../api' | |
| 3 | 3 | import { IconMapPin } from './Icons' |
| 4 | 4 | import OriginBadge from './OriginBadge' |
| 5 | 5 | import StoreLogo from './StoreLogo' |
@@ -28,6 +28,11 @@ export default function StoreCard({ store }: { store: Store }) { | ||
| 28 | 28 | {store.platform && ( |
| 29 | 29 | <span className="platform-badge">{store.platform}</span> |
| 30 | 30 | )} |
| 31 | + {store.store_kind && STORE_KIND_LABELS[store.store_kind] && ( | |
| 32 | + <span className="platform-badge" title="Type de boutique"> | |
| 33 | + {STORE_KIND_LABELS[store.store_kind]} | |
| 34 | + </span> | |
| 35 | + )} | |
| 31 | 36 | <span className="store-card-count"> |
| 32 | 37 | {formatInt(store.product_count)} produit |
| 33 | 38 | {store.product_count === 1 ? '' : 's'} |
modified
frontend/src/pages/Catalog.tsx
+4 −0
@@ -50,6 +50,7 @@ export default function Catalog() { | ||
| 50 | 50 | .filter((o) => o.length > 0), |
| 51 | 51 | priceMin: searchParams.get('price_min') ?? '', |
| 52 | 52 | priceMax: searchParams.get('price_max') ?? '', |
| 53 | + kind: searchParams.get('kind') ?? '', | |
| 53 | 54 | sort: searchParams.get('sort') ?? 'recent', |
| 54 | 55 | }), |
| 55 | 56 | [searchParams, fixedCategory, fixedRegion] |
@@ -92,6 +93,7 @@ export default function Catalog() { | ||
| 92 | 93 | origin: values.origins.length > 0 ? values.origins.join(',') : undefined, |
| 93 | 94 | price_min: values.priceMin || undefined, |
| 94 | 95 | price_max: values.priceMax || undefined, |
| 96 | + kind: values.kind || undefined, | |
| 95 | 97 | sort: values.sort || 'recent', |
| 96 | 98 | page, |
| 97 | 99 | per_page: PER_PAGE, |
@@ -127,6 +129,7 @@ export default function Catalog() { | ||
| 127 | 129 | if (merged.origins.length > 0) sp.set('origin', merged.origins.join(',')) |
| 128 | 130 | if (merged.priceMin) sp.set('price_min', merged.priceMin) |
| 129 | 131 | if (merged.priceMax) sp.set('price_max', merged.priceMax) |
| 132 | + if (merged.kind) sp.set('kind', merged.kind) | |
| 130 | 133 | if (merged.sort && merged.sort !== 'recent') sp.set('sort', merged.sort) |
| 131 | 134 | const qs = sp.toString() |
| 132 | 135 | navigate(qs ? `/produits?${qs}` : '/produits') |
@@ -146,6 +149,7 @@ export default function Catalog() { | ||
| 146 | 149 | setOrDelete('origin', merged.origins.join(',')) |
| 147 | 150 | setOrDelete('price_min', merged.priceMin) |
| 148 | 151 | setOrDelete('price_max', merged.priceMax) |
| 152 | + setOrDelete('kind', merged.kind) | |
| 149 | 153 | setOrDelete('sort', merged.sort !== 'recent' ? merged.sort : '') |
| 150 | 154 | if (resetPage) next.delete('page') |
| 151 | 155 | return next |
modified
frontend/src/pages/ProductDetail.tsx
+24 −7
@@ -8,6 +8,7 @@ import { | ||
| 8 | 8 | ORIGIN_LABELS, |
| 9 | 9 | ProductDetail as ProductDetailType, |
| 10 | 10 | setPageMeta, |
| 11 | + STORE_KIND_LABELS, | |
| 11 | 12 | } from '../api' |
| 12 | 13 | import EmptyState from '../components/EmptyState' |
| 13 | 14 | import { IconExternal, IconLeaf } from '../components/Icons' |
@@ -45,7 +46,7 @@ export default function ProductDetail() { | ||
| 45 | 46 | setProduct(p) |
| 46 | 47 | setPageMeta( |
| 47 | 48 | `${p.title} — ${p.store_name} | Fabri-Ka`, |
| 48 | − [p.title, formatPrice(p.price), `offert par ${p.store_name}`] | |
| 49 | + [p.title, formatPrice(p.price, p.currency), `offert par ${p.store_name}`] | |
| 49 | 50 | .filter(Boolean) |
| 50 | 51 | .join(' — ') |
| 51 | 52 | ) |
@@ -186,6 +187,11 @@ export default function ProductDetail() { | ||
| 186 | 187 | <div className="product-detail-info"> |
| 187 | 188 | <div className="product-detail-badges"> |
| 188 | 189 | <OriginBadge origin={product.origin_class} withLabel /> |
| 190 | + {product.store_kind && STORE_KIND_LABELS[product.store_kind] && ( | |
| 191 | + <span className="tag" title="Type de boutique"> | |
| 192 | + {STORE_KIND_LABELS[product.store_kind]} | |
| 193 | + </span> | |
| 194 | + )} | |
| 189 | 195 | {!product.available && ( |
| 190 | 196 | <span className="unavailable-badge">Non disponible</span> |
| 191 | 197 | )} |
@@ -198,21 +204,28 @@ export default function ProductDetail() { | ||
| 198 | 204 | <div className="product-detail-price"> |
| 199 | 205 | {product.price !== null ? ( |
| 200 | 206 | <span className="price-chip price-chip-lg"> |
| 201 | − {formatPrice(product.price)} | |
| 207 | + {formatPrice(product.price, product.currency)} | |
| 202 | 208 | {product.price_max !== null && |
| 203 | 209 | product.price_max > product.price && |
| 204 | − ` – ${formatPrice(product.price_max)}`} | |
| 210 | + ` – ${formatPrice(product.price_max, product.currency)}`} | |
| 205 | 211 | </span> |
| 206 | 212 | ) : ( |
| 207 | 213 | <span className="price-chip price-chip-muted"> |
| 208 | − Prix affiché en boutique | |
| 214 | + {product.price_on_request | |
| 215 | + ? 'Sur devis — prix non publié par la boutique' | |
| 216 | + : 'Prix affiché en boutique'} | |
| 209 | 217 | </span> |
| 210 | 218 | )} |
| 211 | 219 | {hasDiscount && ( |
| 212 | 220 | <s className="price-compare"> |
| 213 | − {formatPrice(product.compare_at_price)} | |
| 221 | + {formatPrice(product.compare_at_price, product.currency)} | |
| 214 | 222 | </s> |
| 215 | 223 | )} |
| 224 | + {product.currency && product.currency !== 'CAD' && ( | |
| 225 | + <span className="tag" title="Devise de la boutique"> | |
| 226 | + Devise : {product.currency} | |
| 227 | + </span> | |
| 228 | + )} | |
| 216 | 229 | </div> |
| 217 | 230 | |
| 218 | 231 | {description && ( |
@@ -330,9 +343,13 @@ export default function ProductDetail() { | ||
| 330 | 343 | <div className="pd-cta-bar"> |
| 331 | 344 | <div className="pd-cta-price"> |
| 332 | 345 | {product.price !== null ? ( |
| 333 | − <span className="price-chip">{formatPrice(product.price)}</span> | |
| 346 | + <span className="price-chip"> | |
| 347 | + {formatPrice(product.price, product.currency)} | |
| 348 | + </span> | |
| 334 | 349 | ) : ( |
| 335 | − <span className="price-chip price-chip-muted">Prix en boutique</span> | |
| 350 | + <span className="price-chip price-chip-muted"> | |
| 351 | + {product.price_on_request ? 'Sur devis' : 'Prix en boutique'} | |
| 352 | + </span> | |
| 336 | 353 | )} |
| 337 | 354 | </div> |
| 338 | 355 | <a |
modified
frontend/src/pages/StoreDetail.tsx
+34 −0
@@ -9,6 +9,7 @@ import { | ||
| 9 | 9 | hostnameOf, |
| 10 | 10 | ProductsResponse, |
| 11 | 11 | StoreDetail as StoreDetailType, |
| 12 | + STORE_KIND_LABELS, | |
| 12 | 13 | } from '../api' |
| 13 | 14 | import EmptyState from '../components/EmptyState' |
| 14 | 15 | import { |
@@ -229,6 +230,11 @@ export default function StoreDetail() { | ||
| 229 | 230 | {location} |
| 230 | 231 | </span> |
| 231 | 232 | <OriginBadge origin={store.origin_class} withLabel /> |
| 233 | + {store.store_kind && STORE_KIND_LABELS[store.store_kind] && ( | |
| 234 | + <span className="store-hero-chip" title="Type de boutique"> | |
| 235 | + {STORE_KIND_LABELS[store.store_kind]} | |
| 236 | + </span> | |
| 237 | + )} | |
| 232 | 238 | </div> |
| 233 | 239 | <div className="store-hero-actions"> |
| 234 | 240 | <a |
@@ -282,6 +288,34 @@ export default function StoreDetail() { | ||
| 282 | 288 | </section> |
| 283 | 289 | )} |
| 284 | 290 | |
| 291 | + {/* ---- Coordonnées & livraison ------------------------------- */} | |
| 292 | + {(store.email || store.phone || store.shipping_info) && ( | |
| 293 | + <section className="store-about" aria-label="Coordonnées et livraison"> | |
| 294 | + {(store.email || store.phone) && ( | |
| 295 | + <p className="store-about-text"> | |
| 296 | + {store.email && ( | |
| 297 | + <> | |
| 298 | + Courriel :{' '} | |
| 299 | + <a href={`mailto:${store.email}`}>{store.email}</a> | |
| 300 | + </> | |
| 301 | + )} | |
| 302 | + {store.email && store.phone && ' · '} | |
| 303 | + {store.phone && ( | |
| 304 | + <> | |
| 305 | + Téléphone :{' '} | |
| 306 | + <a href={`tel:${store.phone}`}>{store.phone}</a> | |
| 307 | + </> | |
| 308 | + )} | |
| 309 | + </p> | |
| 310 | + )} | |
| 311 | + {store.shipping_info && ( | |
| 312 | + <p className="store-about-text"> | |
| 313 | + <strong>Livraison :</strong> {store.shipping_info} | |
| 314 | + </p> | |
| 315 | + )} | |
| 316 | + </section> | |
| 317 | + )} | |
| 318 | + | |
| 285 | 319 | {/* ---- Stats band -------------------------------------------- */} |
| 286 | 320 | <section className="store-stats" aria-label="Statistiques de la boutique"> |
| 287 | 321 | <div className="store-stat-tile"> |
| 288 | 322 | |