@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
@@ -0,0 +1,62 @@
1
+ "use client"
2
+
3
+ import { useActionState, useState } from "react"
4
+ import { useT } from "@lib/boutique/i18n-client"
5
+ import { submitReview, type ReviewFormState } from "@lib/data/review-actions"
6
+
7
+ /**
8
+ * Formulaire d'avis d'un produit (page « Laisser un avis », lien de l'e-mail « commande livrée »).
9
+ * Étoiles au clavier et à la souris (boutons radio), prénom prérempli, titre facultatif, commentaire.
10
+ * Envoi par action serveur ; le backend vérifie tout (jeton, commande, produit, doublon).
11
+ */
12
+ export default function ReviewForm({ token, productId, firstName }: { token: string; productId: string; firstName: string }) {
13
+ const t = useT()
14
+ const [state, action, pending] = useActionState<ReviewFormState, FormData>(submitReview, {})
15
+ const [rating, setRating] = useState(0)
16
+ const [hover, setHover] = useState(0)
17
+
18
+ if (state.ok) return <p className="ub-rform__done">{t("reviews.thanks")}</p>
19
+
20
+ const shown = hover || rating
21
+ return (
22
+ <form action={action} className="ub-rform">
23
+ <input type="hidden" name="token" value={token} />
24
+ <input type="hidden" name="product_id" value={productId} />
25
+ <fieldset className="ub-rform__stars" onMouseLeave={() => setHover(0)}>
26
+ <legend>{t("reviews.yourRating")}</legend>
27
+ {[1, 2, 3, 4, 5].map((n) => (
28
+ <label key={n} onMouseEnter={() => setHover(n)} className={n <= shown ? "is-on" : undefined} title={t(`reviews.rating${n}`)}>
29
+ <input type="radio" name="rating" value={n} required checked={rating === n} onChange={() => setRating(n)} />
30
+ <span aria-hidden>★</span>
31
+ <span className="ub-sr">{t(`reviews.rating${n}`)}</span>
32
+ </label>
33
+ ))}
34
+ <span className="ub-rform__hint" aria-live="polite">
35
+ {shown ? t(`reviews.rating${shown}`) : ""}
36
+ </span>
37
+ </fieldset>
38
+ <label className="ub-rform__field">
39
+ <span>{t("reviews.name")}</span>
40
+ <input name="author_name" required maxLength={40} defaultValue={firstName} autoComplete="given-name" />
41
+ </label>
42
+ <label className="ub-rform__field">
43
+ <span>
44
+ {t("reviews.titleField")} <small>{t("reviews.optional")}</small>
45
+ </span>
46
+ <input name="title" maxLength={80} />
47
+ </label>
48
+ <label className="ub-rform__field">
49
+ <span>{t("reviews.comment")}</span>
50
+ <textarea name="comment" required minLength={10} maxLength={1000} rows={4} placeholder={t("reviews.commentHint")} />
51
+ </label>
52
+ {state.error && (
53
+ <p className="ub-rform__error" role="alert">
54
+ {t(`reviews.error.${state.error}`)}
55
+ </p>
56
+ )}
57
+ <button type="submit" className="ub-rform__btn" disabled={pending || !rating}>
58
+ {pending ? t("reviews.sending") : t("reviews.send")}
59
+ </button>
60
+ </form>
61
+ )
62
+ }
@@ -0,0 +1,131 @@
1
+ import Link from "next/link"
2
+ import { getBoutique } from "@lib/boutique/config"
3
+ import { t } from "@lib/boutique/i18n"
4
+ import { getLatestReviews, getProductReviews } from "@lib/data/reviews"
5
+ import FadeImg from "./fade-img"
6
+
7
+ /**
8
+ * Avis clients sur le site : étoiles, section de la fiche produit, section de l'accueil.
9
+ * Apparence : variables du thème (--star, --surface, --ink, polices, arrondis) → s'accorde à chaque boutique.
10
+ * Texte simple rendu par React (jamais de HTML). CSS : styles/ub/reviews.css.
11
+ */
12
+
13
+ /** Étoiles (note décimale : la dernière est remplie en partie) */
14
+ export function Stars({ value, size = 16, label }: { value: number; size?: number; label?: string }) {
15
+ return (
16
+ <span className="ub-stars" role="img" aria-label={label ?? t("reviews.outOf", { value: String(value) })} style={{ ["--s" as string]: `${size}px` }}>
17
+ {[1, 2, 3, 4, 5].map((i) => {
18
+ const fill = Math.max(0, Math.min(1, value - (i - 1)))
19
+ return (
20
+ <span key={i} className="ub-stars__one" aria-hidden>
21
+ <span style={{ width: `${fill * 100}%` }} />
22
+ </span>
23
+ )
24
+ })}
25
+ </span>
26
+ )
27
+ }
28
+
29
+ const date = (iso: string) => new Intl.DateTimeFormat(getBoutique().locale, { day: "numeric", month: "long", year: "numeric" }).format(new Date(iso))
30
+
31
+ /** Fiche produit : note moyenne, répartition, derniers avis. */
32
+ export async function ProductReviews({ productId }: { productId: string }) {
33
+ const { reviews, summary } = await getProductReviews(productId)
34
+ return (
35
+ <section className="ub-section ub-reviews" id="avis" aria-labelledby="ub-reviews-title">
36
+ <div className="ub-wrap">
37
+ <h2 id="ub-reviews-title" className="ub-head ub-reviews__title">
38
+ {t("reviews.title")}
39
+ </h2>
40
+ {!summary.count ? (
41
+ <p className="ub-reviews__empty">{t("reviews.none")}</p>
42
+ ) : (
43
+ <div className="ub-reviews__grid">
44
+ <div className="ub-reviews__summary">
45
+ <p className="ub-reviews__avg">
46
+ <b>{summary.average.toLocaleString(getBoutique().locale)}</b>
47
+ <span>/5</span>
48
+ </p>
49
+ <Stars value={summary.average} size={22} />
50
+ <p className="ub-reviews__count">{t(summary.count > 1 ? "reviews.countMany" : "reviews.countOne", { count: String(summary.count) })}</p>
51
+ <ul className="ub-reviews__bars">
52
+ {[5, 4, 3, 2, 1].map((n) => {
53
+ const c = summary.distribution[n - 1]
54
+ return (
55
+ <li key={n}>
56
+ <span>{n}★</span>
57
+ <span className="ub-reviews__bar" aria-hidden>
58
+ <i style={{ width: `${summary.count ? (c / summary.count) * 100 : 0}%` }} />
59
+ </span>
60
+ <span>{c}</span>
61
+ </li>
62
+ )
63
+ })}
64
+ </ul>
65
+ <p className="ub-reviews__how">{t("reviews.how")}</p>
66
+ </div>
67
+ <ul className="ub-reviews__list">
68
+ {reviews.map((r) => (
69
+ <li key={r.id} className="ub-review">
70
+ <div className="ub-review__head">
71
+ <Stars value={r.rating} />
72
+ <span className="ub-review__verified">{t("reviews.verified")}</span>
73
+ </div>
74
+ {r.title && <p className="ub-review__title">{r.title}</p>}
75
+ <p className="ub-review__text">{r.comment}</p>
76
+ <p className="ub-review__by">
77
+ {r.author_name} · {date(r.created_at)}
78
+ </p>
79
+ </li>
80
+ ))}
81
+ </ul>
82
+ </div>
83
+ )}
84
+ </div>
85
+ </section>
86
+ )
87
+ }
88
+
89
+ /** Accueil : les derniers avis 4-5 étoiles sur des produits de la boutique (rien s'il n'y en a pas). */
90
+ export async function HomeReviews() {
91
+ const reviews = await getLatestReviews(6)
92
+ if (!reviews.length) return null
93
+ return (
94
+ <section className="ub-section ub-homereviews" aria-labelledby="ub-homereviews-title">
95
+ <div className="ub-wrap">
96
+ <div className="ub-homereviews__head">
97
+ <p className="ub-homereviews__kicker">{t("reviews.homeKicker")}</p>
98
+ <h2 id="ub-homereviews-title" className="ub-head">
99
+ {t("reviews.homeTitle")}
100
+ </h2>
101
+ </div>
102
+ <ul className="ub-homereviews__list">
103
+ {reviews.map((r) => (
104
+ <li key={r.id}>
105
+ <figure className="ub-quote">
106
+ <Stars value={r.rating} />
107
+ <blockquote>
108
+ {r.title && <b>{r.title}</b>}
109
+ <p>{r.comment}</p>
110
+ </blockquote>
111
+ <figcaption>
112
+ <Link href={`/products/${r.product.handle}`} className="ub-quote__product">
113
+ {r.product.thumbnail && (
114
+ <span className="ub-quote__img">
115
+ <FadeImg src={r.product.thumbnail} alt="" />
116
+ </span>
117
+ )}
118
+ <span>
119
+ <b>{r.author_name}</b>
120
+ <small>{r.product.title}</small>
121
+ </span>
122
+ </Link>
123
+ </figcaption>
124
+ </figure>
125
+ </li>
126
+ ))}
127
+ </ul>
128
+ </div>
129
+ </section>
130
+ )
131
+ }
@@ -0,0 +1,175 @@
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 HeroImage from "./hero-image"
8
+ import { type P, discountLabel, heroImageOf, imageOf, priceOf, splitTitle, spaced } from "./home-kit"
9
+ import { Icon } from "./icons"
10
+
11
+ /**
12
+ * Accueil « Riviera » (Summer, theme.json → "home": "riviera") : élégance d'été, mise en page de magazine.
13
+ * Hero éditorial (grand titre à empattements, visuel posé sur une « scène » en arc), lookbook (Look N°1…),
14
+ * « Couleurs de la saison » (vestiaire rangé par couleur), sur-mesure en 3 étapes, bandeau final. CSS : styles/ub/riviera.css.
15
+ */
16
+
17
+ export function RivieraHero({ 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-rv-hero">
25
+ {/* Visuel en fond, fondu dans le décor (pas de cadre) : à droite, s'efface vers le texte */}
26
+ {image && <HeroImage src={image} position="center 70%" className="ub-rv-hero__bg" />}
27
+ <div className="ub-wrap ub-rv-hero__in">
28
+ <div className="ub-rv-hero__text">
29
+ {hero.eyebrow && <p className="ub-rv-kicker">{hero.eyebrow}</p>}
30
+ <h1 className="ub-rv-title">
31
+ {before} {hl && <em>{hl}</em>}{spaced(after)}
32
+ </h1>
33
+ {hero.sub && <p className="ub-rv-sub">{hero.sub}</p>}
34
+ <div className="ub-rv-actions">
35
+ <Link href="/store" className="ub-rv-btn ub-rv-btn--solid">
36
+ {hero.cta}
37
+ </Link>
38
+ {hero.cta2 && (
39
+ <a href="#looks" className="ub-rv-link">
40
+ {hero.cta2} <Icon name="arrowRight" />
41
+ </a>
42
+ )}
43
+ </div>
44
+ {off && (
45
+ <p className="ub-rv-note">
46
+ <b>{off}</b> {hero.bubble?.[2]}
47
+ </p>
48
+ )}
49
+ </div>
50
+ </div>
51
+ </section>
52
+ )
53
+ }
54
+
55
+ /** Lookbook : 1 grande image + 2 petites, légendées « Look N° », puis une ligne « Le vestiaire ». */
56
+ export function RivieraLookbook({ products, title }: { products: P[]; title: string }) {
57
+ const looks = products.filter(imageOf).slice(0, 3)
58
+ if (looks.length < 3) return null
59
+ return (
60
+ <section className="ub-wrap ub-rv-section" id="looks">
61
+ <header className="ub-rv-head">
62
+ <p className="ub-rv-kicker">{t("riviera.looksKicker")}</p>
63
+ <h2 className="ub-rv-h2">{title}</h2>
64
+ </header>
65
+ <div className="ub-rv-looks">
66
+ {looks.map((p, i) => {
67
+ const { price, onSale } = priceOf(p)
68
+ return (
69
+ <Link key={p.id} href={`/products/${p.handle}`} className={`ub-rv-look ub-rv-look--${i + 1}`}>
70
+ <span className="ub-rv-look__img">
71
+ <FadeImg src={imageOf(p)} alt="" />
72
+ </span>
73
+ <span className="ub-rv-look__cap">
74
+ <small>
75
+ {t("riviera.look", { n: String(i + 1) })}
76
+ </small>
77
+ <b>{p.title}</b>
78
+ {price && (
79
+ <span className="ub-rv-price">
80
+ {price.calculated_price}
81
+ {onSale && <s>{price.original_price}</s>}
82
+ </span>
83
+ )}
84
+ </span>
85
+ </Link>
86
+ )
87
+ })}
88
+ </div>
89
+ </section>
90
+ )
91
+ }
92
+
93
+ /** Couleurs de la saison : le vestiaire rangé par couleur (une pastille par pièce, nuance tirée de la photo). */
94
+ const SWATCHES = ["#e8dcc6", "#c9b48f", "#2f3b52", "#5f6f52", "#8a6a4f", "#d9d4ca", "#1f2a24", "#b7a07a"]
95
+ export function RivieraColours({ products }: { products: P[] }) {
96
+ const items = products.filter(imageOf).slice(3, 11)
97
+ if (items.length < 4) return null
98
+ return (
99
+ <section className="ub-rv-colours">
100
+ <div className="ub-wrap">
101
+ <header className="ub-rv-head ub-rv-head--row">
102
+ <div>
103
+ <p className="ub-rv-kicker">{t("riviera.coloursKicker")}</p>
104
+ <h2 className="ub-rv-h2">{t("riviera.colours")}</h2>
105
+ </div>
106
+ <Link href="/store" className="ub-rv-link">
107
+ {t("riviera.all")} <Icon name="arrowRight" />
108
+ </Link>
109
+ </header>
110
+ <ul className="ub-rv-rail">
111
+ {items.map((p, i) => {
112
+ const { price, onSale } = priceOf(p)
113
+ return (
114
+ <li key={p.id}>
115
+ <Link href={`/products/${p.handle}`} className="ub-rv-piece">
116
+ <span className="ub-rv-piece__img">
117
+ <FadeImg src={imageOf(p)} alt="" />
118
+ </span>
119
+ <span className="ub-rv-piece__swatch" style={{ background: SWATCHES[i % SWATCHES.length] }} aria-hidden />
120
+ <span className="ub-rv-piece__name">{p.title}</span>
121
+ {price && (
122
+ <span className="ub-rv-price">
123
+ {price.calculated_price}
124
+ {onSale && <s>{price.original_price}</s>}
125
+ </span>
126
+ )}
127
+ </Link>
128
+ </li>
129
+ )
130
+ })}
131
+ </ul>
132
+ </div>
133
+ </section>
134
+ )
135
+ }
136
+
137
+ /** Sur-mesure : 3 étapes, puis l'offre (texte promo + compte à rebours). */
138
+ export function RivieraTailor({ campaign }: { campaign: Campaign }) {
139
+ const promo = campaign.promo
140
+ const steps: [string, string][] = [
141
+ [t("riviera.step1"), t("riviera.step1Sub")],
142
+ [t("riviera.step2"), t("riviera.step2Sub")],
143
+ [t("riviera.step3"), t("riviera.step3Sub")],
144
+ ]
145
+ return (
146
+ <section className="ub-wrap ub-rv-section">
147
+ <div className="ub-rv-tailor">
148
+ <div className="ub-rv-tailor__text">
149
+ {promo?.eyebrow && <p className="ub-rv-kicker">{promo.eyebrow}</p>}
150
+ <h2 className="ub-rv-h2">{promo?.title ?? campaign.hero.title}</h2>
151
+ {promo?.text && <p className="ub-rv-sub">{promo.text}</p>}
152
+ {campaign.showCountdown !== false && (
153
+ <div className="ub-rv-cd">
154
+ <CampaignCountdown endsAt={campaign.endsAt} />
155
+ </div>
156
+ )}
157
+ <Link href="/store" className="ub-rv-btn ub-rv-btn--light">
158
+ {promo?.cta ?? campaign.hero.cta}
159
+ </Link>
160
+ </div>
161
+ <ol className="ub-rv-steps">
162
+ {steps.map(([a, b], i) => (
163
+ <li key={a}>
164
+ <span className="ub-rv-steps__n">0{i + 1}</span>
165
+ <span>
166
+ <b>{a}</b>
167
+ <small>{b}</small>
168
+ </span>
169
+ </li>
170
+ ))}
171
+ </ol>
172
+ </div>
173
+ </section>
174
+ )
175
+ }
@@ -0,0 +1,214 @@
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 { HttpTypes } from "@medusajs/types"
6
+ import { CampaignCountdown } from "./campaign-blocks"
7
+ import FadeImg from "./fade-img"
8
+ import { type P, discountLabel, discountOf, heroImageOf, imageOf, priceOf, splitTitle, spaced } from "./home-kit"
9
+ import { Icon } from "./icons"
10
+
11
+ /**
12
+ * Accueil « Souk » (Mubarak · Sable, theme.json → "home": "souk") : Aïd, Korité, Tabaski.
13
+ * Crème, sable, terre cuite, safran, indigo ; treillis géométrique. Hero : texte à gauche, visuel collé au bord droit
14
+ * (fond blanc fondu dans le crème), remise en cachet de cire. Puis : les trésors au comptoir (fenêtre en arc + liste à choisir), « Pour qui ? »
15
+ * (catégories), carte de vœux en enveloppe avec cachet et compte à rebours. CSS : styles/ub/souk.css.
16
+ */
17
+
18
+ const TONES = ["terra", "saffron", "indigo", "teal"] as const
19
+
20
+ /** Treillis géométrique (motif de fond), dessiné en SVG. */
21
+ function Lattice({ id, className }: { id: string; className?: string }) {
22
+ return (
23
+ <svg className={className} aria-hidden>
24
+ <defs>
25
+ <pattern id={id} width="48" height="48" patternUnits="userSpaceOnUse">
26
+ <path d="M24 0 48 24 24 48 0 24zM24 10 38 24 24 38 10 24z" fill="none" stroke="currentColor" strokeWidth="1" />
27
+ </pattern>
28
+ </defs>
29
+ <rect width="100%" height="100%" fill={`url(#${id})`} />
30
+ </svg>
31
+ )
32
+ }
33
+
34
+ export function SoukHero({ campaign }: { campaign: Campaign }) {
35
+ const { hero } = campaign
36
+ const image = heroImageOf(campaign)
37
+ if (image) preload(image, { as: "image", fetchPriority: "high" })
38
+ const { before, hl, after } = splitTitle(hero.title, hero.highlight)
39
+ const off = discountLabel(campaign)
40
+ return (
41
+ <section className="ub-sk-hero">
42
+ <Lattice id="ub-sk-lattice-hero" className="ub-sk-lattice" />
43
+ {/* Visuel collé au bord droit et en bas du hero, toute la hauteur ; son côté blanc (treillis) se fond dans le crème.
44
+ Cachet de cire avec la remise posé sur la zone fondue. */}
45
+ <figure className="ub-sk-visual">
46
+ {image ? <FadeImg src={image} alt="" priority /> : <span className="ub-ph" />}
47
+ {off && (
48
+ <p className="ub-sk-seal" aria-hidden>
49
+ <small>{hero.bubble?.[0]}</small>
50
+ <b>{off}</b>
51
+ </p>
52
+ )}
53
+ </figure>
54
+ <div className="ub-wrap ub-sk-hero__in">
55
+ <div className="ub-sk-hero__text">
56
+ {hero.eyebrow && <p className="ub-sk-kicker">{hero.eyebrow}</p>}
57
+ <h1 className="ub-sk-title">
58
+ {before} {hl && <span className="ub-sk-title__hl">{hl}</span>}{spaced(after)}
59
+ </h1>
60
+ {hero.sub && <p className="ub-sk-sub">{hero.sub}</p>}
61
+ <div className="ub-sk-actions">
62
+ <Link href="/store" className="ub-sk-btn">
63
+ {hero.cta} <Icon name="arrowRight" />
64
+ </Link>
65
+ {hero.cta2 && (
66
+ <a href="#voeux" className="ub-sk-btn ub-sk-btn--ghost">
67
+ {hero.cta2}
68
+ </a>
69
+ )}
70
+ </div>
71
+ <ul className="ub-sk-perks">
72
+ <li>
73
+ <Icon name="gift" /> {t("souk.perk1")}
74
+ </li>
75
+ <li>
76
+ <Icon name="truck" /> {t("souk.perk2")}
77
+ </li>
78
+ </ul>
79
+ </div>
80
+ </div>
81
+ </section>
82
+ )
83
+ }
84
+
85
+ /**
86
+ * Les trésors du souk : le comptoir. À gauche, une grande fenêtre en arc outrepassé montre le produit choisi ;
87
+ * à droite, la liste (vignette, nom, prix, remise). Choix par boutons radio + :has() : aucun JavaScript.
88
+ */
89
+ export function SoukStalls({ products, title }: { products: P[]; title: string }) {
90
+ const items = products.filter(imageOf).slice(0, 6)
91
+ if (!items.length) return null
92
+ return (
93
+ <section className="ub-sk-section ub-sk-vitrine">
94
+ <Lattice id="ub-sk-lattice-vitrine" className="ub-sk-lattice" />
95
+ <div className="ub-wrap">
96
+ <header className="ub-sk-head ub-sk-head--row">
97
+ <div>
98
+ <p className="ub-sk-kicker">{t("souk.stallsKicker")}</p>
99
+ <h2 className="ub-sk-h2">{title}</h2>
100
+ </div>
101
+ <Link href="/store" className="ub-sk-more">
102
+ {t("souk.all")} <Icon name="arrowRight" />
103
+ </Link>
104
+ </header>
105
+ <div className="ub-sk-counter">
106
+ <div className="ub-sk-counter__stage">
107
+ {items.map((p, i) => {
108
+ const { price, onSale } = priceOf(p)
109
+ const pct = Math.round(discountOf(p) * 100)
110
+ return (
111
+ <Link key={p.id} href={`/products/${p.handle}`} className="ub-sk-view" data-i={i} tabIndex={-1}>
112
+ <span className="ub-sk-view__img">
113
+ <FadeImg src={imageOf(p)} alt="" />
114
+ </span>
115
+ <span className="ub-sk-view__cap">
116
+ {onSale && pct > 0 && <span className="ub-sk-view__off">-{pct}%</span>}
117
+ <span className="ub-sk-view__name">{p.title}</span>
118
+ {price && (
119
+ <span className="ub-sk-price">
120
+ <b>{price.calculated_price}</b>
121
+ {onSale && <s>{price.original_price}</s>}
122
+ </span>
123
+ )}
124
+ <span className="ub-sk-view__go">
125
+ {t("souk.see")} <Icon name="arrowRight" />
126
+ </span>
127
+ </span>
128
+ </Link>
129
+ )
130
+ })}
131
+ </div>
132
+ <ul className="ub-sk-counter__list">
133
+ {items.map((p, i) => {
134
+ const { price } = priceOf(p)
135
+ const pct = Math.round(discountOf(p) * 100)
136
+ return (
137
+ <li key={p.id}>
138
+ <input type="radio" name="ub-sk-pick" id={`ub-sk-pick-${i}`} className="ub-sk-pick" defaultChecked={i === 0} />
139
+ <label htmlFor={`ub-sk-pick-${i}`} className="ub-sk-row">
140
+ <span className="ub-sk-row__img">
141
+ <FadeImg src={imageOf(p)} alt="" />
142
+ </span>
143
+ <span className="ub-sk-row__name">{p.title}</span>
144
+ {pct > 0 && <span className="ub-sk-row__off">-{pct}%</span>}
145
+ {price && <b className="ub-sk-row__price">{price.calculated_price}</b>}
146
+ </label>
147
+ </li>
148
+ )
149
+ })}
150
+ </ul>
151
+ </div>
152
+ </div>
153
+ </section>
154
+ )
155
+ }
156
+
157
+ /** « Pour qui ? » : les catégories en tuiles arrondies en arc (masqué s'il n'y a pas de catégories). */
158
+ export function SoukWho({ categories, title }: { categories: HttpTypes.StoreProductCategory[]; title: string }) {
159
+ const roots = categories.filter((c) => !c.parent_category).slice(0, 6)
160
+ if (!roots.length) return null
161
+ return (
162
+ <section className="ub-sk-section">
163
+ <div className="ub-wrap">
164
+ <header className="ub-sk-head">
165
+ <h2 className="ub-sk-h2">{title}</h2>
166
+ </header>
167
+ <ul className="ub-sk-who">
168
+ {roots.map((c, i) => (
169
+ <li key={c.id}>
170
+ <Link href={`/categories/${c.handle}`} className={`ub-sk-door is-${TONES[i % TONES.length]}`}>
171
+ <span>{c.name}</span>
172
+ <Icon name="arrowRight" />
173
+ </Link>
174
+ </li>
175
+ ))}
176
+ </ul>
177
+ </div>
178
+ </section>
179
+ )
180
+ }
181
+
182
+ /** Carte de vœux : enveloppe ouverte, cachet de cire, texte de la promo et compte à rebours. */
183
+ export function SoukLetter({ campaign }: { campaign: Campaign }) {
184
+ const promo = campaign.promo
185
+ return (
186
+ <section className="ub-sk-section" id="voeux">
187
+ <div className="ub-wrap">
188
+ <div className="ub-sk-letter">
189
+ <span className="ub-sk-letter__flap" aria-hidden />
190
+ <div className="ub-sk-letter__card">
191
+ <Lattice id="ub-sk-lattice-letter" className="ub-sk-lattice" />
192
+ <p className="ub-sk-kicker">{promo?.eyebrow ?? campaign.hero.eyebrow}</p>
193
+ <h2 className="ub-sk-h2">{promo?.title ?? campaign.hero.title}</h2>
194
+ {promo?.text && <p className="ub-sk-sub">{promo.text}</p>}
195
+ {campaign.showCountdown !== false && (
196
+ <div className="ub-sk-cd">
197
+ <CampaignCountdown endsAt={campaign.endsAt} />
198
+ </div>
199
+ )}
200
+ <Link href="/store" className="ub-sk-btn">
201
+ {promo?.cta ?? campaign.hero.cta} <Icon name="arrowRight" />
202
+ </Link>
203
+ <p className="ub-sk-sign">{t("souk.sign")}</p>
204
+ </div>
205
+ <span className="ub-sk-letter__seal" aria-hidden>
206
+ <svg viewBox="0 0 24 26">
207
+ <path d="M12 1.5 14.6 6l5.1-.7-.7 5.1 4.5 2.6-4.5 2.6.7 5.1-5.1-.7L12 24.5 9.4 20l-5.1.7.7-5.1L.5 13l4.5-2.6-.7-5.1 5.1.7z" />
208
+ </svg>
209
+ </span>
210
+ </div>
211
+ </div>
212
+ </section>
213
+ )
214
+ }