Initial local backup snapshot

This commit is contained in:
Selecta Keke 2026-06-24 00:02:55 -03:00
commit acd9e14ba5
367 changed files with 118038 additions and 0 deletions

19
shared/_core/errors.ts Normal file
View file

@ -0,0 +1,19 @@
/**
* Base HTTP error class with status code.
* Throw this from route handlers to send specific HTTP errors.
*/
export class HttpError extends Error {
constructor(
public statusCode: number,
message: string
) {
super(message);
this.name = "HttpError";
}
}
// Convenience constructors
export const BadRequestError = (msg: string) => new HttpError(400, msg);
export const UnauthorizedError = (msg: string) => new HttpError(401, msg);
export const ForbiddenError = (msg: string) => new HttpError(403, msg);
export const NotFoundError = (msg: string) => new HttpError(404, msg);

764
shared/appearance.ts Normal file
View file

@ -0,0 +1,764 @@
export const appearanceThemeModes = ["light", "dark", "system"] as const;
export const appearanceFontFamilies = ["marianne", "system", "rounded", "serif"] as const;
export const appearanceCardStyles = ["soft", "outlined", "elevated"] as const;
export const appearanceTableStyles = ["comfortable", "compact", "striped"] as const;
export const appearanceDisplayDensities = ["comfortable", "compact", "detailed"] as const;
export const appearanceSidebarStyles = ["soft", "contrast", "floating"] as const;
export const appearanceButtonStyles = ["rounded", "pill", "square"] as const;
export const appearanceInputStyles = ["outline", "soft", "filled"] as const;
export const appearanceBackgroundModes = ["cover", "repeat", "fixed"] as const;
export const appearanceBackgroundPresentationModes = ["full", "edge_frame"] as const;
export const appearanceDecorationScopes = [
"all",
"home",
"public",
"public_directory",
"public_map",
"dashboard",
"dashboard_home",
"dashboard_profile",
"dashboard_documents",
"dashboard_requests",
"dashboard_new_request",
"dashboard_reservation_salle",
"dashboard_material",
"dashboard_directory",
"admin",
"admin_pilotage",
"admin_logistics",
"admin_directory",
"admin_compliance",
] as const;
export const appearanceDecorationLayers = ["background", "card"] as const;
export const appearanceCardDecorationAnchors = ["top_left", "top_right", "bottom_left", "bottom_right"] as const;
export const appearanceBackgroundPositions = [
"center",
"top",
"bottom",
"left",
"right",
"top_left",
"top_right",
"bottom_left",
"bottom_right",
] as const;
export const appearanceBackgroundTargets = [
"home",
"public",
"public_directory",
"public_map",
"dashboard",
"dashboard_home",
"dashboard_profile",
"dashboard_documents",
"dashboard_requests",
"dashboard_new_request",
"dashboard_reservation_salle",
"dashboard_material",
"dashboard_directory",
"admin",
"admin_pilotage",
"admin_logistics",
"admin_directory",
"admin_compliance",
] as const;
export const appearanceAssetFields = ["logoUrl", "faviconUrl", "heroImageUrl", "backgroundImageUrl"] as const;
export type AppearanceThemeMode = (typeof appearanceThemeModes)[number];
export type AppearanceFontFamily = (typeof appearanceFontFamilies)[number];
export type AppearanceCardStyle = (typeof appearanceCardStyles)[number];
export type AppearanceTableStyle = (typeof appearanceTableStyles)[number];
export type AppearanceDisplayDensity = (typeof appearanceDisplayDensities)[number];
export type AppearanceSidebarStyle = (typeof appearanceSidebarStyles)[number];
export type AppearanceButtonStyle = (typeof appearanceButtonStyles)[number];
export type AppearanceInputStyle = (typeof appearanceInputStyles)[number];
export type AppearanceBackgroundMode = (typeof appearanceBackgroundModes)[number];
export type AppearanceBackgroundPresentationMode = (typeof appearanceBackgroundPresentationModes)[number];
export type AppearanceDecorationScope = (typeof appearanceDecorationScopes)[number];
export type AppearanceDecorationLayer = (typeof appearanceDecorationLayers)[number];
export type AppearanceCardDecorationAnchor = (typeof appearanceCardDecorationAnchors)[number];
export type AppearanceBackgroundPosition = (typeof appearanceBackgroundPositions)[number];
export type AppearanceBackgroundTarget = (typeof appearanceBackgroundTargets)[number];
export type AppearanceAssetField = (typeof appearanceAssetFields)[number];
export interface PortalAppearanceDecoration {
id: string;
imageUrl: string;
scope: AppearanceDecorationScope;
layer: AppearanceDecorationLayer;
widthPercent: number;
topPercent: number;
leftPercent: number;
opacity: number;
blurRadius: number;
cardAnchor: AppearanceCardDecorationAnchor;
cardInsetPercent: number;
}
export interface PortalAppearance {
themeMode: AppearanceThemeMode;
activeSkinId: string;
portalTitle: string;
portalTagline: string;
primaryColor: string;
secondaryColor: string;
accentColor: string;
buttonColor: string;
alertColor: string;
fontFamily: AppearanceFontFamily;
headingFontFamily: AppearanceFontFamily;
headingScalePercent: number;
bodyScalePercent: number;
logoUrl: string;
faviconUrl: string;
heroImageUrl: string;
backgroundImageUrl: string;
backgroundEnabled: boolean;
backgroundMode: AppearanceBackgroundMode;
backgroundPresentationMode: AppearanceBackgroundPresentationMode;
backgroundOverlayOpacity: number;
backgroundBlurRadius: number;
backgroundTargets: AppearanceBackgroundTarget[];
backgroundTargetImageUrls: Partial<Record<AppearanceBackgroundTarget, string>>;
backgroundTargetOverlayOpacities: Partial<Record<AppearanceBackgroundTarget, number>>;
backgroundTargetBlurRadii: Partial<Record<AppearanceBackgroundTarget, number>>;
backgroundTargetPositions: Partial<Record<AppearanceBackgroundTarget, AppearanceBackgroundPosition>>;
backgroundTargetPresentationModes: Partial<Record<AppearanceBackgroundTarget, AppearanceBackgroundPresentationMode>>;
heroImagePositionX: number;
heroImagePositionY: number;
heroOverlayOpacity: number;
heroTitlePositionX: number;
heroTitlePositionY: number;
heroTitleWidthPercent: number;
heroTaglinePositionX: number;
heroTaglinePositionY: number;
heroTaglineWidthPercent: number;
decorativeElements: PortalAppearanceDecoration[];
cardStyle: AppearanceCardStyle;
tableStyle: AppearanceTableStyle;
displayDensity: AppearanceDisplayDensity;
sidebarStyle: AppearanceSidebarStyle;
buttonStyle: AppearanceButtonStyle;
inputStyle: AppearanceInputStyle;
}
export interface AppearanceSkinPreset {
id: string;
label: string;
description: string;
category: string;
usage: string[];
value: Partial<PortalAppearance>;
}
export const APPEARANCE_SETTING_KEY = "appearance";
export const defaultPortalAppearance: PortalAppearance = {
themeMode: "light",
activeSkinId: "guyane-institutionnelle",
portalTitle: "Portail Associations 973",
portalTagline: "Démarches, annuaire et services du tissu associatif guyanais",
primaryColor: "#2e7d32",
secondaryColor: "#1565c0",
accentColor: "#f9a825",
buttonColor: "#1565c0",
alertColor: "#dc2626",
fontFamily: "marianne",
headingFontFamily: "marianne",
headingScalePercent: 100,
bodyScalePercent: 100,
logoUrl: "",
faviconUrl: "",
heroImageUrl: "",
backgroundImageUrl: "",
backgroundEnabled: false,
backgroundMode: "cover",
backgroundPresentationMode: "full",
backgroundOverlayOpacity: 72,
backgroundBlurRadius: 0,
backgroundTargets: ["home", "public", "dashboard", "admin"],
backgroundTargetImageUrls: {},
backgroundTargetOverlayOpacities: {},
backgroundTargetBlurRadii: {},
backgroundTargetPositions: {},
backgroundTargetPresentationModes: {},
heroImagePositionX: 50,
heroImagePositionY: 50,
heroOverlayOpacity: 74,
heroTitlePositionX: 0,
heroTitlePositionY: 0,
heroTitleWidthPercent: 50,
heroTaglinePositionX: 0,
heroTaglinePositionY: 0,
heroTaglineWidthPercent: 80,
decorativeElements: [],
cardStyle: "soft",
tableStyle: "comfortable",
displayDensity: "comfortable",
sidebarStyle: "soft",
buttonStyle: "rounded",
inputStyle: "outline",
};
export const appearanceSkinPresets: AppearanceSkinPreset[] = [
{
id: "guyane-institutionnelle",
label: "Guyane institutionnelle",
description: "Palette ancrée dans le territoire, pensée pour les collectivités, services publics et fédérations.",
category: "Institutionnel",
usage: ["collectivités", "mairies", "fédérations", "services publics"],
value: {
activeSkinId: "guyane-institutionnelle",
portalTitle: "Portail Associations 973",
portalTagline: "Lespace territorial des démarches associatives en Guyane",
primaryColor: "#2e7d32",
secondaryColor: "#1565c0",
accentColor: "#f9a825",
buttonColor: "#1565c0",
alertColor: "#c62828",
fontFamily: "marianne",
headingFontFamily: "marianne",
cardStyle: "soft",
tableStyle: "comfortable",
displayDensity: "comfortable",
sidebarStyle: "soft",
buttonStyle: "rounded",
inputStyle: "outline",
backgroundEnabled: false,
backgroundMode: "cover",
backgroundPresentationMode: "full",
backgroundOverlayOpacity: 72,
backgroundBlurRadius: 0,
backgroundTargets: ["home", "public", "dashboard", "admin"],
backgroundTargetImageUrls: {},
themeMode: "light",
},
},
{
id: "centre-spatial-guyanais",
label: "Centre spatial guyanais",
description: "Ambiance technologique et institutionnelle, inspirée du spatial, de linnovation et des grands projets.",
category: "Spatial",
usage: ["innovation", "recherche", "éducation", "projets scientifiques"],
value: {
activeSkinId: "centre-spatial-guyanais",
portalTitle: "Portail Associations 973",
portalTagline: "Innovation, projets et coopérations du territoire guyanais",
primaryColor: "#0b3d91",
secondaryColor: "#1f5fbf",
accentColor: "#f6b317",
buttonColor: "#155eef",
alertColor: "#d92d20",
fontFamily: "system",
headingFontFamily: "rounded",
cardStyle: "elevated",
tableStyle: "striped",
displayDensity: "compact",
sidebarStyle: "contrast",
buttonStyle: "pill",
inputStyle: "soft",
backgroundEnabled: false,
backgroundMode: "cover",
backgroundPresentationMode: "full",
backgroundOverlayOpacity: 68,
backgroundBlurRadius: 0,
backgroundTargets: ["home", "public", "dashboard", "admin"],
backgroundTargetImageUrls: {},
themeMode: "light",
},
},
{
id: "nature-amazonienne",
label: "Nature amazonienne",
description: "Univers plus organique, centré sur la biodiversité, lenvironnement et les initiatives de terrain.",
category: "Territoire",
usage: ["environnement", "tourisme", "biodiversité", "éducation populaire"],
value: {
activeSkinId: "nature-amazonienne",
portalTitle: "Portail Associations 973",
portalTagline: "Les initiatives guyanaises entre nature, territoire et engagement",
primaryColor: "#1b5e20",
secondaryColor: "#4caf50",
accentColor: "#0277bd",
buttonColor: "#2e7d32",
alertColor: "#d84315",
fontFamily: "serif",
headingFontFamily: "serif",
cardStyle: "outlined",
tableStyle: "comfortable",
displayDensity: "detailed",
sidebarStyle: "floating",
buttonStyle: "rounded",
inputStyle: "filled",
backgroundEnabled: false,
backgroundMode: "cover",
backgroundPresentationMode: "full",
backgroundOverlayOpacity: 70,
backgroundBlurRadius: 0,
backgroundTargets: ["home", "public", "dashboard", "admin"],
backgroundTargetImageUrls: {},
themeMode: "light",
},
},
{
id: "republicain",
label: "Républicain",
description: "Lecture claire, signal institutionnel fort, pour les espaces administratifs et les usages plus officiels.",
category: "Administration",
usage: ["préfecture", "administration", "services publics", "collectivités"],
value: {
activeSkinId: "republicain",
portalTitle: "Portail Associations 973",
portalTagline: "Un portail administratif lisible, fiable et immédiatement identifiable",
primaryColor: "#0055a4",
secondaryColor: "#ffffff",
accentColor: "#ef4135",
buttonColor: "#0055a4",
alertColor: "#b42318",
fontFamily: "marianne",
headingFontFamily: "marianne",
cardStyle: "outlined",
tableStyle: "compact",
displayDensity: "compact",
sidebarStyle: "contrast",
buttonStyle: "square",
inputStyle: "outline",
backgroundEnabled: false,
backgroundMode: "cover",
backgroundPresentationMode: "full",
backgroundOverlayOpacity: 78,
backgroundBlurRadius: 0,
backgroundTargets: ["home", "public", "dashboard", "admin"],
backgroundTargetImageUrls: {},
themeMode: "light",
},
},
{
id: "institution-immersif",
label: "Institution immersif",
description: "Preset prêt à lemploi pour un fond premium lisible, avec centre neutre et bords illustrés renforcés.",
category: "Immersif",
usage: ["dashboard", "portail institutionnel", "parcours connectés", "habillage premium"],
value: {
activeSkinId: "institution-immersif",
portalTitle: "Portail Associations 973",
portalTagline: "Un portail territorial clair, immersif et immédiatement lisible",
primaryColor: "#2e7d32",
secondaryColor: "#1565c0",
accentColor: "#f9a825",
buttonColor: "#1565c0",
alertColor: "#c62828",
fontFamily: "marianne",
headingFontFamily: "marianne",
cardStyle: "soft",
tableStyle: "comfortable",
displayDensity: "compact",
sidebarStyle: "soft",
buttonStyle: "rounded",
inputStyle: "outline",
backgroundEnabled: true,
backgroundMode: "cover",
backgroundPresentationMode: "edge_frame",
backgroundOverlayOpacity: 62,
backgroundBlurRadius: 0,
backgroundTargets: ["home", "public", "dashboard", "admin"],
backgroundTargetImageUrls: {},
backgroundTargetOverlayOpacities: {
home: 58,
public: 64,
dashboard: 60,
admin: 68,
},
backgroundTargetPositions: {
home: "center",
public: "center",
dashboard: "right",
admin: "right",
},
backgroundTargetPresentationModes: {
home: "edge_frame",
public: "edge_frame",
dashboard: "edge_frame",
admin: "edge_frame",
},
themeMode: "light",
},
},
{
id: "parcours-guide-immersif",
label: "Parcours guidé immersif",
description: "Met en avant les parcours, les formulaires et les cartes avec un habillage plus narratif et très lisible.",
category: "Immersif",
usage: ["nouvelle demande", "réservation", "matériel", "parcours guidés"],
value: {
activeSkinId: "parcours-guide-immersif",
portalTitle: "Portail Associations 973",
portalTagline: "Des démarches plus claires, étape par étape",
primaryColor: "#2f9e44",
secondaryColor: "#1d4ed8",
accentColor: "#f6c453",
buttonColor: "#2563eb",
alertColor: "#c2410c",
fontFamily: "marianne",
headingFontFamily: "rounded",
cardStyle: "soft",
tableStyle: "comfortable",
displayDensity: "compact",
sidebarStyle: "floating",
buttonStyle: "pill",
inputStyle: "soft",
backgroundEnabled: true,
backgroundMode: "cover",
backgroundPresentationMode: "edge_frame",
backgroundOverlayOpacity: 56,
backgroundBlurRadius: 1,
backgroundTargets: [
"dashboard",
"dashboard_home",
"dashboard_new_request",
"dashboard_reservation_salle",
"dashboard_material",
],
backgroundTargetImageUrls: {},
backgroundTargetOverlayOpacities: {
dashboard: 58,
dashboard_home: 54,
dashboard_new_request: 52,
dashboard_reservation_salle: 56,
dashboard_material: 56,
},
backgroundTargetPositions: {
dashboard: "right",
dashboard_home: "right",
dashboard_new_request: "right",
dashboard_reservation_salle: "bottom_right",
dashboard_material: "bottom_left",
},
backgroundTargetPresentationModes: {
dashboard: "edge_frame",
dashboard_home: "edge_frame",
dashboard_new_request: "edge_frame",
dashboard_reservation_salle: "edge_frame",
dashboard_material: "edge_frame",
},
themeMode: "light",
},
},
{
id: "pilotage-territorial",
label: "Pilotage territorial",
description: "Preset plus sobre et plus dense, pensé pour le bordereau, la logistique et les référentiels admin.",
category: "Immersif",
usage: ["bordereau", "logistique", "référentiels", "pilotage admin"],
value: {
activeSkinId: "pilotage-territorial",
portalTitle: "Portail Associations 973",
portalTagline: "Référentiels, logistique et pilotage du territoire",
primaryColor: "#256f5c",
secondaryColor: "#0f4c81",
accentColor: "#d97706",
buttonColor: "#0f4c81",
alertColor: "#b91c1c",
fontFamily: "marianne",
headingFontFamily: "marianne",
cardStyle: "outlined",
tableStyle: "compact",
displayDensity: "compact",
sidebarStyle: "soft",
buttonStyle: "rounded",
inputStyle: "outline",
backgroundEnabled: true,
backgroundMode: "cover",
backgroundPresentationMode: "edge_frame",
backgroundOverlayOpacity: 68,
backgroundBlurRadius: 0,
backgroundTargets: ["admin", "admin_pilotage", "admin_logistics", "admin_directory", "admin_compliance"],
backgroundTargetImageUrls: {},
backgroundTargetOverlayOpacities: {
admin: 68,
admin_pilotage: 70,
admin_logistics: 66,
admin_directory: 64,
admin_compliance: 72,
},
backgroundTargetPositions: {
admin: "right",
admin_pilotage: "center",
admin_logistics: "bottom_right",
admin_directory: "left",
admin_compliance: "top_right",
},
backgroundTargetPresentationModes: {
admin: "edge_frame",
admin_pilotage: "edge_frame",
admin_logistics: "edge_frame",
admin_directory: "edge_frame",
admin_compliance: "edge_frame",
},
themeMode: "light",
},
},
];
const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
function normalizeHexColor(value: string | undefined, fallback: string) {
const trimmed = value?.trim();
if (!trimmed || !HEX_COLOR_REGEX.test(trimmed)) {
return fallback;
}
if (trimmed.length === 4) {
return `#${trimmed[1]}${trimmed[1]}${trimmed[2]}${trimmed[2]}${trimmed[3]}${trimmed[3]}`.toLowerCase();
}
return trimmed.toLowerCase();
}
function normalizeUrl(value: string | undefined) {
return value?.trim() || "";
}
function normalizeText(value: string | undefined, fallback: string, maxLength: number) {
const trimmed = value?.trim();
if (!trimmed) {
return fallback;
}
return trimmed.slice(0, maxLength);
}
function normalizePercent(value: number | undefined, fallback: number, min = 0, max = 100) {
if (typeof value !== "number" || Number.isNaN(value)) {
return fallback;
}
return Math.min(max, Math.max(min, Math.round(value)));
}
function normalizeOption<T extends readonly string[]>(value: string | undefined, allowed: T, fallback: T[number]) {
if (value && allowed.includes(value)) {
return value as T[number];
}
return fallback;
}
function normalizeOptionsArray<T extends readonly string[]>(value: string[] | undefined, allowed: T, fallback: readonly T[number][]) {
if (!Array.isArray(value)) {
return [...fallback] as T[number][];
}
const filtered = value.filter((entry): entry is T[number] => allowed.includes(entry as T[number]));
return filtered.length ? Array.from(new Set(filtered)) : [...fallback] as T[number][];
}
function normalizeTargetImageUrls(
value: Partial<Record<string, string>> | undefined,
allowed: readonly AppearanceBackgroundTarget[],
) {
const next: Partial<Record<AppearanceBackgroundTarget, string>> = {};
if (!value || typeof value !== "object") {
return next;
}
for (const target of allowed) {
const normalized = normalizeUrl(value[target]);
if (normalized) {
next[target] = normalized;
}
}
return next;
}
function normalizeTargetOverlayOpacities(
value: Partial<Record<string, number>> | undefined,
allowed: readonly AppearanceBackgroundTarget[],
) {
const next: Partial<Record<AppearanceBackgroundTarget, number>> = {};
if (!value || typeof value !== "object") {
return next;
}
for (const target of allowed) {
const raw = value[target];
if (typeof raw === "number" && !Number.isNaN(raw)) {
next[target] = Math.min(100, Math.max(0, Math.round(raw)));
}
}
return next;
}
function normalizeTargetBlurRadii(
value: Partial<Record<string, number>> | undefined,
allowed: readonly AppearanceBackgroundTarget[],
) {
const next: Partial<Record<AppearanceBackgroundTarget, number>> = {};
if (!value || typeof value !== "object") {
return next;
}
for (const target of allowed) {
const raw = value[target];
if (typeof raw === "number" && !Number.isNaN(raw)) {
next[target] = Math.min(24, Math.max(0, Math.round(raw)));
}
}
return next;
}
function normalizeTargetPositions(
value: Partial<Record<string, string>> | undefined,
allowed: readonly AppearanceBackgroundTarget[],
) {
const next: Partial<Record<AppearanceBackgroundTarget, AppearanceBackgroundPosition>> = {};
if (!value || typeof value !== "object") {
return next;
}
for (const target of allowed) {
const raw = value[target];
if (raw && appearanceBackgroundPositions.includes(raw as AppearanceBackgroundPosition)) {
next[target] = raw as AppearanceBackgroundPosition;
}
}
return next;
}
function normalizeTargetPresentationModes(
value: Partial<Record<string, string>> | undefined,
allowed: readonly AppearanceBackgroundTarget[],
) {
const next: Partial<Record<AppearanceBackgroundTarget, AppearanceBackgroundPresentationMode>> = {};
if (!value || typeof value !== "object") {
return next;
}
for (const target of allowed) {
const raw = value[target];
if (raw && appearanceBackgroundPresentationModes.includes(raw as AppearanceBackgroundPresentationMode)) {
next[target] = raw as AppearanceBackgroundPresentationMode;
}
}
return next;
}
function normalizeDecorativeElements(value: unknown): PortalAppearanceDecoration[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry, index) => {
const raw = entry && typeof entry === "object" ? entry as Partial<PortalAppearanceDecoration> : {};
const imageUrl = normalizeUrl(raw.imageUrl);
if (!imageUrl) {
return null;
}
return {
id: normalizeText(raw.id, `decor-${index + 1}`, 80),
imageUrl,
scope: normalizeOption(raw.scope, appearanceDecorationScopes, "all"),
layer: normalizeOption(raw.layer, appearanceDecorationLayers, "background"),
widthPercent: normalizePercent(raw.widthPercent, 18, 6, 38),
topPercent: normalizePercent(raw.topPercent, 20, 0, 100),
leftPercent: normalizePercent(raw.leftPercent, 10, 0, 100),
opacity: normalizePercent(raw.opacity, 12, 4, 28),
blurRadius: normalizePercent(raw.blurRadius, 2, 0, 12),
cardAnchor: normalizeOption(raw.cardAnchor, appearanceCardDecorationAnchors, "top_right"),
cardInsetPercent: normalizePercent(raw.cardInsetPercent, 6, 0, 20),
} satisfies PortalAppearanceDecoration;
})
.filter((entry): entry is PortalAppearanceDecoration => Boolean(entry))
.slice(0, 6);
}
export function getAppearanceSkinPreset(presetId: string | undefined) {
return appearanceSkinPresets.find((preset) => preset.id === presetId) || null;
}
export function sanitizePortalAppearance(value?: Partial<PortalAppearance> | null): PortalAppearance {
const preset = getAppearanceSkinPreset(value?.activeSkinId) || getAppearanceSkinPreset(defaultPortalAppearance.activeSkinId);
const presetValue = preset?.value || {};
const base: PortalAppearance = {
...defaultPortalAppearance,
...presetValue,
};
const normalizedBackgroundImageUrl = normalizeUrl(value?.backgroundImageUrl);
const normalizedBackgroundTargetImageUrls = normalizeTargetImageUrls(value?.backgroundTargetImageUrls, appearanceBackgroundTargets);
const normalizedBackgroundTargetOverlayOpacities = normalizeTargetOverlayOpacities(
value?.backgroundTargetOverlayOpacities,
appearanceBackgroundTargets
);
const normalizedBackgroundTargetBlurRadii = normalizeTargetBlurRadii(
value?.backgroundTargetBlurRadii,
appearanceBackgroundTargets
);
const normalizedBackgroundTargetPositions = normalizeTargetPositions(
value?.backgroundTargetPositions,
appearanceBackgroundTargets
);
const normalizedBackgroundTargetPresentationModes = normalizeTargetPresentationModes(
value?.backgroundTargetPresentationModes,
appearanceBackgroundTargets
);
const normalizedDecorativeElements = normalizeDecorativeElements(value?.decorativeElements);
const hasAnyBackgroundAsset =
Boolean(normalizedBackgroundImageUrl) || Object.values(normalizedBackgroundTargetImageUrls).some(Boolean);
return {
themeMode: normalizeOption(value?.themeMode, appearanceThemeModes, base.themeMode),
activeSkinId: normalizeText(value?.activeSkinId, base.activeSkinId, 80),
portalTitle: normalizeText(value?.portalTitle, base.portalTitle, 120),
portalTagline: normalizeText(value?.portalTagline, base.portalTagline, 220),
primaryColor: normalizeHexColor(value?.primaryColor, base.primaryColor),
secondaryColor: normalizeHexColor(value?.secondaryColor, base.secondaryColor),
accentColor: normalizeHexColor(value?.accentColor, base.accentColor),
buttonColor: normalizeHexColor(value?.buttonColor, base.buttonColor),
alertColor: normalizeHexColor(value?.alertColor, base.alertColor),
fontFamily: normalizeOption(value?.fontFamily, appearanceFontFamilies, base.fontFamily),
headingFontFamily: normalizeOption(value?.headingFontFamily, appearanceFontFamilies, base.headingFontFamily),
headingScalePercent: normalizePercent(value?.headingScalePercent, base.headingScalePercent, 85, 130),
bodyScalePercent: normalizePercent(value?.bodyScalePercent, base.bodyScalePercent, 90, 115),
logoUrl: normalizeUrl(value?.logoUrl),
faviconUrl: normalizeUrl(value?.faviconUrl),
heroImageUrl: normalizeUrl(value?.heroImageUrl),
backgroundImageUrl: normalizedBackgroundImageUrl,
backgroundEnabled: Boolean(value?.backgroundEnabled) && hasAnyBackgroundAsset,
backgroundMode: normalizeOption(value?.backgroundMode, appearanceBackgroundModes, base.backgroundMode),
backgroundPresentationMode: normalizeOption(
value?.backgroundPresentationMode,
appearanceBackgroundPresentationModes,
base.backgroundPresentationMode
),
backgroundOverlayOpacity: normalizePercent(value?.backgroundOverlayOpacity, base.backgroundOverlayOpacity, 0, 100),
backgroundBlurRadius: normalizePercent(value?.backgroundBlurRadius, base.backgroundBlurRadius, 0, 24),
backgroundTargets: normalizeOptionsArray(value?.backgroundTargets, appearanceBackgroundTargets, base.backgroundTargets),
backgroundTargetImageUrls: normalizedBackgroundTargetImageUrls,
backgroundTargetOverlayOpacities: normalizedBackgroundTargetOverlayOpacities,
backgroundTargetBlurRadii: normalizedBackgroundTargetBlurRadii,
backgroundTargetPositions: normalizedBackgroundTargetPositions,
backgroundTargetPresentationModes: normalizedBackgroundTargetPresentationModes,
heroImagePositionX: normalizePercent(value?.heroImagePositionX, base.heroImagePositionX),
heroImagePositionY: normalizePercent(value?.heroImagePositionY, base.heroImagePositionY),
heroOverlayOpacity: normalizePercent(value?.heroOverlayOpacity, base.heroOverlayOpacity),
heroTitlePositionX: normalizePercent(value?.heroTitlePositionX, base.heroTitlePositionX, -50, 50),
heroTitlePositionY: normalizePercent(value?.heroTitlePositionY, base.heroTitlePositionY, -50, 50),
heroTitleWidthPercent: normalizePercent(value?.heroTitleWidthPercent, base.heroTitleWidthPercent, 30, 100),
heroTaglinePositionX: normalizePercent(value?.heroTaglinePositionX, base.heroTaglinePositionX, -50, 50),
heroTaglinePositionY: normalizePercent(value?.heroTaglinePositionY, base.heroTaglinePositionY, -50, 50),
heroTaglineWidthPercent: normalizePercent(value?.heroTaglineWidthPercent, base.heroTaglineWidthPercent, 30, 100),
decorativeElements: normalizedDecorativeElements,
cardStyle: normalizeOption(value?.cardStyle, appearanceCardStyles, base.cardStyle),
tableStyle: normalizeOption(value?.tableStyle, appearanceTableStyles, base.tableStyle),
displayDensity: normalizeOption(value?.displayDensity, appearanceDisplayDensities, base.displayDensity),
sidebarStyle: normalizeOption(value?.sidebarStyle, appearanceSidebarStyles, base.sidebarStyle),
buttonStyle: normalizeOption(value?.buttonStyle, appearanceButtonStyles, base.buttonStyle),
inputStyle: normalizeOption(value?.inputStyle, appearanceInputStyles, base.inputStyle),
};
}

