projeto base completo

This commit is contained in:
2026-10-02 21:36:17 -03:00
commit 64aa705df6
69 changed files with 13187 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { updateInvitation } from "@/app/admin/actions";
import { InvitationForm } from "@/components/admin";
import { requireOrganizer } from "@/lib/auth";
import { getInvitationForEdit } from "@/lib/organizer";
export const metadata: Metadata = { title: "Editar convite" };
export default async function EditInvitationPage({ params }: { params: Promise<{ id: string }> }) {
await requireOrganizer();
const { id } = await params;
const invitation = await getInvitationForEdit(id);
if (!invitation) notFound();
return (
<div className="max-w-xl mx-auto space-y-6">
<Link href={`/admin/eventos/${invitation.event.id}`} className="text-sm text-amber-600 hover:text-amber-800">
← {invitation.event.name}
</Link>
<h1 className="text-3xl font-bold text-amber-900">Editar convite</h1>
<div className="bg-white/80 rounded-2xl shadow-xl border border-amber-100 p-6">
<InvitationForm
action={updateInvitation.bind(null, invitation.id)}
submitLabel="Salvar alterações"
defaults={{
displayName: invitation.displayName,
guests: invitation.guests.map((g) => ({ id: g.id, name: g.name, type: g.type, status: g.status })),
}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { createInvitation } from "@/app/admin/actions";
import { InvitationForm } from "@/components/admin";
import { requireOrganizer } from "@/lib/auth";
import { getEvent } from "@/lib/organizer";
export const metadata: Metadata = { title: "Novo convite" };
export default async function NewInvitationPage({ params }: { params: Promise<{ id: string }> }) {
await requireOrganizer();
const { id } = await params;
const event = await getEvent(id);
if (!event) notFound();
return (
<div className="max-w-xl mx-auto space-y-6">
<Link href={`/admin/eventos/${event.id}`} className="text-sm text-amber-600 hover:text-amber-800">
← {event.name}
</Link>
<h1 className="text-3xl font-bold text-amber-900">Novo convite</h1>
<div className="bg-white/80 rounded-2xl shadow-xl border border-amber-100 p-6">
<InvitationForm action={createInvitation.bind(null, event.id)} submitLabel="Criar convite" />
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { deleteEvent, updateEvent } from "@/app/admin/actions";
import { ConfirmActionButton, EventForm } from "@/components/admin";
import { requireOrganizer } from "@/lib/auth";
import { getEvent } from "@/lib/organizer";
export const metadata: Metadata = { title: "Editar evento" };
export default async function EditEventPage({ params }: { params: Promise<{ id: string }> }) {
await requireOrganizer();
const { id } = await params;
const event = await getEvent(id);
if (!event) notFound();
return (
<div className="max-w-xl mx-auto space-y-6">
<Link href={`/admin/eventos/${event.id}`} className="text-sm text-amber-600 hover:text-amber-800">
← {event.name}
</Link>
<h1 className="text-3xl font-bold text-amber-900">Editar evento</h1>
<div className="bg-white/80 rounded-2xl shadow-xl border border-amber-100 p-6">
<EventForm action={updateEvent.bind(null, event.id)} submitLabel="Salvar alterações" defaults={{
name: event.name,
date: event.date,
time: event.time,
venue: event.venue,
address: event.address,
rsvpDeadline: event.rsvpDeadline,
}}
/>
</div>
<div className="flex justify-end">
<ConfirmActionButton
action={deleteEvent.bind(null, event.id)}
label="Excluir evento"
confirmMessage={`Excluir "${event.name}" e TODOS os convites dele? Esta ação não pode ser desfeita.`}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,147 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { deleteInvitation, resetInvitationRsvp } from "@/app/admin/actions";
import { ConfirmActionButton, ShareInvitation, StatsBar } from "@/components/admin";
import { requireOrganizer } from "@/lib/auth";
import { formatShortDate, getEventWithInvitations } from "@/lib/organizer";
import type { GuestType, RsvpStatus } from "@/types/invitation";
export const metadata: Metadata = { title: "Evento" };
const statusStyles: Record<RsvpStatus, string> = {
confirmed: "bg-emerald-100 text-emerald-800",
declined: "bg-rose-100 text-rose-800",
pending: "bg-amber-100 text-amber-800",
};
const statusLabels: Record<RsvpStatus, string> = {
confirmed: "✓ Presente",
declined: "✗ Ausente",
pending: "⏳ Pendente",
};
const typeIcons: Record<GuestType, string> = {
adult: "👤",
teen: "🧑",
child: "👶",
};
const respondedFormatter = new Intl.DateTimeFormat("pt-BR", {
dateStyle: "short",
timeStyle: "short",
timeZone: "America/Sao_Paulo",
});
export default async function EventPage({ params }: { params: Promise<{ id: string }> }) {
await requireOrganizer();
const { id } = await params;
const event = await getEventWithInvitations(id);
if (!event) notFound();
return (
<div className="space-y-8">
<div className="space-y-2">
<Link href="/admin" className="text-sm text-amber-600 hover:text-amber-800">
← Eventos
</Link>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-amber-900">{event.name}</h1>
<p className="text-amber-600 mt-1">
📅 {formatShortDate(event.date)} · 🕐 {event.time} · 📍 {event.venue} — {event.address}
</p>
{event.rsvpDeadline && (
<p className={`text-sm mt-1 ${event.rsvpClosed ? "text-rose-700 font-medium" : "text-amber-600"}`}>
{event.rsvpClosed
? `🔒 Confirmações encerradas em ${formatShortDate(event.rsvpDeadline)} — pendentes contam como ausentes`
: `⏰ Confirmações até ${formatShortDate(event.rsvpDeadline)}`}
</p>
)}
</div>
<Link
href={`/admin/eventos/${event.id}/editar`}
className="text-sm px-3 py-1.5 rounded-lg border border-amber-300 text-amber-700 hover:bg-amber-50"
>
Editar evento
</Link>
</div>
</div>
<section className="bg-white/80 rounded-2xl shadow-xl border border-amber-100 p-6 space-y-3">
<h2 className="text-lg font-bold text-amber-900">Resumo de presença</h2>
<StatsBar stats={event.stats} />
</section>
<section className="space-y-4">
<div className="flex items-center justify-between gap-4">
<h2 className="text-2xl font-bold text-amber-900">
Convites <span className="text-base font-medium text-amber-600">({event.invitations.length})</span>
</h2>
<Link
href={`/admin/eventos/${event.id}/convites/novo`}
className="bg-amber-500 text-white px-4 py-2 rounded-xl font-medium hover:bg-amber-600 shadow-lg shadow-amber-500/30"
>
+ Novo convite
</Link>
</div>
{event.invitations.length === 0 ? (
<p className="text-amber-700 text-center py-8">Nenhum convite criado para este evento.</p>
) : (
event.invitations.map((inv) => (
<article key={inv.id} className="bg-white/80 rounded-2xl shadow border border-amber-100 p-5 space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-lg font-bold text-amber-900">{inv.displayName}</h3>
<p className="text-xs text-amber-600">
Link: /r/{inv.token}
{inv.respondedAt && <> · respondeu em {respondedFormatter.format(inv.respondedAt)}</>}
</p>
</div>
<ShareInvitation token={inv.token} displayName={inv.displayName} eventName={event.name} />
</div>
<ul className="flex flex-wrap gap-2">
{inv.guests.map((g) => (
<li key={g.id} className={`text-sm px-3 py-1 rounded-full ${statusStyles[g.status]}`}>
{typeIcons[g.type]} {g.name} · {statusLabels[g.status]}
</li>
))}
</ul>
<div className="flex flex-wrap gap-2 pt-2 border-t border-amber-100">
<Link
href={`/admin/convites/${inv.id}/editar`}
className="text-sm px-3 py-1.5 rounded-lg border border-amber-300 text-amber-700 hover:bg-amber-50"
>
Editar
</Link>
<Link
href={`/r/${inv.token}`}
target="_blank"
className="text-sm px-3 py-1.5 rounded-lg border border-amber-300 text-amber-700 hover:bg-amber-50"
>
Abrir convite
</Link>
{inv.respondedAt && (
<ConfirmActionButton
action={resetInvitationRsvp.bind(null, inv.id)}
label="Zerar respostas"
confirmMessage={`Voltar todos os convidados de "${inv.displayName}" para pendente?`}
variant="neutral"
/>
)}
<ConfirmActionButton
action={deleteInvitation.bind(null, inv.id)}
label="Excluir"
confirmMessage={`Excluir o convite de "${inv.displayName}"? O link deixará de funcionar.`}
/>
</div>
</article>
))
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import Link from "next/link";
import { createEvent } from "@/app/admin/actions";
import { EventForm } from "@/components/admin";
import { requireOrganizer } from "@/lib/auth";
export const metadata: Metadata = { title: "Novo evento" };
export default async function NewEventPage() {
await requireOrganizer();
return (
<div className="max-w-xl mx-auto space-y-6">
<Link href="/admin" className="text-sm text-amber-600 hover:text-amber-800">
← Eventos
</Link>
<h1 className="text-3xl font-bold text-amber-900">Novo evento</h1>
<div className="bg-white/80 rounded-2xl shadow-xl border border-amber-100 p-6">
<EventForm action={createEvent} submitLabel="Criar evento" />
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import Link from "next/link";
import { logout } from "@/app/admin/auth-actions";
import { requireOrganizer } from "@/lib/auth";
export default async function PainelLayout({ children }: { children: React.ReactNode }) {
await requireOrganizer();
return (
<div className="min-h-screen bg-gradient-to-b from-amber-50 via-white to-emerald-50">
<header className="border-b border-amber-200 bg-white/70 backdrop-blur-sm">
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
<Link href="/admin" className="flex items-center gap-2 font-bold text-amber-900">
<span className="text-2xl">🦁</span> Organizador
</Link>
<form action={logout}>
<button type="submit" className="text-sm text-amber-700 hover:text-amber-900">
Sair
</button>
</form>
</div>
</header>
<main className="max-w-4xl mx-auto px-4 py-8">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import Link from "next/link";
import { StatsBar } from "@/components/admin";
import { requireOrganizer } from "@/lib/auth";
import { formatShortDate, listEventsWithStats } from "@/lib/organizer";
export const metadata: Metadata = { title: "Eventos" };
export default async function AdminHome() {
await requireOrganizer();
const eventList = await listEventsWithStats();
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<h1 className="text-3xl font-bold text-amber-900">Eventos</h1>
<Link
href="/admin/eventos/novo"
className="bg-amber-500 text-white px-4 py-2 rounded-xl font-medium hover:bg-amber-600 shadow-lg shadow-amber-500/30"
>
+ Novo evento
</Link>
</div>
{eventList.length === 0 ? (
<p className="text-amber-700 text-center py-12">Nenhum evento cadastrado ainda.</p>
) : (
<div className="space-y-4">
{eventList.map((event) => (
<Link
key={event.id}
href={`/admin/eventos/${event.id}`}
className="block bg-white/80 rounded-2xl shadow-xl border border-amber-100 p-6 hover:border-amber-300 transition-colors"
>
<div className="flex flex-wrap items-baseline justify-between gap-2 mb-1">
<h2 className="text-xl font-bold text-amber-900">{event.name}</h2>
<span className="text-sm text-amber-600">
{event.invitationCount} {event.invitationCount === 1 ? "convite" : "convites"}
</span>
</div>
<p className="text-sm text-amber-600 mb-4">
📅 {formatShortDate(event.date)} · 🕐 {event.time} · 📍 {event.venue}
{event.rsvpDeadline && (
<> · {event.rsvpClosed ? "🔒 encerrado" : `⏰ até ${formatShortDate(event.rsvpDeadline)}`}</>
)}
</p>
<StatsBar stats={event.stats} />
</Link>
))}
</div>
)}
</div>
);
}

235
src/app/admin/actions.ts Normal file
View File

@@ -0,0 +1,235 @@
"use server";
import { randomBytes } from "node:crypto";
import { and, eq, inArray, notInArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { db } from "@/db";
import { events, guests, invitations } from "@/db/schema";
import { requireOrganizer } from "@/lib/auth";
import { isUuid } from "@/lib/organizer";
import type { GuestType } from "@/types/invitation";
export interface FormState {
error?: string;
}
function field(formData: FormData, name: string): string {
const value = formData.get(name);
return typeof value === "string" ? value.trim() : "";
}
// ---------- Eventos ----------
function parseEvent(formData: FormData) {
const data = {
name: field(formData, "name"),
date: field(formData, "date"),
time: field(formData, "time"),
venue: field(formData, "venue"),
address: field(formData, "address"),
rsvpDeadline: field(formData, "rsvpDeadline") || null,
};
if (!data.name || !data.date || !data.time || !data.venue || !data.address) {
return { error: "Preencha todos os campos." } as const;
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(data.date) || !/^\d{2}:\d{2}$/.test(data.time)) {
return { error: "Data ou horário inválido." } as const;
}
if (data.rsvpDeadline !== null) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(data.rsvpDeadline)) {
return { error: "Prazo de confirmação inválido." } as const;
}
if (data.rsvpDeadline > data.date) {
return { error: "O prazo de confirmação não pode ser depois da festa." } as const;
}
}
return { data } as const;
}
export async function createEvent(_prev: FormState, formData: FormData): Promise<FormState> {
await requireOrganizer();
const parsed = parseEvent(formData);
if ("error" in parsed) return { error: parsed.error };
const [event] = await db.insert(events).values(parsed.data).returning({ id: events.id });
revalidatePath("/admin");
redirect(`/admin/eventos/${event.id}`);
}
export async function updateEvent(eventId: string, _prev: FormState, formData: FormData): Promise<FormState> {
await requireOrganizer();
if (!isUuid(eventId)) return { error: "Evento inválido." };
const parsed = parseEvent(formData);
if ("error" in parsed) return { error: parsed.error };
await db.update(events).set(parsed.data).where(eq(events.id, eventId));
revalidatePath("/admin");
revalidatePath(`/admin/eventos/${eventId}`);
// O prazo muda o que os convidados veem em todos os convites do evento
revalidatePath("/r/[token]", "page");
redirect(`/admin/eventos/${eventId}`);
}
export async function deleteEvent(eventId: string): Promise<void> {
await requireOrganizer();
if (!isUuid(eventId)) return;
await db.delete(events).where(eq(events.id, eventId));
revalidatePath("/admin");
redirect("/admin");
}
// ---------- Convites ----------
interface GuestInput {
id?: string;
name: string;
type: GuestType;
}
const guestTypes = new Set<unknown>(["adult", "teen", "child"]);
function parseInvitation(formData: FormData) {
const displayName = field(formData, "displayName");
if (!displayName) return { error: "Informe o nome exibido no convite." } as const;
let raw: unknown;
try {
raw = JSON.parse(field(formData, "guests") || "[]");
} catch {
return { error: "Lista de convidados inválida." } as const;
}
if (!Array.isArray(raw)) return { error: "Lista de convidados inválida." } as const;
const guestList: GuestInput[] = [];
for (const item of raw) {
const name = typeof item?.name === "string" ? item.name.trim() : "";
if (!name) continue; // linhas vazias são ignoradas
if (!guestTypes.has(item.type)) {
return { error: "Tipo de convidado inválido." } as const;
}
guestList.push({ id: isUuid(item.id) ? item.id : undefined, name, type: item.type });
}
if (guestList.length === 0) return { error: "Adicione pelo menos um convidado." } as const;
return { displayName, guests: guestList } as const;
}
// Token curto e não adivinhável usado no link /r/<token>
function generateToken(): string {
return randomBytes(6).toString("base64url");
}
export async function createInvitation(eventId: string, _prev: FormState, formData: FormData): Promise<FormState> {
await requireOrganizer();
if (!isUuid(eventId)) return { error: "Evento inválido." };
const parsed = parseInvitation(formData);
if ("error" in parsed) return { error: parsed.error };
await db.transaction(async (tx) => {
const [invitation] = await tx
.insert(invitations)
.values({ eventId, token: generateToken(), displayName: parsed.displayName })
.returning({ id: invitations.id });
await tx.insert(guests).values(
parsed.guests.map((g, index) => ({
invitationId: invitation.id,
name: g.name,
type: g.type,
sortOrder: index,
}))
);
});
revalidatePath("/admin");
revalidatePath(`/admin/eventos/${eventId}`);
redirect(`/admin/eventos/${eventId}`);
}
export async function updateInvitation(
invitationId: string,
_prev: FormState,
formData: FormData
): Promise<FormState> {
await requireOrganizer();
if (!isUuid(invitationId)) return { error: "Convite inválido." };
const parsed = parseInvitation(formData);
if ("error" in parsed) return { error: parsed.error };
const invitation = await db.query.invitations.findFirst({
where: eq(invitations.id, invitationId),
columns: { id: true, eventId: true, token: true },
});
if (!invitation) return { error: "Convite não encontrado." };
await db.transaction(async (tx) => {
await tx.update(invitations).set({ displayName: parsed.displayName }).where(eq(invitations.id, invitationId));
// Convidados existentes mantêm o id (e a resposta de RSVP); os removidos são apagados
const keptIds = parsed.guests.flatMap((g) => (g.id ? [g.id] : []));
await tx
.delete(guests)
.where(
keptIds.length > 0
? and(eq(guests.invitationId, invitationId), notInArray(guests.id, keptIds))
: eq(guests.invitationId, invitationId)
);
const existing = keptIds.length
? await tx
.select({ id: guests.id })
.from(guests)
.where(and(eq(guests.invitationId, invitationId), inArray(guests.id, keptIds)))
: [];
const existingIds = new Set(existing.map((g) => g.id));
for (const [index, g] of parsed.guests.entries()) {
if (g.id && existingIds.has(g.id)) {
await tx.update(guests).set({ name: g.name, type: g.type, sortOrder: index }).where(eq(guests.id, g.id));
} else {
await tx.insert(guests).values({ invitationId, name: g.name, type: g.type, sortOrder: index });
}
}
});
revalidatePath("/admin");
revalidatePath(`/admin/eventos/${invitation.eventId}`);
revalidatePath(`/r/${invitation.token}`);
redirect(`/admin/eventos/${invitation.eventId}`);
}
export async function deleteInvitation(invitationId: string): Promise<void> {
await requireOrganizer();
if (!isUuid(invitationId)) return;
const [deleted] = await db
.delete(invitations)
.where(eq(invitations.id, invitationId))
.returning({ eventId: invitations.eventId, token: invitations.token });
if (!deleted) return;
revalidatePath("/admin");
revalidatePath(`/r/${deleted.token}`);
redirect(`/admin/eventos/${deleted.eventId}`);
}
// Volta todos os convidados do convite para "pendente"
export async function resetInvitationRsvp(invitationId: string): Promise<void> {
await requireOrganizer();
if (!isUuid(invitationId)) return;
const invitation = await db.query.invitations.findFirst({
where: eq(invitations.id, invitationId),
columns: { eventId: true, token: true },
});
if (!invitation) return;
await db.transaction(async (tx) => {
await tx.update(guests).set({ status: "pending", respondedAt: null }).where(eq(guests.invitationId, invitationId));
await tx.update(invitations).set({ respondedAt: null }).where(eq(invitations.id, invitationId));
});
revalidatePath(`/admin/eventos/${invitation.eventId}`);
revalidatePath(`/r/${invitation.token}`);
}

View File

@@ -0,0 +1,22 @@
"use server";
import { redirect } from "next/navigation";
import { checkOrganizerPassword, createSession, deleteSession } from "@/lib/auth";
export interface LoginState {
error?: string;
}
export async function login(_prev: LoginState, formData: FormData): Promise<LoginState> {
const password = formData.get("password");
if (typeof password !== "string" || !checkOrganizerPassword(password)) {
return { error: "Senha incorreta." };
}
await createSession();
redirect("/admin");
}
export async function logout(): Promise<void> {
await deleteSession();
redirect("/admin/login");
}

View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { LoginForm } from "@/components/admin";
import { isOrganizer } from "@/lib/auth";
export const metadata: Metadata = { title: "Entrar" };
export default async function LoginPage() {
if (await isOrganizer()) {
redirect("/admin");
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-amber-50 to-white px-4">
<div className="w-full max-w-sm bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-amber-100 p-8">
<div className="text-center mb-6">
<span className="text-4xl">🦁</span>
<h1 className="text-2xl font-bold text-amber-900 mt-2">Área do Organizador</h1>
</div>
<LoginForm />
</div>
</div>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

105
src/app/globals.css Normal file
View File

@@ -0,0 +1,105 @@
@import "tailwindcss";
:root {
--background: #fefce8;
--foreground: #78350f;
--color-amber-50: #fefce8;
--color-amber-100: #fef3c7;
--color-amber-200: #fde68a;
--color-amber-300: #fcd34d;
--color-amber-400: #fbbf24;
--color-amber-500: #f59e0b;
--color-amber-600: #d97706;
--color-amber-700: #b45309;
--color-amber-800: #92400e;
--color-amber-900: #78350f;
--color-emerald-50: #ecfdf5;
--color-emerald-100: #d1fae5;
--color-emerald-200: #a7f3d0;
--color-emerald-300: #6ee7b7;
--color-emerald-400: #34d399;
--color-emerald-500: #10b981;
--color-emerald-600: #059669;
--color-emerald-700: #047857;
--color-emerald-800: #065f46;
--color-emerald-900: #064e3b;
--color-rose-50: #fff1f2;
--color-rose-100: #ffe4e6;
--color-rose-200: #fecdd3;
--color-rose-300: #fda4af;
--color-rose-400: #fb7185;
--color-rose-500: #f43f5e;
--color-rose-600: #e11d48;
--color-rose-700: #be123c;
--color-rose-800: #9f1239;
--color-rose-900: #881337;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans), system-ui, -apple-system, sans-serif;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
:focus-visible {
outline: 2px solid #f59e0b;
outline-offset: 2px;
}
::selection {
background-color: #f59e0b;
color: white;
}
input[type="checkbox"],
input[type="radio"] {
accent-color: #f59e0b;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #fef3c7;
}
::-webkit-scrollbar-thumb {
background: #f59e0b;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #d97706;
}

59
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,59 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: {
default: "Convite Safari - Aniversário de 1 Ano",
template: "%s | Convite Safari",
},
description: "Convite digital personalizado com tema Safari para aniversário de 1 ano. RSVP funcional e design mobile-first.",
metadataBase: new URL("http://localhost:3000"),
openGraph: {
type: "website",
locale: "pt_BR",
siteName: "Convite Safari",
title: "Convite Safari - Aniversário de 1 Ano",
description: "Convite digital personalizado com tema Safari para aniversário de 1 ano.",
},
twitter: {
card: "summary_large_image",
title: "Convite Safari",
description: "Convite digital personalizado com tema Safari para aniversário de 1 ano.",
},
robots: {
index: false,
follow: false,
},
};
export const viewport: Viewport = {
themeColor: "#f59e0b",
width: "device-width",
initialScale: 1,
maximumScale: 5,
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="pt-BR" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
</head>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}

25
src/app/page.tsx Normal file
View File

@@ -0,0 +1,25 @@
import Link from "next/link";
// Página pública neutra: os convites só são acessados pelo link /r/<token>,
// e a listagem fica na área do organizador (/admin)
export default function Home() {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-amber-50 via-white to-emerald-50 px-4">
<div className="max-w-md text-center">
<div className="inline-flex items-center gap-2 bg-amber-100/80 backdrop-blur-sm rounded-full px-4 py-2 mb-6">
<span className="text-2xl">🦁</span>
<span className="text-amber-800 font-medium">Convites Safari</span>
</div>
<h1 className="text-4xl sm:text-5xl font-bold text-amber-900 leading-tight mb-4">
Convites Digitais de Aniversário
</h1>
<p className="text-lg text-amber-700 mb-10">
Recebeu um convite? Abra o link que foi enviado para você para confirmar sua presença.
</p>
<Link href="/admin" className="text-sm text-amber-500 hover:text-amber-700">
Área do organizador
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
"use server";
import { and, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
import { guests, invitations } from "@/db/schema";
import { isUuid } from "@/lib/organizer";
import { isRsvpClosed } from "@/lib/rsvp";
import type { RsvpSubmission } from "@/types/invitation";
const validStatuses = new Set(["confirmed", "declined"]);
// Chamável por POST direto: o token é a única credencial do convidado,
// então tudo é revalidado aqui no servidor.
export async function submitRsvp(submission: RsvpSubmission): Promise<{ success: boolean; closed?: boolean }> {
const { token, responses } = submission;
if (typeof token !== "string" || !Array.isArray(responses) || responses.length === 0) {
return { success: false };
}
if (responses.some((r) => !isUuid(r.guestId) || !validStatuses.has(r.status))) {
return { success: false };
}
const invitation = await db.query.invitations.findFirst({
where: eq(invitations.token, token),
columns: { id: true },
with: { event: { columns: { rsvpDeadline: true } } },
});
if (!invitation) {
return { success: false };
}
if (isRsvpClosed(invitation.event.rsvpDeadline)) {
return { success: false, closed: true };
}
const guestIds = responses.map((r) => r.guestId);
const ownGuests = await db
.select({ id: guests.id })
.from(guests)
.where(and(eq(guests.invitationId, invitation.id), inArray(guests.id, guestIds)));
if (ownGuests.length !== new Set(guestIds).size) {
return { success: false };
}
const now = new Date();
await db.transaction(async (tx) => {
for (const response of responses) {
await tx
.update(guests)
.set({ status: response.status, respondedAt: now })
.where(and(eq(guests.id, response.guestId), eq(guests.invitationId, invitation.id)));
}
await tx.update(invitations).set({ respondedAt: now }).where(eq(invitations.id, invitation.id));
});
revalidatePath(`/r/${token}`);
return { success: true };
}

View File

@@ -0,0 +1,15 @@
export default function Loading() {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-amber-50 to-white">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-amber-100 mb-4">
<svg className="animate-spin w-8 h-8 text-amber-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
<p className="text-amber-600">Carregando convite...</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-amber-50 to-white px-4">
<div className="text-center max-w-md">
<div className="inline-flex items-center justify-center w-24 h-24 rounded-full bg-amber-100 mb-6">
<span className="text-5xl">🦁</span>
</div>
<h1 className="text-4xl font-bold text-amber-900 mb-4">Convite não encontrado</h1>
<p className="text-amber-700 mb-8 text-lg">
O link que você acessou não corresponde a nenhum convite válido.
Verifique se o link está correto ou entre em contato com os organizadores.
</p>
<Link
href="/"
className="inline-flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors shadow-lg shadow-amber-500/30"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Voltar ao Início
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { notFound } from "next/navigation";
import { connection } from "next/server";
import { InvitationView } from "@/components/invitation";
import { getInvitationByToken } from "@/lib/invitation";
interface InvitationPageProps {
params: Promise<{ token: string }>;
}
export default async function InvitationPage({ params }: InvitationPageProps) {
// Sempre renderiza na hora: o convite muda com RSVPs e edições do organizador
await connection();
const { token } = await params;
const invitation = await getInvitationByToken(token);
if (!invitation) {
notFound();
}
return <InvitationView invitation={invitation} />;
}

View File

@@ -0,0 +1,41 @@
"use client";
import { useFormStatus } from "react-dom";
function SubmitButton({ label, className }: { label: string; className: string }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} className={className}>
{pending ? "Aguarde..." : label}
</button>
);
}
// Formulário de um botão só, que pede confirmação antes de executar a Server Action
export function ConfirmActionButton({
action,
label,
confirmMessage,
variant = "danger",
}: {
action: () => Promise<void>;
label: string;
confirmMessage: string;
variant?: "danger" | "neutral";
}) {
const className =
variant === "danger"
? "text-sm px-3 py-1.5 rounded-lg border border-rose-300 text-rose-700 hover:bg-rose-50 disabled:opacity-50"
: "text-sm px-3 py-1.5 rounded-lg border border-amber-300 text-amber-700 hover:bg-amber-50 disabled:opacity-50";
return (
<form
action={action}
onSubmit={(e) => {
if (!window.confirm(confirmMessage)) e.preventDefault();
}}
>
<SubmitButton label={label} className={className} />
</form>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import { useActionState } from "react";
import type { FormState } from "@/app/admin/actions";
import { Button, Input } from "@/components/ui";
interface EventFormProps {
action: (prev: FormState, formData: FormData) => Promise<FormState>;
submitLabel: string;
defaults?: {
name: string;
date: string;
time: string;
venue: string;
address: string;
rsvpDeadline: string | null;
};
}
export function EventForm({ action, submitLabel, defaults }: EventFormProps) {
const [state, formAction, pending] = useActionState(action, {});
return (
<form action={formAction} className="space-y-4">
<label className="block">
<span className="block text-sm font-medium text-amber-800 mb-1">Nome da festa</span>
<Input name="name" required defaultValue={defaults?.name} placeholder="1º Aniversário do Arthur" />
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="block text-sm font-medium text-amber-800 mb-1">Data</span>
<Input type="date" name="date" required defaultValue={defaults?.date} />
</label>
<label className="block">
<span className="block text-sm font-medium text-amber-800 mb-1">Horário</span>
<Input type="time" name="time" required defaultValue={defaults?.time} />
</label>
</div>
<label className="block">
<span className="block text-sm font-medium text-amber-800 mb-1">Local</span>
<Input name="venue" required defaultValue={defaults?.venue} placeholder="Espaço Festa" />
</label>
<label className="block">
<span className="block text-sm font-medium text-amber-800 mb-1">Endereço</span>
<Input name="address" required defaultValue={defaults?.address} placeholder="Rua, número - Cidade/UF" />
</label>
<label className="block">
<span className="block text-sm font-medium text-amber-800 mb-1">Confirmar presença até (opcional)</span>
<Input type="date" name="rsvpDeadline" defaultValue={defaults?.rsvpDeadline ?? undefined} />
<span className="block text-xs text-amber-600 mt-1">
Depois deste dia, quem não respondeu conta como ausente e o convite não aceita mais alterações.
</span>
</label>
{state.error && (
<p className="text-sm text-rose-600" role="alert">
{state.error}
</p>
)}
<Button type="submit" disabled={pending} className="w-full">
{pending ? "Salvando..." : submitLabel}
</Button>
</form>
);
}

View File

@@ -0,0 +1,123 @@
"use client";
import { useActionState, useState } from "react";
import type { FormState } from "@/app/admin/actions";
import { Button, Input } from "@/components/ui";
import type { GuestType, RsvpStatus } from "@/types/invitation";
interface GuestRow {
key: string;
id?: string;
name: string;
type: GuestType;
status?: RsvpStatus;
}
interface InvitationFormProps {
action: (prev: FormState, formData: FormData) => Promise<FormState>;
submitLabel: string;
defaults?: {
displayName: string;
guests: { id: string; name: string; type: GuestType; status: RsvpStatus }[];
};
}
const statusLabels: Record<RsvpStatus, string> = {
confirmed: "✓ Presente",
declined: "✗ Ausente",
pending: "⏳ Pendente",
};
let rowCounter = 0;
const newRow = (type: GuestType = "adult"): GuestRow => ({ key: `new-${rowCounter++}`, name: "", type });
export function InvitationForm({ action, submitLabel, defaults }: InvitationFormProps) {
const [state, formAction, pending] = useActionState(action, {});
const [rows, setRows] = useState<GuestRow[]>(() =>
defaults?.guests.length ? defaults.guests.map((g) => ({ ...g, key: g.id })) : [newRow()]
);
const updateRow = (key: string, patch: Partial<GuestRow>) =>
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...patch } : r)));
const removeRow = (key: string) => setRows((prev) => prev.filter((r) => r.key !== key));
const guestsJson = JSON.stringify(rows.map(({ id, name, type }) => ({ id, name, type })));
return (
<form action={formAction} className="space-y-6">
<label className="block">
<span className="block text-sm font-medium text-amber-800 mb-1">Nome exibido no convite</span>
<Input
name="displayName"
required
defaultValue={defaults?.displayName}
placeholder="Família Silva"
/>
</label>
<fieldset className="space-y-3">
<legend className="text-sm font-medium text-amber-800 mb-1">Convidados</legend>
{rows.map((row) => (
<div key={row.key} className="flex items-center gap-2">
<Input
value={row.name}
onChange={(e) => updateRow(row.key, { name: e.target.value })}
placeholder="Nome"
aria-label="Nome do convidado"
className="flex-1"
/>
<select
value={row.type}
onChange={(e) => updateRow(row.key, { type: e.target.value as GuestType })}
aria-label="Tipo do convidado"
className="px-3 py-3 rounded-xl border-2 border-amber-200 bg-white focus:border-amber-500 focus:outline-none"
>
<option value="adult">Adulto</option>
<option value="teen">Adolescente</option>
<option value="child">Criança</option>
</select>
{row.status && (
<span className="hidden sm:inline text-xs text-amber-700 whitespace-nowrap">{statusLabels[row.status]}</span>
)}
<button
type="button"
onClick={() => removeRow(row.key)}
disabled={rows.length === 1}
className="p-3 rounded-xl text-rose-600 hover:bg-rose-50 disabled:opacity-30"
aria-label={`Remover ${row.name || "convidado"}`}
>
✕
</button>
</div>
))}
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setRows((prev) => [...prev, newRow("adult")])}>
+ Adulto
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setRows((prev) => [...prev, newRow("teen")])}>
+ Adolescente
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setRows((prev) => [...prev, newRow("child")])}>
+ Criança
</Button>
</div>
{defaults && (
<p className="text-xs text-amber-600">
Convidados removidos perdem a resposta já registrada.
</p>
)}
</fieldset>
<input type="hidden" name="guests" value={guestsJson} />
{state.error && (
<p className="text-sm text-rose-600" role="alert">
{state.error}
</p>
)}
<Button type="submit" disabled={pending} className="w-full">
{pending ? "Salvando..." : submitLabel}
</Button>
</form>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import { useActionState } from "react";
import { login, type LoginState } from "@/app/admin/auth-actions";
import { Button, Input } from "@/components/ui";
export function LoginForm() {
const [state, formAction, pending] = useActionState<LoginState, FormData>(login, {});
return (
<form action={formAction} className="space-y-4">
<label className="block">
<span className="block text-sm font-medium text-amber-800 mb-1">Senha do organizador</span>
<Input type="password" name="password" required autoFocus autoComplete="current-password" />
</label>
{state.error && (
<p className="text-sm text-rose-600" role="alert">
{state.error}
</p>
)}
<Button type="submit" disabled={pending} className="w-full">
{pending ? "Entrando..." : "Entrar"}
</Button>
</form>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import { useState } from "react";
// Copiar via textarea: navigator.clipboard só existe em contexto seguro (HTTPS/localhost),
// e o acesso direto por http://IP não é
function fallbackCopy(text: string): boolean {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
return ok;
}
export function ShareInvitation({ token, displayName, eventName }: { token: string; displayName: string; eventName: string }) {
const [copied, setCopied] = useState(false);
const getUrl = () => `${window.location.origin}/r/${token}`;
const handleCopy = async () => {
const url = getUrl();
let ok = false;
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(url);
ok = true;
} else {
ok = fallbackCopy(url);
}
} catch {
ok = fallbackCopy(url);
}
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
window.prompt("Copie o link do convite:", url);
}
};
const handleWhatsApp = () => {
const text = `Olá, ${displayName}! Vocês estão convidados para o ${eventName} 🦁\nConfirme a presença: ${getUrl()}`;
window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, "_blank", "noopener");
};
return (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={handleCopy}
className="text-sm px-3 py-1.5 rounded-lg border border-amber-300 text-amber-700 hover:bg-amber-50"
>
{copied ? "✓ Link copiado" : "Copiar link"}
</button>
<button
type="button"
onClick={handleWhatsApp}
className="text-sm px-3 py-1.5 rounded-lg border border-emerald-300 text-emerald-700 hover:bg-emerald-50"
>
WhatsApp
</button>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import type { RsvpStats } from "@/lib/organizer";
export function StatsBar({ stats }: { stats: RsvpStats }) {
return (
<div className="grid grid-cols-3 gap-3 text-center">
<div className="bg-emerald-50 rounded-xl p-3 border border-emerald-100">
<div className="text-2xl font-bold text-emerald-700">{stats.confirmed}</div>
<div className="text-xs text-emerald-600 font-medium">Presentes</div>
{stats.confirmed > 0 && (
<div className="text-[11px] text-emerald-600 mt-1">
{stats.confirmedAdults} adultos · {stats.confirmedTeens} adolescentes · {stats.confirmedChildren} crianças
</div>
)}
</div>
<div className="bg-rose-50 rounded-xl p-3 border border-rose-100">
<div className="text-2xl font-bold text-rose-700">{stats.declined}</div>
<div className="text-xs text-rose-600 font-medium">Ausentes</div>
</div>
<div className="bg-amber-50 rounded-xl p-3 border border-amber-100">
<div className="text-2xl font-bold text-amber-700">{stats.pending}</div>
<div className="text-xs text-amber-600 font-medium">Pendentes</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
export { LoginForm } from "./LoginForm";
export { EventForm } from "./EventForm";
export { InvitationForm } from "./InvitationForm";
export { ShareInvitation } from "./ShareInvitation";
export { ConfirmActionButton } from "./ConfirmActionButton";
export { StatsBar } from "./StatsBar";

View File

@@ -0,0 +1,83 @@
import type { Guest } from "@/types/invitation";
interface ConfirmationSuccessProps {
guests: Guest[];
displayName: string;
onClose?: () => void;
}
export function ConfirmationSuccess({ guests, displayName, onClose }: ConfirmationSuccessProps) {
const confirmed = guests.filter((g) => g.status === "confirmed");
const declined = guests.filter((g) => g.status === "declined");
const patternSvg = "data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E";
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="success-title"
>
<div className="relative w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden animate-scale-in">
<div className="bg-gradient-to-r from-emerald-500 to-teal-600 px-6 py-8 text-center relative overflow-hidden">
<div className="absolute inset-0" style={{ backgroundImage: `url('${patternSvg}')` }} />
<div className="relative z-10">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-white/20 backdrop-blur-sm mb-4">
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 id="success-title" className="text-2xl font-bold text-white mb-2">
Presença Confirmada!
</h2>
<p className="text-white/90">{displayName}, obrigado pela resposta!</p>
</div>
</div>
<div className="p-6 space-y-6">
{confirmed.length > 0 && (
<div>
<h3 className="flex items-center gap-2 text-sm font-semibold text-emerald-700 mb-3">
<span className="w-6 h-6 rounded-full bg-emerald-100 flex items-center justify-center text-emerald-600 text-xs">✓</span>
Presentes ({confirmed.length})
</h3>
<ul className="space-y-2" role="list">
{confirmed.map((guest) => (
<li key={guest.id} className="flex items-center gap-3 p-3 bg-emerald-50 rounded-xl border border-emerald-100">
<span className="w-8 h-8 rounded-lg bg-emerald-100 flex items-center justify-center text-emerald-600 text-sm">👤</span>
<span className="font-medium text-amber-900">{guest.name}</span>
</li>
))}
</ul>
</div>
)}
{declined.length > 0 && (
<div>
<h3 className="flex items-center gap-2 text-sm font-semibold text-rose-700 mb-3">
<span className="w-6 h-6 rounded-full bg-rose-100 flex items-center justify-center text-rose-600 text-xs">✗</span>
Ausentes ({declined.length})
</h3>
<ul className="space-y-2" role="list">
{declined.map((guest) => (
<li key={guest.id} className="flex items-center gap-3 p-3 bg-rose-50 rounded-xl border border-rose-100">
<span className="w-8 h-8 rounded-lg bg-rose-100 flex items-center justify-center text-rose-600 text-sm">👤</span>
<span className="font-medium text-amber-900">{guest.name}</span>
</li>
))}
</ul>
</div>
)}
<button
onClick={onClose}
className="w-full bg-amber-500 text-white py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
>
Fechar
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import type { InvitationEvent } from "@/types/invitation";
const icons = {
date: "📅",
time: "🕐",
venue: "📍",
};
export function EventDetails({ event }: { event: InvitationEvent }) {
const details = [
{ label: "Data", value: event.date, icon: icons.date },
{ label: "Horário", value: event.time, icon: icons.time },
{ label: "Local", value: event.venue, icon: icons.venue },
{ label: "Endereço", value: event.address, icon: icons.venue },
];
return (
<section className="space-y-4">
<h2 className="text-2xl font-bold text-amber-900 text-center mb-6">
Detalhes da Festa
</h2>
<div className="grid gap-4 sm:grid-cols-2">
{details.map((detail) => (
<div
key={detail.label}
className="bg-white/60 backdrop-blur-sm rounded-xl p-5 border border-amber-100 text-center hover:border-amber-300 transition-colors group"
>
<span className="text-3xl block mb-2 group-hover:scale-110 transition-transform">{detail.icon}</span>
<dt className="text-sm font-medium text-amber-600 uppercase tracking-wide mb-1">
{detail.label}
</dt>
<dd className="text-amber-900 font-medium text-base">{detail.value}</dd>
</div>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,122 @@
import type { Guest } from "@/types/invitation";
interface GuestListProps {
guests: Guest[];
onGuestToggle: (guestId: string, status: "confirmed" | "declined") => void;
submitted?: boolean;
// Prazo de confirmação encerrado: mostra as respostas sem permitir alteração
locked?: boolean;
}
export function GuestList({ guests, onGuestToggle, submitted, locked }: GuestListProps) {
const readOnly = submitted || locked;
const typeLabels = {
adult: "Adulto",
teen: "Adolescente",
child: "Criança",
};
const typeIcons = {
adult: "👤",
teen: "🧑",
child: "👶",
};
const statusStyles = {
confirmed: "bg-emerald-100 text-emerald-800 border-emerald-200",
declined: "bg-rose-100 text-rose-800 border-rose-200",
pending: "bg-amber-100 text-amber-800 border-amber-200",
};
return (
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-amber-900">Convidados</h2>
<span className="text-sm text-amber-600 bg-amber-50 px-3 py-1 rounded-full font-medium">
{guests.length} {guests.length === 1 ? "convidado" : "convidados"}
</span>
</div>
<div className="space-y-3" role="list" aria-label="Lista de convidados">
{guests.map((guest) => (
<article
key={guest.id}
className={`relative group flex items-center gap-4 p-4 rounded-xl border-2 transition-all duration-200 ${
submitted
? "opacity-60"
: "bg-white/60 backdrop-blur-sm border-amber-100 hover:border-amber-300"
}`}
role="listitem"
>
<div className="flex-shrink-0 w-12 h-12 rounded-xl bg-gradient-to-br from-amber-100 to-amber-200 flex items-center justify-center text-2xl">
{typeIcons[guest.type]}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-amber-900 truncate">{guest.name}</h3>
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusStyles[guest.status]}`}
>
{guest.status === "confirmed" && "✓ Presente"}
{guest.status === "declined" && "✗ Ausente"}
{guest.status === "pending" && "⏳ Pendente"}
</span>
</div>
<p className="text-sm text-amber-600">{typeLabels[guest.type]}</p>
</div>
{!readOnly && (
<div className="flex items-center gap-2">
<button
onClick={() => onGuestToggle(guest.id, "confirmed")}
disabled={guest.status === "confirmed"}
className={`p-2 rounded-xl transition-all duration-200 ${
guest.status === "confirmed"
? "bg-emerald-500 text-white shadow-lg shadow-emerald-500/30"
: "bg-white/80 text-amber-600 hover:bg-emerald-50 hover:text-emerald-600 border border-amber-200"
}`}
aria-label={guest.status === "confirmed" ? "Confirmado" : "Marcar como presente"}
aria-pressed={guest.status === "confirmed"}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
<button
onClick={() => onGuestToggle(guest.id, "declined")}
disabled={guest.status === "declined"}
className={`p-2 rounded-xl transition-all duration-200 ${
guest.status === "declined"
? "bg-rose-500 text-white shadow-lg shadow-rose-500/30"
: "bg-white/80 text-amber-600 hover:bg-rose-50 hover:text-rose-600 border border-amber-200"
}`}
aria-label={guest.status === "declined" ? "Recusado" : "Marcar como ausente"}
aria-pressed={guest.status === "declined"}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
{readOnly && guest.status !== "pending" && (
<div className={`px-3 py-1 rounded-full text-sm font-medium ${statusStyles[guest.status]}`}>
{guest.status === "confirmed" ? "✓ Confirmado" : "✗ Recusado"}
</div>
)}
</article>
))}
</div>
{!readOnly && guests.every((g) => g.status === "pending") && (
<p className="text-center text-amber-600 text-sm italic">
Toque em ✓ para confirmar ou ✗ para recusar
</p>
)}
</section>
);
}

View File

@@ -0,0 +1,17 @@
export function InvitationFooter() {
return (
<footer className="mt-12 pt-8 border-t border-amber-200 text-center">
<div className="flex items-center justify-center gap-2 text-amber-600 mb-4">
<span className="text-xl">🌿</span>
<span className="text-xl">🦁</span>
<span className="text-xl">🌿</span>
</div>
<p className="text-sm text-amber-600 mb-2">
Convite digital personalizado
</p>
<p className="text-xs text-amber-400">
Feito com carinho para o 1º Aniversário
</p>
</footer>
);
}

View File

@@ -0,0 +1,36 @@
export function InvitationHero({
displayName,
}: {
displayName: string;
}) {
const patternSvg = "data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23f59e0b' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E";
return (
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-b from-amber-100 via-amber-50 to-white p-8 sm:p-12 text-center">
<div className="absolute inset-0 opacity-50" style={{ backgroundImage: `url('${patternSvg}')` }} />
<div className="absolute top-4 right-4 w-20 h-20 bg-gradient-to-br from-amber-400 to-orange-500 rounded-full opacity-20 blur-2xl" />
<div className="absolute bottom-4 left-4 w-16 h-16 bg-gradient-to-br from-emerald-400 to-teal-500 rounded-full opacity-20 blur-2xl" />
<div className="relative z-10">
<div className="inline-flex items-center gap-2 bg-amber-100/80 backdrop-blur-sm rounded-full px-4 py-2 mb-6">
<span className="text-2xl">🦁</span>
<span className="text-amber-800 font-medium text-sm">Safari Party</span>
</div>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-amber-900 leading-tight mb-4">
{displayName}
</h1>
<p className="text-xl sm:text-2xl text-amber-700 font-medium max-w-md mx-auto">
esperamos vocês para celebrar!
</p>
<div className="mt-8 flex items-center justify-center gap-4">
<div className="w-24 h-px bg-gradient-to-r from-transparent via-amber-400 to-transparent" />
<span className="text-3xl">🌿</span>
<div className="w-24 h-px bg-gradient-to-r from-transparent via-amber-400 to-transparent" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { InvitationHero } from "./InvitationHero";
import { EventDetails } from "./EventDetails";
import { GuestList } from "./GuestList";
import { RsvpSelector } from "./RsvpSelector";
import { ConfirmationSuccess } from "./ConfirmationSuccess";
import { InvitationFooter } from "./InvitationFooter";
import { submitRsvp } from "@/app/r/[token]/actions";
import type { Invitation, Guest } from "@/types/invitation";
export function InvitationView({ invitation }: { invitation: Invitation }) {
const [guests, setGuests] = useState<Guest[]>(invitation.guests);
const [showSuccess, setShowSuccess] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(false);
const [closed, setClosed] = useState(invitation.rsvpClosed);
const hasChanges = guests.some((g) => g.status !== "pending");
const handleGuestToggle = (guestId: string, status: "confirmed" | "declined") => {
setGuests((prev) =>
prev.map((g) =>
g.id === guestId ? { ...g, status: g.status === status ? "pending" : status } : g
)
);
};
const handleSubmit = async () => {
setSubmitting(true);
setSubmitError(false);
try {
const result = await submitRsvp({
token: invitation.token,
responses: guests
.filter((g) => g.status !== "pending")
.map((g) => ({ guestId: g.id, status: g.status as "confirmed" | "declined" })),
});
if (result.success) {
setShowSuccess(true);
} else if (result.closed) {
// O prazo acabou com a página aberta: pendentes passam a ausentes
setClosed(true);
setGuests((prev) => prev.map((g) => (g.status === "pending" ? { ...g, status: "declined" } : g)));
} else {
setSubmitError(true);
}
} catch (err) {
console.error("Erro ao enviar RSVP:", err);
setSubmitError(true);
} finally {
setSubmitting(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-b from-amber-50 via-white to-emerald-50 py-8 px-4 sm:px-6 lg:px-8">
<main className="max-w-2xl mx-auto space-y-6">
<InvitationHero displayName={invitation.displayName} />
<EventDetails event={invitation.event} />
<GuestList guests={guests} onGuestToggle={handleGuestToggle} submitted={showSuccess} locked={closed} />
<RsvpSelector
guests={guests}
onSubmit={handleSubmit}
submitting={submitting}
hasChanges={hasChanges}
deadline={invitation.event.rsvpDeadline}
closed={closed}
/>
{submitError && (
<p className="text-center text-sm text-rose-600" role="alert">
Não foi possível enviar sua resposta. Tente novamente.
</p>
)}
<InvitationFooter />
</main>
{showSuccess && (
<ConfirmationSuccess
guests={guests}
displayName={invitation.displayName}
onClose={() => setShowSuccess(false)}
/>
)}
<style jsx>{`
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-scale-in {
animation: scale-in 0.3s ease-out;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { Button } from "@/components/ui";
interface RsvpSelectorProps {
guests: { id: string; name: string; status: "pending" | "confirmed" | "declined" }[];
onSubmit: () => void;
submitting: boolean;
hasChanges: boolean;
deadline?: string;
closed?: boolean;
}
export function RsvpSelector({ guests, onSubmit, submitting, hasChanges, deadline, closed }: RsvpSelectorProps) {
const confirmedCount = guests.filter((g) => g.status === "confirmed").length;
const declinedCount = guests.filter((g) => g.status === "declined").length;
const pendingCount = guests.filter((g) => g.status === "pending").length;
const canSubmit = (confirmedCount + declinedCount) > 0;
return (
<section className="space-y-4 pt-4 border-t border-amber-200">
<h2 className="text-2xl font-bold text-amber-900 text-center">Confirmação de Presença</h2>
<div className="grid grid-cols-3 gap-3 text-center">
<div className="bg-emerald-50 rounded-xl p-4 border border-emerald-100">
<div className="text-3xl font-bold text-emerald-700">{confirmedCount}</div>
<div className="text-xs text-emerald-600 font-medium">Presentes</div>
</div>
<div className="bg-rose-50 rounded-xl p-4 border border-rose-100">
<div className="text-3xl font-bold text-rose-700">{declinedCount}</div>
<div className="text-xs text-rose-600 font-medium">Ausentes</div>
</div>
<div className="bg-amber-50 rounded-xl p-4 border border-amber-100">
<div className="text-3xl font-bold text-amber-700">{pendingCount}</div>
<div className="text-xs text-amber-600 font-medium">Pendentes</div>
</div>
</div>
{closed ? (
<p className="text-center text-rose-700 bg-rose-50 border border-rose-100 rounded-xl p-4 text-sm font-medium">
🔒 O prazo para confirmar presença terminou{deadline && ` em ${deadline}`}. O convite não aceita mais
alterações.
</p>
) : (
<>
{deadline && (
<p className="text-center text-amber-700 text-sm font-medium">⏰ Confirme até {deadline}</p>
)}
{!hasChanges && (
<p className="text-center text-amber-600 text-sm">
Selecione a presença dos convidados acima para ativar o botão
</p>
)}
<Button
onClick={onSubmit}
disabled={!canSubmit || submitting}
size="lg"
className="w-full"
variant="primary"
>
{submitting ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Enviando...
</>
) : (
"Confirmar Presença"
)}
</Button>
{canSubmit && !submitting && (
<p className="text-center text-xs text-amber-500">
Você pode alterar as seleções antes de enviar
</p>
)}
</>
)}
</section>
);
}

View File

@@ -0,0 +1,7 @@
export { InvitationHero } from "./InvitationHero";
export { EventDetails } from "./EventDetails";
export { GuestList } from "./GuestList";
export { RsvpSelector } from "./RsvpSelector";
export { ConfirmationSuccess } from "./ConfirmationSuccess";
export { InvitationFooter } from "./InvitationFooter";
export { InvitationView } from "./InvitationView";

View File

@@ -0,0 +1,47 @@
import * as React from "react";
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "outline";
size?: "sm" | "md" | "lg";
}) {
const { children, className = "", variant = "primary", size = "md", disabled = false, ...rest } = props;
const baseStyles =
"inline-flex items-center justify-center font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed";
const variants = {
primary: "bg-amber-500 text-white hover:bg-amber-600 focus:ring-amber-500 shadow-lg shadow-amber-500/30",
secondary: "bg-emerald-500 text-white hover:bg-emerald-600 focus:ring-emerald-500 shadow-lg shadow-emerald-500/30",
outline: "border-2 border-amber-500 text-amber-700 hover:bg-amber-50 focus:ring-amber-500",
};
const sizes = {
sm: "px-4 py-2 text-sm",
md: "px-6 py-3 text-base",
lg: "px-8 py-4 text-lg",
};
const combinedClassName = [baseStyles, variants[variant], sizes[size], className].filter(Boolean).join(" ");
return React.createElement(
"button",
{
className: combinedClassName,
disabled,
...rest,
},
children
);
}
export function Card(props: React.HTMLAttributes<HTMLDivElement>) {
const { children, className = "", ...rest } = props;
const cardClassName = ["bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-amber-100 p-6", className].filter(Boolean).join(" ");
return React.createElement("div", { className: cardClassName, ...rest }, children);
}
export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
const { className = "", ...rest } = props;
const inputClassName = ["w-full px-4 py-3 rounded-xl border-2 border-amber-200 focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 transition-colors", className].filter(Boolean).join(" ");
return React.createElement("input", { className: inputClassName, ...rest });
}

23
src/db/index.ts Normal file
View File

@@ -0,0 +1,23 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
// Reaproveita o pool entre hot reloads do `next dev` para não esgotar conexões
const globalForDb = globalThis as unknown as { pgPool?: Pool };
function createPool(): Pool {
if (!process.env.DATABASE_URL) {
console.error("DATABASE_URL não definida. Copie .env.example para .env e configure a conexão.");
}
// O Pool só conecta na primeira consulta, então importar este módulo no `next build` é seguro
return new Pool({ connectionString: process.env.DATABASE_URL });
}
const pool = globalForDb.pgPool ?? createPool();
if (process.env.NODE_ENV !== "production") {
globalForDb.pgPool = pool;
}
export const db = drizzle(pool, { schema });
export { pool };

53
src/db/schema.ts Normal file
View File

@@ -0,0 +1,53 @@
import { relations } from "drizzle-orm";
import { date, integer, pgEnum, pgTable, text, time, timestamp, uuid } from "drizzle-orm/pg-core";
export const guestTypeEnum = pgEnum("guest_type", ["adult", "teen", "child"]);
export const rsvpStatusEnum = pgEnum("rsvp_status", ["pending", "confirmed", "declined"]);
export const events = pgTable("events", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
date: date("date").notNull(),
time: time("time").notNull(),
venue: text("venue").notNull(),
address: text("address").notNull(),
// Último dia para confirmar presença; depois disso os pendentes contam como ausentes
rsvpDeadline: date("rsvp_deadline"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const invitations = pgTable("invitations", {
id: uuid("id").primaryKey().defaultRandom(),
eventId: uuid("event_id")
.notNull()
.references(() => events.id, { onDelete: "cascade" }),
token: text("token").notNull().unique(),
displayName: text("display_name").notNull(),
respondedAt: timestamp("responded_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const guests = pgTable("guests", {
id: uuid("id").primaryKey().defaultRandom(),
invitationId: uuid("invitation_id")
.notNull()
.references(() => invitations.id, { onDelete: "cascade" }),
name: text("name").notNull(),
type: guestTypeEnum("type").notNull(),
status: rsvpStatusEnum("status").notNull().default("pending"),
sortOrder: integer("sort_order").notNull().default(0),
respondedAt: timestamp("responded_at", { withTimezone: true }),
});
export const eventsRelations = relations(events, ({ many }) => ({
invitations: many(invitations),
}));
export const invitationsRelations = relations(invitations, ({ one, many }) => ({
event: one(events, { fields: [invitations.eventId], references: [events.id] }),
guests: many(guests),
}));
export const guestsRelations = relations(guests, ({ one }) => ({
invitation: one(invitations, { fields: [guests.invitationId], references: [invitations.id] }),
}));

76
src/db/seed.ts Normal file
View File

@@ -0,0 +1,76 @@
import { eq } from "drizzle-orm";
import { db, pool } from "./index";
import { events, guests, invitations } from "./schema";
// Dados da POC, usados para popular um banco novo
const seedData = [
{
token: "abc123",
displayName: "Família Silva",
event: {
name: "1º Aniversário do Arthur",
date: "2026-11-15",
time: "16:00",
venue: "Espaço Festa",
address: "Belo Horizonte - MG",
},
guests: [
{ name: "João", type: "adult" },
{ name: "Maria", type: "adult" },
{ name: "Pedro", type: "child" },
],
},
{
token: "xyz789",
displayName: "Família Santos",
event: {
name: "1º Aniversário da Alice",
date: "2026-12-20",
time: "15:00",
venue: "Buffet Kids",
address: "São Paulo - SP",
},
guests: [
{ name: "Carlos", type: "adult" },
{ name: "Ana", type: "adult" },
{ name: "Lucas", type: "child" },
{ name: "Julia", type: "child" },
],
},
] as const;
async function main() {
for (const item of seedData) {
const existing = await db.query.invitations.findFirst({
where: eq(invitations.token, item.token),
});
if (existing) {
console.log(`Convite ${item.token} já existe, pulando.`);
continue;
}
await db.transaction(async (tx) => {
const [event] = await tx.insert(events).values(item.event).returning();
const [invitation] = await tx
.insert(invitations)
.values({ eventId: event.id, token: item.token, displayName: item.displayName })
.returning();
await tx.insert(guests).values(
item.guests.map((g, index) => ({
invitationId: invitation.id,
name: g.name,
type: g.type,
sortOrder: index,
}))
);
});
console.log(`Convite ${item.token} criado.`);
}
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => pool.end());

70
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,70 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
const SESSION_COOKIE = "organizer_session";
const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 dias
function getSecret(): string {
const secret = process.env.SESSION_SECRET;
if (!secret || secret.length < 32) {
throw new Error("SESSION_SECRET não definida (mínimo 32 caracteres).");
}
return secret;
}
function sign(value: string): string {
return createHmac("sha256", getSecret()).update(value).digest("base64url");
}
function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
}
// Compara via HMAC para que o tempo não dependa do tamanho da senha
export function checkOrganizerPassword(password: string): boolean {
const expected = process.env.ORGANIZER_PASSWORD;
if (!expected) {
throw new Error("ORGANIZER_PASSWORD não definida.");
}
return safeEqual(sign(`pwd:${password}`), sign(`pwd:${expected}`));
}
export async function createSession(): Promise<void> {
const expiresAt = Date.now() + SESSION_DURATION_MS;
const payload = String(expiresAt);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, `${payload}.${sign(payload)}`, {
httpOnly: true,
// Atrás do Cloudflare (HTTPS) defina COOKIE_SECURE=true; acesso direto por IP via HTTP precisa de false
secure: process.env.COOKIE_SECURE === "true",
sameSite: "lax",
path: "/",
expires: new Date(expiresAt),
});
}
export async function deleteSession(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE);
}
export async function isOrganizer(): Promise<boolean> {
const cookieStore = await cookies();
const value = cookieStore.get(SESSION_COOKIE)?.value;
if (!value) return false;
const [payload, signature] = value.split(".");
if (!payload || !signature || !safeEqual(signature, sign(payload))) return false;
return Number(payload) > Date.now();
}
// Usar em toda página e Server Action do organizador
export async function requireOrganizer(): Promise<void> {
if (!(await isOrganizer())) {
redirect("/admin/login");
}
}

63
src/lib/invitation.ts Normal file
View File

@@ -0,0 +1,63 @@
import { cache } from "react";
import { asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { guests, invitations } from "@/db/schema";
import { effectiveStatus, isRsvpClosed } from "@/lib/rsvp";
import type { Invitation } from "@/types/invitation";
type InvitationRow = NonNullable<Awaited<ReturnType<typeof findInvitationRow>>>;
const dateFormatter = new Intl.DateTimeFormat("pt-BR", {
day: "numeric",
month: "long",
year: "numeric",
timeZone: "UTC",
});
// "2026-11-15" -> "15 de novembro de 2026"
function formatEventDate(isoDate: string): string {
return dateFormatter.format(new Date(`${isoDate}T00:00:00Z`));
}
// "16:00:00" -> "16:00"
function formatEventTime(time: string): string {
return time.slice(0, 5);
}
function findInvitationRow(token: string) {
return db.query.invitations.findFirst({
where: eq(invitations.token, token),
with: {
event: true,
guests: { orderBy: [asc(guests.sortOrder), asc(guests.name)] },
},
});
}
function toInvitation(row: InvitationRow): Invitation {
const rsvpClosed = isRsvpClosed(row.event.rsvpDeadline);
return {
token: row.token,
displayName: row.displayName,
event: {
name: row.event.name,
date: formatEventDate(row.event.date),
time: formatEventTime(row.event.time),
venue: row.event.venue,
address: row.event.address,
rsvpDeadline: row.event.rsvpDeadline ? formatEventDate(row.event.rsvpDeadline) : undefined,
},
rsvpClosed,
guests: row.guests.map((g) => ({
id: g.id,
name: g.name,
type: g.type,
status: effectiveStatus(g.status, rsvpClosed),
})),
};
}
export const getInvitationByToken = cache(async (token: string): Promise<Invitation | undefined> => {
const row = await findInvitationRow(token);
return row ? toInvitation(row) : undefined;
});

125
src/lib/organizer.ts Normal file
View File

@@ -0,0 +1,125 @@
import { asc, desc, eq } from "drizzle-orm";
import { db } from "@/db";
import { events, guests, invitations } from "@/db/schema";
import { effectiveStatus, isRsvpClosed } from "@/lib/rsvp";
import type { GuestType, RsvpStatus } from "@/types/invitation";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Evita erro do Postgres ao consultar uuid com texto inválido vindo da URL
export function isUuid(value: unknown): value is string {
return typeof value === "string" && UUID_RE.test(value);
}
export interface RsvpStats {
confirmed: number;
declined: number;
pending: number;
confirmedAdults: number;
confirmedTeens: number;
confirmedChildren: number;
}
const confirmedKey = { adult: "confirmedAdults", teen: "confirmedTeens", child: "confirmedChildren" } as const;
function computeStats(list: { type: GuestType; status: RsvpStatus }[]): RsvpStats {
const stats: RsvpStats = {
confirmed: 0,
declined: 0,
pending: 0,
confirmedAdults: 0,
confirmedTeens: 0,
confirmedChildren: 0,
};
for (const g of list) {
stats[g.status]++;
if (g.status === "confirmed") stats[confirmedKey[g.type]]++;
}
return stats;
}
export async function listEventsWithStats() {
const rows = await db.query.events.findMany({
orderBy: [desc(events.date)],
with: {
invitations: { columns: { id: true }, with: { guests: { columns: { type: true, status: true } } } },
},
});
return rows.map((event) => {
const rsvpClosed = isRsvpClosed(event.rsvpDeadline);
return {
id: event.id,
name: event.name,
date: event.date,
time: event.time.slice(0, 5),
venue: event.venue,
rsvpDeadline: event.rsvpDeadline,
rsvpClosed,
invitationCount: event.invitations.length,
stats: computeStats(
event.invitations.flatMap((inv) =>
inv.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed) }))
)
),
};
});
}
export async function getEventWithInvitations(eventId: string) {
if (!isUuid(eventId)) return undefined;
const event = await db.query.events.findFirst({
where: eq(events.id, eventId),
with: {
invitations: {
orderBy: [asc(invitations.displayName)],
with: { guests: { orderBy: [asc(guests.sortOrder), asc(guests.name)] } },
},
},
});
if (!event) return undefined;
const rsvpClosed = isRsvpClosed(event.rsvpDeadline);
const invitationList = event.invitations.map((inv) => {
const guestList = inv.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed) }));
return { ...inv, guests: guestList, stats: computeStats(guestList) };
});
return {
...event,
time: event.time.slice(0, 5),
rsvpClosed,
stats: computeStats(invitationList.flatMap((inv) => inv.guests)),
invitations: invitationList,
};
}
export async function getEvent(eventId: string) {
if (!isUuid(eventId)) return undefined;
const event = await db.query.events.findFirst({ where: eq(events.id, eventId) });
return event ? { ...event, time: event.time.slice(0, 5) } : undefined;
}
export async function getInvitationForEdit(invitationId: string) {
if (!isUuid(invitationId)) return undefined;
const invitation = await db.query.invitations.findFirst({
where: eq(invitations.id, invitationId),
with: {
event: { columns: { id: true, name: true, rsvpDeadline: true } },
guests: { orderBy: [asc(guests.sortOrder), asc(guests.name)] },
},
});
if (!invitation) return undefined;
const rsvpClosed = isRsvpClosed(invitation.event.rsvpDeadline);
return {
...invitation,
guests: invitation.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed) })),
};
}
// "2026-11-15" -> "15/11/2026"
export function formatShortDate(isoDate: string): string {
const [y, m, d] = isoDate.split("-");
return `${d}/${m}/${y}`;
}

14
src/lib/rsvp.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { RsvpStatus } from "@/types/invitation";
// en-CA formata como "2026-11-15", comparável direto com a coluna date
const todayFormatter = new Intl.DateTimeFormat("en-CA", { timeZone: "America/Sao_Paulo" });
// O prazo vale até o fim do dia informado, no horário de Brasília
export function isRsvpClosed(deadline: string | null, now = new Date()): boolean {
return deadline !== null && todayFormatter.format(now) > deadline;
}
// Encerrado o prazo, quem não respondeu conta como ausente
export function effectiveStatus(status: RsvpStatus, closed: boolean): RsvpStatus {
return closed && status === "pending" ? "declined" : status;
}

35
src/types/invitation.ts Normal file
View File

@@ -0,0 +1,35 @@
export type GuestType = "adult" | "teen" | "child";
export type RsvpStatus = "pending" | "confirmed" | "declined";
export interface Guest {
id: string;
name: string;
type: GuestType;
status: RsvpStatus;
}
export interface InvitationEvent {
name: string;
date: string;
time: string;
venue: string;
address: string;
rsvpDeadline?: string;
}
export interface Invitation {
token: string;
event: InvitationEvent;
displayName: string;
guests: Guest[];
rsvpClosed: boolean;
}
export interface RsvpSubmission {
token: string;
responses: {
guestId: string;
status: "confirmed" | "declined";
}[];
}