@salifbiaye/create-boutique 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/cli/dist/code.js +110 -0
  2. package/cli/dist/commands.js +241 -17
  3. package/cli/dist/create.js +108 -15
  4. package/cli/dist/generate.js +108 -39
  5. package/cli/dist/guide.js +12 -4
  6. package/cli/dist/index.js +38 -7
  7. package/cli/dist/payments.js +150 -0
  8. package/cli/dist/project.js +42 -3
  9. package/docs/fiche-client.en.html +2 -2
  10. package/docs/fiche-client.html +2 -2
  11. package/docs/guide-complet.fr.html +681 -256
  12. package/docs/guide-images/categories.jpg +0 -0
  13. package/docs/guide-images/clients.jpg +0 -0
  14. package/docs/guide-images/collections.jpg +0 -0
  15. package/docs/guide-images/commande-detail.jpg +0 -0
  16. package/docs/guide-images/commande-expedier.jpg +0 -0
  17. package/docs/guide-images/commande-menu.jpg +0 -0
  18. package/docs/guide-images/commande-paiement.jpg +0 -0
  19. package/docs/guide-images/commandes-liste.jpg +0 -0
  20. package/docs/guide-images/connexion.jpg +0 -0
  21. package/docs/guide-images/groupes-clients.jpg +0 -0
  22. package/docs/guide-images/inventaire.jpg +0 -0
  23. package/docs/guide-images/listes-prix.jpg +0 -0
  24. package/docs/guide-images/menu.jpg +0 -0
  25. package/docs/guide-images/mes-boutiques-fermer.jpg +0 -0
  26. package/docs/guide-images/mes-boutiques-panneau.jpg +0 -0
  27. package/docs/guide-images/mes-boutiques.jpg +0 -0
  28. package/docs/guide-images/parametres-boutique.jpg +0 -0
  29. package/docs/guide-images/parametres-canaux.jpg +0 -0
  30. package/docs/guide-images/parametres-emplacements.jpg +0 -0
  31. package/docs/guide-images/parametres-profil.jpg +0 -0
  32. package/docs/guide-images/parametres-regions.jpg +0 -0
  33. package/docs/guide-images/parametres-utilisateurs.jpg +0 -0
  34. package/docs/guide-images/produit-creer-details.jpg +0 -0
  35. package/docs/guide-images/produit-detail.jpg +0 -0
  36. package/docs/guide-images/produit-variantes-menu.jpg +0 -0
  37. package/docs/guide-images/produit-variantes.jpg +0 -0
  38. package/docs/guide-images/produits-liste.jpg +0 -0
  39. package/docs/guide-images/promotion-details.jpg +0 -0
  40. package/docs/guide-images/promotion-type.jpg +0 -0
  41. package/docs/guide-images/promotions-liste.jpg +0 -0
  42. package/docs/guide-images/site-boutique.jpg +0 -0
  43. package/docs/guide-images/site-fermee.png +0 -0
  44. package/docs/guide-images/vue-ensemble.jpg +0 -0
  45. package/package.json +3 -2
  46. package/templates/backend/medusa-config.ts +31 -25
  47. package/templates/backend/package-lock.json +21 -0
  48. package/templates/backend/package.json +2 -0
  49. package/templates/backend/src/admin/i18n/json/en.json +63 -5
  50. package/templates/backend/src/admin/i18n/json/fr.json +63 -5
  51. package/templates/backend/src/admin/routes/boutique-campaigns/campaign-drawer.tsx +29 -17
  52. package/templates/backend/src/admin/routes/boutique-campaigns/page.tsx +84 -6
  53. package/templates/backend/src/admin/routes/reviews/page.tsx +259 -0
  54. package/templates/backend/src/api/admin/active-boutique/route.ts +25 -0
  55. package/templates/backend/src/api/admin/boutique-campaigns/[salesChannelId]/route.ts +7 -1
  56. package/templates/backend/src/api/admin/boutique-campaigns/route.ts +9 -1
  57. package/templates/backend/src/api/admin/boutique-online/[salesChannelId]/route.ts +22 -0
  58. package/templates/backend/src/api/admin/reviews/[id]/route.ts +15 -0
  59. package/templates/backend/src/api/admin/reviews/route.ts +47 -0
  60. package/templates/backend/src/api/middlewares.ts +9 -0
  61. package/templates/backend/src/api/store/campaign/route.ts +5 -1
  62. package/templates/backend/src/api/store/reviews/latest/route.ts +39 -0
  63. package/templates/backend/src/api/store/reviews/order/route.ts +36 -0
  64. package/templates/backend/src/api/store/reviews/route.ts +43 -0
  65. package/templates/backend/src/api/store/reviews/summary/route.ts +28 -0
  66. package/templates/backend/src/emails/en.json +3 -1
  67. package/templates/backend/src/emails/fr.json +3 -1
  68. package/templates/backend/src/lib/active.ts +41 -0
  69. package/templates/backend/src/lib/email-config.ts +14 -0
  70. package/templates/backend/src/lib/emails.ts +9 -0
  71. package/templates/backend/src/lib/notify.ts +14 -8
  72. package/templates/backend/src/lib/payment-config.ts +66 -0
  73. package/templates/backend/src/lib/project.ts +38 -1
  74. package/templates/backend/src/lib/redirect-payment.ts +264 -0
  75. package/templates/backend/src/lib/review-token.ts +40 -0
  76. package/templates/backend/src/lib/reviews.ts +25 -0
  77. package/templates/backend/src/modules/naboopay/index.ts +6 -0
  78. package/templates/backend/src/modules/naboopay/service.ts +92 -0
  79. package/templates/backend/src/modules/paydunya/index.ts +6 -0
  80. package/templates/backend/src/modules/paydunya/service.ts +89 -0
  81. package/templates/backend/src/modules/paytech/index.ts +6 -0
  82. package/templates/backend/src/modules/paytech/service.ts +114 -0
  83. package/templates/backend/src/modules/product-review/index.ts +8 -0
  84. package/templates/backend/src/modules/product-review/migrations/Migration20261001090000.ts +18 -0
  85. package/templates/backend/src/modules/product-review/models/review.ts +25 -0
  86. package/templates/backend/src/modules/product-review/service.ts +6 -0
  87. package/templates/backend/src/modules/product-review/validators.ts +27 -0
  88. package/templates/backend/src/modules/smtp/index.ts +6 -0
  89. package/templates/backend/src/modules/smtp/service.ts +73 -0
  90. package/templates/backend/src/scripts/rewrite-file-urls.ts +56 -0
  91. package/templates/backend/src/scripts/seed.ts +60 -36
  92. package/templates/backend/src/subscribers/fulfillment-emails.ts +1 -1
  93. package/templates/backend/src/workflows/reviews.ts +21 -0
  94. package/templates/backend/src/workflows/steps/create-review.ts +52 -0
  95. package/templates/backend/src/workflows/steps/update-review.ts +36 -0
  96. package/templates/storefront/PERSONNALISER.md +134 -0
  97. package/templates/storefront/locales/en.json +198 -11
  98. package/templates/storefront/locales/fr.json +198 -11
  99. package/templates/storefront/public/custom/LISEZMOI.txt +2 -0
  100. package/templates/storefront/src/app/(checkout)/layout.tsx +1 -1
  101. package/templates/storefront/src/app/(main)/layout.tsx +8 -1
  102. package/templates/storefront/src/app/(main)/legal/[page]/page.tsx +42 -7
  103. package/templates/storefront/src/app/(main)/page.tsx +149 -4
  104. package/templates/storefront/src/app/(main)/products/[handle]/page.tsx +4 -1
  105. package/templates/storefront/src/app/(main)/review/page.tsx +60 -0
  106. package/templates/storefront/src/app/(main)/store/page.tsx +3 -1
  107. package/templates/storefront/src/app/(main)/template.tsx +17 -2
  108. package/templates/storefront/src/app/layout.tsx +13 -1
  109. package/templates/storefront/src/lib/boutique/campaign.ts +5 -1
  110. package/templates/storefront/src/lib/boutique/config.ts +71 -23
  111. package/templates/storefront/src/lib/boutique/page-name.ts +25 -0
  112. package/templates/storefront/src/lib/boutique/schema.ts +7 -2
  113. package/templates/storefront/src/lib/config.ts +37 -8
  114. package/templates/storefront/src/lib/constants.tsx +17 -0
  115. package/templates/storefront/src/lib/data/cart.ts +4 -1
  116. package/templates/storefront/src/lib/data/cookies.ts +14 -2
  117. package/templates/storefront/src/lib/data/review-actions.ts +28 -0
  118. package/templates/storefront/src/lib/data/reviews.ts +64 -0
  119. package/templates/storefront/src/modules/checkout/components/payment/index.tsx +26 -4
  120. package/templates/storefront/src/modules/checkout/components/payment-button/index.tsx +85 -2
  121. package/templates/storefront/src/modules/checkout/components/shipping-address/index.tsx +6 -1
  122. package/templates/storefront/src/modules/products/templates/index.tsx +5 -0
  123. package/templates/storefront/src/modules/store/components/shop-search.tsx +85 -0
  124. package/templates/storefront/src/modules/store/templates/index.tsx +16 -8
  125. package/templates/storefront/src/modules/store/templates/paginated-products.tsx +15 -3
  126. package/templates/storefront/src/modules/ub/album.tsx +2 -0
  127. package/templates/storefront/src/modules/ub/arena.tsx +182 -0
  128. package/templates/storefront/src/modules/ub/campaign-blocks.tsx +3 -2
  129. package/templates/storefront/src/modules/ub/campaign-ended.tsx +27 -13
  130. package/templates/storefront/src/modules/ub/cinema.tsx +2 -0
  131. package/templates/storefront/src/modules/ub/cookie-banner.tsx +62 -0
  132. package/templates/storefront/src/modules/ub/gala.tsx +2 -0
  133. package/templates/storefront/src/modules/ub/grimoire.tsx +35 -8
  134. package/templates/storefront/src/modules/ub/harvest.tsx +2 -1
  135. package/templates/storefront/src/modules/ub/hero-image.tsx +14 -0
  136. package/templates/storefront/src/modules/ub/home-kit.ts +45 -0
  137. package/templates/storefront/src/modules/ub/lantern.tsx +244 -0
  138. package/templates/storefront/src/modules/ub/locker.tsx +180 -0
  139. package/templates/storefront/src/modules/ub/mosaic.tsx +189 -0
  140. package/templates/storefront/src/modules/ub/nav.tsx +8 -5
  141. package/templates/storefront/src/modules/ub/review-form.tsx +62 -0
  142. package/templates/storefront/src/modules/ub/reviews.tsx +131 -0
  143. package/templates/storefront/src/modules/ub/riviera.tsx +175 -0
  144. package/templates/storefront/src/modules/ub/souk.tsx +214 -0
  145. package/templates/storefront/src/modules/ub/sunset-checklist.tsx +95 -0
  146. package/templates/storefront/src/modules/ub/sunset.tsx +164 -0
  147. package/templates/storefront/src/modules/ub/tech.tsx +2 -2
  148. package/templates/storefront/src/modules/ub/training.tsx +196 -0
  149. package/templates/storefront/src/proxy.ts +4 -1
  150. package/templates/storefront/src/styles/custom.css +26 -0
  151. package/templates/storefront/src/styles/ub/album.css +1 -1
  152. package/templates/storefront/src/styles/ub/arena.css +120 -0
  153. package/templates/storefront/src/styles/ub/cinema.css +1 -1
  154. package/templates/storefront/src/styles/ub/gala.css +1 -1
  155. package/templates/storefront/src/styles/ub/grimoire.css +23 -7
  156. package/templates/storefront/src/styles/ub/harvest.css +7 -6
  157. package/templates/storefront/src/styles/ub/lantern.css +157 -0
  158. package/templates/storefront/src/styles/ub/locker.css +115 -0
  159. package/templates/storefront/src/styles/ub/media.css +10 -0
  160. package/templates/storefront/src/styles/ub/mosaic.css +118 -0
  161. package/templates/storefront/src/styles/ub/motion.css +0 -2
  162. package/templates/storefront/src/styles/ub/overrides.css +10 -0
  163. package/templates/storefront/src/styles/ub/pages.css +176 -0
  164. package/templates/storefront/src/styles/ub/reviews.css +85 -0
  165. package/templates/storefront/src/styles/ub/riviera.css +103 -0
  166. package/templates/storefront/src/styles/ub/souk.css +144 -0
  167. package/templates/storefront/src/styles/ub/sunset.css +123 -0
  168. package/templates/storefront/src/styles/ub/tech.css +5 -8
  169. package/templates/storefront/src/styles/ub/training.css +120 -0
  170. package/templates/storefront/themes/jeux/campaign.en.json +31 -0
  171. package/templates/storefront/themes/jeux/campaign.fr.json +31 -0
  172. package/templates/storefront/themes/jeux/theme.json +11 -0
  173. package/templates/storefront/themes/jeux-2/campaign.en.json +31 -0
  174. package/templates/storefront/themes/jeux-2/campaign.fr.json +31 -0
  175. package/templates/storefront/themes/jeux-2/theme.json +11 -0
  176. package/templates/storefront/themes/mubarak/campaign.en.json +31 -0
  177. package/templates/storefront/themes/mubarak/campaign.fr.json +31 -0
  178. package/templates/storefront/themes/mubarak/theme.json +11 -0
  179. package/templates/storefront/themes/mubarak-2/campaign.en.json +31 -0
  180. package/templates/storefront/themes/mubarak-2/campaign.fr.json +31 -0
  181. package/templates/storefront/themes/mubarak-2/theme.json +11 -0
  182. package/templates/storefront/themes/sport/campaign.en.json +31 -0
  183. package/templates/storefront/themes/sport/campaign.fr.json +31 -0
  184. package/templates/storefront/themes/sport/theme.json +11 -0
  185. package/templates/storefront/themes/sport-2/campaign.en.json +31 -0
  186. package/templates/storefront/themes/sport-2/campaign.fr.json +31 -0
  187. package/templates/storefront/themes/sport-2/theme.json +11 -0
  188. package/templates/storefront/themes/summer/campaign.en.json +31 -0
  189. package/templates/storefront/themes/summer/campaign.fr.json +31 -0
  190. package/templates/storefront/themes/summer/theme.json +11 -0
  191. package/templates/storefront/themes/summer-2/campaign.en.json +31 -0
  192. package/templates/storefront/themes/summer-2/campaign.fr.json +31 -0
  193. package/templates/storefront/themes/summer-2/theme.json +11 -0
