@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.
- package/cli/dist/commands.js +3 -3
- package/cli/dist/create.js +5 -5
- package/cli/dist/generate.js +68 -10
- package/cli/dist/project.js +2 -0
- package/cli/dist/themes.js +5 -1
- package/docs/guide-complet.en.html +11 -0
- package/docs/guide-complet.fr.html +11 -0
- package/package.json +1 -1
- package/templates/backend/src/lib/emails.ts +8 -1
- package/templates/backend/src/scripts/campaign-import.ts +6 -4
- package/templates/backend/src/scripts/seed.ts +15 -3
- package/templates/storefront/locales/en.json +40 -3
- package/templates/storefront/locales/fr.json +40 -3
- package/templates/storefront/package-lock.json +7 -0
- package/templates/storefront/package.json +1 -0
- package/templates/storefront/src/app/(main)/legal/[page]/page.tsx +3 -9
- package/templates/storefront/src/app/(main)/page.tsx +25 -0
- package/templates/storefront/src/app/(main)/store/page.tsx +3 -1
- package/templates/storefront/src/app/layout.tsx +9 -2
- package/templates/storefront/src/lib/boutique/config.ts +1 -0
- package/templates/storefront/src/lib/boutique/i18n-client.tsx +10 -2
- package/templates/storefront/src/lib/boutique/schema.ts +2 -0
- package/templates/storefront/src/lib/constants.tsx +5 -4
- package/templates/storefront/src/modules/account/components/account-nav/index.tsx +50 -177
- package/templates/storefront/src/modules/account/components/address-card/add-address.tsx +2 -2
- package/templates/storefront/src/modules/account/components/address-card/edit-address-modal.tsx +2 -2
- package/templates/storefront/src/modules/account/components/overview/index.tsx +98 -143
- package/templates/storefront/src/modules/account/components/profile-billing-address/index.tsx +2 -3
- package/templates/storefront/src/modules/account/components/profile-phone/index.tsx +2 -3
- package/templates/storefront/src/modules/account/components/register/index.tsx +2 -3
- package/templates/storefront/src/modules/account/templates/account-layout.tsx +20 -24
- package/templates/storefront/src/modules/checkout/components/billing_address/index.tsx +2 -2
- package/templates/storefront/src/modules/checkout/components/payment/index.tsx +2 -1
- package/templates/storefront/src/modules/checkout/components/payment-button/index.tsx +65 -48
- package/templates/storefront/src/modules/checkout/components/payment-container/index.tsx +23 -29
- package/templates/storefront/src/modules/checkout/components/payment-wrapper/stripe-wrapper.tsx +51 -6
- package/templates/storefront/src/modules/checkout/components/shipping-address/index.tsx +2 -2
- package/templates/storefront/src/modules/common/components/phone-input/index.tsx +110 -0
- package/templates/storefront/src/modules/store/templates/index.tsx +82 -17
- package/templates/storefront/src/modules/store/templates/paginated-products.tsx +5 -0
- package/templates/storefront/src/modules/ub/campaign-blocks.tsx +1 -1
- package/templates/storefront/src/modules/ub/contact-page.tsx +78 -0
- package/templates/storefront/src/modules/ub/footer.tsx +26 -5
- package/templates/storefront/src/modules/ub/mobile-menu.tsx +49 -0
- package/templates/storefront/src/modules/ub/nav.tsx +2 -0
- package/templates/storefront/src/modules/ub/showcase.tsx +137 -0
- package/templates/storefront/src/proxy.ts +3 -3
- package/templates/storefront/src/styles/ub/account.css +199 -0
- package/templates/storefront/src/styles/ub/black-friday.css +96 -0
- package/templates/storefront/src/styles/ub/forms.css +61 -0
- package/templates/storefront/src/styles/ub/pages.css +382 -0
- package/templates/storefront/src/styles/ub/responsive.css +57 -0
- package/templates/storefront/src/styles/ub/showcase.css +85 -0
- package/templates/storefront/src/styles/ub/variants.css +64 -0
- package/templates/storefront/themes/black-friday-2/campaign.en.json +31 -0
- package/templates/storefront/themes/black-friday-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/black-friday-2/theme.json +10 -0
- package/templates/storefront/themes/cyber-monday-2/campaign.en.json +31 -0
- package/templates/storefront/themes/cyber-monday-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/cyber-monday-2/theme.json +10 -0
- package/templates/storefront/themes/halloween-2/campaign.en.json +31 -0
- package/templates/storefront/themes/halloween-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/halloween-2/theme.json +10 -0
- package/templates/storefront/themes/noel-2/campaign.en.json +31 -0
- package/templates/storefront/themes/noel-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/noel-2/theme.json +10 -0
- package/templates/storefront/themes/nouvel-an-2/campaign.en.json +31 -0
- package/templates/storefront/themes/nouvel-an-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/nouvel-an-2/theme.json +10 -0
- package/templates/storefront/themes/thanksgiving-2/campaign.en.json +31 -0
- package/templates/storefront/themes/thanksgiving-2/campaign.fr.json +31 -0
- 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,
|
|
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 {
|
|
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:
|
|
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: (
|
|
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-
|
|
106
|
+
<Text className="txt-medium-plus text-ui-fg-base mb-3">
|
|
112
107
|
<T k="checkout.cardDetails" />
|
|
113
108
|
</Text>
|
|
114
|
-
<
|
|
115
|
-
options={
|
|
109
|
+
<PaymentElement
|
|
110
|
+
options={{ layout: { type: "tabs", defaultCollapsed: false }, wallets: { applePay: "auto", googlePay: "auto" } }}
|
|
116
111
|
onChange={(e) => {
|
|
117
|
-
setCardBrand(
|
|
118
|
-
|
|
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
|
) : (
|
package/templates/storefront/src/modules/checkout/components/payment-wrapper/stripe-wrapper.tsx
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
|
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
|
-
|
|
23
|
-
className="
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
14
|
-
|
|
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
|
-
</
|
|
54
|
-
</
|
|
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
|
+
}
|