View file

@ -0,0 +1,44 @@
export const associationCommuneOptions = [
{ value: "all", label: "Toutes" },
{ value: "kourou", label: "Kourou" },
{ value: "sinnamary", label: "Sinnamary" },
{ value: "iracoubo", label: "Iracoubo" },
{ value: "saint_elie", label: "Saint-Élie" },
] as const;
export type AssociationCommuneFilter = (typeof associationCommuneOptions)[number]["value"];
const communeVariantMap: Record<Exclude<AssociationCommuneFilter, "all">, string[]> = {
kourou: ["Kourou"],
sinnamary: ["Sinnamary"],
iracoubo: ["Iracoubo"],
saint_elie: ["Saint-Élie", "Saint Elie", "ST ELIE", "St Elie", "St-Élie", "Saint-Elie"],
};
export function getAssociationCommuneLabel(value: AssociationCommuneFilter) {
return associationCommuneOptions.find(option => option.value === value)?.label ?? "Toutes";
}
export function normalizeAssociationCommune(value: string | null | undefined): AssociationCommuneFilter {
const normalized = (value || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[-_]/g, " ")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
if (normalized.includes("saint elie") || normalized.includes("st elie")) return "saint_elie";
if (normalized.includes("kourou")) return "kourou";
if (normalized.includes("sinnamary")) return "sinnamary";
if (normalized.includes("iracoubo")) return "iracoubo";
return "all";
}
export function getAssociationCommuneVariants(value?: string | null) {
if (!value || value === "all") {
return [];
}
return communeVariantMap[value as Exclude<AssociationCommuneFilter, "all">] || [];
}

