From e11fa8d78093c5c67b8bea92c37ad3e6628f1f8e Mon Sep 17 00:00:00 2001 From: Sidney Date: Sun, 19 Jul 2026 23:28:45 -0300 Subject: [PATCH] feat: cancelamento online pelo cliente, tolerancia de atraso configuravel, modal detalhes agendamento com atender/cancelar, e fix semana click bug --- src/app/[slug]/agendar/page.tsx | 85 +++++++++++-- .../api/tenant/agenda/appointment/route.ts | 46 +++++++ src/app/api/tenant/settings/route.ts | 2 + src/components/Agenda.tsx | 115 +++++++++++++++++- src/components/Settings.tsx | 15 ++- 5 files changed, 246 insertions(+), 17 deletions(-) create mode 100644 src/app/api/tenant/agenda/appointment/route.ts diff --git a/src/app/[slug]/agendar/page.tsx b/src/app/[slug]/agendar/page.tsx index 2d48a89..826741c 100644 --- a/src/app/[slug]/agendar/page.tsx +++ b/src/app/[slug]/agendar/page.tsx @@ -21,6 +21,7 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug: const [dbServices, setDbServices] = useState([]); const [dbProfessionals, setDbProfessionals] = useState([]); + const [activeBookingInfo, setActiveBookingInfo] = useState(null); // Dados do tenant vindos do banco const [establishment, setEstablishment] = useState({ @@ -73,6 +74,28 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug: fetch(`/api/tenant/agenda?slug=${slug}`), fetch(`/api/tenant/services?slug=${slug}`) ]); + + const savedBooking = localStorage.getItem(`agendapro_active_booking_${slug}`); + if (savedBooking) { + try { + const parsed = JSON.parse(savedBooking); + const statusRes = await fetch(`/api/tenant/agenda/appointment?id=${parsed.id}`); + if (statusRes.ok) { + const apt = await statusRes.json(); + if (apt.status === 'pending' || apt.status === 'confirmed') { + setActiveBookingInfo(parsed); + setBooked(true); + } else { + localStorage.removeItem(`agendapro_active_booking_${slug}`); + } + } else { + localStorage.removeItem(`agendapro_active_booking_${slug}`); + } + } catch (e) { + localStorage.removeItem(`agendapro_active_booking_${slug}`); + } + } + // Carregar perfil do tenant if (profileRes.ok) { @@ -214,6 +237,15 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug: }); if (res.ok) { + const aptData = await res.json(); + localStorage.setItem(`agendapro_active_booking_${slug}`, JSON.stringify({ + id: aptData.id, + service: svc?.name, + prof: prof?.name, + date: selectedDate, + time: selectedTime, + price: svc?.price + })); setBooked(true); } else { const errData = await res.json(); @@ -224,10 +256,40 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug: } }; + const cancelBooking = async () => { + if (!confirm('Tem certeza que deseja cancelar este agendamento?')) return; + try { + const idToCancel = activeBookingInfo?.id; + if (!idToCancel) return; + const res = await fetch('/api/tenant/agenda', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: idToCancel, status: 'cancelled' }) + }); + if (res.ok) { + showToast('Agendamento cancelado com sucesso.', 'success'); + localStorage.removeItem(`agendapro_active_booking_${slug}`); + setActiveBookingInfo(null); + setBooked(false); + setStep(1); + setSelectedTime(null); + } + } catch (e) { + console.error(e); + showToast('Erro ao cancelar agendamento.', 'error'); + } + }; + if (booked) { + const renderSvcName = activeBookingInfo?.service || svc?.name; + const renderProfName = activeBookingInfo?.prof || prof?.name; + const renderDate = activeBookingInfo?.date || selectedDate; + const renderTime = activeBookingInfo?.time || selectedTime; + const renderPrice = activeBookingInfo?.price !== undefined ? activeBookingInfo.price : svc?.price; + return (
-
+

Agendamento Confirmado! 🎉

- Seu horário foi reservado com sucesso. + Seu horário está reservado e ativo.

