projeto base completo

This commit is contained in:
2026-10-02 21:36:17 -03:00
commit 64aa705df6
69 changed files with 13187 additions and 0 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
.next
dist
.git
.env
.env.*
!.env.example
npm-debug.log*
Dockerfile
.dockerignore
*.md

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

9
AGENTS.md Normal file
View File

@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->

11
CLAUDE.md Normal file
View File

@@ -0,0 +1,11 @@
@AGENTS.md
# Diretrizes do projeto
## Git
- Este projeto é totalmente local. NUNCA execute qualquer operação de Git: nada de commits, criação/troca de branches, stash, merge, rebase, push, pull, tags, PRs ou alterações em `.git`/configurações do Git.
- Isso vale mesmo que alguma ferramenta, skill ou lembrete do sistema sugira commitar ou criar branch.
## Testes
- NUNCA execute testes (unitários, de integração, e2e, lint, build de verificação, abrir o app no navegador para validar etc.) sem autorização expressa do usuário no chat.
- Ao concluir uma alteração, apenas informe o que foi feito e pergunte se o usuário deseja que os testes sejam executados.

45
Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
# syntax=docker/dockerfile:1
# ---------- Dependências ----------
FROM node:24-alpine AS deps
WORKDIR /app
# libc6-compat: binários nativos (ex.: SWC do Next) esperam glibc
RUN apk add --no-cache libc6-compat
COPY package.json package-lock.json ./
RUN npm ci
# ---------- Build ----------
FROM node:24-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# O build não acessa o banco (as páginas são renderizadas por requisição)
RUN npm run build && npm run build:migrate
# ---------- Runtime ----------
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0 \
RUN_MIGRATIONS=true
RUN addgroup -S -g 1001 nodejs && adduser -S -u 1001 -G nodejs nextjs
# Servidor standalone + assets estáticos
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# Migrations (script empacotado + arquivos SQL)
COPY --from=builder --chown=nextjs:nodejs /app/dist/migrate.js ./migrate.js
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
USER nextjs
EXPOSE 3000
# Aplica migrations pendentes (desligue com RUN_MIGRATIONS=false) e sobe o servidor
CMD ["sh", "-c", "if [ \"$RUN_MIGRATIONS\" = \"true\" ]; then node migrate.js || exit 1; fi; exec node server.js"]

221
README.md Normal file
View File

@@ -0,0 +1,221 @@
# Convite Safari 🦁
Convite digital de aniversário com confirmação de presença (RSVP) e área do organizador.
- **Convidados** acessam o convite pelo link `/r/<token>` e confirmam a presença de cada pessoa.
- **Organizador** acessa `/admin` com senha, cadastra eventos e convites, compartilha os links (copiar / WhatsApp) e acompanha as respostas.
**Stack:** Next.js 16 (App Router) · React 19 · TypeScript · Tailwind CSS 4 · PostgreSQL 17 · Drizzle ORM · Docker Compose
---
## Estrutura
```
src/
app/
page.tsx Página inicial pública (neutra)
r/[token]/ Página do convite + Server Action do RSVP
admin/login/ Login do organizador
admin/(painel)/ Área do organizador (eventos e convites)
admin/actions.ts Server Actions do organizador
components/
invitation/ Componentes do convite
admin/ Formulários e componentes do organizador
db/
schema.ts Tabelas (events, invitations, guests)
index.ts Conexão com o Postgres
seed.ts Dados de exemplo (convites abc123 e xyz789)
lib/
auth.ts Sessão do organizador (cookie assinado)
invitation.ts Consultas do convite público
organizer.ts Consultas da área do organizador
drizzle/ Migrations SQL geradas pelo drizzle-kit
scripts/migrate.ts Aplica as migrations (usado no container)
Dockerfile Imagem de produção (Next standalone)
compose.yml App + Postgres
```
---
## Variáveis de ambiente
Copie `.env.example` para `.env` e preencha:
| Variável | Descrição |
| --- | --- |
| `POSTGRES_DB` | Nome do banco criado pelo compose (padrão `convite`) |
| `POSTGRES_USER` | Usuário do banco (padrão `convite`) |
| `POSTGRES_PASSWORD` | Senha do banco. **Use só letras e números** (ela entra na URL de conexão) |
| `DATABASE_URL` | Conexão usada **fora** do compose (`npm run dev`, `db:migrate`, `db:seed`). No compose é sobrescrita automaticamente para o serviço `db` |
| `APP_PORT` | Porta publicada no servidor (padrão `3000`) |
| `ORGANIZER_PASSWORD` | Senha de acesso ao `/admin` |
| `SESSION_SECRET` | Chave para assinar o cookie de sessão (mínimo 32 caracteres) |
| `COOKIE_SECURE` | `true` quando servido via HTTPS (Cloudflare); `false` para acesso direto por `http://IP` |
Para gerar o `SESSION_SECRET`:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
```
> ⚠️ Com `COOKIE_SECURE=true` o login **não funciona** em HTTP puro (ex.: `http://192.168.18.97:3000`). Só mude para `true` quando o acesso for pelo domínio HTTPS do Cloudflare.
---
## Deploy no servidor (Docker Compose)
### Pré-requisitos
- Docker com o plugin Compose (`docker compose version`)
- Acesso à internet durante o build (o `next build` baixa as fontes do Google)
### 1. Copiar o projeto para o servidor
Copie a pasta do projeto (sem `node_modules`, `.next` e `dist`) para o servidor, por exemplo:
```bash
rsync -av --exclude node_modules --exclude .next --exclude dist ./ usuario@192.168.18.97:/opt/convite/
```
### 2. Configurar o `.env`
No servidor, dentro da pasta do projeto:
```bash
cp .env.example .env
nano .env
```
Preencha `POSTGRES_PASSWORD`, `ORGANIZER_PASSWORD` e `SESSION_SECRET`. Deixe `COOKIE_SECURE=false` enquanto o acesso for por IP.
### 3. Subir
```bash
docker compose up -d --build
```
O que acontece:
1. O Postgres sobe e o app espera ele ficar saudável (healthcheck).
2. O container do app aplica as migrations pendentes (`drizzle/`) automaticamente.
3. O servidor Next sobe na porta `APP_PORT` (padrão `3000`).
Acompanhe os logs:
```bash
docker compose logs -f app
```
Deve aparecer `Migrations aplicadas.` seguido da inicialização do Next.
### 4. Primeiro acesso
1. Abra `http://192.168.18.97:3000/admin` e entre com a `ORGANIZER_PASSWORD`.
2. Cadastre o evento e os convites.
3. Use **Copiar link** ou **WhatsApp** para enviar cada convite.
O banco começa vazio. Se quiser os convites de exemplo da POC, veja [Seed](#seed-dados-de-exemplo).
---
## Atualizar a aplicação
Depois de copiar a nova versão do código para o servidor:
```bash
docker compose up -d --build app
```
As migrations novas são aplicadas na subida. Os dados do banco ficam no volume `pgdata` e **não são perdidos** ao recriar os containers.
> Para desligar a migration automática, defina `RUN_MIGRATIONS=false` no `.env`.
---
## Publicação via Cloudflare
Quando o Cloudflare passar a apontar para o servidor (igual às outras aplicações):
1. Aponte o domínio/túnel para a porta `APP_PORT` do servidor.
2. No `.env`, altere `COOKIE_SECURE=true`.
3. Recrie o app para aplicar:
```bash
docker compose up -d app
```
---
## Backup e restauração do banco
**Backup:**
```bash
docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > backup-$(date +%F).sql
```
**Restauração** (em um banco vazio):
```bash
docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" "$POSTGRES_DB"' < backup-AAAA-MM-DD.sql
```
> ⚠️ `docker compose down -v` **apaga o volume do banco**. Use apenas `docker compose down` para parar os serviços.
---
## Comandos úteis
| Comando | O que faz |
| --- | --- |
| `docker compose ps` | Status dos containers |
| `docker compose logs -f app` | Logs do app |
| `docker compose restart app` | Reinicia o app |
| `docker compose down` | Para tudo (mantém os dados) |
| `docker compose exec db sh -c 'psql -U "$POSTGRES_USER" "$POSTGRES_DB"'` | Console SQL do banco |
---
## Desenvolvimento local
Requer Node.js 20.9+ e um Postgres acessível (pode ser só o serviço `db` do compose, liberando a porta `127.0.0.1:5432` no `compose.yml`).
```bash
npm install
cp .env.example .env # ajuste DATABASE_URL
npm run db:migrate # cria as tabelas
npm run db:seed # opcional: convites de exemplo
npm run dev
```
Acesse `http://localhost:3000`. Para abrir pela rede local (ex.: celular), o IP precisa estar em `allowedDevOrigins` no `next.config.ts`.
### Scripts do banco
| Script | O que faz |
| --- | --- |
| `npm run db:generate` | Gera uma nova migration após alterar `src/db/schema.ts` |
| `npm run db:migrate` | Aplica as migrations no banco do `DATABASE_URL` |
| `npm run db:seed` | Cria os convites de exemplo (`abc123`, `xyz789`) |
| `npm run db:studio` | Abre o Drizzle Studio para ver/editar os dados |
### Seed (dados de exemplo)
O seed não faz parte da imagem Docker. Para rodá-lo contra o banco do compose, libere a porta do Postgres em `127.0.0.1:5432` no `compose.yml` e, na máquina com o código e as dependências instaladas:
```bash
npm run db:seed
```
---
## Problemas comuns
| Sintoma | Causa provável |
| --- | --- |
| Login do `/admin` volta para a tela de login | `COOKIE_SECURE=true` com acesso por HTTP — use `false` |
| `SESSION_SECRET não definida` nos logs | Variável vazia ou com menos de 32 caracteres |
| `defina POSTGRES_PASSWORD no .env` ao subir | `POSTGRES_PASSWORD` ausente no `.env` |
| Erro de autenticação no Postgres após trocar a senha | A senha só é aplicada na **primeira** criação do volume; altere no banco ou recrie o volume (apaga os dados) |
| Página em branco no `npm run dev` acessando por IP | IP ausente em `allowedDevOrigins` no `next.config.ts` |

37
compose.yml Normal file
View File

@@ -0,0 +1,37 @@
name: convite
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-convite}
POSTGRES_USER: ${POSTGRES_USER:-convite}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?defina POSTGRES_PASSWORD no .env}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
# Sem porta publicada: o banco só é acessível pela rede interna do compose.
# Para acessar do host (ex.: db:studio), descomente:
# ports:
# - "127.0.0.1:5432:5432"
app:
build: .
restart: unless-stopped
depends_on:
db:
condition: service_healthy
env_file: .env
environment:
# Sobrescreve o DATABASE_URL do .env: dentro do compose o host do banco é "db"
DATABASE_URL: postgres://${POSTGRES_USER:-convite}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-convite}
ports:
- "${APP_PORT:-3000}:3000"
volumes:
pgdata:

11
drizzle.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

34
drizzle/0000_init.sql Normal file
View File

@@ -0,0 +1,34 @@
CREATE TYPE "public"."guest_type" AS ENUM('adult', 'child');--> statement-breakpoint
CREATE TYPE "public"."rsvp_status" AS ENUM('pending', 'confirmed', 'declined');--> statement-breakpoint
CREATE TABLE "events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"date" date NOT NULL,
"time" time NOT NULL,
"venue" text NOT NULL,
"address" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "guests" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"invitation_id" uuid NOT NULL,
"name" text NOT NULL,
"type" "guest_type" NOT NULL,
"status" "rsvp_status" DEFAULT 'pending' NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"responded_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "invitations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"event_id" uuid NOT NULL,
"token" text NOT NULL,
"display_name" text NOT NULL,
"responded_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "invitations_token_unique" UNIQUE("token")
);
--> statement-breakpoint
ALTER TABLE "guests" ADD CONSTRAINT "guests_invitation_id_invitations_id_fk" FOREIGN KEY ("invitation_id") REFERENCES "public"."invitations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,2 @@
ALTER TYPE "public"."guest_type" ADD VALUE 'teen' BEFORE 'child';--> statement-breakpoint
ALTER TABLE "events" ADD COLUMN "rsvp_deadline" date;

View File

@@ -0,0 +1,241 @@
{
"id": "8c8de4bc-55ec-4af5-b831-0ef6c6505014",
"prevId": "00000000-0000-0000-0000-000000000000",
"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
},
"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
},
"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
}
},
"enums": {
"public.guest_type": {
"name": "guest_type",
"schema": "public",
"values": [
"adult",
"child"
]
},
"public.rsvp_status": {
"name": "rsvp_status",
"schema": "public",
"values": [
"pending",
"confirmed",
"declined"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,248 @@
{
"id": "e48d8802-8721-4b4e-9fa7-89dc71605f4a",
"prevId": "8c8de4bc-55ec-4af5-b831-0ef6c6505014",
"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
},
"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
},
"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
}
},
"enums": {
"public.guest_type": {
"name": "guest_type",
"schema": "public",
"values": [
"adult",
"teen",
"child"
]
},
"public.rsvp_status": {
"name": "rsvp_status",
"schema": "public",
"values": [
"pending",
"confirmed",
"declined"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1790951633563,
"tag": "0000_init",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1790970071844,
"tag": "0001_teen_and_rsvp_deadline",
"breakpoints": true
}
]
}

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

505
instrucoes.md Normal file
View File