29
shared/associationGeo.ts Normal file
View file

@ -0,0 +1,29 @@
export const associationGeoSources = [
"manual",
"adresse_gouv",
"dataasso",
"commune_center",
] as const;
export type AssociationGeoSource = (typeof associationGeoSources)[number];
export const associationGeoPrecisions = [
"exact_address",
"commune_center",
"hidden",
] as const;
export type AssociationGeoPrecision = (typeof associationGeoPrecisions)[number];
export const associationGeoSourceLabels: Record<AssociationGeoSource, string> = {
manual: "Position définie manuellement",
adresse_gouv: "Adresse.data.gouv.fr",
dataasso: "DataAsso / référentiel association",
commune_center: "Centre de commune",
};
export const associationGeoPrecisionLabels: Record<AssociationGeoPrecision, string> = {
exact_address: "Adresse exacte",
commune_center: "Centre de commune",
hidden: "Masquée du public",
};

View file

@ -0,0 +1,194 @@
export const legalRepresentativeRoleValues = [
"president",
"co_president",
"secretaire_general",
"directeur_gerant",
] as const;
export type LegalRepresentativeRole = typeof legalRepresentativeRoleValues[number];
export const governanceMemberRoleValues = [
"tresorier",
"tresorier_adjoint",
"secretaire",
"secretaire_adjoint",
"administrateur",
"charge_de_mission_referent",
"adherent_benevole",
] as const;
export type GovernanceMemberRole = typeof governanceMemberRoleValues[number];
export const legalRepresentativeRoleLabels: Record<LegalRepresentativeRole, string> = {
president: "Président",
co_president: "Co-président",
secretaire_general: "Secrétaire Général",
directeur_gerant: "Directeur / Gérant",
};
export const governanceMemberRoleLabels: Record<GovernanceMemberRole, string> = {
tresorier: "Trésorier",
tresorier_adjoint: "Trésorier adjoint",
secretaire: "Secrétaire",
secretaire_adjoint: "Secrétaire adjoint",
administrateur: "Membre du conseil d'administration / Administrateur",
charge_de_mission_referent: "Chargé de mission / Référent",
adherent_benevole: "Simple adhérent / Bénévole",
};
export const legalRepresentativeRoleOptions = legalRepresentativeRoleValues.map((value) => ({
value,
label: legalRepresentativeRoleLabels[value],
}));
export const governanceMemberRoleOptions = governanceMemberRoleValues.map((value) => ({
value,
label: governanceMemberRoleLabels[value],
}));
function normalizeRoleKey(value: string | null | undefined) {
return String(value || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}
export function normalizeLegalRepresentativeRole(value: string | null | undefined): LegalRepresentativeRole {
const normalized = normalizeRoleKey(value);
switch (normalized) {
case "president":
case "presidente":
return "president";
case "co_president":
case "copresident":
case "co_presidente":
return "co_president";
case "secretaire_general":
return "secretaire_general";
case "directeur":
case "gerant":
case "directeur_gerant":
return "directeur_gerant";
default:
return "president";
}
}
export function normalizeGovernanceMemberRole(value: string | null | undefined): GovernanceMemberRole {
const normalized = normalizeRoleKey(value);
switch (normalized) {
case "tresorier":
return "tresorier";
case "tresorier_adjoint":
return "tresorier_adjoint";
case "secretaire":
return "secretaire";
case "secretaire_adjoint":
return "secretaire_adjoint";
case "administrateur":
case "membre_du_conseil_d_administration_administrateur":
return "administrateur";
case "charge_de_mission":
case "referent":
case "charge_de_mission_referent":
return "charge_de_mission_referent";
default:
return "adherent_benevole";
}
}
export type GovernancePerson = {
nom: string;
prenom: string;
email?: string | null;
telephone?: string | null;
fonction: string;
};
export type AssociationGovernance = {
representantLegal: GovernancePerson | null;
membres: GovernancePerson[];
};
export function createEmptyGovernancePerson(fonction = ""): GovernancePerson {
return {
nom: "",
prenom: "",
email: "",
telephone: "",
fonction,
};
}
export function parseAssociationGovernance(value: string | null | undefined): AssociationGovernance {
if (!value) {
return { representantLegal: null, membres: [] };
}
try {
const parsed = JSON.parse(value) as Partial<AssociationGovernance>;
const normalizePerson = (person: any): GovernancePerson | null => {
if (!person || typeof person !== "object") return null;
return {
nom: typeof person.nom === "string" ? person.nom : "",
prenom: typeof person.prenom === "string" ? person.prenom : "",
email: typeof person.email === "string" ? person.email : "",
telephone: typeof person.telephone === "string" ? person.telephone : "",
fonction: typeof person.fonction === "string" ? person.fonction : "",
};
};
return {
representantLegal: normalizePerson(parsed.representantLegal),
membres: Array.isArray(parsed.membres)
? parsed.membres.map(normalizePerson).filter(Boolean) as GovernancePerson[]
: [],
};
} catch {
return { representantLegal: null, membres: [] };
}
}
export function serializeAssociationGovernance(
governance: AssociationGovernance | null | undefined
) {
if (!governance?.representantLegal) return null;
return JSON.stringify(governance);
}
export function buildDisplayName(person: GovernancePerson | null | undefined) {
if (!person) return "";
return [person.prenom?.trim(), person.nom?.trim()].filter(Boolean).join(" ").trim();
}
export function sanitizeGovernance(governance: AssociationGovernance): AssociationGovernance {
const trimPerson = (
person: GovernancePerson | null,
roleNormalizer?: (value: string | null | undefined) => string
): GovernancePerson | null => {
if (!person) return null;
const normalized: GovernancePerson = {
nom: person.nom.trim(),
prenom: person.prenom.trim(),
email: person.email?.trim() || "",
telephone: person.telephone?.trim() || "",
fonction: roleNormalizer ? roleNormalizer(person.fonction) : person.fonction.trim(),
};
if (!normalized.nom && !normalized.prenom && !normalized.email && !normalized.telephone && !normalized.fonction) {
return null;
}
return normalized;
};
return {
representantLegal: trimPerson(governance.representantLegal, normalizeLegalRepresentativeRole),
membres: governance.membres
.map((member) => trimPerson(member, normalizeGovernanceMemberRole))
.filter((member): member is GovernancePerson => Boolean(member)),
};
}

View file

@ -0,0 +1,88 @@
export const associationThematicValues = [
"culture_loisirs",
"social_sante",
"education_formation",
"economie_territoire",
"environnement_patrimoine",
"institutions_divers",
] as const;
export type AssociationThematic = typeof associationThematicValues[number];
export const associationThematicDefinitions: Record<
AssociationThematic,
{ label: string; description: string }
> = {
culture_loisirs: {
label: "Culture & Loisirs",
description: "Arts, sport, chasse, pêche, activités civiques et religieuses.",
},
social_sante: {
label: "Social & Santé",
description: "Caritatif, humanitaire, aide aux seniors, santé, services aux familles.",
},
education_formation: {
label: "Éducation et formation",
description: "Écoles, formation continue, apprentissage.",
},
economie_territoire: {
label: "Économie & Territoire",
description: "Emploi, insertion, logement, tourisme, défense d'intérêts économiques.",
},
environnement_patrimoine: {
label: "Environnement et patrimoine",
description: "Écologie, cadre de vie, protection des monuments.",
},
institutions_divers: {
label: "Institutions & Divers",
description: "Justice, sécurité civile, recherche, activités politiques.",
},
};
export const associationThematicOptions = associationThematicValues.map((value) => ({
value,
label: associationThematicDefinitions[value].label,
description: associationThematicDefinitions[value].description,
}));
export function getAssociationThematicLabel(value: AssociationThematic | string | null | undefined) {
if (!value) return "Non renseignée";
return associationThematicDefinitions[value as AssociationThematic]?.label ?? value;
}
export function getAssociationThematicDescription(value: AssociationThematic | string | null | undefined) {
if (!value) return "";
return associationThematicDefinitions[value as AssociationThematic]?.description ?? "";
}
export function parseAssociationThematics(value: string | null | undefined): AssociationThematic[] {
if (!value) return [];
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) {
return parsed.filter((item): item is AssociationThematic =>
associationThematicValues.includes(item as AssociationThematic)
);
}
} catch {
if (associationThematicValues.includes(value as AssociationThematic)) {
return [value as AssociationThematic];
}
}
return [];
}
export function serializeAssociationThematics(values: Array<AssociationThematic | string> | null | undefined) {
const normalized = Array.from(
new Set(
(values || []).filter((item): item is AssociationThematic =>
associationThematicValues.includes(item as AssociationThematic)
)
)
);
return normalized.length > 0 ? JSON.stringify(normalized) : null;
}
export function getAssociationThematicLabels(values: Array<AssociationThematic | string> | null | undefined) {
return (values || []).map((value) => getAssociationThematicLabel(value));
}

