projeto base completo
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal 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
41
.gitignore
vendored
Normal 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
9
AGENTS.md
Normal 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
11
CLAUDE.md
Normal 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
45
Dockerfile
Normal 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
221
README.md
Normal 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
37
compose.yml
Normal 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
11
drizzle.config.ts
Normal 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
34
drizzle/0000_init.sql
Normal 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;
|
||||
2
drizzle/0001_teen_and_rsvp_deadline.sql
Normal file
2
drizzle/0001_teen_and_rsvp_deadline.sql
Normal 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;
|
||||
241
drizzle/meta/0000_snapshot.json
Normal file
241
drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
248
drizzle/meta/0001_snapshot.json
Normal file
248
drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
20
drizzle/meta/_journal.json
Normal file
20
drizzle/meta/_journal.json
Normal 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
18
eslint.config.mjs
Normal 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
505
instrucoes.md
Normal 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
10
next.config.ts
Normal 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;
|
||||
626
openspec-poc-convite-aniversario.md
Normal file
626
openspec-poc-convite-aniversario.md
Normal 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
8693
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
package.json
Normal file
38
package.json
Normal 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
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/next.svg
Normal 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
1
public/vercel.svg
Normal 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
1
public/window.svg
Normal 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
23
scripts/migrate.ts
Normal 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);
|
||||
});
|
||||
35
src/app/admin/(painel)/convites/[id]/editar/page.tsx
Normal file
35
src/app/admin/(painel)/convites/[id]/editar/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/app/admin/(painel)/eventos/[id]/convites/novo/page.tsx
Normal file
28
src/app/admin/(painel)/eventos/[id]/convites/novo/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/app/admin/(painel)/eventos/[id]/editar/page.tsx
Normal file
43
src/app/admin/(painel)/eventos/[id]/editar/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
src/app/admin/(painel)/eventos/[id]/page.tsx
Normal file
147
src/app/admin/(painel)/eventos/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/admin/(painel)/eventos/novo/page.tsx
Normal file
23
src/app/admin/(painel)/eventos/novo/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/app/admin/(painel)/layout.tsx
Normal file
25
src/app/admin/(painel)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/app/admin/(painel)/page.tsx
Normal file
54
src/app/admin/(painel)/page.tsx
Normal 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
235
src/app/admin/actions.ts
Normal 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}`);
|
||||
}
|
||||
22
src/app/admin/auth-actions.ts
Normal file
22
src/app/admin/auth-actions.ts
Normal 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");
|
||||
}
|
||||
24
src/app/admin/login/page.tsx
Normal file
24
src/app/admin/login/page.tsx
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
105
src/app/globals.css
Normal file
105
src/app/globals.css
Normal 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
59
src/app/layout.tsx
Normal 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
25
src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/app/r/[token]/actions.ts
Normal file
59
src/app/r/[token]/actions.ts
Normal 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 };
|
||||
}
|
||||
15
src/app/r/[token]/loading.tsx
Normal file
15
src/app/r/[token]/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/app/r/[token]/not-found.tsx
Normal file
27
src/app/r/[token]/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/app/r/[token]/page.tsx
Normal file
21
src/app/r/[token]/page.tsx
Normal 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} />;
|
||||
}
|
||||
41
src/components/admin/ConfirmActionButton.tsx
Normal file
41
src/components/admin/ConfirmActionButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/components/admin/EventForm.tsx
Normal file
64
src/components/admin/EventForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
src/components/admin/InvitationForm.tsx
Normal file
123
src/components/admin/InvitationForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/admin/LoginForm.tsx
Normal file
26
src/components/admin/LoginForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
src/components/admin/ShareInvitation.tsx
Normal file
68
src/components/admin/ShareInvitation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/components/admin/StatsBar.tsx
Normal file
25
src/components/admin/StatsBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/components/admin/index.ts
Normal file
6
src/components/admin/index.ts
Normal 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";
|
||||
83
src/components/invitation/ConfirmationSuccess.tsx
Normal file
83
src/components/invitation/ConfirmationSuccess.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/components/invitation/EventDetails.tsx
Normal file
38
src/components/invitation/EventDetails.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
src/components/invitation/GuestList.tsx
Normal file
122
src/components/invitation/GuestList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/components/invitation/InvitationFooter.tsx
Normal file
17
src/components/invitation/InvitationFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/invitation/InvitationHero.tsx
Normal file
36
src/components/invitation/InvitationHero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/components/invitation/InvitationView.tsx
Normal file
104
src/components/invitation/InvitationView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
src/components/invitation/RsvpSelector.tsx
Normal file
84
src/components/invitation/RsvpSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
src/components/invitation/index.ts
Normal file
7
src/components/invitation/index.ts
Normal 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";
|
||||
47
src/components/ui/index.ts
Normal file
47
src/components/ui/index.ts
Normal 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
23
src/db/index.ts
Normal 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
53
src/db/schema.ts
Normal 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
76
src/db/seed.ts
Normal 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
70
src/lib/auth.ts
Normal 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
63
src/lib/invitation.ts
Normal 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
125
src/lib/organizer.ts
Normal 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
14
src/lib/rsvp.ts
Normal 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
35
src/types/invitation.ts
Normal 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
34
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user