@salifbiaye/create-boutique 0.1.4 → 0.1.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.
@@ -168,6 +168,7 @@ create-boutique check . # a test order in each store: OK / KO</
168
168
  <li><b>Sale price (strikethrough)</b>: <span class="menu">Price Lists → Create</span>, type <b>Sale</b>, start and end dates, add the products and the sale price per currency. The site shows the old price struck through and the percentage.</li>
169
169
  <li><b>Prices for a group</b> (e.g. VIP customers, resellers): price list of type <b>Override</b> limited to a customer group (B11).</li>
170
170
  </ul>
171
+ <div class="box warn">A price list is not limited to one store: the sale price applies in <b>every store</b> that sells the product (in the same currency). To discount a single store whose products are also sold elsewhere, use a promotion with a <b>Sales channel</b> condition (B10).</div>
171
172
 
172
173
  <h2>B6. Categories and collections</h2>
173
174
  <ul>
@@ -204,6 +205,7 @@ create-boutique check . # a test order in each store: OK / KO</
204
205
  </table>
205
206
  <p>An empty field uses the theme text. <b>Reset to theme texts</b> clears your texts (images, logo and dates are kept). Plain text only; images jpg, png or webp, 5 MB max.</p>
206
207
  <div class="box info">Not to be confused with Medusa's <span class="menu">Promotions → Campaigns</span>, which groups discount codes with a budget (B10).</div>
208
+ <div class="box warn">Campaign texts and dates only <b>display</b> the offer ("up to −35%", countdown). They apply <b>no discount</b>: create the discount itself in <span class="menu">Promotions</span> (B10) or <span class="menu">Price Lists</span> (B5), with the same dates, and check that the text matches.</div>
207
209
 
208
210
  <h2>B10. Discount codes and promotions</h2>
209
211
  <p><span class="menu">Promotions → Create</span>:</p>
@@ -220,6 +222,15 @@ create-boutique check . # a test order in each store: OK / KO</
220
222
  <li><b>Limits and dates</b>: attach the promotion to a <b>promotion campaign</b> (start/end dates, budget in number of uses or amount).</li>
221
223
  <li>Disable: set the promotion to <b>Draft</b>.</li>
222
224
  </ul>
225
+ <h3>Discount on a whole store (e.g. −35% on the New Year store)</h3>
226
+ <ol>
227
+ <li><span class="menu">Promotions → Create</span>, type <b>Automatic</b> (no code) or <b>Code</b>.</li>
228
+ <li>Template <b>Percentage off order</b>, value <b>35</b>.</li>
229
+ <li>Conditions ("who can use it"): <b>Sales channel</b> = the store identifier (first page).</li>
230
+ <li>Attach it to a promotion campaign with a <b>start and end date</b> (the same as the store campaign, B9).</li>
231
+ <li>Check: a product in this store's cart shows the −35% line; in another store, no discount.</li>
232
+ </ol>
233
+ <p>The discount covers every item in the cart (not shipping) and shows <b>in the cart</b>. To show a <b>strikethrough price</b> on product pages, use a "Sale" price list instead (B5). To exclude products: template <b>Percentage off products</b> targeting a category or collection (e.g. "Sale").</p>
223
234
  <div class="box warn">An amount discount (e.g. −10) applies in the chosen currency: create one promotion per currency if needed. Test each code in the right store before announcing it.</div>
224
235
 
225
236
  <h2>B11. Customers and groups</h2>
@@ -168,6 +168,7 @@ create-boutique check . # une commande test dans chaque boutiqu
168
168
  <li><b>Prix barré (soldes)</b> : <span class="menu">Listes de prix → Créer</span>, type <b>Soldes (Sale)</b>, dates de début et de fin, ajouter les produits et le prix soldé par devise. Le site affiche l'ancien prix barré et le pourcentage.</li>
169
169
  <li><b>Prix réservés à un groupe</b> (ex. clients VIP, revendeurs) : liste de prix de type <b>Remplacement (Override)</b> limitée à un groupe de clients (B11).</li>
170
170
  </ul>
171
+ <div class="box warn">Une liste de prix ne se limite pas à une boutique : le prix soldé s'applique dans <b>toutes les boutiques</b> qui vendent le produit (dans la même devise). Pour solder une seule boutique alors que ses produits sont aussi vendus ailleurs, utilisez une promotion avec la condition <b>Canal de vente</b> (B10).</div>
171
172
 
