SPB Git forge

spb/fabri-ka

Public

Agrégateur de produits québécois — www.fabri-ka.com

217commits 1branches 0releases
66.1 MBsize
maindefault branch
4 h agolast push
Python 38.4% HTML 30.3% TypeScript 17.2% CSS 11% JavaScript 3.2%

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 19, 2026) parent b99ed8a

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