@@ -0,0 +1,505 @@
# Convite Safari 🦁 — Manual de Instruções
Este documento reúne **tudo o que é preciso para instalar, publicar e usar** o Convite Safari.
- **Parte 1 — Deploy:** para quem vai instalar e manter a aplicação no servidor.
- **Parte 2 — Uso:** para o organizador da festa e para os convidados.
---
## Sumário
**Parte 1 — Deploy**
1. [Visão geral da arquitetura](#1-visão-geral-da-arquitetura)
2. [Pré-requisitos](#2-pré-requisitos)
3. [Copiar o projeto para o servidor](#3-copiar-o-projeto-para-o-servidor)
4. [Configurar as variáveis de ambiente](#4-configurar-as-variáveis-de-ambiente)
5. [Subir a aplicação](#5-subir-a-aplicação)
6. [Verificar se está tudo funcionando](#6-verificar-se-está-tudo-funcionando)
7. [Publicar via Cloudflare](#7-publicar-via-cloudflare)
8. [Atualizar para uma nova versão](#8-atualizar-para-uma-nova-versão)
9. [Backup e restauração](#9-backup-e-restauração)
10. [Operação do dia a dia](#10-operação-do-dia-a-dia)
11. [Solução de problemas](#11-solução-de-problemas)
**Parte 2 — Uso**
12. [Conceitos](#12-conceitos)
13. [Área do organizador](#13-área-do-organizador)
14. [Experiência do convidado](#14-experiência-do-convidado)
15. [Perguntas frequentes](#15-perguntas-frequentes)
---
# Parte 1 — Deploy
## 1. Visão geral da arquitetura
A aplicação roda em **dois containers** gerenciados pelo Docker Compose:
| Serviço | Imagem | Função |
| --- | --- | --- |
| `app` | construída pelo `Dockerfile` do projeto | Servidor Next.js (páginas, área do organizador, RSVP) na porta `3000` |
| `db` | `postgres:17-alpine` | Banco de dados. Os dados ficam no volume `pgdata` |
```
Convidado / Organizador
│
▼
Cloudflare (HTTPS) ──► Servidor :3000 ──► container app ──► container db (Postgres)
└─ volume pgdata
```
Pontos importantes:
- O banco **não fica exposto** para fora do servidor; só o container `app` o acessa.
- As **migrations** (criação/atualização das tabelas) são aplicadas **automaticamente** toda vez que o container `app` sobe.
- Os dados ficam no volume Docker `pgdata` e **sobrevivem** a reinícios e rebuilds dos containers.
---
## 2. Pré-requisitos
No servidor (ex.: `192.168.18.97`):
- **Docker** com o plugin **Compose**. Para conferir:
```bash
docker --version
docker compose version
```
- **Acesso à internet durante o build** — o build baixa as dependências npm e as fontes do Google.
- **Porta livre** para a aplicação (padrão `3000`; pode ser alterada em `APP_PORT`).
---
## 3. Copiar o projeto para o servidor
Copie a pasta do projeto para o servidor **sem** as pastas `node_modules`, `.next` e `dist` (elas são recriadas no build).
Exemplo com `rsync`, a partir da máquina onde está o código:
```bash
rsync -av --exclude node_modules --exclude .next --exclude dist ./ usuario@192.168.18.97:/opt/convite/
```
> Troque `usuario` e `/opt/convite/` pelo usuário e pela pasta usados nas outras aplicações do servidor.
Arquivos essenciais que precisam estar no servidor:
```
Dockerfile
compose.yml
.dockerignore
.env.example
package.json
package-lock.json
next.config.ts
tsconfig.json
postcss.config.mjs
drizzle.config.ts
drizzle/ (migrations SQL)
scripts/ (script de migration)
src/
public/
```
---
## 4. Configurar as variáveis de ambiente
No servidor, dentro da pasta do projeto:
```bash
cd /opt/convite
cp .env.example .env
nano .env
```
### Variáveis
| Variável | Obrigatória | Exemplo | Descrição |
| --- | --- | --- | --- |
| `POSTGRES_DB` | não | `convite` | Nome do banco |
| `POSTGRES_USER` | não | `convite` | Usuário do banco |
| `POSTGRES_PASSWORD` | **sim** | `Xk29fLq81` | Senha do banco. **Só letras e números** |
| `DATABASE_URL` | não* | — | Usada apenas fora do Docker. No compose é montada automaticamente |
| `APP_PORT` | não | `3000` | Porta publicada no servidor |
| `ORGANIZER_PASSWORD` | **sim** | — | Senha de acesso à área do organizador |
| `SESSION_SECRET` | **sim** | — | Chave de 32+ caracteres para assinar a sessão |
| `COOKIE_SECURE` | **sim** | `false` | `false` para acesso por `http://IP`; `true` atrás do Cloudflare (HTTPS) |
| `RUN_MIGRATIONS` | não | `true` | `false` desliga a migration automática na subida |
\* Não precisa alterar para o deploy com Docker.
### Gerar o `SESSION_SECRET`
Em qualquer máquina com Node.js:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
```
Ou no próprio servidor, sem Node:
```bash
openssl rand -base64 32 | tr -d '/+='
```
Cole o resultado em `SESSION_SECRET=`.
### Exemplo de `.env` preenchido
```dotenv
POSTGRES_DB=convite
POSTGRES_USER=convite
POSTGRES_PASSWORD=Xk29fLq81mPz
APP_PORT=3000
ORGANIZER_PASSWORD=uma-senha-forte-para-o-admin
SESSION_SECRET=3qD0x9bN1pY7sKcVb2T8wM4rH6jLz5aEoUfGiQ
COOKIE_SECURE=false
```
> 🔒 O `.env` contém senhas. Não envie esse arquivo por e-mail/chat e restrinja o acesso: `chmod 600 .env`.
---
## 5. Subir a aplicação
```bash
docker compose up -d --build
```
O que acontece, em ordem:
1. A imagem do `app` é construída (pode levar alguns minutos na primeira vez).
2. O Postgres sobe; o `app` aguarda o banco ficar saudável.
3. O `app` aplica as migrations pendentes.
4. O servidor Next.js inicia na porta `APP_PORT`.
---
## 6. Verificar se está tudo funcionando
**Status dos containers** — ambos devem estar `running` (e o `db` como `healthy`):
```bash
docker compose ps
```
**Logs do app:**
```bash
docker compose logs -f app
```
Saída esperada (resumida):
```
Migrations aplicadas.
▲ Next.js 16.x
- Local: http://0.0.0.0:3000
✓ Ready
```
**No navegador:**
| Endereço | Deve mostrar |
| --- | --- |
| `http://192.168.18.97:3000/` | Página inicial "Convites Digitais de Aniversário" |
| `http://192.168.18.97:3000/admin` | Tela de login do organizador |
Entre com a `ORGANIZER_PASSWORD`. Se cair na lista de eventos (vazia), o deploy está concluído.
---
## 7. Publicar via Cloudflare
Quando o Cloudflare for apontar para a aplicação (mesmo padrão das outras aplicações do servidor):
1. Configure o domínio/túnel no Cloudflare apontando para `http://<servidor>:<APP_PORT>`.
2. No `.env`, altere:
```dotenv
COOKIE_SECURE=true
```
3. Recrie o container do app:
```bash
docker compose up -d app
```
4. Acesse `https://<seu-domínio>/admin` e faça login para confirmar.
> ⚠️ Depois de `COOKIE_SECURE=true`, o login **pelo IP em HTTP deixa de funcionar** — isso é esperado. Use sempre o domínio HTTPS.
Os links de convite são gerados a partir do endereço em que o organizador está navegando. Por isso, **depois da publicação, gere/copie os links acessando o `/admin` pelo domínio**, e não pelo IP.
---
## 8. Atualizar para uma nova versão
1. Copie o código novo para o servidor (mesmo comando `rsync` do passo 3). O `.env` do servidor **não** é sobrescrito, pois não está na pasta de origem — confira antes.
2. Faça um backup do banco (ver [seção 9](#9-backup-e-restauração)) — recomendado sempre que houver mudança no banco.
3. Reconstrua e suba o app:
```bash
docker compose up -d --build app
```
4. Verifique os logs (`docker compose logs -f app`). Novas migrations são aplicadas automaticamente.
Os dados **não são perdidos** em atualizações.
---
## 9. Backup e restauração
### Backup
```bash
docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > backup-$(date +%F).sql
```
Gera um arquivo como `backup-2026-11-10.sql` na pasta atual. Guarde uma cópia **fora do servidor**.
**Backup automático diário (opcional)** — adicione ao `crontab -e` do servidor:
```cron
0 3 * * * cd /opt/convite && docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > /opt/convite/backups/backup-$(date +\%F).sql
```
(crie a pasta antes: `mkdir -p /opt/convite/backups`)
### Restauração
A restauração deve ser feita em um **banco vazio**. Para recriar o banco do zero:
```bash
docker compose down
docker volume rm convite_pgdata # ⚠️ APAGA todos os dados atuais
docker compose up -d db
docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" "$POSTGRES_DB"' < backup-AAAA-MM-DD.sql
docker compose up -d app
```
> ⚠️ **Nunca** use `docker compose down -v` sem querer apagar o banco — o `-v` remove o volume `pgdata`.
---
## 10. Operação do dia a dia
| Tarefa | Comando |
| --- | --- |
| Ver status | `docker compose ps` |
| Ver logs do app | `docker compose logs -f app` |
| Ver logs do banco | `docker compose logs -f db` |
| Reiniciar o app | `docker compose restart app` |
| Parar tudo (mantém dados) | `docker compose down` |
| Subir novamente | `docker compose up -d` |
| Console SQL | `docker compose exec db sh -c 'psql -U "$POSTGRES_USER" "$POSTGRES_DB"'` |
| Trocar a senha do organizador | Edite `ORGANIZER_PASSWORD` no `.env` e rode `docker compose up -d app` |
| Derrubar todas as sessões abertas | Troque o `SESSION_SECRET` no `.env` e rode `docker compose up -d app` |
Os containers estão com `restart: unless-stopped`: voltam sozinhos se o servidor reiniciar.
---
## 11. Solução de problemas
| Sintoma | Causa provável | Solução |
| --- | --- | --- |
| `defina POSTGRES_PASSWORD no .env` ao subir | Variável ausente | Preencha `POSTGRES_PASSWORD` no `.env` |
| App reiniciando em loop; log com `Falha ao aplicar migrations` | Banco inacessível ou senha errada | Confira `docker compose logs db` e as variáveis `POSTGRES_*` |
| Erro de senha no Postgres após trocar `POSTGRES_PASSWORD` | A senha só é definida na **primeira** criação do volume | Volte a senha antiga no `.env` ou altere no banco: `ALTER USER convite PASSWORD 'nova';` |
| Login do `/admin` sempre volta para a tela de login | `COOKIE_SECURE=true` com acesso via HTTP | Use `false` no acesso por IP, ou acesse pelo domínio HTTPS |
| Erro `SESSION_SECRET não definida` no log | Variável vazia ou curta | Gere um valor de 32+ caracteres (seção 4) |
| "Senha incorreta." no login | `ORGANIZER_PASSWORD` diferente da digitada | Confira o `.env` e recrie o app após alterar |
| Build falha baixando fontes/pacotes | Servidor sem internet no build | Libere o acesso à internet durante o `docker compose build` |
| Porta 3000 em uso | Outra aplicação na mesma porta | Altere `APP_PORT` no `.env` |
| Link do convite abre com o IP em vez do domínio | Link copiado acessando o admin pelo IP | Acesse o `/admin` pelo domínio e copie de novo |
---
# Parte 2 — Uso
## 12. Conceitos
| Termo | Significado |
| --- | --- |
| **Evento** | A festa: nome, data, horário, local e endereço. Ex.: "1º Aniversário do Arthur" |
| **Convite** | Um link enviado para uma família/grupo. Tem um **nome exibido** (ex.: "Família Silva") e uma lista de convidados |
| **Convidado** | Cada pessoa do convite, marcada como **Adulto** ou **Criança** |
| **Token** | Código único no final do link (`/r/Ab3xY9kL`). Funciona como a "chave" do convite |
| **RSVP / Status** | Resposta de cada convidado: ✓ **Presente**, ✗ **Ausente** ou ⏳ **Pendente** |
Um evento pode ter vários convites; cada convite pode ter vários convidados.
---
## 13. Área do organizador
### 13.1 Entrar
1. Acesse `https://<seu-domínio>/admin` (ou `http://192.168.18.97:3000/admin` antes da publicação). Também há o link discreto **"Área do organizador"** no rodapé da página inicial.
2. Digite a senha do organizador e clique em **Entrar**.
A sessão dura **7 dias** no mesmo navegador. Para sair, clique em **Sair** no topo da página.
### 13.2 Painel de eventos
A tela inicial do organizador lista todos os eventos com:
- data, horário e local;
- quantidade de convites;
- resumo de presença: **Presentes** (com divisão adultos/crianças), **Ausentes** e **Pendentes**.
Clique em um evento para abrir os detalhes.
### 13.3 Criar um evento
1. No painel, clique em **+ Novo evento**.
2. Preencha:
- **Nome da festa** — ex.: "1º Aniversário do Arthur"
- **Data** e **Horário**
- **Local** — ex.: "Espaço Festa"
- **Endereço** — ex.: "Rua das Flores, 100 - Belo Horizonte/MG"
3. Clique em **Criar evento**. Você será levado à página do evento.
### 13.4 Criar um convite
1. Na página do evento, clique em **+ Novo convite**.
2. Preencha o **Nome exibido no convite** — é o título grande que o convidado vê. Ex.: "Família Silva", "Tio João e Tia Ana".
3. Adicione os convidados:
- digite o nome na primeira linha e escolha **Adulto** ou **Criança**;
- use **+ Adulto** / **+ Criança** para adicionar mais linhas;
- use **✕** para remover uma linha.
Linhas com o nome em branco são ignoradas.
4. Clique em **Criar convite**.
O link do convite é gerado automaticamente.
> 💡 Dica: crie **um convite por família/núcleo**. Assim cada família responde por todos os seus membros de uma vez.
### 13.5 Enviar o convite
Em cada convite, na página do evento:
- **Copiar link** — copia o endereço completo (ex.: `https://seu-dominio/r/Ab3xY9kL`) para colar onde quiser.
- **WhatsApp** — abre o WhatsApp com uma mensagem pronta contendo o nome da família, o nome da festa e o link. Basta escolher o contato e enviar.
- **Abrir convite** — abre o convite em outra aba, exatamente como o convidado verá.
> 🔒 Cada link dá acesso àquele convite e permite responder por ele. **Envie apenas para a família correspondente** e não publique em grupos abertos ou redes sociais.
### 13.6 Acompanhar as respostas
Na página do evento:
- O **Resumo de presença** mostra o total de presentes (adultos e crianças), ausentes e pendentes de todo o evento — útil para fechar números com buffet e decoração.
- Cada convite mostra seus convidados com o status colorido:
- 🟢 **✓ Presente**
- 🔴 **✗ Ausente**
- 🟡 **⏳ Pendente**
- Quando a família responde, aparece **"respondeu em dd/mm/aaaa hh:mm"** abaixo do nome.
Recarregue a página para ver respostas novas.
### 13.7 Editar um convite
1. No convite, clique em **Editar**.
2. Altere o nome exibido, renomeie convidados, troque o tipo, adicione ou remova pessoas.
3. Clique em **Salvar alterações**.
O link **não muda** ao editar. O status de cada convidado aparece ao lado do nome durante a edição.
> ⚠️ Remover um convidado apaga também a resposta dele. Convidados mantidos ou apenas renomeados **preservam** a resposta.
### 13.8 Zerar as respostas de um convite
Se uma família respondeu por engano e quer começar de novo:
1. No convite, clique em **Zerar respostas** (só aparece se já houve resposta).
2. Confirme.
Todos os convidados daquele convite voltam para **Pendente**. O link continua o mesmo.
### 13.9 Excluir um convite
1. No convite, clique em **Excluir** e confirme.
O link deixa de funcionar imediatamente (o convidado verá "Convite não encontrado"). **Não é possível desfazer.**
### 13.10 Editar ou excluir um evento
1. Na página do evento, clique em **Editar evento**.
2. Altere os dados e clique em **Salvar alterações** — as mudanças aparecem na hora em todos os convites do evento.
3. Para excluir, use **Excluir evento** no final da página e confirme.
> ⚠️ Excluir um evento apaga **todos os convites e respostas** dele. Não é possível desfazer.
---
## 14. Experiência do convidado
O convidado **não precisa de cadastro nem senha** — só do link recebido.
### 14.1 Abrir o convite
Ao abrir o link, o convidado vê:
1. O **nome do convite** em destaque (ex.: "Família Silva — esperamos vocês para celebrar!").
2. Os **detalhes da festa**: data, horário, local e endereço.
3. A **lista de convidados** daquele convite, cada um com seu status atual.
4. O quadro de **Confirmação de Presença**, com a contagem de presentes, ausentes e pendentes.
### 14.2 Responder
1. Para cada pessoa da lista, toque em:
- **✓** para confirmar presença, ou
- **✗** para informar ausência.
Para mudar de ideia antes de enviar, basta tocar no outro botão.
2. Toque em **Confirmar Presença**.
3. Aparece a tela **"Presença Confirmada!"** com o resumo de quem vai e quem não vai. Toque em **Fechar**.
Não é obrigatório responder por todos de uma vez: quem ficar como pendente pode ser respondido depois.
### 14.3 Mudar a resposta
O convidado pode abrir o **mesmo link** a qualquer momento. As respostas já enviadas aparecem marcadas; basta trocar (ex.: de ✓ para ✗) e tocar em **Confirmar Presença** novamente.
### 14.4 Mensagens possíveis
| Mensagem | Significado |
| --- | --- |
| **Convite não encontrado** | Link digitado errado, incompleto ou convite excluído pelo organizador |
| **Não foi possível enviar sua resposta. Tente novamente.** | Falha de conexão ou o convite foi alterado no meio do caminho. Recarregue a página e responda de novo |
---
## 15. Perguntas frequentes
**O convidado precisa instalar algum aplicativo?**
Não. Funciona no navegador do celular ou do computador.
**Posso usar o mesmo sistema para várias festas?**
Sim. Crie um evento para cada festa; cada um tem seus próprios convites e totais.
**Duas pessoas da mesma família podem responder pelo mesmo link?**
Sim. Vale a última resposta enviada para cada convidado.
**Um convidado novo apareceu depois que a família já respondeu. O que faço?**
Edite o convite e adicione a pessoa. Ela aparece como **Pendente** e as respostas anteriores dos outros são mantidas. Peça para a família abrir o link de novo e responder por ela.
**Enviei o link errado para uma família. Como invalido?**
Exclua o convite e crie outro — o novo convite terá um link diferente.
**Esqueci a senha do organizador.**
Quem administra o servidor consulta/altera `ORGANIZER_PASSWORD` no `.env` e recria o app (`docker compose up -d app`).
**Os dados ficam salvos se o servidor reiniciar?**
Sim. Os dados ficam no banco Postgres, em um volume persistente. Mesmo assim, mantenha backups (seção 9).

10
next.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Gera .next/standalone com server.js e só as dependências necessárias (imagem Docker enxuta)
output: "standalone",
// Permite acessar o dev server pela rede local (ex.: celular), não só por localhost
allowedDevOrigins: ["192.168.18.97"],
};
export default nextConfig;

View File

@@ -0,0 +1,626 @@
# OpenSpec — Contexto e Diretrizes da POC do Convite de Aniversário
## 1. Objetivo
Construir uma POC (Proof of Concept) de um convite digital de aniversário de 1 ano utilizando Next.js.
A POC deve validar a experiência visual e funcional do convite antes da implementação da infraestrutura completa de RSVP, banco de dados, autenticação administrativa, automações e integração com WhatsApp.
O resultado deve parecer próximo de um produto real para permitir apresentação e aprovação da próxima fase.
## 2. Contexto
O produto final será uma plataforma de convites digitais personalizados.
Cada família/convidado poderá receber um link exclusivo, por exemplo:
`https://convite.exemplo.com/r/AbC123xyz`
O convite poderá conter:
- nome do aniversariante;
- idade;
- data;
- horário;
- local;
- tema visual;
- mensagem personalizada;
- lista de convidados daquele convite;
- confirmação de presença.
A POC não precisa implementar o backend definitivo.
## 3. Escopo da POC
### Deve possuir
- Next.js;
- React;
- TypeScript;
- Tailwind CSS;
- página inicial;
- página `/r/[token]`;
- dados mockados em TypeScript/JSON;
- convite personalizado;
- lista de convidados;
- RSVP funcionando no frontend;
- feedback de confirmação;
- layout mobile-first;
- tema Safari.
Exemplo de dados:
```ts
{
token: "abc123",
event: {
name: "1º Aniversário do Arthur",
date: "15 de novembro de 2026",
time: "16:00",
venue: "Espaço Festa",
address: "Belo Horizonte - MG"
},
invitation: {
displayName: "Família Silva"
},
guests: [
{ id: "1", name: "João", type: "adult", status: "pending" },
{ id: "2", name: "Maria", type: "adult", status: "pending" },
{ id: "3", name: "Pedro", type: "child", status: "pending" }
]
}
```
## 4. RSVP
O usuário deve conseguir:
- confirmar um convidado;
- recusar um convidado;
- alterar a seleção antes do envio;
- confirmar presença;
- visualizar uma mensagem de sucesso.
Na POC não é obrigatório PostgreSQL.
Pode usar React state ou localStorage.
A interface deve ser preparada para futuramente substituir o mock por uma API.
## 5. Fluxo
```text
WhatsApp
↓
abre link personalizado
↓
convite
↓
dados da festa
↓
lista de convidados
↓
seleção de presença
↓
confirmar
↓
mensagem de sucesso
```
## 6. Design
Tema inicial: **Safari / animais da selva**.
Elementos possíveis:
- leão;
- elefante;
- girafa;
- folhas;
- árvores;
- animais infantis;
- natureza;
- ilustrações infantis.
A aparência deve ser:
- infantil;
- elegante;
- alegre;
- moderna;
- adequada para aniversário de 1 ano.
Evitar aparência genérica de formulário.
## 7. Responsividade
Mobile-first é obrigatório.
Principal cenário:
```text
WhatsApp → Android/iPhone → Browser → Convite
```
Também deve funcionar em tablet e desktop.
## 8. Arquitetura da POC
```text
Next.js
│
├── páginas
├── componentes
├── dados mockados
└── camada de domínio
```
O acesso aos dados deve ser centralizado, permitindo futuramente:
```text
Mock → API → PostgreSQL
```
Evitar espalhar dados mockados pelos componentes.
## 9. Estrutura sugerida
```text
app/
├── page.tsx
├── r/
│ └── [token]/
│ └── page.tsx
└── globals.css
components/
├── invitation/
│ ├── InvitationHero.tsx
│ ├── EventDetails.tsx
│ ├── GuestList.tsx
│ ├── RsvpSelector.tsx
│ ├── ConfirmationSuccess.tsx
│ └── InvitationFooter.tsx
└── ui/
data/
└── invitations.ts
types/
└── invitation.ts
lib/
└── invitation.ts
public/
└── images/
```
A estrutura pode ser alterada se o OpenSpec encontrar uma organização melhor.
## 10. Modelo de domínio
Criar tipos explícitos.
```ts
export type GuestType = "adult" | "child";
export type RsvpStatus =
| "pending"
| "confirmed"
| "declined";
export interface Guest {
id: string;
name: string;
type: GuestType;
status: RsvpStatus;
}
export interface InvitationEvent {
name: string;
date: string;
time: string;
venue: string;
address: string;
}
export interface Invitation {
token: string;
event: InvitationEvent;
displayName: string;
guests: Guest[];
}
```
## 11. Requisitos funcionais
### RF01 — Acessar convite
Permitir acessar:
`/r/[token]`
### RF02 — Personalização
Apresentar o nome do grupo/convidado.
Exemplo:
> Família Silva, esperamos vocês!
### RF03 — Dados do evento
Exibir:
- nome;
- data;
- horário;
- local;
- endereço.
### RF04 — Lista de convidados
Exibir todos os convidados associados.
### RF05 — Seleção
Permitir:
- presente;
- não presente.
### RF06 — Confirmação
Validar que existe pelo menos uma resposta.
Após confirmar:
- mostrar feedback;
- impedir envio duplicado enquanto processa;
- mostrar resumo das respostas.
### RF07 — Estado
Na POC o estado pode ficar no cliente.
## 12. Requisitos não funcionais
### RNF01 — Responsividade
Mobile-first.
### RNF02 — Performance
Evitar bibliotecas pesadas sem necessidade.
### RNF03 — Acessibilidade
Utilizar:
- HTML semântico;
- labels;
- foco visível;
- contraste adequado;
- botões reais;
- navegação por teclado.
### RNF04 — Componentização
Evitar uma única página gigante.
### RNF05 — Manutenibilidade
Separar regras de negócio da apresentação sempre que fizer sentido.
## 13. Fora do escopo da POC
Não implementar ainda:
- PostgreSQL;
- Prisma;
- autenticação administrativa;
- Cloudflare Access;
- Cloudflare Tunnel;
- Nginx Proxy Manager;
- n8n;
- WhatsApp API;
- envio automático;
- geração de PDF;
- QR Code obrigatório;
- painel administrativo;
- analytics;
- cadastro real de convidados;
- upload administrativo;
- LGPD completa;
- sistema multi-evento.
## 14. Preparação para evolução
Manter a separação:
```text
UI
↓
Domain / Model
↓
Data Source
```
Criar, por exemplo:
```ts
getInvitationByToken(token)
```
Na POC:
```ts
return mockInvitations.find(...)
```
Futuramente:
```text
getInvitationByToken()
↓
API
↓
PostgreSQL
```
## 15. Token
Na POC pode ser estático e sem segurança definitiva.
No produto final:
```text
randomBytes()
↓
token público
↓
SHA-256
↓
tokenHash no banco
```
Não antecipar segurança complexa na POC.
## 16. Critérios de aprovação
A POC estará aprovada quando for possível demonstrar:
### Cenário 1
Abrir `/r/abc123` e visualizar um convite completo.
### Cenário 2
Abrir `/r/xyz789` e visualizar dados diferentes.
### Cenário 3
Selecionar:
```text
João → Confirmado
Maria → Confirmado
Pedro → Recusado
```
e enviar.
### Cenário 4
Visualizar mensagem final com as respostas.
### Cenário 5
Executar o fluxo corretamente em celular.
## 17. Critérios visuais
- primeira impressão de convite real;
- tema Safari consistente;
- boa hierarquia visual;
- animações discretas;
- textos legíveis;
- botões claros;
- RSVP intuitivo;
- boa experiência mobile;
- sem aparência de formulário administrativo;
- carregamento rápido.
## 18. Diretriz para o OpenSpec
Trabalhar em ciclos:
```text
PROPOSE
↓
REVIEW
↓
APPLY
↓
VERIFY
```
Antes do APPLY, verificar:
1. atende à POC;
2. não adiciona complexidade desnecessária;
3. não antecipa funcionalidades da fase 2;
4. contempla mobile;
5. permite substituir mock por API;
6. design adequado para aniversário;
7. componentes com responsabilidades claras.
## 19. Regra principal
Não implementar funcionalidades futuras apenas porque são tecnicamente interessantes.
Exemplo: não adicionar PostgreSQL apenas porque ele será utilizado na versão final.
A pergunta da POC é:
> Este convite é suficientemente bom para obter aprovação?
Somente depois da aprovação:
```text
POC aprovada
↓
API
↓
PostgreSQL
↓
tokens reais
↓
admin
↓
Cloudflare
↓
automação
```
## 20. Fases planejadas
### Fase 1 — POC visual
```text
Next.js
React
TypeScript
Tailwind
Mock
```
Objetivo: aprovação do convite.
### Fase 2 — Backend
```text
Next.js API
PostgreSQL
Prisma
```
Objetivo: dados reais e RSVP persistente.
### Fase 3 — Infraestrutura
```text
Cloudflare Tunnel
Cloudflare Access
Rate limiting
Backups
```
Objetivo: publicação segura.
### Fase 4 — Administração
```text
/admin
```
Objetivo: gerenciar eventos, famílias e convidados.
### Fase 5 — Automação
```text
n8n
WhatsApp
lembretes
```
Objetivo: automatizar comunicação.
## 21. Visão futura
```text
WhatsApp
│
▼
convite.n1b3.com.br
│
▼
Cloudflare
│
▼
Cloudflare Tunnel
│
▼
Next.js
┌───────┴────────┐
│ │
Web API
│ │
└───────┬────────┘
│
Prisma
│
▼
PostgreSQL
│
┌───────────┴───────────┐
│ │
RSVP Admin
│ │
└───────────┬───────────┘
│
n8n
│
┌───────┼────────┐
│ │ │
WhatsApp Email Outros
```
## 22. Princípio central
A POC deve ser:
**pequena no escopo, mas com aparência de produto real.**
O objetivo imediato é validar:
```text
DESIGN
+
EXPERIÊNCIA
+
RSVP
+
PERSONALIZAÇÃO
```
A infraestrutura definitiva pode esperar.
## 23. Resultado esperado
Ao final da POC deverá existir uma aplicação Next.js executável localmente com:
```text
/
└── apresentação
/r/abc123
└── convite personalizado
/r/xyz789
└── outro convite personalizado
```
Fluxo:
```text
abrir convite
↓
visualizar
↓
selecionar convidados
↓
confirmar
↓
feedback de sucesso
```
## 24. Decisão arquitetural registrada
**POC = Next.js + React + TypeScript + Tailwind + dados mockados.**
**Backend, PostgreSQL, Prisma, Cloudflare, autenticação, n8n e WhatsApp ficam fora do escopo da POC.**
A POC deve permitir a substituição futura do mock por uma API sem refatoração completa da interface.

8693
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "convite",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:seed": "tsx --env-file=.env src/db/seed.ts",
"db:studio": "drizzle-kit studio",
"build:migrate": "esbuild scripts/migrate.ts --bundle --platform=node --target=node24 --external:pg-native --outfile=dist/migrate.js"
},
"dependencies": {
"dotenv": "^18.0.5",
"drizzle-orm": "^0.45.3",
"next": "16.3.8",
"pg": "^8.23.1",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.23.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.11",
"esbuild": "^0.28.2",
"eslint": "^9",
"eslint-config-next": "16.3.8",
"tailwindcss": "^4",
"tsx": "^4.23.15",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

23
scripts/migrate.ts Normal file
View File

@@ -0,0 +1,23 @@
// Aplica as migrations de ./drizzle no banco. No container roda antes do servidor subir,
// empacotado com esbuild (não depende do drizzle-kit, que é devDependency).
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { Pool } from "pg";
async function main() {
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL não definida.");
}
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
try {
await migrate(drizzle(pool), { migrationsFolder: process.env.MIGRATIONS_DIR ?? "./drizzle" });
console.log("Migrations aplicadas.");
} finally {
await pool.end();
}
}
main().catch((err) => {
console.error("Falha ao aplicar migrations:", err);
process.exit(1);
});

View File

@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { updateInvitation } from "@/app/admin/actions";
import { InvitationForm } from "@/components/admin";
import { requireOrganizer } from "@/lib/auth";
import { getInvitationForEdit } from "@/lib/organizer";
export const metadata: Metadata = { title: "Editar convite" };
export default async function EditInvitationPage({ params }: { params: Promise<{ id: string }> }) {
await requireOrganizer();
const { id } = await params;
const invitation = await getInvitationForEdit(id);
if (!invitation) notFound();
return (
<div className="max-w-xl mx-auto space-y-6">
<Link href={`/admin/eventos/${invitation.event.id}`} className="text-sm text-amber-600 hover:text-amber-800">
← {invitation.event.name}
</Link>
<h1 className="text-3xl font-bold text-amber-900">Editar convite</h1>
<div className="bg-white/80 rounded-2xl shadow-xl border border-amber-100 p-6">
<InvitationForm
action={updateInvitation.bind(null, invitation.id)}
submitLabel="Salvar alterações"
defaults={{
displayName: invitation.displayName,
guests: invitation.guests.map((g) => ({ id: g.id, name: g.name, type: g.type, status: g.status })),
}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { createInvitation } from "@/app/admin/actions";
import { InvitationForm } from "@/components/admin";
import { requireOrganizer } from "@/lib/auth";
import { getEvent } from "@/lib/organizer";
export const metadata: Metadata = { title: "Novo convite" };
export default async function NewInvitationPage({ params }: { params: Promise<{ id: string }> }) {
await requireOrganizer();
const { id } = await params;
const event = await getEvent(id);
if (!event) notFound();
return (
<div className="max-w-xl mx-auto space-y-6">
<Link href={`/admin/eventos/${event.id}`} className="text-sm text-amber-600 hover:text-amber-800">
← {event.name}
</Link>
<h1 className="text-3xl font-bold text-amber-900">Novo convite</h1>
<div className="bg-white/80 rounded-2xl shadow-xl border border-amber-100 p-6">
<InvitationForm action={createInvitation.bind(null, event.id)} submitLabel="Criar convite" />
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { deleteEvent, updateEvent } from "@/app/admin/actions";
import { ConfirmActionButton, EventForm } from "@/components/admin";
import { requireOrganizer } from "@/lib/auth";
import { getEvent } from "@/lib/organizer";
export const metadata: Metadata = { title: "Editar evento" };
export default async function EditEventPage({ params }: { params: Promise<{ id: string }> }) {
await requireOrganizer();
const { id } = await params;
const event = await getEvent(id);
if (!event) notFound();
return (
<div className="max-w-xl mx-auto space-y-6">
<Link href={`/admin/eventos/${event.id}`} className="text-sm text-amber-600 hover:text-amber-800">
← {event.name}
</Link>
<h1 className="text-3xl font-bold text-amber-900">Editar evento</h1>
<div className="bg-white/80 rounded-2xl shadow-xl border border-amber-100 p-6">
<EventForm action={updateEvent.bind(null, event.id)} submitLabel="Salvar alterações" defaults={{
name: event.name,
date: event.date,
time: event.time,
venue: event.venue,
address: event.address,
rsvpDeadline: event.rsvpDeadline,
}}
/>
</div>
<div className="flex justify-end">
<ConfirmActionButton
action={deleteEvent.bind(null, event.id)}
label="Excluir evento"
confirmMessage={`Excluir "${event.name}" e TODOS os convites dele? Esta ação não pode ser desfeita.`}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,147 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { deleteInvitation, resetInvitationRsvp } from "@/app/admin/actions";
import { ConfirmActionButton, ShareInvitation, StatsBar } from "@/components/admin";
import { requireOrganizer } from "@/lib/auth";
import { formatShortDate, getEventWithInvitations } from "@/lib/organizer";
import type { GuestType, RsvpStatus } from "@/types/invitation";
export const metadata: Metadata = { title: "Evento" };
const statusStyles: Record<RsvpStatus, string> = {
confirmed: "bg-emerald-100 text-emerald-800",
declined: "bg-rose-100 text-rose-800",
pending: "bg-amber-100 text-amber-800",
};
const statusLabels: Record<RsvpStatus, string> = {
confirmed: "✓ Presente",
declined: "✗ Ausente",
pending: "⏳ Pendente",
};
const typeIcons: Record<GuestType, string> = {
adult: "👤",
teen: "🧑",
child: "👶",
};
const respondedFormatter = new Intl.DateTimeFormat("pt-BR", {
dateStyle: "short",
timeStyle: "short",
timeZone: "America/Sao_Paulo",
});
export default async function EventPage({ params }: { params: Promise<{ id: string }> }) {
await requireOrganizer();
const { id } = await params;
const event = await getEventWithInvitations(id);
if (!event) notFound();
return (
<div className="space-y-8">
<div className="space-y-2">
<Link href="/admin" className="text-sm text-amber-600 hover:text-amber-800">
← Eventos
</Link>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-amber-900">{event.name}</h1>
<p className="text-amber-600 mt-1">
📅 {formatShortDate(event.date)} · 🕐 {event.time} · 📍 {event.venue} — {event.address}
</p>
{event.rsvpDeadline && (
<p className={`text-sm mt-1 ${event.rsvpClosed ? "text-rose-700 font-medium" : "text-amber-600"}`}>
{event.rsvpClosed
? `🔒 Confirmações encerradas em ${formatShortDate(event.rsvpDeadline)} — pendentes contam como ausentes`
: `⏰ Confirmações até ${formatShortDate(event.rsvpDeadline)}`}
</p>
)}
</div>
<Link
href={`/admin/eventos/${event.id}/editar`}
className="text-sm px-3 py-1.5 rounded-lg border border-amber-300 text-amber-700 hover:bg-amber-50"
>
Editar evento
</Link>
</div>
</div>
<section className="bg-white/80 rounded-2xl shadow-xl border border-amber-100 p-6 space-y-3">
<h2 className="text-lg font-bold text-amber-900">Resumo de presença</h2>
<StatsBar stats={event.stats} />
</section>
<section className="space-y-4">
<div className="flex items-center justify-between gap-4">
<h2 className="text-2xl font-bold text-amber-900">
Convites <span className="text-base font-medium text-amber-600">({event.invitations.length})</span>
</h2>
<Link
href={`/admin/eventos/${event.id}/convites/novo`}
className="bg-amber-500 text-white px-4 py-2 rounded-xl font-medium hover:bg-amber-600 shadow-lg shadow-amber-500/30"
>
+ Novo convite
</Link>
</div>
{event.invitations.length === 0 ? (
<p className="text-amber-700 text-center py-8">Nenhum convite criado para este evento.</p>
) : (
event.invitations.map((inv) => (
<article key={inv.id} className="bg-white/80 rounded-2xl shadow border border-amber-100 p-5 space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-lg font-bold text-amber-900">{inv.displayName}</h3>
<p className="text-xs text-amber-600">
Link: /r/{inv.token}
{inv.respondedAt && <> · respondeu em {respondedFormatter.format(inv.respondedAt)}</>}
</p>
</div>
<ShareInvitation token={inv.token} displayName={inv.displayName} eventName={event.name} />
</div>
<ul className="flex flex-wrap gap-2">
{inv.guests.map((g) => (
<li key={g.id} className={`text-sm px-3 py-1 rounded-full ${statusStyles[g.status]}`}>
{typeIcons[g.type]} {g.name} · {statusLabels[g.status]}
</li>
))}
</ul>
<div className="flex flex-wrap gap-2 pt-2 border-t border-amber-100">
<Link
href={`/admin/convites/${inv.id}/editar`}
className="text-sm px-3 py-1.5 rounded-lg border border-amber-300 text-amber-700 hover:bg-amber-50"
>
Editar
</Link>
<Link
href={`/r/${inv.token}`}
target="_blank"
className="text-sm px-3 py-1.5 rounded-lg border border-amber-300 text-amber-700 hover:bg-amber-50"
>
Abrir convite
</Link>
{inv.respondedAt && (
<ConfirmActionButton
action={resetInvitationRsvp.bind(null, inv.id)}
label="Zerar respostas"
confirmMessage={`Voltar todos os convidados de "${inv.displayName}" para pendente?`}
variant="neutral"
/>
)}
<ConfirmActionButton
action={deleteInvitation.bind(null, inv.id)}
label="Excluir"
confirmMessage={`Excluir o convite de "${inv.displayName}"? O link deixará de funcionar.`}
/>
</div>
</article>
))
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import Link from "next/link";
import { createEvent } from "@/app/admin/actions";
import { EventForm } from "@/components/admin";
import { requireOrganizer } from "@/lib/auth";
export const metadata: Metadata = { title: "Novo evento" };
export default async function NewEventPage() {
await requireOrganizer();
return (
<div className="max-w-xl mx-auto space-y-6">
<Link href="/admin" className="text-sm text-amber-600 hover:text-amber-800">
← Eventos
</Link>
<h1 className="text-3xl font-bold text-amber-900">Novo evento</h1>
<div className="bg-white/80 rounded-2xl shadow-xl border border-amber-100 p-6">
<EventForm action={createEvent} submitLabel="Criar evento" />
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import Link from "next/link";
import { logout } from "@/app/admin/auth-actions";
import { requireOrganizer } from "@/lib/auth";
export default async function PainelLayout({ children }: { children: React.ReactNode }) {
await requireOrganizer();
return (
<div className="min-h-screen bg-gradient-to-b from-amber-50 via-white to-emerald-50">
<header className="border-b border-amber-200 bg-white/70 backdrop-blur-sm">
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
<Link href="/admin" className="flex items-center gap-2 font-bold text-amber-900">
<span className="text-2xl">🦁</span> Organizador
</Link>
<form action={logout}>
<button type="submit" className="text-sm text-amber-700 hover:text-amber-900">
Sair
</button>
</form>
</div>
</header>
<main className="max-w-4xl mx-auto px-4 py-8">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import Link from "next/link";
import { StatsBar } from "@/components/admin";
import { requireOrganizer } from "@/lib/auth";
import { formatShortDate, listEventsWithStats } from "@/lib/organizer";
export const metadata: Metadata = { title: "Eventos" };
export default async function AdminHome() {
await requireOrganizer();
const eventList = await listEventsWithStats();
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<h1 className="text-3xl font-bold text-amber-900">Eventos</h1>
<Link
href="/admin/eventos/novo"
className="bg-amber-500 text-white px-4 py-2 rounded-xl font-medium hover:bg-amber-600 shadow-lg shadow-amber-500/30"
>
+ Novo evento
</Link>
</div>
{eventList.length === 0 ? (
<p className="text-amber-700 text-center py-12">Nenhum evento cadastrado ainda.</p>
) : (
<div className="space-y-4">
{eventList.map((event) => (
<Link
key={event.id}
href={`/admin/eventos/${event.id}`}
className="block bg-white/80 rounded-2xl shadow-xl border border-amber-100 p-6 hover:border-amber-300 transition-colors"
>
<div className="flex flex-wrap items-baseline justify-between gap-2 mb-1">
<h2 className="text-xl font-bold text-amber-900">{event.name}</h2>
<span className="text-sm text-amber-600">
{event.invitationCount} {event.invitationCount === 1 ? "convite" : "convites"}
</span>
</div>
<p className="text-sm text-amber-600 mb-4">
📅 {formatShortDate(event.date)} · 🕐 {event.time} · 📍 {event.venue}
{event.rsvpDeadline && (
<> · {event.rsvpClosed ? "🔒 encerrado" : `⏰ até ${formatShortDate(event.rsvpDeadline)}`}</>
)}
</p>
<StatsBar stats={event.stats} />
</Link>
))}
</div>
)}
</div>
);
}

235
src/app/admin/actions.ts Normal file
View File

@@ -0,0 +1,235 @@
"use server";
import { randomBytes } from "node:crypto";
import { and, eq, inArray, notInArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { db } from "@/db";
import { events, guests, invitations } from "@/db/schema";
import { requireOrganizer } from "@/lib/auth";
import { isUuid } from "@/lib/organizer";
import type { GuestType } from "@/types/invitation";
export interface FormState {
error?: string;
}
function field(formData: FormData, name: string): string {
const value = formData.get(name);
return typeof value === "string" ? value.trim() : "";
}
// ---------- Eventos ----------
function parseEvent(formData: FormData) {
const data = {
name: field(formData, "name"),
date: field(formData, "date"),
time: field(formData, "time"),
venue: field(formData, "venue"),
address: field(formData, "address"),
rsvpDeadline: field(formData, "rsvpDeadline") || null,
};
if (!data.name || !data.date || !data.time || !data.venue || !data.address) {
return { error: "Preencha todos os campos." } as const;
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(data.date) || !/^\d{2}:\d{2}$/.test(data.time)) {
return { error: "Data ou horário inválido." } as const;
}
if (data.rsvpDeadline !== null) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(data.rsvpDeadline)) {
return { error: "Prazo de confirmação inválido." } as const;
}
if (data.rsvpDeadline > data.date) {
return { error: "O prazo de confirmação não pode ser depois da festa." } as const;
}
}
return { data } as const;
}
export async function createEvent(_prev: FormState, formData: FormData): Promise<FormState> {
await requireOrganizer();
const parsed = parseEvent(formData);
if ("error" in parsed) return { error: parsed.error };
const [event] = await db.insert(events).values(parsed.data).returning({ id: events.id });
revalidatePath("/admin");
redirect(`/admin/eventos/${event.id}`);
}
export async function updateEvent(eventId: string, _prev: FormState, formData: FormData): Promise<FormState> {
await requireOrganizer();
if (!isUuid(eventId)) return { error: "Evento inválido." };
const parsed = parseEvent(formData);
if ("error" in parsed) return { error: parsed.error };
await db.update(events).set(parsed.data).where(eq(events.id, eventId));
revalidatePath("/admin");
revalidatePath(`/admin/eventos/${eventId}`);
// O prazo muda o que os convidados veem em todos os convites do evento
revalidatePath("/r/[token]", "page");
redirect(`/admin/eventos/${eventId}`);
}
export async function deleteEvent(eventId: string): Promise<void> {
await requireOrganizer();
if (!isUuid(eventId)) return;
await db.delete(events).where(eq(events.id, eventId));
revalidatePath("/admin");
redirect("/admin");
}
// ---------- Convites ----------
interface GuestInput {
id?: string;
name: string;
type: GuestType;
}
const guestTypes = new Set<unknown>(["adult", "teen", "child"]);
function parseInvitation(formData: FormData) {
const displayName = field(formData, "displayName");
if (!displayName) return { error: "Informe o nome exibido no convite." } as const;
let raw: unknown;
try {
raw = JSON.parse(field(formData, "guests") || "[]");
} catch {
return { error: "Lista de convidados inválida." } as const;
}
if (!Array.isArray(raw)) return { error: "Lista de convidados inválida." } as const;
const guestList: GuestInput[] = [];
for (const item of raw) {
const name = typeof item?.name === "string" ? item.name.trim() : "";
if (!name) continue; // linhas vazias são ignoradas
if (!guestTypes.has(item.type)) {
return { error: "Tipo de convidado inválido." } as const;
}
guestList.push({ id: isUuid(item.id) ? item.id : undefined, name, type: item.type });
}
if (guestList.length === 0) return { error: "Adicione pelo menos um convidado." } as const;
return { displayName, guests: guestList } as const;
}
// Token curto e não adivinhável usado no link /r/<token>
function generateToken(): string {
return randomBytes(6).toString("base64url");
}
export async function createInvitation(eventId: string, _prev: FormState, formData: FormData): Promise<FormState> {
await requireOrganizer();
if (!isUuid(eventId)) return { error: "Evento inválido." };
const parsed = parseInvitation(formData);
if ("error" in parsed) return { error: parsed.error };
await db.transaction(async (tx) => {
const [invitation] = await tx
.insert(invitations)
.values({ eventId, token: generateToken(), displayName: parsed.displayName })
.returning({ id: invitations.id });
await tx.insert(guests).values(
parsed.guests.map((g, index) => ({
invitationId: invitation.id,
name: g.name,
type: g.type,
sortOrder: index,
}))
);
});
revalidatePath("/admin");
revalidatePath(`/admin/eventos/${eventId}`);
redirect(`/admin/eventos/${eventId}`);
}
export async function updateInvitation(
invitationId: string,
_prev: FormState,
formData: FormData
): Promise<FormState> {
await requireOrganizer();
if (!isUuid(invitationId)) return { error: "Convite inválido." };
const parsed = parseInvitation(formData);
if ("error" in parsed) return { error: parsed.error };
const invitation = await db.query.invitations.findFirst({
where: eq(invitations.id, invitationId),
columns: { id: true, eventId: true, token: true },
});
if (!invitation) return { error: "Convite não encontrado." };
await db.transaction(async (tx) => {
await tx.update(invitations).set({ displayName: parsed.displayName }).where(eq(invitations.id, invitationId));
// Convidados existentes mantêm o id (e a resposta de RSVP); os removidos são apagados
const keptIds = parsed.guests.flatMap((g) => (g.id ? [g.id] : []));
await tx
.delete(guests)
.where(
keptIds.length > 0
? and(eq(guests.invitationId, invitationId), notInArray(guests.id, keptIds))
: eq(guests.invitationId, invitationId)
);
const existing = keptIds.length
? await tx
.select({ id: guests.id })
.from(guests)
.where(and(eq(guests.invitationId, invitationId), inArray(guests.id, keptIds)))
: [];
const existingIds = new Set(existing.map((g) => g.id));
for (const [index, g] of parsed.guests.entries()) {
if (g.id && existingIds.has(g.id)) {
await tx.update(guests).set({ name: g.name, type: g.type, sortOrder: index }).where(eq(guests.id, g.id));
} else {
await tx.insert(guests).values({ invitationId, name: g.name, type: g.type, sortOrder: index });
}
}
});
revalidatePath("/admin");
revalidatePath(`/admin/eventos/${invitation.eventId}`);
revalidatePath(`/r/${invitation.token}`);
redirect(`/admin/eventos/${invitation.eventId}`);
}
export async function deleteInvitation(invitationId: string): Promise<void> {
await requireOrganizer();
if (!isUuid(invitationId)) return;
const [deleted] = await db
.delete(invitations)
.where(eq(invitations.id, invitationId))
.returning({ eventId: invitations.eventId, token: invitations.token });
if (!deleted) return;
revalidatePath("/admin");
revalidatePath(`/r/${deleted.token}`);
redirect(`/admin/eventos/${deleted.eventId}`);
}
// Volta todos os convidados do convite para "pendente"
export async function resetInvitationRsvp(invitationId: string): Promise<void> {
await requireOrganizer();
if (!isUuid(invitationId)) return;
const invitation = await db.query.invitations.findFirst({
where: eq(invitations.id, invitationId),
columns: { eventId: true, token: true },
});
if (!invitation) return;
await db.transaction(async (tx) => {
await tx.update(guests).set({ status: "pending", respondedAt: null }).where(eq(guests.invitationId, invitationId));
await tx.update(invitations).set({ respondedAt: null }).where(eq(invitations.id, invitationId));
});
revalidatePath(`/admin/eventos/${invitation.eventId}`);
revalidatePath(`/r/${invitation.token}`);
}

View File

@@ -0,0 +1,22 @@
"use server";
import { redirect } from "next/navigation";
import { checkOrganizerPassword, createSession, deleteSession } from "@/lib/auth";
export interface LoginState {
error?: string;
}
export async function login(_prev: LoginState, formData: FormData): Promise<LoginState> {
const password = formData.get("password");
if (typeof password !== "string" || !checkOrganizerPassword(password)) {
return { error: "Senha incorreta." };
}
await createSession();
redirect("/admin");
}
export async function logout(): Promise<void> {
await deleteSession();
redirect("/admin/login");
}

View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { LoginForm } from "@/components/admin";
import { isOrganizer } from "@/lib/auth";
export const metadata: Metadata = { title: "Entrar" };
export default async function LoginPage() {
if (await isOrganizer()) {
redirect("/admin");
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-amber-50 to-white px-4">
<div className="w-full max-w-sm bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-amber-100 p-8">
<div className="text-center mb-6">
<span className="text-4xl">🦁</span>
<h1 className="text-2xl font-bold text-amber-900 mt-2">Área do Organizador</h1>
</div>
<LoginForm />
</div>
</div>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

105
src/app/globals.css Normal file
View File

@@ -0,0 +1,105 @@
@import "tailwindcss";
:root {
--background: #fefce8;
--foreground: #78350f;
--color-amber-50: #fefce8;
--color-amber-100: #fef3c7;
--color-amber-200: #fde68a;
--color-amber-300: #fcd34d;
--color-amber-400: #fbbf24;
--color-amber-500: #f59e0b;
--color-amber-600: #d97706;
--color-amber-700: #b45309;
--color-amber-800: #92400e;
--color-amber-900: #78350f;
--color-emerald-50: #ecfdf5;
--color-emerald-100: #d1fae5;
--color-emerald-200: #a7f3d0;
--color-emerald-300: #6ee7b7;
--color-emerald-400: #34d399;
--color-emerald-500: #10b981;
--color-emerald-600: #059669;
--color-emerald-700: #047857;
--color-emerald-800: #065f46;
--color-emerald-900: #064e3b;
--color-rose-50: #fff1f2;
--color-rose-100: #ffe4e6;
--color-rose-200: #fecdd3;
--color-rose-300: #fda4af;
--color-rose-400: #fb7185;
--color-rose-500: #f43f5e;
--color-rose-600: #e11d48;
--color-rose-700: #be123c;
--color-rose-800: #9f1239;
--color-rose-900: #881337;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans), system-ui, -apple-system, sans-serif;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
:focus-visible {
outline: 2px solid #f59e0b;
outline-offset: 2px;
}
::selection {
background-color: #f59e0b;
color: white;
}
input[type="checkbox"],
input[type="radio"] {
accent-color: #f59e0b;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #fef3c7;
}
::-webkit-scrollbar-thumb {
background: #f59e0b;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #d97706;
}

59
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,59 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: {
default: "Convite Safari - Aniversário de 1 Ano",
template: "%s | Convite Safari",
},
description: "Convite digital personalizado com tema Safari para aniversário de 1 ano. RSVP funcional e design mobile-first.",
metadataBase: new URL("http://localhost:3000"),
openGraph: {
type: "website",
locale: "pt_BR",
siteName: "Convite Safari",
title: "Convite Safari - Aniversário de 1 Ano",
description: "Convite digital personalizado com tema Safari para aniversário de 1 ano.",
},
twitter: {
card: "summary_large_image",
title: "Convite Safari",
description: "Convite digital personalizado com tema Safari para aniversário de 1 ano.",
},
robots: {
index: false,
follow: false,
},
};
export const viewport: Viewport = {
themeColor: "#f59e0b",
width: "device-width",
initialScale: 1,
maximumScale: 5,
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="pt-BR" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
</head>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}

25
src/app/page.tsx Normal file
View File

@@ -0,0 +1,25 @@
import Link from "next/link";
// Página pública neutra: os convites só são acessados pelo link /r/<token>,
// e a listagem fica na área do organizador (/admin)
export default function Home() {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-amber-50 via-white to-emerald-50 px-4">
<div className="max-w-md text-center">
<div className="inline-flex items-center gap-2 bg-amber-100/80 backdrop-blur-sm rounded-full px-4 py-2 mb-6">
<span className="text-2xl">🦁</span>
<span className="text-amber-800 font-medium">Convites Safari</span>
</div>
<h1 className="text-4xl sm:text-5xl font-bold text-amber-900 leading-tight mb-4">
Convites Digitais de Aniversário
</h1>
<p className="text-lg text-amber-700 mb-10">
Recebeu um convite? Abra o link que foi enviado para você para confirmar sua presença.
</p>
<Link href="/admin" className="text-sm text-amber-500 hover:text-amber-700">
Área do organizador
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
"use server";
import { and, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
import { guests, invitations } from "@/db/schema";
import { isUuid } from "@/lib/organizer";
import { isRsvpClosed } from "@/lib/rsvp";
import type { RsvpSubmission } from "@/types/invitation";
const validStatuses = new Set(["confirmed", "declined"]);
// Chamável por POST direto: o token é a única credencial do convidado,
// então tudo é revalidado aqui no servidor.
export async function submitRsvp(submission: RsvpSubmission): Promise<{ success: boolean; closed?: boolean }> {
const { token, responses } = submission;
if (typeof token !== "string" || !Array.isArray(responses) || responses.length === 0) {
return { success: false };
}
if (responses.some((r) => !isUuid(r.guestId) || !validStatuses.has(r.status))) {
return { success: false };
}
const invitation = await db.query.invitations.findFirst({
where: eq(invitations.token, token),
columns: { id: true },
with: { event: { columns: { rsvpDeadline: true } } },
});
if (!invitation) {
return { success: false };
}
if (isRsvpClosed(invitation.event.rsvpDeadline)) {
return { success: false, closed: true };
}
const guestIds = responses.map((r) => r.guestId);
const ownGuests = await db
.select({ id: guests.id })
.from(guests)
.where(and(eq(guests.invitationId, invitation.id), inArray(guests.id, guestIds)));
if (ownGuests.length !== new Set(guestIds).size) {
return { success: false };
}
const now = new Date();
await db.transaction(async (tx) => {
for (const response of responses) {
await tx
.update(guests)
.set({ status: response.status, respondedAt: now })
.where(and(eq(guests.id, response.guestId), eq(guests.invitationId, invitation.id)));
}
await tx.update(invitations).set({ respondedAt: now }).where(eq(invitations.id, invitation.id));
});
revalidatePath(`/r/${token}`);
return { success: true };
}

View File

@@ -0,0 +1,15 @@
export default function Loading() {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-amber-50 to-white">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-amber-100 mb-4">
<svg className="animate-spin w-8 h-8 text-amber-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
<p className="text-amber-600">Carregando convite...</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-amber-50 to-white px-4">
<div className="text-center max-w-md">
<div className="inline-flex items-center justify-center w-24 h-24 rounded-full bg-amber-100 mb-6">
<span className="text-5xl">🦁</span>
</div>
<h1 className="text-4xl font-bold text-amber-900 mb-4">Convite não encontrado</h1>
<p className="text-amber-700 mb-8 text-lg">
O link que você acessou não corresponde a nenhum convite válido.
Verifique se o link está correto ou entre em contato com os organizadores.
</p>
<Link
href="/"
className="inline-flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors shadow-lg shadow-amber-500/30"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Voltar ao Início
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { notFound } from "next/navigation";
import { connection } from "next/server";
import { InvitationView } from "@/components/invitation";
import { getInvitationByToken } from "@/lib/invitation";
interface InvitationPageProps {
params: Promise<{ token: string }>;
}
export default async function InvitationPage({ params }: InvitationPageProps) {
// Sempre renderiza na hora: o convite muda com RSVPs e edições do organizador
await connection();
const { token } = await params;
const invitation = await getInvitationByToken(token);
if (!invitation) {
notFound();
}
return <InvitationView invitation={invitation} />;
}

View File

@@ -0,0 +1,41 @@
"use client";
import { useFormStatus } from "react-dom";
function SubmitButton({ label, className }: { label: string; className: string }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} className={className}>
{pending ? "Aguarde..." : label}
</button>
);
}
// Formulário de um botão só, que pede confirmação antes de executar a Server Action
export function ConfirmActionButton({
action,
label,
confirmMessage,
variant = "danger",
}: {
action: () => Promise<void>;
label: string;
confirmMessage: string;
variant?: "danger" | "neutral";
}) {
const className =
variant === "danger"
? "text-sm px-3 py-1.5 rounded-lg border border-rose-300 text-rose-700 hover:bg-rose-50 disabled:opacity-50"
: "text-sm px-3 py-1.5 rounded-lg border border-amber-300 text-amber-700 hover:bg-amber-50 disabled:opacity-50";
return (
<form
action={action}
onSubmit={(e) => {
if (!window.confirm(confirmMessage)) e.preventDefault();
}}
>
<SubmitButton label={label} className={className} />
</form>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import { useActionState } from "react";
import type { FormState } from "@/app/admin/actions";
import { Button, Input } from "@/components/ui";
interface EventFormProps {
action: (prev: FormState, formData: FormData) => Promise<FormState>;
submitLabel: string;
defaults?: {
name: string;
date: string;
time: string;
venue: string;
address: string;
rsvpDeadline: string | null;
};
}
export function EventForm({ action, submitLabel, defaults }: EventFormProps) {
const [state, formAction, pending] = useActionState(action, {});
return (
<form action={formAction} className="space-y-4">
<label className="block">
<span className="block text-sm font-medium text-amber-800 mb-1">Nome da festa</span>
<Input name="name" required defaultValue={defaults?.name} placeholder="1º Aniversário do Arthur" />
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="block text-sm font-medium text-amber-800 mb-1">Data</span>
<Input type="date" name="date" required defaultValue={defaults?.date} />
</label>
<label className="block">
<span className="block text-sm font-medium text-amber-800 mb-1">Horário</span>
<Input type="time" name="time" required defaultValue={defaults?.time} />
</label>
</div>
<label className="block">
<span className="block text-sm font-medium text-amber-800 mb-1">Local</span>
<Input name="venue" required defaultValue={defaults?.venue} placeholder="Espaço Festa" />
</label>
<label className="block">
<span className="block text-sm font-medium text-amber-800 mb-1">Endereço</span>
<Input name="address" required defaultValue={defaults?.address} placeholder="Rua, número - Cidade/UF" />
</label>
<label className="block">
<span className="block text-sm font-medium text-amber-800 mb-1">Confirmar presença até (opcional)</span>
<Input type="date" name="rsvpDeadline" defaultValue={defaults?.rsvpDeadline ?? undefined} />
<span className="block text-xs text-amber-600 mt-1">
Depois deste dia, quem não respondeu conta como ausente e o convite não aceita mais alterações.
</span>
</label>
{state.error && (
<p className="text-sm text-rose-600" role="alert">
{state.error}
</p>
)}
<Button type="submit" disabled={pending} className="w-full">
{pending ? "Salvando..." : submitLabel}
</Button>
</form>
);
}

View File

@@ -0,0 +1,123 @@
"use client";
import { useActionState, useState } from "react";
import type { FormState } from "@/app/admin/actions";
import { Button, Input } from "@/components/ui";
import type { GuestType, RsvpStatus } from "@/types/invitation";
interface GuestRow {
key: string;
id?: string;
name: string;
type: GuestType;
status?: RsvpStatus;
}
interface InvitationFormProps {
action: (prev: FormState, formData: FormData) => Promise<FormState>;
submitLabel: string;
defaults?: {
displayName: string;
guests: { id: string; name: string; type: GuestType; status: RsvpStatus }[];
};
}
const statusLabels: Record<RsvpStatus, string> = {
confirmed: "✓ Presente",
declined: "✗ Ausente",
pending: "⏳ Pendente",
};
let rowCounter = 0;
const newRow = (type: GuestType = "adult"): GuestRow => ({ key: `new-${rowCounter++}`, name: "", type });
export function InvitationForm({ action, submitLabel, defaults }: InvitationFormProps) {
const [state, formAction, pending] = useActionState(action, {});
const [rows, setRows] = useState<GuestRow[]>(() =>
defaults?.guests.length ? defaults.guests.map((g) => ({ ...g, key: g.id })) : [newRow()]
);
const updateRow = (key: string, patch: Partial<GuestRow>) =>
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...patch } : r)));
const removeRow = (key: string) => setRows((prev) => prev.filter((r) => r.key !== key));
const guestsJson = JSON.stringify(rows.map(({ id, name, type }) => ({ id, name, type })));
return (
<form action={formAction} className="space-y-6">
<label className="block">
<span className="block text-sm font-medium text-amber-800 mb-1">Nome exibido no convite</span>
<Input
name="displayName"
required
defaultValue={defaults?.displayName}
placeholder="Família Silva"
/>
</label>
<fieldset className="space-y-3">
<legend className="text-sm font-medium text-amber-800 mb-1">Convidados</legend>
{rows.map((row) => (
<div key={row.key} className="flex items-center gap-2">
<Input
value={row.name}
onChange={(e) => updateRow(row.key, { name: e.target.value })}
placeholder="Nome"
aria-label="Nome do convidado"
className="flex-1"
/>
<select
value={row.type}
onChange={(e) => updateRow(row.key, { type: e.target.value as GuestType })}
aria-label="Tipo do convidado"
className="px-3 py-3 rounded-xl border-2 border-amber-200 bg-white focus:border-amber-500 focus:outline-none"
>
<option value="adult">Adulto</option>
<option value="teen">Adolescente</option>
<option value="child">Criança</option>
</select>
{row.status && (
<span className="hidden sm:inline text-xs text-amber-700 whitespace-nowrap">{statusLabels[row.status]}</span>
)}
<button
type="button"
onClick={() => removeRow(row.key)}
disabled={rows.length === 1}
className="p-3 rounded-xl text-rose-600 hover:bg-rose-50 disabled:opacity-30"
aria-label={`Remover ${row.name || "convidado"}`}
>
✕
</button>
</div>
))}
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setRows((prev) => [...prev, newRow("adult")])}>
+ Adulto
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setRows((prev) => [...prev, newRow("teen")])}>
+ Adolescente
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setRows((prev) => [...prev, newRow("child")])}>
+ Criança
</Button>
</div>
{defaults && (
<p className="text-xs text-amber-600">
Convidados removidos perdem a resposta já registrada.
</p>
)}
</fieldset>
<input type="hidden" name="guests" value={guestsJson} />
{state.error && (
<p className="text-sm text-rose-600" role="alert">
{state.error}
</p>
)}
<Button type="submit" disabled={pending} className="w-full">
{pending ? "Salvando..." : submitLabel}
</Button>
</form>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import { useActionState } from "react";
import { login, type LoginState } from "@/app/admin/auth-actions";
import { Button, Input } from "@/components/ui";
export function LoginForm() {
const [state, formAction, pending] = useActionState<LoginState, FormData>(login, {});
return (
<form action={formAction} className="space-y-4">
<label className="block">
<span className="block text-sm font-medium text-amber-800 mb-1">Senha do organizador</span>
<Input type="password" name="password" required autoFocus autoComplete="current-password" />
</label>
{state.error && (
<p className="text-sm text-rose-600" role="alert">
{state.error}
</p>
)}
<Button type="submit" disabled={pending} className="w-full">
{pending ? "Entrando..." : "Entrar"}
</Button>
</form>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import { useState } from "react";
// Copiar via textarea: navigator.clipboard só existe em contexto seguro (HTTPS/localhost),
// e o acesso direto por http://IP não é
function fallbackCopy(text: string): boolean {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
return ok;
}
export function ShareInvitation({ token, displayName, eventName }: { token: string; displayName: string; eventName: string }) {
const [copied, setCopied] = useState(false);
const getUrl = () => `${window.location.origin}/r/${token}`;
const handleCopy = async () => {
const url = getUrl();
let ok = false;
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(url);
ok = true;
} else {
ok = fallbackCopy(url);
}
} catch {
ok = fallbackCopy(url);
}
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
window.prompt("Copie o link do convite:", url);
}
};
const handleWhatsApp = () => {
const text = `Olá, ${displayName}! Vocês estão convidados para o ${eventName} 🦁\nConfirme a presença: ${getUrl()}`;
window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, "_blank", "noopener");
};
return (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={handleCopy}
className="text-sm px-3 py-1.5 rounded-lg border border-amber-300 text-amber-700 hover:bg-amber-50"
>
{copied ? "✓ Link copiado" : "Copiar link"}
</button>
<button
type="button"
onClick={handleWhatsApp}
className="text-sm px-3 py-1.5 rounded-lg border border-emerald-300 text-emerald-700 hover:bg-emerald-50"
>
WhatsApp
</button>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import type { RsvpStats } from "@/lib/organizer";
export function StatsBar({ stats }: { stats: RsvpStats }) {
return (
<div className="grid grid-cols-3 gap-3 text-center">
<div className="bg-emerald-50 rounded-xl p-3 border border-emerald-100">
<div className="text-2xl font-bold text-emerald-700">{stats.confirmed}</div>
<div className="text-xs text-emerald-600 font-medium">Presentes</div>
{stats.confirmed > 0 && (
<div className="text-[11px] text-emerald-600 mt-1">
{stats.confirmedAdults} adultos · {stats.confirmedTeens} adolescentes · {stats.confirmedChildren} crianças
</div>
)}
</div>
<div className="bg-rose-50 rounded-xl p-3 border border-rose-100">
<div className="text-2xl font-bold text-rose-700">{stats.declined}</div>
<div className="text-xs text-rose-600 font-medium">Ausentes</div>
</div>
<div className="bg-amber-50 rounded-xl p-3 border border-amber-100">
<div className="text-2xl font-bold text-amber-700">{stats.pending}</div>
<div className="text-xs text-amber-600 font-medium">Pendentes</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
export { LoginForm } from "./LoginForm";
export { EventForm } from "./EventForm";
export { InvitationForm } from "./InvitationForm";
export { ShareInvitation } from "./ShareInvitation";
export { ConfirmActionButton } from "./ConfirmActionButton";
export { StatsBar } from "./StatsBar";

View File

@@ -0,0 +1,83 @@
import type { Guest } from "@/types/invitation";
interface ConfirmationSuccessProps {
guests: Guest[];
displayName: string;
onClose?: () => void;
}
export function ConfirmationSuccess({ guests, displayName, onClose }: ConfirmationSuccessProps) {
const confirmed = guests.filter((g) => g.status === "confirmed");
const declined = guests.filter((g) => g.status === "declined");
const patternSvg = "data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E";
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="success-title"
>
<div className="relative w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden animate-scale-in">
<div className="bg-gradient-to-r from-emerald-500 to-teal-600 px-6 py-8 text-center relative overflow-hidden">
<div className="absolute inset-0" style={{ backgroundImage: `url('${patternSvg}')` }} />
<div className="relative z-10">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-white/20 backdrop-blur-sm mb-4">
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 id="success-title" className="text-2xl font-bold text-white mb-2">
Presença Confirmada!
</h2>
<p className="text-white/90">{displayName}, obrigado pela resposta!</p>
</div>
</div>
<div className="p-6 space-y-6">
{confirmed.length > 0 && (
<div>
<h3 className="flex items-center gap-2 text-sm font-semibold text-emerald-700 mb-3">
<span className="w-6 h-6 rounded-full bg-emerald-100 flex items-center justify-center text-emerald-600 text-xs">✓</span>
Presentes ({confirmed.length})
</h3>
<ul className="space-y-2" role="list">
{confirmed.map((guest) => (
<li key={guest.id} className="flex items-center gap-3 p-3 bg-emerald-50 rounded-xl border border-emerald-100">
<span className="w-8 h-8 rounded-lg bg-emerald-100 flex items-center justify-center text-emerald-600 text-sm">👤</span>
<span className="font-medium text-amber-900">{guest.name}</span>
</li>
))}
</ul>
</div>
)}
{declined.length > 0 && (
<div>
<h3 className="flex items-center gap-2 text-sm font-semibold text-rose-700 mb-3">
<span className="w-6 h-6 rounded-full bg-rose-100 flex items-center justify-center text-rose-600 text-xs">✗</span>
Ausentes ({declined.length})
</h3>
<ul className="space-y-2" role="list">
{declined.map((guest) => (
<li key={guest.id} className="flex items-center gap-3 p-3 bg-rose-50 rounded-xl border border-rose-100">
<span className="w-8 h-8 rounded-lg bg-rose-100 flex items-center justify-center text-rose-600 text-sm">👤</span>
<span className="font-medium text-amber-900">{guest.name}</span>
</li>
))}
</ul>
</div>
)}
<button
onClick={onClose}
className="w-full bg-amber-500 text-white py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
>
Fechar
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import type { InvitationEvent } from "@/types/invitation";
const icons = {
date: "📅",
time: "🕐",
venue: "📍",
};
export function EventDetails({ event }: { event: InvitationEvent }) {
const details = [
{ label: "Data", value: event.date, icon: icons.date },
{ label: "Horário", value: event.time, icon: icons.time },
{ label: "Local", value: event.venue, icon: icons.venue },
{ label: "Endereço", value: event.address, icon: icons.venue },
];
return (
<section className="space-y-4">
<h2 className="text-2xl font-bold text-amber-900 text-center mb-6">
Detalhes da Festa
</h2>
<div className="grid gap-4 sm:grid-cols-2">
{details.map((detail) => (
<div
key={detail.label}
className="bg-white/60 backdrop-blur-sm rounded-xl p-5 border border-amber-100 text-center hover:border-amber-300 transition-colors group"
>
<span className="text-3xl block mb-2 group-hover:scale-110 transition-transform">{detail.icon}</span>
<dt className="text-sm font-medium text-amber-600 uppercase tracking-wide mb-1">
{detail.label}
</dt>
<dd className="text-amber-900 font-medium text-base">{detail.value}</dd>
</div>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,122 @@
import type { Guest } from "@/types/invitation";
interface GuestListProps {
guests: Guest[];
onGuestToggle: (guestId: string, status: "confirmed" | "declined") => void;
submitted?: boolean;
// Prazo de confirmação encerrado: mostra as respostas sem permitir alteração
locked?: boolean;
}
export function GuestList({ guests, onGuestToggle, submitted, locked }: GuestListProps) {
const readOnly = submitted || locked;
const typeLabels = {
adult: "Adulto",
teen: "Adolescente",
child: "Criança",
};
const typeIcons = {
adult: "👤",
teen: "🧑",
child: "👶",
};
const statusStyles = {
confirmed: "bg-emerald-100 text-emerald-800 border-emerald-200",
declined: "bg-rose-100 text-rose-800 border-rose-200",
pending: "bg-amber-100 text-amber-800 border-amber-200",
};
return (
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-amber-900">Convidados</h2>
<span className="text-sm text-amber-600 bg-amber-50 px-3 py-1 rounded-full font-medium">
{guests.length} {guests.length === 1 ? "convidado" : "convidados"}
</span>
</div>
<div className="space-y-3" role="list" aria-label="Lista de convidados">
{guests.map((guest) => (
<article
key={guest.id}
className={`relative group flex items-center gap-4 p-4 rounded-xl border-2 transition-all duration-200 ${
submitted
? "opacity-60"
: "bg-white/60 backdrop-blur-sm border-amber-100 hover:border-amber-300"
}`}
role="listitem"
>
<div className="flex-shrink-0 w-12 h-12 rounded-xl bg-gradient-to-br from-amber-100 to-amber-200 flex items-center justify-center text-2xl">
{typeIcons[guest.type]}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-amber-900 truncate">{guest.name}</h3>
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusStyles[guest.status]}`}
>
{guest.status === "confirmed" && "✓ Presente"}
{guest.status === "declined" && "✗ Ausente"}
{guest.status === "pending" && "⏳ Pendente"}
</span>
</div>
<p className="text-sm text-amber-600">{typeLabels[guest.type]}</p>
</div>
{!readOnly && (
<div className="flex items-center gap-2">
<button
onClick={() => onGuestToggle(guest.id, "confirmed")}
disabled={guest.status === "confirmed"}
className={`p-2 rounded-xl transition-all duration-200 ${
guest.status === "confirmed"
? "bg-emerald-500 text-white shadow-lg shadow-emerald-500/30"
: "bg-white/80 text-amber-600 hover:bg-emerald-50 hover:text-emerald-600 border border-amber-200"
}`}
aria-label={guest.status === "confirmed" ? "Confirmado" : "Marcar como presente"}
aria-pressed={guest.status === "confirmed"}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
<button
onClick={() => onGuestToggle(guest.id, "declined")}
disabled={guest.status === "declined"}
className={`p-2 rounded-xl transition-all duration-200 ${
guest.status === "declined"
? "bg-rose-500 text-white shadow-lg shadow-rose-500/30"
: "bg-white/80 text-amber-600 hover:bg-rose-50 hover:text-rose-600 border border-amber-200"
}`}
aria-label={guest.status === "declined" ? "Recusado" : "Marcar como ausente"}
aria-pressed={guest.status === "declined"}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
{readOnly && guest.status !== "pending" && (
<div className={`px-3 py-1 rounded-full text-sm font-medium ${statusStyles[guest.status]}`}>
{guest.status === "confirmed" ? "✓ Confirmado" : "✗ Recusado"}
</div>
)}
</article>
))}
</div>
{!readOnly && guests.every((g) => g.status === "pending") && (
<p className="text-center text-amber-600 text-sm italic">
Toque em ✓ para confirmar ou ✗ para recusar
</p>
)}
</section>
);
}

View File

@@ -0,0 +1,17 @@
export function InvitationFooter() {
return (
<footer className="mt-12 pt-8 border-t border-amber-200 text-center">
<div className="flex items-center justify-center gap-2 text-amber-600 mb-4">
<span className="text-xl">🌿</span>
<span className="text-xl">🦁</span>
<span className="text-xl">🌿</span>
</div>
<p className="text-sm text-amber-600 mb-2">
Convite digital personalizado
</p>
<p className="text-xs text-amber-400">
Feito com carinho para o 1º Aniversário
</p>
</footer>
);
}

View File

@@ -0,0 +1,36 @@
export function InvitationHero({
displayName,
}: {
displayName: string;
}) {
const patternSvg = "data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23f59e0b' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E";
return (
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-b from-amber-100 via-amber-50 to-white p-8 sm:p-12 text-center">
<div className="absolute inset-0 opacity-50" style={{ backgroundImage: `url('${patternSvg}')` }} />
<div className="absolute top-4 right-4 w-20 h-20 bg-gradient-to-br from-amber-400 to-orange-500 rounded-full opacity-20 blur-2xl" />
<div className="absolute bottom-4 left-4 w-16 h-16 bg-gradient-to-br from-emerald-400 to-teal-500 rounded-full opacity-20 blur-2xl" />
<div className="relative z-10">
<div className="inline-flex items-center gap-2 bg-amber-100/80 backdrop-blur-sm rounded-full px-4 py-2 mb-6">
<span className="text-2xl">🦁</span>
<span className="text-amber-800 font-medium text-sm">Safari Party</span>
</div>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-amber-900 leading-tight mb-4">
{displayName}
</h1>
<p className="text-xl sm:text-2xl text-amber-700 font-medium max-w-md mx-auto">
esperamos vocês para celebrar!
</p>
<div className="mt-8 flex items-center justify-center gap-4">
<div className="w-24 h-px bg-gradient-to-r from-transparent via-amber-400 to-transparent" />
<span className="text-3xl">🌿</span>
<div className="w-24 h-px bg-gradient-to-r from-transparent via-amber-400 to-transparent" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { InvitationHero } from "./InvitationHero";
import { EventDetails } from "./EventDetails";
import { GuestList } from "./GuestList";
import { RsvpSelector } from "./RsvpSelector";
import { ConfirmationSuccess } from "./ConfirmationSuccess";
import { InvitationFooter } from "./InvitationFooter";
import { submitRsvp } from "@/app/r/[token]/actions";
import type { Invitation, Guest } from "@/types/invitation";
export function InvitationView({ invitation }: { invitation: Invitation }) {
const [guests, setGuests] = useState<Guest[]>(invitation.guests);
const [showSuccess, setShowSuccess] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(false);
const [closed, setClosed] = useState(invitation.rsvpClosed);
const hasChanges = guests.some((g) => g.status !== "pending");
const handleGuestToggle = (guestId: string, status: "confirmed" | "declined") => {
setGuests((prev) =>
prev.map((g) =>
g.id === guestId ? { ...g, status: g.status === status ? "pending" : status } : g
)
);
};
const handleSubmit = async () => {
setSubmitting(true);
setSubmitError(false);
try {
const result = await submitRsvp({
token: invitation.token,
responses: guests
.filter((g) => g.status !== "pending")
.map((g) => ({ guestId: g.id, status: g.status as "confirmed" | "declined" })),
});
if (result.success) {
setShowSuccess(true);
} else if (result.closed) {
// O prazo acabou com a página aberta: pendentes passam a ausentes
setClosed(true);
setGuests((prev) => prev.map((g) => (g.status === "pending" ? { ...g, status: "declined" } : g)));
} else {
setSubmitError(true);
}
} catch (err) {
console.error("Erro ao enviar RSVP:", err);
setSubmitError(true);
} finally {
setSubmitting(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-b from-amber-50 via-white to-emerald-50 py-8 px-4 sm:px-6 lg:px-8">
<main className="max-w-2xl mx-auto space-y-6">
<InvitationHero displayName={invitation.displayName} />
<EventDetails event={invitation.event} />
<GuestList guests={guests} onGuestToggle={handleGuestToggle} submitted={showSuccess} locked={closed} />
<RsvpSelector
guests={guests}
onSubmit={handleSubmit}
submitting={submitting}
hasChanges={hasChanges}
deadline={invitation.event.rsvpDeadline}
closed={closed}
/>
{submitError && (
<p className="text-center text-sm text-rose-600" role="alert">
Não foi possível enviar sua resposta. Tente novamente.
</p>
)}
<InvitationFooter />
</main>
{showSuccess && (
<ConfirmationSuccess
guests={guests}
displayName={invitation.displayName}
onClose={() => setShowSuccess(false)}
/>
)}
<style jsx>{`
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-scale-in {
animation: scale-in 0.3s ease-out;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { Button } from "@/components/ui";
interface RsvpSelectorProps {
guests: { id: string; name: string; status: "pending" | "confirmed" | "declined" }[];
onSubmit: () => void;
submitting: boolean;
hasChanges: boolean;
deadline?: string;
closed?: boolean;
}
export function RsvpSelector({ guests, onSubmit, submitting, hasChanges, deadline, closed }: RsvpSelectorProps) {
const confirmedCount = guests.filter((g) => g.status === "confirmed").length;
const declinedCount = guests.filter((g) => g.status === "declined").length;
const pendingCount = guests.filter((g) => g.status === "pending").length;
const canSubmit = (confirmedCount + declinedCount) > 0;
return (
<section className="space-y-4 pt-4 border-t border-amber-200">
<h2 className="text-2xl font-bold text-amber-900 text-center">Confirmação de Presença</h2>
<div className="grid grid-cols-3 gap-3 text-center">
<div className="bg-emerald-50 rounded-xl p-4 border border-emerald-100">
<div className="text-3xl font-bold text-emerald-700">{confirmedCount}</div>
<div className="text-xs text-emerald-600 font-medium">Presentes</div>
</div>
<div className="bg-rose-50 rounded-xl p-4 border border-rose-100">
<div className="text-3xl font-bold text-rose-700">{declinedCount}</div>
<div className="text-xs text-rose-600 font-medium">Ausentes</div>
</div>
<div className="bg-amber-50 rounded-xl p-4 border border-amber-100">
<div className="text-3xl font-bold text-amber-700">{pendingCount}</div>
<div className="text-xs text-amber-600 font-medium">Pendentes</div>
</div>
</div>
{closed ? (
<p className="text-center text-rose-700 bg-rose-50 border border-rose-100 rounded-xl p-4 text-sm font-medium">
🔒 O prazo para confirmar presença terminou{deadline && ` em ${deadline}`}. O convite não aceita mais
alterações.
</p>
) : (
<>
{deadline && (
<p className="text-center text-amber-700 text-sm font-medium">⏰ Confirme até {deadline}</p>
)}
{!hasChanges && (
<p className="text-center text-amber-600 text-sm">
Selecione a presença dos convidados acima para ativar o botão
</p>
)}
<Button
onClick={onSubmit}
disabled={!canSubmit || submitting}
size="lg"
className="w-full"
variant="primary"
>
{submitting ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Enviando...
</>
) : (
"Confirmar Presença"
)}
</Button>
{canSubmit && !submitting && (
<p className="text-center text-xs text-amber-500">
Você pode alterar as seleções antes de enviar
</p>
)}
</>
)}
</section>
);
}

View File

@@ -0,0 +1,7 @@
export { InvitationHero } from "./InvitationHero";
export { EventDetails } from "./EventDetails";
export { GuestList } from "./GuestList";
export { RsvpSelector } from "./RsvpSelector";
export { ConfirmationSuccess } from "./ConfirmationSuccess";
export { InvitationFooter } from "./InvitationFooter";
export { InvitationView } from "./InvitationView";

View File

@@ -0,0 +1,47 @@
import * as React from "react";
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "outline";
size?: "sm" | "md" | "lg";
}) {
const { children, className = "", variant = "primary", size = "md", disabled = false, ...rest } = props;
const baseStyles =
"inline-flex items-center justify-center font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed";
const variants = {
primary: "bg-amber-500 text-white hover:bg-amber-600 focus:ring-amber-500 shadow-lg shadow-amber-500/30",
secondary: "bg-emerald-500 text-white hover:bg-emerald-600 focus:ring-emerald-500 shadow-lg shadow-emerald-500/30",
outline: "border-2 border-amber-500 text-amber-700 hover:bg-amber-50 focus:ring-amber-500",
};
const sizes = {
sm: "px-4 py-2 text-sm",
md: "px-6 py-3 text-base",
lg: "px-8 py-4 text-lg",
};
const combinedClassName = [baseStyles, variants[variant], sizes[size], className].filter(Boolean).join(" ");
return React.createElement(
"button",
{
className: combinedClassName,
disabled,
...rest,
},
children
);
}
export function Card(props: React.HTMLAttributes<HTMLDivElement>) {
const { children, className = "", ...rest } = props;
const cardClassName = ["bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-amber-100 p-6", className].filter(Boolean).join(" ");
return React.createElement("div", { className: cardClassName, ...rest }, children);
}
export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
const { className = "", ...rest } = props;
const inputClassName = ["w-full px-4 py-3 rounded-xl border-2 border-amber-200 focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 transition-colors", className].filter(Boolean).join(" ");
return React.createElement("input", { className: inputClassName, ...rest });
}

23
src/db/index.ts Normal file
View File

@@ -0,0 +1,23 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
// Reaproveita o pool entre hot reloads do `next dev` para não esgotar conexões
const globalForDb = globalThis as unknown as { pgPool?: Pool };
function createPool(): Pool {
if (!process.env.DATABASE_URL) {
console.error("DATABASE_URL não definida. Copie .env.example para .env e configure a conexão.");
}
// O Pool só conecta na primeira consulta, então importar este módulo no `next build` é seguro
return new Pool({ connectionString: process.env.DATABASE_URL });
}
const pool = globalForDb.pgPool ?? createPool();
if (process.env.NODE_ENV !== "production") {
globalForDb.pgPool = pool;
}
export const db = drizzle(pool, { schema });
export { pool };

53
src/db/schema.ts Normal file
View File

@@ -0,0 +1,53 @@
import { relations } from "drizzle-orm";
import { date, integer, pgEnum, pgTable, text, time, timestamp, uuid } from "drizzle-orm/pg-core";
export const guestTypeEnum = pgEnum("guest_type", ["adult", "teen", "child"]);
export const rsvpStatusEnum = pgEnum("rsvp_status", ["pending", "confirmed", "declined"]);
export const events = pgTable("events", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
date: date("date").notNull(),
time: time("time").notNull(),
venue: text("venue").notNull(),
address: text("address").notNull(),
// Último dia para confirmar presença; depois disso os pendentes contam como ausentes
rsvpDeadline: date("rsvp_deadline"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const invitations = pgTable("invitations", {
id: uuid("id").primaryKey().defaultRandom(),
eventId: uuid("event_id")
.notNull()
.references(() => events.id, { onDelete: "cascade" }),
token: text("token").notNull().unique(),
displayName: text("display_name").notNull(),
respondedAt: timestamp("responded_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const guests = pgTable("guests", {
id: uuid("id").primaryKey().defaultRandom(),
invitationId: uuid("invitation_id")
.notNull()
.references(() => invitations.id, { onDelete: "cascade" }),
name: text("name").notNull(),
type: guestTypeEnum("type").notNull(),
status: rsvpStatusEnum("status").notNull().default("pending"),
sortOrder: integer("sort_order").notNull().default(0),
respondedAt: timestamp("responded_at", { withTimezone: true }),
});
export const eventsRelations = relations(events, ({ many }) => ({
invitations: many(invitations),
}));
export const invitationsRelations = relations(invitations, ({ one, many }) => ({
event: one(events, { fields: [invitations.eventId], references: [events.id] }),
guests: many(guests),
}));
export const guestsRelations = relations(guests, ({ one }) => ({
invitation: one(invitations, { fields: [guests.invitationId], references: [invitations.id] }),
}));

76
src/db/seed.ts Normal file
View File

@@ -0,0 +1,76 @@
import { eq } from "drizzle-orm";
import { db, pool } from "./index";
import { events, guests, invitations } from "./schema";
// Dados da POC, usados para popular um banco novo
const seedData = [
{
token: "abc123",
displayName: "Família Silva",
event: {
name: "1º Aniversário do Arthur",
date: "2026-11-15",
time: "16:00",
venue: "Espaço Festa",
address: "Belo Horizonte - MG",
},
guests: [
{ name: "João", type: "adult" },
{ name: "Maria", type: "adult" },
{ name: "Pedro", type: "child" },
],
},
{
token: "xyz789",
displayName: "Família Santos",
event: {
name: "1º Aniversário da Alice",
date: "2026-12-20",
time: "15:00",
venue: "Buffet Kids",
address: "São Paulo - SP",
},
guests: [
{ name: "Carlos", type: "adult" },
{ name: "Ana", type: "adult" },
{ name: "Lucas", type: "child" },
{ name: "Julia", type: "child" },
],
},
] as const;
async function main() {
for (const item of seedData) {
const existing = await db.query.invitations.findFirst({
where: eq(invitations.token, item.token),
});
if (existing) {
console.log(`Convite ${item.token} já existe, pulando.`);
continue;
}
await db.transaction(async (tx) => {
const [event] = await tx.insert(events).values(item.event).returning();
const [invitation] = await tx
.insert(invitations)
.values({ eventId: event.id, token: item.token, displayName: item.displayName })
.returning();
await tx.insert(guests).values(
item.guests.map((g, index) => ({
invitationId: invitation.id,
name: g.name,
type: g.type,
sortOrder: index,
}))
);
});
console.log(`Convite ${item.token} criado.`);
}
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => pool.end());

70
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,70 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
const SESSION_COOKIE = "organizer_session";
const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 dias
function getSecret(): string {
const secret = process.env.SESSION_SECRET;
if (!secret || secret.length < 32) {
throw new Error("SESSION_SECRET não definida (mínimo 32 caracteres).");
}
return secret;
}
function sign(value: string): string {
return createHmac("sha256", getSecret()).update(value).digest("base64url");
}
function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
}
// Compara via HMAC para que o tempo não dependa do tamanho da senha
export function checkOrganizerPassword(password: string): boolean {
const expected = process.env.ORGANIZER_PASSWORD;
if (!expected) {
throw new Error("ORGANIZER_PASSWORD não definida.");
}
return safeEqual(sign(`pwd:${password}`), sign(`pwd:${expected}`));
}
export async function createSession(): Promise<void> {
const expiresAt = Date.now() + SESSION_DURATION_MS;
const payload = String(expiresAt);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, `${payload}.${sign(payload)}`, {
httpOnly: true,
// Atrás do Cloudflare (HTTPS) defina COOKIE_SECURE=true; acesso direto por IP via HTTP precisa de false
secure: process.env.COOKIE_SECURE === "true",
sameSite: "lax",
path: "/",
expires: new Date(expiresAt),
});
}
export async function deleteSession(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE);
}
export async function isOrganizer(): Promise<boolean> {
const cookieStore = await cookies();
const value = cookieStore.get(SESSION_COOKIE)?.value;
if (!value) return false;
const [payload, signature] = value.split(".");
if (!payload || !signature || !safeEqual(signature, sign(payload))) return false;
return Number(payload) > Date.now();
}
// Usar em toda página e Server Action do organizador
export async function requireOrganizer(): Promise<void> {
if (!(await isOrganizer())) {
redirect("/admin/login");
}
}

63
src/lib/invitation.ts Normal file
View File

@@ -0,0 +1,63 @@
import { cache } from "react";
import { asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { guests, invitations } from "@/db/schema";
import { effectiveStatus, isRsvpClosed } from "@/lib/rsvp";
import type { Invitation } from "@/types/invitation";
type InvitationRow = NonNullable<Awaited<ReturnType<typeof findInvitationRow>>>;
const dateFormatter = new Intl.DateTimeFormat("pt-BR", {
day: "numeric",
month: "long",
year: "numeric",
timeZone: "UTC",
});
// "2026-11-15" -> "15 de novembro de 2026"
function formatEventDate(isoDate: string): string {
return dateFormatter.format(new Date(`${isoDate}T00:00:00Z`));
}
// "16:00:00" -> "16:00"
function formatEventTime(time: string): string {
return time.slice(0, 5);
}
function findInvitationRow(token: string) {
return db.query.invitations.findFirst({
where: eq(invitations.token, token),
with: {
event: true,
guests: { orderBy: [asc(guests.sortOrder), asc(guests.name)] },
},
});
}
function toInvitation(row: InvitationRow): Invitation {
const rsvpClosed = isRsvpClosed(row.event.rsvpDeadline);
return {
token: row.token,
displayName: row.displayName,
event: {
name: row.event.name,
date: formatEventDate(row.event.date),
time: formatEventTime(row.event.time),
venue: row.event.venue,
address: row.event.address,
rsvpDeadline: row.event.rsvpDeadline ? formatEventDate(row.event.rsvpDeadline) : undefined,
},
rsvpClosed,
guests: row.guests.map((g) => ({
id: g.id,
name: g.name,
type: g.type,
status: effectiveStatus(g.status, rsvpClosed),
})),
};
}
export const getInvitationByToken = cache(async (token: string): Promise<Invitation | undefined> => {
const row = await findInvitationRow(token);
return row ? toInvitation(row) : undefined;
});

125
src/lib/organizer.ts Normal file
View File

@@ -0,0 +1,125 @@
import { asc, desc, eq } from "drizzle-orm";
import { db } from "@/db";
import { events, guests, invitations } from "@/db/schema";
import { effectiveStatus, isRsvpClosed } from "@/lib/rsvp";
import type { GuestType, RsvpStatus } from "@/types/invitation";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Evita erro do Postgres ao consultar uuid com texto inválido vindo da URL
export function isUuid(value: unknown): value is string {
return typeof value === "string" && UUID_RE.test(value);
}
export interface RsvpStats {
confirmed: number;
declined: number;
pending: number;
confirmedAdults: number;
confirmedTeens: number;
confirmedChildren: number;
}
const confirmedKey = { adult: "confirmedAdults", teen: "confirmedTeens", child: "confirmedChildren" } as const;
function computeStats(list: { type: GuestType; status: RsvpStatus }[]): RsvpStats {
const stats: RsvpStats = {
confirmed: 0,
declined: 0,
pending: 0,
confirmedAdults: 0,
confirmedTeens: 0,
confirmedChildren: 0,
};
for (const g of list) {
stats[g.status]++;
if (g.status === "confirmed") stats[confirmedKey[g.type]]++;
}
return stats;
}
export async function listEventsWithStats() {
const rows = await db.query.events.findMany({
orderBy: [desc(events.date)],
with: {
invitations: { columns: { id: true }, with: { guests: { columns: { type: true, status: true } } } },
},
});
return rows.map((event) => {
const rsvpClosed = isRsvpClosed(event.rsvpDeadline);
return {
id: event.id,
name: event.name,
date: event.date,
time: event.time.slice(0, 5),
venue: event.venue,
rsvpDeadline: event.rsvpDeadline,
rsvpClosed,
invitationCount: event.invitations.length,
stats: computeStats(
event.invitations.flatMap((inv) =>
inv.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed) }))
)
),
};
});
}
export async function getEventWithInvitations(eventId: string) {
if (!isUuid(eventId)) return undefined;
const event = await db.query.events.findFirst({
where: eq(events.id, eventId),
with: {
invitations: {
orderBy: [asc(invitations.displayName)],
with: { guests: { orderBy: [asc(guests.sortOrder), asc(guests.name)] } },
},
},
});
if (!event) return undefined;
const rsvpClosed = isRsvpClosed(event.rsvpDeadline);
const invitationList = event.invitations.map((inv) => {
const guestList = inv.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed) }));
return { ...inv, guests: guestList, stats: computeStats(guestList) };
});
return {
...event,
time: event.time.slice(0, 5),
rsvpClosed,
stats: computeStats(invitationList.flatMap((inv) => inv.guests)),
invitations: invitationList,
};
}
export async function getEvent(eventId: string) {
if (!isUuid(eventId)) return undefined;
const event = await db.query.events.findFirst({ where: eq(events.id, eventId) });
return event ? { ...event, time: event.time.slice(0, 5) } : undefined;
}
export async function getInvitationForEdit(invitationId: string) {
if (!isUuid(invitationId)) return undefined;
const invitation = await db.query.invitations.findFirst({
where: eq(invitations.id, invitationId),
with: {
event: { columns: { id: true, name: true, rsvpDeadline: true } },
guests: { orderBy: [asc(guests.sortOrder), asc(guests.name)] },
},
});
if (!invitation) return undefined;
const rsvpClosed = isRsvpClosed(invitation.event.rsvpDeadline);
return {
...invitation,
guests: invitation.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed) })),
};
}
// "2026-11-15" -> "15/11/2026"
export function formatShortDate(isoDate: string): string {
const [y, m, d] = isoDate.split("-");
return `${d}/${m}/${y}`;
}

14
src/lib/rsvp.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { RsvpStatus } from "@/types/invitation";
// en-CA formata como "2026-11-15", comparável direto com a coluna date
const todayFormatter = new Intl.DateTimeFormat("en-CA", { timeZone: "America/Sao_Paulo" });
// O prazo vale até o fim do dia informado, no horário de Brasília
export function isRsvpClosed(deadline: string | null, now = new Date()): boolean {
return deadline !== null && todayFormatter.format(now) > deadline;
}
// Encerrado o prazo, quem não respondeu conta como ausente
export function effectiveStatus(status: RsvpStatus, closed: boolean): RsvpStatus {
return closed && status === "pending" ? "declined" : status;
}

35
src/types/invitation.ts Normal file
View File

@@ -0,0 +1,35 @@
export type GuestType = "adult" | "teen" | "child";
export type RsvpStatus = "pending" | "confirmed" | "declined";
export interface Guest {
id: string;
name: string;
type: GuestType;
status: RsvpStatus;
}
export interface InvitationEvent {
name: string;
date: string;
time: string;
venue: string;
address: string;
rsvpDeadline?: string;
}
export interface Invitation {
token: string;
event: InvitationEvent;
displayName: string;
guests: Guest[];
rsvpClosed: boolean;
}
export interface RsvpSubmission {
token: string;
responses: {
guestId: string;
status: "confirmed" | "declined";
}[];
}

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}