ajuste para bloqueio individual dos convites

This commit is contained in:
2026-11-01 23:03:23 -03:00
parent 2df0be7b6b
commit 0b4d40544e
14 changed files with 648 additions and 39 deletions

View File

@@ -14,7 +14,7 @@ Este manual explica como **criar eventos, montar e enviar convites e acompanhar
- 2.4 [Criar um convite](#24-criar-um-convite) · 2.5 [Enviar o convite](#25-enviar-o-convite) · 2.5.1 [Personalizar a mensagem do WhatsApp](#251-personalizar-a-mensagem-do-whatsapp)
- 2.6 [Acompanhar as respostas](#26-acompanhar-as-respostas) · 2.7 [Editar um convite](#27-editar-um-convite) · 2.8 [Zerar as respostas](#28-zerar-as-respostas-de-um-convite)
- 2.9 [Excluir um convite](#29-excluir-um-convite) · 2.10 [Editar ou excluir um evento](#210-editar-ou-excluir-um-evento) · 2.11 [PDFs dos convites](#211-pdfs-dos-convites)
- 2.12 [Prazo de confirmação](#212-prazo-de-confirmação-como-funciona)
- 2.12 [Prazo de confirmação](#212-prazo-de-confirmação-como-funciona) · 2.13 [Bloquear um convite](#213-bloquear-um-convite)
3. [Experiência do convidado](#3-experiência-do-convidado)
4. [Perguntas frequentes](#4-perguntas-frequentes)
5. [Quando falar com o administrador](#5-quando-falar-com-o-administrador)
@@ -91,7 +91,7 @@ Clique em um evento para abrir os detalhes.
- **Prazo para confirmar** (opcional) — último dia para responder
- **Tema do convite** — a prévia logo abaixo mostra as cores e os ícones escolhidos
- **Texto do selo** e **Texto do rodapé** (opcionais) — em branco, usam o texto padrão do tema
- **Imagem de fundo** (opcional) — JPG, PNG ou WebP, até **3 MB**. Aparece atrás do convite, com um véu claro por cima para o texto continuar legível, e também no card de pré-visualização do WhatsApp. Para tirar a imagem, marque **Remover a imagem de fundo atual** ao editar o evento. Prefira fotos leves: no WhatsApp, cards com imagem muito pesada podem aparecer sem foto
- **Imagem de fundo** (opcional) — JPG, PNG ou WebP, até **3 MB**. Aparece no topo do convite (no cartão com o nome da família), com um véu claro por cima para o texto continuar legível, e também no card de pré-visualização do WhatsApp. Para tirar a imagem, ao editar o evento, clique em **Remover imagem de fundo** e depois em **Salvar alterações**. Prefira fotos leves: no WhatsApp, cards com imagem muito pesada podem aparecer sem foto
- **Mensagem do WhatsApp** (opcional) — texto enviado pelo botão **WhatsApp** de cada convite. Em branco, usa a mensagem padrão (ver [2.5.1](#251-personalizar-a-mensagem-do-whatsapp))
3. Clique em **Criar evento**. Você será levado à página do evento.
@@ -413,6 +413,25 @@ Pontos importantes:
- **Prazo vencido com a página aberta:** se o convidado deixar a página aberta, o prazo passar e ele tentar enviar, a resposta é recusada. A página passa para o modo encerrado e mostra os pendentes como ausentes.
- **Zerar respostas** depois do prazo volta todos para pendente no sistema, mas eles continuam aparecendo como ausentes enquanto o prazo estiver encerrado.
### 2.13 Bloquear um convite
Use para encerrar as respostas de **um convite só**, sem mudar o prazo do evento nem os outros convites.
1. Na página do evento, clique em **Bloquear** no convite.
2. Confirme. O convite passa a mostrar 🔒 **"Bloqueado — pendentes contam como ausentes"**.
Enquanto o convite estiver bloqueado:
- o convidado vê "🔒 O prazo para confirmar presença terminou. O convite não aceita mais alterações." Os botões ✓/✗ e **Confirmar Presença** somem;
- se o prazo do evento também já tiver passado, a mensagem é a do prazo, com a data (2.12);
- quem não respondeu aparece como **Ausente**, como acontece depois do prazo: no convite, no painel, na página do evento, na edição do convite e no CSV.
Pontos importantes:
- **Nada é apagado.** Clique em **Desbloquear** para reverter: os pendentes voltam a aparecer como pendentes e o convite volta a aceitar respostas, se o prazo do evento permitir.
- **Bloqueio com a página aberta:** se o convidado deixar a página aberta e tentar enviar depois do bloqueio, a resposta é recusada e a página passa para o modo encerrado.
- **Mensagem do WhatsApp:** o botão continua disponível e o texto não muda.
---
## 3. Experiência do convidado

View File

@@ -132,6 +132,7 @@ Definido em `src/db/schema.ts`. As migrations ficam em `drizzle/` e são aplicad
| `token` | text, único | Código do link `/r/<token>`: 6 bytes aleatórios em base64url (8 caracteres) |
| `display_name` | text | Nome exibido ("Família Silva") |
| `responded_at` | timestamptz | Último envio de resposta pela família |
| `blocked` | boolean, padrão `false` | Bloqueio individual: encerra as respostas deste convite (ver [Prazo de confirmação](#prazo-de-confirmação)) |
| `pdf_status` | enum | `not_generated`, `generating`, `generated` ou `error` |
| `pdf_template_version` | integer | Versão do modelo usada na última geração **bem-sucedida** |
| `pdf_generated_at` | timestamptz | Data da última geração bem-sucedida (não nulo = existe arquivo para baixar) |
@@ -238,6 +239,8 @@ Para mudar o banco: altere `schema.ts`, rode `npm run db:generate` e confira o S
O banco não é alterado. Todas as telas e o CSV usam `effectiveStatus`, então adiar ou apagar o prazo reverte a exibição.
**Bloqueio individual:** `invitations.blocked` tem o mesmo efeito do prazo vencido, mas só naquele convite (`setInvitationBlocked` em `src/app/admin/actions.ts`). Se só o bloqueio encerrou o convite, a mensagem do convidado sai sem a data; com o prazo também vencido, ela mostra a data.
### Temas
`src/lib/themes.ts` define os 15 temas (paleta, selo, ícones). Os componentes do convite usam classes `amber-*` do Tailwind, e `themeStyle()` redefine as variáveis `--color-amber-*` no contêiner. Assim, trocar o tema troca a paleta inteira sem mexer nos componentes. Para criar um tema, acrescente um objeto ao array `themes`.

View File

@@ -0,0 +1 @@
ALTER TABLE "invitations" ADD COLUMN "blocked" boolean DEFAULT false NOT NULL;

View File

@@ -0,0 +1,499 @@
{
"id": "ded69e84-e2c4-46b9-93cd-b2a10a1b8656",
"prevId": "a154e70d-84be-4724-817c-e8e4dfac48f8",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.events": {
"name": "events",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"date": {
"name": "date",
"type": "date",
"primaryKey": false,
"notNull": true
},
"time": {
"name": "time",
"type": "time",
"primaryKey": false,
"notNull": true
},
"venue": {
"name": "venue",
"type": "text",
"primaryKey": false,
"notNull": true
},
"address": {
"name": "address",
"type": "text",
"primaryKey": false,
"notNull": true
},
"rsvp_deadline": {
"name": "rsvp_deadline",
"type": "date",
"primaryKey": false,
"notNull": false
},
"theme": {
"name": "theme",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'safari'"
},
"badge_text": {
"name": "badge_text",
"type": "text",
"primaryKey": false,
"notNull": false
},
"footer_text": {
"name": "footer_text",
"type": "text",
"primaryKey": false,
"notNull": false
},
"whatsapp_message": {
"name": "whatsapp_message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"background_version": {
"name": "background_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"background_type": {
"name": "background_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pdf_template_version": {
"name": "pdf_template_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"pdf_template_name": {
"name": "pdf_template_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pdf_template_updated_at": {
"name": "pdf_template_updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.guests": {
"name": "guests",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"invitation_id": {
"name": "invitation_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "guest_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "rsvp_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"responded_at": {
"name": "responded_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"guests_invitation_id_invitations_id_fk": {
"name": "guests_invitation_id_invitations_id_fk",
"tableFrom": "guests",
"tableTo": "invitations",
"columnsFrom": [
"invitation_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invitations": {
"name": "invitations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"event_id": {
"name": "event_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"responded_at": {
"name": "responded_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"blocked": {
"name": "blocked",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"pdf_status": {
"name": "pdf_status",
"type": "pdf_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'not_generated'"
},
"pdf_template_version": {
"name": "pdf_template_version",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"pdf_generated_at": {
"name": "pdf_generated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"pdf_error": {
"name": "pdf_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"invitations_event_id_events_id_fk": {
"name": "invitations_event_id_events_id_fk",
"tableFrom": "invitations",
"tableTo": "events",
"columnsFrom": [
"event_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"invitations_token_unique": {
"name": "invitations_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.organizer_sessions": {
"name": "organizer_sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.pdf_jobs": {
"name": "pdf_jobs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"event_id": {
"name": "event_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "pdf_job_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'running'"
},
"invitation_ids": {
"name": "invitation_ids",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"total": {
"name": "total",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"processed": {
"name": "processed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"failed": {
"name": "failed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"finished_at": {
"name": "finished_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"pdf_jobs_one_running_per_event": {
"name": "pdf_jobs_one_running_per_event",
"columns": [
{
"expression": "event_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"pdf_jobs\".\"status\" = 'running'",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"pdf_jobs_event_id_events_id_fk": {
"name": "pdf_jobs_event_id_events_id_fk",
"tableFrom": "pdf_jobs",
"tableTo": "events",
"columnsFrom": [
"event_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.guest_type": {
"name": "guest_type",
"schema": "public",
"values": [
"adult",
"teen",
"child"
]
},
"public.pdf_job_status": {
"name": "pdf_job_status",
"schema": "public",
"values": [
"running",
"finished",
"interrupted"
]
},
"public.pdf_status": {
"name": "pdf_status",
"schema": "public",
"values": [
"not_generated",
"generating",
"generated",
"error"
]
},
"public.rsvp_status": {
"name": "rsvp_status",
"schema": "public",
"values": [
"pending",
"confirmed",
"declined"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -57,6 +57,13 @@
"when": 1792548033540,
"tag": "0007_event_background",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1793414801713,
"tag": "0008_invitation_blocked",
"breakpoints": true
}
]
}

View File

@@ -2,7 +2,7 @@ import { AlarmClock, CalendarDays, Clock, Download, Lock, MapPin, Palette } from
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { deleteInvitation, resetInvitationRsvp } from "@/app/admin/actions";
import { deleteInvitation, resetInvitationRsvp, setInvitationBlocked } from "@/app/admin/actions";
import { uploadPdfTemplate } from "@/app/admin/pdf-actions";
import {
ConfirmActionButton,
@@ -155,6 +155,12 @@ export default async function EventPage({ params }: { params: Promise<{ id: stri
Link: /r/{inv.token}
{inv.respondedAt && <> · respondeu em {respondedFormatter.format(inv.respondedAt)}</>}
</p>
{inv.blocked && (
<p className="text-xs text-rose-700 font-medium mt-1 flex items-center gap-1">
<Lock aria-hidden="true" className="w-3.5 h-3.5 shrink-0" /> Bloqueado — pendentes contam como
ausentes
</p>
)}
</div>
<ShareInvitation
token={inv.token}
@@ -195,6 +201,16 @@ export default async function EventPage({ params }: { params: Promise<{ id: stri
>
Abrir convite
</Link>
<ConfirmActionButton
action={setInvitationBlocked.bind(null, inv.id, !inv.blocked)}
label={inv.blocked ? "Desbloquear" : "Bloquear"}
confirmMessage={
inv.blocked
? `Desbloquear o convite de "${inv.displayName}"? Ele volta a aceitar respostas, se o prazo do evento permitir.`
: `Bloquear o convite de "${inv.displayName}"? Ele deixa de aceitar respostas e os pendentes contam como ausentes.`
}
variant="neutral"
/>
{inv.respondedAt && (
<ConfirmActionButton
action={resetInvitationRsvp.bind(null, inv.id)}

View File

@@ -387,6 +387,24 @@ export async function deleteInvitation(invitationId: string): Promise<void> {
redirect(`/admin/eventos/${deleted.eventId}`);
}
// Bloqueia ou libera as respostas de um convite, sem depender do prazo do evento
export async function setInvitationBlocked(invitationId: string, blocked: boolean): Promise<void> {
await requireOrganizer();
if (!isUuid(invitationId)) return;
const [updated] = await db
.update(invitations)
.set({ blocked })
.where(eq(invitations.id, invitationId))
.returning({ eventId: invitations.eventId, token: invitations.token });
if (!updated) return;
// Os pendentes do convite mudam entre pendente e ausente, então os totais do painel também
revalidatePath("/admin");
revalidatePath(`/admin/eventos/${updated.eventId}`);
revalidatePath(`/r/${updated.token}`);
}
// Volta todos os convidados do convite para "pendente"
export async function resetInvitationRsvp(invitationId: string): Promise<void> {
await requireOrganizer();

View File

@@ -12,7 +12,9 @@ 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 }> {
export async function submitRsvp(
submission: RsvpSubmission
): Promise<{ success: boolean; closed?: boolean; blocked?: boolean }> {
const { token, responses } = submission;
if (typeof token !== "string" || !Array.isArray(responses) || responses.length === 0) {
@@ -24,14 +26,16 @@ export async function submitRsvp(submission: RsvpSubmission): Promise<{ success:
const invitation = await db.query.invitations.findFirst({
where: eq(invitations.token, token),
columns: { id: true },
columns: { id: true, blocked: true },
with: { event: { columns: { rsvpDeadline: true } } },
});
if (!invitation) {
return { success: false };
}
if (isRsvpClosed(invitation.event.rsvpDeadline)) {
return { success: false, closed: true };
const deadlinePassed = isRsvpClosed(invitation.event.rsvpDeadline);
if (deadlinePassed || invitation.blocked) {
// blocked: só o bloqueio encerrou, então a mensagem sai sem a data do prazo
return { success: false, closed: true, blocked: !deadlinePassed };
}
const guestIds = responses.map((r) => r.guestId);

View File

@@ -180,7 +180,7 @@ export function EventForm({ action, submitLabel, defaults }: EventFormProps) {
className="block w-full text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-slate-100 file:px-3 file:py-2 file:text-sm file:font-medium file:text-slate-700 hover:file:bg-slate-200"
/>
<span className="block text-xs text-slate-500 mt-1">
JPG, PNG ou WebP, até {BACKGROUND_MAX_MB} MB. Aparece atrás do convite, com um véu claro por cima, e também
JPG, PNG ou WebP, até {BACKGROUND_MAX_MB} MB. Aparece no topo do convite, com um véu claro por cima, e também
na prévia do link no WhatsApp.
</span>
{backgroundError && (
@@ -196,16 +196,30 @@ export function EventForm({ action, submitLabel, defaults }: EventFormProps) {
className="mt-2 h-32 w-full rounded-xl border border-slate-200 object-cover"
/>
)}
{removeBackground && <input type="hidden" name="removeBackground" value="on" />}
{defaults?.backgroundUrl && !newBackground && (
<label className="mt-2 flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
name="removeBackground"
checked={removeBackground}
onChange={(e) => setRemoveBackground(e.target.checked)}
/>
Remover a imagem de fundo atual
</label>
<div className="mt-2 flex flex-wrap items-center gap-3">
{removeBackground ? (
<>
<span className="text-sm text-slate-600">A imagem será removida ao salvar.</span>
<button
type="button"
onClick={() => setRemoveBackground(false)}
className="text-sm font-medium text-indigo-600 hover:text-indigo-800"
>
Desfazer
</button>
</>
) : (
<button
type="button"
onClick={() => setRemoveBackground(true)}
className="rounded-lg border border-rose-200 px-3 py-1.5 text-sm font-medium text-rose-700 hover:bg-rose-50"
>
Remover imagem de fundo
</button>
)}
</div>
)}
</div>
{/* Prévia: as classes amber-* abaixo assumem as cores do tema escolhido */}

View File

@@ -4,15 +4,27 @@ export function InvitationHero({
displayName,
theme,
badgeText,
backgroundUrl,
}: {
displayName: string;
theme: InvitationTheme;
badgeText?: string;
backgroundUrl?: 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">
{backgroundUrl && (
// Foto cobrindo o card, com um véu claro por cima para o texto continuar legível
<div
aria-hidden="true"
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: `linear-gradient(rgba(255,255,255,0.6), rgba(255,255,255,0.6)), url('${backgroundUrl}')`,
}}
/>
)}
<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-amber-600 rounded-full opacity-20 blur-2xl" />
<div className="absolute bottom-4 left-4 w-16 h-16 bg-(--theme-accent) rounded-full opacity-20 blur-2xl" />

View File

@@ -17,6 +17,7 @@ export function InvitationView({ invitation }: { invitation: Invitation }) {
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(false);
const [closed, setClosed] = useState(invitation.rsvpClosed);
const [deadline, setDeadline] = useState(invitation.event.rsvpDeadline);
const theme = getTheme(invitation.theme);
const hasChanges = guests.some((g) => g.status !== "pending");
@@ -42,8 +43,9 @@ export function InvitationView({ invitation }: { invitation: Invitation }) {
if (result.success) {
setShowSuccess(true);
} else if (result.closed) {
// O prazo acabou com a página aberta: pendentes passam a ausentes
// O prazo acabou ou o convite foi bloqueado com a página aberta: pendentes passam a ausentes
setClosed(true);
if (result.blocked) setDeadline(undefined);
setGuests((prev) => prev.map((g) => (g.status === "pending" ? { ...g, status: "declined" } : g)));
} else {
setSubmitError(true);
@@ -59,21 +61,15 @@ export function InvitationView({ invitation }: { invitation: Invitation }) {
return (
<div
style={themeStyle(theme)}
className="relative min-h-screen bg-gradient-to-b from-amber-50 via-white to-(--theme-accent-soft) py-8 px-4 sm:px-6 lg:px-8"
className="min-h-screen bg-gradient-to-b from-amber-50 via-white to-(--theme-accent-soft) py-8 px-4 sm:px-6 lg:px-8"
>
{invitation.backgroundUrl && (
// Camada fixa (e não background-attachment: fixed, que falha no iOS) com um véu claro por cima,
// para o texto continuar legível com qualquer imagem
<div
aria-hidden="true"
className="fixed inset-0 bg-cover bg-center"
style={{
backgroundImage: `linear-gradient(rgba(255,255,255,0.6), rgba(255,255,255,0.6)), url('${invitation.backgroundUrl}')`,
}}
<main className="max-w-2xl mx-auto space-y-6">
<InvitationHero
displayName={invitation.displayName}
theme={theme}
badgeText={invitation.badgeText}
backgroundUrl={invitation.backgroundUrl}
/>
)}
<main className="relative max-w-2xl mx-auto space-y-6">
<InvitationHero displayName={invitation.displayName} theme={theme} badgeText={invitation.badgeText} />
<EventDetails event={invitation.event} />
<GuestList guests={guests} onGuestToggle={handleGuestToggle} submitted={showSuccess} locked={closed} />
<RsvpSelector
@@ -81,7 +77,7 @@ export function InvitationView({ invitation }: { invitation: Invitation }) {
onSubmit={handleSubmit}
submitting={submitting}
hasChanges={hasChanges}
deadline={invitation.event.rsvpDeadline}
deadline={deadline}
closed={closed}
/>
{submitError && (

View File

@@ -1,5 +1,17 @@
import { relations, sql } from "drizzle-orm";
import { date, integer, jsonb, pgEnum, pgTable, text, time, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import {
boolean,
date,
integer,
jsonb,
pgEnum,
pgTable,
text,
time,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
export const guestTypeEnum = pgEnum("guest_type", ["adult", "teen", "child"]);
export const rsvpStatusEnum = pgEnum("rsvp_status", ["pending", "confirmed", "declined"]);
@@ -42,6 +54,8 @@ export const invitations = pgTable("invitations", {
token: text("token").notNull().unique(),
displayName: text("display_name").notNull(),
respondedAt: timestamp("responded_at", { withTimezone: true }),
// Bloqueio individual: encerra as respostas deste convite, como o prazo faz com o evento inteiro
blocked: boolean("blocked").notNull().default(false),
// PDF personalizado: versão do modelo usada na última geração bem-sucedida
pdfStatus: pdfStatusEnum("pdf_status").notNull().default("not_generated"),
pdfTemplateVersion: integer("pdf_template_version"),

View File

@@ -38,7 +38,10 @@ function findInvitationRow(token: string) {
}
function toInvitation(row: InvitationRow): Invitation {
const rsvpClosed = isRsvpClosed(row.event.rsvpDeadline);
const deadlinePassed = isRsvpClosed(row.event.rsvpDeadline);
const rsvpClosed = deadlinePassed || row.blocked;
// Bloqueado antes de o prazo vencer: a data não explica o encerramento e fica fora da mensagem
const rsvpDeadline = deadlinePassed || !row.blocked ? row.event.rsvpDeadline : null;
return {
token: row.token,
displayName: row.displayName,
@@ -48,7 +51,7 @@ function toInvitation(row: InvitationRow): Invitation {
time: formatEventTime(row.event.time),
venue: row.event.venue,
address: row.event.address,
rsvpDeadline: row.event.rsvpDeadline ? formatEventDate(row.event.rsvpDeadline) : undefined,
rsvpDeadline: rsvpDeadline ? formatEventDate(rsvpDeadline) : undefined,
},
rsvpClosed,
theme: row.event.theme,

View File

@@ -42,7 +42,10 @@ 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 } } } },
invitations: {
columns: { id: true, blocked: true },
with: { guests: { columns: { type: true, status: true } } },
},
},
});
@@ -59,7 +62,7 @@ export async function listEventsWithStats() {
invitationCount: event.invitations.length,
stats: computeStats(
event.invitations.flatMap((inv) =>
inv.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed) }))
inv.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed || inv.blocked) }))
)
),
};
@@ -81,7 +84,7 @@ export async function getEventWithInvitations(eventId: string) {
const rsvpClosed = isRsvpClosed(event.rsvpDeadline);
const invitationList = event.invitations.map((inv) => {
const guestList = inv.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed) }));
const guestList = inv.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed || inv.blocked) }));
return { ...inv, guests: guestList, stats: computeStats(guestList) };
});
@@ -111,7 +114,7 @@ export async function getInvitationForEdit(invitationId: string) {
});
if (!invitation) return undefined;
const rsvpClosed = isRsvpClosed(invitation.event.rsvpDeadline);
const rsvpClosed = isRsvpClosed(invitation.event.rsvpDeadline) || invitation.blocked;
return {
...invitation,
guests: invitation.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed) })),