projeto base completo
This commit is contained in:
35
src/app/admin/(painel)/convites/[id]/editar/page.tsx
Normal file
35
src/app/admin/(painel)/convites/[id]/editar/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/app/admin/(painel)/eventos/[id]/convites/novo/page.tsx
Normal file
28
src/app/admin/(painel)/eventos/[id]/convites/novo/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/app/admin/(painel)/eventos/[id]/editar/page.tsx
Normal file
43
src/app/admin/(painel)/eventos/[id]/editar/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
src/app/admin/(painel)/eventos/[id]/page.tsx
Normal file
147
src/app/admin/(painel)/eventos/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/admin/(painel)/eventos/novo/page.tsx
Normal file
23
src/app/admin/(painel)/eventos/novo/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/app/admin/(painel)/layout.tsx
Normal file
25
src/app/admin/(painel)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/app/admin/(painel)/page.tsx
Normal file
54
src/app/admin/(painel)/page.tsx
Normal 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
235
src/app/admin/actions.ts
Normal 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}`);
|
||||
}
|
||||
22
src/app/admin/auth-actions.ts
Normal file
22
src/app/admin/auth-actions.ts
Normal 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");
|
||||
}
|
||||
24
src/app/admin/login/page.tsx
Normal file
24
src/app/admin/login/page.tsx
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
105
src/app/globals.css
Normal file
105
src/app/globals.css
Normal 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
59
src/app/layout.tsx
Normal 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
25
src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/app/r/[token]/actions.ts
Normal file
59
src/app/r/[token]/actions.ts
Normal 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 };
|
||||
}
|
||||
15
src/app/r/[token]/loading.tsx
Normal file
15
src/app/r/[token]/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/app/r/[token]/not-found.tsx
Normal file
27
src/app/r/[token]/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/app/r/[token]/page.tsx
Normal file
21
src/app/r/[token]/page.tsx
Normal 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} />;
|
||||
}
|
||||
41
src/components/admin/ConfirmActionButton.tsx
Normal file
41
src/components/admin/ConfirmActionButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/components/admin/EventForm.tsx
Normal file
64
src/components/admin/EventForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
src/components/admin/InvitationForm.tsx
Normal file
123
src/components/admin/InvitationForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/admin/LoginForm.tsx
Normal file
26
src/components/admin/LoginForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
src/components/admin/ShareInvitation.tsx
Normal file
68
src/components/admin/ShareInvitation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/components/admin/StatsBar.tsx
Normal file
25
src/components/admin/StatsBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/components/admin/index.ts
Normal file
6
src/components/admin/index.ts
Normal 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";
|
||||
83
src/components/invitation/ConfirmationSuccess.tsx
Normal file
83
src/components/invitation/ConfirmationSuccess.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/components/invitation/EventDetails.tsx
Normal file
38
src/components/invitation/EventDetails.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
src/components/invitation/GuestList.tsx
Normal file
122
src/components/invitation/GuestList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/components/invitation/InvitationFooter.tsx
Normal file
17
src/components/invitation/InvitationFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/invitation/InvitationHero.tsx
Normal file
36
src/components/invitation/InvitationHero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/components/invitation/InvitationView.tsx
Normal file
104
src/components/invitation/InvitationView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
src/components/invitation/RsvpSelector.tsx
Normal file
84
src/components/invitation/RsvpSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
src/components/invitation/index.ts
Normal file
7
src/components/invitation/index.ts
Normal 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";
|
||||
47
src/components/ui/index.ts
Normal file
47
src/components/ui/index.ts
Normal 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
23
src/db/index.ts
Normal 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
53
src/db/schema.ts
Normal 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
76
src/db/seed.ts
Normal 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
70
src/lib/auth.ts
Normal 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
63
src/lib/invitation.ts
Normal 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
125
src/lib/organizer.ts
Normal 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
14
src/lib/rsvp.ts
Normal 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
35
src/types/invitation.ts
Normal 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";
|
||||
}[];
|
||||
}
|
||||
Reference in New Issue
Block a user