5
shared/const.ts Normal file
View file

@ -0,0 +1,5 @@
export const COOKIE_NAME = "app_session_id";
export const ONE_YEAR_MS = 1000 * 60 * 60 * 24 * 365;
export const AXIOS_TIMEOUT_MS = 30_000;
export const UNAUTHED_ERR_MSG = 'Please login (10001)';
export const NOT_ADMIN_ERR_MSG = 'You do not have required permission (10002)';

View file

@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { resolveMailConfig } from "./mailProviders";
describe("resolveMailConfig", () => {
it("uses Gmail defaults when provider is gmail", () => {
const config = resolveMailConfig({
smtpProvider: "gmail",
smtpFrom: "notifications@example.com",
smtpUser: "notifications@example.com",
smtpPass: "secret",
});
expect(config.provider).toBe("gmail");
expect(config.host).toBe("smtp.gmail.com");
expect(config.port).toBe(465);
expect(config.secure).toBe(true);
expect(config.ready).toBe(true);
});
it("uses OVH defaults when provider is ovh", () => {
const config = resolveMailConfig({
smtpProvider: "ovh",
smtpFrom: "notifications@example.com",
});
expect(config.provider).toBe("ovh");
expect(config.host).toBe("smtp.mail.ovh.net");
expect(config.port).toBe(465);
expect(config.secure).toBe(true);
expect(config.requireTLS).toBe(false);
expect(config.ready).toBe(true);
});
it("supports custom SMTP host and port", () => {
const config = resolveMailConfig({
smtpHost: "mail.example.org",
smtpPort: "2525",
smtpFrom: "notifications@example.org",
});
expect(config.provider).toBe("custom");
expect(config.host).toBe("mail.example.org");
expect(config.port).toBe(2525);
expect(config.ready).toBe(true);
});
it("reports missing SMTP_PASS when only SMTP_USER is present", () => {
const config = resolveMailConfig({
smtpProvider: "outlook",
smtpFrom: "notifications@example.com",
smtpUser: "notifications@example.com",
});
expect(config.ready).toBe(false);
expect(config.missing).toContain("SMTP_PASS");
});
});