172
173
  <h2>B6. Catégories et collections</h2>
173
174
  <ul>
@@ -204,6 +205,7 @@ create-boutique check . # une commande test dans chaque boutiqu
204
205
  </table>
205
206
  <p>Un champ laissé vide reprend le texte du thème. <b>Réinitialiser aux textes du thème</b> efface vos textes (images, logo et dates sont gardés). Texte simple uniquement ; images jpg, png ou webp, 5 Mo max.</p>
206
207
  <div class="box info">Ne pas confondre avec <span class="menu">Promotions → Campagnes</span> de Medusa, qui regroupe des codes promo avec un budget (B10).</div>
208
+ <div class="box warn">Les textes et les dates de campagne ne font qu'<b>afficher</b> l'offre (« jusqu'à −35 % », compte à rebours). Ils n'appliquent <b>aucune remise</b> : créez la remise elle-même dans <span class="menu">Promotions</span> (B10) ou <span class="menu">Listes de prix</span> (B5), avec les mêmes dates, et vérifiez que le texte correspond.</div>
207
209
 
208
210
  <h2>B10. Codes promo et promotions</h2>
209
211
  <p><span class="menu">Promotions → Créer</span> :</p>
@@ -220,6 +222,15 @@ create-boutique check . # une commande test dans chaque boutiqu
220
222
  <li><b>Limites et dates</b> : rattacher la promotion à une <b>campagne de promotion</b> (dates de début/fin, budget en nombre d'utilisations ou en montant).</li>
221
223
  <li>Désactiver : passer la promotion en <b>Brouillon</b>.</li>
222
224
  </ul>
225
+ <h3>Remise sur toute une boutique (ex. −35 % sur la boutique Nouvel An)</h3>
226
+ <ol>
227
+ <li><span class="menu">Promotions → Créer</span>, type <b>Automatique</b> (sans code) ou <b>Code</b>.</li>
228
+ <li>Modèle <b>Pourcentage sur la commande</b>, valeur <b>35</b>.</li>
229
+ <li>Conditions (« qui peut l'utiliser ») : <b>Canal de vente</b> = l'identifiant de la boutique (première page).</li>
230
+ <li>Rattacher à une campagne de promotion avec <b>date de début et de fin</b> (les mêmes que la campagne de la boutique, B9).</li>
231
+ <li>Vérifier : un produit au panier de cette boutique affiche la ligne −35 % ; dans une autre boutique, aucune remise.</li>
232
+ </ol>
233
+ <p>La remise porte sur tous les articles du panier (pas sur la livraison) et apparaît <b>dans le panier</b>. Pour afficher un <b>prix barré</b> sur les fiches produits, utilisez plutôt une liste de prix « Soldes » (B5). Pour exclure des produits : modèle <b>Pourcentage sur des produits</b> ciblant une catégorie ou une collection (ex. « Soldes »).</p>
223
234
  <div class="box warn">Une remise en montant (ex. −10) s'applique dans la devise choisie : créez une promotion par devise si besoin. Testez chaque code dans la bonne boutique avant de l'annoncer.</div>
224
235
 
225
236
  <h2>B11. Clients et groupes</h2>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salifbiaye/create-boutique",
3
- "version": "0.1.4",
3
+ "version": "0.1.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" },
@@ -155,10 +155,15 @@
155
155
  "checkout.paymentMethod": "Payment method",
156
156
  "checkout.paymentDetails": "Payment details",
157
157
  "checkout.giftCard": "Gift card",
158
+ "checkout.cardAndWallets": "Card, Apple Pay, Google Pay",
159
+ "checkout.manualPayment": "Manual payment",
158
160
  "checkout.continueReview": "Continue to review",
159
- "checkout.enterCard": "Enter card details",
161
+ "checkout.enterCard": "Enter payment details",
160
162
  "checkout.anotherStep": "Another step will appear",
161
- "checkout.cardDetails": "Enter your card details:",
163
+ "checkout.cardDetails": "Choose how to pay:",
164
+ "checkout.card": "Card",
165
+ "checkout.paymentPending": "Payment is still being processed. Refresh this page in a moment.",
166
+ "checkout.paymentFailed": "The payment was not completed. Please try again or choose another method.",
162
167
  "checkout.selectPayment": "Select a payment method",
163
168
  "checkout.placeOrder": "Place order",
164
169
  "checkout.review": "Review",
@@ -155,10 +155,15 @@
155
155
  "checkout.paymentMethod": "Moyen de paiement",
156
156
  "checkout.paymentDetails": "Détails du paiement",
157
157
  "checkout.giftCard": "Carte cadeau",
158
+ "checkout.cardAndWallets": "Carte, Apple Pay, Google Pay",
159
+ "checkout.manualPayment": "Paiement manuel",
158
160
  "checkout.continueReview": "Vérifier la commande",
159
- "checkout.enterCard": "Saisir les données de carte",
161
+ "checkout.enterCard": "Saisir le paiement",
160
162
  "checkout.anotherStep": "Une étape supplémentaire va s'afficher",
161
- "checkout.cardDetails": "Saisissez vos données de carte :",
163
+ "checkout.cardDetails": "Choisissez votre moyen de paiement :",
164
+ "checkout.card": "Carte",
165
+ "checkout.paymentPending": "Le paiement est encore en cours de traitement. Actualisez la page dans un instant.",
166
+ "checkout.paymentFailed": "Le paiement n’a pas abouti. Réessayez ou choisissez un autre moyen de paiement.",
162
167
  "checkout.selectPayment": "Choisissez un moyen de paiement",
163
168
  "checkout.placeOrder": "Valider la commande",
164
169
  "checkout.review": "Vérification",
@@ -1,5 +1,6 @@
1
1
  import React from "react"
2
2
  import { CreditCard } from "@medusajs/icons"
3
+ import { T } from "@lib/boutique/i18n-client"
3
4
 
4
5
  import Ideal from "@modules/common/icons/ideal"
5
6
  import Bancontact from "@modules/common/icons/bancontact"
@@ -8,14 +9,14 @@ import PayPal from "@modules/common/icons/paypal"
8
9
  /* Map of payment provider_id to their title and icon. Add in any payment providers you want to use. */
9
10
  export const paymentInfoMap: Record<
10
11
  string,
11
- { title: string; icon: React.JSX.Element }
12
+ { title: React.ReactNode; icon: React.JSX.Element }
12
13
  > = {
13
14
  pp_stripe_stripe: {
14
- title: "Credit card",
15
+ title: <T k="checkout.cardAndWallets" />,
15
16
  icon: <CreditCard />,
16
17
  },
17
18
  "pp_medusa-payments_default": {
18
- title: "Credit card",
19
+ title: <T k="checkout.cardAndWallets" />,
19
20
  icon: <CreditCard />,
20
21
  },
21
22
  "pp_stripe-ideal_stripe": {
@@ -31,7 +32,7 @@ export const paymentInfoMap: Record<
31
32
  icon: <PayPal />,
32
33
  },
33
34
  pp_system_default: {
34
- title: "Manual Payment",
35
+ title: <T k="checkout.manualPayment" />,
35
36
  icon: <CreditCard />,
36
37
  },
37
38
  // Add more payment providers here
@@ -10,6 +10,7 @@ import { Button, Container, Heading, Text, clx } from "@medusajs/ui"
10
10
  import ErrorMessage from "@modules/checkout/components/error-message"
11
11
  import PaymentContainer, {
12
12
  StripeCardContainer,
13
+ paymentTypeLabel,
13
14
  } from "@modules/checkout/components/payment-container"
14
15
  import Divider from "@modules/common/components/divider"
15
16
  import { usePathname, useRouter, useSearchParams } from "next/navigation"
@@ -232,7 +233,7 @@ const Payment = ({
232
233
  </Container>
233
234
  <Text>
234
235
  {isStripeLike(selectedPaymentMethod) && cardBrand
235
- ? cardBrand
236
+ ? paymentTypeLabel(cardBrand)
236
237
  : <T k="checkout.anotherStep" />}
237
238
  </Text>
238
239
  </div>
@@ -7,7 +7,8 @@ import { placeOrder } from "@lib/data/cart"
7
7
  import { HttpTypes } from "@medusajs/types"
8
8
  import { Button } from "@medusajs/ui"
9
9
  import { useElements, useStripe } from "@stripe/react-stripe-js"
10
- import React, { useState } from "react"
10
+ import { useSearchParams } from "next/navigation"
11
+ import React, { useEffect, useRef, useState } from "react"
11
12
  import ErrorMessage from "../error-message"
12
13
 
13
14
  type PaymentButtonProps = {
@@ -27,8 +28,15 @@ const PaymentButton: React.FC<PaymentButtonProps> = ({
27
28
  (cart.shipping_methods?.length ?? 0) < 1
28
29
 
29
30
  const paymentSession = cart.payment_collection?.payment_sessions?.[0]
31
+ const redirectStatus = useSearchParams().get("redirect_status")
30
32
 
31
33
  switch (true) {
34
+ case isStripeLike(paymentSession?.provider_id) && !!redirectStatus:
35
+ return (
36
+ <StripeRedirectReturn status={redirectStatus!}>
37
+ <Button size="large" disabled><T k="checkout.placeOrder" /></Button>
38
+ </StripeRedirectReturn>
39
+ )
32
40
  case isStripeLike(paymentSession?.provider_id):
33
41
  return (
34
42
  <StripePaymentButton
@@ -46,6 +54,32 @@ const PaymentButton: React.FC<PaymentButtonProps> = ({
46
54
  }
47
55
  }
48
56
 
57
+ /** Retour d'un moyen de paiement à redirection (Klarna, banque…) : Stripe renvoie sur /checkout?step=review&redirect_status=… */
58
+ const StripeRedirectReturn = ({ status, children }: { status: string; children: React.ReactNode }) => {
59
+ const [errorMessage, setErrorMessage] = useState<string | null>(null)
60
+ const started = useRef(false)
61
+
62
+ useEffect(() => {
63
+ if (status !== "succeeded" || started.current) return
64
+ started.current = true
65
+ placeOrder().catch((err) => setErrorMessage(err.message))
66
+ }, [status])
67
+
68
+ if (status === "succeeded" && !errorMessage) return <Button size="large" isLoading disabled><T k="checkout.placeOrder" /></Button>
69
+ return (
70
+ <>
71
+ {children}
72
+ {errorMessage ? (
73
+ <ErrorMessage error={errorMessage} data-testid="stripe-payment-error-message" />
74
+ ) : (
75
+ <p className="text-small-regular text-ui-fg-subtle mt-2" role="status">
76
+ <T k={status === "processing" ? "checkout.paymentPending" : "checkout.paymentFailed"} />
77
+ </p>
78
+ )}
79
+ </>
80
+ )
81
+ }
82
+
49
83
  const StripePaymentButton = ({
50
84
  cart,
51
85
  notReady,
@@ -70,68 +104,51 @@ const StripePaymentButton = ({
70
104
 
71
105
  const stripe = useStripe()
72
106
  const elements = useElements()
73
- const card = elements?.getElement("card")
74
-
75
- const session = cart.payment_collection?.payment_sessions?.find(
76
- (s) => s.status === "pending"
77
- )
78
107
 
79
108
  const disabled = !stripe || !elements ? true : false
80
109
 
110
+ // Stripe Payment Element : carte, Apple Pay, Google Pay… Aucune donnée de paiement ne passe par nos serveurs ;
111
+ // le montant est celui du PaymentIntent créé par Medusa. Pas de redirection sauf moyen qui l'exige.
81
112
  const handlePayment = async () => {
82
113
  setSubmitting(true)
114
+ setErrorMessage(null)
83
115
 
84
- if (!stripe || !elements || !card || !cart) {
116
+ if (!stripe || !elements || !cart) {
85
117
  setSubmitting(false)
86
118
  return
87
119
  }
88
120
 
89
- await stripe
90
- .confirmCardPayment(session?.data.client_secret as string, {
91
- payment_method: {
92
- card: card,
121
+ const address = cart.billing_address
122
+ const { error, paymentIntent } = await stripe.confirmPayment({
123
+ elements,
124
+ redirect: "if_required",
125
+ confirmParams: {
126
+ return_url: `${window.location.origin}/checkout?step=review`,
127
+ payment_method_data: {
93
128
  billing_details: {
94
- name:
95
- cart.billing_address?.first_name +
96
- " " +
97
- cart.billing_address?.last_name,
129
+ name: [address?.first_name, address?.last_name].filter(Boolean).join(" ") || undefined,
130
+ email: cart.email ?? undefined,
131
+ phone: address?.phone || undefined,
98
132
  address: {
99
- city: cart.billing_address?.city ?? undefined,
100
- country: cart.billing_address?.country_code ?? undefined,
101
- line1: cart.billing_address?.address_1 ?? undefined,
102
- line2: cart.billing_address?.address_2 ?? undefined,
103
- postal_code: cart.billing_address?.postal_code ?? undefined,
104
- state: cart.billing_address?.province ?? undefined,
133
+ city: address?.city ?? undefined,
134
+ country: address?.country_code ?? undefined,
135
+ line1: address?.address_1 ?? undefined,
136
+ line2: address?.address_2 ?? undefined,
137
+ postal_code: address?.postal_code ?? undefined,
138
+ state: address?.province ?? undefined,
105
139
  },
106
- email: cart.email,
107
- phone: cart.billing_address?.phone ?? undefined,
108
140
  },
109
141
  },
110
- })
111
- .then(({ error, paymentIntent }) => {
112
- if (error) {
113
- const pi = error.payment_intent
114
-
115
- if (
116
- (pi && pi.status === "requires_capture") ||
117
- (pi && pi.status === "succeeded")
118
- ) {
119
- onPaymentCompleted()
120
- }
121
-
122
- setErrorMessage(error.message || null)
123
- return
124
- }
125
-
126
- if (
127
- (paymentIntent && paymentIntent.status === "requires_capture") ||
128
- paymentIntent.status === "succeeded"
129
- ) {
130
- return onPaymentCompleted()
131
- }
132
-
133
- return
134
- })
142
+ },
143
+ })
144
+
145
+ const intent = paymentIntent ?? (error && "payment_intent" in error ? error.payment_intent : undefined)
146
+ if (intent && (intent.status === "succeeded" || intent.status === "requires_capture")) {
147
+ return onPaymentCompleted()
148
+ }
149
+
150
+ setErrorMessage(error?.message ?? null)
151
+ setSubmitting(false)
135
152
  }
136
153
 
137
154
  return (
@@ -1,14 +1,13 @@
1
1
  import { T } from "@lib/boutique/i18n-client"
2
2
  import { Radio as RadioGroupOption } from "@headlessui/react"
3
3
  import { Text, clx } from "@medusajs/ui"
4
- import React, { useContext, useMemo, type JSX } from "react"
4
+ import React, { useContext, type JSX } from "react"
5
5
 
6
6
  import Radio from "@modules/common/components/radio"
7
7
 
8
8
  import { isManual } from "@lib/constants"
9
9
  import SkeletonCardDetails from "@modules/skeletons/components/skeleton-card-details"
10
- import { CardElement } from "@stripe/react-stripe-js"
11
- import { StripeCardElementOptions } from "@stripe/stripe-js"
10
+ import { PaymentElement } from "@stripe/react-stripe-js"
12
11
  import PaymentTest from "../payment-test"
13
12
  import { StripeContext } from "../payment-wrapper/stripe-wrapper"
14
13
 
@@ -16,7 +15,7 @@ type PaymentContainerProps = {
16
15
  paymentProviderId: string
17
16
  selectedPaymentOptionId: string | null
18
17
  disabled?: boolean
19
- paymentInfoMap: Record<string, { title: string; icon: JSX.Element }>
18
+ paymentInfoMap: Record<string, { title: React.ReactNode; icon: JSX.Element }>
20
19
  children?: React.ReactNode
21
20
  }
22
21
 
@@ -66,6 +65,19 @@ const PaymentContainer: React.FC<PaymentContainerProps> = ({
66
65
 
67
66
  export default PaymentContainer
68
67
 
68
+ /** Noms affichés des moyens de paiement Stripe (Payment Element) ; « card » est traduit. */
69
+ const WALLET_NAMES: Record<string, string> = {
70
+ apple_pay: "Apple Pay",
71
+ google_pay: "Google Pay",
72
+ link: "Link",
73
+ klarna: "Klarna",
74
+ paypal: "PayPal",
75
+ }
76
+
77
+ export const paymentTypeLabel = (type: string | null) =>
78
+ !type ? null : type === "card" ? <T k="checkout.card" /> : WALLET_NAMES[type] ?? type
79
+
80
+ /** Stripe Payment Element : carte, Apple Pay, Google Pay… selon l'appareil et les moyens activés dans Stripe. */
69
81
  export const StripeCardContainer = ({
70
82
  paymentProviderId,
71
83
  selectedPaymentOptionId,
@@ -75,29 +87,12 @@ export const StripeCardContainer = ({
75
87
  setError,
76
88
  setCardComplete,
77
89
  }: Omit<PaymentContainerProps, "children"> & {
78
- setCardBrand: (brand: string) => void
90
+ setCardBrand: (type: string | null) => void
79
91
  setError: (error: string | null) => void
80
92
  setCardComplete: (complete: boolean) => void
81
93
  }) => {
82
94
  const stripeReady = useContext(StripeContext)
83
95
 
84
- const useOptions: StripeCardElementOptions = useMemo(() => {
85
- return {
86
- style: {
87
- base: {
88
- fontFamily: "inherit",
89
- color: "#424270",
90
- "::placeholder": {
91
- color: "rgb(107 114 128)",
92
- },
93
- },
94
- },
95
- classes: {
96
- base: "pt-3 pb-1 block w-full h-11 px-4 mt-0 bg-ui-bg-field border rounded-md appearance-none focus:outline-none focus:ring-0 focus:shadow-borders-interactive-with-active border-ui-border-base hover:bg-ui-bg-field-hover transition-all duration-300 ease-in-out",
97
- },
98
- }
99
- }, [])
100
-
101
96
  return (
102
97
  <PaymentContainer
103
98
  paymentProviderId={paymentProviderId}
@@ -108,18 +103,17 @@ export const StripeCardContainer = ({
108
103
  {selectedPaymentOptionId === paymentProviderId &&
109
104
  (stripeReady ? (
110
105
  <div className="my-4 transition-all duration-150 ease-in-out">
111
- <Text className="txt-medium-plus text-ui-fg-base mb-1">
106
+ <Text className="txt-medium-plus text-ui-fg-base mb-3">
112
107
  <T k="checkout.cardDetails" />
113
108
  </Text>
114
- <CardElement
115
- options={useOptions as StripeCardElementOptions}
109
+ <PaymentElement
110
+ options={{ layout: { type: "tabs", defaultCollapsed: false }, wallets: { applePay: "auto", googlePay: "auto" } }}
116
111
  onChange={(e) => {
117
- setCardBrand(
118
- e.brand && e.brand.charAt(0).toUpperCase() + e.brand.slice(1)
119
- )
120
- setError(e.error?.message || null)
112
+ setCardBrand(e.value?.type ?? null)
113
+ setError(null)
121
114
  setCardComplete(e.complete)
122
115
  }}
116
+ onLoadError={(e) => setError(e.error?.message ?? null)}
123
117
  />
124
118
  </div>
125
119
  ) : (
@@ -1,9 +1,9 @@
1
1
  "use client"
2
2
 
3
- import { Stripe, StripeElementsOptions } from "@stripe/stripe-js"
3
+ import { Appearance, Stripe, StripeElementLocale, StripeElementsOptions } from "@stripe/stripe-js"
4
4
  import { Elements } from "@stripe/react-stripe-js"
5
5
  import { HttpTypes } from "@medusajs/types"
6
- import { createContext } from "react"
6
+ import { createContext, useEffect, useState } from "react"
7
7
 
8
8
  type StripeWrapperProps = {
9
9
  paymentSession: HttpTypes.StorePaymentSession
@@ -14,15 +14,53 @@ type StripeWrapperProps = {
14
14
 
15
15
  export const StripeContext = createContext(false)
16
16
 
17
+ /** Valeur calculée (rgb…) d'une variable CSS du thème : Stripe n'accepte pas var(--x) ni color-mix. */
18
+ function themeColor(name: string): string | undefined {
19
+ const probe = document.createElement("span")
20
+ probe.style.color = `var(${name})`
21
+ document.body.appendChild(probe)
22
+ const value = getComputedStyle(probe).color
23
+ probe.remove()
24
+ return value || undefined
25
+ }
26
+
27
+ function isDark(rgb?: string): boolean {
28
+ const [r, g, b] = (rgb?.match(/\d+(\.\d+)?/g) ?? []).map(Number)
29
+ if ([r, g, b].some((v) => v === undefined)) return false
30
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b < 128
31
+ }
32
+
33
+ /** Bloc de paiement Stripe aux couleurs du thème de la boutique (lu dans les variables CSS de data-theme). */
34
+ function themeAppearance(): Appearance {
35
+ const background = themeColor("--bg")
36
+ const radius = getComputedStyle(document.documentElement).getPropertyValue("--ub-r-card").trim()
37
+ return {
38
+ theme: isDark(background) ? "night" : "stripe",
39
+ variables: {
40
+ colorPrimary: themeColor("--primary"),
41
+ colorBackground: themeColor("--surface") ?? background,
42
+ colorText: themeColor("--ink"),
43
+ colorTextSecondary: themeColor("--ink-muted"),
44
+ colorDanger: "#df1b41",
45
+ fontFamily: getComputedStyle(document.body).fontFamily,
46
+ borderRadius: radius && radius.endsWith("px") ? radius : "8px",
47
+ },
48
+ }
49
+ }
50
+
17
51
  const StripeWrapper: React.FC<StripeWrapperProps> = ({
18
52
  paymentSession,
19
53
  stripeKey,
20
54
  stripePromise,
21
55
  children,
22
56
  }) => {
23
- const options: StripeElementsOptions = {
24
- clientSecret: paymentSession!.data?.client_secret as string | undefined,
25
- }
57
+ // Calculé après montage (document indisponible au rendu serveur) ; Elements applique la mise à jour.
58
+ const [appearance, setAppearance] = useState<Appearance | undefined>()
59
+ const [locale, setLocale] = useState<StripeElementLocale>("auto")
60
+ useEffect(() => {
61
+ setAppearance(themeAppearance())
62
+ setLocale((document.documentElement.lang || "auto") as StripeElementLocale)
63
+ }, [])
26
64
 
27
65
  if (!stripeKey) {
28
66
  throw new Error(
@@ -42,9 +80,16 @@ const StripeWrapper: React.FC<StripeWrapperProps> = ({
42
80
  )
43
81
  }
44
82
 
83
+ const options: StripeElementsOptions = {
84
+ clientSecret: paymentSession.data.client_secret as string,
85
+ appearance,
86
+ locale,
87
+ }
88
+
45
89
  return (
46
90
  <StripeContext.Provider value={true}>
47
- <Elements options={options} stripe={stripePromise}>
91
+ {/* key : un nouveau PaymentIntent (panier modifié) recrée les Elements */}
92
+ <Elements key={options.clientSecret} options={options} stripe={stripePromise}>
48
93
  {children}
49
94
  </Elements>
50
95
  </StripeContext.Provider>
@@ -24,12 +24,12 @@ function contentSecurityPolicy(nonce: string) {
24
24
 
25
25
  return [
26
26
  "default-src 'self'",
27
- `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://js.stripe.com${isDev ? " 'unsafe-eval'" : ""}`,
27
+ `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://js.stripe.com https://*.js.stripe.com${isDev ? " 'unsafe-eval'" : ""}`,
28
28
  "style-src 'self' 'unsafe-inline'",
29
29
  `img-src 'self' data: blob: ${files} https://*.stripe.com`,
30
30
  "font-src 'self'",
31
31
  `connect-src 'self' https://api.stripe.com https://m.stripe.network https://*.stripe.com${isDev ? " ws:" : ""}`,
32
- "frame-src https://js.stripe.com https://hooks.stripe.com",
32
+ "frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com",
33
33
  "frame-ancestors 'none'",
34
34
  "form-action 'self'",
35
35
  "base-uri 'self'",
@@ -68,7 +68,7 @@ export function proxy(request: NextRequest) {
68
68
  response.headers.set("X-Frame-Options", "DENY")
69
69
  response.headers.set("X-Content-Type-Options", "nosniff")
70
70
  response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin")
71
- response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(self \"https://js.stripe.com\")")
71
+ response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(self \"https://js.stripe.com\" \"https://*.js.stripe.com\")")
72
72
  if (request.headers.get("x-forwarded-proto") === "https" || request.nextUrl.protocol === "https:") {
73
73
  response.headers.set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
74
74
  }