@@ -0,0 +1,95 @@
1
+ "use client"
2
+
3
+ import { addToCart } from "@lib/data/cart"
4
+ import { useLocale } from "@lib/boutique/i18n-client"
5
+ import { useRouter } from "next/navigation"
6
+ import { useMemo, useState, useTransition } from "react"
7
+ import FadeImg from "./fade-img"
8
+
9
+ export type ChecklistItem = {
10
+ id: string
11
+ handle: string
12
+ title: string
13
+ image?: string
14
+ /** Variante unique : ajout direct au panier ; sinon lien vers la fiche (taille, couleur à choisir) */
15
+ variantId?: string
16
+ price: number
17
+ currency: string
18
+ priceLabel: string
19
+ }
20
+
21
+ /**
22
+ * Checklist de vacances : le client coche ce qu'il emporte, le total se met à jour, puis « Tout ajouter au panier ».
23
+ * Les articles à options (taille, couleur) renvoient vers leur fiche. Les prix viennent de Medusa (aucun prix envoyé).
24
+ */
25
+ export default function SunsetChecklist({ items, labels }: { items: ChecklistItem[]; labels: Record<"total" | "add" | "added" | "choose" | "empty", string> }) {
26
+ const locale = useLocale()
27
+ const router = useRouter()
28
+ const [checked, setChecked] = useState<Set<string>>(() => new Set(items.filter((i) => i.variantId).slice(0, 3).map((i) => i.id)))
29
+ const [done, setDone] = useState(false)
30
+ const [pending, start] = useTransition()
31
+
32
+ const total = useMemo(() => items.filter((i) => checked.has(i.id)).reduce((s, i) => s + i.price, 0), [checked, items])
33
+ const currency = items[0]?.currency ?? "eur"
34
+ const fmt = new Intl.NumberFormat(locale, { style: "currency", currency: currency.toUpperCase() })
35
+ const toggle = (id: string) => {
36
+ setDone(false)
37
+ setChecked((prev) => {
38
+ const next = new Set(prev)
39
+ if (next.has(id)) next.delete(id)
40
+ else next.add(id)
41
+ return next
42
+ })
43
+ }
44
+ const addAll = () =>
45
+ start(async () => {
46
+ for (const i of items) if (checked.has(i.id) && i.variantId) await addToCart({ variantId: i.variantId, quantity: 1 })
47
+ setDone(true)
48
+ router.refresh()
49
+ })
50
+
51
+ return (
52
+ <div className="ub-ss-list">
53
+ <ul>
54
+ {items.map((i) => (
55
+ <li key={i.id} className={checked.has(i.id) ? "is-on" : undefined}>
56
+ {i.variantId ? (
57
+ <label className="ub-ss-item">
58
+ <input type="checkbox" checked={checked.has(i.id)} onChange={() => toggle(i.id)} />
59
+ <span className="ub-ss-item__box" aria-hidden />
60
+ <span className="ub-ss-item__img">{i.image && <FadeImg src={i.image} alt="" />}</span>
61
+ <span className="ub-ss-item__name">{i.title}</span>
62
+ <span className="ub-ss-item__price">{i.priceLabel}</span>
63
+ </label>
64
+ ) : (
65
+ <a className="ub-ss-item" href={`/products/${i.handle}`}>
66
+ <span className="ub-ss-item__box ub-ss-item__box--link" aria-hidden>
67
+ →
68
+ </span>
69
+ <span className="ub-ss-item__img">{i.image && <FadeImg src={i.image} alt="" />}</span>
70
+ <span className="ub-ss-item__name">
71
+ {i.title} <small>{labels.choose}</small>
72
+ </span>
73
+ <span className="ub-ss-item__price">{i.priceLabel}</span>
74
+ </a>
75
+ )}
76
+ </li>
77
+ ))}
78
+ </ul>
79
+ <div className="ub-ss-list__foot">
80
+ <span>
81
+ {labels.total} <b>{fmt.format(total)}</b>
82
+ </span>
83
+ {done ? (
84
+ <a className="ub-ss-btn" href="/cart">
85
+ {labels.added}
86
+ </a>
87
+ ) : (
88
+ <button type="button" className="ub-ss-btn" onClick={addAll} disabled={pending || checked.size === 0}>
89
+ {checked.size === 0 ? labels.empty : labels.add}
90
+ </button>
91
+ )}
92
+ </div>
93
+ </div>
94
+ )
95
+ }
@@ -0,0 +1,164 @@
1
+ import Link from "next/link"
2
+ import { preload } from "react-dom"
3
+ import type { Campaign } from "@lib/boutique/campaign"
4
+ import { t } from "@lib/boutique/i18n"
5
+ import { CampaignCountdown } from "./campaign-blocks"
6
+ import FadeImg from "./fade-img"
7
+ import { type P, discountLabel, heroImageOf, imageOf, priceOf, splitTitle } from "./home-kit"
8
+ import { Icon } from "./icons"
9
+ import SunsetChecklist, { type ChecklistItem } from "./sunset-checklist"
10
+
11
+ /**
12
+ * Accueil « Sunset » (Summer 2, theme.json → "home": "sunset") : affiche de plage au coucher du soleil.
13
+ * Hero affiche (soleil, palmiers, vague), cartes postales (timbre = remise, cachet = date), checklist de vacances
14
+ * interactive (cocher → total → tout ajouter au panier), bande « coucher de soleil » avec compte à rebours. CSS : styles/ub/sunset.css.
15
+ */
16
+
17
+ const Wave = ({ className }: { className?: string }) => (
18
+ <svg className={className} viewBox="0 0 1440 90" preserveAspectRatio="none" aria-hidden>
19
+ <path d="M0 40c120-30 240-30 360 0s240 30 360 0 240-30 360 0 240 30 360 0v50H0z" />
20
+ </svg>
21
+ )
22
+
23
+ export function SunsetHero({ campaign }: { campaign: Campaign }) {
24
+ const { hero } = campaign
25
+ const image = heroImageOf(campaign)
26
+ if (image) preload(image, { as: "image", fetchPriority: "high" })
27
+ const { before, hl, after } = splitTitle(hero.title, hero.highlight)
28
+ const off = discountLabel(campaign)
29
+ return (
30
+ <section className="ub-ss-hero">
31
+ <div className="ub-wrap ub-ss-hero__in">
32
+ <div className="ub-ss-hero__text">
33
+ {hero.eyebrow && <p className="ub-ss-kicker">{hero.eyebrow}</p>}
34
+ <h1 className="ub-ss-title">
35
+ {before && <span>{before}</span>}
36
+ {hl && <span className="ub-ss-title__hl">{hl}</span>}
37
+ {after && <span>{after}</span>}
38
+ </h1>
39
+ {hero.sub && <p className="ub-ss-sub">{hero.sub}</p>}
40
+ <div className="ub-ss-actions">
41
+ <Link href="/store" className="ub-ss-btn">
42
+ {hero.cta} <Icon name="arrowRight" />
43
+ </Link>
44
+ {hero.cta2 && (
45
+ <a href="#checklist" className="ub-ss-btn ub-ss-btn--ghost">
46
+ {hero.cta2}
47
+ </a>
48
+ )}
49
+ </div>
50
+ </div>
51
+ {/* Grande carte postale : le visuel entier (jamais recadré ni agrandi), la remise en autocollant */}
52
+ <figure className="ub-ss-poster">
53
+ <span className="ub-ss-poster__img">{image ? <FadeImg src={image} alt="" priority /> : <span className="ub-ph" />}</span>
54
+ {off && (
55
+ <p className="ub-ss-sticker" aria-hidden>
56
+ <small>{hero.bubble?.[0]}</small>
57
+ <b>{off}</b>
58
+ </p>
59
+ )}
60
+ </figure>
61
+ </div>
62
+ <Wave className="ub-ss-wave" />
63
+ </section>
64
+ )
65
+ }
66
+
67
+ /** Cartes postales : photo, timbre (remise), cachet (date de fin), nom et prix au dos. */
68
+ export function SunsetPostcards({ products, title, campaign }: { products: P[]; title: string; campaign: Campaign }) {
69
+ const items = products.filter(imageOf).slice(0, 8)
70
+ if (!items.length) return null
71
+ const until = campaign.promo?.seal
72
+ return (
73
+ <section className="ub-ss-section">
74
+ <div className="ub-wrap">
75
+ <header className="ub-ss-head">
76
+ <p className="ub-ss-kicker">{t("sunset.postcardsKicker")}</p>
77
+ <h2 className="ub-ss-h2">{title}</h2>
78
+ </header>
79
+ <ul className="ub-ss-cards">
80
+ {items.map((p, i) => {
81
+ const { price, onSale } = priceOf(p)
82
+ return (
83
+ <li key={p.id} style={{ ["--tilt" as string]: `${[-2, 1.5, -1, 2][i % 4]}deg` }}>
84
+ <Link href={`/products/${p.handle}`} className="ub-ss-card">
85
+ <span className="ub-ss-card__img">
86
+ <FadeImg src={imageOf(p)} alt="" />
87
+ </span>
88
+ {onSale && price?.percentage_diff && (
89
+ <span className="ub-ss-stamp">
90
+ <b>-{price.percentage_diff}%</b>
91
+ </span>
92
+ )}
93
+ {until && <span className="ub-ss-postmark">{until}</span>}
94
+ <span className="ub-ss-card__back">
95
+ <span className="ub-ss-card__name">{p.title}</span>
96
+ {price && (
97
+ <span className="ub-ss-card__price">
98
+ <b>{price.calculated_price}</b>
99
+ {onSale && <s>{price.original_price}</s>}
100
+ </span>
101
+ )}
102
+ </span>
103
+ </Link>
104
+ </li>
105
+ )
106
+ })}
107
+ </ul>
108
+ </div>
109
+ </section>
110
+ )
111
+ }
112
+
113
+ /** Checklist de vacances (interactive, cf. sunset-checklist.tsx). */
114
+ export function SunsetChecklistSection({ products }: { products: P[] }) {
115
+ const items: ChecklistItem[] = products
116
+ .filter(imageOf)
117
+ .slice(0, 7)
118
+ .flatMap((p) => {
119
+ const { price } = priceOf(p)
120
+ if (!price) return []
121
+ const single = (p.variants ?? []).length === 1 ? p.variants![0].id : undefined
122
+ return [{ id: p.id, handle: p.handle!, title: p.title, image: imageOf(p), variantId: single, price: price.calculated_price_number, currency: price.currency_code, priceLabel: price.calculated_price }]
123
+ })
124
+ if (items.length < 3) return null
125
+ return (
126
+ <section className="ub-ss-section ub-ss-check" id="checklist">
127
+ <div className="ub-wrap ub-ss-check__in">
128
+ <div className="ub-ss-check__text">
129
+ <p className="ub-ss-kicker">{t("sunset.checklistKicker")}</p>
130
+ <h2 className="ub-ss-h2">{t("sunset.checklist")}</h2>
131
+ <p className="ub-ss-lead">{t("sunset.checklistSub")}</p>
132
+ </div>
133
+ <SunsetChecklist
134
+ items={items}
135
+ labels={{ total: t("sunset.total"), add: t("sunset.addAll"), added: t("sunset.added"), choose: t("sunset.choose"), empty: t("sunset.empty") }}
136
+ />
137
+ </div>
138
+ </section>
139
+ )
140
+ }
141
+
142
+ /** Bande « coucher de soleil » : dégradé, soleil qui se couche, texte promo et compte à rebours. */
143
+ export function SunsetBand({ campaign }: { campaign: Campaign }) {
144
+ const promo = campaign.promo
145
+ return (
146
+ <section className="ub-ss-band">
147
+ <span className="ub-ss-band__sun" aria-hidden />
148
+ <div className="ub-wrap ub-ss-band__in">
149
+ {promo?.eyebrow && <p className="ub-ss-kicker">{promo.eyebrow}</p>}
150
+ <h2 className="ub-ss-h2">{promo?.title ?? campaign.hero.title}</h2>
151
+ {promo?.text && <p className="ub-ss-lead">{promo.text}</p>}
152
+ {campaign.showCountdown !== false && (
153
+ <div className="ub-ss-cd">
154
+ <CampaignCountdown endsAt={campaign.endsAt} />
155
+ </div>
156
+ )}
157
+ <Link href="/store" className="ub-ss-btn">
158
+ {promo?.cta ?? campaign.hero.cta} <Icon name="arrowRight" />
159
+ </Link>
160
+ </div>
161
+ <Wave className="ub-ss-wave ub-ss-wave--sea" />
162
+ </section>
163
+ )
164
+ }
@@ -39,7 +39,7 @@ export function TechHero({ campaign }: { campaign: Campaign }) {
39
39
  {/* Cyber Monday 2 : le visuel couvre tout le hero (masqué par le CSS en variante 1) */}
40
40
  {image && (
41
41
  <div className="ub-tc-hero__cover" aria-hidden>
42
- <img src={image} alt="" fetchPriority="high" decoding="async" />
42
+ <FadeImg src={image} alt="" priority />
43
43
  </div>
44
44
  )}
45
45
  <div className="ub-wrap ub-tc-hero__in">
@@ -83,7 +83,7 @@ export function TechHero({ campaign }: { campaign: Campaign }) {
83
83
  </div>
84
84
  {/* Cyber Monday 1 : visuel sur le côté, cadre lumineux + pastille de remise */}
85
85
  <div className="ub-tc-hero__side">
86
- {image ? <img src={image} alt={t("hero.photo")} fetchPriority="high" decoding="async" /> : <span className="ub-ph" />}
86
+ {image ? <FadeImg src={image} alt={t("hero.photo")} priority /> : <span className="ub-ph" />}
87
87
  {b2 && (
88
88
  <p className="ub-tc-badge">
89
89
  <small>{b1}</small>
@@ -0,0 +1,196 @@
1
+ import Link from "next/link"
2
+ import { preload } from "react-dom"
3
+ import type { Campaign } from "@lib/boutique/campaign"
4
+ import { getBoutique } from "@lib/boutique/config"
5
+ import { t } from "@lib/boutique/i18n"
6
+ import { CampaignCountdown } from "./campaign-blocks"
7
+ import FadeImg from "./fade-img"
8
+ import HeroImage from "./hero-image"
9
+ import { type P, discountLabel, heroImageOf, imageOf, priceOf, splitTitle, spaced } from "./home-kit"
10
+ import { Icon, type IconName } from "./icons"
11
+
12
+ /**
13
+ * Accueil « Training » (Sport 2, theme.json → "home": "training") : salle de sport, sombre, accent citron vert.
14
+ * Hero (visuel à droite fondu dans le noir, mots de la campagne en lettres de tableau), les plus choisis juste sous le hero (classement),
15
+ * programme de la semaine (lundi → dimanche, un produit par jour), pack de départ (3 produits, total et économie),
16
+ * engagements. CSS : styles/ub/training.css.
17
+ */
18
+
19
+ export function TrainingHero({ campaign }: { campaign: Campaign }) {
20
+ const { hero } = campaign
21
+ const image = heroImageOf(campaign)
22
+ if (image) preload(image, { as: "image", fetchPriority: "high" })
23
+ const { before, hl, after } = splitTitle(hero.title, hero.highlight)
24
+ const words = (hero.eyebrow ?? "").split(/[·•|]/).map((w) => w.trim()).filter(Boolean)
25
+ const off = discountLabel(campaign)
26
+ return (
27
+ <section className="ub-tr-hero">
28
+ {image && <HeroImage src={image} position="center 40%" className="ub-tr-hero__img" />}
29
+ <div className="ub-wrap ub-tr-hero__in">
30
+ <div className="ub-tr-hero__text">
31
+ {/* Les mots de la campagne, en lettres de tableau (comme le tableau du visuel) + la remise */}
32
+ <p className="ub-tr-words">
33
+ {words.map((w) => (
34
+ <span key={w}>{w}</span>
35
+ ))}
36
+ {off && <b>{off}</b>}
37
+ </p>
38
+ <h1 className="ub-tr-title">
39
+ {before} {hl && <span className="ub-tr-title__hl">{hl}</span>}{spaced(after)}
40
+ </h1>
41
+ {hero.sub && <p className="ub-tr-sub">{hero.sub}</p>}
42
+ <div className="ub-tr-actions">
43
+ <Link href="/store" className="ub-tr-btn">
44
+ {hero.cta} <Icon name="arrowRight" />
45
+ </Link>
46
+ {hero.cta2 && (
47
+ <a href="#programme" className="ub-tr-btn ub-tr-btn--ghost">
48
+ {hero.cta2}
49
+ </a>
50
+ )}
51
+ </div>
52
+ </div>
53
+ </div>
54
+ </section>
55
+ )
56
+ }
57
+
58
+ /** Les plus choisis : classement compact juste sous le hero (repère rapide pour décider). */
59
+ export function TrainingTop({ products, title }: { products: P[]; title: string }) {
60
+ const items = products.filter(imageOf).slice(0, 4)
61
+ if (!items.length) return null
62
+ return (
63
+ <section className="ub-wrap ub-tr-top">
64
+ <h2 className="ub-tr-h3">{title}</h2>
65
+ <ol className="ub-tr-top__list">
66
+ {items.map((p, i) => {
67
+ const { price, onSale } = priceOf(p)
68
+ return (
69
+ <li key={p.id}>
70
+ <Link href={`/products/${p.handle}`} className="ub-tr-rank">
71
+ <span className="ub-tr-rank__n">#{i + 1}</span>
72
+ <span className="ub-tr-rank__img">
73
+ <FadeImg src={imageOf(p)} alt="" />
74
+ </span>
75
+ <span className="ub-tr-rank__body">
76
+ <b>{p.title}</b>
77
+ {price && (
78
+ <span className="ub-tr-price">
79
+ {price.calculated_price}
80
+ {onSale && <s>{price.original_price}</s>}
81
+ </span>
82
+ )}
83
+ </span>
84
+ </Link>
85
+ </li>
86
+ )
87
+ })}
88
+ </ol>
89
+ </section>
90
+ )
91
+ }
92
+
93
+ /** Programme de la semaine : 7 jours, un objectif et un produit par jour. */
94
+ export function TrainingProgram({ products }: { products: P[] }) {
95
+ const items = products.filter(imageOf)
96
+ if (items.length < 4) return null
97
+ const days = Array.from({ length: 7 }, (_, i) => ({ day: t(`training.day${i + 1}`), focus: t(`training.focus${i + 1}`), p: items[i % items.length] }))
98
+ return (
99
+ <section className="ub-tr-section" id="programme">
100
+ <div className="ub-wrap">
101
+ <header className="ub-tr-head">
102
+ <p className="ub-tr-kicker">{t("training.programKicker")}</p>
103
+ <h2 className="ub-tr-h2">{t("training.program")}</h2>
104
+ </header>
105
+ <ol className="ub-tr-week">
106
+ {days.map(({ day, focus, p }, i) => (
107
+ <li key={day} className={i === 6 ? "is-rest" : undefined}>
108
+ <Link href={`/products/${p.handle}`} className="ub-tr-day">
109
+ <span className="ub-tr-day__d">{day}</span>
110
+ <span className="ub-tr-day__f">{focus}</span>
111
+ <span className="ub-tr-day__img">
112
+ <FadeImg src={imageOf(p)} alt="" />
113
+ </span>
114
+ <span className="ub-tr-day__name">{p.title}</span>
115
+ </Link>
116
+ </li>
117
+ ))}
118
+ </ol>
119
+ </div>
120
+ </section>
121
+ )
122
+ }
123
+
124
+ /** Pack de départ : 3 produits additionnés, total et économie réelle (prix calculés par Medusa). */
125
+ export function TrainingPack({ campaign, products }: { campaign: Campaign; products: P[] }) {
126
+ const pack = products.filter(imageOf).slice(0, 3)
127
+ if (pack.length < 3) return null
128
+ const prices = pack.map((p) => priceOf(p).price).filter((x): x is NonNullable<typeof x> => !!x)
129
+ const total = prices.reduce((s, x) => s + x.calculated_price_number, 0)
130
+ const before = prices.reduce((s, x) => s + (x.original_price_number ?? x.calculated_price_number), 0)
131
+ const { locale } = getBoutique()
132
+ const fmt = new Intl.NumberFormat(locale, { style: "currency", currency: (prices[0]?.currency_code ?? "eur").toUpperCase() })
133
+ const promo = campaign.promo
134
+ return (
135
+ <section className="ub-tr-section">
136
+ <div className="ub-wrap ub-tr-pack">
137
+ <div className="ub-tr-pack__text">
138
+ {promo?.eyebrow && <p className="ub-tr-kicker">{promo.eyebrow}</p>}
139
+ <h2 className="ub-tr-h2">{promo?.title ?? t("training.pack")}</h2>
140
+ {promo?.text && <p className="ub-tr-lead">{promo.text}</p>}
141
+ <p className="ub-tr-pack__total">
142
+ <span>{t("training.total")}</span>
143
+ <b>{fmt.format(total)}</b>
144
+ {before > total && <em>{t("training.saving", { amount: fmt.format(before - total) })}</em>}
145
+ </p>
146
+ {campaign.showCountdown !== false && (
147
+ <div className="ub-tr-cd">
148
+ <CampaignCountdown endsAt={campaign.endsAt} />
149
+ </div>
150
+ )}
151
+ </div>
152
+ <ul className="ub-tr-pack__items">
153
+ {pack.map((p) => {
154
+ const { price } = priceOf(p)
155
+ return (
156
+ <li key={p.id}>
157
+ <Link href={`/products/${p.handle}`} className="ub-tr-pack__item">
158
+ <span className="ub-tr-pack__img">
159
+ <FadeImg src={imageOf(p)} alt="" />
160
+ </span>
161
+ <span className="ub-tr-pack__name">{p.title}</span>
162
+ {price && <span className="ub-tr-price">{price.calculated_price}</span>}
163
+ </Link>
164
+ </li>
165
+ )
166
+ })}
167
+ </ul>
168
+ </div>
169
+ </section>
170
+ )
171
+ }
172
+
173
+ /** Engagements (confiance) : retours, livraison, paiement, qualité. */
174
+ export function TrainingPromise({ freeShipping }: { freeShipping?: string }) {
175
+ const items: [IconName, string, string][] = [
176
+ ["returns", t("training.promise1"), t("training.promise1Sub")],
177
+ ["truck", t("trust.shipping"), freeShipping ? t("trust.shippingSub", { freeShipping }) : t("trust.shippingSubAll")],
178
+ ["lock", t("trust.secure"), t("trust.secureSub")],
179
+ ["shield", t("training.promise4"), t("training.promise4Sub")],
180
+ ]
181
+ return (
182
+ <section className="ub-wrap ub-tr-section">
183
+ <ul className="ub-tr-promise">
184
+ {items.map(([ic, a, b]) => (
185
+ <li key={a}>
186
+ <Icon name={ic} />
187
+ <span>
188
+ <b>{a}</b>
189
+ <small>{b}</small>
190
+ </span>
191
+ </li>
192
+ ))}
193
+ </ul>
194
+ </section>
195
+ )
196
+ }
@@ -44,8 +44,11 @@ export function proxy(request: NextRequest) {
44
44
  // Next lit la CSP de la requête pour poser le nonce sur ses propres scripts.
45
45
  request.headers.set("content-security-policy", csp)
46
46
  request.headers.set("x-nonce", nonce)
47
+ // Chemin demandé : le layout laisse passer compte, suivi de commande et panier quand le site est fermé
48
+ request.headers.set("x-ub-path", request.nextUrl.pathname)
47
49
 
48
- const cacheCookie = `_medusa_cache_id_${process.env.BOUTIQUE}`
50
+ // Même nom que lib/data/cookies.ts (mode single : « site », commun à toutes les boutiques)
51
+ const cacheCookie = `_medusa_cache_id_${process.env.BOUTIQUE === "auto" ? "site" : process.env.BOUTIQUE}`
49
52
  const newCacheId = request.cookies.has(cacheCookie) ? null : crypto.randomUUID()
50
53
  if (newCacheId) {
51
54
  // Visible dès cette requête par les server components…
@@ -0,0 +1,26 @@
1
+ /*
2
+ * RETOUCHES DE CE PROJET : couleurs, polices, espacements, éléments masqués…
3
+ *
4
+ * MODE D'EMPLOI COMPLET : storefront/PERSONNALISER.md (plan du site page par page, classes, variables, exemples,
5
+ * ce que ce fichier permet ou non). Tenu à jour par create-boutique update.
6
+ *
7
+ * - Chargé en dernier : il l'emporte sur les thèmes.
8
+ * - Jamais remplacé par `create-boutique update` (le reste du code, si).
9
+ * - Images ou polices utilisées ici : dans public/custom/ (conservé aussi), puis url("/custom/mon-image.png").
10
+ * - Après modification : docker compose up -d --build
11
+ *
12
+ * CIBLER :
13
+ * tout le projet ...................... directement (ex. .ub-tc-ticker { display: none; })
14
+ * une boutique ........................ [data-boutique="<slug>"] … (sections plus bas, une par boutique)
15
+ * toutes les boutiques d'un thème ..... [data-theme="halloween"] … (variante 2 : [data-variant="halloween-2"])
16
+ * une page ............................ [data-page="product"] … (home, store, search, product, cart, checkout, account,
17
+ * contact, privacy, terms…) ; en-tête d'une page : :root:has([data-page="home"]) .ub-nav
18
+ * mobile seulement .................... @media (max-width: 600px) { … }
19
+ * ordinateur seulement ................ @media (min-width: 1024px) { … }
20
+ *
21
+ * Couleurs du thème (variables) : --primary, --accent, --bg, --surface, --ink, --ink-muted, --border, --sale,
22
+ * --hero-bg, --hero-ink, --topbar-bg, --topbar-ink. Ex. : [data-boutique="yout-noel"] { --primary: #b91c1c; }
23
+ */
24
+
25
+ /* ─────────── Tout le projet ─────────── */
26
+
@@ -33,7 +33,7 @@
33
33
  background:
34
34
  radial-gradient(60% 70% at 50% 40%, color-mix(in srgb, var(--al-red) 90%, #ff3b3b) 0%, var(--al-red) 45%, var(--al-red-dark) 100%);
35
35
  }
36
- .ub-al-hero[data-has-image] { background: var(--al-red-dark) var(--al-img) center / cover no-repeat; }
36
+ .ub-al-hero[data-has-image] { background: var(--al-red-dark); } /* image : modules/ub/hero-image.tsx */
37
37
  .ub-al-hero[data-has-image]::after { content: ""; position: absolute; inset: 0; z-index: -1; background: radial-gradient(50% 60% at 50% 45%, rgb(0 0 0 / 0.25), rgb(0 0 0 / 0.55)); }
38
38
  /* Rideau (plis) à gauche quand il n'y a pas d'image */
39
39
  .ub-al-hero__curtain {
@@ -0,0 +1,120 @@
1
+ /* Accueil « Arène » (Jeux 1) — modules/ub/arena.tsx. Clair, bleu roi + flamme (or → orange → rouge), coupes en biais
2
+ comme les éclats de l'image du hero. Le texte du hero se place dans la zone blanche à droite de l'image. */
3
+
4
+ [data-variant="jeux"] {
5
+ --bg: #f5f7fc; --surface: #ffffff; --tint-1: #e7eeff; --tint-2: #fff1d6; --tint-3: #e3f4ff; --tint-4: #ffe6da;
6
+ --ink: #0b1a4a; --ink-muted: #56607f; --border: #dde3f0;
7
+ --primary: #1646d8; --on-primary: #ffffff; --accent: #ff7a1a; --on-accent: #1a0800;
8
+ --sale: #e8412b; --star: #ffb020; --success: #149e5f;
9
+ --hero-bg: #f7f7f7; --hero-ink: #0b1a4a; --hero-muted: #56607f;
10
+ --band-bg: #1646d8; --band-ink: #ffffff; --bubble-bg: #ffb020; --bubble-ink: #1a0800;
11
+ --topbar-bg: #0b1a4a; --topbar-ink: #ffffff; --focus: #1646d8;
12
+ --shadow-card: 0 18px 40px -26px rgb(11 26 74 / 0.45);
13
+ --ub-font-head: var(--font-sora); --ub-font-body: var(--font-inter);
14
+ --ub-head-weight: 800; --ub-head-case: none; --ub-head-track: -0.02em;
15
+ --ub-r-card: 14px; --ub-r-btn: 10px; --ub-r-tile: 14px; --ub-r-hero: 0px; --ub-r-media: 12px;
16
+ --ar-blue: #1646d8; --ar-navy: #0b1a4a; --ar-sky: #19a8f0;
17
+ --ar-flame: linear-gradient(100deg, #ffc93a 0%, #ff9a1f 45%, #f0512b 100%);
18
+ --ar-gold: #f2c14e; --ar-silver: #c3ccd8; --ar-bronze: #d08a4f;
19
+ --ar-cut: polygon(0 0, 100% 0, 100% calc(100% - 18px), calc(100% - 18px) 100%, 0 100%);
20
+ color-scheme: light;
21
+ }
22
+ [data-variant="jeux"] .ub-nav { background: rgb(255 255 255 / 0.94); border-bottom: 1px solid var(--border); }
23
+ [data-variant="jeux"] .ub-nav__cta { background: var(--ar-blue); color: #fff; }
24
+ [data-variant="jeux"] .ub-footwrap { --foot-bg: #0b1a4a; --foot-ink: #ffffff; --cta-bg: #ffb020; --cta-ink: #1a0800; }
25
+
26
+ .ub-ar-kicker { display: inline-flex; align-items: center; gap: 10px; margin: 0; color: var(--ar-blue); font: 700 12px/16px var(--font-inter); letter-spacing: 0.24em; text-transform: uppercase; }
27
+ .ub-ar-flame { width: 12px; height: 18px; border-radius: 50% 50% 50% 50% / 60% 60% 40% 40%; background: var(--ar-flame); }
28
+ .ub-ar-h2 { margin: 8px 0 0; font: 800 clamp(30px, 3.6vw, 50px) / 1.05 var(--font-sora); letter-spacing: -0.025em; color: var(--ar-navy); }
29
+ .ub-ar-lead { max-width: 36em; margin: 10px 0 0; color: var(--ink-muted); font-size: 16px; line-height: 25px; }
30
+ .ub-ar-btn { display: inline-flex; align-items: center; gap: 10px; min-height: 52px; padding: 0 26px; background: var(--ar-blue); color: #fff; font: 700 15px/18px var(--font-sora); clip-path: polygon(0 0, 100% 0, 100% calc(100% - 12px), calc(100% - 12px) 100%, 0 100%); transition: background-color 0.2s, transform 0.2s; }
31
+ .ub-ar-btn:hover { background: var(--ar-navy); transform: translateY(-2px); }
32
+ .ub-ar-btn svg { width: 18px; height: 18px; }
33
+ .ub-ar-btn--ghost { background: transparent; color: var(--ar-navy); box-shadow: inset 0 0 0 2px var(--ar-navy); clip-path: none; border-radius: 0; }
34
+ .ub-ar-btn--ghost:hover { background: var(--ar-navy); color: #fff; }
35
+ .ub-ar-price { display: inline-flex; align-items: baseline; gap: 8px; }
36
+ .ub-ar-price b { font: 800 17px/1 var(--font-sora); color: var(--ar-navy); }
37
+ .ub-ar-price s { color: var(--ink-muted); font-size: 13px; }
38
+ .ub-ar-price em { font-style: normal; font-weight: 700; font-size: 13px; color: var(--sale); }
39
+
40
+ /* ---------- Hero : image à gauche (flamme), texte dans le blanc à droite ---------- */
41
+ .ub-ar-hero { position: relative; isolation: isolate; overflow: hidden; background: var(--hero-bg); color: var(--ar-navy); }
42
+ /* Éclats en biais dans le fond, comme ceux du visuel */
43
+ .ub-ar-hero::before { content: ""; position: absolute; inset: 0; z-index: -1; background:
44
+ linear-gradient(115deg, transparent 58%, rgb(25 168 240 / 0.12) 58% 66%, transparent 66%),
45
+ linear-gradient(115deg, transparent 70%, rgb(22 70 216 / 0.10) 70% 80%, transparent 80%),
46
+ linear-gradient(115deg, transparent 84%, rgb(255 154 31 / 0.14) 84% 90%, transparent 90%); }
47
+ .ub-ar-hero__in { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); align-items: center; gap: var(--space-12); min-height: clamp(600px, 48vw, 760px); padding-top: var(--space-12); padding-bottom: var(--space-12); }
48
+ .ub-ar-hero__text { display: grid; justify-items: start; gap: 20px; }
49
+ /* Panneau : le visuel garde ses proportions (1280 × 918), coin coupé et liseré flamme */
50
+ .ub-ar-poster { position: relative; margin: 0; padding: 10px; background: var(--ar-flame); clip-path: polygon(0 0, 100% 0, 100% calc(100% - 44px), calc(100% - 44px) 100%, 0 100%); box-shadow: 0 30px 60px -30px rgb(11 26 74 / 0.5); }
51
+ .ub-ar-poster__img { display: block; aspect-ratio: 1280 / 918; overflow: hidden; background: #fff; clip-path: polygon(0 0, 100% 0, 100% calc(100% - 38px), calc(100% - 38px) 100%, 0 100%); }
52
+ .ub-ar-poster__img img { display: block; width: 100%; height: 100%; object-fit: cover; }
53
+ .ub-ar-title { margin: 0; font: 800 clamp(44px, 5.2vw, 86px) / 1 var(--font-sora); letter-spacing: -0.04em; text-wrap: balance; }
54
+ .ub-ar-title__hl { background: var(--ar-flame); -webkit-background-clip: text; background-clip: text; color: transparent; }
55
+ .ub-ar-sub { max-width: 30em; margin: 0; color: var(--hero-muted); font-size: 17px; line-height: 27px; }
56
+ .ub-ar-actions { display: flex; flex-wrap: wrap; gap: 12px; }
57
+ .ub-ar-chrono { display: flex; flex-wrap: wrap; align-items: stretch; margin-top: 8px; background: var(--ar-navy); color: #fff; clip-path: var(--ar-cut); }
58
+ .ub-ar-chrono > * { margin: 0; padding: 14px 22px; }
59
+ .ub-ar-chrono > * + * { border-left: 1px solid rgb(255 255 255 / 0.16); }
60
+ .ub-ar-chrono small { display: block; color: #b9c3e6; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; }
61
+ .ub-ar-chrono__off b { font: 800 40px/1 var(--font-sora); background: var(--ar-flame); -webkit-background-clip: text; background-clip: text; color: transparent; }
62
+ .ub-ar-chrono__time .ub-countdown { margin-top: 6px; }
63
+ .ub-ar-chrono__time .ub-countdown__c { background: rgb(255 255 255 / 0.08); box-shadow: none; color: #fff; font-variant-numeric: tabular-nums; }
64
+
65
+ /* ---------- Sections ---------- */
66
+ .ub-ar-section { padding-top: var(--space-16); }
67
+ .ub-arena > :last-child { padding-bottom: var(--space-16); }
68
+ .ub-ar-head { display: grid; justify-items: center; gap: 4px; margin-bottom: var(--space-8); text-align: center; }
69
+ .ub-ar-head--row { display: flex; justify-content: space-between; align-items: end; text-align: left; }
70
+ .ub-ar-more { display: inline-flex; align-items: center; gap: 8px; color: var(--ar-blue); font-weight: 700; }
71
+ .ub-ar-more svg { width: 16px; height: 16px; }
72
+
73
+ /* Podium */
74
+ .ub-ar-podium { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: end; gap: 14px; max-width: 980px; margin: 0 auto; padding: 0; list-style: none; }
75
+ .ub-ar-step { display: grid; justify-items: center; gap: 10px; color: var(--ink); text-align: center; }
76
+ .ub-ar-step__img { display: block; width: 100%; aspect-ratio: 4 / 5; overflow: hidden; background: var(--surface); box-shadow: var(--shadow-card); clip-path: var(--ar-cut); transition: transform 0.25s; }
77
+ .ub-ar-step:hover .ub-ar-step__img { transform: translateY(-6px); }
78
+ .ub-ar-step__img img { width: 100%; height: 100%; object-fit: cover; }
79
+ .ub-ar-step__name { max-width: 22ch; font-weight: 600; font-size: 15px; line-height: 20px; }
80
+ .ub-ar-step__block { display: grid; justify-items: center; align-content: start; gap: 6px; width: 100%; padding-top: 18px; background: linear-gradient(180deg, var(--ar-blue), var(--ar-navy)); color: #fff; border-top: 5px solid var(--m); }
81
+ .ub-ar-step__block small { color: #c9d3f5; font-size: 12px; letter-spacing: 0.18em; text-transform: uppercase; }
82
+ .ub-ar-medal { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #fff9 0 12%, transparent 40%), var(--m); color: #1a1000; font: 800 20px/1 var(--font-sora); box-shadow: 0 0 0 4px rgb(255 255 255 / 0.15); }
83
+ .is-gold { --m: var(--ar-gold); } .is-gold .ub-ar-step__block { height: 150px; }
84
+ .is-silver { --m: var(--ar-silver); } .is-silver .ub-ar-step__block { height: 110px; }
85
+ .is-bronze { --m: var(--ar-bronze); } .is-bronze .ub-ar-step__block { height: 80px; }
86
+
87
+ /* Couloirs de course (piste bleue, lignes blanches) */
88
+ .ub-ar-lanes { display: grid; margin: 0; padding: 0; list-style: none; overflow: hidden; background: var(--ar-blue); clip-path: var(--ar-cut); }
89
+ .ub-ar-lanes li + li { border-top: 2px solid rgb(255 255 255 / 0.75); }
90
+ .ub-ar-lane { display: grid; grid-template-columns: 44px 64px minmax(0, 1.2fr) minmax(0, 1fr) auto; align-items: center; gap: 18px; padding: 12px 20px; color: #fff; transition: background-color 0.2s; }
91
+ .ub-ar-lane:hover { background-color: rgb(255 255 255 / 0.08); }
92
+ .ub-ar-lane__n { display: grid; place-items: center; width: 36px; height: 36px; background: #fff; color: var(--ar-blue); font: 800 17px/1 var(--font-sora); }
93
+ .ub-ar-lane__img { display: block; width: 64px; height: 64px; overflow: hidden; border-radius: 8px; background: rgb(255 255 255 / 0.15); }
94
+ .ub-ar-lane__img img { width: 100%; height: 100%; object-fit: cover; }
95
+ .ub-ar-lane__name { font-weight: 600; font-size: 15px; line-height: 20px; }
96
+ .ub-ar-lane__track { position: relative; height: 6px; border-radius: 999px; background: rgb(255 255 255 / 0.18); }
97
+ .ub-ar-lane__track i { position: absolute; inset: 0 auto 0 0; max-width: 100%; border-radius: inherit; background: var(--ar-flame); }
98
+ .ub-ar-lane .ub-ar-price b { color: #fff; }
99
+ .ub-ar-lane .ub-ar-price em { color: #ffc93a; }
100
+
101
+ /* Record */
102
+ .ub-ar-record { position: relative; display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--space-8); overflow: hidden; padding: clamp(28px, 4vw, 56px); padding-left: calc(clamp(28px, 4vw, 56px) + 34px); background: linear-gradient(120deg, #fff 55%, #fff4e0); border: 1px solid var(--border); clip-path: var(--ar-cut); }
103
+ .ub-ar-record__finish { position: absolute; inset: 0 auto 0 0; width: 22px; background: repeating-conic-gradient(var(--ar-navy) 0 25%, #fff 0 50%) 0 0 / 11px 11px; }
104
+
105
+ @media (max-width: 900px) {
106
+ .ub-ar-hero .ub-ar-hero__in { grid-template-columns: 1fr; gap: var(--space-8); min-height: 0; padding-top: 40px; padding-bottom: 40px; }
107
+ .ub-ar-lane { grid-template-columns: 36px 56px minmax(0, 1fr) auto; }
108
+ .ub-ar-lane__track { display: none; }
109
+ }
110
+ @media (max-width: 600px) {
111
+ .ub-ar-title { font-size: 42px; }
112
+ .ub-ar-podium { gap: 8px; }
113
+ .ub-ar-step__name { font-size: 13px; line-height: 17px; }
114
+ .ub-ar-price { flex-direction: column; gap: 2px; align-items: center; }
115
+ .is-gold .ub-ar-step__block { height: 110px; } .is-silver .ub-ar-step__block { height: 84px; } .is-bronze .ub-ar-step__block { height: 64px; }
116
+ .ub-ar-medal { width: 36px; height: 36px; font-size: 16px; }
117
+ .ub-ar-lane { grid-template-columns: 48px minmax(0, 1fr) auto; gap: 12px; padding: 10px 14px; }
118
+ .ub-ar-lane__n { display: none; }
119
+ .ub-ar-head--row { flex-direction: column; align-items: start; }
120
+ }