125
shared/mailProviders.ts Normal file
View file

@ -0,0 +1,125 @@
export const supportedMailProviders = ["custom", "ovh", "gmail", "outlook"] as const;
export type SupportedMailProvider = typeof supportedMailProviders[number];
export type MailEnvInput = {
smtpProvider?: string;
smtpHost?: string;
smtpPort?: string;
smtpUser?: string;
smtpPass?: string;
smtpFrom?: string;
smtpSecure?: string;
smtpRequireTls?: string;
};
export type ResolvedMailConfig = {
provider: SupportedMailProvider;
host: string;
port: number | null;
secure: boolean;
requireTLS: boolean;
user: string;
pass: string;
from: string;
missing: string[];
ready: boolean;
};
export const providerDefaults: Record<
Exclude<SupportedMailProvider, "custom">,
{ host: string; port: number; secure: boolean; requireTLS: boolean }
> = {
ovh: {
host: "smtp.mail.ovh.net",
port: 465,
secure: true,
requireTLS: false,
},
gmail: {
host: "smtp.gmail.com",
port: 465,
secure: true,
requireTLS: false,
},
outlook: {
host: "smtp.office365.com",
port: 587,
secure: false,
requireTLS: true,
},
};
function normalizeProvider(value?: string): SupportedMailProvider {
const normalized = String(value || "")
.trim()
.toLowerCase();
if (normalized === "ovh" || normalized === "gmail" || normalized === "outlook") {
return normalized;
}
return "custom";
}
function parseBoolean(value?: string, fallback = false): boolean {
const normalized = String(value || "")
.trim()
.toLowerCase();
if (!normalized) {
return fallback;
}
return ["1", "true", "yes", "oui", "on"].includes(normalized);
}
function parsePort(value?: string, fallback: number | null = null): number | null {
const trimmed = String(value || "").trim();
if (!trimmed) {
return fallback;
}
const port = Number(trimmed);
if (!Number.isFinite(port) || port <= 0) {
return fallback;
}
return port;
}
export function resolveMailConfig(input: MailEnvInput): ResolvedMailConfig {
const provider = normalizeProvider(input.smtpProvider);
const defaults = provider === "custom" ? null : providerDefaults[provider];
const host = String(input.smtpHost || defaults?.host || "").trim();
const port = parsePort(input.smtpPort, defaults?.port ?? null);
const secure = parseBoolean(input.smtpSecure, defaults?.secure ?? false);
const requireTLS = parseBoolean(input.smtpRequireTls, defaults?.requireTLS ?? false);
const user = String(input.smtpUser || "").trim();
const pass = String(input.smtpPass || "").trim();
const from = String(input.smtpFrom || "").trim();
const missing: string[] = [];
if (!host) {
missing.push("SMTP_HOST");
}
if (!port) {
missing.push("SMTP_PORT");
}
if (!from) {
missing.push("SMTP_FROM");
}
if (user && !pass) {
missing.push("SMTP_PASS");
}
if (pass && !user) {
missing.push("SMTP_USER");
}
return {
provider,
host,
port,
secure,
requireTLS,
user,
pass,
from,
missing,
ready: missing.length === 0,
};
}

