@salifbiaye/create-boutique 0.3.3 → 0.3.5
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/package.json +1 -1
- package/templates/backend/src/admin/i18n/json/en.json +8 -3
- package/templates/backend/src/admin/i18n/json/fr.json +8 -3
- package/templates/backend/src/admin/routes/boutique-campaigns/campaign-drawer.tsx +14 -1
- package/templates/backend/src/modules/campaign/validators.ts +9 -0
- package/templates/backend/src/workflows/upsert-campaign.ts +2 -2
- package/templates/storefront/src/app/layout.tsx +20 -4
- package/templates/storefront/src/lib/boutique/campaign.ts +3 -0
- package/templates/storefront/src/lib/boutique/schema.ts +2 -0
- package/templates/storefront/src/modules/checkout/components/country-select/index.tsx +29 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salifbiaye/create-boutique",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Usine à Boutiques : génère un projet complet (backend Medusa + une boutique Next.js par campagne) prêt pour docker compose",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": { "type": "git", "url": "git+https://github.com/salifbiaye/create-boutique.git" },
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"topbar": "Top bar messages",
|
|
24
24
|
"promo": "Promo banner",
|
|
25
25
|
"vars": "Variables used by theme texts",
|
|
26
|
-
"identity": "Identity"
|
|
26
|
+
"identity": "Identity",
|
|
27
|
+
"share": "Social sharing (link preview)"
|
|
27
28
|
},
|
|
28
29
|
"fields": {
|
|
29
30
|
"startsAt": "Starts at",
|
|
@@ -49,7 +50,10 @@
|
|
|
49
50
|
"countdownTheme": "As the theme",
|
|
50
51
|
"countdownYes": "Always show",
|
|
51
52
|
"countdownNo": "Never show",
|
|
52
|
-
"promoImage": "Promo banner image"
|
|
53
|
+
"promoImage": "Promo banner image",
|
|
54
|
+
"seoTitle": "Share title (70 characters max)",
|
|
55
|
+
"seoDescription": "Share description (200 characters max)",
|
|
56
|
+
"seoImage": "Share image (ideally 1200 × 630, jpg, png or webp)"
|
|
53
57
|
},
|
|
54
58
|
"errors": {
|
|
55
59
|
"imageType": "Image must be jpg, png or webp",
|
|
@@ -61,6 +65,7 @@
|
|
|
61
65
|
"hint": "Shown on the category tiles of the stores.",
|
|
62
66
|
"save": "Save image",
|
|
63
67
|
"saved": "Image saved"
|
|
64
|
-
}
|
|
68
|
+
},
|
|
69
|
+
"shareHint": "What shows when the store link is shared (WhatsApp, Facebook, LinkedIn…). Empty = store name, subtitle and main visual."
|
|
65
70
|
}
|
|
66
71
|
}
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"topbar": "Messages de la barre du haut",
|
|
24
24
|
"promo": "Bandeau promo",
|
|
25
25
|
"vars": "Variables des textes du thème",
|
|
26
|
-
"identity": "Identité"
|
|
26
|
+
"identity": "Identité",
|
|
27
|
+
"share": "Partage sur les réseaux (aperçu du lien)"
|
|
27
28
|
},
|
|
28
29
|
"fields": {
|
|
29
30
|
"startsAt": "Début",
|
|
@@ -49,7 +50,10 @@
|
|
|
49
50
|
"countdownTheme": "Comme le thème",
|
|
50
51
|
"countdownYes": "Toujours afficher",
|
|
51
52
|
"countdownNo": "Ne jamais afficher",
|
|
52
|
-
"promoImage": "Image du bandeau promo"
|
|
53
|
+
"promoImage": "Image du bandeau promo",
|
|
54
|
+
"seoTitle": "Titre de partage (70 caractères max)",
|
|
55
|
+
"seoDescription": "Description de partage (200 caractères max)",
|
|
56
|
+
"seoImage": "Image de partage (idéal 1200 × 630, jpg, png ou webp)"
|
|
53
57
|
},
|
|
54
58
|
"errors": {
|
|
55
59
|
"imageType": "L'image doit être en jpg, png ou webp",
|
|
@@ -61,6 +65,7 @@
|
|
|
61
65
|
"hint": "Affichée sur les tuiles catégories des boutiques.",
|
|
62
66
|
"save": "Enregistrer l'image",
|
|
63
67
|
"saved": "Image enregistrée"
|
|
64
|
-
}
|
|
68
|
+
},
|
|
69
|
+
"shareHint": "Ce qui s'affiche quand on partage le lien de la boutique (WhatsApp, Facebook, LinkedIn…). Vide = nom de la boutique, sous-titre et visuel principal."
|
|
65
70
|
}
|
|
66
71
|
}
|
|
@@ -84,6 +84,9 @@ export function CampaignDrawer({
|
|
|
84
84
|
const [heroImage, setHeroImage] = useState(c?.hero?.image ?? "")
|
|
85
85
|
const [promoImage, setPromoImage] = useState(c?.promo?.image ?? "")
|
|
86
86
|
const [logo, setLogo] = useState(c?.logo ?? "")
|
|
87
|
+
// Aperçu de partage (Open Graph) : vide = nom de la boutique, sous-titre et visuel du hero
|
|
88
|
+
const [seo, setSeo] = useState({ title: c?.seo?.title ?? "", description: c?.seo?.description ?? "" })
|
|
89
|
+
const [seoImage, setSeoImage] = useState(c?.seo?.image ?? "")
|
|
87
90
|
const [countdown, setCountdown] = useState(c?.showCountdown == null ? "theme" : c.showCountdown ? "yes" : "no")
|
|
88
91
|
|
|
89
92
|
const save = useMutation({
|
|
@@ -124,6 +127,7 @@ export function CampaignDrawer({
|
|
|
124
127
|
topbar: topbar.some((m) => m.trim()) ? topbar.filter((m) => m.trim()) : undefined,
|
|
125
128
|
hero: clean({ ...hero, bubble: bubble.some((b) => b.trim()) ? bubble : undefined }),
|
|
126
129
|
promo: clean(promo),
|
|
130
|
+
seo: clean({ ...seo, image: seoImage || undefined }),
|
|
127
131
|
})
|
|
128
132
|
}
|
|
129
133
|
|
|
@@ -131,7 +135,7 @@ export function CampaignDrawer({
|
|
|
131
135
|
const reset = () => {
|
|
132
136
|
const d = dates()
|
|
133
137
|
if (!d) return
|
|
134
|
-
save.mutate({ ...d, nextCampaign: nextCampaign || null, vars: clean(vars) ?? null, ...media() }, {
|
|
138
|
+
save.mutate({ ...d, nextCampaign: nextCampaign || null, vars: clean(vars) ?? null, ...media(), seo: clean({ ...seo, image: seoImage || undefined }) }, {
|
|
135
139
|
onSuccess: () => toast.success(t("campaigns.resetDone")),
|
|
136
140
|
})
|
|
137
141
|
}
|
|
@@ -164,6 +168,15 @@ export function CampaignDrawer({
|
|
|
164
168
|
<Drawer.Description>{t("campaigns.emptyHint")}</Drawer.Description>
|
|
165
169
|
</Drawer.Header>
|
|
166
170
|
<Drawer.Body className="flex flex-col gap-y-8 overflow-y-auto">
|
|
171
|
+
{section(
|
|
172
|
+
t("campaigns.sections.share"),
|
|
173
|
+
<>
|
|
174
|
+
<Text size="small" className="text-ui-fg-subtle">{t("campaigns.shareHint")}</Text>
|
|
175
|
+
{field(t("campaigns.fields.seoTitle"), seo.title, (v) => setSeo({ ...seo, title: v }))}
|
|
176
|
+
{field(t("campaigns.fields.seoDescription"), seo.description, (v) => setSeo({ ...seo, description: v }), true)}
|
|
177
|
+
<ImageField label={t("campaigns.fields.seoImage")} value={seoImage} onChange={setSeoImage} previewClassName="h-20 w-40 object-cover" />
|
|
178
|
+
</>
|
|
179
|
+
)}
|
|
167
180
|
{section(
|
|
168
181
|
t("campaigns.sections.identity"),
|
|
169
182
|
<>
|
|
@@ -32,6 +32,15 @@ export const CampaignTexts = z.object({
|
|
|
32
32
|
.partial()
|
|
33
33
|
.optional(),
|
|
34
34
|
sections: z.object({ categories: plain(40), products: plain(40) }).partial().optional(),
|
|
35
|
+
/** Aperçu de partage (Open Graph / réseaux sociaux) ; vide = nom de la boutique, sous-titre et image du hero */
|
|
36
|
+
seo: z
|
|
37
|
+
.object({
|
|
38
|
+
title: plain(70),
|
|
39
|
+
description: plain(200),
|
|
40
|
+
image: z.url().max(500).refine((u) => /\.(jpe?g|png|webp)(\?.*)?$/i.test(u), "Image : jpg, png ou webp"),
|
|
41
|
+
})
|
|
42
|
+
.partial()
|
|
43
|
+
.optional(),
|
|
35
44
|
})
|
|
36
45
|
|
|
37
46
|
const imageUrl = z
|
|
@@ -7,9 +7,9 @@ type Input = { sales_channel_id: string; campaign: CampaignInput }
|
|
|
7
7
|
/** Enregistre la campagne d'une boutique (admin « Campagnes », seed, `campaign import`). */
|
|
8
8
|
export const upsertCampaignWorkflow = createWorkflow("upsert-campaign", function (input: Input) {
|
|
9
9
|
const data = transform({ input }, ({ input }) => {
|
|
10
|
-
const { startsAt, endsAt, nextCampaign, vars, heroImage, logo, promoImage, showCountdown, topbar, hero, promo, sections } = input.campaign
|
|
10
|
+
const { startsAt, endsAt, nextCampaign, vars, heroImage, logo, promoImage, showCountdown, topbar, hero, promo, sections, seo } = input.campaign
|
|
11
11
|
const texts = Object.fromEntries(
|
|
12
|
-
Object.entries({ topbar, hero, promo, sections }).filter(([, v]) => v !== undefined && JSON.stringify(v) !== "{}")
|
|
12
|
+
Object.entries({ topbar, hero, promo, sections, seo }).filter(([, v]) => v !== undefined && JSON.stringify(v) !== "{}")
|
|
13
13
|
)
|
|
14
14
|
return {
|
|
15
15
|
sales_channel_id: input.sales_channel_id,
|
|
@@ -25,13 +25,29 @@ export const dynamic = "force-dynamic"
|
|
|
25
25
|
export async function generateMetadata(): Promise<Metadata> {
|
|
26
26
|
const b = getBoutique()
|
|
27
27
|
// Favicon = logo de la boutique s'il existe (admin → Campagnes)
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
const campaign = await getCampaign().catch(() => undefined)
|
|
29
|
+
const logo = campaign?.logo
|
|
30
|
+
// Aperçu de partage (WhatsApp, Facebook, LinkedIn, X…) : réglages de l'admin, sinon nom, sous-titre et image du hero
|
|
31
|
+
const seo = campaign?.seo ?? {}
|
|
32
|
+
const heroImage = campaign?.hero.image && /^https?:\/\//.test(campaign.hero.image) ? campaign.hero.image : undefined
|
|
33
|
+
const title = seo.title || b.storeName
|
|
34
|
+
const description = seo.description || campaign?.hero.sub || undefined
|
|
35
|
+
const image = seo.image || heroImage || logo
|
|
31
36
|
return {
|
|
32
37
|
metadataBase: new URL(b.siblings[b.slug]),
|
|
33
|
-
title: { default:
|
|
38
|
+
title: { default: title, template: `%s | ${b.storeName}` },
|
|
39
|
+
description,
|
|
34
40
|
...(logo ? { icons: { icon: logo, apple: logo } } : {}),
|
|
41
|
+
openGraph: {
|
|
42
|
+
type: "website",
|
|
43
|
+
siteName: b.storeName,
|
|
44
|
+
title,
|
|
45
|
+
description,
|
|
46
|
+
locale: b.locale.replace("-", "_"),
|
|
47
|
+
url: b.siblings[b.slug],
|
|
48
|
+
...(image ? { images: [{ url: image, width: 1200, height: 630, alt: title }] } : {}),
|
|
49
|
+
},
|
|
50
|
+
twitter: { card: image ? "summary_large_image" : "summary", title, description, ...(image ? { images: [image] } : {}) },
|
|
35
51
|
}
|
|
36
52
|
}
|
|
37
53
|
|
|
@@ -16,6 +16,8 @@ export type Campaign = CampaignTexts & {
|
|
|
16
16
|
freeShipping?: string
|
|
17
17
|
/** Logo (URL) ; absent = pastille de couleur + nom */
|
|
18
18
|
logo?: string
|
|
19
|
+
/** Aperçu de partage (Open Graph) : titre, description, image ; vides = valeurs par défaut */
|
|
20
|
+
seo?: { title?: string; description?: string; image?: string }
|
|
19
21
|
/** Compte à rebours : undefined = selon le thème (CSS), true = toujours, false = jamais */
|
|
20
22
|
showCountdown?: boolean
|
|
21
23
|
}
|
|
@@ -104,6 +106,7 @@ export const getCampaign = cache(async (): Promise<Campaign> => {
|
|
|
104
106
|
freeShipping: own.vars?.freeShipping,
|
|
105
107
|
logo: own.logo,
|
|
106
108
|
showCountdown: own.showCountdown,
|
|
109
|
+
seo: own.seo,
|
|
107
110
|
}
|
|
108
111
|
})
|
|
109
112
|
|
|
@@ -125,6 +125,8 @@ export const campaignFileSchema = z
|
|
|
125
125
|
hero: campaignTextsSchema.shape.hero.partial().optional(),
|
|
126
126
|
promo: campaignTextsSchema.shape.promo.partial().optional(),
|
|
127
127
|
sections: campaignTextsSchema.shape.sections.unwrap().partial().optional(),
|
|
128
|
+
/** Aperçu de partage (Open Graph) défini dans l'admin */
|
|
129
|
+
seo: z.object({ title: plain(70), description: plain(200), image: z.url().max(500) }).partial().optional(),
|
|
128
130
|
/** Logo de la boutique (URL du stockage), aussi utilisé comme favicon */
|
|
129
131
|
logo: z.url().max(500).optional(),
|
|
130
132
|
/** undefined = selon le thème */
|
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import { forwardRef, useImperativeHandle, useMemo, useRef } from "react"
|
|
2
2
|
|
|
3
|
+
import { useLocale, useT } from "@lib/boutique/i18n-client"
|
|
3
4
|
import NativeSelect, {
|
|
4
5
|
NativeSelectProps,
|
|
5
6
|
} from "@modules/common/components/native-select"
|
|
6
7
|
import { HttpTypes } from "@medusajs/types"
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Liste des pays de la région : noms dans la langue du site (« Sénégal », « Côte d'Ivoire »), triés par ordre
|
|
11
|
+
* alphabétique, pays de la boutique en tête. (Le starter affichait les noms anglais de Medusa, non triés.)
|
|
12
|
+
*/
|
|
8
13
|
const CountrySelect = forwardRef<
|
|
9
14
|
HTMLSelectElement,
|
|
10
15
|
NativeSelectProps & {
|
|
11
16
|
region?: HttpTypes.StoreRegion
|
|
12
17
|
}
|
|
13
|
-
>(({ placeholder = "
|
|
18
|
+
>(({ placeholder = "form.country", region, defaultValue, ...props }, ref) => {
|
|
14
19
|
const innerRef = useRef<HTMLSelectElement>(null)
|
|
20
|
+
const t = useT()
|
|
21
|
+
const locale = useLocale()
|
|
15
22
|
|
|
16
23
|
useImperativeHandle<HTMLSelectElement | null, HTMLSelectElement | null>(
|
|
17
24
|
ref,
|
|
@@ -19,25 +26,36 @@ const CountrySelect = forwardRef<
|
|
|
19
26
|
)
|
|
20
27
|
|
|
21
28
|
const countryOptions = useMemo(() => {
|
|
22
|
-
if (!region) {
|
|
29
|
+
if (!region?.countries) {
|
|
23
30
|
return []
|
|
24
31
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
let names: Intl.DisplayNames | undefined
|
|
33
|
+
try {
|
|
34
|
+
names = new Intl.DisplayNames([locale], { type: "region" })
|
|
35
|
+
} catch {
|
|
36
|
+
names = undefined
|
|
37
|
+
}
|
|
38
|
+
const home = (locale.split("-")[1] ?? "").toLowerCase()
|
|
39
|
+
const options = region.countries
|
|
40
|
+
.filter((c) => c.iso_2)
|
|
41
|
+
.map((c) => ({
|
|
42
|
+
value: c.iso_2!,
|
|
43
|
+
label: names?.of(c.iso_2!.toUpperCase()) ?? c.display_name ?? c.iso_2!,
|
|
44
|
+
}))
|
|
45
|
+
.sort((a, b) => a.label.localeCompare(b.label, locale, { sensitivity: "base" }))
|
|
46
|
+
const first = options.find((o) => o.value === home)
|
|
47
|
+
return first ? [first, ...options.filter((o) => o !== first)] : options
|
|
48
|
+
}, [region, locale])
|
|
31
49
|
|
|
32
50
|
return (
|
|
33
51
|
<NativeSelect
|
|
34
52
|
ref={innerRef}
|
|
35
|
-
placeholder={placeholder}
|
|
53
|
+
placeholder={t(placeholder)}
|
|
36
54
|
defaultValue={defaultValue}
|
|
37
55
|
{...props}
|
|
38
56
|
>
|
|
39
|
-
{countryOptions
|
|
40
|
-
<option key={
|
|
57
|
+
{countryOptions.map(({ value, label }) => (
|
|
58
|
+
<option key={value} value={value}>
|
|
41
59
|
{label}
|
|
42
60
|
</option>
|
|
43
61
|
))}
|