@salifbiaye/create-boutique 0.1.5 → 0.2.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 (64) hide show
  1. package/cli/dist/commands.js +3 -3
  2. package/cli/dist/create.js +5 -5
  3. package/cli/dist/generate.js +68 -10
  4. package/cli/dist/project.js +2 -0
  5. package/cli/dist/themes.js +5 -1
  6. package/package.json +1 -1
  7. package/templates/backend/src/lib/emails.ts +8 -1
  8. package/templates/backend/src/scripts/campaign-import.ts +6 -4
  9. package/templates/backend/src/scripts/seed.ts +15 -3
  10. package/templates/storefront/locales/en.json +33 -1
  11. package/templates/storefront/locales/fr.json +33 -1
  12. package/templates/storefront/package-lock.json +7 -0
  13. package/templates/storefront/package.json +1 -0
  14. package/templates/storefront/src/app/(main)/legal/[page]/page.tsx +3 -9
  15. package/templates/storefront/src/app/(main)/page.tsx +25 -0
  16. package/templates/storefront/src/app/(main)/store/page.tsx +3 -1
  17. package/templates/storefront/src/app/layout.tsx +9 -2
  18. package/templates/storefront/src/lib/boutique/config.ts +1 -0
  19. package/templates/storefront/src/lib/boutique/i18n-client.tsx +10 -2
  20. package/templates/storefront/src/lib/boutique/schema.ts +2 -0
  21. package/templates/storefront/src/modules/account/components/account-nav/index.tsx +50 -177
  22. package/templates/storefront/src/modules/account/components/address-card/add-address.tsx +2 -2
  23. package/templates/storefront/src/modules/account/components/address-card/edit-address-modal.tsx +2 -2
  24. package/templates/storefront/src/modules/account/components/overview/index.tsx +98 -143
  25. package/templates/storefront/src/modules/account/components/profile-billing-address/index.tsx +2 -3
  26. package/templates/storefront/src/modules/account/components/profile-phone/index.tsx +2 -3
  27. package/templates/storefront/src/modules/account/components/register/index.tsx +2 -3
  28. package/templates/storefront/src/modules/account/templates/account-layout.tsx +20 -24
  29. package/templates/storefront/src/modules/checkout/components/billing_address/index.tsx +2 -2
  30. package/templates/storefront/src/modules/checkout/components/shipping-address/index.tsx +2 -2
  31. package/templates/storefront/src/modules/common/components/phone-input/index.tsx +110 -0
  32. package/templates/storefront/src/modules/store/templates/index.tsx +82 -17
  33. package/templates/storefront/src/modules/store/templates/paginated-products.tsx +5 -0
  34. package/templates/storefront/src/modules/ub/campaign-blocks.tsx +1 -1
  35. package/templates/storefront/src/modules/ub/contact-page.tsx +78 -0
  36. package/templates/storefront/src/modules/ub/footer.tsx +26 -5
  37. package/templates/storefront/src/modules/ub/mobile-menu.tsx +49 -0
  38. package/templates/storefront/src/modules/ub/nav.tsx +2 -0
  39. package/templates/storefront/src/modules/ub/showcase.tsx +137 -0
  40. package/templates/storefront/src/styles/ub/account.css +199 -0
  41. package/templates/storefront/src/styles/ub/black-friday.css +96 -0
  42. package/templates/storefront/src/styles/ub/forms.css +61 -0
  43. package/templates/storefront/src/styles/ub/pages.css +382 -0
  44. package/templates/storefront/src/styles/ub/responsive.css +57 -0
  45. package/templates/storefront/src/styles/ub/showcase.css +85 -0
  46. package/templates/storefront/src/styles/ub/variants.css +64 -0
  47. package/templates/storefront/themes/black-friday-2/campaign.en.json +31 -0
  48. package/templates/storefront/themes/black-friday-2/campaign.fr.json +31 -0
  49. package/templates/storefront/themes/black-friday-2/theme.json +10 -0
  50. package/templates/storefront/themes/cyber-monday-2/campaign.en.json +31 -0
  51. package/templates/storefront/themes/cyber-monday-2/campaign.fr.json +31 -0
  52. package/templates/storefront/themes/cyber-monday-2/theme.json +10 -0
  53. package/templates/storefront/themes/halloween-2/campaign.en.json +31 -0
  54. package/templates/storefront/themes/halloween-2/campaign.fr.json +31 -0
  55. package/templates/storefront/themes/halloween-2/theme.json +10 -0
  56. package/templates/storefront/themes/noel-2/campaign.en.json +31 -0
  57. package/templates/storefront/themes/noel-2/campaign.fr.json +31 -0
  58. package/templates/storefront/themes/noel-2/theme.json +10 -0
  59. package/templates/storefront/themes/nouvel-an-2/campaign.en.json +31 -0
  60. package/templates/storefront/themes/nouvel-an-2/campaign.fr.json +31 -0
  61. package/templates/storefront/themes/nouvel-an-2/theme.json +10 -0
  62. package/templates/storefront/themes/thanksgiving-2/campaign.en.json +31 -0
  63. package/templates/storefront/themes/thanksgiving-2/campaign.fr.json +31 -0
  64. package/templates/storefront/themes/thanksgiving-2/theme.json +10 -0
