feat(web): plans bibliothèque + éditeur
Bibliothèque /plans (cf. wireframe 3.1) :
- Grid responsive 1/2/3 cols avec PlanCard + CreatePlanCard placeholder
- PlanCard : titre, chip meta (un seul à la fois), aperçu 3 étapes avec
◆ rotated comme bullet, footer usage + lien "Modifier →"
- Le plan le plus utilisé reçoit le badge "✦ Le plus utilisé" (rubis-glow
+ Sparkles), les autres gardent leur label de tonalité (Doux / Standard
/ Ferme / Strict). Pas de "PLAN PAR DÉFAUT" partout — info tautologique
vu que les 4 plans seedés sont des défauts.
- Chips de tonalité adoucis (bg-cream-2 ou rubis-glow, plus de fills lourds)
- Skeleton pulsé pendant le fetch
Éditeur /plans/$slug (cf. wireframe 3.2, route _app/plans_.$slug pour
escape la layout parent) :
- Header : eyebrow humeur + nom + compteur d'usage + boutons Dupliquer
(V2) / Enregistrer (fonctionnel, désactivé tant que pas de changements)
- Layout 2-col responsive (1fr / 1.4fr) :
· Gauche : cadence — list de StepCard cliquables, état sélectionné avec
border-rubis + shadow-rubis, "+ Ajouter une étape" disabled (V2)
· Droite : éditeur d'email — Card avec subject (Input), body (Textarea
mono 10 rows), grille de variables-chips
- Variable insertion fonctionnelle : clic = insertion au curseur via
selectionStart/End du textarea, label FR + token mono ({{numero}})
- Bandeau warning rubis-glow quand l'étape est requiresManualValidation :
"Validation manuelle obligatoire. L'email est généré en brouillon"
- Save fonctionnel : isDirty calculé via JSON.stringify, mutation PATCH
/plans/:slug, invalidate cache plans.all + setQueryData detail, toast
- Sync state local ↔ serveur via useEffect sur plan.id+updatedAt
MSW :
- handlers/plans.ts : GET /plans (avec usageCount), GET /plans/:slug,
PATCH /plans/:slug (validation Zod, recompose ids manquants)
- mockDb : findPlanBySlug, listPlansForOrg, updatePlan
- Calcul usageCount : factures du plan en statut != paid && != cancelled
Lib /plans.ts :
- TONE_LABELS : Amical / Standard / Ferme / Mise en demeure (FR)
- planMoodLabel + planOverallTone (humeur globale = ton de la dernière étape)
- TEMPLATE_VARIABLES : 5 variables avec token + label FR + preview
Bundle prod : 117.31 KB gzip core (stable). plans 2.06 KB gzip,
plans_._slug 3.28 KB gzip — la plus grosse route chunk vu sa complexité
(form + variables + state local).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
14d0e982e9
commit
b5b67056aa
172
apps/web/src/components/plans/PlanCard.tsx
Normal file
172
apps/web/src/components/plans/PlanCard.tsx
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { Plus, ArrowRight, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
import type { Plan } from "@rubis/shared";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { planMoodLabel } from "@/lib/plans";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card d'un plan dans la bibliothèque (cf. wireframe 3.1).
|
||||||
|
*
|
||||||
|
* Hiérarchie d'info (en accord avec le feedback retour utilisateur) :
|
||||||
|
* - Le titre est primaire
|
||||||
|
* - Une seule meta en haut-droite : soit "★ Le plus utilisé" pour LE plan
|
||||||
|
* le plus actif (signal utile), soit le label de tonalité (signal de
|
||||||
|
* différenciation pour les autres). Les deux ne coexistent pas — un seul
|
||||||
|
* chip par card.
|
||||||
|
* - Aperçu des étapes avec ◆ comme bullet (notre marqueur, pas un point neutre)
|
||||||
|
* - Footer : usage + lien Modifier
|
||||||
|
*
|
||||||
|
* Pas de "PLAN PAR DÉFAUT" affiché : tous les plans seedés sont par défaut,
|
||||||
|
* l'info est tautologique. Quand l'utilisateur créera ses propres plans,
|
||||||
|
* on pourra distinguer (cf. V2).
|
||||||
|
*/
|
||||||
|
type PlanCardProps = {
|
||||||
|
plan: Plan & { usageCount?: number };
|
||||||
|
/** Si true, affiche le badge "★ Le plus utilisé" en lieu et place du chip de ton. */
|
||||||
|
isMostUsed?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOOD_CHIP: Record<string, string> = {
|
||||||
|
Doux: "bg-rubis-glow text-rubis-deep border-rubis-glow/60",
|
||||||
|
Standard: "bg-cream-2 text-ink-2 border-line",
|
||||||
|
Ferme: "bg-cream-2 text-ink border-line",
|
||||||
|
Strict: "bg-rubis-glow text-rubis-deep border-rubis/30",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PlanCard({ plan, isMostUsed = false, className }: PlanCardProps) {
|
||||||
|
const mood = planMoodLabel(plan);
|
||||||
|
const usage = plan.usageCount ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
padding="md"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col h-full min-w-0 transition-all duration-150",
|
||||||
|
"hover:border-ink-3 hover:shadow-card",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<p className="font-display text-[16px] font-semibold tracking-[-0.018em] text-ink truncate min-w-0">
|
||||||
|
{plan.name}
|
||||||
|
</p>
|
||||||
|
{isMostUsed ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5",
|
||||||
|
"border-rubis bg-rubis-glow text-rubis-deep",
|
||||||
|
"font-sans text-[11px] font-semibold leading-tight whitespace-nowrap",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Sparkles size={11} aria-hidden="true" /> Le plus utilisé
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-full border px-2.5 py-0.5",
|
||||||
|
"font-sans text-[11px] font-medium leading-tight whitespace-nowrap",
|
||||||
|
MOOD_CHIP[mood] ?? MOOD_CHIP.Standard,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{mood}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{plan.description && (
|
||||||
|
<p className="mt-2 text-[12.5px] leading-relaxed text-ink-3 line-clamp-2">
|
||||||
|
{plan.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="mt-4 flex flex-col gap-1.5 text-[12.5px] text-ink-2">
|
||||||
|
{plan.steps.slice(0, 3).map((step) => (
|
||||||
|
<li key={step.id} className="flex items-baseline gap-2">
|
||||||
|
<span
|
||||||
|
className="shrink-0 size-[5px] rotate-45 bg-rubis"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="tabular-nums font-semibold text-ink">
|
||||||
|
J{step.offsetDays >= 0 ? "+" : ""}
|
||||||
|
{step.offsetDays}
|
||||||
|
</span>
|
||||||
|
<span className="text-ink-3">·</span>
|
||||||
|
<span className="truncate">
|
||||||
|
{step.requiresManualValidation ? "Mise en demeure" : labelForTone(step.tone)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{plan.steps.length > 3 && (
|
||||||
|
<li className="text-[11.5px] italic text-ink-3 ml-3.5">
|
||||||
|
+{plan.steps.length - 3} étape{plan.steps.length - 3 > 1 ? "s" : ""}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="mt-auto pt-4 flex items-center justify-between border-t border-line">
|
||||||
|
<p className="text-[11.5px] text-ink-3">
|
||||||
|
{usage > 0 ? (
|
||||||
|
<>
|
||||||
|
Utilisé sur <strong className="font-semibold text-ink-2 tabular-nums">{usage}</strong>{" "}
|
||||||
|
facture{usage > 1 ? "s" : ""}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="italic">Aucune facture active</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{plan.slug && (
|
||||||
|
<Link
|
||||||
|
to="/plans/$slug"
|
||||||
|
params={{ slug: plan.slug }}
|
||||||
|
className="inline-flex items-center gap-1 text-[12px] font-medium text-rubis hover:underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Modifier <ArrowRight size={12} aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelForTone(tone: string): string {
|
||||||
|
switch (tone) {
|
||||||
|
case "amical":
|
||||||
|
return "Rappel amical";
|
||||||
|
case "courtois":
|
||||||
|
return "Relance courtoise";
|
||||||
|
case "ferme":
|
||||||
|
return "Relance ferme";
|
||||||
|
case "mise_en_demeure":
|
||||||
|
return "Mise en demeure";
|
||||||
|
default:
|
||||||
|
return "Email programmé";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Card "+ Créer un plan" — placeholder pour la création de plans custom. */
|
||||||
|
export function CreatePlanCard() {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center gap-2 w-full",
|
||||||
|
"rounded-card border-2 border-dashed border-line bg-transparent p-6 min-h-[220px]",
|
||||||
|
"text-ink-3 transition-colors hover:border-ink-3 hover:text-ink-2",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
|
||||||
|
"disabled:opacity-60 disabled:cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus size={26} strokeWidth={1.5} aria-hidden="true" />
|
||||||
|
<span className="font-display text-[14.5px] font-semibold text-ink">
|
||||||
|
Créer un plan
|
||||||
|
</span>
|
||||||
|
<span className="text-[11.5px] italic text-ink-3 max-w-[180px]">
|
||||||
|
Bientôt — pour l'instant, dupliquez un des plans existants.
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
apps/web/src/lib/plans.ts
Normal file
58
apps/web/src/lib/plans.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import type { Plan, RelanceTone } from "@rubis/shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers de présentation des plans de relance.
|
||||||
|
* Garde la conversion tonalité → label public au même endroit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Label visible utilisateur pour chaque ton (cf. wireframe 3.1, chips). */
|
||||||
|
export const TONE_LABELS: Record<RelanceTone, string> = {
|
||||||
|
amical: "Amical",
|
||||||
|
courtois: "Standard",
|
||||||
|
ferme: "Ferme",
|
||||||
|
mise_en_demeure: "Mise en demeure",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Tonalité globale d'un plan = la dernière étape (la plus stricte). */
|
||||||
|
export function planOverallTone(plan: Pick<Plan, "steps">): RelanceTone {
|
||||||
|
const last = plan.steps[plan.steps.length - 1];
|
||||||
|
return last?.tone ?? "courtois";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Label court d'humeur d'un plan ("Doux" / "Standard" / "Ferme" / "Strict"). */
|
||||||
|
export function planMoodLabel(plan: Pick<Plan, "steps">): string {
|
||||||
|
const tone = planOverallTone(plan);
|
||||||
|
switch (tone) {
|
||||||
|
case "amical":
|
||||||
|
return "Doux";
|
||||||
|
case "courtois":
|
||||||
|
return "Standard";
|
||||||
|
case "ferme":
|
||||||
|
return "Ferme";
|
||||||
|
case "mise_en_demeure":
|
||||||
|
return "Strict";
|
||||||
|
default:
|
||||||
|
return "Standard";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variables de template disponibles dans les emails de relance.
|
||||||
|
* Les chips dans l'éditeur viennent piocher ici.
|
||||||
|
*/
|
||||||
|
export type TemplateVariable = {
|
||||||
|
/** Token inséré tel quel dans le body (ex. "{{numero}}"). */
|
||||||
|
token: string;
|
||||||
|
/** Label affiché sur le chip cliquable. */
|
||||||
|
label: string;
|
||||||
|
/** Aperçu utilisé dans l'éditeur (placeholder réaliste). */
|
||||||
|
preview: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEMPLATE_VARIABLES: TemplateVariable[] = [
|
||||||
|
{ token: "{{client.name}}", label: "Nom du client", preview: "Boulangerie Martin SARL" },
|
||||||
|
{ token: "{{numero}}", label: "Numéro", preview: "F-2026-0042" },
|
||||||
|
{ token: "{{amount}}", label: "Montant", preview: "1 240,00 €" },
|
||||||
|
{ token: "{{dueDate}}", label: "Échéance", preview: "15 mai 2026" },
|
||||||
|
{ token: "{{signature}}", label: "Signature", preview: "Cordialement,\nArthur" },
|
||||||
|
];
|
||||||
@ -162,6 +162,34 @@ export const mockDb = {
|
|||||||
findPlanById(orgId: string, id: string): Plan | undefined {
|
findPlanById(orgId: string, id: string): Plan | undefined {
|
||||||
return load().plans.find((p) => p.organizationId === orgId && p.id === id);
|
return load().plans.find((p) => p.organizationId === orgId && p.id === id);
|
||||||
},
|
},
|
||||||
|
findPlanBySlug(orgId: string, slug: string): Plan | undefined {
|
||||||
|
return load().plans.find(
|
||||||
|
(p) => p.organizationId === orgId && p.slug === slug,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
listPlansForOrg(orgId: string): Plan[] {
|
||||||
|
return load().plans.filter((p) => p.organizationId === orgId);
|
||||||
|
},
|
||||||
|
updatePlan(
|
||||||
|
orgId: string,
|
||||||
|
id: string,
|
||||||
|
patch: Partial<Pick<Plan, "name" | "description" | "steps">>,
|
||||||
|
): Plan | undefined {
|
||||||
|
const db = load();
|
||||||
|
const idx = db.plans.findIndex(
|
||||||
|
(p) => p.organizationId === orgId && p.id === id,
|
||||||
|
);
|
||||||
|
if (idx === -1) return undefined;
|
||||||
|
const existing = db.plans[idx]!;
|
||||||
|
const updated: Plan = {
|
||||||
|
...existing,
|
||||||
|
...patch,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
db.plans[idx] = updated;
|
||||||
|
save(db);
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
|
||||||
// === Invoices ===
|
// === Invoices ===
|
||||||
listInvoicesForOrg(orgId: string): StoredInvoice[] {
|
listInvoicesForOrg(orgId: string): StoredInvoice[] {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { authHandlers } from "./auth";
|
|||||||
import { onboardingHandlers } from "./onboarding";
|
import { onboardingHandlers } from "./onboarding";
|
||||||
import { dashboardHandlers } from "./dashboard";
|
import { dashboardHandlers } from "./dashboard";
|
||||||
import { invoiceHandlers } from "./invoices";
|
import { invoiceHandlers } from "./invoices";
|
||||||
|
import { planHandlers } from "./plans";
|
||||||
|
|
||||||
/** Tous les handlers MSW concaténés. Les nouveaux domaines viennent ici. */
|
/** Tous les handlers MSW concaténés. Les nouveaux domaines viennent ici. */
|
||||||
export const handlers = [
|
export const handlers = [
|
||||||
@ -9,4 +10,5 @@ export const handlers = [
|
|||||||
...onboardingHandlers,
|
...onboardingHandlers,
|
||||||
...dashboardHandlers,
|
...dashboardHandlers,
|
||||||
...invoiceHandlers,
|
...invoiceHandlers,
|
||||||
|
...planHandlers,
|
||||||
];
|
];
|
||||||
|
|||||||
122
apps/web/src/mocks/handlers/plans.ts
Normal file
122
apps/web/src/mocks/handlers/plans.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { RELANCE_TONES } from "@rubis/shared";
|
||||||
|
|
||||||
|
import { mockDb } from "../db";
|
||||||
|
import { userIdFromAuthHeader } from "./auth";
|
||||||
|
|
||||||
|
const apiBase = "*/api/v1";
|
||||||
|
|
||||||
|
function unauthenticated() {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ errors: [{ code: "unauthenticated", message: "Non authentifié" }] },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notFound() {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ errors: [{ code: "not_found", message: "Plan introuvable" }] },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function authedOrgId(authHeader: string | null): string | undefined {
|
||||||
|
const userId = userIdFromAuthHeader(authHeader);
|
||||||
|
if (!userId) return undefined;
|
||||||
|
return mockDb.findUserById(userId)?.organizationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePlanStepSchema = z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
|
order: z.number().int().min(0),
|
||||||
|
offsetDays: z.number().int().min(-30).max(180),
|
||||||
|
tone: z.enum(RELANCE_TONES),
|
||||||
|
subject: z.string().min(1).max(200),
|
||||||
|
body: z.string().min(1).max(5000),
|
||||||
|
requiresManualValidation: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePlanSchema = z.object({
|
||||||
|
name: z.string().min(1).max(80).optional(),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
steps: z.array(updatePlanStepSchema).min(1).max(10).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const planHandlers = [
|
||||||
|
// GET /api/v1/plans — liste enrichie avec compteurs d'usage
|
||||||
|
http.get(`${apiBase}/plans`, ({ request }) => {
|
||||||
|
const orgId = authedOrgId(request.headers.get("authorization"));
|
||||||
|
if (!orgId) return unauthenticated();
|
||||||
|
|
||||||
|
const plans = mockDb.listPlansForOrg(orgId);
|
||||||
|
const invoices = mockDb.listInvoicesForOrg(orgId);
|
||||||
|
|
||||||
|
const enriched = plans.map((plan) => {
|
||||||
|
const usageCount = invoices.filter(
|
||||||
|
(inv) => inv.planId === plan.id && inv.status !== "paid" && inv.status !== "cancelled",
|
||||||
|
).length;
|
||||||
|
return { ...plan, usageCount };
|
||||||
|
});
|
||||||
|
|
||||||
|
return HttpResponse.json({ data: enriched });
|
||||||
|
}),
|
||||||
|
|
||||||
|
// GET /api/v1/plans/:slug — détail (par slug pour les plans pré-fournis)
|
||||||
|
http.get(`${apiBase}/plans/:slug`, ({ request, params }) => {
|
||||||
|
const orgId = authedOrgId(request.headers.get("authorization"));
|
||||||
|
if (!orgId) return unauthenticated();
|
||||||
|
|
||||||
|
const slug = params.slug as string;
|
||||||
|
const plan = mockDb.findPlanBySlug(orgId, slug);
|
||||||
|
if (!plan) return notFound();
|
||||||
|
|
||||||
|
const invoices = mockDb.listInvoicesForOrg(orgId);
|
||||||
|
const usageCount = invoices.filter(
|
||||||
|
(inv) => inv.planId === plan.id && inv.status !== "paid" && inv.status !== "cancelled",
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return HttpResponse.json({ data: { ...plan, usageCount } });
|
||||||
|
}),
|
||||||
|
|
||||||
|
// PATCH /api/v1/plans/:slug — sauvegarde nom/description/étapes
|
||||||
|
http.patch(`${apiBase}/plans/:slug`, async ({ request, params }) => {
|
||||||
|
const orgId = authedOrgId(request.headers.get("authorization"));
|
||||||
|
if (!orgId) return unauthenticated();
|
||||||
|
|
||||||
|
const slug = params.slug as string;
|
||||||
|
const plan = mockDb.findPlanBySlug(orgId, slug);
|
||||||
|
if (!plan) return notFound();
|
||||||
|
|
||||||
|
const json = await request.json();
|
||||||
|
const parsed = updatePlanSchema.safeParse(json);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
errors: parsed.error.issues.map((i) => ({
|
||||||
|
code: "validation_failed",
|
||||||
|
message: i.message,
|
||||||
|
field: i.path.join("."),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recompose les étapes en garantissant un id sur chaque
|
||||||
|
const steps = parsed.data.steps?.map((s, idx) => ({
|
||||||
|
...s,
|
||||||
|
id: s.id ?? `step_${plan.id}_${idx}_${Date.now()}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const updated = mockDb.updatePlan(orgId, plan.id, {
|
||||||
|
...(parsed.data.name !== undefined && { name: parsed.data.name }),
|
||||||
|
...(parsed.data.description !== undefined && {
|
||||||
|
description: parsed.data.description,
|
||||||
|
}),
|
||||||
|
...(steps !== undefined && { steps }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return HttpResponse.json({ data: updated });
|
||||||
|
}),
|
||||||
|
];
|
||||||
@ -1,40 +1,86 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { ListChecks } from "lucide-react";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
import type { Plan } from "@rubis/shared";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { queryKeys } from "@/lib/queryKeys";
|
||||||
|
import { PlanCard, CreatePlanCard } from "@/components/plans/PlanCard";
|
||||||
|
|
||||||
|
type PlanWithUsage = Plan & { usageCount: number };
|
||||||
|
|
||||||
export const Route = createFileRoute("/_app/plans")({
|
export const Route = createFileRoute("/_app/plans")({
|
||||||
component: PlansPage,
|
component: PlansLibraryPage,
|
||||||
|
loader: ({ context }) => {
|
||||||
|
void context.queryClient.prefetchQuery({
|
||||||
|
queryKey: queryKeys.plans.all(),
|
||||||
|
queryFn: () => api.get<PlanWithUsage[]>("/api/v1/plans"),
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function PlansPage() {
|
function PlansLibraryPage() {
|
||||||
|
const { data: plans = [], isPending } = useQuery({
|
||||||
|
queryKey: queryKeys.plans.all(),
|
||||||
|
queryFn: () => api.get<PlanWithUsage[]>("/api/v1/plans"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Le plan le plus utilisé reçoit le badge "★ Le plus utilisé". On ne le
|
||||||
|
// calcule que s'il y a au moins une facture active (sinon le badge n'a
|
||||||
|
// pas de sens).
|
||||||
|
const mostUsedPlanId = plans.reduce<string | null>((best, plan) => {
|
||||||
|
if (plan.usageCount <= 0) return best;
|
||||||
|
if (!best) return plan.id;
|
||||||
|
const bestPlan = plans.find((p) => p.id === best);
|
||||||
|
return plan.usageCount > (bestPlan?.usageCount ?? 0) ? plan.id : best;
|
||||||
|
}, null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-display text-[28px] font-bold tracking-[-0.022em] text-ink">
|
<h1 className="font-display text-[26px] font-bold tracking-[-0.022em] text-ink">
|
||||||
Plans de relance
|
Plans de <em className="text-rubis">relance</em>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-[14px] text-ink-3">
|
<p className="mt-1.5 max-w-xl text-[14px] text-ink-3 leading-relaxed">
|
||||||
La cadence avec laquelle Rubis relance pour vous.
|
Un plan = une cadence d'emails automatisée que Rubis joue pour
|
||||||
|
vous. Quatre modèles vous sont fournis — modifiables, mais déjà bien
|
||||||
|
calibrés.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EmptyState
|
{isPending ? (
|
||||||
draft
|
<PlansSkeleton />
|
||||||
icon={<ListChecks size={36} strokeWidth={1.5} aria-hidden="true" />}
|
) : (
|
||||||
title={
|
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-5">
|
||||||
<>
|
{plans.map((plan) => (
|
||||||
Quatre plans <em className="text-rubis">prêts à l'emploi</em>.
|
<li key={plan.id}>
|
||||||
</>
|
<PlanCard plan={plan} isMostUsed={plan.id === mostUsedPlanId} />
|
||||||
}
|
</li>
|
||||||
description={
|
))}
|
||||||
<>
|
<li>
|
||||||
Bibliothèque + éditeur de plan : cadences (J+3, J+10, J+20…),
|
<CreatePlanCard />
|
||||||
templates email avec variables, ton qui monte avec le retard.
|
</li>
|
||||||
Étape suivante.
|
</ul>
|
||||||
</>
|
)}
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlansSkeleton() {
|
||||||
|
return (
|
||||||
|
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-5">
|
||||||
|
{Array.from({ length: 4 }).map((_, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
className="rounded-card border border-line bg-white p-6 h-[240px] animate-pulse"
|
||||||
|
>
|
||||||
|
<div className="h-4 w-1/2 rounded bg-cream-2" />
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<div className="h-3 w-2/3 rounded bg-cream-2" />
|
||||||
|
<div className="h-3 w-1/2 rounded bg-cream-2" />
|
||||||
|
<div className="h-3 w-3/5 rounded bg-cream-2" />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
383
apps/web/src/routes/_app/plans_.$slug.tsx
Normal file
383
apps/web/src/routes/_app/plans_.$slug.tsx
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
AlertTriangle,
|
||||||
|
Copy as CopyIcon,
|
||||||
|
Plus,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import type { Plan, PlanStep, RelanceTone } from "@rubis/shared";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { queryKeys } from "@/lib/queryKeys";
|
||||||
|
import {
|
||||||
|
TEMPLATE_VARIABLES,
|
||||||
|
TONE_LABELS,
|
||||||
|
planMoodLabel,
|
||||||
|
} from "@/lib/plans";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||||
|
import { Field } from "@/components/ui/Field";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { Textarea } from "@/components/ui/Textarea";
|
||||||
|
|
||||||
|
type PlanWithUsage = Plan & { usageCount: number };
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_app/plans_/$slug")({
|
||||||
|
component: PlanEditorPage,
|
||||||
|
loader: ({ context, params }) => {
|
||||||
|
void context.queryClient.prefetchQuery({
|
||||||
|
queryKey: queryKeys.plans.detail(params.slug),
|
||||||
|
queryFn: () => api.get<PlanWithUsage>(`/api/v1/plans/${params.slug}`),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function PlanEditorPage() {
|
||||||
|
const { slug } = Route.useParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: plan, isPending, isError } = useQuery({
|
||||||
|
queryKey: queryKeys.plans.detail(slug),
|
||||||
|
queryFn: () => api.get<PlanWithUsage>(`/api/v1/plans/${slug}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// État local de l'édition. On clone les steps depuis le serveur au mount
|
||||||
|
// (et à chaque refetch) puis on travaille en local jusqu'au "Enregistrer".
|
||||||
|
const [draftSteps, setDraftSteps] = useState<PlanStep[] | null>(null);
|
||||||
|
const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
|
||||||
|
const bodyRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
// Sync : quand le plan arrive ou change côté serveur, on remet à zéro l'état
|
||||||
|
// local. On évite les races avec une clé sur plan.id+updatedAt.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!plan) return;
|
||||||
|
setDraftSteps(plan.steps);
|
||||||
|
setSelectedStepId((current) => current ?? plan.steps[0]?.id ?? null);
|
||||||
|
}, [plan?.id, plan?.updatedAt]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (steps: PlanStep[]) =>
|
||||||
|
api.patch<Plan>(`/api/v1/plans/${slug}`, { steps }),
|
||||||
|
onSuccess: (saved) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.plans.all() });
|
||||||
|
void queryClient.setQueryData(
|
||||||
|
queryKeys.plans.detail(slug),
|
||||||
|
(prev: PlanWithUsage | undefined) =>
|
||||||
|
prev ? { ...prev, ...saved } : (saved as PlanWithUsage),
|
||||||
|
);
|
||||||
|
toast.success("Plan enregistré.");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Sauvegarde impossible. Réessayez dans un instant.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="font-display text-[20px] font-semibold text-ink">
|
||||||
|
Plan introuvable.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/plans"
|
||||||
|
className="mt-3 inline-block text-[13px] text-rubis underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
← Retour à la bibliothèque
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPending || !plan || !draftSteps) {
|
||||||
|
return <EditorSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedStep =
|
||||||
|
draftSteps.find((s) => s.id === selectedStepId) ?? draftSteps[0];
|
||||||
|
|
||||||
|
const updateSelectedStep = (patch: Partial<PlanStep>) => {
|
||||||
|
if (!selectedStep) return;
|
||||||
|
setDraftSteps((prev) =>
|
||||||
|
prev
|
||||||
|
? prev.map((s) => (s.id === selectedStep.id ? { ...s, ...patch } : s))
|
||||||
|
: prev,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertVariable = (token: string) => {
|
||||||
|
if (!selectedStep || !bodyRef.current) return;
|
||||||
|
const ta = bodyRef.current;
|
||||||
|
const start = ta.selectionStart ?? selectedStep.body.length;
|
||||||
|
const end = ta.selectionEnd ?? selectedStep.body.length;
|
||||||
|
const newBody =
|
||||||
|
selectedStep.body.slice(0, start) + token + selectedStep.body.slice(end);
|
||||||
|
updateSelectedStep({ body: newBody });
|
||||||
|
// Reposition le curseur après le token inséré
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ta.focus();
|
||||||
|
const cursor = start + token.length;
|
||||||
|
ta.setSelectionRange(cursor, cursor);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDirty =
|
||||||
|
JSON.stringify(draftSteps) !== JSON.stringify(plan.steps);
|
||||||
|
const mood = planMoodLabel({ steps: draftSteps });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<Link
|
||||||
|
to="/plans"
|
||||||
|
className="inline-flex items-center gap-1.5 self-start text-[12.5px] text-ink-3 hover:text-rubis"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={13} aria-hidden="true" /> Plans · {plan.name}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<header className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Eyebrow>{mood}</Eyebrow>
|
||||||
|
<h1 className="mt-2 font-display text-[28px] font-bold tracking-[-0.022em] text-ink lg:text-[32px]">
|
||||||
|
{plan.name}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1.5 text-[13px] text-ink-3">
|
||||||
|
{plan.usageCount > 0 ? (
|
||||||
|
<>
|
||||||
|
Utilisé sur{" "}
|
||||||
|
<strong className="font-semibold text-ink-2 tabular-nums">
|
||||||
|
{plan.usageCount}
|
||||||
|
</strong>{" "}
|
||||||
|
facture{plan.usageCount > 1 ? "s" : ""} active
|
||||||
|
{plan.usageCount > 1 ? "s" : ""}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="italic">Aucune facture active</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="secondary" size="sm" disabled>
|
||||||
|
<CopyIcon size={14} aria-hidden="true" /> Dupliquer
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!isDirty}
|
||||||
|
loading={saveMutation.isPending}
|
||||||
|
onClick={() => saveMutation.mutate(draftSteps)}
|
||||||
|
>
|
||||||
|
{isDirty ? "Enregistrer" : "Aucune modification"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_1.4fr]">
|
||||||
|
{/* === Cadence (left) === */}
|
||||||
|
<section aria-label="Cadence du plan" className="flex flex-col gap-3">
|
||||||
|
<Eyebrow tone="ink">Cadence</Eyebrow>
|
||||||
|
<ol className="flex flex-col gap-3">
|
||||||
|
{draftSteps.map((step, idx) => (
|
||||||
|
<li key={step.id}>
|
||||||
|
<StepCard
|
||||||
|
step={step}
|
||||||
|
index={idx}
|
||||||
|
selected={step.id === selectedStep?.id}
|
||||||
|
onSelect={() => setSelectedStepId(step.id)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
className={cn(
|
||||||
|
"rounded-default border border-dashed border-line bg-transparent",
|
||||||
|
"px-4 py-3 text-[13px] font-medium text-ink-3",
|
||||||
|
"transition-colors hover:border-ink-3 hover:text-ink-2",
|
||||||
|
"disabled:opacity-60 disabled:cursor-not-allowed",
|
||||||
|
"inline-flex items-center justify-center gap-1.5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus size={14} aria-hidden="true" /> Ajouter une étape
|
||||||
|
<span className="ml-1 text-[11px] italic">(bientôt)</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* === Email editor (right) === */}
|
||||||
|
<section aria-label="Édition de l'étape sélectionnée">
|
||||||
|
{selectedStep ? (
|
||||||
|
<Card padding="md" className="flex flex-col gap-5">
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<Eyebrow tone="ink">
|
||||||
|
Étape {(selectedStep.order ?? 0) + 1} ·{" "}
|
||||||
|
<span className="text-rubis">
|
||||||
|
J{selectedStep.offsetDays >= 0 ? "+" : ""}
|
||||||
|
{selectedStep.offsetDays}
|
||||||
|
</span>
|
||||||
|
</Eyebrow>
|
||||||
|
<span className="text-[12px] text-ink-3">
|
||||||
|
Tonalité · {TONE_LABELS[selectedStep.tone]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedStep.requiresManualValidation && (
|
||||||
|
<div className="flex items-start gap-2 rounded-default bg-rubis-glow/60 px-3 py-2 text-[12.5px] text-rubis-deep">
|
||||||
|
<AlertTriangle size={14} className="mt-0.5 shrink-0" aria-hidden="true" />
|
||||||
|
<p>
|
||||||
|
<strong className="font-semibold">Validation manuelle obligatoire.</strong>{" "}
|
||||||
|
L'email est <em className="not-italic font-medium">généré en brouillon</em>,
|
||||||
|
jamais envoyé automatiquement (cf. principe produit).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label="Objet de l'email" htmlFor={`subject-${selectedStep.id}`}>
|
||||||
|
<Input
|
||||||
|
id={`subject-${selectedStep.id}`}
|
||||||
|
value={selectedStep.subject}
|
||||||
|
onChange={(e) => updateSelectedStep({ subject: e.target.value })}
|
||||||
|
placeholder="Rappel — facture {{numero}}"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Corps"
|
||||||
|
htmlFor={`body-${selectedStep.id}`}
|
||||||
|
hint="Markdown léger non interprété. Les variables ci-dessous sont remplacées à l'envoi."
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
id={`body-${selectedStep.id}`}
|
||||||
|
ref={bodyRef}
|
||||||
|
rows={10}
|
||||||
|
className="font-mono text-[13px] leading-relaxed"
|
||||||
|
value={selectedStep.body}
|
||||||
|
onChange={(e) => updateSelectedStep({ body: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-ink-3">
|
||||||
|
Variables — clic pour insérer au curseur
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{TEMPLATE_VARIABLES.map((variable) => (
|
||||||
|
<button
|
||||||
|
key={variable.token}
|
||||||
|
type="button"
|
||||||
|
onClick={() => insertVariable(variable.token)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1",
|
||||||
|
"font-mono text-[11.5px] leading-tight",
|
||||||
|
"border-line bg-cream-2 text-ink-2",
|
||||||
|
"transition-colors hover:border-rubis hover:bg-rubis-glow hover:text-rubis-deep",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
|
||||||
|
)}
|
||||||
|
title={`Insère "${variable.token}" — ex: ${variable.preview.split("\n")[0]}`}
|
||||||
|
>
|
||||||
|
<span className="font-sans text-[11px] text-ink-3 not-italic">
|
||||||
|
{variable.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-rubis">{variable.token}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card padding="md">
|
||||||
|
<p className="text-[13px] italic text-ink-3">
|
||||||
|
Sélectionnez une étape à gauche pour l'éditer.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Card d'une étape dans la colonne cadence (gauche). */
|
||||||
|
function StepCard({
|
||||||
|
step,
|
||||||
|
index,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
step: PlanStep;
|
||||||
|
index: number;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={selected}
|
||||||
|
onClick={onSelect}
|
||||||
|
className={cn(
|
||||||
|
"group block w-full text-left",
|
||||||
|
"rounded-card border bg-white p-4 transition-all duration-150",
|
||||||
|
selected
|
||||||
|
? "border-rubis shadow-rubis"
|
||||||
|
: "border-line hover:border-ink-3",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"absolute left-0 top-4 bottom-4 w-[3px] rounded-r-full transition-colors",
|
||||||
|
selected ? "bg-rubis" : "bg-transparent",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<p className="font-sans text-[13.5px] font-semibold text-ink">
|
||||||
|
Étape {index + 1} ·{" "}
|
||||||
|
<span className="tabular-nums text-rubis">
|
||||||
|
J{step.offsetDays >= 0 ? "+" : ""}
|
||||||
|
{step.offsetDays}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[11px] font-semibold uppercase tracking-[0.1em]",
|
||||||
|
selected ? "text-rubis" : "text-ink-3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step.requiresManualValidation ? "Brouillon" : "Email"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-[12.5px] text-ink-2 line-clamp-2">
|
||||||
|
« {step.subject} »
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] text-ink-3">
|
||||||
|
Tonalité · {TONE_LABELS[step.tone as RelanceTone]}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 animate-pulse">
|
||||||
|
<div className="h-3 w-24 rounded bg-cream-2" />
|
||||||
|
<div className="h-8 w-1/3 rounded bg-cream-2" />
|
||||||
|
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_1.4fr]">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="h-24 rounded-card bg-cream-2" />
|
||||||
|
<div className="h-24 rounded-card bg-cream-2" />
|
||||||
|
<div className="h-24 rounded-card bg-cream-2" />
|
||||||
|
</div>
|
||||||
|
<div className="h-96 rounded-card bg-cream-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user