feat(ui): booking redesign + auto-assign settings + specialist fields
Dynamic 3/4-step wizard (omits specialist step when auto-assign on), always-visible MonthCalendar, 2-column desktop layout with morning/afternoon slot grouping, confirmation reasons badges. Settings: auto-assign checkbox + business working-hours editor. Employee modal: specialties, efficiency, per-employee working hours. Fix icon/placeholder overlap app-wide via @layer components + pl-10.
This commit is contained in:
@@ -0,0 +1,118 @@
|
|||||||
|
// src/components/MonthCalendar.tsx
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { cn } from "../lib/format";
|
||||||
|
|
||||||
|
const WD = ["Lun", "Mar", "Mié", "Jue", "Vie", "Sáb", "Dom"];
|
||||||
|
const MONTHS = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"];
|
||||||
|
|
||||||
|
function toIso(d: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
function isoToDate(iso: string): Date {
|
||||||
|
const [y, m, d] = iso.split("-").map(Number);
|
||||||
|
return new Date(y, (m || 1) - 1, d || 1);
|
||||||
|
}
|
||||||
|
function atMidnight(d: Date): Date {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonthCalendar({
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
availableDates,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
min?: string;
|
||||||
|
max?: string;
|
||||||
|
availableDates?: Set<string>;
|
||||||
|
onSelect: (iso: string) => void;
|
||||||
|
}) {
|
||||||
|
const initial = value ? isoToDate(value) : new Date();
|
||||||
|
const [cursor, setCursor] = useState(new Date(initial.getFullYear(), initial.getMonth(), 1));
|
||||||
|
const todayIso = toIso(new Date());
|
||||||
|
const minDate = min ? atMidnight(isoToDate(min)) : null;
|
||||||
|
const maxDate = max ? atMidnight(isoToDate(max)) : null;
|
||||||
|
|
||||||
|
const cells = useMemo(() => {
|
||||||
|
const first = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
|
||||||
|
const lead = (first.getDay() + 6) % 7; // Lun primero
|
||||||
|
const start = new Date(first);
|
||||||
|
start.setDate(first.getDate() - lead);
|
||||||
|
const out: Date[] = [];
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
const d = new Date(start);
|
||||||
|
d.setDate(start.getDate() + i);
|
||||||
|
out.push(d);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [cursor]);
|
||||||
|
|
||||||
|
const inMonth = (d: Date) => d.getMonth() === cursor.getMonth();
|
||||||
|
const selectable = (d: Date) => {
|
||||||
|
const dm = atMidnight(d);
|
||||||
|
if (minDate && dm < minDate) return false;
|
||||||
|
if (maxDate && dm > maxDate) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const move = (delta: number) => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + delta, 1));
|
||||||
|
const firstOfMonth = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
|
||||||
|
const canPrev = !minDate || new Date(cursor.getFullYear(), cursor.getMonth(), 0) >= firstOfMonth;
|
||||||
|
const canNext = !maxDate || new Date(cursor.getFullYear(), cursor.getMonth() + 2, 0) <= new Date(maxDate.getFullYear(), maxDate.getMonth() + 1, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="select-none">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<button type="button" onClick={() => move(-1)} disabled={!canPrev}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
|
||||||
|
aria-label="Mes anterior">
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<div className="text-sm font-extrabold text-slate-800">{MONTHS[cursor.getMonth()]} {cursor.getFullYear()}</div>
|
||||||
|
<button type="button" onClick={() => move(1)} disabled={!canNext}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
|
||||||
|
aria-label="Mes siguiente">
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-1 grid grid-cols-7 gap-1">
|
||||||
|
{WD.map((w) => (
|
||||||
|
<div key={w} className="text-center text-[11px] font-bold uppercase tracking-wide text-slate-400">{w}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{cells.map((d, i) => {
|
||||||
|
const dIso = toIso(d);
|
||||||
|
const sel = dIso === value;
|
||||||
|
const on = selectable(d);
|
||||||
|
const inM = inMonth(d);
|
||||||
|
const isToday = dIso === todayIso;
|
||||||
|
const hasAvail = availableDates?.has(dIso);
|
||||||
|
const weekend = d.getDay() === 0 || d.getDay() === 6;
|
||||||
|
return (
|
||||||
|
<button key={i} type="button" disabled={!on} onClick={() => onSelect(dIso)}
|
||||||
|
className={cn(
|
||||||
|
"relative flex aspect-square items-center justify-center rounded-xl text-sm font-bold transition-all",
|
||||||
|
!inM && "text-slate-300",
|
||||||
|
inM && !sel && on && "text-slate-700 hover:bg-brand-50 hover:text-brand-700",
|
||||||
|
inM && !on && "cursor-not-allowed text-slate-300",
|
||||||
|
sel && "bg-brand-500 text-white shadow-soft ring-2 ring-brand-500/30",
|
||||||
|
weekend && inM && !sel && on && "text-slate-500"
|
||||||
|
)}>
|
||||||
|
{d.getDate()}
|
||||||
|
{hasAvail && !sel && (
|
||||||
|
<span className="absolute bottom-1 h-1.5 w-1.5 rounded-full bg-accent-500" aria-hidden />
|
||||||
|
)}
|
||||||
|
{isToday && !sel && (
|
||||||
|
<span className="absolute top-1 h-1.5 w-1.5 rounded-full bg-brand-400" aria-hidden />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+9
-7
@@ -258,9 +258,10 @@ body {
|
|||||||
background: #fecaca;
|
background: #fecaca;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input,
|
@layer components {
|
||||||
.select,
|
.input,
|
||||||
.textarea {
|
.select,
|
||||||
|
.textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
@@ -270,12 +271,13 @@ body {
|
|||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.input:focus,
|
.input:focus,
|
||||||
.select:focus,
|
.select:focus,
|
||||||
.textarea:focus {
|
.textarea:focus {
|
||||||
border-color: #3b66ff;
|
border-color: #3b66ff;
|
||||||
box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15);
|
box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.label {
|
.label {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface PublicBusiness {
|
|||||||
cancel_penalty_pct: number;
|
cancel_penalty_pct: number;
|
||||||
require_deposit: boolean;
|
require_deposit: boolean;
|
||||||
deposit_pct: number;
|
deposit_pct: number;
|
||||||
|
auto_assign_specialist: number | boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicService {
|
export interface PublicService {
|
||||||
@@ -58,7 +59,7 @@ export interface BookClient {
|
|||||||
|
|
||||||
export interface BookPayload {
|
export interface BookPayload {
|
||||||
service_id: number;
|
service_id: number;
|
||||||
employee_id: number;
|
employee_id?: number;
|
||||||
start_at: string;
|
start_at: string;
|
||||||
client: BookClient;
|
client: BookClient;
|
||||||
}
|
}
|
||||||
@@ -69,7 +70,9 @@ export interface BookResponse {
|
|||||||
start_at: string;
|
start_at: string;
|
||||||
price: number;
|
price: number;
|
||||||
service_name: string;
|
service_name: string;
|
||||||
|
employee_id: number;
|
||||||
employee_name: string;
|
employee_name: string;
|
||||||
|
reasons: string[];
|
||||||
};
|
};
|
||||||
business: { name: string; currency_symbol: string };
|
business: { name: string; currency_symbol: string };
|
||||||
client_created: boolean;
|
client_created: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const DEFAULT_WH: Record<number, { start: string; end: string } | null> = {
|
||||||
|
1: { start: "09:00", end: "20:00" }, 2: { start: "09:00", end: "20:00" },
|
||||||
|
3: { start: "09:00", end: "20:00" }, 4: { start: "09:00", end: "20:00" },
|
||||||
|
5: { start: "09:00", end: "20:00" }, 6: null, 7: null,
|
||||||
|
};
|
||||||
|
export const DAYS = [
|
||||||
|
{ n: 1, label: "Lunes" }, { n: 2, label: "Martes" }, { n: 3, label: "Miércoles" },
|
||||||
|
{ n: 4, label: "Jueves" }, { n: 5, label: "Viernes" }, { n: 6, label: "Sábado" }, { n: 7, label: "Domingo" },
|
||||||
|
];
|
||||||
|
export function parseWh(v: any): Record<number, { start: string; end: string } | null> {
|
||||||
|
let obj = v;
|
||||||
|
if (typeof v === "string") { try { obj = JSON.parse(v); } catch { obj = null; } }
|
||||||
|
if (!obj || typeof obj !== "object") return { ...DEFAULT_WH };
|
||||||
|
const out: Record<number, any> = {};
|
||||||
|
for (let d = 1; d <= 7; d++) out[d] = obj[String(d)] ?? null;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
+130
-1
@@ -16,6 +16,7 @@ import type { Employee } from "../../shared/types";
|
|||||||
import { PageHeader, Avatar, Spinner, EmptyState } from "../components/ui";
|
import { PageHeader, Avatar, Spinner, EmptyState } from "../components/ui";
|
||||||
import { Modal } from "../components/Modal";
|
import { Modal } from "../components/Modal";
|
||||||
import { formatCurrency, cn } from "../lib/format";
|
import { formatCurrency, cn } from "../lib/format";
|
||||||
|
import { DEFAULT_WH, DAYS, parseWh } from "../lib/workingHours";
|
||||||
|
|
||||||
const COLORS = ["#3b66ff", "#f17616", "#10b981", "#a855f7", "#ec4899", "#14b8a6", "#f59e0b", "#6366f1"];
|
const COLORS = ["#3b66ff", "#f17616", "#10b981", "#a855f7", "#ec4899", "#14b8a6", "#f59e0b", "#6366f1"];
|
||||||
|
|
||||||
@@ -193,6 +194,11 @@ function EmployeeModal({
|
|||||||
const [color, setColor] = useState(COLORS[0]);
|
const [color, setColor] = useState(COLORS[0]);
|
||||||
const [active, setActive] = useState(true);
|
const [active, setActive] = useState(true);
|
||||||
const [svcIds, setSvcIds] = useState<number[]>([]);
|
const [svcIds, setSvcIds] = useState<number[]>([]);
|
||||||
|
const [specialties, setSpecialties] = useState<string[]>([]);
|
||||||
|
const [specialtyInput, setSpecialtyInput] = useState("");
|
||||||
|
const [efficiency, setEfficiency] = useState(50);
|
||||||
|
const [inheritHours, setInheritHours] = useState(true);
|
||||||
|
const [wh, setWh] = useState<Record<number, { start: string; end: string } | null>>({ ...DEFAULT_WH });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -204,6 +210,12 @@ function EmployeeModal({
|
|||||||
setColor(employee.color);
|
setColor(employee.color);
|
||||||
setActive(!!employee.active);
|
setActive(!!employee.active);
|
||||||
setSvcIds(employee.service_ids ?? []);
|
setSvcIds(employee.service_ids ?? []);
|
||||||
|
setSpecialties(Array.isArray(employee.specialties) ? employee.specialties : []);
|
||||||
|
setEfficiency(typeof employee.efficiency_score === "number" ? employee.efficiency_score : 50);
|
||||||
|
const parsed = parseWh(employee.working_hours);
|
||||||
|
const hasOwn = !!employee.working_hours && Object.values(parsed).some((v) => v !== null);
|
||||||
|
setInheritHours(!hasOwn);
|
||||||
|
setWh(hasOwn ? parsed : { ...DEFAULT_WH });
|
||||||
} else {
|
} else {
|
||||||
setName("");
|
setName("");
|
||||||
setRole("Especialista");
|
setRole("Especialista");
|
||||||
@@ -212,12 +224,28 @@ function EmployeeModal({
|
|||||||
setColor(COLORS[Math.floor(Math.random() * COLORS.length)]);
|
setColor(COLORS[Math.floor(Math.random() * COLORS.length)]);
|
||||||
setActive(true);
|
setActive(true);
|
||||||
setSvcIds([]);
|
setSvcIds([]);
|
||||||
|
setSpecialties([]);
|
||||||
|
setSpecialtyInput("");
|
||||||
|
setEfficiency(50);
|
||||||
|
setInheritHours(true);
|
||||||
|
setWh({ ...DEFAULT_WH });
|
||||||
}
|
}
|
||||||
}, [open, employee]);
|
}, [open, employee]);
|
||||||
|
|
||||||
const mut = useMutation({
|
const mut = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const payload = { name, role, email, phone, color, active, service_ids: svcIds };
|
const payload = {
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
color,
|
||||||
|
active,
|
||||||
|
service_ids: svcIds,
|
||||||
|
specialties,
|
||||||
|
efficiency_score: efficiency,
|
||||||
|
working_hours: inheritHours ? null : wh,
|
||||||
|
};
|
||||||
if (employee) return api.employees.update(employee.id, payload);
|
if (employee) return api.employees.update(employee.id, payload);
|
||||||
return api.employees.create(payload);
|
return api.employees.create(payload);
|
||||||
},
|
},
|
||||||
@@ -303,6 +331,107 @@ function EmployeeModal({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Especialidades</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 rounded-xl border border-slate-100 p-2">
|
||||||
|
{specialties.map((sp) => (
|
||||||
|
<span key={sp} className="chip bg-brand-50 text-brand-700">
|
||||||
|
{sp}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSpecialties((a) => a.filter((x) => x !== sp))}
|
||||||
|
className="ml-0.5 text-brand-400 hover:text-brand-700"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
className="min-w-[120px] flex-1 bg-transparent text-sm outline-none"
|
||||||
|
placeholder="Ej. Coloración y pulsa Enter"
|
||||||
|
value={specialtyInput}
|
||||||
|
onChange={(e) => setSpecialtyInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && specialtyInput.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSpecialties((a) => [...new Set([...a, specialtyInput.trim()])]);
|
||||||
|
setSpecialtyInput("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[11px] text-slate-500">Etiquetas que el algoritmo compara con la categoría y el nombre del servicio.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">
|
||||||
|
Eficiencia <span className="ml-1 text-brand-700">{efficiency}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={efficiency}
|
||||||
|
onChange={(e) => setEfficiency(Number(e.target.value))}
|
||||||
|
className="w-full accent-brand-500"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[11px] text-slate-500">Qué tan rápido/hábil es. El algoritmo premia valores altos cuando hay empate por disponibilidad.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 accent-brand-500"
|
||||||
|
checked={inheritHours}
|
||||||
|
onChange={(e) => setInheritHours(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-bold text-slate-800">Heredar horario del negocio</span>
|
||||||
|
</label>
|
||||||
|
{!inheritHours && (
|
||||||
|
<div className="mt-2 space-y-1.5">
|
||||||
|
{DAYS.map(({ n, label }) => {
|
||||||
|
const on = !!wh[n];
|
||||||
|
return (
|
||||||
|
<div key={n} className="flex items-center gap-2 rounded-lg border border-slate-100 px-2 py-1.5">
|
||||||
|
<label className="flex w-24 cursor-pointer items-center gap-2 text-xs font-semibold text-slate-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 accent-brand-500"
|
||||||
|
checked={on}
|
||||||
|
onChange={() =>
|
||||||
|
setWh({ ...wh, [n]: wh[n] ? null : { start: "09:00", end: "18:00" } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{on ? (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
className="input !w-auto !py-1 text-xs"
|
||||||
|
value={wh[n]!.start}
|
||||||
|
onChange={(e) =>
|
||||||
|
setWh({ ...wh, [n]: { ...(wh[n] as { start: string; end: string }), start: e.target.value } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-400">a</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
className="input !w-auto !py-1 text-xs"
|
||||||
|
value={wh[n]!.end}
|
||||||
|
onChange={(e) =>
|
||||||
|
setWh({ ...wh, [n]: { ...(wh[n] as { start: string; end: string }), end: e.target.value } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-medium text-slate-400">Cerrado</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||||
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
|
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
|
||||||
Activo (puede recibir citas)
|
Activo (puede recibir citas)
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Wand2,
|
||||||
|
Clock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { PageHeader, Spinner } from "../components/ui";
|
import { PageHeader, Spinner } from "../components/ui";
|
||||||
|
import { DEFAULT_WH, DAYS, parseWh } from "../lib/workingHours";
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -20,9 +23,10 @@ export function SettingsPage() {
|
|||||||
const [f, setF] = useState<any>(null);
|
const [f, setF] = useState<any>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [wh, setWh] = useState<Record<number, { start: string; end: string } | null>>({ ...DEFAULT_WH });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.settings) setF(data.settings);
|
if (data?.settings) { setF(data.settings); setWh(parseWh(data.settings.working_hours)); }
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const mut = useMutation({
|
const mut = useMutation({
|
||||||
@@ -39,6 +43,8 @@ export function SettingsPage() {
|
|||||||
require_deposit: f.require_deposit ? 1 : 0,
|
require_deposit: f.require_deposit ? 1 : 0,
|
||||||
deposit_pct: Number(f.deposit_pct),
|
deposit_pct: Number(f.deposit_pct),
|
||||||
timezone: f.timezone,
|
timezone: f.timezone,
|
||||||
|
auto_assign_specialist: f.auto_assign_specialist ? 1 : 0,
|
||||||
|
working_hours: wh,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["settings"] });
|
qc.invalidateQueries({ queryKey: ["settings"] });
|
||||||
@@ -97,6 +103,19 @@ export function SettingsPage() {
|
|||||||
<input className="input" value={f.slug} onChange={(e) => set("slug", e.target.value)} placeholder="mi-negocio" />
|
<input className="input" value={f.slug} onChange={(e) => set("slug", e.target.value)} placeholder="mi-negocio" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 p-3">
|
||||||
|
<input type="checkbox" className="mt-0.5 h-4 w-4 accent-brand-500" checked={!!f.auto_assign_specialist} onChange={(e) => set("auto_assign_specialist", e.target.checked)} />
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5 text-sm font-bold text-slate-800"><Wand2 className="h-4 w-4 text-brand-500" /> Asignar especialista automáticamente</div>
|
||||||
|
<p className="text-xs text-slate-500">El cliente no elige especialista: el sistema lo asigna según especialidad, eficiencia y disponibilidad. Evita dobles reservas.</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label flex items-center gap-1.5"><Clock className="h-3.5 w-3.5 text-slate-400" /> Horario del negocio</label>
|
||||||
|
<WorkingHoursEditor value={wh} onChange={setWh} />
|
||||||
|
<p className="mt-1 text-[11px] text-slate-500">Define qué días y horas se ofrecen citas. Los especialistas pueden tener su propio horario.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -175,3 +194,35 @@ function SectionTitle({ icon: Icon, color, title, subtitle }: { icon: any; color
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WorkingHoursEditor({ value, onChange }: { value: Record<number, { start: string; end: string } | null>; onChange: (v: Record<number, { start: string; end: string } | null>) => void }) {
|
||||||
|
const toggle = (n: number) => onChange({ ...value, [n]: value[n] ? null : { start: "09:00", end: "18:00" } });
|
||||||
|
const set = (n: number, field: "start" | "end", v: string) => {
|
||||||
|
const cur = value[n] ?? { start: "09:00", end: "18:00" };
|
||||||
|
onChange({ ...value, [n]: { ...cur, [field]: v } });
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{DAYS.map(({ n, label }) => {
|
||||||
|
const on = !!value[n];
|
||||||
|
return (
|
||||||
|
<div key={n} className="flex items-center gap-2 rounded-lg border border-slate-100 px-2 py-1.5">
|
||||||
|
<label className="flex w-24 cursor-pointer items-center gap-2 text-xs font-semibold text-slate-700">
|
||||||
|
<input type="checkbox" className="h-4 w-4 accent-brand-500" checked={on} onChange={() => toggle(n)} />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{on ? (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input type="time" className="input !w-auto !py-1 text-xs" value={value[n]!.start} onChange={(e) => set(n, "start", e.target.value)} />
|
||||||
|
<span className="text-xs text-slate-400">a</span>
|
||||||
|
<input type="time" className="input !w-auto !py-1 text-xs" value={value[n]!.end} onChange={(e) => set(n, "end", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-medium text-slate-400">Cerrado</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
type BookResponse,
|
type BookResponse,
|
||||||
} from "../../lib/publicApi";
|
} from "../../lib/publicApi";
|
||||||
import { Spinner } from "../../components/ui";
|
import { Spinner } from "../../components/ui";
|
||||||
|
import { MonthCalendar } from "../../components/MonthCalendar";
|
||||||
import { cn, formatCurrency, formatDate, formatTime } from "../../lib/format";
|
import { cn, formatCurrency, formatDate, formatTime } from "../../lib/format";
|
||||||
|
|
||||||
function todayStr() {
|
function todayStr() {
|
||||||
@@ -33,12 +34,25 @@ function todayStr() {
|
|||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEPS = [
|
function maxBookableDate() {
|
||||||
|
const d = new Date();
|
||||||
|
d.setMonth(d.getMonth() + 3);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FULL_STEPS = [
|
||||||
{ n: 1, label: "Servicio" },
|
{ n: 1, label: "Servicio" },
|
||||||
{ n: 2, label: "Especialista" },
|
{ n: 2, label: "Especialista" },
|
||||||
{ n: 3, label: "Horario" },
|
{ n: 3, label: "Horario" },
|
||||||
{ n: 4, label: "Datos" },
|
{ n: 4, label: "Datos" },
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
|
function stepsFor(autoAssign: boolean) {
|
||||||
|
return autoAssign
|
||||||
|
? FULL_STEPS.filter((s) => s.label !== "Especialista").map((s, i) => ({ n: i + 1, label: s.label, original: s.n }))
|
||||||
|
: FULL_STEPS.map((s, i) => ({ n: i + 1, label: s.label, original: s.n }));
|
||||||
|
}
|
||||||
|
|
||||||
export default function BookingPage() {
|
export default function BookingPage() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
@@ -63,6 +77,11 @@ export default function BookingPage() {
|
|||||||
const services = data?.services ?? [];
|
const services = data?.services ?? [];
|
||||||
const employees = data?.employees ?? [];
|
const employees = data?.employees ?? [];
|
||||||
|
|
||||||
|
const autoAssign = !!business?.auto_assign_specialist;
|
||||||
|
const steps = useMemo(() => stepsFor(autoAssign), [autoAssign]);
|
||||||
|
const stepCount = steps.length;
|
||||||
|
const currentOriginal = steps.find((s) => s.n === step)?.original;
|
||||||
|
|
||||||
const selectedService = useMemo<PublicService | undefined>(
|
const selectedService = useMemo<PublicService | undefined>(
|
||||||
() => services.find((s) => s.id === serviceId),
|
() => services.find((s) => s.id === serviceId),
|
||||||
[services, serviceId]
|
[services, serviceId]
|
||||||
@@ -71,7 +90,7 @@ export default function BookingPage() {
|
|||||||
const slotsQuery = useQuery({
|
const slotsQuery = useQuery({
|
||||||
queryKey: ["public", "slots", slug, serviceId, selectedDate, employeeId],
|
queryKey: ["public", "slots", slug, serviceId, selectedDate, employeeId],
|
||||||
queryFn: () => getSlots(slug!, serviceId!, selectedDate, employeeId ?? undefined),
|
queryFn: () => getSlots(slug!, serviceId!, selectedDate, employeeId ?? undefined),
|
||||||
enabled: step >= 3 && !!serviceId && !!selectedDate && !result,
|
enabled: currentOriginal === 3 && !!serviceId && !!selectedDate && !result,
|
||||||
});
|
});
|
||||||
|
|
||||||
const slots = slotsQuery.data?.slots ?? [];
|
const slots = slotsQuery.data?.slots ?? [];
|
||||||
@@ -130,7 +149,7 @@ export default function BookingPage() {
|
|||||||
if (!business.booking_enabled) {
|
if (!business.booking_enabled) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-[#f6f7fb]">
|
<div className="flex min-h-screen flex-col bg-[#f6f7fb]">
|
||||||
<TopBar name={business.name} industry={business.industry} step={0} />
|
<TopBar name={business.name} industry={business.industry} step={0} stepCount={4} steps={[]} />
|
||||||
<main className="flex flex-1 items-center justify-center px-5 py-10">
|
<main className="flex flex-1 items-center justify-center px-5 py-10">
|
||||||
<div className="card w-full max-w-md p-8 text-center shadow-card animate-slide-up">
|
<div className="card w-full max-w-md p-8 text-center shadow-card animate-slide-up">
|
||||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-amber-100 text-amber-600">
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-amber-100 text-amber-600">
|
||||||
@@ -151,7 +170,7 @@ export default function BookingPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const goNext = () => setStep((s) => Math.min(4, s + 1));
|
const goNext = () => setStep((s) => Math.min(stepCount, s + 1));
|
||||||
const goBack = () => setStep((s) => Math.max(1, s - 1));
|
const goBack = () => setStep((s) => Math.max(1, s - 1));
|
||||||
|
|
||||||
const pickService = (id: number) => {
|
const pickService = (id: number) => {
|
||||||
@@ -168,7 +187,7 @@ export default function BookingPage() {
|
|||||||
|
|
||||||
const pickSlot = (slot: PublicSlot) => {
|
const pickSlot = (slot: PublicSlot) => {
|
||||||
setSelectedSlot(slot);
|
setSelectedSlot(slot);
|
||||||
setStep(4);
|
setStep((s) => Math.min(stepCount, s + 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBook = () => {
|
const handleBook = () => {
|
||||||
@@ -176,7 +195,7 @@ export default function BookingPage() {
|
|||||||
if (!client.name.trim()) return;
|
if (!client.name.trim()) return;
|
||||||
bookMut.mutate({
|
bookMut.mutate({
|
||||||
service_id: selectedService.id,
|
service_id: selectedService.id,
|
||||||
employee_id: selectedSlot.employee_id,
|
employee_id: autoAssign ? undefined : selectedSlot.employee_id,
|
||||||
start_at: selectedSlot.iso,
|
start_at: selectedSlot.iso,
|
||||||
client: {
|
client: {
|
||||||
name: client.name.trim(),
|
name: client.name.trim(),
|
||||||
@@ -188,26 +207,27 @@ export default function BookingPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const canContinue =
|
const canContinue =
|
||||||
(step === 1 && !!serviceId) ||
|
(currentOriginal === 1 && !!serviceId) ||
|
||||||
step === 2 ||
|
currentOriginal === 2 ||
|
||||||
(step === 3 && !!selectedSlot) ||
|
(currentOriginal === 3 && !!selectedSlot) ||
|
||||||
(step === 4 && client.name.trim().length > 0);
|
(currentOriginal === 4 && client.name.trim().length > 0);
|
||||||
|
|
||||||
const currency = business.currency_symbol || "$";
|
const currency = business.currency_symbol || "$";
|
||||||
const primaryLabel = step === 4 ? "Confirmar reserva" : "Continuar";
|
const primaryLabel = currentOriginal === 4 ? "Confirmar reserva" : "Continuar";
|
||||||
const onPrimary = step === 4 ? handleBook : goNext;
|
const onPrimary = currentOriginal === 4 ? handleBook : goNext;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-gradient-to-b from-brand-50/50 via-[#f6f7fb] to-[#f6f7fb]">
|
<div className="flex min-h-screen flex-col bg-gradient-to-b from-brand-50/50 via-[#f6f7fb] to-[#f6f7fb]">
|
||||||
<TopBar name={business.name} industry={business.industry} step={step} />
|
<TopBar name={business.name} industry={business.industry} step={step} stepCount={stepCount} steps={steps} />
|
||||||
|
|
||||||
<main className="mx-auto w-full max-w-2xl flex-1 px-4 pb-32 pt-5 sm:px-6 md:pb-28">
|
<main className={cn(
|
||||||
{step === 1 && (
|
"mx-auto w-full flex-1 px-4 pb-32 pt-5 sm:px-6 md:pb-28",
|
||||||
<Hero business={business} />
|
currentOriginal === 3 ? "max-w-5xl" : "max-w-2xl"
|
||||||
)}
|
)}>
|
||||||
|
{currentOriginal === 1 && <Hero business={business} />}
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{step === 1 && (
|
{currentOriginal === 1 && (
|
||||||
<StepShell
|
<StepShell
|
||||||
icon={<Sparkles className="h-4 w-4" />}
|
icon={<Sparkles className="h-4 w-4" />}
|
||||||
title="Elige un servicio"
|
title="Elige un servicio"
|
||||||
@@ -222,7 +242,7 @@ export default function BookingPage() {
|
|||||||
</StepShell>
|
</StepShell>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 2 && (
|
{currentOriginal === 2 && (
|
||||||
<StepShell
|
<StepShell
|
||||||
icon={<User className="h-4 w-4" />}
|
icon={<User className="h-4 w-4" />}
|
||||||
title="Elige un especialista"
|
title="Elige un especialista"
|
||||||
@@ -236,7 +256,7 @@ export default function BookingPage() {
|
|||||||
</StepShell>
|
</StepShell>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 3 && selectedService && (
|
{currentOriginal === 3 && selectedService && (
|
||||||
<StepShell
|
<StepShell
|
||||||
icon={<CalendarDays className="h-4 w-4" />}
|
icon={<CalendarDays className="h-4 w-4" />}
|
||||||
title="Elige fecha y hora"
|
title="Elige fecha y hora"
|
||||||
@@ -249,6 +269,7 @@ export default function BookingPage() {
|
|||||||
setSelectedSlot(null);
|
setSelectedSlot(null);
|
||||||
}}
|
}}
|
||||||
min={todayStr()}
|
min={todayStr()}
|
||||||
|
max={maxBookableDate()}
|
||||||
slots={slots}
|
slots={slots}
|
||||||
loading={slotsQuery.isLoading}
|
loading={slotsQuery.isLoading}
|
||||||
isError={slotsQuery.isError}
|
isError={slotsQuery.isError}
|
||||||
@@ -258,7 +279,7 @@ export default function BookingPage() {
|
|||||||
</StepShell>
|
</StepShell>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 4 && selectedService && selectedSlot && (
|
{currentOriginal === 4 && selectedService && selectedSlot && (
|
||||||
<StepShell
|
<StepShell
|
||||||
icon={<CalendarCheck className="h-4 w-4" />}
|
icon={<CalendarCheck className="h-4 w-4" />}
|
||||||
title="Tus datos"
|
title="Tus datos"
|
||||||
@@ -272,7 +293,7 @@ export default function BookingPage() {
|
|||||||
<SummaryRow label="Servicio" value={selectedService.name} />
|
<SummaryRow label="Servicio" value={selectedService.name} />
|
||||||
<SummaryRow label="Fecha" value={formatDate(selectedSlot.iso, { weekday: "long" })} />
|
<SummaryRow label="Fecha" value={formatDate(selectedSlot.iso, { weekday: "long" })} />
|
||||||
<SummaryRow label="Hora" value={formatTime(selectedSlot.iso)} />
|
<SummaryRow label="Hora" value={formatTime(selectedSlot.iso)} />
|
||||||
<SummaryRow label="Especialista" value={selectedSlot.employee_name} />
|
{!autoAssign && <SummaryRow label="Especialista" value={selectedSlot.employee_name} />}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -284,6 +305,7 @@ export default function BookingPage() {
|
|||||||
<ActionBar
|
<ActionBar
|
||||||
hidden={!!result}
|
hidden={!!result}
|
||||||
step={step}
|
step={step}
|
||||||
|
stepCount={stepCount}
|
||||||
goBack={goBack}
|
goBack={goBack}
|
||||||
onPrimary={onPrimary}
|
onPrimary={onPrimary}
|
||||||
primaryLabel={primaryLabel}
|
primaryLabel={primaryLabel}
|
||||||
@@ -301,11 +323,11 @@ export default function BookingPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TopBar({ name, industry, step }: { name: string; industry: string; step: number }) {
|
function TopBar({ name, industry, step, stepCount, steps }: { name: string; industry: string; step: number; stepCount: number; steps: { n: number; label: string }[]; }) {
|
||||||
const current = STEPS.find((s) => s.n === step);
|
const current = steps.find((s) => s.n === step);
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-30 border-b border-slate-200/70 bg-white/85 backdrop-blur-md">
|
<header className="sticky top-0 z-30 border-b border-slate-200/70 bg-white/85 backdrop-blur-md">
|
||||||
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-3 px-4 py-3 sm:px-6">
|
<div className="mx-auto flex w-full max-w-5xl items-center justify-between gap-3 px-4 py-3 sm:px-6">
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-700 text-white shadow-soft">
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-700 text-white shadow-soft">
|
||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
@@ -319,10 +341,10 @@ function TopBar({ name, industry, step }: { name: string; industry: string; step
|
|||||||
{step > 0 && (
|
{step > 0 && (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="hidden text-[11px] font-semibold uppercase tracking-wide text-slate-400 sm:inline">
|
<span className="hidden text-[11px] font-semibold uppercase tracking-wide text-slate-400 sm:inline">
|
||||||
Paso {step} de 4
|
Paso {step} de {stepCount}
|
||||||
</span>
|
</span>
|
||||||
<div className="hidden items-center gap-1 md:flex">
|
<div className="hidden items-center gap-1 md:flex">
|
||||||
{STEPS.map((s) => (
|
{steps.map((s) => (
|
||||||
<div
|
<div
|
||||||
key={s.n}
|
key={s.n}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -342,7 +364,7 @@ function TopBar({ name, industry, step }: { name: string; industry: string; step
|
|||||||
<div className="flex items-center gap-1.5 rounded-full bg-brand-50 px-3 py-1 text-xs font-bold text-brand-700 md:hidden">
|
<div className="flex items-center gap-1.5 rounded-full bg-brand-50 px-3 py-1 text-xs font-bold text-brand-700 md:hidden">
|
||||||
<span>{step}</span>
|
<span>{step}</span>
|
||||||
<span className="font-semibold text-brand-400">/</span>
|
<span className="font-semibold text-brand-400">/</span>
|
||||||
<span className="text-brand-400">4</span>
|
<span className="text-brand-400">{stepCount}</span>
|
||||||
<span className="text-brand-300">·</span>
|
<span className="text-brand-300">·</span>
|
||||||
<span>{current?.label}</span>
|
<span>{current?.label}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -556,6 +578,7 @@ function DateTimePicker({
|
|||||||
date,
|
date,
|
||||||
onDate,
|
onDate,
|
||||||
min,
|
min,
|
||||||
|
max,
|
||||||
slots,
|
slots,
|
||||||
loading,
|
loading,
|
||||||
isError,
|
isError,
|
||||||
@@ -565,28 +588,42 @@ function DateTimePicker({
|
|||||||
date: string;
|
date: string;
|
||||||
onDate: (d: string) => void;
|
onDate: (d: string) => void;
|
||||||
min: string;
|
min: string;
|
||||||
|
max?: string;
|
||||||
slots: PublicSlot[];
|
slots: PublicSlot[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
selectedSlot: PublicSlot | null;
|
selectedSlot: PublicSlot | null;
|
||||||
onPick: (s: PublicSlot) => void;
|
onPick: (s: PublicSlot) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { morning, afternoon } = useMemo(() => {
|
||||||
|
const morning: PublicSlot[] = [];
|
||||||
|
const afternoon: PublicSlot[] = [];
|
||||||
|
for (const s of slots) {
|
||||||
|
const m = /(\d{1,2}):(\d{2})/.exec(s.time);
|
||||||
|
const h = m ? Number(m[1]) : 0;
|
||||||
|
const pm = /PM/i.test(s.time);
|
||||||
|
const hour24 = pm && h !== 12 ? h + 12 : !pm && h === 12 ? 0 : h;
|
||||||
|
(hour24 < 12 ? morning : afternoon).push(s);
|
||||||
|
}
|
||||||
|
return { morning, afternoon };
|
||||||
|
}, [slots]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] md:items-start md:gap-8">
|
||||||
<div>
|
{/* Columna izquierda: calendario */}
|
||||||
|
<div className="card p-4">
|
||||||
<label className="label flex items-center gap-1.5">
|
<label className="label flex items-center gap-1.5">
|
||||||
<CalendarDays className="h-3.5 w-3.5 text-slate-400" /> Fecha
|
<CalendarDays className="h-3.5 w-3.5 text-slate-400" /> Fecha
|
||||||
</label>
|
</label>
|
||||||
<input
|
<MonthCalendar value={date} min={min} max={max} onSelect={(d) => onDate(d)} />
|
||||||
type="date"
|
<div className="mt-3 flex flex-wrap items-center gap-3 text-[11px] text-slate-500">
|
||||||
className="input"
|
<span className="inline-flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-accent-500" /> Con cupo</span>
|
||||||
value={date}
|
<span className="inline-flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-brand-400" /> Hoy</span>
|
||||||
min={min}
|
</div>
|
||||||
onChange={(e) => e.target.value && onDate(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Columna derecha: horarios */}
|
||||||
|
<div className="mt-4 md:mt-0">
|
||||||
<label className="label flex items-center gap-1.5">
|
<label className="label flex items-center gap-1.5">
|
||||||
<Clock className="h-3.5 w-3.5 text-slate-400" /> Horario disponible
|
<Clock className="h-3.5 w-3.5 text-slate-400" /> Horario disponible
|
||||||
</label>
|
</label>
|
||||||
@@ -597,14 +634,12 @@ function DateTimePicker({
|
|||||||
<span className="text-sm font-medium">Buscando horarios…</span>
|
<span className="text-sm font-medium">Buscando horarios…</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && isError && (
|
{!loading && isError && (
|
||||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||||
<AlertCircle className="h-6 w-6 text-rose-400" />
|
<AlertCircle className="h-6 w-6 text-rose-400" />
|
||||||
<p className="text-sm text-slate-500">No pudimos cargar los horarios. Intenta otra fecha.</p>
|
<p className="text-sm text-slate-500">No pudimos cargar los horarios. Intenta otra fecha.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !isError && slots.length === 0 && (
|
{!loading && !isError && slots.length === 0 && (
|
||||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||||
<CalendarDays className="h-6 w-6 text-slate-300" />
|
<CalendarDays className="h-6 w-6 text-slate-300" />
|
||||||
@@ -612,8 +647,36 @@ function DateTimePicker({
|
|||||||
<p className="text-xs text-slate-400">Prueba con otra fecha o especialista.</p>
|
<p className="text-xs text-slate-400">Prueba con otra fecha o especialista.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !isError && slots.length > 0 && (
|
{!loading && !isError && slots.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SlotGroup title="Mañana" slots={morning} selectedSlot={selectedSlot} onPick={onPick} />
|
||||||
|
<SlotGroup title="Tarde" slots={afternoon} selectedSlot={selectedSlot} onPick={onPick} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SlotGroup({
|
||||||
|
title,
|
||||||
|
slots,
|
||||||
|
selectedSlot,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
slots: PublicSlot[];
|
||||||
|
selectedSlot: PublicSlot | null;
|
||||||
|
onPick: (s: PublicSlot) => void;
|
||||||
|
}) {
|
||||||
|
if (slots.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<span className="rounded-full bg-accent-50 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-accent-700">{title}</span>
|
||||||
|
<span className="text-[11px] font-medium text-slate-400">{slots.length} opciones</span>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
|
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
|
||||||
{slots.map((slot) => {
|
{slots.map((slot) => {
|
||||||
const active = selectedSlot?.iso === slot.iso && selectedSlot?.employee_id === slot.employee_id;
|
const active = selectedSlot?.iso === slot.iso && selectedSlot?.employee_id === slot.employee_id;
|
||||||
@@ -634,9 +697,6 @@ function DateTimePicker({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -661,7 +721,7 @@ function DetailsForm({
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
<User className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
className="input pl-9"
|
className="input pl-10"
|
||||||
value={client.name}
|
value={client.name}
|
||||||
onChange={(e) => setClient({ ...client, name: e.target.value })}
|
onChange={(e) => setClient({ ...client, name: e.target.value })}
|
||||||
placeholder="Tu nombre"
|
placeholder="Tu nombre"
|
||||||
@@ -675,7 +735,7 @@ function DetailsForm({
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Phone className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
<Phone className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
className="input pl-9"
|
className="input pl-10"
|
||||||
inputMode="tel"
|
inputMode="tel"
|
||||||
value={client.phone}
|
value={client.phone}
|
||||||
onChange={(e) => setClient({ ...client, phone: e.target.value })}
|
onChange={(e) => setClient({ ...client, phone: e.target.value })}
|
||||||
@@ -689,7 +749,7 @@ function DetailsForm({
|
|||||||
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
className="input pl-9"
|
className="input pl-10"
|
||||||
value={client.email}
|
value={client.email}
|
||||||
onChange={(e) => setClient({ ...client, email: e.target.value })}
|
onChange={(e) => setClient({ ...client, email: e.target.value })}
|
||||||
placeholder="[email protected]"
|
placeholder="[email protected]"
|
||||||
@@ -725,6 +785,7 @@ function SummaryRow({ label, value }: { label: string; value: string }) {
|
|||||||
function ActionBar({
|
function ActionBar({
|
||||||
hidden,
|
hidden,
|
||||||
step,
|
step,
|
||||||
|
stepCount,
|
||||||
goBack,
|
goBack,
|
||||||
onPrimary,
|
onPrimary,
|
||||||
primaryLabel,
|
primaryLabel,
|
||||||
@@ -736,6 +797,7 @@ function ActionBar({
|
|||||||
}: {
|
}: {
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
step: number;
|
step: number;
|
||||||
|
stepCount: number;
|
||||||
goBack: () => void;
|
goBack: () => void;
|
||||||
onPrimary: () => void;
|
onPrimary: () => void;
|
||||||
primaryLabel: string;
|
primaryLabel: string;
|
||||||
@@ -748,7 +810,7 @@ function ActionBar({
|
|||||||
if (hidden) return null;
|
if (hidden) return null;
|
||||||
return (
|
return (
|
||||||
<div className="sticky bottom-0 z-30 border-t border-slate-200/70 bg-white/90 backdrop-blur-md">
|
<div className="sticky bottom-0 z-30 border-t border-slate-200/70 bg-white/90 backdrop-blur-md">
|
||||||
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-4 py-3 sm:px-6">
|
<div className="mx-auto flex w-full max-w-5xl items-center gap-3 px-4 py-3 sm:px-6">
|
||||||
{step > 1 && (
|
{step > 1 && (
|
||||||
<button onClick={goBack} className="btn btn-ghost shrink-0" type="button">
|
<button onClick={goBack} className="btn btn-ghost shrink-0" type="button">
|
||||||
<ChevronLeft className="h-4 w-4" /> <span className="hidden sm:inline">Atrás</span>
|
<ChevronLeft className="h-4 w-4" /> <span className="hidden sm:inline">Atrás</span>
|
||||||
@@ -764,7 +826,7 @@ function ActionBar({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm font-medium text-slate-400">
|
<div className="text-sm font-medium text-slate-400">
|
||||||
{step === 1 ? "Selecciona un servicio" : "Continúa para reservar"}
|
{step === 1 ? "Selecciona un servicio" : `Paso ${step} de ${stepCount} · Continúa para reservar`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -802,7 +864,7 @@ function Confirmation({
|
|||||||
const currency = result.business.currency_symbol || "$";
|
const currency = result.business.currency_symbol || "$";
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-gradient-to-b from-brand-50/60 to-[#f6f7fb]">
|
<div className="flex min-h-screen flex-col bg-gradient-to-b from-brand-50/60 to-[#f6f7fb]">
|
||||||
<TopBar name={businessName} industry="Reserva confirmada" step={0} />
|
<TopBar name={businessName} industry="Reserva confirmada" step={0} stepCount={4} steps={[]} />
|
||||||
<main className="flex flex-1 items-center justify-center px-5 py-10">
|
<main className="flex flex-1 items-center justify-center px-5 py-10">
|
||||||
<div className="w-full max-w-md animate-slide-up">
|
<div className="w-full max-w-md animate-slide-up">
|
||||||
<div className="card overflow-hidden p-0 text-center shadow-card">
|
<div className="card overflow-hidden p-0 text-center shadow-card">
|
||||||
@@ -825,6 +887,17 @@ function Confirmation({
|
|||||||
<SummaryRow label="Hora" value={formatTime(appointment.start_at)} />
|
<SummaryRow label="Hora" value={formatTime(appointment.start_at)} />
|
||||||
<div className="h-px bg-slate-100" />
|
<div className="h-px bg-slate-100" />
|
||||||
<SummaryRow label="Especialista" value={appointment.employee_name} />
|
<SummaryRow label="Especialista" value={appointment.employee_name} />
|
||||||
|
{appointment.reasons && appointment.reasons.length > 0 && (
|
||||||
|
<div className="px-3 pb-2 pt-1">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{appointment.reasons.map((r) => (
|
||||||
|
<span key={r} className="inline-flex items-center gap-1 rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-bold text-brand-700">
|
||||||
|
<Sparkles className="h-3 w-3" /> {r}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="h-px bg-slate-100" />
|
<div className="h-px bg-slate-100" />
|
||||||
<div className="flex items-center justify-between gap-3 px-3 py-3">
|
<div className="flex items-center justify-between gap-3 px-3 py-3">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-slate-400">Total</span>
|
<span className="text-xs font-semibold uppercase tracking-wide text-slate-400">Total</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user