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

112
resources/js/pages/faq.tsx Normal file
View File

@@ -0,0 +1,112 @@
import { Head } from '@inertiajs/react';
import NavGuestLayout from '@/layouts/nav-guest-layout';
import { Footer } from '@/components/footer';
import { Container } from '@/components/common/Container';
import { SectionHeading } from '@/components/common/SectionHeading';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
const faqItems = [
{
question: "Qu'est-ce que Le Retzien Libre ?",
answer: "Le Retzien Libre est une association locale engagée pour un internet éthique, libre et respectueux de la vie privée. Nous proposons des services numériques basés sur des logiciels libres, hébergés localement, en alternative aux géants du numérique.",
},
{
question: "Comment adhérer à l'association ?",
answer: "Vous pouvez adhérer directement en ligne via notre formulaire d'adhésion. Choisissez votre formule (année en cours, 1 an ou 2 ans), remplissez vos informations et validez. Notre équipe traitera votre demande dans les meilleurs délais.",
},
{
question: "Quels services sont inclus dans l'adhésion ?",
answer: "L'adhésion inclut l'accès à l'ensemble de nos services : boîte mail @retzien.fr, stockage cloud via NextCloud, hébergement web, listes de diffusion et bien plus. Consultez notre documentation pour la liste complète des services disponibles.",
},
{
question: "Combien coûte l'adhésion ?",
answer: "L'adhésion est à 1€ par mois. Le montant est calculé en fonction de la durée choisie : reste de l'année en cours, année en cours + 1 an, ou année en cours + 2 ans. Les adhésions sont basées sur l'année civile (janvier à décembre).",
},
{
question: "Comment fonctionne ma boîte mail @retzien.fr ?",
answer: "Lors de votre adhésion, vous pouvez indiquer le nom de boîte mail souhaité (ex : prenom.nom@retzien.fr). Une fois votre adhésion validée, votre boîte sera créée et vous recevrez vos identifiants de connexion. Vous pouvez la consulter via webmail ou la configurer dans votre client mail préféré.",
},
{
question: "Que se passe-t-il à l'expiration de mon adhésion ?",
answer: "Vous recevrez un rappel par e-mail avant l'expiration de votre adhésion. Si vous ne renouvelez pas, vos services seront suspendus. Vos données sont conservées pendant une période raisonnable pour vous permettre de renouveler sans perte.",
},
{
question: "Comment contacter l'association ?",
answer: "Vous pouvez nous joindre via le formulaire de contact disponible sur le site, ou directement par e-mail. Pour les questions techniques, consultez d'abord notre documentation en ligne.",
},
{
question: "Où trouver la documentation technique ?",
answer: "Notre documentation complète est disponible à l'adresse doc.retzien.fr. Vous y trouverez des guides de configuration pour tous nos services, des tutoriels et une base de connaissances.",
},
];
export default function Faq() {
return (
<>
<Head title="Foire aux questions" />
<div className="flex flex-col min-h-screen bg-white dark:bg-[#0a0a0a] text-[#1b1b18] dark:text-[#EDEDEC]">
<div className="flex flex-col items-center px-4">
<NavGuestLayout />
</div>
<main className="flex-1 py-12">
<Container className="flex flex-col gap-10 max-w-4xl mx-auto">
<SectionHeading
title="Foire aux questions"
color="primary"
subtitle="Retrouvez les réponses aux questions les plus fréquentes sur notre association et nos services."
align="left"
/>
<Accordion type="single" collapsible className="w-full flex flex-col gap-3">
{faqItems.map((item, index) => (
<AccordionItem
key={index}
value={`item-${index}`}
className="nb-shadow-static rounded-2xl border-3 border-black px-5 overflow-hidden"
>
<AccordionTrigger className="text-left font-semibold text-base py-4 hover:no-underline">
{item.question}
</AccordionTrigger>
<AccordionContent className="text-muted-foreground pb-4">
{item.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
<div className="bg-primary dark:bg-[#171717] nb-shadow-static rounded-2xl p-6 text-center">
<p className="text-accent font-semibold mb-2">Vous n'avez pas trouvé votre réponse ?</p>
<p className="text-sm text-muted-foreground mb-4">
Consultez notre documentation complète ou contactez-nous directement.
</p>
<div className="flex flex-wrap justify-center gap-3">
<a
href="https://doc.retzien.fr"
target="_blank"
rel="noopener noreferrer"
className="nb-shadow inline-flex items-center justify-center rounded-md text-sm font-bold bg-secondary text-secondary-foreground hover:bg-secondary/80 h-10 px-4 py-2 no-underline"
>
Documentation
</a>
<a
href="/contact"
className="nb-shadow inline-flex items-center justify-center rounded-md text-sm font-bold bg-accent text-white hover:bg-accent/80 h-10 px-4 py-2 no-underline"
>
Nous contacter
</a>
</div>
</div>
</Container>
</main>
<Footer />
</div>
</>
);
}

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" />}