@salifbiaye/create-boutique 0.5.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 +241 -17
- package/cli/dist/create.js +108 -15
- package/cli/dist/generate.js +108 -39
- package/cli/dist/guide.js +12 -4
- package/cli/dist/index.js +38 -7
- package/cli/dist/payments.js +150 -0
- package/cli/dist/project.js +42 -3
- 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 +63 -5
- package/templates/backend/src/admin/i18n/json/fr.json +63 -5
- package/templates/backend/src/admin/routes/boutique-campaigns/campaign-drawer.tsx +29 -17
- package/templates/backend/src/admin/routes/boutique-campaigns/page.tsx +84 -6
- package/templates/backend/src/admin/routes/reviews/page.tsx +259 -0
- package/templates/backend/src/api/admin/active-boutique/route.ts +25 -0
- package/templates/backend/src/api/admin/boutique-campaigns/[salesChannelId]/route.ts +7 -1
- package/templates/backend/src/api/admin/boutique-campaigns/route.ts +9 -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 +9 -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/active.ts +41 -0
- 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 +38 -1
- 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/rewrite-file-urls.ts +56 -0
- package/templates/backend/src/scripts/seed.ts +60 -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)/products/[handle]/page.tsx +4 -1
- 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/config.ts +71 -23
- package/templates/storefront/src/lib/boutique/page-name.ts +25 -0
- package/templates/storefront/src/lib/boutique/schema.ts +7 -2
- package/templates/storefront/src/lib/config.ts +37 -8
- package/templates/storefront/src/lib/constants.tsx +17 -0
- package/templates/storefront/src/lib/data/cart.ts +4 -1
- package/templates/storefront/src/lib/data/cookies.ts +14 -2
- 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/checkout/components/shipping-address/index.tsx +6 -1
- 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 +4 -1
- 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 -2
- 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 +5 -8
- 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
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Migration } from "@medusajs/framework/mikro-orm/migrations";
|
|
2
|
+
|
|
3
|
+
// Écrite à la main (même forme que celles générées par `medusa db:generate productReview`) : table des avis.
|
|
4
|
+
export class Migration20261001090000 extends Migration {
|
|
5
|
+
|
|
6
|
+
override async up(): Promise<void> {
|
|
7
|
+
this.addSql(`create table if not exists "product_review" ("id" text not null, "product_id" text not null, "sales_channel_id" text not null, "order_id" text not null, "customer_email" text not null, "author_name" text not null, "rating" integer not null, "title" text null, "comment" text not null, "status" text check ("status" in ('pending', 'approved', 'rejected')) not null default 'pending', "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_review_pkey" primary key ("id"));`);
|
|
8
|
+
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_product_id" ON "product_review" ("product_id") WHERE deleted_at IS NULL;`);
|
|
9
|
+
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_sales_channel_id" ON "product_review" ("sales_channel_id") WHERE deleted_at IS NULL;`);
|
|
10
|
+
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_product_review_order_id_product_id_unique" ON "product_review" ("order_id", "product_id") WHERE deleted_at IS NULL;`);
|
|
11
|
+
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_deleted_at" ON "product_review" ("deleted_at") WHERE deleted_at IS NULL;`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
override async down(): Promise<void> {
|
|
15
|
+
this.addSql(`drop table if exists "product_review" cascade;`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { model } from "@medusajs/framework/utils"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Avis client sur un produit. Seul un acheteur peut en laisser un (lien signé de l'e-mail « commande livrée »),
|
|
5
|
+
* une fois par produit et par commande ; il n'est affiché qu'après validation dans l'admin (page Avis).
|
|
6
|
+
* sales_channel_id = boutique d'où vient la commande : l'admin filtre par boutique, et le site affiche les avis
|
|
7
|
+
* de toutes les boutiques (réglage « shared », défaut) ou seulement les siens (« boutique », project.json → defaults.reviews).
|
|
8
|
+
* customer_email n'est jamais renvoyé par les routes publiques.
|
|
9
|
+
*/
|
|
10
|
+
const Review = model
|
|
11
|
+
.define("product_review", {
|
|
12
|
+
id: model.id({ prefix: "prev" }).primaryKey(),
|
|
13
|
+
product_id: model.text().index(),
|
|
14
|
+
sales_channel_id: model.text().index(),
|
|
15
|
+
order_id: model.text(),
|
|
16
|
+
customer_email: model.text(),
|
|
17
|
+
author_name: model.text(),
|
|
18
|
+
rating: model.number(),
|
|
19
|
+
title: model.text().nullable(),
|
|
20
|
+
comment: model.text(),
|
|
21
|
+
status: model.enum(["pending", "approved", "rejected"]).default("pending"),
|
|
22
|
+
})
|
|
23
|
+
.indexes([{ on: ["order_id", "product_id"], unique: true }])
|
|
24
|
+
|
|
25
|
+
export default Review
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from "@medusajs/framework/zod"
|
|
2
|
+
|
|
3
|
+
/** Texte simple uniquement (aucun < ou >), espaces superflus retirés, longueur bornée */
|
|
4
|
+
const plain = (min: number, max: number) =>
|
|
5
|
+
z
|
|
6
|
+
.string()
|
|
7
|
+
.trim()
|
|
8
|
+
.min(min)
|
|
9
|
+
.max(max)
|
|
10
|
+
.refine((v) => !/[<>]/.test(v), "Texte simple uniquement (pas de HTML)")
|
|
11
|
+
|
|
12
|
+
/** POST /store/reviews : avis d'un acheteur (jeton du lien de l'e-mail « commande livrée ») */
|
|
13
|
+
export const ReviewInput = z.object({
|
|
14
|
+
token: z.string().min(10).max(300),
|
|
15
|
+
product_id: z.string().min(1).max(100),
|
|
16
|
+
rating: z.number().int().min(1).max(5),
|
|
17
|
+
title: plain(0, 80).optional(),
|
|
18
|
+
comment: plain(10, 1000),
|
|
19
|
+
author_name: plain(1, 40),
|
|
20
|
+
})
|
|
21
|
+
export type ReviewInput = z.infer<typeof ReviewInput>
|
|
22
|
+
|
|
23
|
+
/** POST /admin/reviews/:id : valider, refuser, remettre en attente */
|
|
24
|
+
export const ReviewStatusInput = z.object({
|
|
25
|
+
status: z.enum(["pending", "approved", "rejected"]),
|
|
26
|
+
})
|
|
27
|
+
export type ReviewStatusInput = z.infer<typeof ReviewStatusInput>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Logger, NotificationTypes } from "@medusajs/framework/types"
|
|
2
|
+
import { AbstractNotificationProviderService, MedusaError } from "@medusajs/framework/utils"
|
|
3
|
+
import nodemailer, { type Transporter } from "nodemailer"
|
|
4
|
+
|
|
5
|
+
type Options = {
|
|
6
|
+
host: string
|
|
7
|
+
/** 465 = SSL direct (Gmail), 587 = STARTTLS */
|
|
8
|
+
port: number
|
|
9
|
+
user: string
|
|
10
|
+
/** Mot de passe SMTP ; pour Gmail : mot de passe d'application (16 caractères), jamais le mot de passe du compte */
|
|
11
|
+
pass: string
|
|
12
|
+
/** Adresse « De : » ; pour Gmail, la même adresse que user (sinon Gmail la remplace ou le message est rejeté) */
|
|
13
|
+
from: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type InjectedDependencies = { logger: Logger }
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Envoi des e-mails par un serveur SMTP (Gmail avec mot de passe d'application, OVH, Brevo, Mailjet…),
|
|
20
|
+
* alternative à Resend. Même contrat que le provider Resend : le contenu est préparé par src/lib/emails.ts,
|
|
21
|
+
* data.fromName = nom de la boutique (nom affiché), data.replyTo = e-mail du marchand.
|
|
22
|
+
*/
|
|
23
|
+
class SmtpNotificationProviderService extends AbstractNotificationProviderService {
|
|
24
|
+
static identifier = "notification-smtp"
|
|
25
|
+
|
|
26
|
+
private transporter: Transporter
|
|
27
|
+
private options: Options
|
|
28
|
+
private logger: Logger
|
|
29
|
+
|
|
30
|
+
static validateOptions(options: Record<string, unknown>) {
|
|
31
|
+
for (const k of ["host", "user", "pass", "from"]) {
|
|
32
|
+
if (!options[k]) throw new MedusaError(MedusaError.Types.INVALID_DATA, `SMTP : option ${k} manquante`)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
constructor({ logger }: InjectedDependencies, options: Options) {
|
|
37
|
+
super()
|
|
38
|
+
this.options = options
|
|
39
|
+
this.logger = logger
|
|
40
|
+
this.transporter = nodemailer.createTransport({
|
|
41
|
+
host: options.host,
|
|
42
|
+
port: options.port,
|
|
43
|
+
secure: options.port === 465,
|
|
44
|
+
auth: { user: options.user, pass: options.pass },
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async send(notification: NotificationTypes.ProviderSendNotificationDTO): Promise<NotificationTypes.ProviderSendNotificationResultsDTO> {
|
|
49
|
+
const { content, data } = notification
|
|
50
|
+
if (!content?.subject || !content.html) {
|
|
51
|
+
throw new MedusaError(MedusaError.Types.INVALID_DATA, `SMTP : contenu manquant pour ${notification.template}`)
|
|
52
|
+
}
|
|
53
|
+
const fromName = typeof data?.fromName === "string" ? data.fromName.replace(/[<>"]/g, "") : undefined
|
|
54
|
+
const replyTo = typeof data?.replyTo === "string" ? data.replyTo : undefined
|
|
55
|
+
try {
|
|
56
|
+
const info = await this.transporter.sendMail({
|
|
57
|
+
from: fromName ? { name: fromName, address: this.options.from } : this.options.from,
|
|
58
|
+
to: notification.to,
|
|
59
|
+
subject: content.subject,
|
|
60
|
+
html: content.html,
|
|
61
|
+
text: content.text,
|
|
62
|
+
...(replyTo ? { replyTo } : {}),
|
|
63
|
+
})
|
|
64
|
+
return { id: info.messageId }
|
|
65
|
+
} catch (e) {
|
|
66
|
+
const message = (e as Error).message
|
|
67
|
+
this.logger.error(`[smtp] ${notification.template} → ${notification.to} : ${message}`)
|
|
68
|
+
throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, message)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export default SmtpNotificationProviderService
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `create-boutique urls` : réécrit l'adresse des images déjà en base quand l'adresse publique du stockage change
|
|
3
|
+
* (ex. http://localhost:9100/medusa → https://images.marque.com/medusa). Les fichiers ne bougent pas :
|
|
4
|
+
* seul le début de l'adresse enregistrée change. Variables : FILES_FROM (ancien préfixe), FILES_TO (nouveau).
|
|
5
|
+
* Concerne : photos et vignettes des produits, visuels et logo des campagnes, image de partage (Open Graph).
|
|
6
|
+
* Aucun SQL : services des modules Product et Campaign. Relançable : sans effet si plus rien ne commence par FILES_FROM.
|
|
7
|
+
*/
|
|
8
|
+
import { ExecArgs } from "@medusajs/framework/types"
|
|
9
|
+
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
|
|
10
|
+
import { CAMPAIGN_MODULE } from "../modules/campaign"
|
|
11
|
+
import CampaignModuleService from "../modules/campaign/service"
|
|
12
|
+
|
|
13
|
+
export default async function rewriteFileUrls({ container }: ExecArgs) {
|
|
14
|
+
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
|
|
15
|
+
const from = (process.env.FILES_FROM ?? "").replace(/\/+$/, "")
|
|
16
|
+
const to = (process.env.FILES_TO ?? "").replace(/\/+$/, "")
|
|
17
|
+
if (!/^https?:\/\//.test(from) || !/^https?:\/\//.test(to)) throw new Error("FILES_FROM et FILES_TO : adresses http(s) obligatoires")
|
|
18
|
+
if (from === to) return logger.info("[urls] rien à faire : même adresse")
|
|
19
|
+
|
|
20
|
+
// Remplace seulement le début exact (préfixe + « / ») : une autre adresse n'est jamais touchée.
|
|
21
|
+
const swap = <T extends string | null | undefined>(u: T): T => (typeof u === "string" && u.startsWith(`${from}/`) ? (`${to}${u.slice(from.length)}` as T) : u)
|
|
22
|
+
|
|
23
|
+
// ---------- Produits : vignette + photos (id conservés : les photos des variantes restent liées) ----------
|
|
24
|
+
const products = container.resolve(Modules.PRODUCT)
|
|
25
|
+
const list = await products.listProducts({}, { select: ["id", "thumbnail"], relations: ["images"], take: 100000 })
|
|
26
|
+
let productCount = 0
|
|
27
|
+
for (const p of list) {
|
|
28
|
+
const images = (p.images ?? []).filter((i) => swap(i.url) !== i.url)
|
|
29
|
+
if (swap(p.thumbnail) === p.thumbnail && !images.length) continue
|
|
30
|
+
await products.updateProducts(p.id, {
|
|
31
|
+
thumbnail: swap(p.thumbnail),
|
|
32
|
+
images: (p.images ?? []).map((i) => ({ id: i.id, url: swap(i.url) })),
|
|
33
|
+
})
|
|
34
|
+
productCount++
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------- Campagnes : hero, bannière promo, logo, image de partage ----------
|
|
38
|
+
const campaigns: CampaignModuleService = container.resolve(CAMPAIGN_MODULE)
|
|
39
|
+
let campaignCount = 0
|
|
40
|
+
for (const c of await campaigns.listBoutiqueCampaigns({})) {
|
|
41
|
+
const texts = (c.texts ?? {}) as Record<string, unknown> & { seo?: { image?: string } }
|
|
42
|
+
const seoImage = swap(texts.seo?.image)
|
|
43
|
+
const update = {
|
|
44
|
+
hero_image: swap(c.hero_image),
|
|
45
|
+
promo_image: swap(c.promo_image),
|
|
46
|
+
logo: swap(c.logo),
|
|
47
|
+
texts: seoImage !== texts.seo?.image ? { ...texts, seo: { ...texts.seo, image: seoImage } } : c.texts,
|
|
48
|
+
}
|
|
49
|
+
if (update.hero_image === c.hero_image && update.promo_image === c.promo_image && update.logo === c.logo && update.texts === c.texts) continue
|
|
50
|
+
await campaigns.updateBoutiqueCampaigns({ id: c.id, ...update })
|
|
51
|
+
campaignCount++
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
logger.info(`[urls] ${from} → ${to} : ${productCount} produit(s), ${campaignCount} campagne(s) mis à jour`)
|
|
55
|
+
process.stdout.write(`<<<URLS${JSON.stringify({ products: productCount, campaigns: campaignCount })}URLS>>>\n`)
|
|
56
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Seed idempotent, lancé à chaque démarrage du container backend (docker/entrypoint.sh).
|
|
3
3
|
* Lit config/project.json et crée seulement ce qui manque :
|
|
4
4
|
* - devises du store, une région par devise (un pays = une devise), régions de taxe ;
|
|
5
|
-
* - entrepôt, zone de livraison « Monde », options Standard / Express
|
|
5
|
+
* - entrepôt, zone de livraison « Monde », options Standard / Express créées au 1er démarrage seulement (ensuite : admin) ;
|
|
6
6
|
* - 1 sales channel + 1 publishable key par boutique, clé écrite dans KEYS_DIR/<slug> ;
|
|
7
7
|
* - produits de seed/products.csv (par handle) et images de seed/images.
|
|
8
8
|
* Aucun SQL : uniquement les workflows et services Medusa.
|
|
@@ -33,10 +33,12 @@ import {
|
|
|
33
33
|
uploadFilesWorkflow,
|
|
34
34
|
} from "@medusajs/medusa/core-flows"
|
|
35
35
|
import { CONFIG_DIR, KEYS_DIR, SEED_DIR, countriesByCurrency, readProject, shippingRates } from "../lib/project"
|
|
36
|
+
import { getActive, writeActiveFile } from "../lib/active"
|
|
36
37
|
import { CAMPAIGN_MODULE } from "../modules/campaign"
|
|
37
38
|
import CampaignModuleService from "../modules/campaign/service"
|
|
38
39
|
import { CampaignFile, CampaignInput } from "../modules/campaign/validators"
|
|
39
40
|
import { upsertCampaignWorkflow } from "../workflows/upsert-campaign"
|
|
41
|
+
import { regionPaymentProviders } from "../lib/payment-config"
|
|
40
42
|
|
|
41
43
|
const LOCATION_NAME = "Entrepôt principal"
|
|
42
44
|
const FULFILLMENT_SET_NAME = "Livraison"
|
|
@@ -62,6 +64,10 @@ export default async function seed({ container }: ExecArgs) {
|
|
|
62
64
|
// ---------- Store : devises ----------
|
|
63
65
|
const [store] = await storeService.listStores({}, { relations: ["supported_currencies"] })
|
|
64
66
|
const knownCurrencies = new Set(store.supported_currencies?.map((c) => c.currency_code))
|
|
67
|
+
// Nom affiché en haut de l'admin : celui du projet (pas « Medusa Store »)
|
|
68
|
+
if (store.name === "Medusa Store" || !store.name) {
|
|
69
|
+
await updateStoresWorkflow(container).run({ input: { selector: { id: store.id }, update: { name: project.boutiques[0].storeName } } })
|
|
70
|
+
}
|
|
65
71
|
if (currencies.some((c) => !knownCurrencies.has(c)) || !store.supported_currencies?.find((c) => c.is_default && c.currency_code === project.defaultCurrency)) {
|
|
66
72
|
await updateStoresWorkflow(container).run({
|
|
67
73
|
input: {
|
|
@@ -77,7 +83,6 @@ export default async function seed({ container }: ExecArgs) {
|
|
|
77
83
|
// ---------- Régions : une par devise ----------
|
|
78
84
|
const allCountries = (await regionService.listCountries({}, { take: 500 })).map((c) => c.iso_2)
|
|
79
85
|
const target = countriesByCurrency(project, allCountries)
|
|
80
|
-
const paymentProviders = ["pp_system_default", ...(process.env.STRIPE_API_KEY ? ["pp_stripe_stripe"] : [])]
|
|
81
86
|
const existing = await regionService.listRegions({}, { relations: ["countries"] })
|
|
82
87
|
const managed = new Map(existing.filter((r) => currencies.includes(r.currency_code) && r.name === r.currency_code.toUpperCase()).map((r) => [r.currency_code, r]))
|
|
83
88
|
|
|
@@ -101,11 +106,11 @@ export default async function seed({ container }: ExecArgs) {
|
|
|
101
106
|
const region = managed.get(currency)
|
|
102
107
|
if (region) {
|
|
103
108
|
await updateRegionsWorkflow(container).run({
|
|
104
|
-
input: { selector: { id: region.id }, update: { countries: [...countries], payment_providers:
|
|
109
|
+
input: { selector: { id: region.id }, update: { countries: [...countries], payment_providers: regionPaymentProviders(currency) } },
|
|
105
110
|
})
|
|
106
111
|
} else {
|
|
107
112
|
await createRegionsWorkflow(container).run({
|
|
108
|
-
input: { regions: [{ name: currency.toUpperCase(), currency_code: currency, countries: [...countries], payment_providers:
|
|
113
|
+
input: { regions: [{ name: currency.toUpperCase(), currency_code: currency, countries: [...countries], payment_providers: regionPaymentProviders(currency) }] },
|
|
109
114
|
})
|
|
110
115
|
}
|
|
111
116
|
logger.info(`[seed] région ${currency.toUpperCase()} : ${countries.size} pays`)
|
|
@@ -158,45 +163,57 @@ export default async function seed({ container }: ExecArgs) {
|
|
|
158
163
|
{ attribute: "enabled_in_store", value: "true", operator: "eq" as const },
|
|
159
164
|
{ attribute: "is_return", value: "false", operator: "eq" as const },
|
|
160
165
|
]
|
|
161
|
-
|
|
166
|
+
const DEFAULT_OPTIONS = [
|
|
162
167
|
[0, "Standard", "standard", "3-7 business days"],
|
|
163
168
|
[1, "Express", "express", "1-3 business days"],
|
|
164
|
-
] as const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
] as const
|
|
170
|
+
// Prix liés via le module Pricing : demandés par query.graph mais absents du type généré.
|
|
171
|
+
type PricedOption = { id: string; name: string; prices?: { id: string; amount: number; currency_code: string }[] }
|
|
172
|
+
|
|
173
|
+
// Standard et Express ne sont créées qu'une fois (1er démarrage). Ensuite, c'est l'admin qui décide : le marchand peut
|
|
174
|
+
// les renommer, changer les prix, en supprimer une, en ajouter d'autres ; le seed ne les recrée jamais.
|
|
175
|
+
// (Projet existant : des options déjà présentes valent « déjà fait ».)
|
|
176
|
+
const shippingSeeded = store.metadata?.ub_shipping_seeded === true || options.length > 0
|
|
177
|
+
if (!shippingSeeded) {
|
|
178
|
+
await createShippingOptionsWorkflow(container).run({
|
|
179
|
+
input: DEFAULT_OPTIONS.map(([i, name, code, description]) => ({
|
|
180
|
+
name,
|
|
181
|
+
price_type: "flat" as const,
|
|
182
|
+
provider_id: "manual_manual",
|
|
183
|
+
service_zone_id: zone.id,
|
|
184
|
+
shipping_profile_id: profile.id,
|
|
185
|
+
type: { label: name, description, code },
|
|
186
|
+
prices: currencies.map((c) => ({ currency_code: c, amount: shippingRates(c)[i] })),
|
|
187
|
+
rules,
|
|
188
|
+
})),
|
|
189
|
+
})
|
|
190
|
+
await updateStoresWorkflow(container).run({
|
|
191
|
+
input: { selector: { id: store.id }, update: { metadata: { ...(store.metadata ?? {}), ub_shipping_seeded: true } } },
|
|
192
|
+
})
|
|
193
|
+
} else {
|
|
194
|
+
if (store.metadata?.ub_shipping_seeded !== true) {
|
|
195
|
+
await updateStoresWorkflow(container).run({
|
|
196
|
+
input: { selector: { id: store.id }, update: { metadata: { ...(store.metadata ?? {}), ub_shipping_seeded: true } } },
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
// Nouvelle devise (boutique ajoutée) : prix ajouté aux options existantes, quel que soit leur nom (tarif Express
|
|
200
|
+
// pour une option nommée « Express », tarif Standard sinon). Les prix déjà fixés dans l'admin ne sont pas touchés.
|
|
201
|
+
for (const option of options as PricedOption[]) {
|
|
202
|
+
const tier = /express/i.test(option.name) ? 1 : 0
|
|
203
|
+
const priced = new Set((option.prices ?? []).map((p) => p.currency_code))
|
|
204
|
+
const add = currencies.filter((c) => !priced.has(c))
|
|
205
|
+
if (!add.length) continue
|
|
206
|
+
await updateShippingOptionsWorkflow(container).run({
|
|
171
207
|
input: [
|
|
172
208
|
{
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
type: { label: name, description, code },
|
|
179
|
-
prices: currencies.map((c) => ({ currency_code: c, amount: shippingRates(c)[i] })),
|
|
180
|
-
rules,
|
|
209
|
+
id: option.id,
|
|
210
|
+
prices: [
|
|
211
|
+
...(option.prices ?? []).map((p) => ({ id: p.id, amount: p.amount })),
|
|
212
|
+
...add.map((c) => ({ currency_code: c, amount: shippingRates(c)[tier] })),
|
|
213
|
+
],
|
|
181
214
|
},
|
|
182
215
|
],
|
|
183
216
|
})
|
|
184
|
-
} else {
|
|
185
|
-
const priced = new Set((option.prices ?? []).map((p) => p.currency_code))
|
|
186
|
-
const add = currencies.filter((c) => !priced.has(c))
|
|
187
|
-
if (add.length) {
|
|
188
|
-
await updateShippingOptionsWorkflow(container).run({
|
|
189
|
-
input: [
|
|
190
|
-
{
|
|
191
|
-
id: option.id,
|
|
192
|
-
prices: [
|
|
193
|
-
...(option.prices ?? []).map((p) => ({ id: p.id, amount: p.amount })),
|
|
194
|
-
...add.map((c) => ({ currency_code: c, amount: shippingRates(c)[i] })),
|
|
195
|
-
],
|
|
196
|
-
},
|
|
197
|
-
],
|
|
198
|
-
})
|
|
199
|
-
}
|
|
200
217
|
}
|
|
201
218
|
}
|
|
202
219
|
|
|
@@ -245,6 +262,13 @@ export default async function seed({ container }: ExecArgs) {
|
|
|
245
262
|
})
|
|
246
263
|
}
|
|
247
264
|
|
|
265
|
+
// ---------- Mode single : boutique en ligne (la première au départ, ensuite celle choisie dans l'admin) ----------
|
|
266
|
+
if (project.mode === "single") {
|
|
267
|
+
const active = await getActive(container)
|
|
268
|
+
writeActiveFile(active)
|
|
269
|
+
logger.info(`[seed] mode single : boutique en ligne ${active}`)
|
|
270
|
+
}
|
|
271
|
+
|
|
248
272
|
// ---------- Campagnes : valeur initiale depuis config/campaigns/<slug>.json ----------
|
|
249
273
|
// Importée seulement si la boutique n'a pas encore de campagne : une campagne modifiée
|
|
250
274
|
// dans l'admin (source de vérité = base) n'est jamais écrasée.
|
|
@@ -24,7 +24,7 @@ export default async function fulfillmentEmails({
|
|
|
24
24
|
if (!orderId) return
|
|
25
25
|
|
|
26
26
|
if (name === "delivery.created") {
|
|
27
|
-
await sendOrderEmail(container, orderId, "order_delivered")
|
|
27
|
+
await sendOrderEmail(container, orderId, "order_delivered", { reviewLink: true })
|
|
28
28
|
return
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
|
|
2
|
+
import { type CreateReviewData, createReviewStep } from "./steps/create-review"
|
|
3
|
+
import { deleteReviewStep, updateReviewStatusStep } from "./steps/update-review"
|
|
4
|
+
|
|
5
|
+
/** Avis d'un acheteur (lien signé de l'e-mail « commande livrée ») : en attente de validation. */
|
|
6
|
+
export const createReviewWorkflow = createWorkflow("create-review", function (input: CreateReviewData) {
|
|
7
|
+
return new WorkflowResponse(createReviewStep(input))
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
/** Admin → Avis : valider, refuser, remettre en attente. */
|
|
11
|
+
export const updateReviewStatusWorkflow = createWorkflow(
|
|
12
|
+
"update-review-status",
|
|
13
|
+
function (input: { id: string; status: "pending" | "approved" | "rejected" }) {
|
|
14
|
+
return new WorkflowResponse(updateReviewStatusStep(input))
|
|
15
|
+
}
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
/** Admin → Avis : supprimer. */
|
|
19
|
+
export const deleteReviewWorkflow = createWorkflow("delete-review", function (input: { id: string }) {
|
|
20
|
+
return new WorkflowResponse(deleteReviewStep(input.id))
|
|
21
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ContainerRegistrationKeys, MedusaError } from "@medusajs/framework/utils"
|
|
2
|
+
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
|
|
3
|
+
import { PRODUCT_REVIEW_MODULE } from "../../modules/product-review"
|
|
4
|
+
import ProductReviewModuleService from "../../modules/product-review/service"
|
|
5
|
+
|
|
6
|
+
export type CreateReviewData = {
|
|
7
|
+
order_id: string
|
|
8
|
+
product_id: string
|
|
9
|
+
rating: number
|
|
10
|
+
title?: string
|
|
11
|
+
comment: string
|
|
12
|
+
author_name: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Crée un avis « en attente » après vérifications : la commande existe, le produit en fait partie,
|
|
17
|
+
* pas déjà d'avis pour ce produit dans cette commande. Boutique et e-mail pris dans la commande (jamais du client).
|
|
18
|
+
* Rollback : supprime l'avis créé.
|
|
19
|
+
*/
|
|
20
|
+
export const createReviewStep = createStep(
|
|
21
|
+
"create-review",
|
|
22
|
+
async (input: CreateReviewData, { container }) => {
|
|
23
|
+
const query = container.resolve(ContainerRegistrationKeys.QUERY)
|
|
24
|
+
const { data } = await query.graph({
|
|
25
|
+
entity: "order",
|
|
26
|
+
fields: ["id", "email", "sales_channel_id", "status", "items.product_id"],
|
|
27
|
+
filters: { id: input.order_id },
|
|
28
|
+
})
|
|
29
|
+
const order = data[0]
|
|
30
|
+
if (!order || order.status === "canceled") throw new MedusaError(MedusaError.Types.NOT_FOUND, "Commande introuvable")
|
|
31
|
+
if (!(order.items ?? []).some((i) => i?.product_id === input.product_id)) {
|
|
32
|
+
throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Ce produit ne fait pas partie de la commande")
|
|
33
|
+
}
|
|
34
|
+
const service: ProductReviewModuleService = container.resolve(PRODUCT_REVIEW_MODULE)
|
|
35
|
+
const [already] = await service.listReviews({ order_id: order.id, product_id: input.product_id })
|
|
36
|
+
if (already) throw new MedusaError(MedusaError.Types.DUPLICATE_ERROR, "Un avis existe déjà pour ce produit")
|
|
37
|
+
|
|
38
|
+
const review = await service.createReviews({
|
|
39
|
+
...input,
|
|
40
|
+
title: input.title || null,
|
|
41
|
+
sales_channel_id: order.sales_channel_id ?? "",
|
|
42
|
+
customer_email: order.email ?? "",
|
|
43
|
+
status: "pending",
|
|
44
|
+
})
|
|
45
|
+
return new StepResponse(review, review.id)
|
|
46
|
+
},
|
|
47
|
+
async (id, { container }) => {
|
|
48
|
+
if (!id) return
|
|
49
|
+
const service: ProductReviewModuleService = container.resolve(PRODUCT_REVIEW_MODULE)
|
|
50
|
+
await service.deleteReviews(id)
|
|
51
|
+
}
|
|
52
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
|
|
2
|
+
import { PRODUCT_REVIEW_MODULE } from "../../modules/product-review"
|
|
3
|
+
import ProductReviewModuleService from "../../modules/product-review/service"
|
|
4
|
+
|
|
5
|
+
type Status = "pending" | "approved" | "rejected"
|
|
6
|
+
|
|
7
|
+
/** Change le statut d'un avis (valider, refuser) ; rollback : statut précédent. */
|
|
8
|
+
export const updateReviewStatusStep = createStep(
|
|
9
|
+
"update-review-status",
|
|
10
|
+
async (input: { id: string; status: Status }, { container }) => {
|
|
11
|
+
const service: ProductReviewModuleService = container.resolve(PRODUCT_REVIEW_MODULE)
|
|
12
|
+
const previous = await service.retrieveReview(input.id)
|
|
13
|
+
const updated = await service.updateReviews({ id: input.id, status: input.status })
|
|
14
|
+
return new StepResponse(updated, { id: input.id, status: previous.status as Status })
|
|
15
|
+
},
|
|
16
|
+
async (prev, { container }) => {
|
|
17
|
+
if (!prev) return
|
|
18
|
+
const service: ProductReviewModuleService = container.resolve(PRODUCT_REVIEW_MODULE)
|
|
19
|
+
await service.updateReviews(prev)
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
/** Supprime un avis (suppression douce) ; rollback : restauration. */
|
|
24
|
+
export const deleteReviewStep = createStep(
|
|
25
|
+
"delete-review",
|
|
26
|
+
async (id: string, { container }) => {
|
|
27
|
+
const service: ProductReviewModuleService = container.resolve(PRODUCT_REVIEW_MODULE)
|
|
28
|
+
await service.softDeleteReviews(id)
|
|
29
|
+
return new StepResponse(id, id)
|
|
30
|
+
},
|
|
31
|
+
async (id, { container }) => {
|
|
32
|
+
if (!id) return
|
|
33
|
+
const service: ProductReviewModuleService = container.resolve(PRODUCT_REVIEW_MODULE)
|
|
34
|
+
await service.restoreReviews(id)
|
|
35
|
+
}
|
|
36
|
+
)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Personnaliser l'apparence d'un projet
|
|
2
|
+
|
|
3
|
+
Tout se fait dans **`storefront/src/styles/custom.css`** (+ images et polices dans `storefront/public/custom/`).
|
|
4
|
+
Ces deux emplacements sont **conservés par `create-boutique update`** ; le reste du code est remplacé à chaque mise à jour.
|
|
5
|
+
Après une modification : `docker compose up -d --build`.
|
|
6
|
+
|
|
7
|
+
Ce fichier-ci (`PERSONNALISER.md`) est remis à jour par `update` : il décrit toujours la version installée.
|
|
8
|
+
|
|
9
|
+
## 1. Ce que custom.css permet, et ce qu'il ne permet pas
|
|
10
|
+
|
|
11
|
+
| Oui, avec custom.css | Non : il faut changer le code du modèle |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| Couleurs, polices, tailles, espacements, arrondis, ombres | Ajouter une nouvelle section ou un nouveau texte |
|
|
14
|
+
| Masquer un élément (`display: none`) | Changer ce qu'affiche une section (autres produits, autre logique) |
|
|
15
|
+
| Réordonner des blocs d'une même rangée (`order`, `flex-direction`) | Ajouter un champ au paiement, une page, un lien de menu |
|
|
16
|
+
| Image de fond, motif, décoration (`::before`, `::after`, `url("/custom/…")`) | Modifier un e-mail, une règle de prix, de livraison |
|
|
17
|
+
| Cibler une seule boutique, un thème, une page, l'ordinateur ou le mobile | |
|
|
18
|
+
|
|
19
|
+
Les **textes** (titres, sous-titres, bandeau, bouton, dates, visuels) se changent dans l'**admin → Mes boutiques**, pas ici.
|
|
20
|
+
Un besoin de la colonne de droite : on le fait dans le modèle (dépôt create-boutique), puis `create-boutique update` sur les projets.
|
|
21
|
+
Modifier directement un autre fichier du projet reste possible, mais `update` le remplacera (il le liste et le sauvegarde
|
|
22
|
+
dans `backups/` avant).
|
|
23
|
+
|
|
24
|
+
## 2. Cibler
|
|
25
|
+
|
|
26
|
+
| Quoi | Sélecteur |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| Tout le projet | directement : `.ub-card { … }` |
|
|
29
|
+
| Une boutique | `[data-boutique="mon-slug"] .ub-card { … }` |
|
|
30
|
+
| Toutes les boutiques d'un thème | `[data-theme="halloween"] …` ; variante 2 : `[data-variant="halloween-2"] …` |
|
|
31
|
+
| Un événement (les 2 variantes) | `[data-event="noel"] …` |
|
|
32
|
+
| Une page (son contenu) | `[data-page="product"] .ub-pdp__title { … }` |
|
|
33
|
+
| L'en-tête ou le pied d'une page précise | `:root:has([data-page="home"]) .ub-nav { … }` |
|
|
34
|
+
| Ordinateur seulement | `@media (min-width: 1024px) { … }` |
|
|
35
|
+
| Mobile seulement | `@media (max-width: 600px) { … }` |
|
|
36
|
+
|
|
37
|
+
Pages (`data-page`) : `home`, `store` (catalogue), `search` (résultats de recherche), `product`, `category`, `collection`,
|
|
38
|
+
`cart`, `checkout`, `account`, `order` (confirmation), `track` (suivi de commande), `contact`, `shipping`, `returns`,
|
|
39
|
+
`privacy`, `terms`.
|
|
40
|
+
|
|
41
|
+
## 3. Couleurs et polices du thème (le plus simple)
|
|
42
|
+
|
|
43
|
+
Changer une variable change tout le site d'un coup :
|
|
44
|
+
|
|
45
|
+
```css
|
|
46
|
+
[data-boutique="yout-noel"] {
|
|
47
|
+
--primary: #b91c1c; /* boutons, liens, accents */
|
|
48
|
+
--on-primary: #fff; /* texte sur --primary */
|
|
49
|
+
--accent: #f59e0b;
|
|
50
|
+
--bg: #fffaf5; /* fond des pages */
|
|
51
|
+
--surface: #ffffff; /* cartes, blocs */
|
|
52
|
+
--ink: #1f2937; /* texte */
|
|
53
|
+
--ink-muted: #6b7280; /* texte secondaire */
|
|
54
|
+
--border: #e5e7eb;
|
|
55
|
+
--sale: #dc2626; /* prix barrés, remises */
|
|
56
|
+
--hero-bg: #0f172a; --hero-ink: #fff; /* hero de l'accueil */
|
|
57
|
+
--topbar-bg: #111; --topbar-ink: #fff; /* bandeau tout en haut */
|
|
58
|
+
--ub-r-card: 16px; --ub-r-btn: 999px; /* arrondis des cartes / boutons */
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Police à vous : mettre le fichier dans `public/custom/`, puis
|
|
63
|
+
`@font-face { font-family: "Ma police"; src: url("/custom/ma-police.woff2") format("woff2"); }`
|
|
64
|
+
et `[data-boutique="…"] { --ub-font-head: "Ma police", serif; }` (titres) ou `--ub-font-body` (texte).
|
|
65
|
+
|
|
66
|
+
## 4. Plan du site : quelle classe pour quoi
|
|
67
|
+
|
|
68
|
+
**Partout**
|
|
69
|
+
|
|
70
|
+
| Élément | Classes |
|
|
71
|
+
| --- | --- |
|
|
72
|
+
| Bandeau du haut (messages défilants) | `.ub-topbar` |
|
|
73
|
+
| En-tête | `.ub-nav`, logo `.ub-logo`, menu `.ub-nav__links`, recherche `.ub-search`, icônes `.ub-nav__icons`, bouton `.ub-nav__cta` |
|
|
74
|
+
| Pied de page | bloc d'appel `.ub-footcta` (`__eyebrow`, `__sub`, `__btn`), pied `.ub-footer` (`__top`, `__bot`), moyens de paiement `.ub-pays` |
|
|
75
|
+
| Carte produit (toutes les listes) | `.ub-card`, image, `.ub-badge` (remise), `.ub-card__name`, `.ub-card__cat`, prix `.ub-price__now` / `.ub-price__was`, bouton panier `.ub-card__cart` |
|
|
76
|
+
| Grille de produits | `.ub-grid` |
|
|
77
|
+
| Bandeau cookies | `.ub-cookies` (`__title`, `__btn`) |
|
|
78
|
+
| Page « boutique fermée » | `.ub-closed` |
|
|
79
|
+
| Largeur du contenu | `.ub-wrap` |
|
|
80
|
+
|
|
81
|
+
**Accueil** (`data-page="home"`) : chaque thème a sa propre mise en page, préfixée :
|
|
82
|
+
|
|
83
|
+
| Thème (`home`) | Préfixe | Thème (`home`) | Préfixe |
|
|
84
|
+
| --- | --- | --- | --- |
|
|
85
|
+
| black-friday (default) | `.ub-hero`, `.ub-fold`, `.ub-spot`, `.ub-tiles` | summer (riviera) | `.ub-rv-` |
|
|
86
|
+
| cyber-monday (tech) | `.ub-tc-` | summer-2 (sunset) | `.ub-ss-` |
|
|
87
|
+
| halloween (cinema) | `.ub-cn-` | sport (locker) | `.ub-lk-` |
|
|
88
|
+
| halloween-2 (grimoire) | `.ub-gr-` | sport-2 (training) | `.ub-tr-` |
|
|
89
|
+
| noel (album) | `.ub-al-` | jeux (arena) | `.ub-ar-` |
|
|
90
|
+
| nouvel-an (gala) | `.ub-gl-` | jeux-2 (mosaic) | `.ub-mo-` |
|
|
91
|
+
| thanksgiving (harvest) | `.ub-hv-` | mubarak (lantern) | `.ub-ln-` |
|
|
92
|
+
| | | mubarak-2 (souk) | `.ub-sk-` |
|
|
93
|
+
|
|
94
|
+
Exemples : titre du hero Riviera `.ub-rv-title`, image du hero de tous les thèmes `.ub-heroimg`, compte à rebours `.ub-countdown`.
|
|
95
|
+
|
|
96
|
+
**Catalogue et recherche** (`store`, `search`, `category`, `collection`) : bandeau `.ub-shophero` (`__title`, `__sub`),
|
|
97
|
+
barre de filtres `.ub-shop__bar`, catégories `.ub-chips` / `.ub-chip`, recherche `.ub-shopsearch`, tri `.ub-sort` / `.ub-sort__opt`,
|
|
98
|
+
nombre de produits `.ub-shop__count`, aucun résultat `.ub-shop__empty`.
|
|
99
|
+
|
|
100
|
+
**Produit** (`product`) : `.ub-pdp`, titre `.ub-pdp__title`, sous-titre `.ub-pdp__subtitle`, infos `.ub-pdp__info`,
|
|
101
|
+
description `.ub-pdp__desc`, garanties `.ub-pdp__trust`, questions `.ub-faq`.
|
|
102
|
+
|
|
103
|
+
**Panier, paiement, confirmation** (`cart`, `checkout`, `order`) : composants de Medusa ; cibler la page puis l'attribut
|
|
104
|
+
`data-testid`, ex. `[data-page="checkout"] [data-testid="submit-order-button"] { … }`.
|
|
105
|
+
|
|
106
|
+
**Compte** (`account`) : `.ub-acc` (tableau de bord), menu `.ub-accnav`, commandes `.ub-acc__orders` / `.ub-acc__order`.
|
|
107
|
+
|
|
108
|
+
**Contact** (`contact`) : bandeau `.ub-shophero`, cartes `.ub-contact__cards` / `.ub-contact__card`, questions `.ub-contact__faq`.
|
|
109
|
+
|
|
110
|
+
**Pages légales** (`shipping`, `returns`, `privacy`, `terms`) : `.ub-legal`, sommaire `.ub-legal__toc`, sections `.ub-legal__sec`.
|
|
111
|
+
|
|
112
|
+
## 5. Trouver la classe d'un élément précis
|
|
113
|
+
|
|
114
|
+
Sur la boutique, clic droit sur l'élément → **Inspecter** (ou F12) : la classe apparaît (`class="ub-…"`).
|
|
115
|
+
Ne pas s'appuyer sur les classes utilitaires sans préfixe `ub-` (ex. `flex`, `mt-4`) : elles peuvent changer d'une version à l'autre.
|
|
116
|
+
|
|
117
|
+
## 6. Exemples
|
|
118
|
+
|
|
119
|
+
```css
|
|
120
|
+
/* Masquer le bandeau défilant du haut sur une boutique */
|
|
121
|
+
[data-boutique="yout-noel"] .ub-topbar { display: none; }
|
|
122
|
+
|
|
123
|
+
/* Titres produits plus grands, sur la page produit seulement */
|
|
124
|
+
[data-page="product"] .ub-pdp__title { font-size: 44px; }
|
|
125
|
+
|
|
126
|
+
/* En-tête transparent sur l'accueil uniquement */
|
|
127
|
+
:root:has([data-page="home"]) .ub-nav { background: transparent; }
|
|
128
|
+
|
|
129
|
+
/* Motif de fond sur toutes les boutiques du thème Noël */
|
|
130
|
+
[data-event="noel"] body { background-image: url("/custom/flocons.png"); }
|
|
131
|
+
|
|
132
|
+
/* Cartes produits sans ombre, coins carrés, sur ordinateur */
|
|
133
|
+
@media (min-width: 1024px) { .ub-card { box-shadow: none; border-radius: 0; } }
|
|
134
|
+
```
|