@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.
- 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/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 +33 -1
- package/templates/storefront/locales/fr.json +33 -1
- 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/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/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/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,40 +1,36 @@
|
|
|
1
1
|
import { T } from "@lib/boutique/i18n-client"
|
|
2
2
|
import React from "react"
|
|
3
3
|
|
|
4
|
-
import
|
|
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
|
-
|
|
15
|
-
|
|
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="
|
|
20
|
-
<div className=
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
<div
|
|
26
|
-
<
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
</
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
+
}
|
|
@@ -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")}
|