@salifbiaye/create-boutique 0.6.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 (182) hide show
  1. package/cli/dist/code.js +110 -0
  2. package/cli/dist/commands.js +143 -10
  3. package/cli/dist/create.js +54 -7
  4. package/cli/dist/generate.js +65 -4
  5. package/cli/dist/guide.js +10 -2
  6. package/cli/dist/index.js +20 -7
  7. package/cli/dist/payments.js +150 -0
  8. package/docs/fiche-client.en.html +2 -2
  9. package/docs/fiche-client.html +2 -2
  10. package/docs/guide-complet.fr.html +681 -256
  11. package/docs/guide-images/categories.jpg +0 -0
  12. package/docs/guide-images/clients.jpg +0 -0
  13. package/docs/guide-images/collections.jpg +0 -0
  14. package/docs/guide-images/commande-detail.jpg +0 -0
  15. package/docs/guide-images/commande-expedier.jpg +0 -0
  16. package/docs/guide-images/commande-menu.jpg +0 -0
  17. package/docs/guide-images/commande-paiement.jpg +0 -0
  18. package/docs/guide-images/commandes-liste.jpg +0 -0
  19. package/docs/guide-images/connexion.jpg +0 -0
  20. package/docs/guide-images/groupes-clients.jpg +0 -0
  21. package/docs/guide-images/inventaire.jpg +0 -0
  22. package/docs/guide-images/listes-prix.jpg +0 -0
  23. package/docs/guide-images/menu.jpg +0 -0
  24. package/docs/guide-images/mes-boutiques-fermer.jpg +0 -0
  25. package/docs/guide-images/mes-boutiques-panneau.jpg +0 -0
  26. package/docs/guide-images/mes-boutiques.jpg +0 -0
  27. package/docs/guide-images/parametres-boutique.jpg +0 -0
  28. package/docs/guide-images/parametres-canaux.jpg +0 -0
  29. package/docs/guide-images/parametres-emplacements.jpg +0 -0
  30. package/docs/guide-images/parametres-profil.jpg +0 -0
  31. package/docs/guide-images/parametres-regions.jpg +0 -0
  32. package/docs/guide-images/parametres-utilisateurs.jpg +0 -0
  33. package/docs/guide-images/produit-creer-details.jpg +0 -0
  34. package/docs/guide-images/produit-detail.jpg +0 -0
  35. package/docs/guide-images/produit-variantes-menu.jpg +0 -0
  36. package/docs/guide-images/produit-variantes.jpg +0 -0
  37. package/docs/guide-images/produits-liste.jpg +0 -0
  38. package/docs/guide-images/promotion-details.jpg +0 -0
  39. package/docs/guide-images/promotion-type.jpg +0 -0
  40. package/docs/guide-images/promotions-liste.jpg +0 -0
  41. package/docs/guide-images/site-boutique.jpg +0 -0
  42. package/docs/guide-images/site-fermee.png +0 -0
  43. package/docs/guide-images/vue-ensemble.jpg +0 -0
  44. package/package.json +3 -2
  45. package/templates/backend/medusa-config.ts +31 -25
  46. package/templates/backend/package-lock.json +21 -0
  47. package/templates/backend/package.json +2 -0
  48. package/templates/backend/src/admin/i18n/json/en.json +51 -3
  49. package/templates/backend/src/admin/i18n/json/fr.json +51 -3
  50. package/templates/backend/src/admin/routes/boutique-campaigns/campaign-drawer.tsx +2 -0
  51. package/templates/backend/src/admin/routes/boutique-campaigns/page.tsx +36 -1
  52. package/templates/backend/src/admin/routes/reviews/page.tsx +259 -0
  53. package/templates/backend/src/api/admin/boutique-campaigns/route.ts +3 -1
  54. package/templates/backend/src/api/admin/boutique-online/[salesChannelId]/route.ts +22 -0
  55. package/templates/backend/src/api/admin/reviews/[id]/route.ts +15 -0
  56. package/templates/backend/src/api/admin/reviews/route.ts +47 -0
  57. package/templates/backend/src/api/middlewares.ts +7 -0
  58. package/templates/backend/src/api/store/campaign/route.ts +5 -1
  59. package/templates/backend/src/api/store/reviews/latest/route.ts +39 -0
  60. package/templates/backend/src/api/store/reviews/order/route.ts +36 -0
  61. package/templates/backend/src/api/store/reviews/route.ts +43 -0
  62. package/templates/backend/src/api/store/reviews/summary/route.ts +28 -0
  63. package/templates/backend/src/emails/en.json +3 -1
  64. package/templates/backend/src/emails/fr.json +3 -1
  65. package/templates/backend/src/lib/email-config.ts +14 -0
  66. package/templates/backend/src/lib/emails.ts +9 -0
  67. package/templates/backend/src/lib/notify.ts +14 -8
  68. package/templates/backend/src/lib/payment-config.ts +66 -0
  69. package/templates/backend/src/lib/project.ts +12 -0
  70. package/templates/backend/src/lib/redirect-payment.ts +264 -0
  71. package/templates/backend/src/lib/review-token.ts +40 -0
  72. package/templates/backend/src/lib/reviews.ts +25 -0
  73. package/templates/backend/src/modules/naboopay/index.ts +6 -0
  74. package/templates/backend/src/modules/naboopay/service.ts +92 -0
  75. package/templates/backend/src/modules/paydunya/index.ts +6 -0
  76. package/templates/backend/src/modules/paydunya/service.ts +89 -0
  77. package/templates/backend/src/modules/paytech/index.ts +6 -0
  78. package/templates/backend/src/modules/paytech/service.ts +114 -0
  79. package/templates/backend/src/modules/product-review/index.ts +8 -0
  80. package/templates/backend/src/modules/product-review/migrations/Migration20261001090000.ts +18 -0
  81. package/templates/backend/src/modules/product-review/models/review.ts +25 -0
  82. package/templates/backend/src/modules/product-review/service.ts +6 -0
  83. package/templates/backend/src/modules/product-review/validators.ts +27 -0
  84. package/templates/backend/src/modules/smtp/index.ts +6 -0
  85. package/templates/backend/src/modules/smtp/service.ts +73 -0
  86. package/templates/backend/src/scripts/seed.ts +52 -36
  87. package/templates/backend/src/subscribers/fulfillment-emails.ts +1 -1
  88. package/templates/backend/src/workflows/reviews.ts +21 -0
  89. package/templates/backend/src/workflows/steps/create-review.ts +52 -0
  90. package/templates/backend/src/workflows/steps/update-review.ts +36 -0
  91. package/templates/storefront/PERSONNALISER.md +134 -0
  92. package/templates/storefront/locales/en.json +198 -11
  93. package/templates/storefront/locales/fr.json +198 -11
  94. package/templates/storefront/public/custom/LISEZMOI.txt +2 -0
  95. package/templates/storefront/src/app/(checkout)/layout.tsx +1 -1
  96. package/templates/storefront/src/app/(main)/layout.tsx +8 -1
  97. package/templates/storefront/src/app/(main)/legal/[page]/page.tsx +42 -7
  98. package/templates/storefront/src/app/(main)/page.tsx +149 -4
  99. package/templates/storefront/src/app/(main)/review/page.tsx +60 -0
  100. package/templates/storefront/src/app/(main)/store/page.tsx +3 -1
  101. package/templates/storefront/src/app/(main)/template.tsx +17 -2
  102. package/templates/storefront/src/app/layout.tsx +13 -1
  103. package/templates/storefront/src/lib/boutique/campaign.ts +5 -1
  104. package/templates/storefront/src/lib/boutique/page-name.ts +25 -0
  105. package/templates/storefront/src/lib/boutique/schema.ts +3 -1
  106. package/templates/storefront/src/lib/constants.tsx +17 -0
  107. package/templates/storefront/src/lib/data/review-actions.ts +28 -0
  108. package/templates/storefront/src/lib/data/reviews.ts +64 -0
  109. package/templates/storefront/src/modules/checkout/components/payment/index.tsx +26 -4
  110. package/templates/storefront/src/modules/checkout/components/payment-button/index.tsx +85 -2
  111. package/templates/storefront/src/modules/products/templates/index.tsx +5 -0
  112. package/templates/storefront/src/modules/store/components/shop-search.tsx +85 -0
  113. package/templates/storefront/src/modules/store/templates/index.tsx +16 -8
  114. package/templates/storefront/src/modules/store/templates/paginated-products.tsx +15 -3
  115. package/templates/storefront/src/modules/ub/album.tsx +2 -0
  116. package/templates/storefront/src/modules/ub/arena.tsx +182 -0
  117. package/templates/storefront/src/modules/ub/campaign-blocks.tsx +3 -2
  118. package/templates/storefront/src/modules/ub/campaign-ended.tsx +27 -13
  119. package/templates/storefront/src/modules/ub/cinema.tsx +2 -0
  120. package/templates/storefront/src/modules/ub/cookie-banner.tsx +62 -0
  121. package/templates/storefront/src/modules/ub/gala.tsx +2 -0
  122. package/templates/storefront/src/modules/ub/grimoire.tsx +35 -8
  123. package/templates/storefront/src/modules/ub/harvest.tsx +2 -1
  124. package/templates/storefront/src/modules/ub/hero-image.tsx +14 -0
  125. package/templates/storefront/src/modules/ub/home-kit.ts +45 -0
  126. package/templates/storefront/src/modules/ub/lantern.tsx +244 -0
  127. package/templates/storefront/src/modules/ub/locker.tsx +180 -0
  128. package/templates/storefront/src/modules/ub/mosaic.tsx +189 -0
  129. package/templates/storefront/src/modules/ub/nav.tsx +8 -5
  130. package/templates/storefront/src/modules/ub/review-form.tsx +62 -0
  131. package/templates/storefront/src/modules/ub/reviews.tsx +131 -0
  132. package/templates/storefront/src/modules/ub/riviera.tsx +175 -0
  133. package/templates/storefront/src/modules/ub/souk.tsx +214 -0
  134. package/templates/storefront/src/modules/ub/sunset-checklist.tsx +95 -0
  135. package/templates/storefront/src/modules/ub/sunset.tsx +164 -0
  136. package/templates/storefront/src/modules/ub/tech.tsx +2 -2
  137. package/templates/storefront/src/modules/ub/training.tsx +196 -0
  138. package/templates/storefront/src/proxy.ts +2 -0
  139. package/templates/storefront/src/styles/custom.css +26 -0
  140. package/templates/storefront/src/styles/ub/album.css +1 -1
  141. package/templates/storefront/src/styles/ub/arena.css +120 -0
  142. package/templates/storefront/src/styles/ub/cinema.css +1 -1
  143. package/templates/storefront/src/styles/ub/gala.css +1 -1
  144. package/templates/storefront/src/styles/ub/grimoire.css +23 -7
  145. package/templates/storefront/src/styles/ub/harvest.css +7 -6
  146. package/templates/storefront/src/styles/ub/lantern.css +157 -0
  147. package/templates/storefront/src/styles/ub/locker.css +115 -0
  148. package/templates/storefront/src/styles/ub/media.css +10 -0
  149. package/templates/storefront/src/styles/ub/mosaic.css +118 -0
  150. package/templates/storefront/src/styles/ub/motion.css +0 -3
  151. package/templates/storefront/src/styles/ub/overrides.css +10 -0
  152. package/templates/storefront/src/styles/ub/pages.css +176 -0
  153. package/templates/storefront/src/styles/ub/reviews.css +85 -0
  154. package/templates/storefront/src/styles/ub/riviera.css +103 -0
  155. package/templates/storefront/src/styles/ub/souk.css +144 -0
  156. package/templates/storefront/src/styles/ub/sunset.css +123 -0
  157. package/templates/storefront/src/styles/ub/tech.css +2 -5
  158. package/templates/storefront/src/styles/ub/training.css +120 -0
  159. package/templates/storefront/themes/jeux/campaign.en.json +31 -0
  160. package/templates/storefront/themes/jeux/campaign.fr.json +31 -0
  161. package/templates/storefront/themes/jeux/theme.json +11 -0
  162. package/templates/storefront/themes/jeux-2/campaign.en.json +31 -0
  163. package/templates/storefront/themes/jeux-2/campaign.fr.json +31 -0
  164. package/templates/storefront/themes/jeux-2/theme.json +11 -0
  165. package/templates/storefront/themes/mubarak/campaign.en.json +31 -0
  166. package/templates/storefront/themes/mubarak/campaign.fr.json +31 -0
  167. package/templates/storefront/themes/mubarak/theme.json +11 -0
  168. package/templates/storefront/themes/mubarak-2/campaign.en.json +31 -0
  169. package/templates/storefront/themes/mubarak-2/campaign.fr.json +31 -0
  170. package/templates/storefront/themes/mubarak-2/theme.json +11 -0
  171. package/templates/storefront/themes/sport/campaign.en.json +31 -0
  172. package/templates/storefront/themes/sport/campaign.fr.json +31 -0
  173. package/templates/storefront/themes/sport/theme.json +11 -0
  174. package/templates/storefront/themes/sport-2/campaign.en.json +31 -0
  175. package/templates/storefront/themes/sport-2/campaign.fr.json +31 -0
  176. package/templates/storefront/themes/sport-2/theme.json +11 -0
  177. package/templates/storefront/themes/summer/campaign.en.json +31 -0
  178. package/templates/storefront/themes/summer/campaign.fr.json +31 -0
  179. package/templates/storefront/themes/summer/theme.json +11 -0
  180. package/templates/storefront/themes/summer-2/campaign.en.json +31 -0
  181. package/templates/storefront/themes/summer-2/campaign.fr.json +31 -0
  182. package/templates/storefront/themes/summer-2/theme.json +11 -0
