Initial local backup snapshot
This commit is contained in:
commit
acd9e14ba5
367 changed files with 118038 additions and 0 deletions
19
shared/_core/errors.ts
Normal file
19
shared/_core/errors.ts
Normal 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
764
shared/appearance.ts
Normal 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: "L’espace 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 l’innovation 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é, l’environnement 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 à l’emploi 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),
|
||||
};
|
||||
}
|
||||
44
shared/associationCommunes.ts
Normal file
44
shared/associationCommunes.ts
Normal 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
29
shared/associationGeo.ts
Normal 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",
|
||||
};
|
||||
194
shared/associationGovernance.ts
Normal file
194
shared/associationGovernance.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
88
shared/associationThematics.ts
Normal file
88
shared/associationThematics.ts
Normal 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
5
shared/const.ts
Normal 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)';
|
||||
57
shared/mailProviders.test.ts
Normal file
57
shared/mailProviders.test.ts
Normal 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
125
shared/mailProviders.ts
Normal 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
72
shared/materialEvent.ts
Normal 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;
|
||||
}
|
||||
36
shared/privacyCompliance.ts
Normal file
36
shared/privacyCompliance.ts
Normal 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
298
shared/sallePricing.ts
Normal 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 11–17 m²",
|
||||
categoryLabel: "Bureau 11–17 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
62
shared/socialLinks.ts
Normal 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
7
shared/types.ts
Normal 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";
|
||||
Loading…
Add table
Add a link
Reference in a new issue