/* global React, ReactDOM */
const { useState, useEffect, useRef, useMemo } = React;
// ============================================================
// Carte Famili — Certificat d'inscription au régime de
// couverture santé familiale — bilingual editor
// (Original design — not affiliated with CNSS)
// ============================================================
const TWEAKS_DEFAULTS = /*EDITMODE-BEGIN*/{
"theme": "teal",
"pattern": "octagram",
"showLogo": true,
"showWatermark": true,
"layout": "classic"
}/*EDITMODE-END*/;
const THEMES = {
teal: { ink: "oklch(0.36 0.05 200)", brand: "oklch(0.50 0.09 195)", accent: "oklch(0.72 0.10 75)", paper: "oklch(0.985 0.012 85)", soft: "oklch(0.94 0.018 195)" },
plum: { ink: "oklch(0.30 0.05 320)", brand: "oklch(0.46 0.10 330)", accent: "oklch(0.74 0.10 60)", paper: "oklch(0.985 0.010 320)", soft: "oklch(0.94 0.018 320)" },
olive: { ink: "oklch(0.32 0.04 130)", brand: "oklch(0.48 0.08 140)", accent: "oklch(0.72 0.10 80)", paper: "oklch(0.985 0.012 100)", soft: "oklch(0.93 0.020 130)" },
ink: { ink: "oklch(0.20 0.01 250)", brand: "oklch(0.30 0.02 250)", accent: "oklch(0.70 0.13 50)", paper: "oklch(0.985 0.005 250)", soft: "oklch(0.93 0.008 250)" },
rose: { ink: "oklch(0.34 0.06 20)", brand: "oklch(0.50 0.11 20)", accent: "oklch(0.74 0.10 60)", paper: "oklch(0.985 0.012 30)", soft: "oklch(0.94 0.020 20)" },
};
// Relationship options — adapt to spouse type ----------------------
const SPOUSE_OPTIONS = {
wife: { relAr: "الزوجة", relFr: "Épouse" },
husband: { relAr: "الزوج", relFr: "Époux" },
none: { relAr: "—", relFr: "—" },
};
const CHILD_OPTIONS = [
{ value: "son", relAr: "ابن", relFr: "Fils" },
{ value: "daughter", relAr: "ابنة", relFr: "Fille" },
];
// ---- form state ----------------------------------------------------
const initialData = {
cardNumber: "921990126",
cin: "C689324",
idcs: "1234567890",
lastNameLat: "FENNICHA",
firstNameLat: "ABDELLAZIZ",
lastNameAr: "فنيشة",
firstNameAr: "عبد العزيز",
birthDate: "1970-02-15",
issueDate: "2023-05-23",
titulaireGender: "male",
footerFr: "Conçu par Brahim Es-Sbai · Écrivain Public",
footerAr: "من تصميم إبراهيم الصبائي · كاتب عمومي", // male => spouse is wife; female => husband
hasSpouse: true,
spouse: {
nameAr: "بوبدارة حفيظة",
id: "9159436153",
birthDate: "1975-08-12",
},
children: [
{ nameAr: "فنيشة غزلان", id: "5245986805", birthDate: "2000-04-10", kind: "daughter" },
{ nameAr: "فنيشة ابراهيم", id: "4510519488", birthDate: "2003-11-22", kind: "son" },
{ nameAr: "فنيشة معاد", id: "8299493429", birthDate: "2007-06-03", kind: "son" },
{ nameAr: "فنيشة اية", id: "7817893042", birthDate: "2010-09-15", kind: "daughter" },
],
};
// ---- helpers -------------------------------------------------------
function fmtDate(iso) {
if (!iso) return "—";
const [y, m, d] = iso.split("-");
if (!y) return iso;
return `${d}/${m}/${y}`;
}
function spaceDigits(s) { return (s || "").split("").join("\u2009"); }
function spouseRel(gender) {
return gender === "female" ? SPOUSE_OPTIONS.husband : SPOUSE_OPTIONS.wife;
}
function childRel(kind) {
return CHILD_OPTIONS.find(c => c.value === kind) || CHILD_OPTIONS[0];
}
// =====================================================================
// Geometric pattern — original octagram tessellation
// =====================================================================
function PatternDefs({ id, color, kind }) {
if (kind === "rings") {
return (
);
}
if (kind === "weave") {
return (
);
}
if (kind === "lines") {
return (
);
}
return (
);
}
function PatternBackground({ kind, color, opacity = 0.18 }) {
const id = `pat-${kind}`;
return (
);
}
// =====================================================================
// Original logo — three nested family shields
// =====================================================================
function CFLogo({ color, accent, size = 56 }) {
return (
);
}
// =====================================================================
// COMPACT CARD — for PNG download at business card size (54×85.6mm)
// Rendered at 600×950px (~280 DPI for crisp print)
// =====================================================================
function CardCompact({ data, theme }) {
const t = THEMES[theme] || THEMES.teal;
// Total family members count
const totalCount = 1 + (data.hasSpouse ? 1 : 0) + data.children.length;
// When totalCount >= 6, the holder + spouse info moves to the back card.
// The recto then shows ONLY the children (or nothing if no children).
const moveHolderToBack = totalCount >= 6;
// The list shown on the recto under "Bénéficiaires"
const beneficiaries = useMemo(() => {
const list = [];
// For 1 or 2 total: show holder (and spouse) — uses single/pair layout
// For 3+ total: omit holder from the list (no duplication)
// For 6+ total: also omit spouse (moved to back), show only children
if (totalCount <= 2) {
list.push({
nameAr: `${data.lastNameAr} ${data.firstNameAr}`.trim(),
id: data.idcs,
birthDate: data.birthDate,
relAr: "صاحب(ة) البطاقة",
});
if (data.hasSpouse) {
const r = spouseRel(data.titulaireGender);
list.push({
nameAr: data.spouse.nameAr,
id: data.spouse.id,
birthDate: data.spouse.birthDate,
relAr: r.relAr,
});
}
} else if (!moveHolderToBack && data.hasSpouse) {
// 3-5 total: show spouse but skip holder
const r = spouseRel(data.titulaireGender);
list.push({
nameAr: data.spouse.nameAr,
id: data.spouse.id,
birthDate: data.spouse.birthDate,
relAr: r.relAr,
});
}
// For 6+ total, holder & spouse are on the back — list only children below
data.children.forEach(c => {
const r = childRel(c.kind);
list.push({ nameAr: c.nameAr, id: c.id, birthDate: c.birthDate, relAr: r.relAr });
});
return list;
}, [data, totalCount, moveHolderToBack]);
return (
{/* Top accent bar */}
{/* Header — CNSS logo (left) | AMO TADAMON (center) | Morocco logo (right) */}
AMO TADAMON
Régime d'assurance maladie obligatoire
{/* Card number */}
N° d'immatriculation · رقم التسجيل
{spaceDigits(data.cardNumber || "—")}
{/* Titulaire identity — bilingual condensed */}
{data.lastNameLat} {data.firstNameLat}
{data.lastNameAr} {data.firstNameAr}
CIN: {data.cin || "—"}
IDCS: {data.idcs || "—"}
Né(e): {fmtDate(data.birthDate)}
Inscrit: {fmtDate(data.issueDate)}
{/* Beneficiaries — layout adapts to count */}
Bénéficiaires · {totalCount}
المستفيدون
{beneficiaries.length === 1 ? (
/* SINGLE BENEFICIARY — hero card layout */
{beneficiaries[0].nameAr || "—"}
IDCS
{beneficiaries[0].id || "—"}
Date de naissance
{fmtDate(beneficiaries[0].birthDate)}
) : beneficiaries.length === 2 ? (
/* TWO BENEFICIARIES — paired card layout */
{beneficiaries.map((b, i) => (
{b.relAr}
{b.nameAr || "—"}
IDCS
{b.id || "—"}
Né(e)
{fmtDate(b.birthDate)}
))}
) : (
/* 3-8 BENEFICIARIES — table layout (no relation column) */
{beneficiaries.map((b, i) => (
{i + 1}
{b.nameAr || "—"}
{b.id || "—"}
{fmtDate(b.birthDate)}
))}
)}
{/* Footer — designer signature (editable) */}
{data.footerFr && {data.footerFr} }
{data.footerAr && {data.footerAr} }
);
}
// =====================================================================
// CARD BACK — Verso of the business card (54×85.6mm vertical)
// Decorative side with logos and brand name only
// =====================================================================
function CardBack({ theme, data }) {
const t = THEMES[theme] || THEMES.teal;
const totalCount = data ? (1 + (data.hasSpouse ? 1 : 0) + data.children.length) : 0;
const showHolderInfo = data && totalCount >= 6;
return (
{/* Top accent bar */}
{/* Decorative geometric pattern background */}
{/* Decorative corner ornaments */}
{/* Content */}
{/* Morocco coat of arms — top */}
المملكة المغربية
Royaume du Maroc
{/* Center divider with ornament */}
{showHolderInfo ? (
/* When 6+ beneficiaries: holder + spouse info on the back */
{/* Card holder */}
صاحب(ة) البطاقة
{`${data.lastNameAr} ${data.firstNameAr}`.trim() || "—"}
{`${data.lastNameLat} ${data.firstNameLat}`.trim()}
IDCS
{data.idcs || "—"}
Né(e)
{fmtDate(data.birthDate)}
{/* Spouse — only if hasSpouse */}
{data.hasSpouse && (
{spouseRel(data.titulaireGender).relAr}
{data.spouse.nameAr || "—"}
IDCS
{data.spouse.id || "—"}
Né(e)
{fmtDate(data.spouse.birthDate)}
)}
) : (
/* Default decorative back (no holder info) */
AMO TADAMON
آمو تضامن
Régime d'Assurance Maladie Obligatoire
نظام التأمين الإجباري الأساسي عن المرض
)}
{/* Center divider */}
{/* CNSS — bottom */}
الصندوق الوطني للضمان الاجتماعي
Caisse Nationale de Sécurité Sociale
{/* Bottom accent bar */}
);
}
// =====================================================================
// CARD
// =====================================================================
function Card({ data, theme, pattern, showLogo, showWatermark, layout }) {
const t = THEMES[theme] || THEMES.teal;
// Build beneficiaries list dynamically
const beneficiaries = useMemo(() => {
const list = [{
nameAr: `${data.lastNameAr} ${data.firstNameAr}`.trim(),
id: data.idcs,
birthDate: data.birthDate,
relAr: "صاحب(ة) البطاقة",
relFr: "Titulaire",
}];
if (data.hasSpouse) {
const r = spouseRel(data.titulaireGender);
list.push({
nameAr: data.spouse.nameAr,
id: data.spouse.id,
birthDate: data.spouse.birthDate,
relAr: r.relAr,
relFr: r.relFr,
});
}
data.children.forEach(c => {
const r = childRel(c.kind);
list.push({ nameAr: c.nameAr, id: c.id, birthDate: c.birthDate, relAr: r.relAr, relFr: r.relFr });
});
return list;
}, [data]);
return (
{showWatermark && (
)}
{/* Header */}
{/* Number band */}
N° d'immatriculation
{spaceDigits(data.cardNumber || "—")}
رقم التسجيل
{/* Titulaire identity — Arabic on RIGHT, French on LEFT */}
{/* Beneficiaries section — improved table-style */}
Bénéficiaires · {beneficiaries.length}
المستفيدون · {beneficiaries.length}
#
الصلة
الاسم الكامل
تاريخ الازدياد
IDCS · المعرف الرقمي
{beneficiaries.map((b, i) => (
{i + 1}
{b.relAr}
{b.nameAr || "—"}
{fmtDate(b.birthDate)}
{b.id || "—"}
))}
{/* Footer removed per user request */}
);
}
function Field({ label, value }) {
return (
);
}
function FieldAr({ label, value }) {
return (
);
}
// =====================================================================
// FORM
// =====================================================================
function Form({ data, setData }) {
const update = (k, v) => setData({ ...data, [k]: v });
const updateSpouse = (k, v) => setData({ ...data, spouse: { ...data.spouse, [k]: v } });
const updateChild = (i, k, v) => {
const children = data.children.map((c, idx) => idx === i ? { ...c, [k]: v } : c);
setData({ ...data, children });
};
const addChild = () => {
if (data.children.length >= 8) return;
setData({ ...data, children: [...data.children, { nameAr: "", id: "", birthDate: "", kind: "son" }] });
};
const rmChild = (i) => setData({ ...data, children: data.children.filter((_, idx) => idx !== i) });
const spouseLabel = data.titulaireGender === "female" ? "Époux / الزوج" : "Épouse / الزوجة";
return (
update("cardNumber", v)} />
update("cin", v)} />
update("idcs", v)} />
update("lastNameLat", v.toUpperCase())} />
update("firstNameLat", v.toUpperCase())} />
update("lastNameAr", v)} rtl />
update("firstNameAr", v)} rtl />
update("birthDate", v)} />
update("issueDate", v)} />
update("titulaireGender", v)}
options={[
{ value: "male", label: "Homme · ذكر" },
{ value: "female", label: "Femme · أنثى" },
]}
/>
update("hasSpouse", v)}
/>
}
>
{data.hasSpouse ? (
<>
updateSpouse("nameAr", v)} rtl />
updateSpouse("id", v)} />
updateSpouse("birthDate", v)} />
>
) : (
Aucun conjoint inclus.
)}
= 8}>
+ Ajouter
}
>
{data.children.length === 0 && (
Aucun enfant ajouté.
)}
{data.children.map((c, i) => (
{i + 1}
updateChild(i, "nameAr", v)} rtl />
updateChild(i, "id", v)} />
updateChild(i, "birthDate", v)} />
updateChild(i, "kind", v)}
options={[
{ value: "son", label: "Fils · ابن" },
{ value: "daughter", label: "Fille · ابنة" },
]}
/>
rmChild(i)} aria-label="Supprimer">×
))}
update("footerFr", v)}
/>
update("footerAr", v)}
rtl
/>
);
}
function FormSection({ title, subtitle, children, right }) {
return (
{title}
{subtitle &&
{subtitle}
}
{right}
{children}
);
}
function Row({ children }) { return {children}
; }
function Input({ label, value, onChange, type = "text", rtl }) {
return (
{label}
onChange(e.target.value)}
dir={rtl ? "rtl" : "ltr"}
/>
);
}
function SelectField({ label, value, onChange, options }) {
return (
{label}
onChange(e.target.value)}>
{options.map(o => {o.label} )}
);
}
function RadioGroup({ label, value, onChange, options }) {
return (
);
}
function ToggleSwitch({ label, checked, onChange }) {
return (
{label}
onChange(e.target.checked)} />
);
}
// =====================================================================
// TWEAKS
// =====================================================================
function TweaksUI({ tweaks, setTweak }) {
const { TweaksPanel, TweakSection, TweakRadio, TweakToggle, TweakSelect } = window;
return (
setTweak("theme", v)}
options={[
{ value: "teal", label: "Teal" },
{ value: "plum", label: "Plum" },
{ value: "olive", label: "Olive" },
{ value: "ink", label: "Ink (mono)" },
{ value: "rose", label: "Rose" },
]}
/>
setTweak("pattern", v)}
options={[
{ value: "octagram", label: "Octagramme" },
{ value: "weave", label: "Tressage" },
{ value: "rings", label: "Anneaux" },
{ value: "lines", label: "Hachures" },
]}
/>
setTweak("layout", v)}
options={[
{ value: "classic", label: "Classique" },
{ value: "compact", label: "Compact" },
]}
/>
setTweak("showLogo", v)} />
setTweak("showWatermark", v)} />
);
}
// =====================================================================
// APP
// =====================================================================
function App() {
const [data, setData] = useState(initialData);
const [tweaks, setTweak] = window.useTweaks
? window.useTweaks(TWEAKS_DEFAULTS)
: [TWEAKS_DEFAULTS, () => {}];
const handlePrint = () => window.print();
// Generic helper to download a card element as PNG
const downloadCardAsPNG = async (selector, suffix) => {
const el = document.querySelector(selector);
if (!el) return;
if (!window.htmlToImage) {
alert("Bibliothèque html-to-image non chargée. Vérifiez votre connexion internet.");
return;
}
try {
const original = el.getAttribute("style") || "";
el.setAttribute("style",
original + "; width: 600px !important; height: 950px !important; transform: none !important;"
);
const dataUrl = await window.htmlToImage.toPng(el, {
pixelRatio: 2,
backgroundColor: "#ffffff",
width: 600,
height: 950,
canvasWidth: 600,
canvasHeight: 950,
skipFonts: false,
cacheBust: true,
});
if (original) el.setAttribute("style", original);
else el.removeAttribute("style");
const link = document.createElement("a");
const fname = (data.lastNameLat || "carte") + "_" + (data.firstNameLat || "amo") + "_" + suffix + ".png";
link.download = fname.toLowerCase().replace(/\s+/g, "_");
link.href = dataUrl;
link.click();
} catch (err) {
console.error(err);
alert("Erreur lors du téléchargement: " + err.message);
}
};
const handleDownloadPNG = () => downloadCardAsPNG(".card-compact", "amo_recto");
const handleDownloadBackPNG = () => downloadCardAsPNG(".card-back", "amo_verso");
const handleReset = () => {
if (confirm("Effacer tous les champs ?")) {
setData({
cardNumber: "", cin: "", idcs: "",
lastNameLat: "", firstNameLat: "",
lastNameAr: "", firstNameAr: "",
birthDate: "", issueDate: "",
titulaireGender: "male",
footerFr: "", footerAr: "",
hasSpouse: false,
spouse: { nameAr: "", id: "", birthDate: "" },
children: [],
});
}
};
return (
Aperçu — Recto / Verso
{1 + (data.hasSpouse ? 1 : 0) + data.children.length} bénéficiaire(s)
);
}
ReactDOM.createRoot(document.getElementById("root")).render( );