ajuste para bloqueio individual dos convites
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
1
drizzle/0008_invitation_blocked.sql
Normal file
1
drizzle/0008_invitation_blocked.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "invitations" ADD COLUMN "blocked" boolean DEFAULT false NOT NULL;
|
||||
499
drizzle/meta/0008_snapshot.json
Normal file
499
drizzle/meta/0008_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,13 @@
|
||||
"when": 1792548033540,
|
||||
"tag": "0007_event_background",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1793414801713,
|
||||
"tag": "0008_invitation_blocked",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) })),
|
||||
|
||||
Reference in New Issue
Block a user