Refonte des horaires et contacts adhérents

This commit is contained in:
Michael committed 2026-07-23 16:22:28 +02:00
1 parent 5e7233f98d
commit dd9697019c
7 files changed
+960 -41

No files matched your search

+148 -35
View File
@@ -7,6 +7,13 @@ import { SiteFooter } from "@/components/SiteFooter";
import { VitrineImage } from "@/components/VitrineImage";
import { PromoImage } from "@/components/PromoImage";
import { getMemberLivePromotions, getPublicMember } from "@/lib/queries";
import {
formatSlots,
getOpenStatus,
normalizeWebsite,
parseMemberHours,
websiteLabel,
} from "@/lib/member-profile";
export const dynamic = "force-dynamic";
@@ -31,6 +38,52 @@ function initialsOf(name: string) {
return name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
}
type ContactIconName = "phone" | "mail" | "website" | "pin";
function ContactIcon({ name }: { name: ContactIconName }) {
const paths: Record<ContactIconName, React.ReactNode> = {
phone: <path d="M7.4 3.8 9 7.2 7.2 8.6a14.4 14.4 0 0 0 6.2 6.2L14.8 13l3.4 1.6-.8 3.6c-.2.9-1 1.5-1.9 1.5C8.2 19.7 2.3 13.8 2.3 6.5c0-.9.6-1.7 1.5-1.9l3.6-.8Z" />,
mail: <><rect x="2.5" y="4.5" width="19" height="15" rx="2.5" /><path d="m4 6 8 6 8-6" /></>,
website: <><circle cx="12" cy="12" r="9.5" /><path d="M2.8 12h18.4M12 2.5c2.4 2.6 3.6 5.8 3.6 9.5S14.4 18.9 12 21.5M12 2.5C9.6 5.1 8.4 8.3 8.4 12s1.2 6.9 3.6 9.5" /></>,
pin: <><path d="M20 10c0 5.5-8 11.5-8 11.5S4 15.5 4 10a8 8 0 1 1 16 0Z" /><circle cx="12" cy="10" r="2.5" /></>,
};
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
{paths[name]}
</svg>
);
}
function ContactAction({
href,
icon,
label,
detail,
external = false,
}: {
href: string;
icon: ContactIconName;
label: string;
detail: string;
external?: boolean;
}) {
return (
<a
href={href}
className="contact-action"
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
>
<span className="contact-action__icon"><ContactIcon name={icon} /></span>
<span className="contact-action__copy">
<strong>{label}</strong>
<span>{detail}</span>
</span>
<span className="contact-action__arrow" aria-hidden="true">{external ? "↗" : "→"}</span>
</a>
);
}
export async function generateMetadata({
params,
}: {
@@ -65,10 +118,11 @@ export default async function FicheAdherentPage({
);
const paragraphs = (member.description ?? "").split("\n").map((s) => s.trim()).filter(Boolean);
const tags = (member.tags ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const hours = (member.hours ?? "")
.split("\n")
.map((line) => line.split("|").map((s) => s.trim()))
.filter((p) => p[0]);
const hours = parseMemberHours(member.hours);
const openStatus = getOpenStatus(hours);
const normalizedWebsite = normalizeWebsite(member.website);
const shortWebsite = websiteLabel(member.website);
const phoneHref = member.phone ? `tel:${member.phone.replace(/[^\d+]/g, "")}` : null;
const sectionCard: React.CSSProperties = {
background: "#fff",
@@ -149,11 +203,14 @@ export default async function FicheAdherentPage({
</div>
</div>
<div style={{ display: "flex", gap: 10, paddingBottom: 4, flexWrap: "wrap" }}>
{member.phone && (
<a href={`tel:${member.phone.replace(/\s/g, "")}`} className="font-display" style={{ textDecoration: "none", background: "#2C6FB3", color: "#fff", fontWeight: 700, fontSize: 14, padding: "11px 18px", borderRadius: 11 }}>Appeler</a>
{phoneHref && (
<a href={phoneHref} className="font-display profile-hero-action profile-hero-action--primary">Appeler</a>
)}
{normalizedWebsite && (
<a href={normalizedWebsite} target="_blank" rel="noopener noreferrer" className="font-display profile-hero-action">Site web ↗</a>
)}
{mapQuery && (
<a href={`https://www.google.com/maps/dir/?api=1&destination=${mapQuery}`} target="_blank" rel="noopener" className="font-display" style={{ textDecoration: "none", background: "#fff", border: "1px solid #d8cdb4", color: "#3c3322", fontWeight: 700, fontSize: 14, padding: "11px 18px", borderRadius: 11 }}>Itinéraire</a>
<a href={`https://www.google.com/maps/dir/?api=1&destination=${mapQuery}`} target="_blank" rel="noopener noreferrer" className="font-display profile-hero-action">Itinéraire</a>
)}
</div>
</div>
@@ -186,44 +243,90 @@ export default async function FicheAdherentPage({
</section>
{/* practical info */}
<section style={sectionCard}>
<h2 className="font-display" style={{ ...h2, marginBottom: 16 }}>Informations pratiques</h2>
<div className="grid grid-2" style={{ gap: 22 }}>
<section className="practical-section">
<div className="practical-section__heading">
<div>
<div style={{ fontSize: 12.5, letterSpacing: "0.06em", textTransform: "uppercase", color: "#a99c82", fontWeight: 700, marginBottom: 10 }}>Horaires</div>
<span className="eyebrow">Préparer votre visite</span>
<h2 className="font-display" style={{ ...h2, margin: "5px 0 0" }}>Informations pratiques</h2>
</div>
{openStatus && (
<div className={`open-status${openStatus.isOpen ? " is-open" : ""}`}>
<span />
<div>
<strong>{openStatus.label}</strong>
{openStatus.detail && <small>{openStatus.detail}</small>}
</div>
</div>
)}
</div>
<div className="practical-grid">
<section className="schedule-card" aria-labelledby="schedule-title">
<div className="practical-card-title">
<span className="practical-card-title__icon" aria-hidden="true">◷</span>
<div>
<span>Cette semaine</span>
<h3 id="schedule-title" className="font-display">Horaires d&apos;ouverture</h3>
</div>
</div>
{hours.length > 0 ? (
<div style={{ display: "flex", flexDirection: "column", gap: 7, fontSize: 14 }}>
{hours.map(([label, range], i) => {
const closed = (range ?? "").toLowerCase().includes("ferm");
<div className="schedule-list">
{hours.map((day) => {
const isToday = openStatus?.todayKey === day.key;
return (
<div key={i} style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
<span style={{ color: "#6c6150" }}>{label}</span>
<span style={{ color: closed ? "#c0392b" : "#3c3322", fontWeight: 600 }}>{range}</span>
<div
key={day.key}
className={`schedule-row${isToday ? " is-today" : ""}`}
aria-current={isToday ? "date" : undefined}
>
<span className="schedule-row__day">
{day.label}
{isToday && <small>Aujourd&apos;hui</small>}
</span>
<strong className={day.closed ? "is-closed" : undefined}>
{day.closed ? "Fermé" : formatSlots(day.slots)}
</strong>
</div>
);
})}
</div>
) : (
<div style={{ fontSize: 14, color: "#a99c82" }}>Horaires non communiqués.</div>
<div className="practical-empty">Horaires non communiqués.</div>
)}
</div>
<div>
<div style={{ fontSize: 12.5, letterSpacing: "0.06em", textTransform: "uppercase", color: "#a99c82", fontWeight: 700, marginBottom: 10 }}>Contact</div>
<div style={{ display: "flex", flexDirection: "column", gap: 9, fontSize: 14, color: "#5e5444" }}>
{member.phone && (
<div style={{ display: "flex", alignItems: "center", gap: 9 }}><span style={{ width: 7, height: 7, borderRadius: "50%", background: "#E0A63C" }} />{member.phone}</div>
</section>
<section className="contact-card" aria-labelledby="contact-title">
<div className="practical-card-title">
<span className="practical-card-title__icon practical-card-title__icon--blue" aria-hidden="true">↗</span>
<div>
<span>Contact direct</span>
<h3 id="contact-title" className="font-display">Joindre l&apos;établissement</h3>
</div>
</div>
<div className="contact-actions">
{phoneHref && member.phone && (
<ContactAction href={phoneHref} icon="phone" label="Appeler" detail={member.phone} />
)}
{member.email && (
<a href={`mailto:${member.email}`} style={{ display: "flex", alignItems: "center", gap: 9, color: "#5e5444", textDecoration: "none" }}><span style={{ width: 7, height: 7, borderRadius: "50%", background: "#6FB0C6" }} />{member.email}</a>
<ContactAction href={`mailto:${member.email}`} icon="mail" label="Envoyer un e-mail" detail={member.email} />
)}
{member.website && (
<a href={member.website} target="_blank" rel="noopener" style={{ display: "flex", alignItems: "center", gap: 9, color: "#2C6FB3", textDecoration: "none", fontWeight: 600 }}><span style={{ width: 7, height: 7, borderRadius: "50%", background: "#2C6FB3" }} />{member.website.replace(/^https?:\/\//, "")}</a>
{normalizedWebsite && shortWebsite && (
<ContactAction href={normalizedWebsite} icon="website" label="Visiter le site" detail={shortWebsite} external />
)}
{!member.phone && !member.email && !member.website && (
<div style={{ color: "#a99c82" }}>Coordonnées non communiquées.</div>
{mapQuery && fullAddress && (
<ContactAction
href={`https://www.google.com/maps/dir/?api=1&destination=${mapQuery}`}
icon="pin"
label="Itinéraire"
detail={fullAddress}
external
/>
)}
{!phoneHref && !member.email && !normalizedWebsite && !fullAddress && (
<div className="practical-empty">Coordonnées non communiquées.</div>
)}
</div>
</div>
</section>
</div>
</section>
@@ -287,11 +390,21 @@ export default async function FicheAdherentPage({
</section>
{/* contact CTA */}
<section style={{ background: "linear-gradient(120deg,#13324F,#1d4a72)", borderRadius: 18, padding: 22, color: "#fff" }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 17, margin: "0 0 6px" }}>Une question pour ce commerçant ?</h3>
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#bcd3e6" }}>Contactez {member.name} directement.</p>
<a href={member.email ? `mailto:${member.email}` : "#"} className="font-display" style={{ textDecoration: "none", display: "block", textAlign: "center", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 14.5, padding: 12, borderRadius: 11 }}>Envoyer un message</a>
</section>
{(member.email || phoneHref || normalizedWebsite) && (
<section style={{ background: "linear-gradient(120deg,#13324F,#1d4a72)", borderRadius: 18, padding: 22, color: "#fff" }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 17, margin: "0 0 6px" }}>Une question pour ce commerçant ?</h3>
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#bcd3e6" }}>Contactez {member.name} directement.</p>
<a
href={member.email ? `mailto:${member.email}` : phoneHref ?? normalizedWebsite ?? undefined}
target={!member.email && !phoneHref ? "_blank" : undefined}
rel={!member.email && !phoneHref ? "noopener noreferrer" : undefined}
className="font-display"
style={{ textDecoration: "none", display: "block", textAlign: "center", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 14.5, padding: 12, borderRadius: 11 }}
>
{member.email ? "Envoyer un message" : phoneHref ? "Appeler maintenant" : "Visiter le site"}
</a>
</section>
)}
</aside>
</div>
</div>
+3 -2
View File
@@ -23,6 +23,7 @@ import {
users,
} from "@/db/schema";
import { can, LABEL_TO_ROLE } from "@/lib/rbac";
import { normalizeWebsite } from "@/lib/member-profile";
import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
import type { AppRole } from "@/types/next-auth";
@@ -267,7 +268,7 @@ export async function updateMember(formData: FormData) {
description: String(formData.get("description") ?? "").trim() || null,
postalCode: String(formData.get("postalCode") ?? "").trim() || null,
phone: String(formData.get("phone") ?? "").trim() || null,
website: String(formData.get("website") ?? "").trim() || null,
website: normalizeWebsite(String(formData.get("website") ?? "")),
memberSince: formData.get("memberSince") ? Number(formData.get("memberSince")) : null,
coverUrl: String(formData.get("coverUrl") ?? "").trim() || null,
logoUrl: String(formData.get("logoUrl") ?? "").trim() || null,
@@ -797,7 +798,7 @@ export async function updateOwnProfile(formData: FormData) {
postalCode: String(formData.get("postalCode") ?? "").trim() || null,
city: String(formData.get("city") ?? "").trim() || null,
phone: String(formData.get("phone") ?? "").trim() || null,
website: String(formData.get("website") ?? "").trim() || null,
website: normalizeWebsite(String(formData.get("website") ?? "")),
hours: String(formData.get("hours") ?? "").trim() || null,
tags: String(formData.get("tags") ?? "").trim() || null,
coverUrl: String(formData.get("coverUrl") ?? "").trim() || null,
+2 -2
View File
@@ -6,6 +6,7 @@ import { db } from "@/db";
import { categories, members, users } from "@/db/schema";
import { can } from "@/lib/rbac";
import { ImageField } from "@/components/ImageField";
import { HoursEditor } from "@/components/HoursEditor";
import { communeOptions } from "@/lib/communes";
import { deleteMember, resetMemberPassword, updateMember } from "../../actions";
@@ -148,8 +149,7 @@ export default async function EditMemberPage({
</div>
<div style={{ marginTop: 16 }}>
<label className="field-label">Horaires (une ligne par jour : « Jour|plage », ex : Mardi – Vendredi|7h – 19h30)</label>
<textarea name="hours" rows={4} defaultValue={member.hours ?? ""} className="field" style={{ resize: "vertical" }} placeholder={"Mardi – Vendredi|7h – 19h30\nSamedi|7h – 19h\nDimanche|7h – 13h\nLundi|Fermé"} />
<HoursEditor defaultValue={member.hours} />
</div>
<div style={{ display: "flex", gap: 10, marginTop: 22 }}>
+2 -2
View File
@@ -5,6 +5,7 @@ import { db } from "@/db";
import { categories, imageConsents, meetingRegistrations, meetings, members, promotions, type Member } from "@/db/schema";
import { ImageField } from "@/components/ImageField";
import { ImageConsentForm } from "@/components/ImageConsentForm";
import { HoursEditor } from "@/components/HoursEditor";
import { communeOptions } from "@/lib/communes";
import { saveImageConsent, updateOwnProfile } from "../actions";
import { MemberSpaceForm } from "./MemberSpaceForm";
@@ -196,8 +197,7 @@ export default async function EspacePage() {
</div>
<div style={{ marginTop: 16 }}>
<label className="field-label">Horaires (une ligne par jour : « Jour|plage »)</label>
<textarea name="hours" rows={4} defaultValue={profile.hours ?? ""} className="field" style={{ resize: "vertical" }} placeholder={"Mardi – Vendredi|7h – 19h30\nSamedi|7h – 19h"} />
<HoursEditor defaultValue={profile.hours} />
</div>
<button
+406
View File
@@ -551,3 +551,409 @@ img {
.email-preview-shell iframe { height: 620px; }
}
/* ---- Fiche adhérent : horaires et contact ---- */
.profile-hero-action {
padding: 11px 18px;
border: 1px solid #d8cdb4;
border-radius: 11px;
background: #fff;
color: #3c3322;
font-size: 14px;
font-weight: 700;
text-decoration: none;
transition: border-color 160ms ease, color 160ms ease, transform 160ms ease;
}
.profile-hero-action:hover {
border-color: #2c6fb3;
color: #2c6fb3;
transform: translateY(-1px);
}
.profile-hero-action--primary {
border-color: #2c6fb3;
background: #2c6fb3;
color: #fff;
}
.profile-hero-action--primary:hover {
background: #235d97;
color: #fff;
}
.practical-section {
overflow: hidden;
padding: 26px 28px 28px;
border: 1px solid #e6dcc6;
border-radius: 18px;
background:
radial-gradient(circle at 100% 0, rgba(224, 166, 60, 0.12), transparent 190px),
#fff;
}
.practical-section__heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-bottom: 20px;
}
.eyebrow {
color: #9a6638;
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.open-status {
display: flex;
align-items: center;
gap: 9px;
min-width: 154px;
padding: 8px 12px;
border: 1px solid #e7d3ce;
border-radius: 12px;
background: #fff8f6;
color: #9f3e2e;
}
.open-status > span {
width: 9px;
height: 9px;
flex: 0 0 auto;
border-radius: 50%;
background: #d8472b;
box-shadow: 0 0 0 4px rgba(216, 71, 43, 0.12);
}
.open-status div {
display: flex;
min-width: 0;
flex-direction: column;
}
.open-status strong {
font-size: 12.5px;
line-height: 1.25;
}
.open-status small {
margin-top: 2px;
color: #916a61;
font-size: 10.5px;
}
.open-status.is-open {
border-color: #cce7d7;
background: #f1faf5;
color: #176c48;
}
.open-status.is-open > span {
background: #1f8a5b;
box-shadow: 0 0 0 4px rgba(31, 138, 91, 0.12);
}
.open-status.is-open small { color: #5d7f6e; }
.practical-grid {
display: grid;
grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr);
gap: 14px;
}
.schedule-card,
.contact-card {
min-width: 0;
border: 1px solid #eadfca;
border-radius: 16px;
padding: 18px;
}
.schedule-card {
background: linear-gradient(145deg, #fffdf8, #fbf5e8);
}
.contact-card {
background: linear-gradient(145deg, #fff, #f3f7fa);
}
.practical-card-title {
display: flex;
align-items: center;
gap: 11px;
padding-bottom: 14px;
border-bottom: 1px solid rgba(216, 205, 180, 0.68);
}
.practical-card-title__icon {
display: grid;
width: 35px;
height: 35px;
flex: 0 0 auto;
place-items: center;
border-radius: 11px;
background: #f3dfb6;
color: #8c5f2e;
font-size: 20px;
font-weight: 800;
}
.practical-card-title__icon--blue {
background: #dfeef5;
color: #2c6fb3;
}
.practical-card-title span:not(.practical-card-title__icon) {
display: block;
margin-bottom: 2px;
color: #a09279;
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.practical-card-title h3 {
margin: 0;
color: #26201a;
font-size: 15.5px;
line-height: 1.25;
}
.schedule-list {
display: flex;
flex-direction: column;
margin-top: 8px;
}
.schedule-row {
display: grid;
grid-template-columns: minmax(90px, 0.75fr) minmax(120px, 1.25fr);
align-items: center;
gap: 12px;
min-height: 38px;
padding: 7px 8px;
border-radius: 9px;
color: #655b4c;
font-size: 12.5px;
}
.schedule-row.is-today {
background: #fff;
box-shadow: 0 5px 16px -12px rgba(80, 55, 22, 0.58);
}
.schedule-row__day {
display: flex;
min-width: 0;
flex-direction: column;
font-weight: 600;
}
.schedule-row__day small {
margin-top: 1px;
color: #9a6638;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.schedule-row > strong {
color: #352d22;
font-size: 12px;
font-weight: 700;
text-align: right;
}
.schedule-row > strong.is-closed {
color: #b16a5e;
font-weight: 600;
}
.contact-actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
}
.contact-action {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
min-width: 0;
padding: 10px;
border: 1px solid transparent;
border-radius: 11px;
color: #4d4437;
text-decoration: none;
transition: background 160ms ease, border-color 160ms ease, transform 160ms ease;
}
.contact-action:hover {
border-color: #d7e4eb;
background: #fff;
transform: translateX(2px);
}
.contact-action__icon {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border-radius: 10px;
background: #eaf2f7;
color: #2c6fb3;
}
.contact-action__icon svg {
width: 17px;
height: 17px;
}
.contact-action__copy {
display: flex;
min-width: 0;
flex-direction: column;
}
.contact-action__copy strong {
color: #33291d;
font-size: 12.5px;
}
.contact-action__copy > span {
overflow: hidden;
margin-top: 2px;
color: #7d715f;
font-size: 10.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-action__arrow {
color: #7f9db5;
font-size: 14px;
}
.practical-empty {
padding: 20px 4px 8px;
color: #9b8e78;
font-size: 13px;
}
/* ---- Éditeur hebdomadaire des horaires ---- */
.hours-editor {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.hours-editor__hint {
margin: 5px 0 12px;
color: #93866f;
font-size: 12px;
}
.hours-editor__days {
overflow: hidden;
border: 1px solid #e2d7c1;
border-radius: 13px;
background: #faf7ef;
}
.hours-editor__day {
display: grid;
grid-template-columns: 150px minmax(0, 1fr);
min-height: 58px;
padding: 11px 13px;
border-bottom: 1px solid #ebe2d0;
}
.hours-editor__day:last-child { border-bottom: 0; }
.hours-editor__day.is-open { background: #fff; }
.hours-editor__day-head {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 5px;
}
.hours-editor__day-head > strong {
color: #3a3125;
font-size: 13px;
}
.hours-editor__toggle {
display: inline-flex;
align-items: center;
gap: 6px;
color: #9b5c51;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.hours-editor__toggle:has(input:checked) { color: #1f7a53; }
.hours-editor__toggle input {
width: 15px;
height: 15px;
accent-color: #1f8a5b;
}
.hours-editor__slots {
display: flex;
min-width: 0;
flex-direction: column;
justify-content: center;
gap: 7px;
}
.hours-editor__slot {
display: grid;
grid-template-columns: minmax(112px, 1fr) minmax(112px, 1fr) 28px;
align-items: end;
gap: 7px;
}
.hours-editor__slot label {
display: grid;
grid-template-columns: 22px minmax(0, 1fr);
align-items: center;
gap: 5px;
color: #93866f;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
}
.hours-editor__slot input {
width: 100%;
min-width: 0;
padding: 8px 7px;
border: 1px solid #ddd2bb;
border-radius: 8px;
background: #faf7ef;
color: #3c3322;
font: inherit;
}
.hours-editor__slot input:focus {
outline: 2px solid rgba(44, 111, 179, 0.3);
outline-offset: 1px;
}
.hours-editor__remove {
width: 28px;
height: 34px;
border: 0;
background: transparent;
color: #b06a5e;
font-size: 20px;
cursor: pointer;
}
.hours-editor__add {
align-self: flex-start;
padding: 2px 0;
border: 0;
background: transparent;
color: #2c6fb3;
font: inherit;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
@media (max-width: 720px) {
.practical-section { padding: 21px 16px; }
.practical-section__heading {
align-items: flex-start;
flex-direction: column;
}
.practical-grid { grid-template-columns: 1fr; }
.schedule-card,
.contact-card { padding: 15px; }
.profile-hero-action {
flex: 1 1 130px;
text-align: center;
}
.hours-editor__day {
grid-template-columns: 1fr;
gap: 10px;
}
.hours-editor__day-head {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
@media (max-width: 460px) {
.schedule-row {
grid-template-columns: minmax(82px, 0.7fr) minmax(0, 1.3fr);
}
.schedule-row > strong {
line-height: 1.4;
}
.hours-editor__slot {
grid-template-columns: 1fr 1fr 28px;
}
.hours-editor__slot label {
grid-template-columns: 1fr;
}
}
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { useMemo, useState } from "react";
import {
editorSchedule,
serializeMemberHours,
type DaySchedule,
type HoursSlot,
} from "@/lib/member-profile";
function emptySlot(): HoursSlot {
return { start: "09:00", end: "18:00" };
}
export function HoursEditor({ defaultValue }: { defaultValue?: string | null }) {
const initialDays = useMemo(() => editorSchedule(defaultValue), [defaultValue]);
const [days, setDays] = useState<DaySchedule[]>(initialDays);
const [touched, setTouched] = useState(Boolean(defaultValue?.trim()));
function updateDay(index: number, update: (day: DaySchedule) => DaySchedule) {
setTouched(true);
setDays((current) => current.map((day, dayIndex) => (dayIndex === index ? update(day) : day)));
}
function setOpen(index: number, open: boolean) {
updateDay(index, (day) => ({
...day,
closed: !open,
slots: open ? (day.slots.length > 0 ? day.slots : [emptySlot()]) : [],
}));
}
function updateSlot(index: number, slotIndex: number, key: keyof HoursSlot, value: string) {
updateDay(index, (day) => ({
...day,
slots: day.slots.map((slot, currentSlot) =>
currentSlot === slotIndex ? { ...slot, [key]: value } : slot
),
}));
}
function addSlot(index: number) {
updateDay(index, (day) => ({
...day,
closed: false,
slots: [...day.slots, { start: "14:00", end: "18:00" }].slice(0, 2),
}));
}
function removeSlot(index: number, slotIndex: number) {
updateDay(index, (day) => {
const slots = day.slots.filter((_, currentSlot) => currentSlot !== slotIndex);
return { ...day, closed: slots.length === 0, slots };
});
}
const serialized = touched ? serializeMemberHours(days) : defaultValue ?? "";
return (
<fieldset className="hours-editor">
<legend className="field-label">Horaires d&apos;ouverture</legend>
<p className="hours-editor__hint">
Activez jours ouverts. Ajoutez seconde plage pour coupure déjeuner.
</p>
<input type="hidden" name="hours" value={serialized} />
<div className="hours-editor__days">
{days.map((day, index) => {
const open = !day.closed;
return (
<div className={`hours-editor__day${open ? " is-open" : ""}`} key={day.key}>
<div className="hours-editor__day-head">
<strong>{day.label}</strong>
<label className="hours-editor__toggle">
<input
type="checkbox"
checked={open}
onChange={(event) => setOpen(index, event.target.checked)}
/>
<span>{open ? "Ouvert" : "Fermé"}</span>
</label>
</div>
{open && (
<div className="hours-editor__slots">
{day.slots.map((slot, slotIndex) => (
<div className="hours-editor__slot" key={`${day.key}-${slotIndex}`}>
<label>
<span>De</span>
<input
type="time"
value={slot.start}
onChange={(event) =>
updateSlot(index, slotIndex, "start", event.target.value)
}
aria-label={`${day.label}, ouverture ${slotIndex + 1}`}
/>
</label>
<label>
<span>À</span>
<input
type="time"
value={slot.end}
onChange={(event) =>
updateSlot(index, slotIndex, "end", event.target.value)
}
aria-label={`${day.label}, fermeture ${slotIndex + 1}`}
/>
</label>
{day.slots.length > 1 && (
<button
type="button"
className="hours-editor__remove"
onClick={() => removeSlot(index, slotIndex)}
aria-label={`Supprimer plage ${slotIndex + 1} du ${day.label.toLowerCase()}`}
>
×
</button>
)}
</div>
))}
{day.slots.length < 2 && (
<button
type="button"
className="hours-editor__add"
onClick={() => addSlot(index)}
>
+ Ajouter une seconde plage
</button>
)}
</div>
)}
</div>
);
})}
</div>
</fieldset>
);
}
+260
View File
@@ -0,0 +1,260 @@
export const WEEK_DAYS = [
{ key: "monday", label: "Lundi", shortLabel: "Lun." },
{ key: "tuesday", label: "Mardi", shortLabel: "Mar." },
{ key: "wednesday", label: "Mercredi", shortLabel: "Mer." },
{ key: "thursday", label: "Jeudi", shortLabel: "Jeu." },
{ key: "friday", label: "Vendredi", shortLabel: "Ven." },
{ key: "saturday", label: "Samedi", shortLabel: "Sam." },
{ key: "sunday", label: "Dimanche", shortLabel: "Dim." },
] as const;
export type WeekDayKey = (typeof WEEK_DAYS)[number]["key"];
export type HoursSlot = {
start: string;
end: string;
};
export type DaySchedule = {
key: WeekDayKey;
label: string;
closed: boolean;
slots: HoursSlot[];
};
function normalizeText(value: string) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/\./g, "")
.trim();
}
function dayIndex(value: string) {
const normalized = normalizeText(value);
return WEEK_DAYS.findIndex(({ label, shortLabel }) => {
const full = normalizeText(label);
const short = normalizeText(shortLabel);
return normalized === full || normalized === short || normalized.startsWith(full);
});
}
function parseTime(value: string) {
const match = value.trim().match(/^(\d{1,2})(?:\s*(?:h|:)\s*(\d{2})?)?$/i);
if (!match) return null;
const hour = Number(match[1]);
const minute = Number(match[2] ?? 0);
if (hour > 23 || minute > 59) return null;
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
}
function parseSlots(value: string): HoursSlot[] {
return value
.split(/\s*(?:\/|,|;|\bet\b)\s*/i)
.map((part) => {
const match = part.trim().match(
/^(\d{1,2}(?:\s*(?:h|:)\s*\d{0,2})?)\s*(?:–|—|-|à)\s*(\d{1,2}(?:\s*(?:h|:)\s*\d{0,2})?)$/i
);
if (!match) return null;
const start = parseTime(match[1]);
const end = parseTime(match[2]);
return start && end ? { start, end } : null;
})
.filter((slot): slot is HoursSlot => Boolean(slot));
}
function indicesFromLabel(label: string) {
const parts = label.split(/\s+(?:–|—|-|à)\s+/i);
if (parts.length === 1) {
const index = dayIndex(parts[0]);
return index >= 0 ? [index] : [];
}
const start = dayIndex(parts[0]);
const end = dayIndex(parts[parts.length - 1]);
if (start < 0 || end < 0) return [];
const indices: number[] = [];
let current = start;
for (let guard = 0; guard < 7; guard += 1) {
indices.push(current);
if (current === end) break;
current = (current + 1) % 7;
}
return indices;
}
export function parseMemberHours(value: string | null | undefined): DaySchedule[] {
if (!value?.trim()) return [];
const schedules = new Map<number, DaySchedule>();
for (const line of value.split("\n")) {
const separator = line.indexOf("|");
if (separator < 0) continue;
const label = line.slice(0, separator).trim();
const range = line.slice(separator + 1).trim();
const indices = indicesFromLabel(label);
const closed = normalizeText(range).includes("ferm");
const slots = closed ? [] : parseSlots(range);
if (!closed && slots.length === 0) continue;
for (const index of indices) {
const day = WEEK_DAYS[index];
schedules.set(index, {
key: day.key,
label: day.label,
closed,
slots: slots.map((slot) => ({ ...slot })),
});
}
}
return Array.from(schedules.entries())
.sort(([a], [b]) => a - b)
.map(([, schedule]) => schedule);
}
export function editorSchedule(value: string | null | undefined): DaySchedule[] {
const parsed = new Map(parseMemberHours(value).map((day) => [day.key, day]));
return WEEK_DAYS.map((day) => {
const existing = parsed.get(day.key);
return (
existing ?? {
key: day.key,
label: day.label,
closed: true,
slots: [],
}
);
});
}
export function serializeMemberHours(days: DaySchedule[]) {
return WEEK_DAYS.map((day) => {
const schedule = days.find((item) => item.key === day.key);
if (!schedule || schedule.closed || schedule.slots.length === 0) {
return `${day.label}|Fermé`;
}
const slots = schedule.slots
.filter((slot) => slot.start && slot.end)
.map((slot) => `${slot.start} – ${slot.end}`)
.join(" / ");
return `${day.label}|${slots || "Fermé"}`;
}).join("\n");
}
export function formatHour(value: string) {
const [hour, minute] = value.split(":");
return `${Number(hour)}h${minute === "00" ? "" : minute}`;
}
export function formatSlots(slots: HoursSlot[]) {
return slots.map((slot) => `${formatHour(slot.start)} – ${formatHour(slot.end)}`).join(" / ");
}
function minutesOf(value: string) {
const [hour, minute] = value.split(":").map(Number);
return hour * 60 + minute;
}
function parisNow(date: Date) {
const parts = new Intl.DateTimeFormat("en-GB", {
timeZone: "Europe/Paris",
weekday: "long",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const weekday = parts.find((part) => part.type === "weekday")?.value.toLowerCase();
const hour = Number(parts.find((part) => part.type === "hour")?.value ?? 0);
const minute = Number(parts.find((part) => part.type === "minute")?.value ?? 0);
const dayIndex = WEEK_DAYS.findIndex((day) => day.key === weekday);
return { dayIndex: dayIndex < 0 ? 0 : dayIndex, minutes: hour * 60 + minute };
}
export type OpenStatus = {
isOpen: boolean;
label: string;
detail: string | null;
todayKey: WeekDayKey;
};
export function getOpenStatus(
schedules: DaySchedule[],
date = new Date()
): OpenStatus | null {
if (schedules.length === 0) return null;
const byKey = new Map(schedules.map((schedule) => [schedule.key, schedule]));
const now = parisNow(date);
const today = WEEK_DAYS[now.dayIndex];
const todaySchedule = byKey.get(today.key);
for (const slot of todaySchedule?.slots ?? []) {
const start = minutesOf(slot.start);
const end = minutesOf(slot.end);
if (now.minutes >= start && now.minutes < end) {
return {
isOpen: true,
label: "Ouvert maintenant",
detail: `Ferme à ${formatHour(slot.end)}`,
todayKey: today.key,
};
}
if (now.minutes < start) {
return {
isOpen: false,
label: "Fermé actuellement",
detail: `Ouvre à ${formatHour(slot.start)}`,
todayKey: today.key,
};
}
}
for (let offset = 1; offset <= 7; offset += 1) {
const index = (now.dayIndex + offset) % 7;
const nextDay = WEEK_DAYS[index];
const nextSlot = byKey.get(nextDay.key)?.slots[0];
if (!nextSlot) continue;
return {
isOpen: false,
label: "Fermé actuellement",
detail:
offset === 1
? `Ouvre demain à ${formatHour(nextSlot.start)}`
: `Ouvre ${nextDay.label.toLowerCase()} à ${formatHour(nextSlot.start)}`,
todayKey: today.key,
};
}
return {
isOpen: false,
label: "Fermé actuellement",
detail: null,
todayKey: today.key,
};
}
export function normalizeWebsite(value: string | null | undefined) {
const raw = value?.trim();
if (!raw) return null;
const candidate = /^[a-z][a-z\d+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
try {
const url = new URL(candidate);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
const normalized = url.toString();
return normalized.length <= 200 ? normalized : null;
} catch {
return null;
}
}
export function websiteLabel(value: string | null | undefined) {
const normalized = normalizeWebsite(value);
if (!normalized) return null;
const url = new URL(normalized);
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
return `${url.hostname.replace(/^www\./i, "")}${path}`;
}