@@ -13,6 +13,7 @@ type PaginatedProductsParams = {
13
13
  category_id?: string[]
14
14
  id?: string[]
15
15
  order?: string
16
+ q?: string
16
17
  }
17
18
 
18
19
  export default async function PaginatedProducts({
@@ -21,6 +22,7 @@ export default async function PaginatedProducts({
21
22
  collectionId,
22
23
  categoryId,
23
24
  productsIds,
25
+ q,
24
26
  showCount,
25
27
  }: {
26
28
  sortBy?: SortOptions
@@ -28,6 +30,8 @@ export default async function PaginatedProducts({
28
30
  collectionId?: string
29
31
  categoryId?: string
30
32
  productsIds?: string[]
33
+ /** Recherche plein texte (Medusa) */
34
+ q?: string
31
35
  /** Affiche « N produits » au-dessus de la grille */
32
36
  showCount?: boolean
33
37
  }) {
@@ -43,6 +47,10 @@ export default async function PaginatedProducts({
43
47
  queryParams["category_id"] = [categoryId]
44
48
  }
45
49
 
50
+ if (q) {
51
+ queryParams["q"] = q
52
+ }
53
+
46
54
  if (productsIds) {
47
55
  queryParams["id"] = productsIds
48
56
  }
@@ -70,9 +78,13 @@ export default async function PaginatedProducts({
70
78
  return (
71
79
  <>
72
80
  {showCount && <p className="ub-shop__count">{t("shop.count", { count: String(count) })}</p>}
73
- <div data-testid="products-list">
74
- <ProductGrid products={products} />
75
- </div>
81
+ {q && !products.length ? (
82
+ <p className="ub-shop__empty">{t("shop.noResults", { q })}</p>
83
+ ) : (
84
+ <div data-testid="products-list">
85
+ <ProductGrid products={products} />
86
+ </div>
87
+ )}
76
88
  {totalPages > 1 && (
77
89
  <Pagination
78
90
  data-testid="product-pagination"
@@ -7,6 +7,7 @@ import { HttpTypes } from "@medusajs/types"
7
7
  import { CampaignCountdown } from "./campaign-blocks"
8
8
  import SurpriseButton from "./surprise-button"
9
9
  import FadeImg from "./fade-img"
10
+ import HeroImage from "./hero-image"
10
11
 
11
12
  /**
12
13
  * Accueil « Album » (thèmes Noël, theme.json → "home": "album") : un album de Noël fait main.
@@ -23,6 +24,7 @@ export function AlbumHero({ campaign }: { campaign: Campaign }) {
23
24
  if (image) preload(image, { as: "image", fetchPriority: "high" })
24
25
  return (
25
26
  <section className="ub-al-hero" style={image ? { ["--al-img" as string]: `url("${image}")` } : undefined} data-has-image={image ? "" : undefined}>
27
+ {image && <HeroImage src={image} position="center" />}
26
28
  <span className="ub-al-hero__curtain" aria-hidden />
27
29
  <div className="ub-wrap ub-al-hero__in">
28
30
  {hero.eyebrow && <p className="ub-al-eyebrow">{hero.eyebrow}</p>}
@@ -0,0 +1,182 @@
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, discountOf, heroImageOf, imageOf, priceOf, splitTitle, spaced } from "./home-kit"
8
+ import { Icon } from "./icons"
9
+
10
+ /**
11
+ * Accueil « Arène » (Jeux 1, theme.json → "home": "arena") : clair, bleu roi et flamme, coupes en biais. Hero : texte + le visuel entier dans un grand panneau à coin coupé.
12
+ * Hero (mot mis en avant en dégradé de flamme, chronomètre = fin de l'offre), podium (les 3 plus fortes remises :
13
+ * or au centre, argent, bronze), couloirs de course (une offre par couloir), bande « record à battre ».
14
+ * Aucun nom ni symbole protégé (pas d'anneaux, pas « olympique »). CSS : styles/ub/arena.css.
15
+ */
16
+
17
+ export function ArenaHero({ campaign }: { campaign: Campaign }) {
18
+ const { hero } = campaign
19
+ const image = heroImageOf(campaign)
20
+ if (image) preload(image, { as: "image", fetchPriority: "high" })
21
+ const { before, hl, after } = splitTitle(hero.title, hero.highlight)
22
+ const off = discountLabel(campaign)
23
+ return (
24
+ <section className="ub-ar-hero">
25
+ <div className="ub-wrap ub-ar-hero__in">
26
+ <div className="ub-ar-hero__text">
27
+ {hero.eyebrow && (
28
+ <p className="ub-ar-kicker">
29
+ <span className="ub-ar-flame" aria-hidden />
30
+ {hero.eyebrow}
31
+ </p>
32
+ )}
33
+ <h1 className="ub-ar-title">
34
+ {before} {hl && <span className="ub-ar-title__hl">{hl}</span>}{spaced(after)}
35
+ </h1>
36
+ {hero.sub && <p className="ub-ar-sub">{hero.sub}</p>}
37
+ <div className="ub-ar-actions">
38
+ <Link href="/store" className="ub-ar-btn">
39
+ {hero.cta} <Icon name="arrowRight" />
40
+ </Link>
41
+ {hero.cta2 && (
42
+ <a href="#podium" className="ub-ar-btn ub-ar-btn--ghost">
43
+ {hero.cta2}
44
+ </a>
45
+ )}
46
+ </div>
47
+ {/* Chronomètre : remise + temps restant */}
48
+ <div className="ub-ar-chrono">
49
+ {off && (
50
+ <p className="ub-ar-chrono__off">
51
+ <small>{hero.bubble?.[0]}</small>
52
+ <b>{off}</b>
53
+ </p>
54
+ )}
55
+ {campaign.showCountdown !== false && (
56
+ <div className="ub-ar-chrono__time">
57
+ <small>{t("arena.chrono")}</small>
58
+ <CampaignCountdown endsAt={campaign.endsAt} />
59
+ </div>
60
+ )}
61
+ </div>
62
+ </div>
63
+ {/* Grand panneau à coin coupé : le visuel entier (jamais recadré ni agrandi) */}
64
+ <figure className="ub-ar-poster">
65
+ <span className="ub-ar-poster__img">{image ? <FadeImg src={image} alt="" priority /> : <span className="ub-ph" />}</span>
66
+ </figure>
67
+ </div>
68
+ </section>
69
+ )
70
+ }
71
+
72
+ /** Podium : les 3 plus fortes remises, l'or au centre (plus haut), l'argent à gauche, le bronze à droite. */
73
+ export function ArenaPodium({ products }: { products: P[] }) {
74
+ const top = products.filter(imageOf).slice(0, 3)
75
+ if (top.length < 3) return null
76
+ const steps = [
77
+ { p: top[1], place: 2, cls: "is-silver" },
78
+ { p: top[0], place: 1, cls: "is-gold" },
79
+ { p: top[2], place: 3, cls: "is-bronze" },
80
+ ]
81
+ return (
82
+ <section className="ub-ar-section" id="podium">
83
+ <div className="ub-wrap">
84
+ <header className="ub-ar-head">
85
+ <p className="ub-ar-kicker">{t("arena.podiumKicker")}</p>
86
+ <h2 className="ub-ar-h2">{t("arena.podium")}</h2>
87
+ </header>
88
+ <ol className="ub-ar-podium">
89
+ {steps.map(({ p, place, cls }) => {
90
+ const { price, onSale } = priceOf(p)
91
+ return (
92
+ <li key={p.id} className={cls}>
93
+ <Link href={`/products/${p.handle}`} className="ub-ar-step">
94
+ <span className="ub-ar-step__img">
95
+ <FadeImg src={imageOf(p)} alt="" />
96
+ </span>
97
+ <span className="ub-ar-step__name">{p.title}</span>
98
+ {price && (
99
+ <span className="ub-ar-price">
100
+ <b>{price.calculated_price}</b>
101
+ {onSale && <s>{price.original_price}</s>}
102
+ </span>
103
+ )}
104
+ <span className="ub-ar-step__block">
105
+ <span className="ub-ar-medal">{place}</span>
106
+ <small>{t(`arena.place${place}`)}</small>
107
+ </span>
108
+ </Link>
109
+ </li>
110
+ )
111
+ })}
112
+ </ol>
113
+ </div>
114
+ </section>
115
+ )
116
+ }
117
+
118
+ /** Couloirs de course : une offre par couloir, la remise en « temps » à l'arrivée. */
119
+ export function ArenaLanes({ products, title }: { products: P[]; title: string }) {
120
+ const items = products.filter(imageOf).slice(3, 11)
121
+ if (!items.length) return null
122
+ return (
123
+ <section className="ub-ar-section">
124
+ <div className="ub-wrap">
125
+ <header className="ub-ar-head ub-ar-head--row">
126
+ <h2 className="ub-ar-h2">{title}</h2>
127
+ <Link href="/store" className="ub-ar-more">
128
+ {t("arena.all")} <Icon name="arrowRight" />
129
+ </Link>
130
+ </header>
131
+ <ol className="ub-ar-lanes">
132
+ {items.map((p, i) => {
133
+ const { price, onSale } = priceOf(p)
134
+ const pct = Math.round(discountOf(p) * 100)
135
+ return (
136
+ <li key={p.id}>
137
+ <Link href={`/products/${p.handle}`} className="ub-ar-lane">
138
+ <span className="ub-ar-lane__n">{i + 1}</span>
139
+ <span className="ub-ar-lane__img">
140
+ <FadeImg src={imageOf(p)} alt="" />
141
+ </span>
142
+ <span className="ub-ar-lane__name">{p.title}</span>
143
+ <span className="ub-ar-lane__track" aria-hidden>
144
+ <i style={{ width: `${Math.max(12, pct * 1.6)}%` }} />
145
+ </span>
146
+ {price && (
147
+ <span className="ub-ar-price">
148
+ <b>{price.calculated_price}</b>
149
+ {onSale && pct > 0 && <em>-{pct}%</em>}
150
+ </span>
151
+ )}
152
+ </Link>
153
+ </li>
154
+ )
155
+ })}
156
+ </ol>
157
+ </div>
158
+ </section>
159
+ )
160
+ }
161
+
162
+ /** Record à battre : bande promo, ligne d'arrivée. */
163
+ export function ArenaRecord({ campaign }: { campaign: Campaign }) {
164
+ const promo = campaign.promo
165
+ return (
166
+ <section className="ub-ar-section">
167
+ <div className="ub-wrap">
168
+ <div className="ub-ar-record">
169
+ <span className="ub-ar-record__finish" aria-hidden />
170
+ <div>
171
+ {promo?.eyebrow && <p className="ub-ar-kicker">{promo.eyebrow}</p>}
172
+ <h2 className="ub-ar-h2">{promo?.title ?? campaign.hero.title}</h2>
173
+ {promo?.text && <p className="ub-ar-lead">{promo.text}</p>}
174
+ </div>
175
+ <Link href="/store" className="ub-ar-btn">
176
+ {promo?.cta ?? campaign.hero.cta} <Icon name="arrowRight" />
177
+ </Link>
178
+ </div>
179
+ </div>
180
+ </section>
181
+ )
182
+ }
@@ -5,6 +5,7 @@ import { t } from "@lib/boutique/i18n"
5
5
  import Button from "./button"
6
6
  import Countdown from "./countdown"
7
7
  import { Icon, type IconName } from "./icons"
8
+ import FadeImg from "./fade-img"
8
9
 
9
10
  /** Titre avec le mot mis en couleur : `highlight` est retrouvé dans `title` (sinon ajouté à la fin), rendu en <em>. Jamais de HTML. */
10
11
  export function HighlightedTitle({ title, highlight }: { title: string; highlight?: string }) {
@@ -99,7 +100,7 @@ export function Hero({ campaign }: { campaign: Campaign }) {
99
100
  <div className="ub-hero__blob" />
100
101
  {image ? (
101
102
  // eslint-disable-next-line @next/next/no-img-element
102
- <img className="ub-hero__photo" src={image} alt={t("hero.photo")} fetchPriority="high" decoding="async" />
103
+ <FadeImg className="ub-hero__photo" src={image} alt={t("hero.photo")} priority />
103
104
  ) : (
104
105
  <div className="ub-hero__photo ub-ph" role="img" aria-label={t("hero.photo")} />
105
106
  )}
@@ -173,7 +174,7 @@ export function PromoBanner({ campaign, fallbackImage }: { campaign: Campaign; f
173
174
  {promoImage ? (
174
175
  <div className="ub-promo__media" style={{ display: "block", overflow: "hidden" }}>
175
176
  {/* eslint-disable-next-line @next/next/no-img-element */}
176
- <img src={promoImage} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
177
+ <FadeImg src={promoImage} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
177
178
  </div>
178
179
  ) : (
179
180
  <div className="ub-promo__media ub-ph" />
@@ -2,26 +2,40 @@ import { getBoutique } from "@lib/boutique/config"
2
2
  import { t } from "@lib/boutique/i18n"
3
3
  import Button from "./button"
4
4
 
5
- /** Page « This sale has ended » + lien vers la campagne suivante du client (autre boutique du projet). */
6
- export default function CampaignEnded({ nextUrl }: { nextUrl?: string }) {
5
+ /**
6
+ * Site fermé (admin → Campagnes → Hors ligne) : page affichée à la place de la boutique.
7
+ * Restent accessibles : compte, suivi de commande, panier (un client en train de payer finit sa commande).
8
+ */
9
+ export default function BoutiqueClosed({ nextUrl }: { nextUrl?: string }) {
7
10
  const { storeName } = getBoutique()
8
11
 
9
12
  return (
10
- <section className="ub-hero">
11
- <div className="ub-wrap" style={{ padding: "96px 24px", textAlign: "center", display: "grid", gap: 24, justifyItems: "center" }}>
12
- <h1 className="ub-hero__title ub-head">{t("ended.title")}</h1>
13
- <p className="ub-hero__sub">{t("ended.text", { storeName })}</p>
14
- {nextUrl ? (
13
+ <section className="ub-closed">
14
+ {/* Page fermée : ne pas l'indexer (React place la balise dans <head>) */}
15
+ <meta name="robots" content="noindex" />
16
+ <div className="ub-wrap ub-closed__in">
17
+ <span className="ub-closed__icon" aria-hidden>
18
+ <svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
19
+ <rect x="5" y="11" width="14" height="9" rx="2" />
20
+ <path d="M8 11V8a4 4 0 0 1 8 0v3" />
21
+ </svg>
22
+ </span>
23
+ <h1 className="ub-closed__title ub-head">{t("closed.title")}</h1>
24
+ <p className="ub-closed__text">{t("closed.text", { storeName })}</p>
25
+ {nextUrl && (
15
26
  // Lien absolu vers une autre boutique du même projet (URL connue par project.json).
16
27
  <a className="ub-btn ub-btn--primary" href={nextUrl}>
17
- <span>{t("ended.cta")}</span>
28
+ <span>{t("closed.cta")}</span>
18
29
  </a>
19
- ) : (
20
- <p className="ub-hero__sub">{t("ended.soon")}</p>
21
30
  )}
22
- <Button href="/account" variant="ghost">
23
- {t("nav.account")}
24
- </Button>
31
+ <div className="ub-closed__actions">
32
+ <Button href="/order/track" variant="ghost">
33
+ {t("closed.track")}
34
+ </Button>
35
+ <Button href="/account" variant="ghost">
36
+ {t("nav.account")}
37
+ </Button>
38
+ </div>
25
39
  </div>
26
40
  </section>
27
41
  )
@@ -6,6 +6,7 @@ import { getProductPrice } from "@lib/util/get-product-price"
6
6
  import { HttpTypes } from "@medusajs/types"
7
7
  import { CampaignCountdown } from "./campaign-blocks"
8
8
  import FadeImg from "./fade-img"
9
+ import HeroImage from "./hero-image"
9
10
  import { Icon } from "./icons"
10
11
  import SurpriseButton from "./surprise-button"
11
12
 
@@ -36,6 +37,7 @@ export function CinemaHero({ campaign, next }: { campaign: Campaign; next: P[] }
36
37
 
37
38
  return (
38
39
  <section className="ub-cn-hero" style={image ? { ["--cn-img" as string]: `url("${image}")` } : undefined} data-has-image={image ? "" : undefined}>
40
+ {image && <HeroImage src={image} position="center" />}
39
41
  <div className="ub-wrap ub-cn-hero__in">
40
42
  <div className="ub-cn-hero__poster">
41
43
  {hero.eyebrow && <p className="ub-cn-tag">{hero.eyebrow}</p>}
@@ -0,0 +1,62 @@
1
+ "use client"
2
+
3
+ import Link from "next/link"
4
+ import { usePathname } from "next/navigation"
5
+ import { useEffect, useState } from "react"
6
+ import { useT } from "@lib/boutique/i18n-client"
7
+
8
+ const KEY = "ub_cookies"
9
+
10
+ /**
11
+ * Bandeau cookies. Le site n'utilise que des cookies nécessaires (panier, connexion, boutique du panier) : la loi
12
+ * n'exige pas de consentement pour ceux-là, le bandeau informe et se ferme pour de bon (choix gardé dans le navigateur).
13
+ * Si un jour des cookies de mesure ou de publicité sont ajoutés : passer `optional` à true pour proposer
14
+ * « Accepter / Refuser », et ne charger ces outils que si localStorage[ub_cookies] === "all".
15
+ * Apparence : variables du thème (couleurs, police, arrondis) → s'accorde à chaque boutique. CSS : styles/ub/pages.css.
16
+ */
17
+ export default function CookieBanner({ optional = false }: { optional?: boolean }) {
18
+ const t = useT()
19
+ const [open, setOpen] = useState(false)
20
+ const path = usePathname()
21
+
22
+ useEffect(() => {
23
+ try {
24
+ if (!localStorage.getItem(KEY)) setOpen(true)
25
+ } catch {
26
+ // stockage bloqué : on n'insiste pas
27
+ }
28
+ }, [])
29
+
30
+ const close = (choice: "necessary" | "all") => {
31
+ try {
32
+ localStorage.setItem(KEY, choice)
33
+ } catch {
34
+ // ignoré
35
+ }
36
+ setOpen(false)
37
+ }
38
+
39
+ // Jamais pendant le paiement : rien ne doit gêner ni recouvrir le bouton de commande
40
+ if (!open || path.startsWith("/checkout")) return null
41
+ return (
42
+ <aside className="ub-cookies" role="region" aria-label={t("cookies.title")}>
43
+ <div className="ub-cookies__text">
44
+ <b className="ub-cookies__title">{t("cookies.title")}</b>
45
+ <p>
46
+ {t(optional ? "cookies.textOptional" : "cookies.text")}{" "}
47
+ <Link href="/legal/privacy">{t("cookies.more")}</Link>
48
+ </p>
49
+ </div>
50
+ <div className="ub-cookies__actions">
51
+ {optional && (
52
+ <button type="button" className="ub-cookies__btn ub-cookies__btn--ghost" onClick={() => close("necessary")}>
53
+ {t("cookies.refuse")}
54
+ </button>
55
+ )}
56
+ <button type="button" className="ub-cookies__btn" onClick={() => close(optional ? "all" : "necessary")}>
57
+ {t(optional ? "cookies.accept" : "cookies.ok")}
58
+ </button>
59
+ </div>
60
+ </aside>
61
+ )
62
+ }
@@ -6,6 +6,7 @@ import { getProductPrice } from "@lib/util/get-product-price"
6
6
  import { HttpTypes } from "@medusajs/types"
7
7
  import { CampaignCountdown } from "./campaign-blocks"
8
8
  import FadeImg from "./fade-img"
9
+ import HeroImage from "./hero-image"
9
10
  import { Icon } from "./icons"
10
11
  import SurpriseButton from "./surprise-button"
11
12
 
@@ -31,6 +32,7 @@ export function GalaHero({ campaign }: { campaign: Campaign }) {
31
32
 
32
33
  return (
33
34
  <section className="ub-gl-hero" style={image ? { ["--gl-img" as string]: `url("${image}")` } : undefined} data-has-image={image ? "" : undefined}>
35
+ {image && <HeroImage src={image} position="right center" />}
34
36
  <div className="ub-gl-hero__sparkle" aria-hidden />
35
37
  <div className="ub-wrap ub-gl-hero__in">
36
38
  <div className="ub-gl-hero__text">
@@ -6,6 +6,7 @@ import { getProductPrice } from "@lib/util/get-product-price"
6
6
  import { HttpTypes } from "@medusajs/types"
7
7
  import { CampaignCountdown } from "./campaign-blocks"
8
8
  import FadeImg from "./fade-img"
9
+ import HeroImage from "./hero-image"
9
10
  import { Icon } from "./icons"
10
11
  import SurpriseButton from "./surprise-button"
11
12
 
@@ -25,24 +26,27 @@ const priceOf = (p: P) => {
25
26
  const ARCANA = ["XIII", "XVIII", "XV"]
26
27
 
27
28
  export function GrimoireHero({ campaign }: { campaign: Campaign }) {
28
- const { hero } = campaign
29
+ const { hero, promo } = campaign
29
30
  const image = hero.image && /^https?:\/\//.test(hero.image) ? hero.image : undefined
30
31
  if (image) preload(image, { as: "image", fetchPriority: "high" })
31
32
  const hl = hero.highlight && hero.title.includes(hero.highlight) ? hero.highlight : undefined
33
+ const discount = hero.bubble?.[1] ? `-${String(hero.bubble[1]).replace(/^-/, "")}` : undefined
34
+ const badges = [promo?.seal, t("cinema.badgeStock"), t("cinema.badgeShipping")].filter(Boolean) as string[]
32
35
  const before = hl ? hero.title.slice(0, hero.title.indexOf(hl)).trim() : hero.title
33
36
  const after = hl ? hero.title.slice(hero.title.indexOf(hl) + hl.length).trim() : ""
34
37
 
35
38
  return (
36
39
  <section className="ub-gr-hero" style={image ? { ["--gr-img" as string]: `url("${image}")` } : undefined} data-has-image={image ? "" : undefined}>
40
+ {image && <HeroImage src={image} position="center 35%" />}
37
41
  <div className="ub-gr-hero__bg" aria-hidden />
38
42
  <div className="ub-wrap ub-gr-hero__in">
39
- {hero.eyebrow && <p className="ub-gr-eyebrow">{hero.eyebrow}</p>}
40
- <h1 className="ub-gr-title">
41
- {before && <span className="ub-gr-title__line">{before}</span>}
42
- {hl && <span className="ub-gr-title__line ub-gr-title__hl">{hl}</span>}
43
- {after && <span className="ub-gr-title__line">{after}</span>}
44
- </h1>
45
- <div className="ub-gr-hero__foot">
43
+ <div className="ub-gr-hero__main">
44
+ {hero.eyebrow && <p className="ub-gr-eyebrow">{hero.eyebrow}</p>}
45
+ <h1 className="ub-gr-title">
46
+ {before && <span className="ub-gr-title__line">{before}</span>}
47
+ {hl && <span className="ub-gr-title__line ub-gr-title__hl">{hl}</span>}
48
+ {after && <span className="ub-gr-title__line">{after}</span>}
49
+ </h1>
46
50
  {hero.sub && <p className="ub-gr-sub">{hero.sub}</p>}
47
51
  <div className="ub-gr-actions">
48
52
  <Link href="/store" className="ub-gr-btn ub-gr-btn--solid">
@@ -56,6 +60,29 @@ export function GrimoireHero({ campaign }: { campaign: Campaign }) {
56
60
  <SurpriseButton event="halloween" label={t("fx.surprise.halloween")} />
57
61
  </div>
58
62
  </div>
63
+ {/* Étiquette de fiole : remise, offre, compte à rebours et arguments (remplit le côté droit) */}
64
+ <aside className="ub-gr-label">
65
+ <p className="ub-gr-label__kicker">{promo?.eyebrow ?? hero.eyebrow}</p>
66
+ {discount && (
67
+ <p className="ub-gr-label__big">
68
+ {hero.bubble?.[0] && <small>{hero.bubble[0]}</small>}
69
+ <b>{discount}</b>
70
+ </p>
71
+ )}
72
+ {promo?.title && <p className="ub-gr-label__title">{promo.title}</p>}
73
+ {campaign.showCountdown !== false && (
74
+ <div className="ub-gr-label__cd">
75
+ <CampaignCountdown endsAt={campaign.endsAt} />
76
+ </div>
77
+ )}
78
+ <ul className="ub-gr-label__list">
79
+ {badges.map((b) => (
80
+ <li key={b}>
81
+ <Icon name="sparkle" /> {b}
82
+ </li>
83
+ ))}
84
+ </ul>
85
+ </aside>
59
86
  </div>
60
87
  </section>
61
88
  )
@@ -9,6 +9,7 @@ import { CampaignCountdown } from "./campaign-blocks"
9
9
  import { Icon, type IconName } from "./icons"
10
10
 
11
11
  import FadeImg from "./fade-img"
12
+ import HeroImage from "./hero-image"
12
13
  /**
13
14
  * Accueil « Harvest » (thèmes Thanksgiving, theme.json → "home": "harvest") : structure différente de l'accueil
14
15
  * standard. Hero plein cadre avec titre condensé géant + mot manuscrit et bas en vague (« tache »), services en ligne,
@@ -42,7 +43,7 @@ export function HarvestHero({ campaign }: { campaign: Campaign }) {
42
43
 
43
44
  return (
44
45
  <section className="ub-hv-hero" style={image ? { ["--hv-img" as string]: `url("${image}")` } : undefined} data-has-image={image ? "" : undefined}>
45
- {image && <div className="ub-hv-hero__bg" aria-hidden />}
46
+ {image && <HeroImage src={image} position="right center" className="ub-hv-hero__bg" />}
46
47
  <div className="ub-wrap ub-hv-hero__in">
47
48
  <div className="ub-hv-hero__text">
48
49
  {hero.eyebrow && <p className="ub-hv-eyebrow">{hero.eyebrow}</p>}
@@ -0,0 +1,14 @@
1
+ import FadeImg from "./fade-img"
2
+
3
+ /**
4
+ * Image de fond d'un hero, comme les photos produits : fond animé pendant le chargement, puis apparition en fondu
5
+ * (plus d'image qui s'affiche par morceaux). Chargée en priorité (préchargée par le hero). Aucune animation de zoom.
6
+ * Couche absolue derrière le contenu : le hero doit être en position relative avec `isolation: isolate`.
7
+ */
8
+ export default function HeroImage({ src, position = "center", className = "" }: { src: string; position?: string; className?: string }) {
9
+ return (
10
+ <span className={`ub-heroimg${className ? ` ${className}` : ""}`} aria-hidden>
11
+ <FadeImg src={src} alt="" priority style={{ objectPosition: position }} />
12
+ </span>
13
+ )
14
+ }
@@ -0,0 +1,45 @@
1
+ import type { Campaign } from "@lib/boutique/campaign"
2
+ import { getProductPrice } from "@lib/util/get-product-price"
3
+ import { HttpTypes } from "@medusajs/types"
4
+
5
+ /** Petits outils communs aux accueils thématiques (Riviera, Sunset, Vestiaire, Training, Arène, Mosaïque). */
6
+ export type P = HttpTypes.StoreProduct
7
+
8
+ export const imageOf = (p: P) => p.thumbnail ?? p.images?.[0]?.url ?? undefined
9
+
10
+ export function priceOf(p: P) {
11
+ const { cheapestPrice } = getProductPrice({ product: p })
12
+ return { price: cheapestPrice, onSale: cheapestPrice?.price_type === "sale" }
13
+ }
14
+
15
+ /** Remise (0 à 1) la plus forte parmi les variantes. */
16
+ export function discountOf(p: P) {
17
+ return Math.max(
18
+ 0,
19
+ ...(p.variants ?? []).map((v) => {
20
+ const c = v.calculated_price
21
+ return c?.original_amount && c.calculated_amount != null ? 1 - c.calculated_amount / c.original_amount : 0
22
+ })
23
+ )
24
+ }
25
+
26
+ /** Image du hero (URL absolue du stockage) ou rien. */
27
+ export const heroImageOf = (campaign: Campaign) => {
28
+ const image = campaign.hero.image
29
+ return image && /^https?:\/\//.test(image) ? image : undefined
30
+ }
31
+
32
+ /** Titre coupé autour du mot mis en avant : avant / mot / après. */
33
+ export function splitTitle(title: string, highlight?: string) {
34
+ const hl = highlight && title.includes(highlight) ? highlight : undefined
35
+ if (!hl) return { before: title, hl: undefined, after: "" }
36
+ const i = title.indexOf(hl)
37
+ return { before: title.slice(0, i).trim(), hl, after: title.slice(i + hl.length).trim() }
38
+ }
39
+
40
+ /** Suite du titre après le mot mis en avant : précédée d'une espace, sauf ponctuation (« brillent, » et non « brillent , »). */
41
+ export const spaced = (after: string) => (after && !/^[,.;:!?…)»]/.test(after) ? ` ${after}` : after)
42
+
43
+ /** Remise affichée : « -40% » depuis la bulle de la campagne. */
44
+ export const discountLabel = (campaign: Campaign) =>
45
+ campaign.hero.bubble?.[1] ? `-${String(campaign.hero.bubble[1]).replace(/^-/, "")}` : undefined