@salifbiaye/create-boutique 0.1.4 → 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 (72) 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/docs/guide-complet.en.html +11 -0
  7. package/docs/guide-complet.fr.html +11 -0
  8. package/package.json +1 -1
  9. package/templates/backend/src/lib/emails.ts +8 -1
  10. package/templates/backend/src/scripts/campaign-import.ts +6 -4
  11. package/templates/backend/src/scripts/seed.ts +15 -3
  12. package/templates/storefront/locales/en.json +40 -3
  13. package/templates/storefront/locales/fr.json +40 -3
  14. package/templates/storefront/package-lock.json +7 -0
  15. package/templates/storefront/package.json +1 -0
  16. package/templates/storefront/src/app/(main)/legal/[page]/page.tsx +3 -9
  17. package/templates/storefront/src/app/(main)/page.tsx +25 -0
  18. package/templates/storefront/src/app/(main)/store/page.tsx +3 -1
  19. package/templates/storefront/src/app/layout.tsx +9 -2
  20. package/templates/storefront/src/lib/boutique/config.ts +1 -0
  21. package/templates/storefront/src/lib/boutique/i18n-client.tsx +10 -2
  22. package/templates/storefront/src/lib/boutique/schema.ts +2 -0
  23. package/templates/storefront/src/lib/constants.tsx +5 -4
  24. package/templates/storefront/src/modules/account/components/account-nav/index.tsx +50 -177
  25. package/templates/storefront/src/modules/account/components/address-card/add-address.tsx +2 -2
  26. package/templates/storefront/src/modules/account/components/address-card/edit-address-modal.tsx +2 -2
  27. package/templates/storefront/src/modules/account/components/overview/index.tsx +98 -143
  28. package/templates/storefront/src/modules/account/components/profile-billing-address/index.tsx +2 -3
  29. package/templates/storefront/src/modules/account/components/profile-phone/index.tsx +2 -3
  30. package/templates/storefront/src/modules/account/components/register/index.tsx +2 -3
  31. package/templates/storefront/src/modules/account/templates/account-layout.tsx +20 -24
  32. package/templates/storefront/src/modules/checkout/components/billing_address/index.tsx +2 -2
  33. package/templates/storefront/src/modules/checkout/components/payment/index.tsx +2 -1
  34. package/templates/storefront/src/modules/checkout/components/payment-button/index.tsx +65 -48
  35. package/templates/storefront/src/modules/checkout/components/payment-container/index.tsx +23 -29
  36. package/templates/storefront/src/modules/checkout/components/payment-wrapper/stripe-wrapper.tsx +51 -6
  37. package/templates/storefront/src/modules/checkout/components/shipping-address/index.tsx +2 -2
  38. package/templates/storefront/src/modules/common/components/phone-input/index.tsx +110 -0
  39. package/templates/storefront/src/modules/store/templates/index.tsx +82 -17
  40. package/templates/storefront/src/modules/store/templates/paginated-products.tsx +5 -0
  41. package/templates/storefront/src/modules/ub/campaign-blocks.tsx +1 -1
  42. package/templates/storefront/src/modules/ub/contact-page.tsx +78 -0
  43. package/templates/storefront/src/modules/ub/footer.tsx +26 -5
  44. package/templates/storefront/src/modules/ub/mobile-menu.tsx +49 -0
  45. package/templates/storefront/src/modules/ub/nav.tsx +2 -0
  46. package/templates/storefront/src/modules/ub/showcase.tsx +137 -0
  47. package/templates/storefront/src/proxy.ts +3 -3
  48. package/templates/storefront/src/styles/ub/account.css +199 -0
  49. package/templates/storefront/src/styles/ub/black-friday.css +96 -0
  50. package/templates/storefront/src/styles/ub/forms.css +61 -0
  51. package/templates/storefront/src/styles/ub/pages.css +382 -0
  52. package/templates/storefront/src/styles/ub/responsive.css +57 -0
  53. package/templates/storefront/src/styles/ub/showcase.css +85 -0
  54. package/templates/storefront/src/styles/ub/variants.css +64 -0
  55. package/templates/storefront/themes/black-friday-2/campaign.en.json +31 -0
  56. package/templates/storefront/themes/black-friday-2/campaign.fr.json +31 -0
  57. package/templates/storefront/themes/black-friday-2/theme.json +10 -0
  58. package/templates/storefront/themes/cyber-monday-2/campaign.en.json +31 -0
  59. package/templates/storefront/themes/cyber-monday-2/campaign.fr.json +31 -0
  60. package/templates/storefront/themes/cyber-monday-2/theme.json +10 -0
  61. package/templates/storefront/themes/halloween-2/campaign.en.json +31 -0
  62. package/templates/storefront/themes/halloween-2/campaign.fr.json +31 -0
  63. package/templates/storefront/themes/halloween-2/theme.json +10 -0
  64. package/templates/storefront/themes/noel-2/campaign.en.json +31 -0
  65. package/templates/storefront/themes/noel-2/campaign.fr.json +31 -0
  66. package/templates/storefront/themes/noel-2/theme.json +10 -0
  67. package/templates/storefront/themes/nouvel-an-2/campaign.en.json +31 -0
  68. package/templates/storefront/themes/nouvel-an-2/campaign.fr.json +31 -0
  69. package/templates/storefront/themes/nouvel-an-2/theme.json +10 -0
  70. package/templates/storefront/themes/thanksgiving-2/campaign.en.json +31 -0
  71. package/templates/storefront/themes/thanksgiving-2/campaign.fr.json +31 -0
  72. package/templates/storefront/themes/thanksgiving-2/theme.json +10 -0
