feat(Membership, FAQ, Relance): update membership form (member type radio,retzien email field, calendar-year pricing), create FAQ page with accordion, wire renewal reminder notification
All checks were successful
Deploy Roxane to Preprod / deploy (push) Successful in 1m23s

This commit is contained in:
2026-07-27 16:30:48 +02:00
parent 87e23ea50f
commit 6d300ac79c
27 changed files with 1517 additions and 396 deletions

View File

@@ -54,6 +54,14 @@ export default function Contact() {
className="mx-auto"
/>
<p className="text-sm text-muted-foreground max-w-4xl mx-auto">
Pensez à consulter notre{' '}
<a href="/faq" className="text-primary underline hover:no-underline">FAQ</a>
{' '}ou notre{' '}
<a href="https://doc.retzien.fr" target="_blank" rel="noopener noreferrer" className="text-primary underline hover:no-underline">documentation</a>
{' '}avant de nous écrire.
</p>
{showFlashMessage && <FlashMessage messages={flash ?? {}} />}
<Form

View File

@@ -8,6 +8,7 @@ import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import NavGuestLayout from '@/layouts/nav-guest-layout';
import { Checkbox } from '@/components/ui/checkbox';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { useEffect, useState } from 'react';
import { PageProps } from '@/types';
import { FlashMessage } from '@/components/flash-message';
@@ -17,12 +18,14 @@ import { Footer } from '@/components/footer';
import IllustrationLogo from "@/img/utils/lrl-logo-full.svg";
export default function Membership() {
const { flash, plans, services, captcha_question } = usePage().props as PageProps;
const { flash, plans, services, member_types, current_year, captcha_question } = usePage().props as PageProps;
const [showFlashMessage, setFlashMessage] = useState(!!flash);
const [selectedPlan, setSelectedPlan] = useState(plans?.[0]?.identifier ?? null);
const [amount, setAmount] = useState(plans?.[0]?.price ?? 0);
const [memberType, setMemberType] = useState(member_types?.[0]?.identifier ?? 'individual');
const [phone1Value, setPhone1Value] = useState('');
const [zipcodeValue, setZipcodeValue] = useState('');
const [retzienEmailValue, setRetzienEmailValue] = useState('');
const phone1LocalError = phone1Value.length > 0 && !/^\d{10}$/.test(phone1Value)
? 'Le numéro doit contenir exactement 10 chiffres.'
@@ -32,6 +35,10 @@ export default function Membership() {
? 'Le code postal doit contenir exactement 5 chiffres.'
: null;
const retzienEmailLocalError = retzienEmailValue.length > 0 && !/^[a-z0-9._-]+$/.test(retzienEmailValue)
? 'Caractères autorisés : lettres minuscules, chiffres, points, tirets.'
: null;
useEffect(() => {
if (plans && selectedPlan) {
const plan = plans.find((p) => p.identifier === selectedPlan);
@@ -47,6 +54,20 @@ export default function Membership() {
}
}, [flash]);
const getPlanLabel = (plan: NonNullable<typeof plans>[number]) => {
if (!current_year || !plan.months) return plan.name;
if (plan.identifier === 'custom') {
return `Année ${current_year} (${plan.months} mois restants)`;
}
if (plan.identifier === 'one-year') {
return `Années ${current_year}${current_year + 1}`;
}
if (plan.identifier === 'two-years') {
return `Années ${current_year}${current_year + 2}`;
}
return plan.name;
};
return (
<>
<Head title="Adhérer au Retzien Libre" />
@@ -85,6 +106,28 @@ export default function Membership() {
className="rounded-lg max-w-md w-full pt-4"
/>
</div>
{/* Member type radio */}
<div className="grid gap-2">
<Label className="text-accent font-semibold">Vous êtes*</Label>
<RadioGroup
value={memberType}
onValueChange={setMemberType}
className="flex flex-wrap gap-4"
>
{member_types?.map((type) => (
<div key={type.identifier} className="flex items-center gap-2">
<RadioGroupItem value={type.identifier} id={`member-type-${type.identifier}`} />
<Label htmlFor={`member-type-${type.identifier}`} className="cursor-pointer">
{type.name}
</Label>
</div>
))}
</RadioGroup>
<input type="hidden" name="member_type" value={memberType} />
<InputError message={errors.member_type} />
</div>
<h2 className="text-lg font-semibold text-accent">Vos informations</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -130,9 +173,32 @@ export default function Membership() {
<InputError message={errors.company} />
</div>
<div className="grid gap-1">
<Label htmlFor="desired_retzien_email">
Adresse mail Retzien souhaitée <span className="text-muted-foreground text-xs">(facultatif)</span>
</Label>
<div className="group/email flex items-center gap-0 rounded-md has-[:focus-visible]:shadow-[4px_4px_0px_rgba(0,0,0,1)] transition duration-100 ease-in-out">
<Input
id="desired_retzien_email"
name="desired_retzien_email"
type="text"
tabIndex={6}
autoComplete="off"
placeholder="prenom.nom"
className="rounded-r-none focus-visible:shadow-none"
value={retzienEmailValue}
onChange={(e) => setRetzienEmailValue(e.target.value.toLowerCase().replace(/[^a-z0-9._-]/g, ''))}
/>
<span className="inline-flex items-center px-3 h-9 border-3 border-l-0 border-black rounded-r-md bg-muted text-sm text-muted-foreground whitespace-nowrap">
@retzien.fr
</span>
</div>
<InputError message={retzienEmailLocalError ?? errors.desired_retzien_email} />
</div>
<div className="grid gap-1">
<Label htmlFor="address">Adresse*</Label>
<Input id="address" name="address" type="text" required tabIndex={6} autoComplete="street-address" placeholder="Votre adresse" />
<Input id="address" name="address" type="text" required tabIndex={7} autoComplete="street-address" placeholder="Votre adresse" />
<InputError message={errors.address} />
</div>
@@ -144,7 +210,7 @@ export default function Membership() {
name="zipcode"
type="text"
required
tabIndex={7}
tabIndex={8}
autoComplete="postal-code"
inputMode="numeric"
maxLength={5}
@@ -156,7 +222,7 @@ export default function Membership() {
</div>
<div className="grid gap-1">
<Label htmlFor="city">Ville*</Label>
<Input id="city" name="city" type="text" required tabIndex={8} autoComplete="address-level2" placeholder="Ville" />
<Input id="city" name="city" type="text" required tabIndex={9} autoComplete="address-level2" placeholder="Ville" />
<InputError message={errors.city} />
</div>
</div>
@@ -174,7 +240,7 @@ export default function Membership() {
<button
key={plan.id}
type="button"
tabIndex={9}
tabIndex={10}
onClick={() => setSelectedPlan(plan.identifier)}
className={cn(
'flex items-center justify-between rounded-xl border-3 border-black px-5 py-4 text-left transition-all duration-150',
@@ -185,14 +251,12 @@ export default function Membership() {
)}
>
<div className="flex flex-col">
<span className="font-bold text-base">{plan.name}</span>
{plan.months != null ? (
<span className="text-xs text-muted-foreground">
{plan.months} mois × 1/mois
</span>
) : (
<span className="text-xs text-muted-foreground">{plan.description}</span>
)}
<span className="font-bold text-base">{getPlanLabel(plan)}</span>
<span className="text-xs text-muted-foreground">
{plan.months != null
? `${plan.months} mois × 1€/mois`
: plan.description}
</span>
</div>
<span className="text-2xl font-black">{plan.price}</span>
</button>
@@ -204,7 +268,7 @@ export default function Membership() {
<InputError message={errors.package} />
<p className="text-center text-sm text-muted-foreground border-t border-border pt-3">
Montant total : <strong className="text-secondary text-lg">{amount}</strong>
Montant total : <strong className="text-accent text-2xl">{amount}</strong>
</p>
</div>
@@ -222,6 +286,9 @@ export default function Membership() {
</div>
))}
</div>
<div className="text-center">
Et plus encore ...
</div>
</div>
{/* Captcha + CGU + Submit */}
@@ -232,7 +299,7 @@ export default function Membership() {
id="captcha"
name="captcha"
type="text"
tabIndex={10}
tabIndex={11}
placeholder="Votre réponse"
autoComplete="off"
className="max-w-[180px]"
@@ -242,7 +309,7 @@ export default function Membership() {
<div className="flex flex-col gap-1">
<div className="flex items-start gap-3">
<Checkbox id="cgu" name="cgu" tabIndex={11} required className="mt-0.5" />
<Checkbox id="cgu" name="cgu" tabIndex={12} required className="mt-0.5" />
<Label htmlFor="cgu" className="text-sm leading-snug cursor-pointer">
J'ai lu et j'accepte les <a href="#">C.G.U.</a> et je comprends la
nécessité des enregistrements de mes données personnelles.
@@ -254,7 +321,7 @@ export default function Membership() {
<Button
type="submit"
variant="secondary"
tabIndex={12}
tabIndex={13}
className="nb-shadow w-full font-bold text-base py-5"
>
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}