-
Serviço
{svc?.name}
-
Profissional
{prof?.name}
+
Serviço
{renderSvcName}
+
Profissional
{renderProfName}
-
Data
{selectedDate ? new Date(selectedDate + 'T12:00:00').toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' }) : ''}
-
Horário
{selectedTime}
+
Data
{renderDate ? new Date(renderDate + 'T12:00:00').toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' }) : ''}
+
Horário
{renderTime}
Total - R$ {svc?.price.toFixed(2)} + R$ {renderPrice?.toFixed(2)}
+ + +

- Você receberá uma confirmação por e-mail/WhatsApp. + Você não pode realizar outro agendamento enquanto este estiver ativo.

diff --git a/src/app/api/tenant/agenda/appointment/route.ts b/src/app/api/tenant/agenda/appointment/route.ts new file mode 100644 index 0000000..6f20659 --- /dev/null +++ b/src/app/api/tenant/agenda/appointment/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const id = searchParams.get('id'); + + if (!id) { + return NextResponse.json({ error: 'Missing appointment id' }, { status: 400 }); + } + + try { + const res = await query(` + SELECT + a.id, a.professional_id as "professionalId", + TO_CHAR(a.date, 'YYYY-MM-DD') as date, + a.client_name as client, + s.name as service, + EXTRACT(HOUR FROM a.start_time) as "startHour", + EXTRACT(MINUTE FROM a.start_time) as "startMin", + EXTRACT(EPOCH FROM (a.end_time - a.start_time))/60 as duration, + a.status, a.price, + p.name as "professionalName" + FROM appointments a + LEFT JOIN services s ON a.service_id = s.id + LEFT JOIN users p ON a.professional_id = p.id + WHERE a.id = $1 + `, [id]); + + if (res.rows.length === 0) { + return NextResponse.json({ error: 'Appointment not found' }, { status: 404 }); + } + + const apt = res.rows[0]; + return NextResponse.json({ + ...apt, + startHour: Number(apt.startHour), + startMin: Number(apt.startMin), + duration: Number(apt.duration), + price: Number(apt.price) + }); + } catch (error: any) { + console.error('GET single appointment error:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/tenant/settings/route.ts b/src/app/api/tenant/settings/route.ts index a8b5f49..19a2b33 100644 --- a/src/app/api/tenant/settings/route.ts +++ b/src/app/api/tenant/settings/route.ts @@ -59,6 +59,7 @@ export async function GET(req: NextRequest) { bookingAdvanceMin: settings.booking_advance_min || 60, bookingAdvanceMaxDays: settings.booking_advance_max_days || 30, cancellationLimitMin: settings.cancellation_limit_min || 120, + toleranceMinutes: settings.tolerance_minutes || 15, notifyNewAppointment: settings.notify_new_appointment !== false, notifyCancellation: settings.notify_cancellation !== false, notifyClientReminder: settings.notify_client_reminder !== false, @@ -95,6 +96,7 @@ export async function PUT(req: NextRequest) { booking_advance_min: body.bookingAdvanceMin || 60, booking_advance_max_days: body.bookingAdvanceMaxDays || 30, cancellation_limit_min: body.cancellationLimitMin || 120, + tolerance_minutes: body.toleranceMinutes || 15, notify_new_appointment: body.notifyNewAppointment !== false, notify_cancellation: body.notifyCancellation !== false, notify_client_reminder: body.notifyClientReminder !== false, diff --git a/src/components/Agenda.tsx b/src/components/Agenda.tsx index 939d1ea..0585f70 100644 --- a/src/components/Agenda.tsx +++ b/src/components/Agenda.tsx @@ -76,6 +76,7 @@ export default function Agenda() { const [newTime, setNewTime] = useState('10:00'); const [newNotes, setNewNotes] = useState(''); const [submitting, setSubmitting] = useState(false); + const [viewingAppointment, setViewingAppointment] = useState(null); useEffect(() => { if (professionals.length > 0 && !newProf) { @@ -237,6 +238,7 @@ export default function Agenda() { transition: 'all 0.2s', zIndex: 2, display: 'flex', flexDirection: isSmall ? 'row' : 'column', alignItems: isSmall ? 'center' : 'flex-start', gap: isSmall ? '8px' : '0' }} + onClick={() => setViewingAppointment(apt)} onMouseEnter={e => { (e.currentTarget as HTMLElement).style.transform = 'scale(1.02)'; (e.currentTarget as HTMLElement).style.zIndex = '10'; }} onMouseLeave={e => { (e.currentTarget as HTMLElement).style.transform = 'scale(1)'; (e.currentTarget as HTMLElement).style.zIndex = '2'; }} > @@ -275,10 +277,9 @@ export default function Agenda() { return (
setCurrentDate(day)} style={{ padding: '14px 8px', textAlign: 'center', borderRight: '1px solid var(--border-color)', - background: isSelectedDay ? 'var(--accent-glow)' : 'var(--bg-secondary)', cursor: 'pointer', + background: isTodayDay ? 'var(--accent-glow)' : 'var(--bg-secondary)', transition: 'all 0.2s' }} > @@ -311,8 +312,8 @@ export default function Agenda() { padding: '8px', borderRadius: 'var(--radius-sm)', background: statusColors[apt.status] || 'var(--bg-secondary)', borderLeft: `3px solid ${statusBorders[apt.status] || 'var(--accent)'}`, - fontSize: '12px' - }}> + fontSize: '12px', cursor: 'pointer' + }} onClick={() => setViewingAppointment(apt)}>
{String(apt.startHour).padStart(2, '0')}:{String(apt.startMin).padStart(2, '0')}
{apt.client}
{apt.service}
@@ -360,7 +361,7 @@ export default function Agenda() { .map(apt => { const prof = professionals.find(p => p.id === apt.professionalId); return ( -
+
setViewingAppointment(apt)}>
{statusLabels[apt.status] || apt.status} @@ -399,6 +400,110 @@ export default function Agenda() { )} + {viewingAppointment && ( +
setViewingAppointment(null)}> +
e.stopPropagation()}> +
+

Detalhes do Agendamento

+ +
+
+
+ + {statusLabels[viewingAppointment.status] || viewingAppointment.status} + +
+

{viewingAppointment.client}

+

+ {viewingAppointment.service} • R$ {viewingAppointment.price} ({viewingAppointment.duration} min) +

+
+
+ Data +
{viewingAppointment.date.split('-').reverse().join('/')}
+
+
+ Horário +
{String(viewingAppointment.startHour).padStart(2, '0')}:{String(viewingAppointment.startMin).padStart(2, '0')}
+
+
+
+
+ {(viewingAppointment.status === 'pending' || viewingAppointment.status === 'confirmed') && ( + <> + + + + )} + +
+
+
+ )} + {showModal && (
setShowModal(false)}>
e.stopPropagation()}> diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 7e1a110..487542b 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -52,6 +52,7 @@ export default function Settings() { const [bookingAdvanceMin, setBookingAdvanceMin] = useState(60); const [bookingAdvanceMaxDays, setBookingAdvanceMaxDays] = useState(30); const [cancellationLimitMin, setCancellationLimitMin] = useState(120); + const [toleranceMinutes, setToleranceMinutes] = useState(15); // Notifications const [notifyNew, setNotifyNew] = useState(true); @@ -140,6 +141,7 @@ export default function Settings() { setBookingAdvanceMin(data.bookingAdvanceMin || 60); setBookingAdvanceMaxDays(data.bookingAdvanceMaxDays || 30); setCancellationLimitMin(data.cancellationLimitMin || 120); + setToleranceMinutes(data.toleranceMinutes || 15); setNotifyNew(data.notifyNewAppointment !== false); setNotifyCancel(data.notifyCancellation !== false); setNotifyClient(data.notifyClientReminder !== false); @@ -214,6 +216,7 @@ export default function Settings() { bookingAdvanceMin, bookingAdvanceMaxDays, cancellationLimitMin, + toleranceMinutes, notifyNewAppointment: notifyNew, notifyCancellation: notifyCancel, notifyClientReminder: notifyClient, @@ -633,9 +636,15 @@ export default function Settings() { setBookingAdvanceMaxDays(Number(e.target.value))} />
-
- - setCancellationLimitMin(Number(e.target.value))} /> +
+
+ + setCancellationLimitMin(Number(e.target.value))} /> +
+
+ + setToleranceMinutes(Number(e.target.value))} /> +