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 && ( +

+

+ )} 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 && ( - +
+ {removeBackground ? ( + <> + A imagem será removida ao salvar. + + + ) : ( + + )} +
)} {/* 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 +