master #1
14
CLAUDE.md
14
CLAUDE.md
@@ -6,6 +6,20 @@
|
||||
- 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.
|
||||
|
||||
## Escopo de arquivos
|
||||
- NUNCA crie, altere, mova ou apague arquivos fora desta pasta do projeto (`C:\Users\BrunoSantos\Documents\Codex\projeto prl2`).
|
||||
- Isso inclui o `~/.claude.json`, `~/.claude/`, a pasta Downloads e o OneDrive.
|
||||
- Arquivos externos podem ser apenas lidos, e só quando necessário. Ao lê-los, não exiba senhas, tokens nem outros segredos.
|
||||
- Para desenvolver e testar, use dados fictícios ou cópias anonimizadas dentro do projeto, nunca dados reais.
|
||||
- Instalação de dependências (`npm install`) deve ficar restrita a esta pasta. Não use instalação global (`-g`).
|
||||
- Exceção: arquivos temporários no diretório de scratchpad da sessão.
|
||||
|
||||
## Configurações do Claude
|
||||
- Este projeto NÃO altera configurações gerais do Claude. Isso vale para qualquer escopo: user, global ou de outros projetos.
|
||||
- Não modificar `~/.claude/settings.json`, `~/.claude/CLAUDE.md`, `~/.claude.json` (inclusive `mcpServers`), permissões, hooks, keybindings, skills, plugins, memória persistente nem preferências do app (ccd_settings, `/config`).
|
||||
- Não usar comandos que alteram essas configurações, como `claude mcp add/remove` e `claude config set`.
|
||||
- Se alguma mudança desse tipo for necessária, apenas descreva o que deve ser feito e deixe a execução para o usuário.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -14,7 +14,7 @@ Este manual explica como **criar eventos, montar e enviar convites e acompanhar
|
||||
- 2.4 [Criar um convite](#24-criar-um-convite) · 2.5 [Enviar o convite](#25-enviar-o-convite) · 2.5.1 [Personalizar a mensagem do WhatsApp](#251-personalizar-a-mensagem-do-whatsapp)
|
||||
- 2.6 [Acompanhar as respostas](#26-acompanhar-as-respostas) · 2.7 [Editar um convite](#27-editar-um-convite) · 2.8 [Zerar as respostas](#28-zerar-as-respostas-de-um-convite)
|
||||
- 2.9 [Excluir um convite](#29-excluir-um-convite) · 2.10 [Editar ou excluir um evento](#210-editar-ou-excluir-um-evento) · 2.11 [PDFs dos convites](#211-pdfs-dos-convites)
|
||||
- 2.12 [Prazo de confirmação](#212-prazo-de-confirmação-como-funciona) · 2.13 [Bloquear um convite](#213-bloquear-um-convite)
|
||||
- 2.12 [Prazo de confirmação](#212-prazo-de-confirmação-como-funciona)
|
||||
3. [Experiência do convidado](#3-experiência-do-convidado)
|
||||
4. [Perguntas frequentes](#4-perguntas-frequentes)
|
||||
5. [Quando falar com o administrador](#5-quando-falar-com-o-administrador)
|
||||
@@ -91,7 +91,6 @@ Clique em um evento para abrir os detalhes.
|
||||
- **Prazo para confirmar** (opcional) — último dia para responder
|
||||
- **Tema do convite** — a prévia logo abaixo mostra as cores e os ícones escolhidos
|
||||
- **Texto do selo** e **Texto do rodapé** (opcionais) — em branco, usam o texto padrão do tema
|
||||
- **Imagem de fundo** (opcional) — JPG, PNG ou WebP, até **3 MB**. Aparece no topo do convite (no cartão com o nome da família), com um véu claro por cima para o texto continuar legível, e também no card de pré-visualização do WhatsApp. Para tirar a imagem, ao editar o evento, clique em **Remover imagem de fundo** e depois em **Salvar alterações**. Prefira fotos leves: no WhatsApp, cards com imagem muito pesada podem aparecer sem foto
|
||||
- **Mensagem do WhatsApp** (opcional) — texto enviado pelo botão **WhatsApp** de cada convite. Em branco, usa a mensagem padrão (ver [2.5.1](#251-personalizar-a-mensagem-do-whatsapp))
|
||||
3. Clique em **Criar evento**. Você será levado à página do evento.
|
||||
|
||||
@@ -232,6 +231,14 @@ Confirme a presença: {link}
|
||||
|
||||
Quebras de linha e emojis também são mantidos.
|
||||
|
||||
**Emojis** — abaixo dos botões de variáveis há uma linha **Emojis:** com os mais usados em convites (🎉 🎈 🎂 🎁 📅 ⏰ 📍…) e os ícones dos temas. Clique em um para inseri-lo na posição do cursor. Para outros emojis, use o teclado de emojis do computador ou do celular:
|
||||
|
||||
- **Windows:** tecla **Windows** + **.** (ponto);
|
||||
- **Mac:** **Ctrl** + **Cmd** + **Espaço**;
|
||||
- **Celular:** o botão de emoji do teclado.
|
||||
|
||||
> Os emojis da linha **Emojis:** aparecem certos em praticamente qualquer celular. Emojis muito novos, escolhidos pelo teclado, podem aparecer como um quadrado em aparelhos desatualizados. Lembre também que cada emoji ocupa até 12 caracteres no link do WhatsApp (ver as regras abaixo).
|
||||
|
||||
**Exemplo:**
|
||||
|
||||
```
|
||||
@@ -413,25 +420,6 @@ Pontos importantes:
|
||||
- **Prazo vencido com a página aberta:** se o convidado deixar a página aberta, o prazo passar e ele tentar enviar, a resposta é recusada. A página passa para o modo encerrado e mostra os pendentes como ausentes.
|
||||
- **Zerar respostas** depois do prazo volta todos para pendente no sistema, mas eles continuam aparecendo como ausentes enquanto o prazo estiver encerrado.
|
||||
|
||||
### 2.13 Bloquear um convite
|
||||
|
||||
Use para encerrar as respostas de **um convite só**, sem mudar o prazo do evento nem os outros convites.
|
||||
|
||||
1. Na página do evento, clique em **Bloquear** no convite.
|
||||
2. Confirme. O convite passa a mostrar 🔒 **"Bloqueado — pendentes contam como ausentes"**.
|
||||
|
||||
Enquanto o convite estiver bloqueado:
|
||||
|
||||
- o convidado vê "🔒 O prazo para confirmar presença terminou. O convite não aceita mais alterações." Os botões ✓/✗ e **Confirmar Presença** somem;
|
||||
- se o prazo do evento também já tiver passado, a mensagem é a do prazo, com a data (2.12);
|
||||
- quem não respondeu aparece como **Ausente**, como acontece depois do prazo: no convite, no painel, na página do evento, na edição do convite e no CSV.
|
||||
|
||||
Pontos importantes:
|
||||
|
||||
- **Nada é apagado.** Clique em **Desbloquear** para reverter: os pendentes voltam a aparecer como pendentes e o convite volta a aceitar respostas, se o prazo do evento permitir.
|
||||
- **Bloqueio com a página aberta:** se o convidado deixar a página aberta e tentar enviar depois do bloqueio, a resposta é recusada e a página passa para o modo encerrado.
|
||||
- **Mensagem do WhatsApp:** o botão continua disponível e o texto não muda.
|
||||
|
||||
---
|
||||
|
||||
## 3. Experiência do convidado
|
||||
|
||||
@@ -132,7 +132,6 @@ Definido em `src/db/schema.ts`. As migrations ficam em `drizzle/` e são aplicad
|
||||
| `token` | text, único | Código do link `/r/<token>`: 6 bytes aleatórios em base64url (8 caracteres) |
|
||||
| `display_name` | text | Nome exibido ("Família Silva") |
|
||||
| `responded_at` | timestamptz | Último envio de resposta pela família |
|
||||
| `blocked` | boolean, padrão `false` | Bloqueio individual: encerra as respostas deste convite (ver [Prazo de confirmação](#prazo-de-confirmação)) |
|
||||
| `pdf_status` | enum | `not_generated`, `generating`, `generated` ou `error` |
|
||||
| `pdf_template_version` | integer | Versão do modelo usada na última geração **bem-sucedida** |
|
||||
| `pdf_generated_at` | timestamptz | Data da última geração bem-sucedida (não nulo = existe arquivo para baixar) |
|
||||
@@ -208,13 +207,14 @@ Para mudar o banco: altere `schema.ts`, rode `npm run db:generate` e confira o S
|
||||
- troca `{nome}`, `{evento}`, `{data}`, `{hora}`, `{local}`, `{endereco}`, `{icone}` e `{link}`;
|
||||
- chaves desconhecidas ficam como estão;
|
||||
- sem `{link}` no modelo, o link é acrescentado numa linha no final.
|
||||
- **Envio:** `ShareInvitation` monta o texto com os dados do convite. **Copiar mensagem** copia esse texto; **WhatsApp** abre `whatsappShareUrl(text)` (`https://wa.me/?text=<texto codificado>`). A formatação do WhatsApp (`*negrito*`, `_itálico_`...) passa como texto puro e o próprio app aplica.
|
||||
- **Envio:** `ShareInvitation` monta o texto com os dados do convite. **Copiar mensagem** copia esse texto; **WhatsApp** abre `whatsappShareUrl(text)` (`https://api.whatsapp.com/send?text=<texto codificado>`). A formatação do WhatsApp (`*negrito*`, `_itálico_`...) passa como texto puro e o próprio app aplica.
|
||||
- O link usa `api.whatsapp.com/send`, e não `wa.me`: o redirecionamento do `wa.me` estraga emojis de 4 bytes (🦁, 👑...), que chegavam como "<22>", principalmente no WhatsApp Web/Desktop.
|
||||
- **Limites** (só para a mensagem personalizada):
|
||||
|
||||
| Constante | Valor | O que limita |
|
||||
| --- | --- | --- |
|
||||
| `WHATSAPP_MESSAGE_MAX` | 950 | Caracteres do modelo e do texto final, com as variáveis trocadas |
|
||||
| `WHATSAPP_URL_MAX` | 2300 | Tamanho do link codificado do `wa.me`. Acentos ocupam 6 caracteres e emojis até 12 |
|
||||
| `WHATSAPP_URL_MAX` | 2300 | Tamanho do link codificado (`api.whatsapp.com/send?text=...`). Acentos ocupam 6 caracteres e emojis até 12 |
|
||||
|
||||
O WhatsApp não publica um limite oficial: os valores são margens de segurança.
|
||||
- **Conferência no servidor** (`src/app/admin/actions.ts`, funções `measureWhatsAppMessage` e `whatsappLimitError`):
|
||||
@@ -225,6 +225,7 @@ Para mudar o banco: altere `schema.ts`, rode `npm run db:generate` e confira o S
|
||||
Nas três, o link é estimado com `getAppUrl().origin` mais um código de 8 caracteres (`sampleInviteLink`). Como o botão usa a origem do navegador, o tamanho real pode variar alguns caracteres.
|
||||
- **Formulário** (`WhatsAppMessageField.tsx`):
|
||||
- botões que inserem as variáveis no cursor;
|
||||
- paleta curta de emojis (`quickEmojis`), inserida no cursor da mesma forma, com a dica do teclado de emojis do sistema para os demais. A lista usa só emojis de até Emoji 11.0 (2018) e sem sequências ZWJ, para não virarem quadrado em aparelhos desatualizados;
|
||||
- três contadores: modelo, texto final e link, calculados com dados de exemplo;
|
||||
- prévia em balão que imita a formatação do WhatsApp.
|
||||
|
||||
@@ -239,8 +240,6 @@ Para mudar o banco: altere `schema.ts`, rode `npm run db:generate` e confira o S
|
||||
|
||||
O banco não é alterado. Todas as telas e o CSV usam `effectiveStatus`, então adiar ou apagar o prazo reverte a exibição.
|
||||
|
||||
**Bloqueio individual:** `invitations.blocked` tem o mesmo efeito do prazo vencido, mas só naquele convite (`setInvitationBlocked` em `src/app/admin/actions.ts`). Se só o bloqueio encerrou o convite, a mensagem do convidado sai sem a data; com o prazo também vencido, ela mostra a data.
|
||||
|
||||
### Temas
|
||||
|
||||
`src/lib/themes.ts` define os 15 temas (paleta, selo, ícones). Os componentes do convite usam classes `amber-*` do Tailwind, e `themeStyle()` redefine as variáveis `--color-amber-*` no contêiner. Assim, trocar o tema troca a paleta inteira sem mexer nos componentes. Para criar um tema, acrescente um objeto ao array `themes`.
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE "events" ADD COLUMN "background_version" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "events" ADD COLUMN "background_type" text;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "invitations" ADD COLUMN "blocked" boolean DEFAULT false NOT NULL;
|
||||
@@ -1,492 +0,0 @@
|
||||
{
|
||||
"id": "a154e70d-84be-4724-817c-e8e4dfac48f8",
|
||||
"prevId": "54db5cb2-3221-41c3-876d-2d4ed9fae526",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.events": {
|
||||
"name": "events",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "date",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"time": {
|
||||
"name": "time",
|
||||
"type": "time",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"venue": {
|
||||
"name": "venue",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"address": {
|
||||
"name": "address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"rsvp_deadline": {
|
||||
"name": "rsvp_deadline",
|
||||
"type": "date",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"theme": {
|
||||
"name": "theme",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'safari'"
|
||||
},
|
||||
"badge_text": {
|
||||
"name": "badge_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"footer_text": {
|
||||
"name": "footer_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"whatsapp_message": {
|
||||
"name": "whatsapp_message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"background_version": {
|
||||
"name": "background_version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"background_type": {
|
||||
"name": "background_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"pdf_template_version": {
|
||||
"name": "pdf_template_version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"pdf_template_name": {
|
||||
"name": "pdf_template_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"pdf_template_updated_at": {
|
||||
"name": "pdf_template_updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.guests": {
|
||||
"name": "guests",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"invitation_id": {
|
||||
"name": "invitation_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "guest_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "rsvp_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"responded_at": {
|
||||
"name": "responded_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"guests_invitation_id_invitations_id_fk": {
|
||||
"name": "guests_invitation_id_invitations_id_fk",
|
||||
"tableFrom": "guests",
|
||||
"tableTo": "invitations",
|
||||
"columnsFrom": [
|
||||
"invitation_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.invitations": {
|
||||
"name": "invitations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"event_id": {
|
||||
"name": "event_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"responded_at": {
|
||||
"name": "responded_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"pdf_status": {
|
||||
"name": "pdf_status",
|
||||
"type": "pdf_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'not_generated'"
|
||||
},
|
||||
"pdf_template_version": {
|
||||
"name": "pdf_template_version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"pdf_generated_at": {
|
||||
"name": "pdf_generated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"pdf_error": {
|
||||
"name": "pdf_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"invitations_event_id_events_id_fk": {
|
||||
"name": "invitations_event_id_events_id_fk",
|
||||
"tableFrom": "invitations",
|
||||
"tableTo": "events",
|
||||
"columnsFrom": [
|
||||
"event_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"invitations_token_unique": {
|
||||
"name": "invitations_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.organizer_sessions": {
|
||||
"name": "organizer_sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.pdf_jobs": {
|
||||
"name": "pdf_jobs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"event_id": {
|
||||
"name": "event_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "pdf_job_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'running'"
|
||||
},
|
||||
"invitation_ids": {
|
||||
"name": "invitation_ids",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"total": {
|
||||
"name": "total",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"processed": {
|
||||
"name": "processed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"failed": {
|
||||
"name": "failed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"finished_at": {
|
||||
"name": "finished_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"pdf_jobs_one_running_per_event": {
|
||||
"name": "pdf_jobs_one_running_per_event",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "event_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": "\"pdf_jobs\".\"status\" = 'running'",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"pdf_jobs_event_id_events_id_fk": {
|
||||
"name": "pdf_jobs_event_id_events_id_fk",
|
||||
"tableFrom": "pdf_jobs",
|
||||
"tableTo": "events",
|
||||
"columnsFrom": [
|
||||
"event_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.guest_type": {
|
||||
"name": "guest_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"adult",
|
||||
"teen",
|
||||
"child"
|
||||
]
|
||||
},
|
||||
"public.pdf_job_status": {
|
||||
"name": "pdf_job_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"running",
|
||||
"finished",
|
||||
"interrupted"
|
||||
]
|
||||
},
|
||||
"public.pdf_status": {
|
||||
"name": "pdf_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"not_generated",
|
||||
"generating",
|
||||
"generated",
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"public.rsvp_status": {
|
||||
"name": "rsvp_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"pending",
|
||||
"confirmed",
|
||||
"declined"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -1,499 +0,0 @@
|
||||
{
|
||||
"id": "ded69e84-e2c4-46b9-93cd-b2a10a1b8656",
|
||||
"prevId": "a154e70d-84be-4724-817c-e8e4dfac48f8",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.events": {
|
||||
"name": "events",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "date",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"time": {
|
||||
"name": "time",
|
||||
"type": "time",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"venue": {
|
||||
"name": "venue",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"address": {
|
||||
"name": "address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"rsvp_deadline": {
|
||||
"name": "rsvp_deadline",
|
||||
"type": "date",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"theme": {
|
||||
"name": "theme",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'safari'"
|
||||
},
|
||||
"badge_text": {
|
||||
"name": "badge_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"footer_text": {
|
||||
"name": "footer_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"whatsapp_message": {
|
||||
"name": "whatsapp_message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"background_version": {
|
||||
"name": "background_version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"background_type": {
|
||||
"name": "background_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"pdf_template_version": {
|
||||
"name": "pdf_template_version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"pdf_template_name": {
|
||||
"name": "pdf_template_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"pdf_template_updated_at": {
|
||||
"name": "pdf_template_updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.guests": {
|
||||
"name": "guests",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"invitation_id": {
|
||||
"name": "invitation_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "guest_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "rsvp_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"responded_at": {
|
||||
"name": "responded_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"guests_invitation_id_invitations_id_fk": {
|
||||
"name": "guests_invitation_id_invitations_id_fk",
|
||||
"tableFrom": "guests",
|
||||
"tableTo": "invitations",
|
||||
"columnsFrom": [
|
||||
"invitation_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.invitations": {
|
||||
"name": "invitations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"event_id": {
|
||||
"name": "event_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"responded_at": {
|
||||
"name": "responded_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"blocked": {
|
||||
"name": "blocked",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"pdf_status": {
|
||||
"name": "pdf_status",
|
||||
"type": "pdf_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'not_generated'"
|
||||
},
|
||||
"pdf_template_version": {
|
||||
"name": "pdf_template_version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"pdf_generated_at": {
|
||||
"name": "pdf_generated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"pdf_error": {
|
||||
"name": "pdf_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"invitations_event_id_events_id_fk": {
|
||||
"name": "invitations_event_id_events_id_fk",
|
||||
"tableFrom": "invitations",
|
||||
"tableTo": "events",
|
||||
"columnsFrom": [
|
||||
"event_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"invitations_token_unique": {
|
||||
"name": "invitations_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.organizer_sessions": {
|
||||
"name": "organizer_sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.pdf_jobs": {
|
||||
"name": "pdf_jobs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"event_id": {
|
||||
"name": "event_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "pdf_job_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'running'"
|
||||
},
|
||||
"invitation_ids": {
|
||||
"name": "invitation_ids",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"total": {
|
||||
"name": "total",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"processed": {
|
||||
"name": "processed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"failed": {
|
||||
"name": "failed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"finished_at": {
|
||||
"name": "finished_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"pdf_jobs_one_running_per_event": {
|
||||
"name": "pdf_jobs_one_running_per_event",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "event_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": "\"pdf_jobs\".\"status\" = 'running'",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"pdf_jobs_event_id_events_id_fk": {
|
||||
"name": "pdf_jobs_event_id_events_id_fk",
|
||||
"tableFrom": "pdf_jobs",
|
||||
"tableTo": "events",
|
||||
"columnsFrom": [
|
||||
"event_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.guest_type": {
|
||||
"name": "guest_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"adult",
|
||||
"teen",
|
||||
"child"
|
||||
]
|
||||
},
|
||||
"public.pdf_job_status": {
|
||||
"name": "pdf_job_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"running",
|
||||
"finished",
|
||||
"interrupted"
|
||||
]
|
||||
},
|
||||
"public.pdf_status": {
|
||||
"name": "pdf_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"not_generated",
|
||||
"generating",
|
||||
"generated",
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"public.rsvp_status": {
|
||||
"name": "rsvp_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"pending",
|
||||
"confirmed",
|
||||
"declined"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -50,20 +50,6 @@
|
||||
"when": 1791378372969,
|
||||
"tag": "0006_event_whatsapp_message",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1792548033540,
|
||||
"tag": "0007_event_background",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1793414801713,
|
||||
"tag": "0008_invitation_blocked",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -32,6 +32,8 @@ Este documento reúne **tudo o que é preciso para instalar, publicar e manter**
|
||||
12. [Versão de 05/10/2026](#12-versão-de-05102026)
|
||||
13. [Versão de 06/10/2026 — PDFs dos convites](#13-versão-de-06102026--pdfs-dos-convites)
|
||||
14. [Versão de 07/10/2026 — Mensagem do WhatsApp personalizada](#14-versão-de-07102026--mensagem-do-whatsapp-personalizada)
|
||||
15. [Versão de 09/10/2026 — Ícone da mensagem do WhatsApp](#15-versão-de-09102026--ícone-da-mensagem-do-whatsapp)
|
||||
16. [Versão de 10/10/2026 — Emojis na mensagem do WhatsApp](#16-versão-de-10102026--emojis-na-mensagem-do-whatsapp)
|
||||
|
||||
---
|
||||
|
||||
@@ -760,3 +762,86 @@ Uso detalhado na [seção 2.5.1 do MANUAL-USUARIO.md](MANUAL-USUARIO.md#251-pers
|
||||
- em um evento existente com o campo vazio, clique em **WhatsApp** num convite: deve aparecer a nova mensagem padrão ("Chegou o seu convite para: …");
|
||||
- em **Editar evento**, escreva uma mensagem com `*negrito*` e `{nome}` e confira a pré-visualização;
|
||||
- salve, clique em **WhatsApp** e confira no WhatsApp o nome da família, a formatação e o link.
|
||||
|
||||
---
|
||||
|
||||
## 15. Versão de 09/10/2026 — Ícone da mensagem do WhatsApp
|
||||
|
||||
### 15.1 Resumo
|
||||
|
||||
| # | Alteração | Para quem muda algo | Exige ação no deploy? |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Ícone do tema (`{icone}`) e outros emojis não chegam mais como "<22>" na mensagem do botão **WhatsApp** | Organizador | Não (só reconstruir o app) |
|
||||
|
||||
---
|
||||
|
||||
### 15.2 O que mudou
|
||||
|
||||
**Antes:** o botão **WhatsApp** abria `https://wa.me/?text=...`. O `wa.me` redireciona para o `api.whatsapp.com` e, nesse caminho, emojis de 4 bytes (🦁, 👑, 🦖, 🚀 e a maioria dos ícones dos temas) podiam chegar estragados, como "<22>", principalmente no WhatsApp Web e no WhatsApp Desktop.
|
||||
|
||||
**Agora:** o botão abre direto `https://api.whatsapp.com/send?text=...`, sem o redirecionamento. O texto, a formatação e o link da mensagem continuam iguais.
|
||||
|
||||
- O limite de **2300 caracteres no link codificado** (ver 14.2) continua o mesmo. Como o endereço novo tem 16 caracteres a mais, sobram 16 caracteres a menos para o texto; na prática não faz diferença.
|
||||
- Mensagens já enviadas não mudam.
|
||||
|
||||
**Arquivos alterados:**
|
||||
|
||||
- `src/lib/whatsapp-message.ts` (`whatsappShareUrl` passa a usar `api.whatsapp.com/send`);
|
||||
- `src/app/admin/actions.ts` (só comentário);
|
||||
- `README.md` (seção Mensagem do WhatsApp).
|
||||
|
||||
---
|
||||
|
||||
### 15.3 Roteiro de atualização desta versão
|
||||
|
||||
1. **Copie o código novo** para o servidor (mesmo `rsync` da seção 3). Esta versão não altera o banco.
|
||||
2. **Reconstrua e suba o app:**
|
||||
|
||||
```bash
|
||||
docker compose up -d --build app
|
||||
```
|
||||
|
||||
3. **Valide:** num evento com tema de emoji de 4 bytes (ex.: Safari Party 🦁 ou Reino Encantado 👑), clique em **WhatsApp** num convite e confira se o ícone aparece certo na mensagem, no celular e no WhatsApp Web/Desktop.
|
||||
|
||||
---
|
||||
|
||||
## 16. Versão de 10/10/2026 — Emojis na mensagem do WhatsApp
|
||||
|
||||
### 16.1 Resumo
|
||||
|
||||
| # | Alteração | Para quem muda algo | Exige ação no deploy? |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Linha **Emojis:** no campo **Mensagem do WhatsApp**, com botões que inserem emojis no cursor | Organizador | Não (só reconstruir o app) |
|
||||
|
||||
---
|
||||
|
||||
### 16.2 O que mudou
|
||||
|
||||
**Antes:** para colocar emojis na mensagem, o organizador precisava conhecer o teclado de emojis do sistema ou copiar e colar de outro lugar.
|
||||
|
||||
**Agora:**
|
||||
|
||||
- Abaixo dos botões de variáveis há a linha **Emojis:**, com 36 emojis de festa e de informação (🎉 🎈 🎂 🎁 📅 ⏰ 📍…) e os ícones dos temas. Um clique insere o emoji na posição do cursor, como os botões de variáveis.
|
||||
- A mesma linha mostra como abrir o teclado de emojis do sistema para os demais (**Win + .** no Windows, **Ctrl + Cmd + Espaço** no Mac ou o teclado do celular).
|
||||
- A lista usa só emojis antigos (Emoji 11.0, de 2018, ou anterior) e sem combinações, para aparecerem certos mesmo em celulares desatualizados.
|
||||
- Nenhuma dependência nova. Limites, contadores e conferência no servidor continuam iguais: cada emoji segue ocupando até 12 caracteres no link (ver 14.2).
|
||||
|
||||
Uso detalhado na [seção 2.5.1 do MANUAL-USUARIO.md](MANUAL-USUARIO.md#251-personalizar-a-mensagem-do-whatsapp).
|
||||
|
||||
**Arquivos alterados:**
|
||||
|
||||
- `src/components/admin/WhatsAppMessageField.tsx` (lista `quickEmojis` e linha **Emojis:**; a inserção no cursor passou a servir para variáveis e emojis);
|
||||
- `README.md` e `MANUAL-USUARIO.md` (seção 2.5.1).
|
||||
|
||||
---
|
||||
|
||||
### 16.3 Roteiro de atualização desta versão
|
||||
|
||||
1. **Copie o código novo** para o servidor (mesmo `rsync` da seção 3). Esta versão não altera o banco.
|
||||
2. **Reconstrua e suba o app:**
|
||||
|
||||
```bash
|
||||
docker compose up -d --build app
|
||||
```
|
||||
|
||||
3. **Valide:** em **Editar evento**, clique em alguns emojis da linha **Emojis:** e confira que entram no cursor e aparecem na pré-visualização. Depois clique em **WhatsApp** num convite e confira os emojis na mensagem.
|
||||
|
||||
@@ -32,7 +32,6 @@ export default async function EditEventPage({ params }: { params: Promise<{ id:
|
||||
badgeText: event.badgeText,
|
||||
footerText: event.footerText,
|
||||
whatsappMessage: event.whatsappMessage,
|
||||
backgroundUrl: event.backgroundType ? `/admin/eventos/${event.id}/fundo?v=${event.backgroundVersion}` : null,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { notFound } from "next/navigation";
|
||||
import { db } from "@/db";
|
||||
import { events } from "@/db/schema";
|
||||
import { isBackgroundType } from "@/lib/background";
|
||||
import { requireOrganizer } from "@/lib/auth";
|
||||
import { isUuid } from "@/lib/organizer";
|
||||
import { backgroundPath, openPdf } from "@/lib/pdf/storage";
|
||||
|
||||
// GET /admin/eventos/<id>/fundo — imagem de fundo atual, para a prévia no formulário do evento
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
await requireOrganizer();
|
||||
const { id } = await params;
|
||||
if (!isUuid(id)) notFound();
|
||||
|
||||
const event = await db.query.events.findFirst({
|
||||
where: eq(events.id, id),
|
||||
columns: { backgroundVersion: true, backgroundType: true },
|
||||
});
|
||||
if (!event || !isBackgroundType(event.backgroundType)) notFound();
|
||||
|
||||
const file = await openPdf(backgroundPath(id, event.backgroundVersion, event.backgroundType));
|
||||
if (!file) notFound();
|
||||
|
||||
return new Response(file.stream, {
|
||||
headers: {
|
||||
"Content-Type": event.backgroundType,
|
||||
"Content-Length": String(file.size),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { AlarmClock, CalendarDays, Clock, Download, Lock, MapPin, Palette } from
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { deleteInvitation, resetInvitationRsvp, setInvitationBlocked } from "@/app/admin/actions";
|
||||
import { deleteInvitation, resetInvitationRsvp } from "@/app/admin/actions";
|
||||
import { uploadPdfTemplate } from "@/app/admin/pdf-actions";
|
||||
import {
|
||||
ConfirmActionButton,
|
||||
@@ -155,12 +155,6 @@ export default async function EventPage({ params }: { params: Promise<{ id: stri
|
||||
Link: /r/{inv.token}
|
||||
{inv.respondedAt && <> · respondeu em {respondedFormatter.format(inv.respondedAt)}</>}
|
||||
</p>
|
||||
{inv.blocked && (
|
||||
<p className="text-xs text-rose-700 font-medium mt-1 flex items-center gap-1">
|
||||
<Lock aria-hidden="true" className="w-3.5 h-3.5 shrink-0" /> Bloqueado — pendentes contam como
|
||||
ausentes
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ShareInvitation
|
||||
token={inv.token}
|
||||
@@ -201,16 +195,6 @@ export default async function EventPage({ params }: { params: Promise<{ id: stri
|
||||
>
|
||||
Abrir convite
|
||||
</Link>
|
||||
<ConfirmActionButton
|
||||
action={setInvitationBlocked.bind(null, inv.id, !inv.blocked)}
|
||||
label={inv.blocked ? "Desbloquear" : "Bloquear"}
|
||||
confirmMessage={
|
||||
inv.blocked
|
||||
? `Desbloquear o convite de "${inv.displayName}"? Ele volta a aceitar respostas, se o prazo do evento permitir.`
|
||||
: `Bloquear o convite de "${inv.displayName}"? Ele deixa de aceitar respostas e os pendentes contam como ausentes.`
|
||||
}
|
||||
variant="neutral"
|
||||
/>
|
||||
{inv.respondedAt && (
|
||||
<ConfirmActionButton
|
||||
action={resetInvitationRsvp.bind(null, inv.id)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { and, eq, inArray, notInArray } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -8,8 +8,7 @@ import { db } from "@/db";
|
||||
import { events, guests, invitations } from "@/db/schema";
|
||||
import { requireOrganizer } from "@/lib/auth";
|
||||
import { isUuid } from "@/lib/organizer";
|
||||
import { BACKGROUND_MAX_MB, detectBackgroundType, type BackgroundType } from "@/lib/background";
|
||||
import { removeEventFiles, removeGeneratedPdf, removeOtherBackgrounds, saveBackground } from "@/lib/pdf/storage";
|
||||
import { removeEventFiles, removeGeneratedPdf } from "@/lib/pdf/storage";
|
||||
import { getAppUrl } from "@/lib/app-url";
|
||||
import { getTheme, isThemeId } from "@/lib/themes";
|
||||
import {
|
||||
@@ -86,7 +85,7 @@ interface WhatsAppEventFields {
|
||||
whatsappMessage: string | null;
|
||||
}
|
||||
|
||||
// Maiores tamanhos (texto final e link do wa.me) da mensagem personalizada do WhatsApp entre os
|
||||
// Maiores tamanhos (texto final e link do WhatsApp) da mensagem personalizada do WhatsApp entre os
|
||||
// nomes de convite informados. O link real usa a origem de quem clica; a estimativa usa o APP_URL.
|
||||
// A mensagem padrão (campo vazio) não é limitada.
|
||||
function measureWhatsAppMessage(event: WhatsAppEventFields, names: string[]): { chars: number; url: number } {
|
||||
@@ -123,30 +122,6 @@ function whatsappLimitError(size: { chars: number; url: number }, context: "even
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- Imagem de fundo ----------
|
||||
|
||||
interface BackgroundUpload {
|
||||
bytes: Uint8Array;
|
||||
type: BackgroundType;
|
||||
}
|
||||
|
||||
// Campos do formulário: "background" (arquivo, opcional) e "removeBackground" (marcado = tirar o fundo atual)
|
||||
async function parseBackground(
|
||||
formData: FormData
|
||||
): Promise<{ error: string } | { upload: BackgroundUpload | null; remove: boolean }> {
|
||||
const remove = formData.get("removeBackground") === "on";
|
||||
const file = formData.get("background");
|
||||
if (!(file instanceof File) || file.size === 0) return { upload: null, remove };
|
||||
|
||||
if (file.size > BACKGROUND_MAX_MB * 1024 * 1024) {
|
||||
return { error: `A imagem de fundo deve ter no máximo ${BACKGROUND_MAX_MB} MB.` };
|
||||
}
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const type = detectBackgroundType(bytes);
|
||||
if (!type) return { error: "A imagem de fundo deve ser JPG, PNG ou WebP." };
|
||||
return { upload: { bytes, type }, remove: false };
|
||||
}
|
||||
|
||||
export async function createEvent(_prev: FormState, formData: FormData): Promise<FormState> {
|
||||
await requireOrganizer();
|
||||
const parsed = parseEvent(formData);
|
||||
@@ -155,26 +130,9 @@ export async function createEvent(_prev: FormState, formData: FormData): Promise
|
||||
const whatsappError = whatsappLimitError(measureWhatsAppMessage(parsed.data, [""]), "evento");
|
||||
if (whatsappError) return { error: whatsappError };
|
||||
|
||||
const background = await parseBackground(formData);
|
||||
if ("error" in background) return { error: background.error };
|
||||
|
||||
// O id nasce aqui para gravar o arquivo antes da linha: se o insert falhar, o arquivo é removido
|
||||
const eventId = randomUUID();
|
||||
if (background.upload) {
|
||||
await saveBackground(eventId, 1, background.upload.type, background.upload.bytes);
|
||||
}
|
||||
try {
|
||||
await db.insert(events).values({
|
||||
...parsed.data,
|
||||
id: eventId,
|
||||
...(background.upload ? { backgroundVersion: 1, backgroundType: background.upload.type } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
await removeEventFiles(eventId).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
const [event] = await db.insert(events).values(parsed.data).returning({ id: events.id });
|
||||
revalidatePath("/admin");
|
||||
redirect(`/admin/eventos/${eventId}`);
|
||||
redirect(`/admin/eventos/${event.id}`);
|
||||
}
|
||||
|
||||
export async function updateEvent(eventId: string, _prev: FormState, formData: FormData): Promise<FormState> {
|
||||
@@ -192,34 +150,7 @@ export async function updateEvent(eventId: string, _prev: FormState, formData: F
|
||||
if (whatsappError) return { error: whatsappError };
|
||||
}
|
||||
|
||||
const background = await parseBackground(formData);
|
||||
if ("error" in background) return { error: background.error };
|
||||
const current = await db.query.events.findFirst({
|
||||
where: eq(events.id, eventId),
|
||||
columns: { backgroundVersion: true, backgroundType: true },
|
||||
});
|
||||
if (!current) return { error: "Evento não encontrado." };
|
||||
|
||||
// A versão nunca volta atrás: a URL do fundo muda a cada troca e o cache dos convidados acompanha
|
||||
let backgroundChange: { backgroundVersion: number; backgroundType: BackgroundType | null } | null = null;
|
||||
if (background.upload) {
|
||||
const version = current.backgroundVersion + 1;
|
||||
await saveBackground(eventId, version, background.upload.type, background.upload.bytes);
|
||||
backgroundChange = { backgroundVersion: version, backgroundType: background.upload.type };
|
||||
} else if (background.remove && current.backgroundType) {
|
||||
backgroundChange = { backgroundVersion: current.backgroundVersion + 1, backgroundType: null };
|
||||
}
|
||||
|
||||
await db
|
||||
.update(events)
|
||||
.set({ ...parsed.data, ...backgroundChange })
|
||||
.where(eq(events.id, eventId));
|
||||
if (backgroundChange) {
|
||||
const keep = backgroundChange.backgroundType
|
||||
? { version: backgroundChange.backgroundVersion, type: backgroundChange.backgroundType }
|
||||
: undefined;
|
||||
await removeOtherBackgrounds(eventId, keep).catch((e) => console.error("Falha ao remover fundos antigos:", e));
|
||||
}
|
||||
await db.update(events).set(parsed.data).where(eq(events.id, eventId));
|
||||
revalidatePath("/admin");
|
||||
revalidatePath(`/admin/eventos/${eventId}`);
|
||||
// O prazo e o tema mudam o que os convidados veem em todos os convites do evento
|
||||
@@ -387,24 +318,6 @@ export async function deleteInvitation(invitationId: string): Promise<void> {
|
||||
redirect(`/admin/eventos/${deleted.eventId}`);
|
||||
}
|
||||
|
||||
// Bloqueia ou libera as respostas de um convite, sem depender do prazo do evento
|
||||
export async function setInvitationBlocked(invitationId: string, blocked: boolean): Promise<void> {
|
||||
await requireOrganizer();
|
||||
if (!isUuid(invitationId)) return;
|
||||
|
||||
const [updated] = await db
|
||||
.update(invitations)
|
||||
.set({ blocked })
|
||||
.where(eq(invitations.id, invitationId))
|
||||
.returning({ eventId: invitations.eventId, token: invitations.token });
|
||||
if (!updated) return;
|
||||
|
||||
// Os pendentes do convite mudam entre pendente e ausente, então os totais do painel também
|
||||
revalidatePath("/admin");
|
||||
revalidatePath(`/admin/eventos/${updated.eventId}`);
|
||||
revalidatePath(`/r/${updated.token}`);
|
||||
}
|
||||
|
||||
// Volta todos os convidados do convite para "pendente"
|
||||
export async function resetInvitationRsvp(invitationId: string): Promise<void> {
|
||||
await requireOrganizer();
|
||||
|
||||
@@ -12,9 +12,7 @@ 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; blocked?: boolean }> {
|
||||
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) {
|
||||
@@ -26,16 +24,14 @@ export async function submitRsvp(
|
||||
|
||||
const invitation = await db.query.invitations.findFirst({
|
||||
where: eq(invitations.token, token),
|
||||
columns: { id: true, blocked: true },
|
||||
columns: { id: true },
|
||||
with: { event: { columns: { rsvpDeadline: true } } },
|
||||
});
|
||||
if (!invitation) {
|
||||
return { success: false };
|
||||
}
|
||||
const deadlinePassed = isRsvpClosed(invitation.event.rsvpDeadline);
|
||||
if (deadlinePassed || invitation.blocked) {
|
||||
// blocked: só o bloqueio encerrou, então a mensagem sai sem a data do prazo
|
||||
return { success: false, closed: true, blocked: !deadlinePassed };
|
||||
if (isRsvpClosed(invitation.event.rsvpDeadline)) {
|
||||
return { success: false, closed: true };
|
||||
}
|
||||
|
||||
const guestIds = responses.map((r) => r.guestId);
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { notFound } from "next/navigation";
|
||||
import { db } from "@/db";
|
||||
import { invitations } from "@/db/schema";
|
||||
import { isBackgroundType } from "@/lib/background";
|
||||
import { backgroundPath, openPdf } from "@/lib/pdf/storage";
|
||||
|
||||
// GET /r/<token>/fundo?v=<versão> — imagem de fundo do evento do convite.
|
||||
// Pública como o próprio convite: quem tem o link já vê a imagem. O caminho nunca vem do navegador.
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = await params;
|
||||
const invitation = await db.query.invitations.findFirst({
|
||||
where: eq(invitations.token, token),
|
||||
columns: {},
|
||||
with: { event: { columns: { id: true, backgroundVersion: true, backgroundType: true } } },
|
||||
});
|
||||
const event = invitation?.event;
|
||||
if (!event || !isBackgroundType(event.backgroundType)) notFound();
|
||||
|
||||
const file = await openPdf(backgroundPath(event.id, event.backgroundVersion, event.backgroundType));
|
||||
if (!file) notFound();
|
||||
|
||||
// Com a versão certa na URL o conteúdo nunca muda; sem ela (ou antiga), cache curto
|
||||
const requested = new URL(request.url).searchParams.get("v");
|
||||
const immutable = requested === String(event.backgroundVersion);
|
||||
return new Response(file.stream, {
|
||||
headers: {
|
||||
"Content-Type": event.backgroundType,
|
||||
"Content-Length": String(file.size),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": immutable ? "public, max-age=31536000, immutable" : "public, max-age=300",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { connection } from "next/server";
|
||||
import { getBackgroundDataUri, getInvitationByToken } from "@/lib/invitation";
|
||||
import { getInvitationByToken } from "@/lib/invitation";
|
||||
import { getTheme } from "@/lib/themes";
|
||||
|
||||
// Imagem do card quando o link do convite é colado no WhatsApp ou em redes sociais.
|
||||
@@ -40,7 +40,6 @@ export default async function Image({ params }: { params: Promise<{ token: strin
|
||||
}
|
||||
|
||||
const { event } = invitation;
|
||||
const background = await getBackgroundDataUri(token);
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
@@ -55,32 +54,8 @@ export default async function Image({ params }: { params: Promise<{ token: strin
|
||||
background: `linear-gradient(180deg, ${p[100]} 0%, #ffffff 55%, ${theme.accentSoft} 100%)`,
|
||||
color: p[900],
|
||||
textAlign: "center",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{background && (
|
||||
<>
|
||||
{/* Foto cobrindo o card, com véu claro por cima para o texto continuar legível */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={background}
|
||||
alt=""
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "rgba(255, 255, 255, 0.6)",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { type ChangeEvent, useActionState, useEffect, useState } from "react";
|
||||
import { useActionState, useState } from "react";
|
||||
import type { FormState } from "@/app/admin/actions";
|
||||
import { Button, Input } from "@/components/ui";
|
||||
import { BACKGROUND_MAX_MB, BACKGROUND_TYPES } from "@/lib/background";
|
||||
import { DEFAULT_THEME_ID, defaultFooterText, getTheme, themeGroups, themes, themeStyle } from "@/lib/themes";
|
||||
import { formatMessageDate, sampleInviteLink } from "@/lib/whatsapp-message";
|
||||
import { WhatsAppMessageField } from "./WhatsAppMessageField";
|
||||
@@ -22,8 +21,6 @@ interface EventFormProps {
|
||||
badgeText: string | null;
|
||||
footerText: string | null;
|
||||
whatsappMessage: string | null;
|
||||
// URL da imagem de fundo atual (rota do admin), ou null se o evento não tem fundo
|
||||
backgroundUrl: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,35 +36,6 @@ export function EventForm({ action, submitLabel, defaults }: EventFormProps) {
|
||||
const [venue, setVenue] = useState(defaults?.venue ?? "");
|
||||
const [address, setAddress] = useState(defaults?.address ?? "");
|
||||
const [whatsappMessage, setWhatsappMessage] = useState(defaults?.whatsappMessage ?? "");
|
||||
const [newBackground, setNewBackground] = useState<string | null>(null);
|
||||
const [removeBackground, setRemoveBackground] = useState(false);
|
||||
const [backgroundError, setBackgroundError] = useState<string | null>(null);
|
||||
|
||||
// Libera a URL temporária da prévia ao trocar de arquivo ou sair da página
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (newBackground) URL.revokeObjectURL(newBackground);
|
||||
};
|
||||
}, [newBackground]);
|
||||
|
||||
const handleBackgroundChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
setBackgroundError(null);
|
||||
if (!file) {
|
||||
setNewBackground(null);
|
||||
return;
|
||||
}
|
||||
if (file.size > BACKGROUND_MAX_MB * 1024 * 1024) {
|
||||
setBackgroundError(`A imagem deve ter no máximo ${BACKGROUND_MAX_MB} MB.`);
|
||||
e.target.value = "";
|
||||
setNewBackground(null);
|
||||
return;
|
||||
}
|
||||
setRemoveBackground(false);
|
||||
setNewBackground(URL.createObjectURL(file));
|
||||
};
|
||||
|
||||
const currentBackground = removeBackground ? null : (newBackground ?? defaults?.backgroundUrl ?? null);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-4">
|
||||
@@ -167,61 +135,6 @@ export function EventForm({ action, submitLabel, defaults }: EventFormProps) {
|
||||
</label>
|
||||
</div>
|
||||
<span className="block -mt-2 text-xs text-slate-500">Em branco, usa o texto padrão do tema.</span>
|
||||
<div className="block">
|
||||
<label htmlFor="background" className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Imagem de fundo (opcional)
|
||||
</label>
|
||||
<input
|
||||
id="background"
|
||||
type="file"
|
||||
name="background"
|
||||
accept={Object.keys(BACKGROUND_TYPES).join(",")}
|
||||
onChange={handleBackgroundChange}
|
||||
className="block w-full text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-slate-100 file:px-3 file:py-2 file:text-sm file:font-medium file:text-slate-700 hover:file:bg-slate-200"
|
||||
/>
|
||||
<span className="block text-xs text-slate-500 mt-1">
|
||||
JPG, PNG ou WebP, até {BACKGROUND_MAX_MB} MB. Aparece no topo do convite, com um véu claro por cima, e também
|
||||
na prévia do link no WhatsApp.
|
||||
</span>
|
||||
{backgroundError && (
|
||||
<span className="block text-xs text-rose-600 mt-1" role="alert">
|
||||
{backgroundError}
|
||||
</span>
|
||||
)}
|
||||
{currentBackground && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={currentBackground}
|
||||
alt="Prévia da imagem de fundo"
|
||||
className="mt-2 h-32 w-full rounded-xl border border-slate-200 object-cover"
|
||||
/>
|
||||
)}
|
||||
{removeBackground && <input type="hidden" name="removeBackground" value="on" />}
|
||||
{defaults?.backgroundUrl && !newBackground && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||
{removeBackground ? (
|
||||
<>
|
||||
<span className="text-sm text-slate-600">A imagem será removida ao salvar.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRemoveBackground(false)}
|
||||
className="text-sm font-medium text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Desfazer
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRemoveBackground(true)}
|
||||
className="rounded-lg border border-rose-200 px-3 py-1.5 text-sm font-medium text-rose-700 hover:bg-rose-50"
|
||||
>
|
||||
Remover imagem de fundo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Prévia: as classes amber-* abaixo assumem as cores do tema escolhido */}
|
||||
<div
|
||||
style={themeStyle(theme)}
|
||||
|
||||
@@ -59,6 +59,15 @@ function formatWhatsApp(text: string): ReactNode[] {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// Emojis de festa e de informação, mais os ícones dos temas. Só emojis antigos (Emoji 11.0, de 2018, ou anterior) e
|
||||
// sem sequências ZWJ, para não virarem quadrado em aparelhos desatualizados. O resto vem do
|
||||
// teclado de emojis do sistema.
|
||||
const quickEmojis = [
|
||||
"🎉", "🎊", "🎈", "🎂", "🍰", "🎁", "🎶", "✨", "🥂", "🍾", "💐", "🌸",
|
||||
"❤️", "💛", "😊", "😍", "🤩", "🙏", "👏", "👉", "📅", "⏰", "📍", "🏠",
|
||||
"🚗", "📞", "✅", "🦁", "👑", "🦸", "🐳", "🦖", "🦄", "🚀", "🐄", "⚽",
|
||||
];
|
||||
|
||||
const formattingTips = [
|
||||
{ syntax: "*texto*", result: <strong>negrito</strong> },
|
||||
{ syntax: "_texto_", result: <em>itálico</em> },
|
||||
@@ -78,9 +87,8 @@ export function WhatsAppMessageField({
|
||||
}) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Insere a variável na posição do cursor
|
||||
const insertVariable = (key: string) => {
|
||||
const token = `{${key}}`;
|
||||
// Insere a variável ou o emoji na posição do cursor
|
||||
const insertText = (token: string) => {
|
||||
const el = textareaRef.current;
|
||||
const start = el?.selectionStart ?? value.length;
|
||||
const end = el?.selectionEnd ?? value.length;
|
||||
@@ -120,13 +128,32 @@ export function WhatsAppMessageField({
|
||||
key={v.key}
|
||||
type="button"
|
||||
title={v.label}
|
||||
onClick={() => insertVariable(v.key)}
|
||||
onClick={() => insertText(`{${v.key}}`)}
|
||||
className="text-xs px-2 py-0.5 rounded-md border border-slate-300 text-slate-600 font-mono hover:bg-slate-50"
|
||||
>
|
||||
{`{${v.key}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-slate-500 mr-0.5">Emojis:</span>
|
||||
{quickEmojis.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
aria-label={`Inserir ${emoji}`}
|
||||
onClick={() => insertText(emoji)}
|
||||
className="w-7 h-7 rounded-md text-base leading-none hover:bg-slate-100"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
<span className="text-xs text-slate-500 ml-1">
|
||||
Outros: <kbd className="font-mono">Win</kbd> + <kbd className="font-mono">.</kbd> no Windows,{" "}
|
||||
<kbd className="font-mono">Ctrl</kbd> + <kbd className="font-mono">Cmd</kbd> +{" "}
|
||||
<kbd className="font-mono">Espaço</kbd> no Mac ou o teclado do celular.
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{formattingTips.map((tip, i) => (
|
||||
<Fragment key={tip.syntax}>
|
||||
|
||||
@@ -4,27 +4,15 @@ export function InvitationHero({
|
||||
displayName,
|
||||
theme,
|
||||
badgeText,
|
||||
backgroundUrl,
|
||||
}: {
|
||||
displayName: string;
|
||||
theme: InvitationTheme;
|
||||
badgeText?: string;
|
||||
backgroundUrl?: string;
|
||||
}) {
|
||||
const patternSvg = "data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23f59e0b' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E";
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-b from-amber-100 via-amber-50 to-white p-8 sm:p-12 text-center">
|
||||
{backgroundUrl && (
|
||||
// Foto cobrindo o card, com um véu claro por cima para o texto continuar legível
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(255,255,255,0.6), rgba(255,255,255,0.6)), url('${backgroundUrl}')`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 opacity-50" style={{ backgroundImage: `url('${patternSvg}')` }} />
|
||||
<div className="absolute top-4 right-4 w-20 h-20 bg-gradient-to-br from-amber-400 to-amber-600 rounded-full opacity-20 blur-2xl" />
|
||||
<div className="absolute bottom-4 left-4 w-16 h-16 bg-(--theme-accent) rounded-full opacity-20 blur-2xl" />
|
||||
|
||||
@@ -17,7 +17,6 @@ export function InvitationView({ invitation }: { invitation: Invitation }) {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState(false);
|
||||
const [closed, setClosed] = useState(invitation.rsvpClosed);
|
||||
const [deadline, setDeadline] = useState(invitation.event.rsvpDeadline);
|
||||
|
||||
const theme = getTheme(invitation.theme);
|
||||
const hasChanges = guests.some((g) => g.status !== "pending");
|
||||
@@ -43,9 +42,8 @@ export function InvitationView({ invitation }: { invitation: Invitation }) {
|
||||
if (result.success) {
|
||||
setShowSuccess(true);
|
||||
} else if (result.closed) {
|
||||
// O prazo acabou ou o convite foi bloqueado com a página aberta: pendentes passam a ausentes
|
||||
// O prazo acabou com a página aberta: pendentes passam a ausentes
|
||||
setClosed(true);
|
||||
if (result.blocked) setDeadline(undefined);
|
||||
setGuests((prev) => prev.map((g) => (g.status === "pending" ? { ...g, status: "declined" } : g)));
|
||||
} else {
|
||||
setSubmitError(true);
|
||||
@@ -64,12 +62,7 @@ export function InvitationView({ invitation }: { invitation: Invitation }) {
|
||||
className="min-h-screen bg-gradient-to-b from-amber-50 via-white to-(--theme-accent-soft) py-8 px-4 sm:px-6 lg:px-8"
|
||||
>
|
||||
<main className="max-w-2xl mx-auto space-y-6">
|
||||
<InvitationHero
|
||||
displayName={invitation.displayName}
|
||||
theme={theme}
|
||||
badgeText={invitation.badgeText}
|
||||
backgroundUrl={invitation.backgroundUrl}
|
||||
/>
|
||||
<InvitationHero displayName={invitation.displayName} theme={theme} badgeText={invitation.badgeText} />
|
||||
<EventDetails event={invitation.event} />
|
||||
<GuestList guests={guests} onGuestToggle={handleGuestToggle} submitted={showSuccess} locked={closed} />
|
||||
<RsvpSelector
|
||||
@@ -77,7 +70,7 @@ export function InvitationView({ invitation }: { invitation: Invitation }) {
|
||||
onSubmit={handleSubmit}
|
||||
submitting={submitting}
|
||||
hasChanges={hasChanges}
|
||||
deadline={deadline}
|
||||
deadline={invitation.event.rsvpDeadline}
|
||||
closed={closed}
|
||||
/>
|
||||
{submitError && (
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import {
|
||||
boolean,
|
||||
date,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
time,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { date, integer, jsonb, pgEnum, pgTable, text, time, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
||||
|
||||
export const guestTypeEnum = pgEnum("guest_type", ["adult", "teen", "child"]);
|
||||
export const rsvpStatusEnum = pgEnum("rsvp_status", ["pending", "confirmed", "declined"]);
|
||||
@@ -34,10 +22,6 @@ export const events = pgTable("events", {
|
||||
footerText: text("footer_text"),
|
||||
// Mensagem do botão WhatsApp, com variáveis {nome}, {link}...; vazia usa o padrão (src/lib/whatsapp-message.ts)
|
||||
whatsappMessage: text("whatsapp_message"),
|
||||
// Imagem de fundo opcional do convite. O arquivo fica no volume de armazenamento (src/lib/pdf/storage.ts);
|
||||
// a versão só sobe (a cada upload) e entra na URL para renovar o cache; sem fundo = tipo nulo.
|
||||
backgroundVersion: integer("background_version").notNull().default(0),
|
||||
backgroundType: text("background_type"),
|
||||
// PDF modelo do convite. O arquivo fica no volume de armazenamento (src/lib/pdf/storage.ts);
|
||||
// aqui só os metadados. A versão sobe a cada upload e identifica PDFs gerados com um modelo antigo.
|
||||
pdfTemplateVersion: integer("pdf_template_version").notNull().default(0),
|
||||
@@ -54,8 +38,6 @@ export const invitations = pgTable("invitations", {
|
||||
token: text("token").notNull().unique(),
|
||||
displayName: text("display_name").notNull(),
|
||||
respondedAt: timestamp("responded_at", { withTimezone: true }),
|
||||
// Bloqueio individual: encerra as respostas deste convite, como o prazo faz com o evento inteiro
|
||||
blocked: boolean("blocked").notNull().default(false),
|
||||
// PDF personalizado: versão do modelo usada na última geração bem-sucedida
|
||||
pdfStatus: pdfStatusEnum("pdf_status").notNull().default("not_generated"),
|
||||
pdfTemplateVersion: integer("pdf_template_version"),
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// Imagem de fundo opcional do convite. Constantes e validação usadas no servidor e no formulário.
|
||||
|
||||
// O limite de corpo das Server Actions (next.config.ts) é bem maior que este valor
|
||||
export const BACKGROUND_MAX_MB = 3;
|
||||
|
||||
export const BACKGROUND_TYPES = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
} as const;
|
||||
|
||||
export type BackgroundType = keyof typeof BACKGROUND_TYPES;
|
||||
|
||||
export function isBackgroundType(value: unknown): value is BackgroundType {
|
||||
return typeof value === "string" && Object.hasOwn(BACKGROUND_TYPES, value);
|
||||
}
|
||||
|
||||
// O tipo informado pelo navegador não é confiável: identifica pela assinatura do arquivo.
|
||||
// SVG fica de fora de propósito (pode carregar script).
|
||||
export function detectBackgroundType(bytes: Uint8Array): BackgroundType | null {
|
||||
const startsWith = (sig: number[], offset = 0) => sig.every((b, i) => bytes[offset + i] === b);
|
||||
if (startsWith([0xff, 0xd8, 0xff])) return "image/jpeg";
|
||||
if (startsWith([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png";
|
||||
// WebP: "RIFF" .... "WEBP"
|
||||
if (startsWith([0x52, 0x49, 0x46, 0x46]) && startsWith([0x57, 0x45, 0x42, 0x50], 8)) return "image/webp";
|
||||
return null;
|
||||
}
|
||||
|
||||
// URL pública do fundo; a versão renova o cache quando o organizador troca a imagem
|
||||
export function backgroundUrl(token: string, version: number): string {
|
||||
return `/r/${encodeURIComponent(token)}/fundo?v=${version}`;
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { cache } from "react";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { guests, invitations } from "@/db/schema";
|
||||
import { backgroundUrl, isBackgroundType } from "@/lib/background";
|
||||
import { backgroundPath } from "@/lib/pdf/storage";
|
||||
import { effectiveStatus, isRsvpClosed } from "@/lib/rsvp";
|
||||
import type { Invitation } from "@/types/invitation";
|
||||
|
||||
@@ -38,10 +35,7 @@ function findInvitationRow(token: string) {
|
||||
}
|
||||
|
||||
function toInvitation(row: InvitationRow): Invitation {
|
||||
const deadlinePassed = isRsvpClosed(row.event.rsvpDeadline);
|
||||
const rsvpClosed = deadlinePassed || row.blocked;
|
||||
// Bloqueado antes de o prazo vencer: a data não explica o encerramento e fica fora da mensagem
|
||||
const rsvpDeadline = deadlinePassed || !row.blocked ? row.event.rsvpDeadline : null;
|
||||
const rsvpClosed = isRsvpClosed(row.event.rsvpDeadline);
|
||||
return {
|
||||
token: row.token,
|
||||
displayName: row.displayName,
|
||||
@@ -51,13 +45,12 @@ function toInvitation(row: InvitationRow): Invitation {
|
||||
time: formatEventTime(row.event.time),
|
||||
venue: row.event.venue,
|
||||
address: row.event.address,
|
||||
rsvpDeadline: rsvpDeadline ? formatEventDate(rsvpDeadline) : undefined,
|
||||
rsvpDeadline: row.event.rsvpDeadline ? formatEventDate(row.event.rsvpDeadline) : undefined,
|
||||
},
|
||||
rsvpClosed,
|
||||
theme: row.event.theme,
|
||||
badgeText: row.event.badgeText ?? undefined,
|
||||
footerText: row.event.footerText ?? undefined,
|
||||
backgroundUrl: row.event.backgroundType ? backgroundUrl(row.token, row.event.backgroundVersion) : undefined,
|
||||
guests: row.guests.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
@@ -71,21 +64,3 @@ export const getInvitationByToken = cache(async (token: string): Promise<Invitat
|
||||
const row = await findInvitationRow(token);
|
||||
return row ? toInvitation(row) : undefined;
|
||||
});
|
||||
|
||||
// Fundo do evento como data URI, para a imagem de pré-visualização (opengraph-image).
|
||||
// Só JPEG e PNG: são os formatos que o gerador de imagens aceita com segurança.
|
||||
export async function getBackgroundDataUri(token: string): Promise<string | undefined> {
|
||||
const row = await db.query.invitations.findFirst({
|
||||
where: eq(invitations.token, token),
|
||||
columns: {},
|
||||
with: { event: { columns: { id: true, backgroundVersion: true, backgroundType: true } } },
|
||||
});
|
||||
const event = row?.event;
|
||||
if (!event || !isBackgroundType(event.backgroundType) || event.backgroundType === "image/webp") return undefined;
|
||||
try {
|
||||
const bytes = await readFile(backgroundPath(event.id, event.backgroundVersion, event.backgroundType));
|
||||
return `data:${event.backgroundType};base64,${bytes.toString("base64")}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,10 +42,7 @@ export async function listEventsWithStats() {
|
||||
const rows = await db.query.events.findMany({
|
||||
orderBy: [desc(events.date)],
|
||||
with: {
|
||||
invitations: {
|
||||
columns: { id: true, blocked: true },
|
||||
with: { guests: { columns: { type: true, status: true } } },
|
||||
},
|
||||
invitations: { columns: { id: true }, with: { guests: { columns: { type: true, status: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -62,7 +59,7 @@ export async function listEventsWithStats() {
|
||||
invitationCount: event.invitations.length,
|
||||
stats: computeStats(
|
||||
event.invitations.flatMap((inv) =>
|
||||
inv.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed || inv.blocked) }))
|
||||
inv.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed) }))
|
||||
)
|
||||
),
|
||||
};
|
||||
@@ -84,7 +81,7 @@ export async function getEventWithInvitations(eventId: string) {
|
||||
|
||||
const rsvpClosed = isRsvpClosed(event.rsvpDeadline);
|
||||
const invitationList = event.invitations.map((inv) => {
|
||||
const guestList = inv.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed || inv.blocked) }));
|
||||
const guestList = inv.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed) }));
|
||||
return { ...inv, guests: guestList, stats: computeStats(guestList) };
|
||||
});
|
||||
|
||||
@@ -114,7 +111,7 @@ export async function getInvitationForEdit(invitationId: string) {
|
||||
});
|
||||
if (!invitation) return undefined;
|
||||
|
||||
const rsvpClosed = isRsvpClosed(invitation.event.rsvpDeadline) || invitation.blocked;
|
||||
const rsvpClosed = isRsvpClosed(invitation.event.rsvpDeadline);
|
||||
return {
|
||||
...invitation,
|
||||
guests: invitation.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed) })),
|
||||
|
||||
@@ -2,13 +2,11 @@ import { createReadStream } from "node:fs";
|
||||
import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { BACKGROUND_TYPES, type BackgroundType } from "@/lib/background";
|
||||
import { isUuid } from "@/lib/organizer";
|
||||
|
||||
// Estrutura no volume (a mesma usada pelo pdf-service):
|
||||
// <STORAGE_DIR>/events/<eventId>/template/modelo-v<versão>.pdf
|
||||
// <STORAGE_DIR>/events/<eventId>/generated/<invitationId>.pdf
|
||||
// <STORAGE_DIR>/events/<eventId>/background/fundo-v<versão>.<jpg|png|webp>
|
||||
// Os caminhos são montados só a partir de ids validados; nada vindo do navegador vira caminho.
|
||||
|
||||
function storageRoot(): string {
|
||||
@@ -74,40 +72,3 @@ export async function openPdf(filePath: string): Promise<{ stream: ReadableStrea
|
||||
const stream = Readable.toWeb(createReadStream(filePath)) as unknown as ReadableStream;
|
||||
return { stream, size: info.size };
|
||||
}
|
||||
|
||||
|
||||
// ---------- Imagem de fundo do convite ----------
|
||||
|
||||
function backgroundDir(eventId: string): string {
|
||||
return path.join(eventDir(eventId), "background");
|
||||
}
|
||||
|
||||
function backgroundFileName(version: number, type: BackgroundType): string {
|
||||
if (!Number.isInteger(version) || version < 1) throw new Error("Versão de fundo inválida.");
|
||||
return `fundo-v${version}.${BACKGROUND_TYPES[type]}`;
|
||||
}
|
||||
|
||||
export function backgroundPath(eventId: string, version: number, type: BackgroundType): string {
|
||||
return path.join(backgroundDir(eventId), backgroundFileName(version, type));
|
||||
}
|
||||
|
||||
export async function saveBackground(
|
||||
eventId: string,
|
||||
version: number,
|
||||
type: BackgroundType,
|
||||
content: Uint8Array
|
||||
): Promise<void> {
|
||||
const target = backgroundPath(eventId, version, type);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
const tmp = `${target}.${process.pid}.tmp`;
|
||||
await writeFile(tmp, content);
|
||||
await rename(tmp, target);
|
||||
}
|
||||
|
||||
// Apaga os fundos de outras versões; sem keep, apaga todos
|
||||
export async function removeOtherBackgrounds(eventId: string, keep?: { version: number; type: BackgroundType }): Promise<void> {
|
||||
const dir = backgroundDir(eventId);
|
||||
const keepName = keep ? backgroundFileName(keep.version, keep.type) : null;
|
||||
const files = await readdir(dir).catch(() => [] as string[]);
|
||||
await Promise.all(files.filter((f) => f !== keepName).map((f) => rm(path.join(dir, f), { force: true })));
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
// Mensagem enviada pelo botão "WhatsApp" de cada convite. O organizador pode personalizar o texto
|
||||
// por evento usando variáveis entre chaves; a formatação do WhatsApp (*negrito*, _itálico_ etc.)
|
||||
// passa direto, pois o wa.me recebe texto puro e o próprio app aplica a formatação.
|
||||
// passa direto, pois o link recebe texto puro e o próprio app aplica a formatação.
|
||||
|
||||
// Vale para o modelo e para o texto final (variáveis trocadas). Fica abaixo de 1000 para ter margem
|
||||
// no link do wa.me, que alguns aparelhos recusam quando é longo demais.
|
||||
// no link do WhatsApp, que alguns aparelhos recusam quando é longo demais.
|
||||
export const WHATSAPP_MESSAGE_MAX = 950;
|
||||
|
||||
// O que de fato pesa no wa.me é o link codificado: acentos viram 6 caracteres e emojis até 12,
|
||||
// O que de fato pesa é o link codificado: acentos viram 6 caracteres e emojis até 12,
|
||||
// então o texto final também precisa caber neste tamanho de link
|
||||
export const WHATSAPP_URL_MAX = 2300;
|
||||
|
||||
// api.whatsapp.com direto, e não wa.me: o redirecionamento do wa.me para cá estraga emojis de
|
||||
// 4 bytes (🦁, 👑...), que chegam como "<22>" (visto no WhatsApp Web/Desktop)
|
||||
export function whatsappShareUrl(text: string): string {
|
||||
return `https://wa.me/?text=${encodeURIComponent(text)}`;
|
||||
return `https://api.whatsapp.com/send?text=${encodeURIComponent(text)}`;
|
||||
}
|
||||
|
||||
// Link de exemplo com o mesmo tamanho de um link real (token de 8 caracteres, ver generateToken)
|
||||
|
||||
@@ -27,8 +27,6 @@ export interface Invitation {
|
||||
theme: string;
|
||||
badgeText?: string;
|
||||
footerText?: string;
|
||||
// Imagem de fundo do evento, quando houver
|
||||
backgroundUrl?: string;
|
||||
}
|
||||
|
||||
export interface RsvpSubmission {
|
||||
|
||||
Reference in New Issue
Block a user