@@ -1,40 +1,36 @@
1
1
  import { T } from "@lib/boutique/i18n-client"
2
2
  import React from "react"
3
3
 
4
- import UnderlineLink from "@modules/common/components/interactive-link"
4
+ import LocalizedClientLink from "@modules/common/components/localized-client-link"
5
+ import { HttpTypes } from "@medusajs/types"
5
6
 
6
7
  import AccountNav from "../components/account-nav"
7
- import { HttpTypes } from "@medusajs/types"
8
8
 
9
9
  interface AccountLayoutProps {
10
10
  customer: HttpTypes.StoreCustomer | null
11
11
  children: React.ReactNode
12
12
  }
13
13
 
14
- const AccountLayout: React.FC<AccountLayoutProps> = ({
15
- customer,
16
- children,
17
- }) => {
14
+ /** Espace client : menu latéral (connecté) + contenu, puis carte d'aide. Non connecté : formulaire centré. */
15
+ const AccountLayout: React.FC<AccountLayoutProps> = ({ customer, children }) => {
18
16
  return (
19
- <div className="flex-1 small:py-12" data-testid="account-page">
20
- <div className="flex-1 content-container h-full max-w-5xl mx-auto bg-white flex flex-col">
21
- <div className="grid grid-cols-1 small:grid-cols-[240px_1fr] py-12">
22
- <div>{customer && <AccountNav customer={customer} />}</div>
23
- <div className="flex-1">{children}</div>
24
- </div>
25
- <div className="flex flex-col small:flex-row items-end justify-between small:border-t border-gray-200 py-12 gap-8">
26
- <div>
27
- <h3 className="text-xl-semi mb-4"><T k="account.questions" /></h3>
28
- <span className="txt-medium">
29
- <T k="account.questionsText" />
30
- </span>
31
- </div>
32
- <div>
33
- <UnderlineLink href="/legal/contact">
34
- <T k="account.contactUs" />
35
- </UnderlineLink>
36
- </div>
17
+ <div className="ub-wrap ub-account" data-testid="account-page">
18
+ <div className={customer ? "ub-account__grid" : "ub-account__solo"}>
19
+ {customer && <AccountNav customer={customer} />}
20
+ <div className="ub-account__main">{children}</div>
21
+ </div>
22
+ <div className="ub-account__help">
23
+ <div>
24
+ <h3 className="ub-head">
25
+ <T k="account.questions" />
26
+ </h3>
27
+ <p>
28
+ <T k="account.questionsText" />
29
+ </p>
37
30
  </div>
31
+ <LocalizedClientLink href="/legal/contact" className="ub-btn ub-btn--primary">
32
+ <T k="account.contactUs" />
33
+ </LocalizedClientLink>
38
34
  </div>
39
35
  </div>
40
36
  )
@@ -1,5 +1,6 @@
1
1
  import { HttpTypes } from "@medusajs/types"
2
2
  import Input from "@modules/common/components/input"
3
+ import PhoneInput from "@modules/common/components/phone-input"
3
4
  import React, { useState } from "react"
4
5
  import CountrySelect from "../country-select"
5
6
 
@@ -98,10 +99,9 @@ const BillingAddress = ({ cart }: { cart: HttpTypes.StoreCart | null }) => {
98
99
  onChange={handleChange}
99
100
  data-testid="billing-province-input"
100
101
  />
101
- <Input
102
+ <PhoneInput
102
103
  label="form.phone"
103
104
  name="billing_address.phone"
104
- autoComplete="tel"
105
105
  value={formData["billing_address.phone"]}
106
106
  onChange={handleChange}
107
107
  data-testid="billing-phone-input"
@@ -2,6 +2,7 @@ import { HttpTypes } from "@medusajs/types"
2
2
  import { Container } from "@medusajs/ui"
3
3
  import Checkbox from "@modules/common/components/checkbox"
4
4
  import Input from "@modules/common/components/input"
5
+ import PhoneInput from "@modules/common/components/phone-input"
5
6
  import { mapKeys } from "lodash"
6
7
  import React, { useEffect, useMemo, useState } from "react"
7
8
  import AddressSelect from "../address-select"
@@ -203,10 +204,9 @@ const ShippingAddress = ({
203
204
  required
204
205
  data-testid="shipping-email-input"
205
206
  />
206
- <Input
207
+ <PhoneInput
207
208
  label="form.phone"
208
209
  name="shipping_address.phone"
209
- autoComplete="tel"
210
210
  value={formData["shipping_address.phone"]}
211
211
  onChange={handleChange}
212
212
  data-testid="shipping-phone-input"
@@ -0,0 +1,110 @@
1
+ "use client"
2
+
3
+ import { useMemo, useState } from "react"
4
+ import {
5
+ AsYouType,
6
+ getCountries,
7
+ getCountryCallingCode,
8
+ parsePhoneNumberFromString,
9
+ type CountryCode,
10
+ } from "libphonenumber-js/min"
11
+ import { useLocale, useT } from "@lib/boutique/i18n-client"
12
+
13
+ type Props = {
14
+ name: string
15
+ label: string
16
+ /** Valeur contrôlée (formulaires du checkout) ; sinon defaultValue (formulaires simples) */
17
+ value?: string
18
+ defaultValue?: string
19
+ /** Reçoit un pseudo-événement (seuls target.name et target.value sont remplis), comme les autres champs */
20
+ onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
21
+ required?: boolean
22
+ "data-testid"?: string
23
+ }
24
+
25
+ const flag = (cc: string) => String.fromCodePoint(...[...cc.toUpperCase()].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65))
26
+
27
+ /**
28
+ * Numéro de téléphone avec indicatif : pays (drapeau + indicatif) + numéro national formaté à la saisie.
29
+ * Envoie un seul champ `name` au format international (+221 76 727 04 89). Pays par défaut = pays de la boutique.
30
+ */
31
+ export default function PhoneInput({ name, label, value, defaultValue, onChange, required, "data-testid": testId }: Props) {
32
+ const t = useT()
33
+ const locale = useLocale()
34
+ const boutiqueCountry = (locale.split("-")[1] ?? "US").toUpperCase() as CountryCode
35
+
36
+ const initial = useMemo(() => {
37
+ const parsed = parsePhoneNumberFromString(value ?? defaultValue ?? "")
38
+ return parsed?.country
39
+ ? { country: parsed.country, national: parsed.formatNational() }
40
+ : { country: getCountries().includes(boutiqueCountry) ? boutiqueCountry : ("US" as CountryCode), national: value ?? defaultValue ?? "" }
41
+ // Valeur initiale seulement : ensuite le champ est piloté par la saisie.
42
+ // eslint-disable-next-line react-hooks/exhaustive-deps
43
+ }, [])
44
+ const [country, setCountry] = useState<CountryCode>(initial.country)
45
+ const [national, setNational] = useState(initial.national)
46
+
47
+ const countries = useMemo(() => {
48
+ const names = new Intl.DisplayNames([locale], { type: "region" })
49
+ return getCountries()
50
+ .map((cc) => ({ cc, name: names.of(cc) ?? cc, code: getCountryCallingCode(cc) }))
51
+ .sort((a, b) => a.name.localeCompare(b.name, locale))
52
+ }, [locale])
53
+
54
+ const full = (cc: CountryCode, nat: string) => {
55
+ const digits = nat.replace(/[^\d]/g, "")
56
+ if (!digits) return ""
57
+ const parsed = parsePhoneNumberFromString(digits, cc)
58
+ return parsed ? parsed.formatInternational() : `+${getCountryCallingCode(cc)} ${digits}`
59
+ }
60
+ const emit = (cc: CountryCode, nat: string) =>
61
+ onChange?.({ target: { name, value: full(cc, nat) } } as unknown as React.ChangeEvent<HTMLInputElement>)
62
+ const invalid = national.replace(/\D/g, "").length > 3 && !parsePhoneNumberFromString(national.replace(/[^\d]/g, ""), country)?.isValid()
63
+
64
+ return (
65
+ <div className="ub-phone" data-invalid={invalid || undefined}>
66
+ <label className="ub-phone__country">
67
+ <span className="ub-phone__flag" aria-hidden>
68
+ {flag(country)} +{getCountryCallingCode(country)}
69
+ </span>
70
+ <select
71
+ aria-label={t("form.country")}
72
+ value={country}
73
+ onChange={(e) => {
74
+ const cc = e.target.value as CountryCode
75
+ setCountry(cc)
76
+ emit(cc, national)
77
+ }}
78
+ >
79
+ {countries.map((c) => (
80
+ <option key={c.cc} value={c.cc}>
81
+ {flag(c.cc)} {c.name} (+{c.code})
82
+ </option>
83
+ ))}
84
+ </select>
85
+ </label>
86
+ <div className="ub-phone__field">
87
+ <input
88
+ id={`${name}-national`}
89
+ type="tel"
90
+ inputMode="tel"
91
+ autoComplete="tel-national"
92
+ placeholder=" "
93
+ required={required}
94
+ value={national}
95
+ data-testid={testId}
96
+ onChange={(e) => {
97
+ const nat = new AsYouType(country).input(e.target.value)
98
+ setNational(nat)
99
+ emit(country, nat)
100
+ }}
101
+ />
102
+ <label htmlFor={`${name}-national`}>
103
+ {t(label)}
104
+ {required && <span className="text-rose-500">*</span>}
105
+ </label>
106
+ </div>
107
+ <input type="hidden" name={name} value={full(country, national)} />
108
+ </div>
109
+ )
110
+ }
@@ -1,41 +1,106 @@
1
1
  import { Suspense } from "react"
2
+ import Link from "next/link"
2
3
 
4
+ import { campaignStatus, getCampaign } from "@lib/boutique/campaign"
3
5
  import { t } from "@lib/boutique/i18n"
4
-
6
+ import { listCategories } from "@lib/data/categories"
5
7
  import SkeletonProductGrid from "@modules/skeletons/templates/skeleton-product-grid"
6
- import RefinementList from "@modules/store/components/refinement-list"
7
8
  import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
9
+ import { CampaignCountdown } from "@modules/ub/campaign-blocks"
8
10
 
9
11
  import PaginatedProducts from "./paginated-products"
10
12
 
11
- const StoreTemplate = ({
13
+ const SORTS: [SortOptions, string][] = [
14
+ ["created_at", "store.latest"],
15
+ ["price_asc", "store.priceAsc"],
16
+ ["price_desc", "store.priceDesc"],
17
+ ]
18
+
19
+ /** Lien de la page boutique en gardant les autres filtres (serveur, sans JavaScript). */
20
+ const shopHref = (q: { sortBy?: string; category?: string }) => {
21
+ const params = new URLSearchParams(Object.entries(q).filter(([, v]) => v) as [string, string][])
22
+ const s = params.toString()
23
+ return s ? `/store?${s}` : "/store"
24
+ }
25
+
26
+ /** Page « Boutique » : bandeau de campagne, catégories en pastilles, tri en boutons, grille. */
27
+ const StoreTemplate = async ({
12
28
  sortBy,
13
29
  page,
30
+ category,
14
31
  }: {
15
32
  sortBy?: SortOptions
16
33
  page?: string
34
+ category?: string
17
35
  }) => {
18
36
  const pageNumber = page ? parseInt(page) : 1
19
37
  const sort = sortBy || "created_at"
38
+ const [campaign, categories] = await Promise.all([getCampaign(), listCategories().catch(() => [])])
39
+ const topLevel = categories.filter((c) => !c.parent_category_id)
40
+ const active = topLevel.find((c) => c.handle === category)
41
+ const live = campaignStatus(campaign) === "live"
20
42
 
21
43
  return (
22
- <div
23
- className="flex flex-col small:flex-row small:items-start py-6 content-container"
24
- data-testid="category-container"
25
- >
26
- <RefinementList sortBy={sort} />
27
- <div className="w-full">
28
- <div className="mb-8 text-2xl-semi">
29
- <h1 className="ub-head" data-testid="store-page-title">{t("footer.all")}</h1>
44
+ <>
45
+ <section className="ub-shophero">
46
+ <div className="ub-wrap ub-shophero__in">
47
+ <div>
48
+ {campaign.hero.eyebrow && (
49
+ <span className="ub-eyebrow">
50
+ <span className="ub-eyebrow__dot" />
51
+ {campaign.hero.eyebrow}
52
+ </span>
53
+ )}
54
+ <h1 className="ub-head ub-shophero__title" data-testid="store-page-title">
55
+ {active?.name ?? campaign.sections?.products ?? t("footer.all")}
56
+ </h1>
57
+ {campaign.promo?.text && <p className="ub-shophero__sub">{campaign.promo.text}</p>}
58
+ </div>
59
+ {live && campaign.showCountdown !== false && (
60
+ <div className="ub-shophero__cd">
61
+ <small>{campaign.promo?.seal ?? t("countdown.label")}</small>
62
+ <CampaignCountdown endsAt={campaign.endsAt} />
63
+ </div>
64
+ )}
65
+ </div>
66
+ </section>
67
+
68
+ <div className="ub-wrap ub-shop" data-testid="category-container">
69
+ <div className="ub-shop__bar">
70
+ <nav className="ub-chips" aria-label={t("section.categories")}>
71
+ <Link href={shopHref({ sortBy })} className="ub-chip" aria-current={!active ? "page" : undefined}>
72
+ {t("shop.all")}
73
+ </Link>
74
+ {topLevel.map((c) => (
75
+ <Link
76
+ key={c.id}
77
+ href={shopHref({ sortBy, category: c.handle })}
78
+ className="ub-chip"
79
+ aria-current={active?.id === c.id ? "page" : undefined}
80
+ >
81
+ {c.name}
82
+ </Link>
83
+ ))}
84
+ </nav>
85
+ <nav className="ub-sort" aria-label={t("store.sortBy")}>
86
+ {SORTS.map(([value, key]) => (
87
+ <Link
88
+ key={value}
89
+ href={shopHref({ sortBy: value === "created_at" ? undefined : value, category })}
90
+ className="ub-sort__opt"
91
+ aria-current={sort === value ? "true" : undefined}
92
+ scroll={false}
93
+ >
94
+ {t(key)}
95
+ </Link>
96
+ ))}
97
+ </nav>
30
98
  </div>
31
- <Suspense fallback={<SkeletonProductGrid />}>
32
- <PaginatedProducts
33
- sortBy={sort}
34
- page={pageNumber}
35
- />
99
+ <Suspense key={`${sort}-${category}-${pageNumber}`} fallback={<SkeletonProductGrid />}>
100
+ <PaginatedProducts sortBy={sort} page={pageNumber} categoryId={active?.id} showCount />
36
101
  </Suspense>
37
102
  </div>
38
- </div>
103
+ </>
39
104
  )
40
105
  }
41
106
 
@@ -1,3 +1,4 @@
1
+ import { t } from "@lib/boutique/i18n"
1
2
  import { listProductsWithSort } from "@lib/data/products"
2
3
  import { getRegion } from "@lib/data/regions"
3
4
  import { ProductGrid } from "@modules/ub/product-card"
@@ -20,12 +21,15 @@ export default async function PaginatedProducts({
20
21
  collectionId,
21
22
  categoryId,
22
23
  productsIds,
24
+ showCount,
23
25
  }: {
24
26
  sortBy?: SortOptions
25
27
  page: number
26
28
  collectionId?: string
27
29
  categoryId?: string
28
30
  productsIds?: string[]
31
+ /** Affiche « N produits » au-dessus de la grille */
32
+ showCount?: boolean
29
33
  }) {
30
34
  const queryParams: PaginatedProductsParams = {
31
35
  limit: 12,
@@ -65,6 +69,7 @@ export default async function PaginatedProducts({
65
69
 
66
70
  return (
67
71
  <>
72
+ {showCount && <p className="ub-shop__count">{t("shop.count", { count: String(count) })}</p>}
68
73
  <div data-testid="products-list">
69
74
  <ProductGrid products={products} />
70
75
  </div>
@@ -27,7 +27,7 @@ export function HighlightedTitle({ title, highlight }: { title: string; highligh
27
27
  )
28
28
  }
29
29
 
30
- async function CampaignCountdown({ endsAt }: { endsAt: string }) {
30
+ export async function CampaignCountdown({ endsAt }: { endsAt: string }) {
31
31
  await connection()
32
32
  return (
33
33
  <Countdown
@@ -0,0 +1,78 @@
1
+ import Link from "next/link"
2
+
3
+ import { getBoutique } from "@lib/boutique/config"
4
+ import { t } from "@lib/boutique/i18n"
5
+ import { Icon, type IconName } from "./icons"
6
+
7
+ /** Page Contact : bandeau, cartes d'action (e-mail, suivi, livraison, retours) et FAQ (details natifs, sans JS). */
8
+ export default function ContactPage() {
9
+ const { storeName, merchantEmail } = getBoutique()
10
+
11
+ const cards: { icon: IconName; title: string; text: string; href: string; cta: string; external?: boolean }[] = [
12
+ ...(merchantEmail
13
+ ? [{ icon: "headset" as IconName, title: t("contact.email.title"), text: t("contact.email.text"), href: `mailto:${merchantEmail}`, cta: merchantEmail, external: true }]
14
+ : []),
15
+ { icon: "truck", title: t("contact.track.title"), text: t("contact.track.text"), href: "/order/track", cta: t("contact.track.cta") },
16
+ { icon: "shield", title: t("contact.shipping.title"), text: t("contact.shipping.text"), href: "/legal/shipping", cta: t("contact.more") },
17
+ { icon: "returns", title: t("contact.returns.title"), text: t("contact.returns.text"), href: "/legal/returns", cta: t("contact.more") },
18
+ ]
19
+ const faq = [1, 2, 3, 4].map((i) => [t(`contact.faq.q${i}`), t(`contact.faq.a${i}`, { storeName })])
20
+
21
+ return (
22
+ <>
23
+ <section className="ub-shophero ub-contacthero">
24
+ <div className="ub-wrap ub-shophero__in">
25
+ <div>
26
+ <span className="ub-eyebrow">
27
+ <span className="ub-eyebrow__dot" />
28
+ {t("contact.eyebrow")}
29
+ </span>
30
+ <h1 className="ub-head ub-shophero__title">{t("contact.title")}</h1>
31
+ <p className="ub-shophero__sub">{t("legal.contact.body", { storeName })}</p>
32
+ </div>
33
+ <div className="ub-contacthero__badge">
34
+ <b>{t("contact.badge.value")}</b>
35
+ <small>{t("contact.badge.label")}</small>
36
+ </div>
37
+ </div>
38
+ </section>
39
+
40
+ <section className="ub-section">
41
+ <div className="ub-wrap">
42
+ <ul className="ub-contact__cards">
43
+ {cards.map((c) => (
44
+ <li key={c.href} className="ub-contact__card">
45
+ <span className="ub-contact__ic">
46
+ <Icon name={c.icon} />
47
+ </span>
48
+ <h2 className="ub-head">{c.title}</h2>
49
+ <p>{c.text}</p>
50
+ {c.external ? (
51
+ <a href={c.href} className="ub-contact__cta">
52
+ {c.cta} <Icon name="arrowRight" />
53
+ </a>
54
+ ) : (
55
+ <Link href={c.href} className="ub-contact__cta">
56
+ {c.cta} <Icon name="arrowRight" />
57
+ </Link>
58
+ )}
59
+ </li>
60
+ ))}
61
+ </ul>
62
+
63
+ <div className="ub-contact__faq">
64
+ <h2 className="ub-head">{t("pdp.faq")}</h2>
65
+ <div>
66
+ {faq.map(([q, a], i) => (
67
+ <details key={i} className="ub-faq" open={i === 0}>
68
+ <summary>{q}</summary>
69
+ <p>{a}</p>
70
+ </details>
71
+ ))}
72
+ </div>
73
+ </div>
74
+ </div>
75
+ </section>
76
+ </>
77
+ )
78
+ }
@@ -1,17 +1,38 @@
1
1
  import Link from "next/link"
2
+ import { campaignStatus, getCampaign } from "@lib/boutique/campaign"
2
3
  import { getBoutique } from "@lib/boutique/config"
3
4
  import { t } from "@lib/boutique/i18n"
5
+ import Button from "./button"
6
+ import { HighlightedTitle } from "./campaign-blocks"
4
7
  import { Logo } from "./nav"
5
8
 
6
9
  // Simples libellés : en production, remplacer par les marques officielles fournies par Stripe.
7
10
  const PAYMENTS = ["VISA", "Mastercard", "Amex", "Apple Pay", "Google Pay"]
8
11
 
9
- export default function Footer() {
12
+ /** Pied de page : bandeau d'appel à l'action (textes de la campagne) puis bloc sombre arrondi. */
13
+ export default async function Footer() {
10
14
  const { storeName } = getBoutique()
15
+ const campaign = await getCampaign()
16
+ const live = campaignStatus(campaign) !== "ended"
11
17
 
12
18
  return (
13
- <footer className="ub-footer">
14
- <div className="ub-wrap">
19
+ <div className="ub-footwrap">
20
+ {live && (
21
+ <section className="ub-footcta" aria-label={campaign.hero.eyebrow ?? storeName}>
22
+ <span className="ub-footcta__shape ub-footcta__shape--a" aria-hidden />
23
+ <span className="ub-footcta__shape ub-footcta__shape--b" aria-hidden />
24
+ {campaign.hero.eyebrow && <span className="ub-footcta__eyebrow">{campaign.hero.eyebrow}</span>}
25
+ <h2 className="ub-head ub-footcta__title">
26
+ <HighlightedTitle title={campaign.hero.title} highlight={campaign.hero.highlight} />
27
+ </h2>
28
+ {campaign.promo?.text && <p className="ub-footcta__sub">{campaign.promo.text}</p>}
29
+ <Button href="/store" className="ub-footcta__btn">
30
+ {campaign.hero.cta ?? t("nav.cta")}
31
+ </Button>
32
+ </section>
33
+ )}
34
+
35
+ <footer className="ub-footer">
15
36
  <div className="ub-footer__top">
16
37
  <div>
17
38
  <Logo />
@@ -50,7 +71,7 @@ export default function Footer() {
50
71
  ))}
51
72
  </div>
52
73
  </div>
53
- </div>
54
- </footer>
74
+ </footer>
75
+ </div>
55
76
  )
56
77
  }
@@ -0,0 +1,49 @@
1
+ "use client"
2
+
3
+ import Link from "next/link"
4
+ import { usePathname } from "next/navigation"
5
+ import { useEffect, useRef } from "react"
6
+ import { useT } from "@lib/boutique/i18n-client"
7
+
8
+ /**
9
+ * Menu mobile (écrans < 900 px, où les liens du bandeau sont masqués) : <details> natif pour ouvrir/fermer,
10
+ * refermé automatiquement à chaque changement de page.
11
+ */
12
+ export default function MobileMenu() {
13
+ const t = useT()
14
+ const pathname = usePathname()
15
+ const ref = useRef<HTMLDetailsElement>(null)
16
+ useEffect(() => {
17
+ if (ref.current) ref.current.open = false
18
+ }, [pathname])
19
+
20
+ const links: [string, string][] = [
21
+ ["/", "nav.home"],
22
+ ["/store", "nav.shop"],
23
+ ["/order/track", "nav.track"],
24
+ ["/legal/contact", "nav.contact"],
25
+ ["/account", "nav.account"],
26
+ ]
27
+
28
+ return (
29
+ <details className="ub-burger" ref={ref}>
30
+ <summary className="ub-icon" aria-label={t("nav.menu")}>
31
+ <span className="ub-burger__bars" aria-hidden>
32
+ <i />
33
+ <i />
34
+ <i />
35
+ </span>
36
+ </summary>
37
+ <nav className="ub-burger__panel" aria-label={t("nav.main")}>
38
+ {links.map(([href, key]) => (
39
+ <Link key={href} href={href} aria-current={pathname === href ? "page" : undefined}>
40
+ {t(key)}
41
+ </Link>
42
+ ))}
43
+ <Link href="/store" className="ub-btn ub-btn--primary ub-burger__cta">
44
+ {t("nav.cta")}
45
+ </Link>
46
+ </nav>
47
+ </details>
48
+ )
49
+ }
@@ -6,6 +6,7 @@ import { t } from "@lib/boutique/i18n"
6
6
  import { retrieveCart } from "@lib/data/cart"
7
7
  import Button from "./button"
8
8
  import { Icon } from "./icons"
9
+ import MobileMenu from "./mobile-menu"
9
10
 
10
11
  async function CartCount() {
11
12
  const cart = await retrieveCart().catch(() => null)
@@ -80,6 +81,7 @@ export default function Nav({ current }: { current?: "home" | "shop" }) {
80
81
  <Suspense fallback={<CartFallback />}>
81
82
  <CartCount />
82
83
  </Suspense>
84
+ <MobileMenu />
83
85
  </div>
84
86
  <Button href="/store" small className="ub-nav__cta">
85
87
  {t("nav.cta")}