@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salifbiaye/create-boutique",
3
- "version": "0.3.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 logo = await getCampaign()
29
- .then((c) => c.logo)
30
- .catch(() => undefined)
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: b.storeName, template: `%s | ${b.storeName}` },
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 = "Country", region, defaultValue, ...props }, ref) => {
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
- return region.countries?.map((country) => ({
27
- value: country.iso_2,
28
- label: country.display_name,
29
- }))
30
- }, [region])
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?.map(({ value, label }, index) => (
40
- <option key={index} value={value}>
57
+ {countryOptions.map(({ value, label }) => (
58
+ <option key={value} value={value}>
41
59
  {label}
42
60
  </option>
43
61
  ))}