@@ -1,14 +1,13 @@
1
1
  import { T } from "@lib/boutique/i18n-client"
2
2
  import { Radio as RadioGroupOption } from "@headlessui/react"
3
3
  import { Text, clx } from "@medusajs/ui"
4
- import React, { useContext, useMemo, type JSX } from "react"
4
+ import React, { useContext, type JSX } from "react"
5
5
 
6
6
  import Radio from "@modules/common/components/radio"
7
7
 
8
8
  import { isManual } from "@lib/constants"
9
9
  import SkeletonCardDetails from "@modules/skeletons/components/skeleton-card-details"
10
- import { CardElement } from "@stripe/react-stripe-js"
11
- import { StripeCardElementOptions } from "@stripe/stripe-js"
10
+ import { PaymentElement } from "@stripe/react-stripe-js"
12
11
  import PaymentTest from "../payment-test"
13
12
  import { StripeContext } from "../payment-wrapper/stripe-wrapper"
14
13
 
@@ -16,7 +15,7 @@ type PaymentContainerProps = {
16
15
  paymentProviderId: string
17
16
  selectedPaymentOptionId: string | null
18
17
  disabled?: boolean
19
- paymentInfoMap: Record<string, { title: string; icon: JSX.Element }>
18
+ paymentInfoMap: Record<string, { title: React.ReactNode; icon: JSX.Element }>
20
19
  children?: React.ReactNode
21
20
  }
22
21
 
