@salifbiaye/create-boutique 0.3.4 → 0.3.6
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 +21 -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/ub/campaign-blocks.tsx +1 -1
- package/templates/storefront/src/modules/ub/category-tiles.tsx +2 -1
- package/templates/storefront/src/modules/ub/fade-img.tsx +35 -0
- package/templates/storefront/src/modules/ub/harvest.tsx +6 -2
- package/templates/storefront/src/modules/ub/product-card.tsx +2 -1
- package/templates/storefront/src/modules/ub/showcase.tsx +3 -2
- package/templates/storefront/src/styles/ub/harvest.css +2 -1
- package/templates/storefront/src/styles/ub/media.css +37 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salifbiaye/create-boutique",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
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,
|
|
@@ -14,6 +14,7 @@ import "styles/ub/forms.css"
|
|
|
14
14
|
import "styles/ub/account.css"
|
|
15
15
|
import "styles/ub/black-friday.css"
|
|
16
16
|
import "styles/ub/harvest.css"
|
|
17
|
+
import "styles/ub/media.css"
|
|
17
18
|
import "styles/ub/responsive.css"
|
|
18
19
|
import "styles/ub/fonts.css"
|
|
19
20
|
import "styles/ub/medusa-ui.css"
|
|
@@ -25,13 +26,29 @@ export const dynamic = "force-dynamic"
|
|
|
25
26
|
export async function generateMetadata(): Promise<Metadata> {
|
|
26
27
|
const b = getBoutique()
|
|
27
28
|
// Favicon = logo de la boutique s'il existe (admin → Campagnes)
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
const campaign = await getCampaign().catch(() => undefined)
|
|
30
|
+
const logo = campaign?.logo
|
|
31
|
+
// Aperçu de partage (WhatsApp, Facebook, LinkedIn, X…) : réglages de l'admin, sinon nom, sous-titre et image du hero
|
|
32
|
+
const seo = campaign?.seo ?? {}
|
|
33
|
+
const heroImage = campaign?.hero.image && /^https?:\/\//.test(campaign.hero.image) ? campaign.hero.image : undefined
|
|
34
|
+
const title = seo.title || b.storeName
|
|
35
|
+
const description = seo.description || campaign?.hero.sub || undefined
|
|
36
|
+
const image = seo.image || heroImage || logo
|
|
31
37
|
return {
|
|
32
38
|
metadataBase: new URL(b.siblings[b.slug]),
|
|
33
|
-
title: { default:
|
|
39
|
+
title: { default: title, template: `%s | ${b.storeName}` },
|
|
40
|
+
description,
|
|
34
41
|
...(logo ? { icons: { icon: logo, apple: logo } } : {}),
|
|
42
|
+
openGraph: {
|
|
43
|
+
type: "website",
|
|
44
|
+
siteName: b.storeName,
|
|
45
|
+
title,
|
|
46
|
+
description,
|
|
47
|
+
locale: b.locale.replace("-", "_"),
|
|
48
|
+
url: b.siblings[b.slug],
|
|
49
|
+
...(image ? { images: [{ url: image, width: 1200, height: 630, alt: title }] } : {}),
|
|
50
|
+
},
|
|
51
|
+
twitter: { card: image ? "summary_large_image" : "summary", title, description, ...(image ? { images: [image] } : {}) },
|
|
35
52
|
}
|
|
36
53
|
}
|
|
37
54
|
|
|
@@ -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 */
|
|
@@ -99,7 +99,7 @@ export function Hero({ campaign }: { campaign: Campaign }) {
|
|
|
99
99
|
<div className="ub-hero__blob" />
|
|
100
100
|
{image ? (
|
|
101
101
|
// eslint-disable-next-line @next/next/no-img-element
|
|
102
|
-
<img className="ub-hero__photo" src={image} alt={t("hero.photo")} />
|
|
102
|
+
<img className="ub-hero__photo" src={image} alt={t("hero.photo")} fetchPriority="high" decoding="async" />
|
|
103
103
|
) : (
|
|
104
104
|
<div className="ub-hero__photo ub-ph" role="img" aria-label={t("hero.photo")} />
|
|
105
105
|
)}
|
|
@@ -3,6 +3,7 @@ import { t } from "@lib/boutique/i18n"
|
|
|
3
3
|
import { HttpTypes } from "@medusajs/types"
|
|
4
4
|
import { Icon, TILE_ICONS } from "./icons"
|
|
5
5
|
|
|
6
|
+
import FadeImg from "./fade-img"
|
|
6
7
|
/** Tuiles catégorie : catégories racines de Medusa (6 max), couleurs --tint-1…4 du thème. */
|
|
7
8
|
export default function CategoryTiles({ categories }: { categories: HttpTypes.StoreProductCategory[] }) {
|
|
8
9
|
const roots = categories.filter((c) => !c.parent_category).slice(0, 6)
|
|
@@ -21,7 +22,7 @@ export default function CategoryTiles({ categories }: { categories: HttpTypes.St
|
|
|
21
22
|
<span className="ub-tile__img" style={{ overflow: "hidden" }}>
|
|
22
23
|
{/* Image de la catégorie : admin → catégorie → « Image de la catégorie » */}
|
|
23
24
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
24
|
-
<
|
|
25
|
+
<FadeImg src={c.metadata.image} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
|
25
26
|
</span>
|
|
26
27
|
) : (
|
|
27
28
|
<span className="ub-tile__img ub-ph" />
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, type ImgHTMLAttributes } from "react"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Image qui apparaît en fondu une fois chargée (le conteneur affiche un fond animé en attendant, cf. styles/ub/media.css).
|
|
7
|
+
* Chargement différé par défaut ; `priority` pour une image visible dès l'arrivée sur la page.
|
|
8
|
+
*/
|
|
9
|
+
export default function FadeImg({ priority, className = "", alt = "", ...props }: ImgHTMLAttributes<HTMLImageElement> & { priority?: boolean }) {
|
|
10
|
+
const ref = useRef<HTMLImageElement>(null)
|
|
11
|
+
const [loaded, setLoaded] = useState(false)
|
|
12
|
+
|
|
13
|
+
// Image déjà en cache (chargée avant l'hydratation) : pas d'événement load, on l'affiche directement
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (ref.current?.complete && ref.current.naturalWidth > 0) setLoaded(true)
|
|
16
|
+
}, [])
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
20
|
+
<img
|
|
21
|
+
ref={ref}
|
|
22
|
+
alt={alt}
|
|
23
|
+
loading={priority ? "eager" : "lazy"}
|
|
24
|
+
decoding="async"
|
|
25
|
+
fetchPriority={priority ? "high" : "auto"}
|
|
26
|
+
{...props}
|
|
27
|
+
className={`ub-fade${loaded ? " is-loaded" : ""}${className ? ` ${className}` : ""}`}
|
|
28
|
+
onLoad={(e) => {
|
|
29
|
+
setLoaded(true)
|
|
30
|
+
props.onLoad?.(e)
|
|
31
|
+
}}
|
|
32
|
+
onError={() => setLoaded(true)}
|
|
33
|
+
/>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Link from "next/link"
|
|
2
|
+
import { preload } from "react-dom"
|
|
2
3
|
import type { Campaign } from "@lib/boutique/campaign"
|
|
3
4
|
import { getBoutique } from "@lib/boutique/config"
|
|
4
5
|
import { t } from "@lib/boutique/i18n"
|
|
@@ -7,6 +8,7 @@ import { HttpTypes } from "@medusajs/types"
|
|
|
7
8
|
import { CampaignCountdown } from "./campaign-blocks"
|
|
8
9
|
import { Icon, type IconName } from "./icons"
|
|
9
10
|
|
|
11
|
+
import FadeImg from "./fade-img"
|
|
10
12
|
/**
|
|
11
13
|
* Accueil « Harvest » (thèmes Thanksgiving, theme.json → "home": "harvest") : structure différente de l'accueil
|
|
12
14
|
* standard. Hero plein cadre avec titre condensé géant + mot manuscrit et bas en vague (« tache »), services en ligne,
|
|
@@ -28,6 +30,8 @@ function Drip() {
|
|
|
28
30
|
export function HarvestHero({ campaign }: { campaign: Campaign }) {
|
|
29
31
|
const { hero } = campaign
|
|
30
32
|
const image = hero.image && /^https?:\/\//.test(hero.image) ? hero.image : undefined
|
|
33
|
+
// Image de fond (CSS) : préchargée en priorité pour qu'elle arrive avant le reste de la page
|
|
34
|
+
if (image) preload(image, { as: "image", fetchPriority: "high" })
|
|
31
35
|
// Mot manuscrit : en fin de titre, il passe sur sa propre ligne sous le titre condensé ; au milieu de la phrase,
|
|
32
36
|
// il reste à sa place (sinon la phrase devient incompréhensible).
|
|
33
37
|
const hl = hero.highlight && hero.title.includes(hero.highlight) ? hero.highlight : undefined
|
|
@@ -120,7 +124,7 @@ export function HarvestGrid({ products, title, sub }: { products: P[]; title: st
|
|
|
120
124
|
<span className="ub-hv-card__media">
|
|
121
125
|
{image ? (
|
|
122
126
|
// eslint-disable-next-line @next/next/no-img-element
|
|
123
|
-
<
|
|
127
|
+
<FadeImg src={image} alt="" />
|
|
124
128
|
) : (
|
|
125
129
|
<span className="ub-ph" />
|
|
126
130
|
)}
|
|
@@ -186,7 +190,7 @@ export function HarvestStrip({ products }: { products: P[] }) {
|
|
|
186
190
|
<li key={p.id}>
|
|
187
191
|
<Link href={`/products/${p.handle}`} aria-label={p.title ?? ""}>
|
|
188
192
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
189
|
-
<
|
|
193
|
+
<FadeImg src={imageOf(p)} alt="" />
|
|
190
194
|
</Link>
|
|
191
195
|
</li>
|
|
192
196
|
))}
|
|
@@ -4,6 +4,7 @@ import { getProductPrice } from "@lib/util/get-product-price"
|
|
|
4
4
|
import { HttpTypes } from "@medusajs/types"
|
|
5
5
|
import { Icon } from "./icons"
|
|
6
6
|
|
|
7
|
+
import FadeImg from "./fade-img"
|
|
7
8
|
/** Carte produit des maquettes. Prix calculés par Medusa pour la région de la boutique. */
|
|
8
9
|
export default function ProductCard({ product }: { product: HttpTypes.StoreProduct }) {
|
|
9
10
|
const { cheapestPrice: price } = getProductPrice({ product })
|
|
@@ -24,7 +25,7 @@ export default function ProductCard({ product }: { product: HttpTypes.StoreProdu
|
|
|
24
25
|
>
|
|
25
26
|
{image && (
|
|
26
27
|
// eslint-disable-next-line @next/next/no-img-element
|
|
27
|
-
<
|
|
28
|
+
<FadeImg src={image} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
|
28
29
|
)}
|
|
29
30
|
</Link>
|
|
30
31
|
<div className="ub-card__body">
|
|
@@ -4,6 +4,7 @@ import { getProductPrice } from "@lib/util/get-product-price"
|
|
|
4
4
|
import { HttpTypes } from "@medusajs/types"
|
|
5
5
|
import { Icon } from "./icons"
|
|
6
6
|
|
|
7
|
+
import FadeImg from "./fade-img"
|
|
7
8
|
type P = HttpTypes.StoreProduct
|
|
8
9
|
const imageOf = (p: P) => p.thumbnail ?? p.images?.[0]?.url ?? undefined
|
|
9
10
|
const priceOf = (p: P) => {
|
|
@@ -32,7 +33,7 @@ export function DealsFold({ products, title }: { products: P[]; title: string })
|
|
|
32
33
|
<Link href={`/products/${p.handle}`} className="ub-fold__link" aria-label={t("card.view", { name: p.title ?? "" })}>
|
|
33
34
|
{image ? (
|
|
34
35
|
// eslint-disable-next-line @next/next/no-img-element
|
|
35
|
-
<
|
|
36
|
+
<FadeImg className="ub-fold__img" src={image} alt="" />
|
|
36
37
|
) : (
|
|
37
38
|
<span className="ub-fold__img ub-ph" />
|
|
38
39
|
)}
|
|
@@ -92,7 +93,7 @@ export function DealsSpotlight({ products, eyebrow, title }: { products: P[]; ey
|
|
|
92
93
|
<div key={p.id} className="ub-spot__media" data-i={i}>
|
|
93
94
|
{image ? (
|
|
94
95
|
// eslint-disable-next-line @next/next/no-img-element
|
|
95
|
-
<
|
|
96
|
+
<FadeImg src={image} alt={p.title ?? ""} />
|
|
96
97
|
) : (
|
|
97
98
|
<span className="ub-ph" />
|
|
98
99
|
)}
|
|
@@ -196,7 +196,8 @@
|
|
|
196
196
|
.ub-hv-strip__row { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; }
|
|
197
197
|
.ub-hv-strip__row li { flex: 0 0 62%; scroll-snap-align: center; }
|
|
198
198
|
.ub-hv-head { flex-direction: column; align-items: flex-start; }
|
|
199
|
-
|
|
199
|
+
/* Mobile et tablette : pas de cadre (fixé à l'écran, il suivait le défilement et mangeait la place) */
|
|
200
|
+
[data-theme="thanksgiving"]:not([data-variant]) body::after { display: none; }
|
|
200
201
|
}
|
|
201
202
|
@media (prefers-reduced-motion: reduce) {
|
|
202
203
|
.ub-hv-title__main, .ub-hv-title__script { animation: none; }
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/* Chargement des images : fond animé (couleurs du thème) dans le cadre, puis apparition en fondu (FadeImg). */
|
|
2
|
+
.ub-fade {
|
|
3
|
+
opacity: 0;
|
|
4
|
+
transition: opacity 0.5s ease, transform 0.8s cubic-bezier(0.2, 0.7, 0.2, 1);
|
|
5
|
+
}
|
|
6
|
+
.ub-fade.is-loaded { opacity: 1; }
|
|
7
|
+
|
|
8
|
+
/* Cadres d'images : fond animé tant que la photo n'est pas affichée (masqué ensuite par l'image opaque) */
|
|
9
|
+
.ub-card__media,
|
|
10
|
+
.ub-fold__link,
|
|
11
|
+
.ub-spot__media,
|
|
12
|
+
.ub-hv-card__media,
|
|
13
|
+
.ub-hv-strip__row a,
|
|
14
|
+
.ub-tile__img {
|
|
15
|
+
background-color: var(--surface);
|
|
16
|
+
background-image: linear-gradient(100deg, transparent 20%, color-mix(in srgb, var(--tint-1) 80%, transparent) 45%, transparent 70%);
|
|
17
|
+
background-size: 220% 100%;
|
|
18
|
+
animation: ub-shimmer 1.6s ease-in-out infinite;
|
|
19
|
+
}
|
|
20
|
+
/* Une fois l'image chargée, plus d'animation (économie de batterie) */
|
|
21
|
+
.ub-card__media:has(.ub-fade.is-loaded),
|
|
22
|
+
.ub-fold__link:has(.ub-fade.is-loaded),
|
|
23
|
+
.ub-spot__media:has(.ub-fade.is-loaded),
|
|
24
|
+
.ub-hv-card__media:has(.ub-fade.is-loaded),
|
|
25
|
+
.ub-hv-strip__row a:has(.ub-fade.is-loaded),
|
|
26
|
+
.ub-tile__img:has(.ub-fade.is-loaded) {
|
|
27
|
+
animation: none;
|
|
28
|
+
background-image: none;
|
|
29
|
+
}
|
|
30
|
+
@keyframes ub-shimmer {
|
|
31
|
+
from { background-position: 120% 0; }
|
|
32
|
+
to { background-position: -120% 0; }
|
|
33
|
+
}
|
|
34
|
+
@media (prefers-reduced-motion: reduce) {
|
|
35
|
+
.ub-fade { transition: none; }
|
|
36
|
+
.ub-card__media, .ub-fold__link, .ub-spot__media, .ub-hv-card__media, .ub-hv-strip__row a, .ub-tile__img { animation: none; }
|
|
37
|
+
}
|