mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-12 09:48:18 +02:00
Refonte des horaires et contacts adhérents
This commit is contained in:
1 parent
5e7233f98d
commit
dd9697019c
7 files changed
+960
-41
No files matched your search
+148
-35
@@ -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'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'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'é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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in new issue
Block a user