@@ -66,6 +65,19 @@ const PaymentContainer: React.FC<PaymentContainerProps> = ({
66
65
 
67
66
  export default PaymentContainer
68
67
 
68
+ /** Noms affichés des moyens de paiement Stripe (Payment Element) ; « card » est traduit. */
69
+ const WALLET_NAMES: Record<string, string> = {
70
+ apple_pay: "Apple Pay",
71
+ google_pay: "Google Pay",
72
+ link: "Link",
73
+ klarna: "Klarna",
74
+ paypal: "PayPal",
75
+ }
76
+
77
+ export const paymentTypeLabel = (type: string | null) =>
78
+ !type ? null : type === "card" ? <T k="checkout.card" /> : WALLET_NAMES[type] ?? type
79
+
80
+ /** Stripe Payment Element : carte, Apple Pay, Google Pay… selon l'appareil et les moyens activés dans Stripe. */
69
81
  export const StripeCardContainer = ({
70
82
  paymentProviderId,
71
83
  selectedPaymentOptionId,
@@ -75,29 +87,12 @@ export const StripeCardContainer = ({
75
87
  setError,
76
88
  setCardComplete,
77
89
  }: Omit<PaymentContainerProps, "children"> & {
78
- setCardBrand: (brand: string) => void
90
+ setCardBrand: (type: string | null) => void
79
91
  setError: (error: string | null) => void
80
92
  setCardComplete: (complete: boolean) => void
81
93
  }) => {
82
94
  const stripeReady = useContext(StripeContext)
83
95
 
84
- const useOptions: StripeCardElementOptions = useMemo(() => {
85
- return {
86
- style: {
87
- base: {
88
- fontFamily: "inherit",
89
- color: "#424270",
90
- "::placeholder": {
91
- color: "rgb(107 114 128)",
92
- },
93
- },
94
- },
95
- classes: {
96
- base: "pt-3 pb-1 block w-full h-11 px-4 mt-0 bg-ui-bg-field border rounded-md appearance-none focus:outline-none focus:ring-0 focus:shadow-borders-interactive-with-active border-ui-border-base hover:bg-ui-bg-field-hover transition-all duration-300 ease-in-out",
97
- },
98
- }
99
- }, [])
100
-
101
96
  return (
102
97
  <PaymentContainer
103
98
  paymentProviderId={paymentProviderId}
@@ -108,18 +103,17 @@ export const StripeCardContainer = ({
108
103
  {selectedPaymentOptionId === paymentProviderId &&
109
104
  (stripeReady ? (
110
105
  <div className="my-4 transition-all duration-150 ease-in-out">
111
- <Text className="txt-medium-plus text-ui-fg-base mb-1">
106
+ <Text className="txt-medium-plus text-ui-fg-base mb-3">
112
107
  <T k="checkout.cardDetails" />
113
108
  </Text>
114
- <CardElement
115
- options={useOptions as StripeCardElementOptions}
109
+ <PaymentElement
110
+ options={{ layout: { type: "tabs", defaultCollapsed: false }, wallets: { applePay: "auto", googlePay: "auto" } }}
116
111
  onChange={(e) => {
117
- setCardBrand(
118
- e.brand && e.brand.charAt(0).toUpperCase() + e.brand.slice(1)
119
- )
120
- setError(e.error?.message || null)
112
+ setCardBrand(e.value?.type ?? null)
113
+ setError(null)
121
114
  setCardComplete(e.complete)
122
115
  }}
116
+ onLoadError={(e) => setError(e.error?.message ?? null)}
123
117
  />
124
118
  </div>
125
119
  ) : (
@@ -1,9 +1,9 @@
1
1
  "use client"
2
2
 
3
- import { Stripe, StripeElementsOptions } from "@stripe/stripe-js"
3
+ import { Appearance, Stripe, StripeElementLocale, StripeElementsOptions } from "@stripe/stripe-js"
4
4
  import { Elements } from "@stripe/react-stripe-js"
5
5
  import { HttpTypes } from "@medusajs/types"
6
- import { createContext } from "react"
6
+ import { createContext, useEffect, useState } from "react"
7
7
 
8
8
  type StripeWrapperProps = {
9
9
  paymentSession: HttpTypes.StorePaymentSession
@@ -14,15 +14,53 @@ type StripeWrapperProps = {
14
14
 
15
15
  export const StripeContext = createContext(false)
16
16
 
17
+ /** Valeur calculée (rgb…) d'une variable CSS du thème : Stripe n'accepte pas var(--x) ni color-mix. */
18
+ function themeColor(name: string): string | undefined {
19
+ const probe = document.createElement("span")
20
+ probe.style.color = `var(${name})`
21
+ document.body.appendChild(probe)
22
+ const value = getComputedStyle(probe).color
23
+ probe.remove()
24
+ return value || undefined
25
+ }
26
+
27
+ function isDark(rgb?: string): boolean {
28
+ const [r, g, b] = (rgb?.match(/\d+(\.\d+)?/g) ?? []).map(Number)
29
+ if ([r, g, b].some((v) => v === undefined)) return false
30
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b < 128
31
+ }
32
+
33
+ /** Bloc de paiement Stripe aux couleurs du thème de la boutique (lu dans les variables CSS de data-theme). */
34
+ function themeAppearance(): Appearance {
35
+ const background = themeColor("--bg")
36
+ const radius = getComputedStyle(document.documentElement).getPropertyValue("--ub-r-card").trim()
37
+ return {
38
+ theme: isDark(background) ? "night" : "stripe",
39
+ variables: {
40
+ colorPrimary: themeColor("--primary"),
41
+ colorBackground: themeColor("--surface") ?? background,
42
+ colorText: themeColor("--ink"),
43
+ colorTextSecondary: themeColor("--ink-muted"),
44
+ colorDanger: "#df1b41",
45
+ fontFamily: getComputedStyle(document.body).fontFamily,
46
+ borderRadius: radius && radius.endsWith("px") ? radius : "8px",
47
+ },
48
+ }
49
+ }
50
+
17
51
  const StripeWrapper: React.FC<StripeWrapperProps> = ({
18
52
  paymentSession,
19
53
  stripeKey,
20
54
  stripePromise,
21
55
  children,
22
56
  }) => {
23
- const options: StripeElementsOptions = {
24
- clientSecret: paymentSession!.data?.client_secret as string | undefined,
25
- }
57
+ // Calculé après montage (document indisponible au rendu serveur) ; Elements applique la mise à jour.
58
+ const [appearance, setAppearance] = useState<Appearance | undefined>()
59
+ const [locale, setLocale] = useState<StripeElementLocale>("auto")
60
+ useEffect(() => {
61
+ setAppearance(themeAppearance())
62
+ setLocale((document.documentElement.lang || "auto") as StripeElementLocale)
63
+ }, [])
26
64
 
27
65
  if (!stripeKey) {
28
66
  throw new Error(
@@ -42,9 +80,16 @@ const StripeWrapper: React.FC<StripeWrapperProps> = ({
42
80
  )
43
81
  }
44
82
 
83
+ const options: StripeElementsOptions = {
84
+ clientSecret: paymentSession.data.client_secret as string,
85
+ appearance,
86
+ locale,
87
+ }
88
+
45
89
  return (
46
90
  <StripeContext.Provider value={true}>
47
- <Elements options={options} stripe={stripePromise}>
91
+ {/* key : un nouveau PaymentIntent (panier modifié) recrée les Elements */}
92
+ <Elements key={options.clientSecret} options={options} stripe={stripePromise}>
48
93
  {children}
49
94
  </Elements>
50
95
  </StripeContext.Provider>
@@ -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
+ }