@agence-zef/astro 0.1.0 → 0.3.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/README.md +1 -1
- package/components/AccountForm.astro +253 -0
- package/components/AddToCart.astro +107 -0
- package/components/Cart.astro +141 -0
- package/components/Checkout.astro +307 -0
- package/components/ReservedNotice.astro +33 -0
- package/components/Subscriptions.astro +91 -0
- package/components/VideoPlayer.astro +71 -0
- package/components/routes/account.ts +23 -0
- package/components/routes/reserved.astro +54 -0
- package/components/routes/shop.ts +21 -0
- package/components/virtual.d.ts +4 -0
- package/dist/accounts.d.ts +65 -0
- package/dist/accounts.js +301 -0
- package/dist/helpers.d.ts +9 -1
- package/dist/helpers.js +28 -3
- package/dist/index.d.ts +22 -0
- package/dist/index.js +48 -1
- package/dist/middleware.d.ts +7 -0
- package/dist/middleware.js +20 -0
- package/dist/shop.d.ts +62 -0
- package/dist/shop.js +384 -0
- package/package.json +26 -4
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getAccount } from "@agence-zef/astro/accounts";
|
|
3
|
+
/**
|
|
4
|
+
* Paiement d'une commande, sans style imposé, en trois étapes (sans JavaScript jusqu'au paiement) :
|
|
5
|
+
* 1. adresse de livraison (`/__zef/shop/address`, gardée une heure dans un cookie) ;
|
|
6
|
+
* 2. livraison (`?etape=livraison`) : modes proposés pour le pays et le poids du panier (Sendcloud,
|
|
7
|
+
* retrait sur place), point relais parmi les plus proches, conditions générales de vente ; le CMS
|
|
8
|
+
* crée la commande, réserve le stock 30 minutes et la facture (`/__zef/shop/checkout`) ;
|
|
9
|
+
* 3. paiement (`?etape=paiement`) : Payment Element de Stripe (JavaScript, habillé par les
|
|
10
|
+
* variables CSS du site via `appearance`), ou « Payer (simulation) » en développement local.
|
|
11
|
+
* Classes `zef-checkout__*`. Page protégée (compte connecté), rendue à la demande.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
checkoutAddress,
|
|
15
|
+
formatPrice,
|
|
16
|
+
getCart,
|
|
17
|
+
getServicePoints,
|
|
18
|
+
getShippingOptions,
|
|
19
|
+
pendingPayment,
|
|
20
|
+
SHOP_ERRORS,
|
|
21
|
+
} from "@agence-zef/astro/shop";
|
|
22
|
+
|
|
23
|
+
interface Props {
|
|
24
|
+
/** Page des conditions générales de vente, liée à la case à cocher. */
|
|
25
|
+
termsHref: string;
|
|
26
|
+
/** Page de confirmation (`?commande=<id>`). */
|
|
27
|
+
confirmationHref?: string;
|
|
28
|
+
cartHref?: string;
|
|
29
|
+
class?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const {
|
|
33
|
+
termsHref,
|
|
34
|
+
confirmationHref = "/commande",
|
|
35
|
+
cartHref = "/panier",
|
|
36
|
+
class: className,
|
|
37
|
+
} = Astro.props;
|
|
38
|
+
const params = Astro.url.searchParams;
|
|
39
|
+
const saved = checkoutAddress(Astro);
|
|
40
|
+
const asked = params.get("etape");
|
|
41
|
+
const step =
|
|
42
|
+
asked === "paiement" ? "payment" : asked === "livraison" && saved ? "shipping" : "address";
|
|
43
|
+
const errorCode = params.get("zef_shop_error");
|
|
44
|
+
const account = await getAccount(Astro);
|
|
45
|
+
const cart = step !== "payment" ? await getCart(Astro, Astro.currentLocale) : null;
|
|
46
|
+
const options = step === "shipping" && saved ? await getShippingOptions(Astro, saved.country) : [];
|
|
47
|
+
const points = Object.fromEntries(
|
|
48
|
+
await Promise.all(
|
|
49
|
+
options
|
|
50
|
+
.filter((o) => o.servicePoint && saved)
|
|
51
|
+
.map(
|
|
52
|
+
async (o) =>
|
|
53
|
+
[
|
|
54
|
+
o.id,
|
|
55
|
+
await getServicePoints(Astro, {
|
|
56
|
+
option: o.id,
|
|
57
|
+
country: saved?.country ?? "FR",
|
|
58
|
+
postalCode: saved?.postalCode ?? "",
|
|
59
|
+
}),
|
|
60
|
+
] as const,
|
|
61
|
+
),
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
const payment = step === "payment" ? pendingPayment(Astro) : null;
|
|
65
|
+
// Rien à livrer (cartes cadeaux, accès) : ni adresse ni livraison, les conditions puis le paiement.
|
|
66
|
+
const giftsOnly = !!cart && cart.lines.length > 0 && !cart.shippingRequired;
|
|
67
|
+
const returnUrl = payment
|
|
68
|
+
? new URL(`${confirmationHref}?commande=${payment.orderId}`, Astro.url.origin).toString()
|
|
69
|
+
: "";
|
|
70
|
+
const address = saved ?? {
|
|
71
|
+
name: account ? `${account.firstName} ${account.lastName}` : "",
|
|
72
|
+
line1: "",
|
|
73
|
+
line2: "",
|
|
74
|
+
postalCode: "",
|
|
75
|
+
city: "",
|
|
76
|
+
country: "FR",
|
|
77
|
+
phone: account?.phone ?? "",
|
|
78
|
+
};
|
|
79
|
+
const COUNTRIES = [
|
|
80
|
+
["FR", "France"],
|
|
81
|
+
["BE", "Belgique"],
|
|
82
|
+
["LU", "Luxembourg"],
|
|
83
|
+
["CH", "Suisse"],
|
|
84
|
+
["MC", "Monaco"],
|
|
85
|
+
];
|
|
86
|
+
const price = (o: { kind: string; price: number; free: boolean }) =>
|
|
87
|
+
o.kind === "pickup" ? "Gratuit" : o.free || o.price === 0 ? "Offerte" : formatPrice(o.price);
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
<div class:list={["zef-checkout", className]} data-step={step}>
|
|
91
|
+
{errorCode && <p class="zef-checkout__error" role="alert">{SHOP_ERRORS[errorCode] ?? SHOP_ERRORS.unknown}</p>}
|
|
92
|
+
{
|
|
93
|
+
step === "address" &&
|
|
94
|
+
(!cart || cart.lines.length === 0 ? (
|
|
95
|
+
<p class="zef-checkout__empty">
|
|
96
|
+
Votre panier est vide. <a href={cartHref}>Retour au panier</a>
|
|
97
|
+
</p>
|
|
98
|
+
) : giftsOnly ? (
|
|
99
|
+
<form method="post" action="/__zef/shop/checkout" class="zef-checkout__gifts">
|
|
100
|
+
<input type="hidden" name="zef_page" value={Astro.url.pathname} />
|
|
101
|
+
<input type="hidden" name="zef_locale" value={Astro.currentLocale ?? ""} />
|
|
102
|
+
<input type="hidden" name="giftsOnly" value="1" />
|
|
103
|
+
<p class="zef-checkout__summary">
|
|
104
|
+
{cart.count} article{cart.count > 1 ? "s" : ""} · <strong>{formatPrice(cart.total)}</strong> TTC ·{" "}
|
|
105
|
+
<a href={cartHref}>Modifier le panier</a>
|
|
106
|
+
</p>
|
|
107
|
+
<p class="zef-checkout__notice">Rien à livrer : les cartes cadeaux partent par email, les accès s'ouvrent dans votre compte.</p>
|
|
108
|
+
<label class="zef-checkout__terms">
|
|
109
|
+
<input type="checkbox" name="acceptTerms" required />
|
|
110
|
+
J'accepte les <a href={termsHref} target="_blank">conditions générales de vente</a>.
|
|
111
|
+
</label>
|
|
112
|
+
<button type="submit" class="zef-checkout__submit">Continuer vers le paiement</button>
|
|
113
|
+
</form>
|
|
114
|
+
) : (
|
|
115
|
+
<form method="post" action="/__zef/shop/address" class="zef-checkout__address">
|
|
116
|
+
<input type="hidden" name="zef_page" value={Astro.url.pathname} />
|
|
117
|
+
<p class="zef-checkout__summary">
|
|
118
|
+
{cart.count} article{cart.count > 1 ? "s" : ""} · <strong>{formatPrice(cart.total)}</strong> TTC ·{" "}
|
|
119
|
+
<a href={cartHref}>Modifier le panier</a>
|
|
120
|
+
</p>
|
|
121
|
+
<fieldset class="zef-checkout__fields">
|
|
122
|
+
<legend>Adresse de livraison</legend>
|
|
123
|
+
<label>
|
|
124
|
+
Nom
|
|
125
|
+
<input name="name" required autocomplete="name" value={address.name} />
|
|
126
|
+
</label>
|
|
127
|
+
<label>
|
|
128
|
+
Adresse
|
|
129
|
+
<input name="line1" required autocomplete="address-line1" value={address.line1} />
|
|
130
|
+
</label>
|
|
131
|
+
<label>
|
|
132
|
+
Complément <span class="zef-checkout__optional">(facultatif)</span>
|
|
133
|
+
<input name="line2" autocomplete="address-line2" value={address.line2 ?? ""} />
|
|
134
|
+
</label>
|
|
135
|
+
<label>
|
|
136
|
+
Code postal
|
|
137
|
+
<input name="postalCode" required autocomplete="postal-code" inputmode="numeric" value={address.postalCode} />
|
|
138
|
+
</label>
|
|
139
|
+
<label>
|
|
140
|
+
Ville
|
|
141
|
+
<input name="city" required autocomplete="address-level2" value={address.city} />
|
|
142
|
+
</label>
|
|
143
|
+
<label>
|
|
144
|
+
Pays
|
|
145
|
+
<select name="country" autocomplete="country">
|
|
146
|
+
{COUNTRIES.map(([code, label]) => (
|
|
147
|
+
<option value={code} selected={address.country === code}>{label}</option>
|
|
148
|
+
))}
|
|
149
|
+
</select>
|
|
150
|
+
</label>
|
|
151
|
+
<label>
|
|
152
|
+
Téléphone <span class="zef-checkout__optional">(facultatif)</span>
|
|
153
|
+
<input name="phone" type="tel" autocomplete="tel" value={address.phone ?? ""} />
|
|
154
|
+
</label>
|
|
155
|
+
</fieldset>
|
|
156
|
+
<button type="submit" class="zef-checkout__submit">Continuer vers la livraison</button>
|
|
157
|
+
</form>
|
|
158
|
+
))
|
|
159
|
+
}
|
|
160
|
+
{
|
|
161
|
+
step === "shipping" &&
|
|
162
|
+
saved &&
|
|
163
|
+
(!cart || cart.lines.length === 0 ? (
|
|
164
|
+
<p class="zef-checkout__empty">
|
|
165
|
+
Votre panier est vide. <a href={cartHref}>Retour au panier</a>
|
|
166
|
+
</p>
|
|
167
|
+
) : (
|
|
168
|
+
<form method="post" action="/__zef/shop/checkout" class="zef-checkout__shipping">
|
|
169
|
+
<input type="hidden" name="zef_page" value={`${Astro.url.pathname}?etape=livraison`} />
|
|
170
|
+
<input type="hidden" name="zef_locale" value={Astro.currentLocale ?? ""} />
|
|
171
|
+
<p class="zef-checkout__summary">
|
|
172
|
+
Livraison à {saved.name}, {saved.line1}, {saved.postalCode} {saved.city} ·{" "}
|
|
173
|
+
<a href={Astro.url.pathname}>Modifier l'adresse</a>
|
|
174
|
+
</p>
|
|
175
|
+
{options.length === 0 ? (
|
|
176
|
+
<p class="zef-checkout__notice">Aucun mode de livraison ne dessert cette adresse.</p>
|
|
177
|
+
) : (
|
|
178
|
+
<fieldset class="zef-checkout__options">
|
|
179
|
+
<legend>Mode de livraison</legend>
|
|
180
|
+
{options.map((o, i) => (
|
|
181
|
+
<div class="zef-checkout__option" data-kind={o.kind}>
|
|
182
|
+
<label>
|
|
183
|
+
<input type="radio" name="shippingOption" value={o.id} required checked={i === 0} />
|
|
184
|
+
<span class="zef-checkout__option-label">{o.label}</span>
|
|
185
|
+
<span class="zef-checkout__option-price">{price(o)}</span>
|
|
186
|
+
</label>
|
|
187
|
+
{o.pickup && (
|
|
188
|
+
<p class="zef-checkout__pickup">
|
|
189
|
+
{o.pickup.address}
|
|
190
|
+
{o.pickup.hours && <><br />{o.pickup.hours}</>}
|
|
191
|
+
{o.pickup.instructions && <><br />{o.pickup.instructions}</>}
|
|
192
|
+
</p>
|
|
193
|
+
)}
|
|
194
|
+
{o.servicePoint &&
|
|
195
|
+
((points[o.id] ?? []).length === 0 ? (
|
|
196
|
+
<p class="zef-checkout__notice">Aucun point relais près de {saved.postalCode}.</p>
|
|
197
|
+
) : (
|
|
198
|
+
<label class="zef-checkout__points">
|
|
199
|
+
Point relais
|
|
200
|
+
<select name={`servicePoint_${o.id}`}>
|
|
201
|
+
{(points[o.id] ?? []).map((p) => (
|
|
202
|
+
<option value={p.id}>
|
|
203
|
+
{p.name} · {p.address}, {p.postalCode} {p.city}
|
|
204
|
+
{p.distance !== null ? ` (${(p.distance / 1000).toFixed(1).replace(".", ",")} km)` : ""}
|
|
205
|
+
</option>
|
|
206
|
+
))}
|
|
207
|
+
</select>
|
|
208
|
+
</label>
|
|
209
|
+
))}
|
|
210
|
+
</div>
|
|
211
|
+
))}
|
|
212
|
+
</fieldset>
|
|
213
|
+
)}
|
|
214
|
+
<p class="zef-checkout__summary">
|
|
215
|
+
Articles : <strong>{formatPrice(cart.total)}</strong> TTC
|
|
216
|
+
{cart.promo && <> (code {cart.promo.code}{cart.promo.discount ? ` −${formatPrice(cart.promo.discount)}` : ", livraison offerte"})</>}, livraison en plus selon le mode choisi.
|
|
217
|
+
{cart.giftCard && <> Carte cadeau •••• {cart.giftCard.last4} : jusqu'à {formatPrice(cart.giftCard.available)} déduits au paiement.</>}
|
|
218
|
+
</p>
|
|
219
|
+
<label class="zef-checkout__terms">
|
|
220
|
+
<input type="checkbox" name="acceptTerms" required />
|
|
221
|
+
J'accepte les <a href={termsHref} target="_blank">conditions générales de vente</a>.
|
|
222
|
+
</label>
|
|
223
|
+
<button type="submit" class="zef-checkout__submit" disabled={options.length === 0}>
|
|
224
|
+
Continuer vers le paiement
|
|
225
|
+
</button>
|
|
226
|
+
</form>
|
|
227
|
+
))
|
|
228
|
+
}
|
|
229
|
+
{
|
|
230
|
+
step === "payment" &&
|
|
231
|
+
(!payment ? (
|
|
232
|
+
<p class="zef-checkout__empty">
|
|
233
|
+
Aucun paiement en cours. <a href={cartHref}>Retour au panier</a>
|
|
234
|
+
</p>
|
|
235
|
+
) : payment.provider === "simulated" ? (
|
|
236
|
+
<form method="post" action="/__zef/shop/simulate" class="zef-checkout__payment" data-provider="simulated">
|
|
237
|
+
<input type="hidden" name="zef_page" value={Astro.url.pathname} />
|
|
238
|
+
<input type="hidden" name="orderId" value={payment.orderId} />
|
|
239
|
+
<p class="zef-checkout__summary">
|
|
240
|
+
Commande {payment.number} · <strong>{formatPrice(payment.total)}</strong> TTC
|
|
241
|
+
</p>
|
|
242
|
+
<p class="zef-checkout__notice">Paiement simulé (développement) : aucune carte n'est débitée.</p>
|
|
243
|
+
<button type="submit" class="zef-checkout__submit">Payer (simulation)</button>
|
|
244
|
+
</form>
|
|
245
|
+
) : (
|
|
246
|
+
<form
|
|
247
|
+
class="zef-checkout__payment"
|
|
248
|
+
data-provider="stripe"
|
|
249
|
+
data-zef-stripe
|
|
250
|
+
data-key={payment.publishableKey ?? ""}
|
|
251
|
+
data-secret={payment.clientSecret ?? ""}
|
|
252
|
+
data-return={returnUrl}
|
|
253
|
+
>
|
|
254
|
+
<p class="zef-checkout__summary">
|
|
255
|
+
Commande {payment.number} · <strong>{formatPrice(payment.total)}</strong> TTC
|
|
256
|
+
{payment.test && <span class="zef-checkout__test"> · mode test</span>}
|
|
257
|
+
</p>
|
|
258
|
+
<div class="zef-checkout__element" data-element />
|
|
259
|
+
<p class="zef-checkout__error" role="alert" data-message hidden />
|
|
260
|
+
<button type="submit" class="zef-checkout__submit">Payer {formatPrice(payment.total)}</button>
|
|
261
|
+
</form>
|
|
262
|
+
))
|
|
263
|
+
}
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
{payment?.provider === "stripe" && <script is:inline src="https://js.stripe.com/v3/" />}
|
|
267
|
+
|
|
268
|
+
<script>
|
|
269
|
+
type StripeLike = {
|
|
270
|
+
elements(o: object): { create(type: string, o?: object): { mount(el: Element): void } };
|
|
271
|
+
confirmPayment(o: object): Promise<{ error?: { message?: string } }>;
|
|
272
|
+
};
|
|
273
|
+
// Payment Element : les couleurs et la police du site sont reprises de ses variables CSS.
|
|
274
|
+
for (const form of document.querySelectorAll<HTMLFormElement>("[data-zef-stripe]")) {
|
|
275
|
+
const Stripe = (window as unknown as { Stripe?: (key: string) => StripeLike }).Stripe;
|
|
276
|
+
const { key, secret, return: returnUrl } = form.dataset;
|
|
277
|
+
if (!Stripe || !key || !secret) continue;
|
|
278
|
+
const stripe = Stripe(key);
|
|
279
|
+
const css = getComputedStyle(document.documentElement);
|
|
280
|
+
const variable = (name: string) => css.getPropertyValue(name).trim() || undefined;
|
|
281
|
+
const elements = stripe.elements({
|
|
282
|
+
clientSecret: secret,
|
|
283
|
+
appearance: {
|
|
284
|
+
variables: {
|
|
285
|
+
colorPrimary: variable("--color-accent"),
|
|
286
|
+
colorText: variable("--color-text"),
|
|
287
|
+
fontFamily: variable("--font-body"),
|
|
288
|
+
borderRadius: variable("--radius"),
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
const target = form.querySelector("[data-element]");
|
|
293
|
+
if (target) elements.create("payment").mount(target);
|
|
294
|
+
form.addEventListener("submit", async (event) => {
|
|
295
|
+
event.preventDefault();
|
|
296
|
+
const button = form.querySelector<HTMLButtonElement>("button[type=submit]");
|
|
297
|
+
if (button) button.disabled = true;
|
|
298
|
+
const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: returnUrl } });
|
|
299
|
+
const message = form.querySelector<HTMLElement>("[data-message]");
|
|
300
|
+
if (error && message) {
|
|
301
|
+
message.textContent = error.message ?? "Le paiement a échoué.";
|
|
302
|
+
message.hidden = false;
|
|
303
|
+
}
|
|
304
|
+
if (button) button.disabled = false;
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
</script>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Contenu réservé sans droit (lot 14e5c), sans style imposé : titre, phrase, produits qui le
|
|
4
|
+
* débloquent et lien de connexion. Classes `zef-reserved__*`. Le site peut la remplacer par la
|
|
5
|
+
* sienne en lisant `access` sur la page.
|
|
6
|
+
*/
|
|
7
|
+
interface Props {
|
|
8
|
+
title: string;
|
|
9
|
+
products: { id: string; name: string; url: string | null }[];
|
|
10
|
+
signInHref?: string;
|
|
11
|
+
class?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { title, products, signInHref = "/compte/connexion", class: className } = Astro.props;
|
|
15
|
+
const redirect = encodeURIComponent(Astro.url.pathname);
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
<section class:list={["zef-reserved", className]}>
|
|
19
|
+
<h1 class="zef-reserved__title">{title}</h1>
|
|
20
|
+
<p class="zef-reserved__text">Ce contenu est réservé à ses acheteurs.</p>
|
|
21
|
+
{
|
|
22
|
+
products.length > 0 && (
|
|
23
|
+
<ul class="zef-reserved__products">
|
|
24
|
+
{products.map((p) => (
|
|
25
|
+
<li>{p.url ? <a href={p.url}>{p.name}</a> : p.name}</li>
|
|
26
|
+
))}
|
|
27
|
+
</ul>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
<p class="zef-reserved__sign-in">
|
|
31
|
+
Déjà acheté ? <a href={`${signInHref}?redirect=${redirect}`}>Connectez-vous</a>.
|
|
32
|
+
</p>
|
|
33
|
+
</section>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Abonnements du compte connecté (lot 14e6b), sans style imposé : produit, prix et période,
|
|
4
|
+
* statut (essai, actif, paiement refusé, résilié, terminé), prochaine échéance ; « Changer de
|
|
5
|
+
* carte » (portail Stripe) et « Résilier » (à la fin de la période payée, confirmation par une
|
|
6
|
+
* case à cocher). Formulaires sans JavaScript. Classes `zef-subscriptions__*`. Page rendue à la
|
|
7
|
+
* demande.
|
|
8
|
+
*/
|
|
9
|
+
import { formatPrice, getSubscriptions, SHOP_ERRORS } from "@agence-zef/astro/shop";
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
empty?: string;
|
|
13
|
+
class?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { empty = "Aucun abonnement pour le moment.", class: className } = Astro.props;
|
|
17
|
+
const subscriptions = await getSubscriptions(Astro);
|
|
18
|
+
const errorCode = Astro.url.searchParams.get("zef_shop_error");
|
|
19
|
+
const cancelled = Astro.url.searchParams.get("zef_shop_ok") === "cancel-subscription";
|
|
20
|
+
const date = (iso: string) =>
|
|
21
|
+
new Date(iso).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
|
|
22
|
+
const period = (i: "month" | "year") => (i === "year" ? "par an" : "par mois");
|
|
23
|
+
|
|
24
|
+
function state(s: (typeof subscriptions)[number]) {
|
|
25
|
+
if (s.endedAt) return { key: "ended", label: `Terminé le ${date(s.endedAt)}` };
|
|
26
|
+
if (s.cancelAtPeriodEnd)
|
|
27
|
+
return {
|
|
28
|
+
key: "canceling",
|
|
29
|
+
label: s.currentPeriodEnd
|
|
30
|
+
? `Résilié : accès jusqu'au ${date(s.currentPeriodEnd)}`
|
|
31
|
+
: "Résilié",
|
|
32
|
+
};
|
|
33
|
+
if (s.status === "trialing")
|
|
34
|
+
return {
|
|
35
|
+
key: "trialing",
|
|
36
|
+
label: s.trialEnd ? `Essai gratuit jusqu'au ${date(s.trialEnd)}` : "Essai gratuit",
|
|
37
|
+
};
|
|
38
|
+
if (s.status === "past_due" || s.status === "unpaid")
|
|
39
|
+
return { key: "past_due", label: "Paiement refusé : mettez à jour votre carte" };
|
|
40
|
+
return {
|
|
41
|
+
key: "active",
|
|
42
|
+
label: s.currentPeriodEnd
|
|
43
|
+
? `Actif · prochaine échéance le ${date(s.currentPeriodEnd)}`
|
|
44
|
+
: "Actif",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
<div class:list={["zef-subscriptions", className]}>
|
|
50
|
+
{cancelled && <p class="zef-subscriptions__success" role="status">Abonnement résilié : vous gardez vos accès jusqu'à la fin de la période payée.</p>}
|
|
51
|
+
{errorCode && <p class="zef-subscriptions__error" role="alert">{SHOP_ERRORS[errorCode] ?? SHOP_ERRORS.unknown}</p>}
|
|
52
|
+
{
|
|
53
|
+
subscriptions.length === 0 ? (
|
|
54
|
+
<p class="zef-subscriptions__empty">{empty}</p>
|
|
55
|
+
) : (
|
|
56
|
+
<ul class="zef-subscriptions__list">
|
|
57
|
+
{subscriptions.map((s) => {
|
|
58
|
+
const st = state(s);
|
|
59
|
+
const live = st.key !== "ended";
|
|
60
|
+
return (
|
|
61
|
+
<li class="zef-subscriptions__item" data-state={st.key}>
|
|
62
|
+
<span class="zef-subscriptions__product">{s.product}</span>
|
|
63
|
+
<span class="zef-subscriptions__price">
|
|
64
|
+
{formatPrice(s.priceCents)} {period(s.interval)}
|
|
65
|
+
</span>
|
|
66
|
+
<span class="zef-subscriptions__state">{st.label}</span>
|
|
67
|
+
{live && (
|
|
68
|
+
<form method="post" action="/__zef/shop/subscription-card" class="zef-subscriptions__card">
|
|
69
|
+
<input type="hidden" name="zef_page" value={Astro.url.pathname} />
|
|
70
|
+
<input type="hidden" name="subscriptionId" value={s.id} />
|
|
71
|
+
<button type="submit">Changer de carte</button>
|
|
72
|
+
</form>
|
|
73
|
+
)}
|
|
74
|
+
{live && !s.cancelAtPeriodEnd && (
|
|
75
|
+
<form method="post" action="/__zef/shop/cancel-subscription" class="zef-subscriptions__cancel">
|
|
76
|
+
<input type="hidden" name="zef_page" value={Astro.url.pathname} />
|
|
77
|
+
<input type="hidden" name="subscriptionId" value={s.id} />
|
|
78
|
+
<label>
|
|
79
|
+
<input type="checkbox" name="confirm" required />
|
|
80
|
+
Je confirme la résiliation{s.currentPeriodEnd ? ` (accès gardés jusqu'au ${date(s.currentPeriodEnd)})` : ""}
|
|
81
|
+
</label>
|
|
82
|
+
<button type="submit">Résilier</button>
|
|
83
|
+
</form>
|
|
84
|
+
)}
|
|
85
|
+
</li>
|
|
86
|
+
);
|
|
87
|
+
})}
|
|
88
|
+
</ul>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
</div>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Lecture d'une vidéo vendue par la Boutique (lot 14e5b), sans style imposé. Selon l'accès du
|
|
4
|
+
* compte connecté : lecteur (liens signés de courte durée, jamais publics), bouton
|
|
5
|
+
* « Commencer la location » pour une location pas encore commencée (le premier visionnage la
|
|
6
|
+
* démarre), ou message (accès terminé, pas d'accès). Classes `zef-video__*`. Page rendue à la
|
|
7
|
+
* demande, protégée par `accounts.protect` de préférence.
|
|
8
|
+
*/
|
|
9
|
+
import { getAccess, getPlayback } from "@agence-zef/astro/shop";
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
videoId: string;
|
|
13
|
+
/** Texte sans accès (par défaut : lien vers la boutique). */
|
|
14
|
+
locked?: string;
|
|
15
|
+
class?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
videoId,
|
|
20
|
+
locked = "Cette vidéo est réservée à ses acheteurs.",
|
|
21
|
+
class: className,
|
|
22
|
+
} = Astro.props;
|
|
23
|
+
const access = (await getAccess(Astro)).filter((a) => a.videoId === videoId);
|
|
24
|
+
const active = access.find((a) => a.state === "active");
|
|
25
|
+
const waiting = !active ? access.find((a) => a.state === "not_started") : undefined;
|
|
26
|
+
const playback = active ? await getPlayback(Astro, videoId) : null;
|
|
27
|
+
const duration = (h: number | null) =>
|
|
28
|
+
!h ? "" : h % 24 === 0 ? `${h / 24} jour${h / 24 > 1 ? "s" : ""}` : `${h} h`;
|
|
29
|
+
const date = (iso: string) =>
|
|
30
|
+
new Date(iso).toLocaleDateString("fr-FR", {
|
|
31
|
+
day: "numeric",
|
|
32
|
+
month: "long",
|
|
33
|
+
hour: "2-digit",
|
|
34
|
+
minute: "2-digit",
|
|
35
|
+
});
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
<div class:list={["zef-video", className]} data-state={active ? "active" : waiting ? "not_started" : access[0]?.state ?? "none"}>
|
|
39
|
+
{
|
|
40
|
+
playback ? (
|
|
41
|
+
<>
|
|
42
|
+
{playback.iframe ? (
|
|
43
|
+
<iframe
|
|
44
|
+
class="zef-video__player"
|
|
45
|
+
src={playback.iframe}
|
|
46
|
+
title="Lecteur vidéo"
|
|
47
|
+
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture"
|
|
48
|
+
allowfullscreen
|
|
49
|
+
/>
|
|
50
|
+
) : playback.mp4 ? (
|
|
51
|
+
<video class="zef-video__player" src={playback.mp4} controls playsinline />
|
|
52
|
+
) : null}
|
|
53
|
+
{playback.endsAt && <p class="zef-video__until">Accès jusqu'au {date(playback.endsAt)}.</p>}
|
|
54
|
+
</>
|
|
55
|
+
) : waiting ? (
|
|
56
|
+
<form method="post" action="/__zef/shop/play" class="zef-video__start">
|
|
57
|
+
<input type="hidden" name="zef_page" value={Astro.url.pathname + Astro.url.search} />
|
|
58
|
+
<input type="hidden" name="videoId" value={videoId} />
|
|
59
|
+
<p>
|
|
60
|
+
Location de {duration(waiting.durationHours)} à partir du premier visionnage
|
|
61
|
+
{waiting.startBefore && <>, à commencer avant le {date(waiting.startBefore)}</>}.
|
|
62
|
+
</p>
|
|
63
|
+
<button type="submit" class="zef-video__button">Commencer la location</button>
|
|
64
|
+
</form>
|
|
65
|
+
) : access.length ? (
|
|
66
|
+
<p class="zef-video__ended">Votre accès à cette vidéo est terminé.</p>
|
|
67
|
+
) : (
|
|
68
|
+
<p class="zef-video__locked">{locked}</p>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import config from "virtual:zef/config";
|
|
2
|
+
import { handleAccountForm, isAccountAction } from "@agence-zef/astro/accounts";
|
|
3
|
+
import type { APIRoute } from "astro";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Envoi des formulaires de compte (`<AccountForm>`), injecté par l'option `accounts` de
|
|
7
|
+
* `@agence-zef/astro` : appelle le CMS avec la clé du site, pose ou efface le cookie de session,
|
|
8
|
+
* puis redirige (fonctionne sans JavaScript).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const prerender = false;
|
|
12
|
+
|
|
13
|
+
export const POST: APIRoute = async (context) => {
|
|
14
|
+
const action = context.params.action ?? "";
|
|
15
|
+
if (!isAccountAction(action)) return new Response("Action inconnue", { status: 404 });
|
|
16
|
+
const origin = context.request.headers.get("origin");
|
|
17
|
+
if (origin && origin !== context.url.origin) {
|
|
18
|
+
return new Response("Origine refusée", { status: 403 });
|
|
19
|
+
}
|
|
20
|
+
const form = await context.request.formData();
|
|
21
|
+
const fields = config.fields?.find((d) => d.entity === "accounts.account")?.fields;
|
|
22
|
+
return context.redirect(await handleAccountForm(context, action, form, fields), 303);
|
|
23
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Layout from "virtual:zef/layout";
|
|
3
|
+
/**
|
|
4
|
+
* Contenu réservé (lot 14e5c), rendu à la demande : la route statique ne génère pas les pages
|
|
5
|
+
* et éléments qu'un produit d'accès ouvre. Avec la session du compte, le CMS sert les sections
|
|
6
|
+
* à qui en a le droit ; sinon `<ReservedNotice>` dit quoi acheter. Toute autre adresse : 404.
|
|
7
|
+
*/
|
|
8
|
+
import { getClient } from "@agence-zef/astro";
|
|
9
|
+
import { visitorOf } from "@agence-zef/astro/accounts";
|
|
10
|
+
import ReservedNotice from "../ReservedNotice.astro";
|
|
11
|
+
import ZefSections from "../ZefSections.astro";
|
|
12
|
+
|
|
13
|
+
const api = getClient();
|
|
14
|
+
const [found, site] = await Promise.all([
|
|
15
|
+
api.document(decodeURI(Astro.url.pathname), visitorOf(Astro)).catch(() => null),
|
|
16
|
+
api.site(),
|
|
17
|
+
]);
|
|
18
|
+
const doc = found && (found.kind === "page" ? found.page : found.item);
|
|
19
|
+
if (!found || !doc?.access?.reserved) {
|
|
20
|
+
return new Response(null, { status: 404 });
|
|
21
|
+
}
|
|
22
|
+
const locale = found.kind === "page" ? found.page.locale : site.defaultLocale;
|
|
23
|
+
const layout = await api.layout({ locale });
|
|
24
|
+
const title =
|
|
25
|
+
found.kind === "page"
|
|
26
|
+
? (found.page.seo.title ?? found.page.title)
|
|
27
|
+
: String(found.item[found.titleField] ?? "");
|
|
28
|
+
const seo =
|
|
29
|
+
found.kind === "page"
|
|
30
|
+
? found.page.seo
|
|
31
|
+
: { title: null, description: null, noindex: false, canonical: null, image: null };
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
<Layout
|
|
35
|
+
site={site}
|
|
36
|
+
locale={locale}
|
|
37
|
+
title={title}
|
|
38
|
+
seo={{ ...seo, noindex: true }}
|
|
39
|
+
alternates={found.kind === "page" ? found.page.alternates : {}}
|
|
40
|
+
header={layout.header}
|
|
41
|
+
footer={layout.footer}
|
|
42
|
+
>
|
|
43
|
+
{
|
|
44
|
+
doc.access.granted ? (
|
|
45
|
+
found.kind === "page" ? (
|
|
46
|
+
<ZefSections sections={found.page.sections} />
|
|
47
|
+
) : (
|
|
48
|
+
<ZefSections sections={found.item.sections ?? []} item={found.item} />
|
|
49
|
+
)
|
|
50
|
+
) : (
|
|
51
|
+
<ReservedNotice title={title} products={doc.access.products} />
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
</Layout>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import paths from "virtual:zef/shop";
|
|
2
|
+
import { handleShopForm, isShopAction } from "@agence-zef/astro/shop";
|
|
3
|
+
import type { APIRoute } from "astro";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Envoi des formulaires de la boutique (`<AddToCart>`, `<Cart>`, `<Checkout>`), injecté par
|
|
7
|
+
* l'option `shop` de `@agence-zef/astro` : appelle le CMS avec la clé du site et la session du
|
|
8
|
+
* compte, puis redirige (fonctionne sans JavaScript).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const prerender = false;
|
|
12
|
+
|
|
13
|
+
export const POST: APIRoute = async (context) => {
|
|
14
|
+
const action = context.params.action ?? "";
|
|
15
|
+
if (!isShopAction(action)) return new Response("Action inconnue", { status: 404 });
|
|
16
|
+
const origin = context.request.headers.get("origin");
|
|
17
|
+
if (origin && origin !== context.url.origin)
|
|
18
|
+
return new Response("Origine refusée", { status: 403 });
|
|
19
|
+
const form = await context.request.formData();
|
|
20
|
+
return context.redirect(await handleShopForm(context, action, form, paths), 303);
|
|
21
|
+
};
|
package/components/virtual.d.ts
CHANGED
|
@@ -10,3 +10,7 @@ declare module "virtual:zef/layout" {
|
|
|
10
10
|
const layout: (props: Record<string, unknown>) => unknown;
|
|
11
11
|
export default layout;
|
|
12
12
|
}
|
|
13
|
+
declare module "virtual:zef/shop" {
|
|
14
|
+
const paths: { cart: string; checkout: string; confirmation: string; signIn: string };
|
|
15
|
+
export default paths;
|
|
16
|
+
}
|