From 0b4d40544e9ca811d1ef8fac369354dedc4a4471 Mon Sep 17 00:00:00 2001
From: Bruno Santos
Date: Sun, 1 Nov 2026 23:03:23 -0300
Subject: [PATCH] ajuste para bloqueio individual dos convites
---
MANUAL-USUARIO.md | 23 +-
README.md | 3 +
drizzle/0008_invitation_blocked.sql | 1 +
drizzle/meta/0008_snapshot.json | 499 +++++++++++++++++++
drizzle/meta/_journal.json | 7 +
src/app/admin/(painel)/eventos/[id]/page.tsx | 18 +-
src/app/admin/actions.ts | 18 +
src/app/r/[token]/actions.ts | 12 +-
src/components/admin/EventForm.tsx | 34 +-
src/components/invitation/InvitationHero.tsx | 12 +
src/components/invitation/InvitationView.tsx | 26 +-
src/db/schema.ts | 16 +-
src/lib/invitation.ts | 7 +-
src/lib/organizer.ts | 11 +-
14 files changed, 648 insertions(+), 39 deletions(-)
create mode 100644 drizzle/0008_invitation_blocked.sql
create mode 100644 drizzle/meta/0008_snapshot.json
diff --git a/MANUAL-USUARIO.md b/MANUAL-USUARIO.md
index 8c1f570..93a597b 100644
--- a/MANUAL-USUARIO.md
+++ b/MANUAL-USUARIO.md
@@ -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
diff --git a/README.md b/README.md
index 6e2ace2..bc82ab0 100644
--- a/README.md
+++ b/README.md
@@ -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/`: 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`.
diff --git a/drizzle/0008_invitation_blocked.sql b/drizzle/0008_invitation_blocked.sql
new file mode 100644
index 0000000..a9da391
--- /dev/null
+++ b/drizzle/0008_invitation_blocked.sql
@@ -0,0 +1 @@
+ALTER TABLE "invitations" ADD COLUMN "blocked" boolean DEFAULT false NOT NULL;
\ No newline at end of file
diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json
new file mode 100644
index 0000000..351a007
--- /dev/null
+++ b/drizzle/meta/0008_snapshot.json
@@ -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": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 0c9b7af..531d53b 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -57,6 +57,13 @@
"when": 1792548033540,
"tag": "0007_event_background",
"breakpoints": true
+ },
+ {
+ "idx": 8,
+ "version": "7",
+ "when": 1793414801713,
+ "tag": "0008_invitation_blocked",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/app/admin/(painel)/eventos/[id]/page.tsx b/src/app/admin/(painel)/eventos/[id]/page.tsx
index 702c7ce..6884990 100644
--- a/src/app/admin/(painel)/eventos/[id]/page.tsx
+++ b/src/app/admin/(painel)/eventos/[id]/page.tsx
@@ -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)}>}
+ {inv.blocked && (
+
+ Bloqueado — pendentes contam como
+ ausentes
+
+ )}
Abrir convite
+
{inv.respondedAt && (
{
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 {
+ 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 {
await requireOrganizer();
diff --git a/src/app/r/[token]/actions.ts b/src/app/r/[token]/actions.ts
index edda49c..99d64f6 100644
--- a/src/app/r/[token]/actions.ts
+++ b/src/app/r/[token]/actions.ts
@@ -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);
diff --git a/src/components/admin/EventForm.tsx b/src/components/admin/EventForm.tsx
index 9f313a4..4749484 100644
--- a/src/components/admin/EventForm.tsx
+++ b/src/components/admin/EventForm.tsx
@@ -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"
/>
- 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.
{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 && }
{defaults?.backgroundUrl && !newBackground && (
-
- setRemoveBackground(e.target.checked)}
- />
- Remover a imagem de fundo atual
-
+
+ {removeBackground ? (
+ <>
+ A imagem será removida ao salvar.
+ setRemoveBackground(false)}
+ className="text-sm font-medium text-indigo-600 hover:text-indigo-800"
+ >
+ Desfazer
+
+ >
+ ) : (
+ 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
+
+ )}
+
)}
{/* Prévia: as classes amber-* abaixo assumem as cores do tema escolhido */}
diff --git a/src/components/invitation/InvitationHero.tsx b/src/components/invitation/InvitationHero.tsx
index 95987dd..7f1de88 100644
--- a/src/components/invitation/InvitationHero.tsx
+++ b/src/components/invitation/InvitationHero.tsx
@@ -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 (
+ {backgroundUrl && (
+ // Foto cobrindo o card, com um véu claro por cima para o texto continuar legível
+
+ )}
diff --git a/src/components/invitation/InvitationView.tsx b/src/components/invitation/InvitationView.tsx
index 392d42f..28e7053 100644
--- a/src/components/invitation/InvitationView.tsx
+++ b/src/components/invitation/InvitationView.tsx
@@ -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 (
- {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
-
+
- )}
-
-
{submitError && (
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 04493af..034299a 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -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"),
diff --git a/src/lib/invitation.ts b/src/lib/invitation.ts
index a5fa243..0a6f6bc 100644
--- a/src/lib/invitation.ts
+++ b/src/lib/invitation.ts
@@ -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,
diff --git a/src/lib/organizer.ts b/src/lib/organizer.ts
index 19e8336..badebcd 100644
--- a/src/lib/organizer.ts
+++ b/src/lib/organizer.ts
@@ -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) })),