72
shared/materialEvent.ts Normal file
View file

@ -0,0 +1,72 @@
export const materialEventItems = [
{ key: "tente3x3", label: "Tente 3*3" },
{ key: "chapiteau5x5", label: "Chapiteau 5*5" },
{ key: "podium", label: "Podium" },
{ key: "autres", label: "Autres" },
] as const;
export const materialEventInventory: Record<(typeof materialEventItems)[number]["key"], number | null> = {
tente3x3: 12,
chapiteau5x5: 6,
podium: 2,
autres: null,
};
export const materialEventReplacementValues: Record<(typeof materialEventItems)[number]["key"], number | null> = {
tente3x3: 45000,
chapiteau5x5: 120000,
podium: 250000,
autres: null,
};
export type MaterialEventItemKey = (typeof materialEventItems)[number]["key"];
export type MaterialEventSelection = Record<MaterialEventItemKey, boolean>;
export type MaterialEventQuantities = Record<MaterialEventItemKey, string>;
export type MaterialEventQuantityMap = Record<MaterialEventItemKey, number>;
export const emptyMaterialEventSelection = (): MaterialEventSelection => ({
tente3x3: false,
chapiteau5x5: false,
podium: false,
autres: false,
});
export const emptyMaterialEventQuantities = (): MaterialEventQuantities => ({
tente3x3: "",
chapiteau5x5: "",
podium: "",
autres: "",
});
export function getMaterialEventLabel(key: MaterialEventItemKey | string) {
return materialEventItems.find((item) => item.key === key)?.label || String(key);
}
export function parseMaterialEventQuantity(value: unknown) {
const parsed = Number.parseInt(String(value ?? "").trim(), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
export function emptyMaterialEventQuantityMap(initialValue: number = 0): MaterialEventQuantityMap {
return {
tente3x3: initialValue,
chapiteau5x5: initialValue,
podium: initialValue,
autres: initialValue,
};
}
export function sanitizeMaterialEventQuantityMap(rawValue: unknown): MaterialEventQuantityMap {
const next = emptyMaterialEventQuantityMap();
if (!rawValue || typeof rawValue !== "object") {
return next;
}
for (const item of materialEventItems) {
const value = (rawValue as Record<string, unknown>)[item.key];
next[item.key] = parseMaterialEventQuantity(value);
}
return next;
}

View file

@ -0,0 +1,36 @@
import { z } from "zod";
export const DATA_PRIVACY_NOTICE_VERSION = "2026-06-11";
export const dataPrivacyConsentContextValues = [
"register_account",
"save_profile",
"create_request",
"update_request",
] as const;
export type DataPrivacyConsentContext = (typeof dataPrivacyConsentContextValues)[number];
export const dataPrivacyConsentContextLabels: Record<DataPrivacyConsentContext, string> = {
register_account: "Création de compte",
save_profile: "Enregistrement de la fiche association",
create_request: "Création d'une demande",
update_request: "Mise à jour d'une demande",
};
export const dataPrivacyConsentSchema = z.object({
accepted: z.literal(true),
version: z.string().min(1),
context: z.enum(dataPrivacyConsentContextValues),
});
export type DataPrivacyConsent = z.infer<typeof dataPrivacyConsentSchema>;
export function buildDataPrivacyConsent(context: DataPrivacyConsentContext): DataPrivacyConsent {
return {
accepted: true,
version: DATA_PRIVACY_NOTICE_VERSION,
context,
};
}

298
shared/sallePricing.ts Normal file
View file

@ -0,0 +1,298 @@
export type SalleUsageType = "conventionne" | "occasionnel";
export type SalleReservationFrequency = "demi_journee" | "journee" | "mensuel";
export type SalleWorkflowQuoteStatus = "a_preparer" | "en_attente_association" | "accepte" | "refuse";
export type SalleWorkflowDirectorStatus = "a_transmettre" | "en_attente_signature" | "signee";
export type SalleWorkflowPaymentStatus = "en_attente_paiement" | "paiement_partiel" | "paiement_recu" | "paiement_valide";
export type SallePricingLineItem = {
salleId: string;
salleNom: string;
categoryLabel: string;
usageType: SalleUsageType;
frequency: SalleReservationFrequency;
quantity: number;
unitAmountCents: number;
totalAmountCents: number;
};
export type SallePricingSummary = {
usageType: SalleUsageType;
frequency: SalleReservationFrequency;
quantity: number;
lineItems: SallePricingLineItem[];
totalAmountCents: number;
unsupportedSalles: Array<{
salleId: string;
salleNom: string;
}>;
};
export type SalleWorkflowData = {
usageType?: SalleUsageType;
frequency?: SalleReservationFrequency;
conditionsFinancieresText?: string;
decisionAdministrativeText?: string;
pricing?: SallePricingSummary;
quoteStatus?: SalleWorkflowQuoteStatus;
quoteSentAt?: string;
quoteRespondedAt?: string;
quotePdfUrl?: string;
quotePdfName?: string;
administrativeDecisionPdfUrl?: string;
administrativeDecisionPdfName?: string;
directorStatus?: SalleWorkflowDirectorStatus;
directorTransmissionAt?: string;
directorSignedAt?: string;
directorSignedByUserId?: number;
directorSignedByName?: string;
directorReturnComment?: string;
directorReturnedAt?: string;
signedQuotePdfUrl?: string;
signedQuotePdfName?: string;
signedDecisionPdfUrl?: string;
signedDecisionPdfName?: string;
signedRequestPdfUrl?: string;
signedRequestPdfName?: string;
invoicePdfUrl?: string;
invoicePdfName?: string;
invoiceGeneratedAt?: string;
paymentStatus?: SalleWorkflowPaymentStatus;
paymentDueDate?: string;
amountReceivedCents?: number;
paymentReceivedAt?: string;
paymentValidatedAt?: string;
paymentReference?: string;
paymentNotes?: string;
finalNotificationSentAt?: string;
};
type SalleTariffDefinition = {
salleId: string;
salleNom: string;
categoryLabel: string;
pricing: Partial<Record<SalleUsageType, Partial<Record<SalleReservationFrequency, number>>>>;
};
export const SALLE_USAGE_TYPE_LABELS: Record<SalleUsageType, string> = {
conventionne: "Conventionné",
occasionnel: "Occasionnel",
};
export const SALLE_FREQUENCY_LABELS: Record<SalleReservationFrequency, string> = {
demi_journee: "Demi-journée",
journee: "Journée",
mensuel: "Mensuel",
};
export const SALLE_WORKFLOW_QUOTE_STATUS_LABELS: Record<SalleWorkflowQuoteStatus, string> = {
a_preparer: "À préparer",
en_attente_association: "En attente de l'association",
accepte: "Accepté par l'association",
refuse: "Refusé par l'association",
};
export const SALLE_WORKFLOW_DIRECTOR_STATUS_LABELS: Record<SalleWorkflowDirectorStatus, string> = {
a_transmettre: "À transmettre à la Directrice",
en_attente_signature: "En attente de signature",
signee: "Signée",
};
export const SALLE_WORKFLOW_PAYMENT_STATUS_LABELS: Record<SalleWorkflowPaymentStatus, string> = {
en_attente_paiement: "En attente de paiement",
paiement_partiel: "Paiement partiel",
paiement_recu: "Paiement reçu",
paiement_valide: "Paiement validé",
};
const SALLE_TARIFFS: SalleTariffDefinition[] = [
{
salleId: "bureau_11_17",
salleNom: "Bureau 1117 m²",
categoryLabel: "Bureau 1117 m²",
pricing: {
conventionne: {
journee: 1216,
mensuel: 18819,
},
},
},
{
salleId: "salle_toucan",
salleNom: "Salle TOUCAN",
categoryLabel: "Salle de conférence - Formation",
pricing: {
conventionne: {
demi_journee: 7673,
journee: 15345,
},
},
},
{
salleId: "salle_ibis",
salleNom: "Salle IBIS",
categoryLabel: "Espace formation",
pricing: {
conventionne: {
demi_journee: 8173,
journee: 15845,
mensuel: 25000,
},
},
},
{
salleId: "salle_pelican",
salleNom: "Salle PELICAN",
categoryLabel: "Espace formation",
pricing: {
conventionne: {
demi_journee: 8173,
journee: 15845,
mensuel: 25000,
},
},
},
{
salleId: "hall_amphitheatre",
salleNom: "Hall-Amphithéâtre",
categoryLabel: "Hall ouvert - 343,34 m²",
pricing: {
conventionne: {
demi_journee: 5000,
journee: 10000,
},
},
},
{
salleId: "dojo",
salleNom: "DOJO",
categoryLabel: "Dojo",
pricing: {
conventionne: {
mensuel: 65000,
},
occasionnel: {
mensuel: 25000,
},
},
},
];
export const CONDITIONS_FINANCIERES_SALLE_TEXT = `CONDITIONS FINANCIERES - LOCATION DES ESPACES
La mise a disposition d'un espace au sein de la Maison de la Jeunesse des Savanes « Melissa ALVES » est soumise aux conditions suivantes :
1. Tarification
Les tarifs appliques sont ceux votes par le Conseil Communautaire, actualises au 1er septembre 2025.
Le montant est calcule automatiquement en fonction :
de l'espace choisi
de la duree (journee, demi-journee, mensuel)
du type d'utilisateur (conventionne / occasionnel)
2. Acompte
Un acompte peut etre demande selon la nature de l'evenement.
3. Annulation
Toute annulation doit etre signalee 48 heures a l'avance pour un eventuel remboursement de l'acompte.
4. Responsabilite
L'association est responsable :
des participants
du public
des dommages eventuels
du respect des regles de securite
Une attestation de responsabilite civile couvrant la periode doit etre fournie.
5. Entretien des locaux
Il est interdit de manger dans les salles.
Les espaces doivent etre rendus propres et en bon etat.`;
export function buildDecisionAdministrativeText(input: {
espace: string;
dates: string;
horaires: string;
association: string;
}) {
return `DECISION ADMINISTRATIVE PRIORITAIRE
La presente decision atteste que la Maison de la Jeunesse des Savanes « Melissa ALVES » donne un avis favorable a la mise a disposition de l'espace suivant :
Espace : ${input.espace}
Dates : ${input.dates}
Horaires : ${input.horaires}
Association : ${input.association}
Cette decision est transmise avant validation finale et ne vaut pas autorisation definitive.
La mise a disposition devient effective uniquement apres :
validation du devis (si facturation)
signature de la Directrice
reception de l'attestation de responsabilite civile`;
}
export function getSalleTariffDefinition(salleId: string) {
return SALLE_TARIFFS.find((entry) => entry.salleId === salleId) || null;
}
export function listReservationDates(start: string, end: string) {
if (!start || !end) return [] as string[];
const startDate = new Date(`${start}T12:00:00`);
const endDate = new Date(`${end}T12:00:00`);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()) || startDate > endDate) {
return [] as string[];
}
const values: string[] = [];
const cursor = new Date(startDate);
while (cursor <= endDate) {
values.push(cursor.toISOString().slice(0, 10));
cursor.setDate(cursor.getDate() + 1);
}
return values;
}
export function getReservationUnitQuantity(input: {
frequency: SalleReservationFrequency;
dateReservation?: string;
dateFinReservation?: string;
}) {
if (input.frequency === "mensuel") return 1;
const dates = listReservationDates(input.dateReservation || "", input.dateFinReservation || input.dateReservation || "");
return Math.max(dates.length, 1);
}
export function computeSallePricing(input: {
sallesIds: string[];
usageType: SalleUsageType;
frequency: SalleReservationFrequency;
dateReservation?: string;
dateFinReservation?: string;
}) {
const quantity = getReservationUnitQuantity(input);
const summary: SallePricingSummary = {
usageType: input.usageType,
frequency: input.frequency,
quantity,
lineItems: [],
totalAmountCents: 0,
unsupportedSalles: [],
};
input.sallesIds.forEach((salleId) => {
const salle = getSalleTariffDefinition(salleId);
if (!salle) {
summary.unsupportedSalles.push({ salleId, salleNom: salleId });
return;
}
const unitAmountCents = salle.pricing[input.usageType]?.[input.frequency];
if (typeof unitAmountCents !== "number") {
summary.unsupportedSalles.push({ salleId, salleNom: salle.salleNom });
return;
}
const lineItem: SallePricingLineItem = {
salleId,
salleNom: salle.salleNom,
categoryLabel: salle.categoryLabel,
usageType: input.usageType,
frequency: input.frequency,
quantity,
unitAmountCents,
totalAmountCents: unitAmountCents * quantity,
};
summary.lineItems.push(lineItem);
summary.totalAmountCents += lineItem.totalAmountCents;
});
return summary;
}

