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:
AgendaPro Dev
2026-07-27 10:11:16 -06:00
parent 4227db0c8b
commit d796538fd9
57 changed files with 2477 additions and 322 deletions
+120 -4
View File
@@ -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>