@salifbiaye/create-boutique 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/dist/code.js +110 -0
- package/cli/dist/commands.js +143 -10
- package/cli/dist/create.js +54 -7
- package/cli/dist/generate.js +65 -4
- package/cli/dist/guide.js +10 -2
- package/cli/dist/index.js +20 -7
- package/cli/dist/payments.js +150 -0
- package/docs/fiche-client.en.html +2 -2
- package/docs/fiche-client.html +2 -2
- package/docs/guide-complet.fr.html +681 -256
- package/docs/guide-images/categories.jpg +0 -0
- package/docs/guide-images/clients.jpg +0 -0
- package/docs/guide-images/collections.jpg +0 -0
- package/docs/guide-images/commande-detail.jpg +0 -0
- package/docs/guide-images/commande-expedier.jpg +0 -0
- package/docs/guide-images/commande-menu.jpg +0 -0
- package/docs/guide-images/commande-paiement.jpg +0 -0
- package/docs/guide-images/commandes-liste.jpg +0 -0
- package/docs/guide-images/connexion.jpg +0 -0
- package/docs/guide-images/groupes-clients.jpg +0 -0
- package/docs/guide-images/inventaire.jpg +0 -0
- package/docs/guide-images/listes-prix.jpg +0 -0
- package/docs/guide-images/menu.jpg +0 -0
- package/docs/guide-images/mes-boutiques-fermer.jpg +0 -0
- package/docs/guide-images/mes-boutiques-panneau.jpg +0 -0
- package/docs/guide-images/mes-boutiques.jpg +0 -0
- package/docs/guide-images/parametres-boutique.jpg +0 -0
- package/docs/guide-images/parametres-canaux.jpg +0 -0
- package/docs/guide-images/parametres-emplacements.jpg +0 -0
- package/docs/guide-images/parametres-profil.jpg +0 -0
- package/docs/guide-images/parametres-regions.jpg +0 -0
- package/docs/guide-images/parametres-utilisateurs.jpg +0 -0
- package/docs/guide-images/produit-creer-details.jpg +0 -0
- package/docs/guide-images/produit-detail.jpg +0 -0
- package/docs/guide-images/produit-variantes-menu.jpg +0 -0
- package/docs/guide-images/produit-variantes.jpg +0 -0
- package/docs/guide-images/produits-liste.jpg +0 -0
- package/docs/guide-images/promotion-details.jpg +0 -0
- package/docs/guide-images/promotion-type.jpg +0 -0
- package/docs/guide-images/promotions-liste.jpg +0 -0
- package/docs/guide-images/site-boutique.jpg +0 -0
- package/docs/guide-images/site-fermee.png +0 -0
- package/docs/guide-images/vue-ensemble.jpg +0 -0
- package/package.json +3 -2
- package/templates/backend/medusa-config.ts +31 -25
- package/templates/backend/package-lock.json +21 -0
- package/templates/backend/package.json +2 -0
- package/templates/backend/src/admin/i18n/json/en.json +51 -3
- package/templates/backend/src/admin/i18n/json/fr.json +51 -3
- package/templates/backend/src/admin/routes/boutique-campaigns/campaign-drawer.tsx +2 -0
- package/templates/backend/src/admin/routes/boutique-campaigns/page.tsx +36 -1
- package/templates/backend/src/admin/routes/reviews/page.tsx +259 -0
- package/templates/backend/src/api/admin/boutique-campaigns/route.ts +3 -1
- package/templates/backend/src/api/admin/boutique-online/[salesChannelId]/route.ts +22 -0
- package/templates/backend/src/api/admin/reviews/[id]/route.ts +15 -0
- package/templates/backend/src/api/admin/reviews/route.ts +47 -0
- package/templates/backend/src/api/middlewares.ts +7 -0
- package/templates/backend/src/api/store/campaign/route.ts +5 -1
- package/templates/backend/src/api/store/reviews/latest/route.ts +39 -0
- package/templates/backend/src/api/store/reviews/order/route.ts +36 -0
- package/templates/backend/src/api/store/reviews/route.ts +43 -0
- package/templates/backend/src/api/store/reviews/summary/route.ts +28 -0
- package/templates/backend/src/emails/en.json +3 -1
- package/templates/backend/src/emails/fr.json +3 -1
- package/templates/backend/src/lib/email-config.ts +14 -0
- package/templates/backend/src/lib/emails.ts +9 -0
- package/templates/backend/src/lib/notify.ts +14 -8
- package/templates/backend/src/lib/payment-config.ts +66 -0
- package/templates/backend/src/lib/project.ts +12 -0
- package/templates/backend/src/lib/redirect-payment.ts +264 -0
- package/templates/backend/src/lib/review-token.ts +40 -0
- package/templates/backend/src/lib/reviews.ts +25 -0
- package/templates/backend/src/modules/naboopay/index.ts +6 -0
- package/templates/backend/src/modules/naboopay/service.ts +92 -0
- package/templates/backend/src/modules/paydunya/index.ts +6 -0
- package/templates/backend/src/modules/paydunya/service.ts +89 -0
- package/templates/backend/src/modules/paytech/index.ts +6 -0
- package/templates/backend/src/modules/paytech/service.ts +114 -0
- package/templates/backend/src/modules/product-review/index.ts +8 -0
- package/templates/backend/src/modules/product-review/migrations/Migration20261001090000.ts +18 -0
- package/templates/backend/src/modules/product-review/models/review.ts +25 -0
- package/templates/backend/src/modules/product-review/service.ts +6 -0
- package/templates/backend/src/modules/product-review/validators.ts +27 -0
- package/templates/backend/src/modules/smtp/index.ts +6 -0
- package/templates/backend/src/modules/smtp/service.ts +73 -0
- package/templates/backend/src/scripts/seed.ts +52 -36
- package/templates/backend/src/subscribers/fulfillment-emails.ts +1 -1
- package/templates/backend/src/workflows/reviews.ts +21 -0
- package/templates/backend/src/workflows/steps/create-review.ts +52 -0
- package/templates/backend/src/workflows/steps/update-review.ts +36 -0
- package/templates/storefront/PERSONNALISER.md +134 -0
- package/templates/storefront/locales/en.json +198 -11
- package/templates/storefront/locales/fr.json +198 -11
- package/templates/storefront/public/custom/LISEZMOI.txt +2 -0
- package/templates/storefront/src/app/(checkout)/layout.tsx +1 -1
- package/templates/storefront/src/app/(main)/layout.tsx +8 -1
- package/templates/storefront/src/app/(main)/legal/[page]/page.tsx +42 -7
- package/templates/storefront/src/app/(main)/page.tsx +149 -4
- package/templates/storefront/src/app/(main)/review/page.tsx +60 -0
- package/templates/storefront/src/app/(main)/store/page.tsx +3 -1
- package/templates/storefront/src/app/(main)/template.tsx +17 -2
- package/templates/storefront/src/app/layout.tsx +13 -1
- package/templates/storefront/src/lib/boutique/campaign.ts +5 -1
- package/templates/storefront/src/lib/boutique/page-name.ts +25 -0
- package/templates/storefront/src/lib/boutique/schema.ts +3 -1
- package/templates/storefront/src/lib/constants.tsx +17 -0
- package/templates/storefront/src/lib/data/review-actions.ts +28 -0
- package/templates/storefront/src/lib/data/reviews.ts +64 -0
- package/templates/storefront/src/modules/checkout/components/payment/index.tsx +26 -4
- package/templates/storefront/src/modules/checkout/components/payment-button/index.tsx +85 -2
- package/templates/storefront/src/modules/products/templates/index.tsx +5 -0
- package/templates/storefront/src/modules/store/components/shop-search.tsx +85 -0
- package/templates/storefront/src/modules/store/templates/index.tsx +16 -8
- package/templates/storefront/src/modules/store/templates/paginated-products.tsx +15 -3
- package/templates/storefront/src/modules/ub/album.tsx +2 -0
- package/templates/storefront/src/modules/ub/arena.tsx +182 -0
- package/templates/storefront/src/modules/ub/campaign-blocks.tsx +3 -2
- package/templates/storefront/src/modules/ub/campaign-ended.tsx +27 -13
- package/templates/storefront/src/modules/ub/cinema.tsx +2 -0
- package/templates/storefront/src/modules/ub/cookie-banner.tsx +62 -0
- package/templates/storefront/src/modules/ub/gala.tsx +2 -0
- package/templates/storefront/src/modules/ub/grimoire.tsx +35 -8
- package/templates/storefront/src/modules/ub/harvest.tsx +2 -1
- package/templates/storefront/src/modules/ub/hero-image.tsx +14 -0
- package/templates/storefront/src/modules/ub/home-kit.ts +45 -0
- package/templates/storefront/src/modules/ub/lantern.tsx +244 -0
- package/templates/storefront/src/modules/ub/locker.tsx +180 -0
- package/templates/storefront/src/modules/ub/mosaic.tsx +189 -0
- package/templates/storefront/src/modules/ub/nav.tsx +8 -5
- package/templates/storefront/src/modules/ub/review-form.tsx +62 -0
- package/templates/storefront/src/modules/ub/reviews.tsx +131 -0
- package/templates/storefront/src/modules/ub/riviera.tsx +175 -0
- package/templates/storefront/src/modules/ub/souk.tsx +214 -0
- package/templates/storefront/src/modules/ub/sunset-checklist.tsx +95 -0
- package/templates/storefront/src/modules/ub/sunset.tsx +164 -0
- package/templates/storefront/src/modules/ub/tech.tsx +2 -2
- package/templates/storefront/src/modules/ub/training.tsx +196 -0
- package/templates/storefront/src/proxy.ts +2 -0
- package/templates/storefront/src/styles/custom.css +26 -0
- package/templates/storefront/src/styles/ub/album.css +1 -1
- package/templates/storefront/src/styles/ub/arena.css +120 -0
- package/templates/storefront/src/styles/ub/cinema.css +1 -1
- package/templates/storefront/src/styles/ub/gala.css +1 -1
- package/templates/storefront/src/styles/ub/grimoire.css +23 -7
- package/templates/storefront/src/styles/ub/harvest.css +7 -6
- package/templates/storefront/src/styles/ub/lantern.css +157 -0
- package/templates/storefront/src/styles/ub/locker.css +115 -0
- package/templates/storefront/src/styles/ub/media.css +10 -0
- package/templates/storefront/src/styles/ub/mosaic.css +118 -0
- package/templates/storefront/src/styles/ub/motion.css +0 -3
- package/templates/storefront/src/styles/ub/overrides.css +10 -0
- package/templates/storefront/src/styles/ub/pages.css +176 -0
- package/templates/storefront/src/styles/ub/reviews.css +85 -0
- package/templates/storefront/src/styles/ub/riviera.css +103 -0
- package/templates/storefront/src/styles/ub/souk.css +144 -0
- package/templates/storefront/src/styles/ub/sunset.css +123 -0
- package/templates/storefront/src/styles/ub/tech.css +2 -5
- package/templates/storefront/src/styles/ub/training.css +120 -0
- package/templates/storefront/themes/jeux/campaign.en.json +31 -0
- package/templates/storefront/themes/jeux/campaign.fr.json +31 -0
- package/templates/storefront/themes/jeux/theme.json +11 -0
- package/templates/storefront/themes/jeux-2/campaign.en.json +31 -0
- package/templates/storefront/themes/jeux-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/jeux-2/theme.json +11 -0
- package/templates/storefront/themes/mubarak/campaign.en.json +31 -0
- package/templates/storefront/themes/mubarak/campaign.fr.json +31 -0
- package/templates/storefront/themes/mubarak/theme.json +11 -0
- package/templates/storefront/themes/mubarak-2/campaign.en.json +31 -0
- package/templates/storefront/themes/mubarak-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/mubarak-2/theme.json +11 -0
- package/templates/storefront/themes/sport/campaign.en.json +31 -0
- package/templates/storefront/themes/sport/campaign.fr.json +31 -0
- package/templates/storefront/themes/sport/theme.json +11 -0
- package/templates/storefront/themes/sport-2/campaign.en.json +31 -0
- package/templates/storefront/themes/sport-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/sport-2/theme.json +11 -0
- package/templates/storefront/themes/summer/campaign.en.json +31 -0
- package/templates/storefront/themes/summer/campaign.fr.json +31 -0
- package/templates/storefront/themes/summer/theme.json +11 -0
- package/templates/storefront/themes/summer-2/campaign.en.json +31 -0
- package/templates/storefront/themes/summer-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/summer-2/theme.json +11 -0
|
@@ -1,4 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { usePathname, useSearchParams } from "next/navigation"
|
|
4
|
+
import { pageName } from "@lib/boutique/page-name"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Remonté à chaque navigation : point d'accroche de la transition entre pages (styles/ub/motion.css, desktop)
|
|
8
|
+
* et nom de la page (data-page) pour la cibler dans styles/custom.css. Calculé depuis l'adresse courante (côté client) :
|
|
9
|
+
* juste au premier affichage comme après chaque clic dans le site.
|
|
10
|
+
*/
|
|
2
11
|
export default function Template({ children }: { children: React.ReactNode }) {
|
|
3
|
-
|
|
12
|
+
const path = usePathname()
|
|
13
|
+
const search = useSearchParams().toString()
|
|
14
|
+
return (
|
|
15
|
+
<div className="ub-page" data-page={pageName(path, search ? `?${search}` : "")}>
|
|
16
|
+
{children}
|
|
17
|
+
</div>
|
|
18
|
+
)
|
|
4
19
|
}
|
|
@@ -4,6 +4,7 @@ import { themeFontClasses } from "@lib/boutique/fonts"
|
|
|
4
4
|
import { messages } from "@lib/boutique/i18n"
|
|
5
5
|
import { I18nProvider } from "@lib/boutique/i18n-client"
|
|
6
6
|
import Motion from "@modules/ub/motion"
|
|
7
|
+
import CookieBanner from "@modules/ub/cookie-banner"
|
|
7
8
|
import { Metadata } from "next"
|
|
8
9
|
import "styles/globals.css"
|
|
9
10
|
import "styles/ub/tokens.css"
|
|
@@ -20,12 +21,22 @@ import "styles/ub/cinema.css"
|
|
|
20
21
|
import "styles/ub/album.css"
|
|
21
22
|
import "styles/ub/tech.css"
|
|
22
23
|
import "styles/ub/grimoire.css"
|
|
24
|
+
import "styles/ub/riviera.css"
|
|
25
|
+
import "styles/ub/sunset.css"
|
|
26
|
+
import "styles/ub/locker.css"
|
|
27
|
+
import "styles/ub/training.css"
|
|
28
|
+
import "styles/ub/arena.css"
|
|
29
|
+
import "styles/ub/mosaic.css"
|
|
30
|
+
import "styles/ub/lantern.css"
|
|
31
|
+
import "styles/ub/souk.css"
|
|
32
|
+
import "styles/ub/reviews.css"
|
|
23
33
|
import "styles/ub/motion.css"
|
|
24
34
|
import "styles/ub/media.css"
|
|
25
35
|
import "styles/ub/responsive.css"
|
|
26
36
|
import "styles/ub/fonts.css"
|
|
27
37
|
import "styles/ub/medusa-ui.css"
|
|
28
38
|
import "styles/ub/overrides.css"
|
|
39
|
+
import "styles/custom.css" // retouches du projet, conservées par update (toujours en dernier)
|
|
29
40
|
|
|
30
41
|
// Tout dépend de la boutique de l'instance (lue à l'exécution) : rien n'est pré-rendu au build.
|
|
31
42
|
export const dynamic = "force-dynamic"
|
|
@@ -63,11 +74,12 @@ export default function RootLayout(props: { children: React.ReactNode }) {
|
|
|
63
74
|
const b = getBoutique()
|
|
64
75
|
|
|
65
76
|
return (
|
|
66
|
-
<html lang={b.locale} data-theme={b.theme.layout ?? b.theme.id} data-variant={b.theme.layout ? b.theme.id : undefined} data-event={b.theme.id.replace(/-2$/, "")} data-mode="light" className={themeFontClasses(b.theme)}>
|
|
77
|
+
<html lang={b.locale} data-theme={b.theme.layout ?? b.theme.id} data-variant={b.theme.layout ? b.theme.id : undefined} data-event={b.theme.id.replace(/-2$/, "")} data-boutique={b.slug} data-mode="light" className={themeFontClasses(b.theme)}>
|
|
67
78
|
<body className="ub">
|
|
68
79
|
<I18nProvider messages={messages("")} locale={b.locale}>
|
|
69
80
|
<main className="relative">{props.children}</main>
|
|
70
81
|
<Motion event={b.theme.id.replace(/-2$/, "")} />
|
|
82
|
+
<CookieBanner />
|
|
71
83
|
</I18nProvider>
|
|
72
84
|
</body>
|
|
73
85
|
</html>
|
|
@@ -20,6 +20,8 @@ export type Campaign = CampaignTexts & {
|
|
|
20
20
|
seo?: { title?: string; description?: string; image?: string }
|
|
21
21
|
/** Compte à rebours : undefined = selon le thème (CSS), true = toujours, false = jamais */
|
|
22
22
|
showCountdown?: boolean
|
|
23
|
+
/** Site ouvert (défaut) ou fermé dans l'admin. Les dates de campagne ne ferment jamais le site. */
|
|
24
|
+
online: boolean
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
function readJsonIfExists(file: string): unknown | undefined {
|
|
@@ -105,8 +107,10 @@ export const getCampaign = cache(async (): Promise<Campaign> => {
|
|
|
105
107
|
nextCampaignUrl: own.nextCampaign ? b.siblings[own.nextCampaign] : undefined,
|
|
106
108
|
freeShipping: own.vars?.freeShipping,
|
|
107
109
|
logo: own.logo,
|
|
108
|
-
|
|
110
|
+
// Campagne terminée : le site reste une boutique normale, seul le compte à rebours disparaît
|
|
111
|
+
showCountdown: campaignStatus(own) === "ended" ? false : own.showCountdown,
|
|
109
112
|
seo: own.seo,
|
|
113
|
+
online: own.online !== false,
|
|
110
114
|
}
|
|
111
115
|
})
|
|
112
116
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nom de la page courante, posé en data-page sur .ub-page (app/(main)/template.tsx) et sur le checkout :
|
|
3
|
+
* sert à cibler une page dans styles/custom.css, ex. [data-page="product"] .ub-… ou :root:has([data-page="home"]) .ub-nav
|
|
4
|
+
*/
|
|
5
|
+
export type PageName =
|
|
6
|
+
| "home" | "store" | "search" | "product" | "category" | "collection" | "cart" | "checkout"
|
|
7
|
+
| "account" | "order" | "track" | "contact" | "shipping" | "returns" | "privacy" | "terms" | "review" | "other"
|
|
8
|
+
|
|
9
|
+
export function pageName(path: string, search = ""): PageName {
|
|
10
|
+
const p = path.replace(/\/+$/, "") || "/"
|
|
11
|
+
if (p === "/") return "home"
|
|
12
|
+
if (p === "/store") return /(^|[?&])q=/.test(search) ? "search" : "store"
|
|
13
|
+
if (p.startsWith("/products/")) return "product"
|
|
14
|
+
if (p.startsWith("/categories/")) return "category"
|
|
15
|
+
if (p.startsWith("/collections/")) return "collection"
|
|
16
|
+
if (p === "/cart") return "cart"
|
|
17
|
+
if (p.startsWith("/checkout")) return "checkout"
|
|
18
|
+
if (p.startsWith("/account")) return "account"
|
|
19
|
+
if (p === "/review") return "review"
|
|
20
|
+
if (p === "/order/track") return "track"
|
|
21
|
+
if (p.startsWith("/order/")) return "order"
|
|
22
|
+
const legal = p.match(/^\/legal\/(contact|shipping|returns|privacy|terms)$/)
|
|
23
|
+
if (legal) return legal[1] as PageName
|
|
24
|
+
return "other"
|
|
25
|
+
}
|
|
@@ -74,7 +74,7 @@ export const themeSchema = z.object({
|
|
|
74
74
|
/** Variante : reprend la mise en page (règles CSS) de ce thème, avec la palette de styles/ub/variants.css. */
|
|
75
75
|
layout: z.string().regex(SLUG).optional(),
|
|
76
76
|
/** Page d'accueil : « default » (sections standard) ou « harvest » (accueil éditorial des thèmes Thanksgiving) */
|
|
77
|
-
home: z.enum(["default", "harvest", "gala", "cinema", "album", "tech", "grimoire"]).optional(),
|
|
77
|
+
home: z.enum(["default", "harvest", "gala", "cinema", "album", "tech", "grimoire", "riviera", "sunset", "locker", "training", "arena", "mosaic", "lantern", "souk"]).optional(),
|
|
78
78
|
fonts: z.object({
|
|
79
79
|
head: z.enum(["inter", "jakarta", "oswald", "outfit", "poppins", "serif", "sora", "space-grotesk"]),
|
|
80
80
|
body: z.enum(["inter", "jakarta", "oswald", "outfit", "poppins", "serif", "sora", "space-grotesk"]),
|
|
@@ -134,6 +134,8 @@ export const campaignFileSchema = z
|
|
|
134
134
|
logo: z.url().max(500).optional(),
|
|
135
135
|
/** undefined = selon le thème */
|
|
136
136
|
showCountdown: z.boolean().optional(),
|
|
137
|
+
/** Site ouvert (défaut) ou fermé, décidé dans admin → Campagnes (mode multi) ; indépendant des dates */
|
|
138
|
+
online: z.boolean().optional(),
|
|
137
139
|
})
|
|
138
140
|
.refine((c) => new Date(c.startsAt) < new Date(c.endsAt), "startsAt doit précéder endsAt")
|
|
139
141
|
|
|
@@ -35,6 +35,19 @@ export const paymentInfoMap: Record<
|
|
|
35
35
|
title: <T k="checkout.manualPayment" />,
|
|
36
36
|
icon: <CreditCard />,
|
|
37
37
|
},
|
|
38
|
+
// Agrégateurs d'Afrique de l'Ouest : le client paie sur leur page (Wave, Orange Money, carte…), puis revient
|
|
39
|
+
pp_paytech_paytech: {
|
|
40
|
+
title: <T k="checkout.payPaytech" />,
|
|
41
|
+
icon: <CreditCard />,
|
|
42
|
+
},
|
|
43
|
+
pp_paydunya_paydunya: {
|
|
44
|
+
title: <T k="checkout.payPaydunya" />,
|
|
45
|
+
icon: <CreditCard />,
|
|
46
|
+
},
|
|
47
|
+
pp_naboopay_naboopay: {
|
|
48
|
+
title: <T k="checkout.payNaboopay" />,
|
|
49
|
+
icon: <CreditCard />,
|
|
50
|
+
},
|
|
38
51
|
// Add more payment providers here
|
|
39
52
|
}
|
|
40
53
|
|
|
@@ -48,6 +61,10 @@ export const isStripeLike = (providerId?: string) => {
|
|
|
48
61
|
export const isPaypal = (providerId?: string) => {
|
|
49
62
|
return providerId?.startsWith("pp_paypal")
|
|
50
63
|
}
|
|
64
|
+
/** Paiement sur la page de l'agrégateur (PayTech, PayDunya, NabooPay), puis retour sur la boutique */
|
|
65
|
+
export const isRedirectPay = (providerId?: string) =>
|
|
66
|
+
!!providerId && /^pp_(paytech|paydunya|naboopay)_/.test(providerId)
|
|
67
|
+
|
|
51
68
|
export const isManual = (providerId?: string) => {
|
|
52
69
|
return providerId?.startsWith("pp_system_default")
|
|
53
70
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use server"
|
|
2
|
+
|
|
3
|
+
import { sdk } from "@lib/config"
|
|
4
|
+
|
|
5
|
+
export type ReviewFormState = { ok?: boolean; error?: "invalid" | "duplicate" | "expired" | "tooMany" | "failed" }
|
|
6
|
+
|
|
7
|
+
/** Envoi d'un avis depuis la page « Laisser un avis » : le backend vérifie le jeton, la commande et le produit. */
|
|
8
|
+
export async function submitReview(_prev: ReviewFormState, form: FormData): Promise<ReviewFormState> {
|
|
9
|
+
const rating = Number(form.get("rating"))
|
|
10
|
+
const body = {
|
|
11
|
+
token: String(form.get("token") ?? ""),
|
|
12
|
+
product_id: String(form.get("product_id") ?? ""),
|
|
13
|
+
rating,
|
|
14
|
+
title: String(form.get("title") ?? "").trim() || undefined,
|
|
15
|
+
comment: String(form.get("comment") ?? "").trim(),
|
|
16
|
+
author_name: String(form.get("author_name") ?? "").trim(),
|
|
17
|
+
}
|
|
18
|
+
if (!(rating >= 1 && rating <= 5) || body.comment.length < 10 || !body.author_name || /[<>]/.test(body.comment + body.author_name + (body.title ?? ""))) {
|
|
19
|
+
return { error: "invalid" }
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
await sdk.client.fetch("/store/reviews", { method: "POST", body, cache: "no-store" })
|
|
23
|
+
return { ok: true }
|
|
24
|
+
} catch (e) {
|
|
25
|
+
const status = (e as { status?: number }).status
|
|
26
|
+
return { error: status === 429 ? "tooMany" : status === 409 || status === 422 ? "duplicate" : status === 400 || status === 401 || status === 403 || status === 404 ? "expired" : "failed" }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import "server-only"
|
|
2
|
+
|
|
3
|
+
import { cache } from "react"
|
|
4
|
+
import { sdk } from "@lib/config"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Avis clients (backend : module productReview). Lecture en cache 1 min : un avis validé dans l'admin
|
|
8
|
+
* apparaît sur le site en une minute. Toute erreur = « pas d'avis » (jamais une page cassée).
|
|
9
|
+
*/
|
|
10
|
+
export type Review = { id: string; product_id: string; author_name: string; rating: number; title: string | null; comment: string; created_at: string }
|
|
11
|
+
export type ReviewSummary = { average: number; count: number; distribution: [number, number, number, number, number] }
|
|
12
|
+
export type LatestReview = Review & { product: { title: string; handle: string; thumbnail: string | null } }
|
|
13
|
+
export type ReviewOrder = {
|
|
14
|
+
number: number
|
|
15
|
+
first_name: string
|
|
16
|
+
products: { product_id: string; title: string; handle?: string; thumbnail?: string; reviewed: boolean }[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CACHED = { method: "GET" as const, next: { revalidate: 60 }, cache: "force-cache" as const }
|
|
20
|
+
const EMPTY: ReviewSummary = { average: 0, count: 0, distribution: [0, 0, 0, 0, 0] }
|
|
21
|
+
|
|
22
|
+
export const getProductReviews = cache(async (productId: string, limit = 6) => {
|
|
23
|
+
try {
|
|
24
|
+
return await sdk.client.fetch<{ reviews: Review[]; count: number; summary: ReviewSummary }>(
|
|
25
|
+
`/store/reviews?product_id=${encodeURIComponent(productId)}&limit=${limit}`,
|
|
26
|
+
CACHED
|
|
27
|
+
)
|
|
28
|
+
} catch {
|
|
29
|
+
return { reviews: [] as Review[], count: 0, summary: EMPTY }
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/** Note moyenne et nombre d'avis par produit (cartes produits) */
|
|
34
|
+
export async function getReviewSummaries(productIds: string[]): Promise<Record<string, { average: number; count: number }>> {
|
|
35
|
+
const ids = [...new Set(productIds)].filter(Boolean).sort()
|
|
36
|
+
if (!ids.length) return {}
|
|
37
|
+
try {
|
|
38
|
+
const { summaries } = await sdk.client.fetch<{ summaries: Record<string, { average: number; count: number }> }>(
|
|
39
|
+
`/store/reviews/summary?product_ids=${ids.map(encodeURIComponent).join(",")}`,
|
|
40
|
+
CACHED
|
|
41
|
+
)
|
|
42
|
+
return summaries
|
|
43
|
+
} catch {
|
|
44
|
+
return {}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const getLatestReviews = cache(async (limit = 6): Promise<LatestReview[]> => {
|
|
49
|
+
try {
|
|
50
|
+
const { reviews } = await sdk.client.fetch<{ reviews: LatestReview[] }>(`/store/reviews/latest?limit=${limit}`, CACHED)
|
|
51
|
+
return reviews
|
|
52
|
+
} catch {
|
|
53
|
+
return []
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
/** Page « Laisser un avis » : produits de la commande du lien (jamais en cache : dépend du jeton) */
|
|
58
|
+
export async function getReviewOrder(token: string): Promise<ReviewOrder | null> {
|
|
59
|
+
try {
|
|
60
|
+
return await sdk.client.fetch<ReviewOrder>(`/store/reviews/order?token=${encodeURIComponent(token)}`, { method: "GET", cache: "no-store" })
|
|
61
|
+
} catch {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
|
|
4
|
-
import { T } from "@lib/boutique/i18n-client"
|
|
4
|
+
import { T, useT } from "@lib/boutique/i18n-client"
|
|
5
5
|
import { RadioGroup } from "@headlessui/react"
|
|
6
|
-
import { isStripeLike, paymentInfoMap } from "@lib/constants"
|
|
6
|
+
import { isRedirectPay, isStripeLike, paymentInfoMap } from "@lib/constants"
|
|
7
7
|
import { initiatePaymentSession } from "@lib/data/cart"
|
|
8
8
|
import { CheckCircleSolid, CreditCard } from "@medusajs/icons"
|
|
9
9
|
import { Button, Container, Heading, Text, clx } from "@medusajs/ui"
|
|
@@ -43,6 +43,25 @@ const Payment = ({
|
|
|
43
43
|
const pathname = usePathname()
|
|
44
44
|
|
|
45
45
|
const isOpen = searchParams.get("step") === "payment"
|
|
46
|
+
const t = useT()
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Paiement sur la page d'un agrégateur (PayTech, PayDunya, NabooPay) : adresse de retour sur cette boutique
|
|
50
|
+
* (vérifiée par le backend) et client (NabooPay l'exige). Le montant, lui, est toujours calculé par Medusa.
|
|
51
|
+
*/
|
|
52
|
+
const redirectData = () => {
|
|
53
|
+
const a = cart.shipping_address ?? cart.billing_address
|
|
54
|
+
const base = `${window.location.origin}/checkout?step=review`
|
|
55
|
+
return {
|
|
56
|
+
return_url: `${base}&pay_return=1`,
|
|
57
|
+
cancel_url: `${base}&pay_return=cancel`,
|
|
58
|
+
label: t("checkout.orderLabel"),
|
|
59
|
+
first_name: a?.first_name ?? undefined,
|
|
60
|
+
last_name: a?.last_name ?? undefined,
|
|
61
|
+
phone: a?.phone ?? undefined,
|
|
62
|
+
email: cart.email ?? undefined,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
46
65
|
|
|
47
66
|
const setPaymentMethod = async (method: string) => {
|
|
48
67
|
setError(null)
|
|
@@ -85,9 +104,11 @@ const Payment = ({
|
|
|
85
104
|
const checkActiveSession =
|
|
86
105
|
activeSession?.provider_id === selectedPaymentMethod
|
|
87
106
|
|
|
88
|
-
|
|
107
|
+
// Agrégateur : toujours un paiement neuf (montant et client à jour)
|
|
108
|
+
if (!checkActiveSession || isRedirectPay(selectedPaymentMethod)) {
|
|
89
109
|
await initiatePaymentSession(cart, {
|
|
90
110
|
provider_id: selectedPaymentMethod,
|
|
111
|
+
...(isRedirectPay(selectedPaymentMethod) ? { data: redirectData() } : {}),
|
|
91
112
|
})
|
|
92
113
|
}
|
|
93
114
|
|
|
@@ -100,7 +121,8 @@ const Payment = ({
|
|
|
100
121
|
)
|
|
101
122
|
}
|
|
102
123
|
} catch (err: any) {
|
|
103
|
-
|
|
124
|
+
// Agrégateur (clés, montant, service indisponible) : message clair, le détail est dans les logs du backend
|
|
125
|
+
setError(isRedirectPay(selectedPaymentMethod) ? t("checkout.payInitError") : err.message)
|
|
104
126
|
} finally {
|
|
105
127
|
setIsLoading(false)
|
|
106
128
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
import { T } from "@lib/boutique/i18n-client"
|
|
5
|
-
import { isManual, isStripeLike } from "@lib/constants"
|
|
5
|
+
import { isManual, isRedirectPay, isStripeLike } from "@lib/constants"
|
|
6
6
|
import { placeOrder } from "@lib/data/cart"
|
|
7
7
|
import { HttpTypes } from "@medusajs/types"
|
|
8
8
|
import { Button } from "@medusajs/ui"
|
|
@@ -28,7 +28,9 @@ const PaymentButton: React.FC<PaymentButtonProps> = ({
|
|
|
28
28
|
(cart.shipping_methods?.length ?? 0) < 1
|
|
29
29
|
|
|
30
30
|
const paymentSession = cart.payment_collection?.payment_sessions?.[0]
|
|
31
|
-
const
|
|
31
|
+
const params = useSearchParams()
|
|
32
|
+
const redirectStatus = params.get("redirect_status")
|
|
33
|
+
const payReturn = params.get("pay_return")
|
|
32
34
|
|
|
33
35
|
switch (true) {
|
|
34
36
|
case isStripeLike(paymentSession?.provider_id) && !!redirectStatus:
|
|
@@ -45,6 +47,15 @@ const PaymentButton: React.FC<PaymentButtonProps> = ({
|
|
|
45
47
|
data-testid={dataTestId}
|
|
46
48
|
/>
|
|
47
49
|
)
|
|
50
|
+
case isRedirectPay(paymentSession?.provider_id):
|
|
51
|
+
return (
|
|
52
|
+
<RedirectPayButton
|
|
53
|
+
notReady={notReady}
|
|
54
|
+
redirectUrl={typeof paymentSession?.data?.redirect_url === "string" ? paymentSession.data.redirect_url : undefined}
|
|
55
|
+
payReturn={payReturn}
|
|
56
|
+
data-testid={dataTestId}
|
|
57
|
+
/>
|
|
58
|
+
)
|
|
48
59
|
case isManual(paymentSession?.provider_id):
|
|
49
60
|
return (
|
|
50
61
|
<ManualTestPaymentButton notReady={notReady} data-testid={dataTestId} />
|
|
@@ -54,6 +65,78 @@ const PaymentButton: React.FC<PaymentButtonProps> = ({
|
|
|
54
65
|
}
|
|
55
66
|
}
|
|
56
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Agrégateur (PayTech, PayDunya, NabooPay) : « Payer maintenant » envoie le client sur la page de paiement.
|
|
70
|
+
* Au retour (?pay_return=1), la commande est validée : le backend demande le statut à l'agrégateur (on ne croit jamais
|
|
71
|
+
* l'URL de retour). Paiement encore en cours (Wave peut prendre un moment) : message + « Vérifier à nouveau » ;
|
|
72
|
+
* de toute façon la notification de l'agrégateur validera la commande côté serveur.
|
|
73
|
+
*/
|
|
74
|
+
const RedirectPayButton = ({
|
|
75
|
+
notReady,
|
|
76
|
+
redirectUrl,
|
|
77
|
+
payReturn,
|
|
78
|
+
"data-testid": dataTestId,
|
|
79
|
+
}: {
|
|
80
|
+
notReady: boolean
|
|
81
|
+
redirectUrl?: string
|
|
82
|
+
payReturn: string | null
|
|
83
|
+
"data-testid"?: string
|
|
84
|
+
}) => {
|
|
85
|
+
const [busy, setBusy] = useState(payReturn === "1")
|
|
86
|
+
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
|
87
|
+
const started = useRef(false)
|
|
88
|
+
|
|
89
|
+
const confirm = () => {
|
|
90
|
+
setBusy(true)
|
|
91
|
+
setErrorMessage(null)
|
|
92
|
+
placeOrder()
|
|
93
|
+
.catch((err) => setErrorMessage(err.message))
|
|
94
|
+
.finally(() => setBusy(false))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (payReturn !== "1" || started.current) return
|
|
99
|
+
started.current = true
|
|
100
|
+
confirm()
|
|
101
|
+
}, [payReturn])
|
|
102
|
+
|
|
103
|
+
if (payReturn === "1") {
|
|
104
|
+
return (
|
|
105
|
+
<>
|
|
106
|
+
<Button size="large" isLoading={busy} disabled={busy} onClick={confirm} data-testid={dataTestId}>
|
|
107
|
+
<T k={busy ? "checkout.payChecking" : "checkout.payRetry"} />
|
|
108
|
+
</Button>
|
|
109
|
+
{errorMessage && (
|
|
110
|
+
<p className="text-small-regular text-ui-fg-subtle mt-2" role="status">
|
|
111
|
+
<T k="checkout.paymentPending" />
|
|
112
|
+
</p>
|
|
113
|
+
)}
|
|
114
|
+
</>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<>
|
|
120
|
+
<Button
|
|
121
|
+
size="large"
|
|
122
|
+
disabled={notReady || !redirectUrl}
|
|
123
|
+
isLoading={busy}
|
|
124
|
+
onClick={() => {
|
|
125
|
+
if (!redirectUrl) return
|
|
126
|
+
setBusy(true)
|
|
127
|
+
window.location.assign(redirectUrl)
|
|
128
|
+
}}
|
|
129
|
+
data-testid={dataTestId}
|
|
130
|
+
>
|
|
131
|
+
<T k="checkout.payRedirect" />
|
|
132
|
+
</Button>
|
|
133
|
+
<p className="text-small-regular text-ui-fg-subtle mt-2" role="status">
|
|
134
|
+
<T k={payReturn === "cancel" ? "checkout.payCancelled" : "checkout.payRedirectHint"} />
|
|
135
|
+
</p>
|
|
136
|
+
</>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
57
140
|
/** Retour d'un moyen de paiement à redirection (Klarna, banque…) : Stripe renvoie sur /checkout?step=review&redirect_status=… */
|
|
58
141
|
const StripeRedirectReturn = ({ status, children }: { status: string; children: React.ReactNode }) => {
|
|
59
142
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Suspense } from "react"
|
|
2
|
+
import { ProductReviews } from "@modules/ub/reviews"
|
|
2
3
|
import { notFound } from "next/navigation"
|
|
3
4
|
import { HttpTypes } from "@medusajs/types"
|
|
4
5
|
|
|
@@ -110,6 +111,10 @@ export default function ProductTemplate({ product, region, images }: ProductTemp
|
|
|
110
111
|
</div>
|
|
111
112
|
</section>
|
|
112
113
|
|
|
114
|
+
<Suspense fallback={null}>
|
|
115
|
+
<ProductReviews productId={product.id} />
|
|
116
|
+
</Suspense>
|
|
117
|
+
|
|
113
118
|
<Suspense fallback={null}>
|
|
114
119
|
<Related product={product} />
|
|
115
120
|
</Suspense>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
|
4
|
+
import { useEffect, useRef, useState, useTransition } from "react"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Recherche de la page « Boutique » : résultats pendant la frappe (pause de 350 ms), en gardant catégorie et tri.
|
|
8
|
+
* Formulaire GET classique : fonctionne aussi sans JavaScript (Entrée). Focus automatique si l'URL finit par #recherche
|
|
9
|
+
* (icône loupe de l'en-tête sur mobile).
|
|
10
|
+
*/
|
|
11
|
+
export default function ShopSearch({ placeholder, clearLabel }: { placeholder: string; clearLabel: string }) {
|
|
12
|
+
const router = useRouter()
|
|
13
|
+
const pathname = usePathname()
|
|
14
|
+
const params = useSearchParams()
|
|
15
|
+
const [value, setValue] = useState(params.get("q") ?? "")
|
|
16
|
+
const [pending, start] = useTransition()
|
|
17
|
+
const input = useRef<HTMLInputElement>(null)
|
|
18
|
+
const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (window.location.hash === "#recherche") input.current?.focus()
|
|
22
|
+
}, [])
|
|
23
|
+
|
|
24
|
+
const go = (q: string) => {
|
|
25
|
+
const next = new URLSearchParams(params)
|
|
26
|
+
if (q.trim()) next.set("q", q.trim())
|
|
27
|
+
else next.delete("q")
|
|
28
|
+
next.delete("page")
|
|
29
|
+
const s = next.toString()
|
|
30
|
+
start(() => router.replace(s ? `${pathname}?${s}` : pathname, { scroll: false }))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<form
|
|
35
|
+
className="ub-shopsearch"
|
|
36
|
+
role="search"
|
|
37
|
+
action="/store"
|
|
38
|
+
aria-busy={pending}
|
|
39
|
+
onSubmit={(e) => {
|
|
40
|
+
e.preventDefault()
|
|
41
|
+
clearTimeout(timer.current)
|
|
42
|
+
go(value)
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
<svg viewBox="0 0 24 24" aria-hidden>
|
|
46
|
+
<circle cx="11" cy="11" r="6.5" />
|
|
47
|
+
<path d="M20 20l-4.2-4.2" />
|
|
48
|
+
</svg>
|
|
49
|
+
<input
|
|
50
|
+
ref={input}
|
|
51
|
+
id="recherche"
|
|
52
|
+
type="search"
|
|
53
|
+
name="q"
|
|
54
|
+
value={value}
|
|
55
|
+
placeholder={placeholder}
|
|
56
|
+
aria-label={placeholder}
|
|
57
|
+
autoComplete="off"
|
|
58
|
+
onChange={(e) => {
|
|
59
|
+
const q = e.target.value
|
|
60
|
+
setValue(q)
|
|
61
|
+
clearTimeout(timer.current)
|
|
62
|
+
timer.current = setTimeout(() => go(q), 350)
|
|
63
|
+
}}
|
|
64
|
+
/>
|
|
65
|
+
{/* Filtres gardés quand le formulaire est envoyé sans JavaScript */}
|
|
66
|
+
{params.get("sortBy") && <input type="hidden" name="sortBy" value={params.get("sortBy") ?? ""} />}
|
|
67
|
+
{params.get("category") && <input type="hidden" name="category" value={params.get("category") ?? ""} />}
|
|
68
|
+
{value && (
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
className="ub-shopsearch__clear"
|
|
72
|
+
aria-label={clearLabel}
|
|
73
|
+
onClick={() => {
|
|
74
|
+
setValue("")
|
|
75
|
+
clearTimeout(timer.current)
|
|
76
|
+
go("")
|
|
77
|
+
input.current?.focus()
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
×
|
|
81
|
+
</button>
|
|
82
|
+
)}
|
|
83
|
+
</form>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -8,6 +8,8 @@ import SkeletonProductGrid from "@modules/skeletons/templates/skeleton-product-g
|
|
|
8
8
|
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
|
|
9
9
|
import { CampaignCountdown } from "@modules/ub/campaign-blocks"
|
|
10
10
|
|
|
11
|
+
import ShopSearch from "@modules/store/components/shop-search"
|
|
12
|
+
|
|
11
13
|
import PaginatedProducts from "./paginated-products"
|
|
12
14
|
|
|
13
15
|
const SORTS: [SortOptions, string][] = [
|
|
@@ -17,21 +19,24 @@ const SORTS: [SortOptions, string][] = [
|
|
|
17
19
|
]
|
|
18
20
|
|
|
19
21
|
/** Lien de la page boutique en gardant les autres filtres (serveur, sans JavaScript). */
|
|
20
|
-
const shopHref = (q: { sortBy?: string; category?: string }) => {
|
|
22
|
+
const shopHref = (q: { sortBy?: string; category?: string; q?: string }) => {
|
|
21
23
|
const params = new URLSearchParams(Object.entries(q).filter(([, v]) => v) as [string, string][])
|
|
22
24
|
const s = params.toString()
|
|
23
25
|
return s ? `/store?${s}` : "/store"
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
/** Page « Boutique » : bandeau de campagne, catégories en pastilles, tri en boutons, grille. */
|
|
28
|
+
/** Page « Boutique » : bandeau de campagne, catégories en pastilles, recherche, tri en boutons, grille. */
|
|
27
29
|
const StoreTemplate = async ({
|
|
28
30
|
sortBy,
|
|
29
31
|
page,
|
|
30
32
|
category,
|
|
33
|
+
q,
|
|
31
34
|
}: {
|
|
32
35
|
sortBy?: SortOptions
|
|
33
36
|
page?: string
|
|
34
37
|
category?: string
|
|
38
|
+
/** Recherche (titre, description… : recherche plein texte de Medusa, limitée au canal de vente de la boutique) */
|
|
39
|
+
q?: string
|
|
35
40
|
}) => {
|
|
36
41
|
const pageNumber = page ? parseInt(page) : 1
|
|
37
42
|
const sort = sortBy || "created_at"
|
|
@@ -52,7 +57,7 @@ const StoreTemplate = async ({
|
|
|
52
57
|
</span>
|
|
53
58
|
)}
|
|
54
59
|
<h1 className="ub-head ub-shophero__title" data-testid="store-page-title">
|
|
55
|
-
{active?.name ?? campaign.sections?.products ?? t("footer.all")}
|
|
60
|
+
{q ? t("shop.results", { q }) : (active?.name ?? campaign.sections?.products ?? t("footer.all"))}
|
|
56
61
|
</h1>
|
|
57
62
|
{campaign.promo?.text && <p className="ub-shophero__sub">{campaign.promo.text}</p>}
|
|
58
63
|
</div>
|
|
@@ -68,13 +73,13 @@ const StoreTemplate = async ({
|
|
|
68
73
|
<div className="ub-wrap ub-shop" data-testid="category-container">
|
|
69
74
|
<div className="ub-shop__bar">
|
|
70
75
|
<nav className="ub-chips" aria-label={t("section.categories")}>
|
|
71
|
-
<Link href={shopHref({ sortBy })} className="ub-chip" aria-current={!active ? "page" : undefined}>
|
|
76
|
+
<Link href={shopHref({ sortBy, q })} className="ub-chip" aria-current={!active ? "page" : undefined}>
|
|
72
77
|
{t("shop.all")}
|
|
73
78
|
</Link>
|
|
74
79
|
{topLevel.map((c) => (
|
|
75
80
|
<Link
|
|
76
81
|
key={c.id}
|
|
77
|
-
href={shopHref({ sortBy, category: c.handle })}
|
|
82
|
+
href={shopHref({ sortBy, category: c.handle, q })}
|
|
78
83
|
className="ub-chip"
|
|
79
84
|
aria-current={active?.id === c.id ? "page" : undefined}
|
|
80
85
|
>
|
|
@@ -82,11 +87,14 @@ const StoreTemplate = async ({
|
|
|
82
87
|
</Link>
|
|
83
88
|
))}
|
|
84
89
|
</nav>
|
|
90
|
+
<Suspense fallback={<div className="ub-shopsearch" />}>
|
|
91
|
+
<ShopSearch placeholder={t("nav.search")} clearLabel={t("shop.clear")} />
|
|
92
|
+
</Suspense>
|
|
85
93
|
<nav className="ub-sort" aria-label={t("store.sortBy")}>
|
|
86
94
|
{SORTS.map(([value, key]) => (
|
|
87
95
|
<Link
|
|
88
96
|
key={value}
|
|
89
|
-
href={shopHref({ sortBy: value === "created_at" ? undefined : value, category })}
|
|
97
|
+
href={shopHref({ sortBy: value === "created_at" ? undefined : value, category, q })}
|
|
90
98
|
className="ub-sort__opt"
|
|
91
99
|
aria-current={sort === value ? "true" : undefined}
|
|
92
100
|
scroll={false}
|
|
@@ -96,8 +104,8 @@ const StoreTemplate = async ({
|
|
|
96
104
|
))}
|
|
97
105
|
</nav>
|
|
98
106
|
</div>
|
|
99
|
-
<Suspense key={`${sort}-${category}-${pageNumber}`} fallback={<SkeletonProductGrid />}>
|
|
100
|
-
<PaginatedProducts sortBy={sort} page={pageNumber} categoryId={active?.id} showCount />
|
|
107
|
+
<Suspense key={`${sort}-${category}-${pageNumber}-${q}`} fallback={<SkeletonProductGrid />}>
|
|
108
|
+
<PaginatedProducts sortBy={sort} page={pageNumber} categoryId={active?.id} q={q} showCount />
|
|
101
109
|
</Suspense>
|
|
102
110
|
</div>
|
|
103
111
|
</>
|