62
shared/socialLinks.ts Normal file
View file

@ -0,0 +1,62 @@
export function normalizeSocialUrl(value: string | null | undefined) {
const trimmed = (value || "").trim();
if (!trimmed) return "";
return trimmed;
}
function parseSocialUrl(value: string | null | undefined) {
const trimmed = normalizeSocialUrl(value);
if (!trimmed) return null;
try {
return new URL(trimmed);
} catch {
return null;
}
}
function isAllowedSocialHostname(hostname: string, allowed: string[]) {
return allowed.some((domain) => hostname === domain || hostname === `www.${domain}`);
}
export function isValidFacebookUrl(value: string | null | undefined) {
const url = parseSocialUrl(value);
if (!value || !String(value).trim()) return true;
if (!url) return false;
return url.protocol === "https:" && isAllowedSocialHostname(url.hostname.toLowerCase(), ["facebook.com", "fb.com"]);
}
export function isValidInstagramUrl(value: string | null | undefined) {
const url = parseSocialUrl(value);
if (!value || !String(value).trim()) return true;
if (!url) return false;
return url.protocol === "https:" && isAllowedSocialHostname(url.hostname.toLowerCase(), ["instagram.com"]);
}
export function getFacebookEmbedUrl(value: string | null | undefined) {
const trimmed = normalizeSocialUrl(value);
if (!isValidFacebookUrl(trimmed)) return null;
const url = new URL(trimmed);
const embedUrl = new URL("https://www.facebook.com/plugins/page.php");
embedUrl.searchParams.set("href", url.toString());
embedUrl.searchParams.set("tabs", "timeline");
embedUrl.searchParams.set("width", "500");
embedUrl.searchParams.set("height", "380");
embedUrl.searchParams.set("small_header", "true");
embedUrl.searchParams.set("adapt_container_width", "true");
embedUrl.searchParams.set("hide_cover", "false");
embedUrl.searchParams.set("show_facepile", "false");
return embedUrl.toString();
}
export function getSocialHandle(value: string | null | undefined) {
const url = parseSocialUrl(value);
if (!url) return null;
const handle = url.pathname
.split("/")
.map((part) => part.trim())
.filter(Boolean)[0];
return handle ? `@${handle}` : null;
}

7
shared/types.ts Normal file
View file

@ -0,0 +1,7 @@
/**
* Unified type exports
* Import shared types from this single entry point.
*/
export type * from "../drizzle/schema";
export * from "./_core/errors";