feat: rebrand AgendaPro -> AgendaMax + calendar fixes + current-week demo seed
- Rebrand all user-facing text to AgendaMax - Fix sidebar scrolling: card container no longer overflows html (flex h-full min-h-0) - Fix calendar toolbar hover: active buttons keep readable contrast - Sticky calendar toolbar (month/week/day always visible on scroll) - Seed guarantees 2-4 appointments per day for current week (Mon-Sat)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Sparkles,
|
||||
Clock,
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
PartyPopper,
|
||||
AlertCircle,
|
||||
Mail,
|
||||
RefreshCw,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getBusiness,
|
||||
@@ -64,6 +66,9 @@ export default function BookingPage() {
|
||||
const [selectedSlot, setSelectedSlot] = useState<PublicSlot | null>(null);
|
||||
const [client, setClient] = useState({ name: "", phone: "", email: "", notes: "" });
|
||||
const [result, setResult] = useState<BookResponse | null>(null);
|
||||
const [bookError, setBookError] = useState<string | null>(null);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const businessQuery = useQuery({
|
||||
queryKey: ["public", "business", slug],
|
||||
@@ -98,9 +103,16 @@ export default function BookingPage() {
|
||||
const bookMut = useMutation({
|
||||
mutationFn: (payload: Parameters<typeof book>[1]) => book(slug!, payload),
|
||||
onSuccess: (res) => {
|
||||
setBookError(null);
|
||||
setResult(res);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setBookError(err?.message || "No pudimos completar tu reserva. Inténtalo de nuevo.");
|
||||
setResult(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["public", "slots", slug] });
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -115,6 +127,7 @@ export default function BookingPage() {
|
||||
setSelectedSlot(null);
|
||||
setClient({ name: "", phone: "", email: "", notes: "" });
|
||||
setResult(null);
|
||||
setBookError(null);
|
||||
bookMut.reset();
|
||||
};
|
||||
|
||||
@@ -142,8 +155,28 @@ export default function BookingPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const hasPolicy =
|
||||
(business.cancel_penalty_pct ?? 0) > 0 || !!business.require_deposit;
|
||||
const policyNote = (
|
||||
<PolicyNote
|
||||
cancelWindowHours={business.cancel_window_hours}
|
||||
cancelPenaltyPct={business.cancel_penalty_pct}
|
||||
requireDeposit={business.require_deposit}
|
||||
depositPct={business.deposit_pct}
|
||||
/>
|
||||
);
|
||||
|
||||
if (result) {
|
||||
return <Confirmation result={result} businessName={business.name} onReset={reset} />;
|
||||
return (
|
||||
<Confirmation
|
||||
result={result}
|
||||
businessName={business.name}
|
||||
clientName={client.name.trim()}
|
||||
hasPolicy={hasPolicy}
|
||||
policy={policyNote}
|
||||
onReset={reset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!business.booking_enabled) {
|
||||
@@ -177,12 +210,14 @@ export default function BookingPage() {
|
||||
if (serviceId !== id) {
|
||||
setServiceId(id);
|
||||
setSelectedSlot(null);
|
||||
setBookError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const pickEmployee = (id: number | null) => {
|
||||
setEmployeeId(id);
|
||||
setSelectedSlot(null);
|
||||
setBookError(null);
|
||||
};
|
||||
|
||||
const pickSlot = (slot: PublicSlot) => {
|
||||
@@ -193,6 +228,7 @@ export default function BookingPage() {
|
||||
const handleBook = () => {
|
||||
if (!selectedService || !selectedSlot) return;
|
||||
if (!client.name.trim()) return;
|
||||
setBookError(null);
|
||||
bookMut.mutate({
|
||||
service_id: selectedService.id,
|
||||
employee_id: autoAssign ? undefined : selectedSlot.employee_id,
|
||||
@@ -267,6 +303,7 @@ export default function BookingPage() {
|
||||
onDate={(d) => {
|
||||
setSelectedDate(d);
|
||||
setSelectedSlot(null);
|
||||
setBookError(null);
|
||||
}}
|
||||
min={todayStr()}
|
||||
max={maxBookableDate()}
|
||||
@@ -285,9 +322,30 @@ export default function BookingPage() {
|
||||
title="Tus datos"
|
||||
subtitle="Necesitamos algunos datos para confirmar tu cita."
|
||||
>
|
||||
{bookError && (
|
||||
<div className="mb-4 flex items-start gap-3 rounded-2xl bg-rose-600 px-4 py-3 text-white shadow-soft animate-slide-up">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold leading-snug">{bookError}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBookError(null);
|
||||
setSelectedSlot(null);
|
||||
setStep(steps.find((s) => s.original === 3)?.n ?? 3);
|
||||
queryClient.invalidateQueries({ queryKey: ["public", "slots", slug] });
|
||||
}}
|
||||
className="mt-2 inline-flex items-center gap-1.5 rounded-lg bg-white/20 px-2.5 py-1 text-xs font-bold backdrop-blur transition-colors hover:bg-white/30"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" /> Intentar de nuevo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DetailsForm
|
||||
client={client}
|
||||
setClient={setClient}
|
||||
policy={policyNote}
|
||||
summary={
|
||||
<>
|
||||
<SummaryRow label="Servicio" value={selectedService.name} />
|
||||
@@ -705,15 +763,17 @@ function DetailsForm({
|
||||
client,
|
||||
setClient,
|
||||
summary,
|
||||
policy,
|
||||
}: {
|
||||
client: { name: string; phone: string; email: string; notes: string };
|
||||
setClient: (c: { name: string; phone: string; email: string; notes: string }) => void;
|
||||
summary: React.ReactNode;
|
||||
policy?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="card divide-y divide-slate-100 p-2">{summary}</div>
|
||||
|
||||
{policy}
|
||||
<div className="card p-4">
|
||||
<div className="grid gap-3">
|
||||
<div>
|
||||
@@ -773,6 +833,37 @@ function DetailsForm({
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyNote({
|
||||
cancelWindowHours,
|
||||
cancelPenaltyPct,
|
||||
requireDeposit,
|
||||
depositPct,
|
||||
}: {
|
||||
cancelWindowHours: number;
|
||||
cancelPenaltyPct: number;
|
||||
requireDeposit: boolean | number;
|
||||
depositPct: number;
|
||||
}) {
|
||||
const lines: string[] = [];
|
||||
if ((cancelPenaltyPct ?? 0) > 0) {
|
||||
lines.push(`Cancela con al menos ${cancelWindowHours ?? 0} h de anticipación. Si no, aplica un cargo del ${cancelPenaltyPct}%.`);
|
||||
}
|
||||
if (requireDeposit) {
|
||||
lines.push(`Se solicita un depósito del ${depositPct ?? 0}% (te lo indicará el negocio al pagar).`);
|
||||
}
|
||||
if (lines.length === 0) return null;
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-xl bg-amber-50 px-3 py-2.5 text-xs text-amber-800">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<div className="space-y-0.5">
|
||||
{lines.map((l) => (
|
||||
<p key={l}>{l}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 px-3 py-2.5">
|
||||
@@ -846,7 +937,7 @@ function ActionBar({
|
||||
function FooterNote({ name }: { name: string }) {
|
||||
return (
|
||||
<div className="border-t border-slate-200/60 bg-white/50 px-4 py-3 text-center text-[11px] text-slate-400">
|
||||
Reserva en línea · {name} · powered by AgendaPro
|
||||
Reserva en línea · {name} · powered by AgendaMax
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -854,14 +945,21 @@ function FooterNote({ name }: { name: string }) {
|
||||
function Confirmation({
|
||||
result,
|
||||
businessName,
|
||||
clientName,
|
||||
hasPolicy,
|
||||
policy,
|
||||
onReset,
|
||||
}: {
|
||||
result: BookResponse;
|
||||
businessName: string;
|
||||
clientName: string;
|
||||
hasPolicy: boolean;
|
||||
policy?: React.ReactNode;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const { appointment } = result;
|
||||
const currency = result.business.currency_symbol || "$";
|
||||
const showRisk = result.risk_flag === true || (result.no_show_count ?? 0) >= 2;
|
||||
return (
|
||||
<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} stepCount={4} steps={[]} />
|
||||
@@ -879,6 +977,14 @@ function Confirmation({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-slate-100 bg-slate-50/60 px-5 py-3 text-center">
|
||||
<p className="text-sm font-bold text-slate-800">
|
||||
{result.client_created === false
|
||||
? `¡Gracias por volver, ${clientName}! 👋`
|
||||
: `¡Hola, ${clientName}! Tu cita quedó confirmada.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 p-5 text-left">
|
||||
<SummaryRow label="Servicio" value={appointment.service_name} />
|
||||
<div className="h-px bg-slate-100" />
|
||||
@@ -908,6 +1014,16 @@ function Confirmation({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showRisk && (
|
||||
<div className="mt-4 flex items-start gap-2.5 rounded-2xl bg-amber-50 px-4 py-3 text-amber-800 ring-1 ring-amber-200">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
|
||||
<p className="text-xs font-medium leading-relaxed">
|
||||
Notamos ausencias previas sin avisar. Te enviaremos un recordatorio; reserva y no asistir puede generar un cargo según la política del negocio.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{hasPolicy && policy ? <div className="mt-4">{policy}</div> : null}
|
||||
|
||||
<button onClick={onReset} className="btn btn-secondary mt-4 w-full" type="button">
|
||||
<ChevronLeft className="h-4 w-4" /> Reservar otra cita
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user