diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 0000000..7149d4b --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,19 @@ +# Copy to `.dev.vars` (gitignored) for `wrangler dev`. Never commit real secrets. +# These are the Worker secrets the voting flow reads at runtime. + +# guild.host OAuth app (https://guild.host/oauth/authorize) +GUILD_OAUTH_CLIENT_ID= +GUILD_OAUTH_CLIENT_SECRET= +# Must exactly match a redirect URI registered on the OAuth app. +# Local: http://localhost:8787/api/vote/callback Prod: https://api.jsconf.com.br/api/vote/callback +GUILD_OAUTH_REDIRECT_URI=http://localhost:8787/api/vote/callback + +# HMAC/JWT session signing key. Any long random string, e.g. `openssl rand -hex 32`. +SESSION_SECRET= + +# Website origin for credentialed CORS. Must be explicit, never '*'. +# Local: http://localhost:3000 Prod: https://jsconf.com.br +ALLOWED_ORIGIN=http://localhost:3000 + +# 'production' disables the X-Dev-User bypass and the dev session shortcut. +ENVIRONMENT=development diff --git a/.env.example b/.env.example index f85f7c3..20393ee 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,6 @@ +# Build / deploy vars (read by tools/prepare-worker.mts and the CD workflow). +# Worker runtime secrets (guild.host OAuth, session, ALLOWED_ORIGIN) live in .dev.vars, not here. CLOUDFLARE_ACCOUNT_ID='' CLOUDFLARE_API_TOKEN='' WORKER_D1='' WORKER_DOMAIN='' -ALLOWED_ORIGIN='' -STRIPE_SECRET_KEY='' -STRIPE_WEBHOOK_SECRET='' diff --git a/.gitignore b/.gitignore index 6dddee8..0d32e2c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ .env .dev.vars .npmrc +/.serena diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1c6177e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# CLAUDE.md + +Guidance for Claude when working in this repo (JSConf Brasil landing page). + +## What this is + +Three pieces, one repo: + +- **Website** — Docusaurus 3 + React 19, static, deployed to GitHub Pages (`jsconf.com.br`). + Source in `src/website/`. File-based routes under `src/website/pages/`. i18n in `i18n/` + (pt-BR default, en-US, es-419) via Docusaurus `` / `code.json`. +- **Server** — a single Cloudflare Worker (`src/server/index.ts`), custom domain + `api.jsconf.com.br`. No framework: a `fetch` handler with a manual `switch` router. Helpers, + routes, repositories, configs split under `src/server/`. +- **Database** — Cloudflare D1 (SQLite), bound as `DB`. Schema in `resources/schema.sql` + (idempotent `CREATE TABLE IF NOT EXISTS`). Apply with `npm run db:init` (local) / + `db:init:remote`. + +Deploy: push to `main` → `.github/workflows/cd_deploy.yml` builds both, deploys the worker +via wrangler and the site to the `website` branch. + +## Commands + +```sh +npm start # site (pt-BR) + worker via wrangler dev, concurrently +npm run server # worker only (wrangler dev) +npm run db:init # apply resources/schema.sql to local D1 +npm test # poku (-r=compact) +npm run typecheck # tsc +npm run lint # prettier --check (lint:fix to write) +npm run build # worker + docusaurus +``` + +Run `npm ci` first if `node_modules` is missing (typecheck/test need deps). + +## Conventions + +- **Style:** no `else` (early-return + guard clauses), avoid `let`, prefer `for..of` over + `.forEach`, keep it simple over clever. TS: `switch` default `x satisfies never`, + `Record`. +- **Server shape:** routes in `src/server/routes/`, registered in `routes.ts` + the `index.ts` + switch (`METHOD /path`). DB access in `repositories/`, validated with `zod`. Responses via + `helpers/response.ts`. Reuse the existing helpers (`request.ts`, `session.ts`, etc.). +- **Validation is the trust boundary** server-side; the frontend schemas are UX only. +- **Tests:** `poku`, files `*.test.ts` under `test/` mirroring `src/`. Mock D1 with a plain + object implementing `prepare().bind().run()/all()` (see `test/server/routes/c4p/__utils__.ts` + and `test/server/routes/vote.test.ts`). +- **Secrets:** `wrangler secret put` (see `npm run secret`); never commit them. `.env.example` + lists what exists. + +## Git / PRs + +- Conventional commits, single `-m`, no Claude attribution. Never push to `main` — branch + first. No gitmoji. +- `git` here needs a clean env to avoid shell-hook hangs: + `env -i HOME=$HOME PATH=/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin /usr/bin/git --no-pager …` + +## In-flight work + +The `voting-system` branch adds attendee voting on C4P talks (guild.host OAuth identity, +manager-token-based ticket-tier lookup, D1-backed vote casting with budget limits) and is +built and verified end-to-end against a real guild.host login. See `DEVELOPMENT.md` for the +technical deep-dive (architecture, OAuth flow, DB schema) and `TODO.md` for the remaining +deploy steps and known caveats. + + + +## Headroom Learned Patterns + +_Auto-generated by `headroom learn` on 2026-06-26 — do not edit manually_ + +### Dependencies + +_~1,500 tokens/session saved_ + +- Server session/JWT uses `jose` (^6.x). The `cookie` package was tried and removed — it is NOT a dependency; don't re-add it. +- To check an installed package version, `grep -E '"version"' node_modules//package.json`. Do NOT use `node -e "require('/package.json')"` — packages like `jose` define `exports` and throw `ERR_PACKAGE_PATH_NOT_EXPORTED`. + + diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f7c34e1..778ca2d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -84,3 +84,182 @@ npm run typecheck # Verificação de tipos TypeScript ```sh npm run lint # Verificação de linting ``` + +--- + +## Votação de palestras + +Quem comprou ingresso vota nas palestras do C4P em `/vote`. A pessoa entra com a conta do **guild.host**, e quantos votos ela tem depende do **tier** do ingresso. O fluxo já está implementado de ponta a ponta e foi verificado contra um login real no guild.host. + +O site (Docusaurus) e o Worker são dois deploys separados. O navegador nunca fala com o D1 diretamente, só com a API JSON do Worker. As rotas relevantes, registradas no `switch` de `src/server/index.ts` e implementadas em `src/server/routes/auth.ts` e `src/server/routes/vote.ts`: + +| Rota | Handler | +| ------------------------ | -------------------------------------------------------------- | +| `GET /api/vote/login` | `authLogin` — inicia o OAuth | +| `GET /api/vote/callback` | `authCallback` — troca o code, resolve o tier, assina a sessão | +| `GET /api/vote/me` | `authMe` — identidade da sessão atual (página de conta) | +| `GET /api/vote/logout` | `authLogout` — limpa o cookie de sessão | +| `GET /api/vote` | `voteGet` — lista as palestras votáveis + votos da pessoa | +| `POST /api/vote` | `voteSubmit` — registra/remove um voto | + +### Duas identidades OAuth diferentes + +Esta é a parte que mais confunde: o login usa **dois** tokens OAuth com escopos diferentes, um por pessoa (a votante) e um único fixo do lado do servidor (o organizador). + +1. **Token da votante** — escopo `profile:read` apenas (`OAUTH_SCOPE` em `src/server/configs/oauth.ts`). Usado uma única vez no login pra chamar `/oauth/userinfo` do guild.host e pegar `{ sub, name, picture }`. Nunca é guardado. +2. **Token de organizador** — necessário porque o único lugar que expõe o tier do ingresso é `/events/{slug}/attendees`, e esse endpoint exige permissão de organizador do evento (escopo `event_attendees:read`, `MANAGER_SCOPE`) — algo que uma votante comum nunca tem. O Worker mantém uma credencial fixa pra isso: + - `GUILD_ORG_REFRESH_TOKEN` é um secret de **bootstrap**, capturado uma única vez por um fluxo só-dev: acessar `/api/vote/login?manager=1` pede o `MANAGER_SCOPE`, e o callback (gated por `env.ENVIRONMENT !== 'production'` + um cookie `vote_manager_capture`) imprime o refresh token em texto puro no navegador em vez de assinar uma sessão. Esse valor nunca é logado em lugar nenhum. + - `managerAccessToken()` (`src/server/helpers/oauth.ts`) lê a única linha (`id = 1`) da tabela D1 `manager_oauth`. Se o `access_token` em cache ainda não expirou, reusa sem chamar a rede. Senão, faz um `POST grant_type=refresh_token` no endpoint de token do guild. + - **Crítico:** o guild.host **rotaciona o refresh token a cada uso** — o antigo é revogado e um novo é emitido na mesma resposta. Por isso todo refresh bem-sucedido **grava o novo `refresh_token` de volta** na linha de `manager_oauth` (`INSERT ... ON CONFLICT DO UPDATE`). A env var só serve de semente; depois do primeiro uso, o D1 é a fonte de verdade e se auto-atualiza para sempre. Sem isso, o primeiro refresh já mataria o token guardado no `.dev.vars`/secret e quebraria a busca de tier até alguém recapturar o token manualmente. + +### Fluxo de login completo + +```mermaid +sequenceDiagram + participant B as Browser + participant W as Worker + participant G as guild.host + participant D as D1 + + B->>W: GET /api/vote/login + W->>W: gera state, seta cookie vote_oauth_state + W-->>B: 302 -> guild.host /oauth/authorize (scope=profile:read) + B->>G: autoriza o app + G-->>B: 302 -> /api/vote/callback?code&state + B->>W: GET /api/vote/callback?code&state + W->>W: valida state contra o cookie + W->>G: POST /oauth/token (exchangeCode) + G-->>W: access_token, refresh_token (da votante) + W->>G: GET /oauth/userinfo (fetchUserInfo) + G-->>W: sub, name, picture + W->>D: SELECT manager_oauth WHERE id = 1 + alt access_token do organizador ainda válido + D-->>W: access_token em cache + else expirado + W->>G: POST /oauth/token (refresh_token do organizador) + G-->>W: novo access_token + refresh_token rotacionado + W->>D: UPSERT manager_oauth (novo refresh_token) + end + W->>G: GET /events/{slug}/attendees?first=100 (paginado, token de organizador) + G-->>W: tier do ingresso (ou nada) + alt sem tier (não é participante) + W-->>B: 302 -> site/?error=notattendee + else tier encontrado + W->>D: SELECT budget FROM ticket_tiers WHERE name = tier + W->>W: assina JWT (sub, budget, name, photo) + W-->>B: 302 -> /vote + cookie vote_session + end +``` + +Passo a passo: + +1. `GET /api/vote/login` (sem `?dev`/`?manager` em produção) — o Worker gera um `state` aleatório, seta o cookie `vote_oauth_state` (`SameSite=Lax`, 10min) e redireciona pro `/oauth/authorize` do guild.host com `client_id`/`redirect_uri`/`scope=profile:read`/`state`. +2. A pessoa autoriza no guild.host e é redirecionada pra `GET /api/vote/callback?code=...&state=...`. +3. O Worker confere que o `state` bate com o cookie, troca o `code` pelo par `{ access_token, refresh_token }` da votante (`exchangeCode()`). +4. `fetchUserInfo(access_token)` — `{ id (sub), name, picture }` do `/oauth/userinfo` do guild. +5. `managerAccessToken(...)` — obtém (ou renova) o token de organizador, lendo/gravando `manager_oauth` no D1 (ver seção acima). +6. `fetchTicketTier(managerToken, EVENT_SLUG, identity.id)` — pagina `/events/{slug}/attendees?first=100` (recursivo, até 30 páginas) procurando o nó cujo `userId` é o da votante, e retorna `node.ticketOrder.eventTicketOrderItems.nodes[0].eventTicketingTier.name`, ou `null` se não houver pedido de ingresso. +7. Sem tier → redireciona pra home com `?error=notattendee` (nenhuma sessão é assinada). Com tier → `budgetForTier(tier)` consulta a tabela `ticket_tiers` no D1 (qualquer tier sem linha lá recebe `1` por padrão). +8. `signSession(id, budget, secret, ttl, { name, photo })` assina um JWT (HS256, via `jose`) com o **budget já embutido**. Seta o cookie `vote_session` (`SameSite=None; Secure`, 24h) e redireciona pra `/vote`. +9. **Cuidado:** como o budget fica embutido no JWT no momento do login, mudar o budget de um tier no banco **não afeta quem já está logado** — a pessoa precisa deslogar e logar de novo pra pegar um JWT novo com o budget atualizado. Isso já aconteceu de verdade em teste, não é hipotético. +10. Caminhos de erro (todos redirecionam pra home com um `?error=`, exibido por um handler global na navbar que lê o query param e limpa ele em seguida): `?error=denied` (recusou no guild.host), `?error=state` (state não bate, possível CSRF), `?error=token` (troca do code falhou), `?error=identity` (userinfo ou token de organizador falhou), `?error=notattendee` (sem ingresso pra este evento). + +### Autenticação a cada request (depois do login) + +`getSession()` (`src/server/helpers/session.ts`) lê o cookie `vote_session`, verifica o JWT (`jose`) e retorna `{ userId, budget, name, photo }` direto das claims do token — **sem** chamada ao D1 nem ao guild a cada request. É por isso que `GET /api/vote` e `GET /api/vote/me` são rápidos. + +- **Bypass só-dev:** fora de `production`, um header `X-Dev-User` retorna uma sessão fixa sem precisar de cookie — útil pra testar via `curl` sem login de verdade: + ```sh + curl -H 'X-Dev-User: user-1' localhost:8787/api/vote + ``` +- **Login-stub só-dev:** `GET /api/vote/login?dev=1` (mesmo gate de ambiente) assina uma sessão fixa (`dev-user`) direto, sem passar pelo guild — útil pra testar a UI do `/vote` no navegador. + +### A votação em si + +- `GET /api/vote` (`voteGet`) — exige sessão; retorna `{ budget, used, talks, myVotes, closesAt }`. `listTalks()` (`src/server/repositories/vote.ts`) só seleciona palestras com `status = 2` (`VOTABLE_TALK_STATUS`, em `src/server/configs/vote.ts`) e **deliberadamente não faz join** com `speakers` — a API nunca pode expor quem propôs a palestra, pra evitar viés. O frontend (`src/website/pages/vote/index.tsx`) gera, por palestra e a cada fetch, um nome fake com cara de nome (tipo "Aabd Cdaes", não é um nome real) e mostra ele borrado via CSS (`.speaker-name { filter: blur(...) }`) — isso é só estético/cosmético, não é um controle de segurança, já que nada sensível estava sendo escondido (o nome real nunca chegou a ser enviado). +- `POST /api/vote` (`voteSubmit`) — corpo `{ talkId, action: 'add' | 'remove' }`, validado com `zod`. Checa `isVotingOpen()` (`VOTE_CLOSES_AT` em `src/server/configs/vote.ts`, uma data ISO fixa no código — mudar a data exige deploy, de propósito, já que muda raramente). `castVote()` (`src/server/repositories/vote.ts`) confere o número de votos atual contra o budget (422 se já bateu o limite) e faz `INSERT OR IGNORE` em `c4p_votes`. `removeVote()` só faz `DELETE`, sem checagem de budget. +- **UX do frontend:** clicar no botão de votar de uma palestra vira o estado local na hora (otimista) e enfileira `{ talkId, action }` numa fila com debounce (1s de espera, reseta a cada novo clique), então uma rajada de cliques vira uma única leva de requests em vez de um request por clique. Isso existe porque, em dev local, o rate limiter compartilha um único bucket entre todos os requests quando não há header `CF-Connecting-IP` — é uma peculiaridade real de dev local, não acontece em produção (a Cloudflare sempre seta esse header lá). Quando o budget acaba, todo card de palestra ainda não votada fica esmaecido e desabilitado (classe CSS `budget-out`), exceto as já votadas, que continuam clicáveis pra desvotar — desvotar reabilita tudo na hora, sem refetch, é tudo derivado do estado local. + +### Banco de dados + +```mermaid +erDiagram + speakers ||--o{ talks : "propõe" + talks ||--o{ c4p_votes : "recebe" + ticket_tiers { + VARCHAR name PK + INTEGER budget + } + c4p_votes { + INTEGER id PK + TEXT user_id + INTEGER talk_id FK + VARCHAR created_at + } + manager_oauth { + INTEGER id PK + TEXT refresh_token + TEXT access_token + INTEGER expires_at + VARCHAR updated_at + } + talks { + INTEGER id PK + INTEGER speaker_id FK + VARCHAR title + INTEGER status + } +``` + +Ver `resources/schema.sql` pras colunas exatas. Tabelas relevantes pra votação: + +- **`talks`** — a coluna `status` controla visibilidade (só `status = 2` entra na votação); a API de voto nunca faz join com `speakers`. +- **`ticket_tiers`** — `name` (`VARCHAR(200) PRIMARY KEY`, precisa bater exatamente com o nome do tier no guild.host) e `budget`. É uma tabela de **overrides**: `budgetForTier()` retorna `1` por padrão pra qualquer tier sem linha aqui, então só vale adicionar linha pros tiers que têm budget diferente de `1`. +- **`c4p_votes`** — `user_id` é o `sub` (UUID) do guild.host, `talk_id` referencia `talks`, e `UNIQUE(user_id, talk_id)` impede voto duplicado. +- **`manager_oauth`** — tabela de uma linha só (`id INTEGER PRIMARY KEY CHECK(id = 1)`) que guarda o refresh token rotativo do organizador, o access token em cache e a expiração. + +### Operações administrativas + +Rode com `npx wrangler d1 execute jsconf-br --file=` (adicione `--remote` pra produção). + +- **Adicionar uma palestra votável:** insira o speaker, depois a talk com `status = 2`. + + ```sql + INSERT INTO speakers (name, email, phone, city, state, travel_pref, experience, bio) + VALUES ('Nome', 'email@exemplo.com', '11999999999', 'São Paulo', 'SP', 0, 1, 'Bio curta.'); + + INSERT INTO talks (speaker_id, duration, title, description, audience_level, reason, status) + VALUES (last_insert_rowid(), 0, 'Título da palestra', 'Descrição.', 1, 'Motivo.', 2); + ``` + +- **Esconder uma palestra (mantendo os votos):** + ```sql + UPDATE talks SET status = 0 WHERE id = 42; + ``` +- **Mudar o budget de um tier:** + ```sql + UPDATE ticket_tiers SET budget = 3 WHERE name = 'Nome exato do tier no guild.host'; + ``` + Lembrando do aviso acima: quem já logou antes da mudança só pega o novo budget deslogando e logando de novo. +- **Mudar o prazo de votação:** editar `VOTE_CLOSES_AT` em `src/server/configs/vote.ts` (exige código + deploy). + +### Limitação conhecida: ingressos múltiplos + +`fetchTicketTier` retorna o tier do **primeiro** nó de participante que bater na lista paginada do guild, não uma agregação/melhor-tier, caso a mesma pessoa apareça mais de uma vez na lista de participantes do evento. Isso ainda não foi testado na prática (só contas com um único ingresso foram usadas nos testes até agora) — é uma limitação conhecida, não um bug observado. + +Secrets do Worker: `GUILD_OAUTH_CLIENT_ID`, `GUILD_OAUTH_CLIENT_SECRET`, `GUILD_OAUTH_REDIRECT_URI`, `GUILD_ORG_REFRESH_TOKEN`, `SESSION_SECRET`. O `ALLOWED_ORIGIN` precisa ser a origem do site (não pode ser `*`, senão o cookie de sessão não vai). + +### Variáveis de ambiente (dois arquivos) + +São dois arquivos, cada um com um dono diferente — não misture: + +- **`.dev.vars`** — secrets de _runtime_ do Worker, carregados automaticamente pelo `wrangler dev`. É onde ficam `GUILD_OAUTH_*`, `SESSION_SECRET`, `ALLOWED_ORIGIN` e `ENVIRONMENT` no dev local. Modelo: `.dev.vars.example`. Em produção esses valores vêm do `wrangler secret put`, **não** de nenhum `.env`. +- **`.env`** — vars de _build/deploy_ lidas pelos scripts (`tools/prepare-worker.mts`) e pelo CD: `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_API_TOKEN`, `WORKER_D1`, `WORKER_DOMAIN`. Modelo: `.env.example`. + +Os secrets de OAuth/sessão só entram no `.dev.vars` — não os coloque no `.env`, nada os lê de lá. Os dois arquivos são gitignored. + +Rodando local: `npm run db:init` cria as tabelas. + +> [!TIP] +> +> O passo a passo pra subir em produção (registrar o app OAuth, popular `ticket_tiers`, pegar o refresh token de organizador) está no `TODO.md`. diff --git a/README.md b/README.md index 7c11fb8..2f28d67 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A **JSConf Brasil** é um **braço da [NodeBR](https://nodebr.org)** — a assoc ## Site e repositório -O site público está em **[jsconf.com.br](https://jsconf.com.br)**. Este repositório é o código do site e dos serviços que o sustentam (por exemplo, formulários e integrações). +O site público está em **[jsconf.com.br](https://jsconf.com.br)**. Este repositório é o código do site e dos serviços que o sustentam (por exemplo, formulários, integrações e a votação de palestras pela comunidade). Quer contribuir com código, traduções ou correções? Veja **[DEVELOPMENT.md](./DEVELOPMENT.md)**. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..4be718c --- /dev/null +++ b/TODO.md @@ -0,0 +1,73 @@ +# Voting system — remaining work + +> Superseded the old login-only TODO: OAuth login, manager-token tier lookup, D1-backed vote +> casting, the navbar auth dropdown, and the /vote UI are all built and verified against a real +> guild.host login. What's left is deploy + a couple of known caveats. `HANDOVER.md` is deleted — +> this file plus `DEVELOPMENT.md` are now the source of truth. + +## Deploy checklist + +1. Confirm `EVENT_SLUG` in `src/server/configs/oauth.ts` matches the real event (`vdc8dh` today). +2. `npm run db:init:remote` — applies `resources/schema.sql` to prod D1 (idempotent). +3. Seed prod `ticket_tiers` (tier name must match guild.host exactly, case-sensitive): + ```sql + INSERT INTO ticket_tiers (name, budget) VALUES ('Super Early Bird', 2), ('Early Bird', 1); + ``` + Any tier NOT listed here defaults to budget 1 (see `budgetForTier` in + `src/server/repositories/vote.ts`) — only add rows for overrides. +4. `npm run secret` (`wrangler secret put`) for: `GUILD_OAUTH_CLIENT_ID`, + `GUILD_OAUTH_CLIENT_SECRET`, `SESSION_SECRET`, `GUILD_ORG_REFRESH_TOKEN`. + `ALLOWED_ORIGIN` = `https://jsconf.com.br` (never `*` — credentialed cookies need an explicit + origin). +5. Confirm the guild.host OAuth app has `https://api.jsconf.com.br/api/vote/callback` registered + as a redirect URI (in addition to the localhost one used for dev). +6. Bootstrap `GUILD_ORG_REFRESH_TOKEN`: the capture path is gated on `env.ENVIRONMENT === 'development'` + (fail closed — it is never reachable in prod), so run it from a development environment: local + `wrangler dev` with `ENVIRONMENT=development`, or a throwaway dev deploy. Hit `/api/vote/login?manager=1`, + log in with the org account, and copy the printed refresh token into the **prod** secret. The token is + org-scoped, so where you capture it doesn't matter. After first use, D1 (`manager_oauth` table) keeps + itself current — guild rotates the refresh token on every use, and `managerAccessToken()` writes the new + one back automatically. +7. Delete the dev-only `?manager=1` capture branch in `authLogin`/`authCallback` + (`src/server/routes/auth.ts`) once the prod token is bootstrapped — already inert in production, + but worth removing as cleanup. +8. Merge `voting-system` → `main`. CD (`.github/workflows/cd_deploy.yml`) builds the worker + (`tools/prepare-worker.mts` injects the real D1 `database_id` from the `WORKER_D1` secret) and + deploys both the worker and the static site. +9. Do one real login against prod post-deploy — confirm guild consent → callback → tier resolution + → session → vote cast, end to end — before announcing voting is open. + +## Known caveats (not blocking, but real) + +- **Budget is baked into the session JWT at login time.** Changing a tier's `budget` in + `ticket_tiers` does NOT retroactively update anyone already logged in — they need to log out and + back in. Hit this directly during testing. +- **Multiple tickets per user isn't handled as "best tier wins."** `fetchTicketTier` + (`src/server/helpers/oauth.ts`) returns the tier from the FIRST matching attendee node in guild's + paginated list, not an aggregate/max. Untested in practice (no real multi-ticket account seen + yet) — if it comes up, decide whether to prefer the highest-budget tier instead. +- **Rate limiter shares one bucket in local dev.** `checkRateLimit` keys on + `CF-Connecting-IP`/`X-Forwarded-For`, absent locally, so ALL local requests (yours + any curl + testing) share a single `'unknown'` bucket (10 req/60s). Not a prod issue — Cloudflare always + sets `CF-Connecting-IP` there. + +## Admin cheat sheet + +Add a votable talk: + +```sql +INSERT INTO speakers (name, email, phone, city, state, travel_pref, experience, bio) + VALUES (...); +INSERT INTO talks (speaker_id, duration, title, description, audience_level, reason, status) + VALUES (, <0|1>, '', '<description>', <0-3>, '<reason>', 2); +``` + +`status = 2` is what makes a talk appear on `/vote` — nothing else to touch. + +Hide a talk without losing its votes: `UPDATE talks SET status = <anything but 2> WHERE id = <id>;` + +Change a tier's budget: `UPDATE ticket_tiers SET budget = <n> WHERE name = '<exact tier name>';` +(remember the JWT caveat above — affected users need to re-login). + +Change the voting deadline: edit `VOTE_CLOSES_AT` in `src/server/configs/vote.ts` (hardcoded, +requires a deploy — deliberate, since it changes rarely). diff --git a/i18n/en-US/code.json b/i18n/en-US/code.json index fe60042..426ad16 100644 --- a/i18n/en-US/code.json +++ b/i18n/en-US/code.json @@ -14,12 +14,21 @@ "navbar.section.team": { "message": "Our Team" }, + "navbar.section.account": { + "message": "Account" + }, "navbar.section.sponsors": { "message": "Become a Sponsor" }, "navbar.tickets": { "message": "Tickets" }, + "navbar.login": { + "message": "Log in" + }, + "navbar.vote": { + "message": "Vote" + }, "navbar.aria.openMenu": { "message": "Open menu" }, @@ -364,5 +373,458 @@ }, "supporters.subtitle": { "message": "We are grateful for the support of our partners who share our vision of strengthening the JavaScript community in Brazil." + }, + "common.loading": { + "message": "Loading…" + }, + "auth.login": { + "message": "Log in with guild.host" + }, + "auth.logout": { + "message": "Log out" + }, + "vote.title": { + "message": "Voting - JSConf Brasil 2026" + }, + "vote.heading": { + "message": "Vote for talks" + }, + "vote.subheading": { + "message": "Choose the talks you want to see on stage." + }, + "vote.hideAuthorsTitle": { + "message": "Why do we hide the authors' names?" + }, + "vote.hideAuthorsBody": { + "message": "To reduce bias, the names of who proposed each talk are hidden. Vote for the talk's content, not for who wrote it." + }, + "vote.loginHeading": { + "message": "Sign in to vote" + }, + "vote.closedHeading": { + "message": "Voting is closed" + }, + "vote.emptyTalks": { + "message": "No talks available for voting right now." + }, + "vote.by": { + "message": "by" + }, + "vote.voteAction": { + "message": "Vote" + }, + "vote.votedAction": { + "message": "Voted" + }, + "vote.loginPrompt": { + "message": "Sign in with your guild.host account to vote for talks." + }, + "vote.loadError": { + "message": "Could not load voting. Try again later." + }, + "vote.remaining": { + "message": "Votes left: {remaining} of {budget}" + }, + "vote.openUntil": { + "message": "Open until {date}" + }, + "vote.closed": { + "message": "Voting has closed." + }, + "vote.limitReached": { + "message": "You have already used all {budget} of your votes." + }, + "vote.submitError": { + "message": "Error saving your vote. Try again." + }, + "login.error.denied": { + "message": "Login cancelled." + }, + "login.error.state": { + "message": "Login session expired. Try again." + }, + "login.error.token": { + "message": "Failed to authenticate with guild.host. Try again." + }, + "login.error.identity": { + "message": "Could not identify your guild.host account." + }, + "login.error.notattendee": { + "message": "You do not have a ticket for the event." + }, + "login.error.generic": { + "message": "Login error. Try again." + }, + "account.title": { + "message": "Account - JSConf Brasil 2026" + }, + "account.loginHeading": { + "message": "Your account" + }, + "account.idLabel": { + "message": "ID" + }, + "account.loginPrompt": { + "message": "Sign in with your guild.host account to access your account." + }, + "account.heading": { + "message": "Account" + }, + "account.loadError": { + "message": "Could not load. Try again later." + }, + "c4p.pageTitle": { + "message": "Call4Papers - JSConf Brasil 2026" + }, + "c4p.action.back": { + "message": "Back" + }, + "c4p.action.continue": { + "message": "Continue" + }, + "c4p.action.finish": { + "message": "Submit" + }, + "c4p.action.submitAnother": { + "message": "Submit another talk" + }, + "c4p.status.invalid": { + "message": "Invalid" + }, + "c4p.status.valid": { + "message": "Valid" + }, + "c4p.step.about": { + "message": "About you" + }, + "c4p.step.diversity": { + "message": "Diversity and inclusion (optional)" + }, + "c4p.step.talk": { + "message": "About the talk" + }, + "c4p.step.done": { + "message": "Proposal submitted!" + }, + "c4p.progress": { + "message": "Page {current} of 4" + }, + "c4p.error.name": { + "message": "Enter your name" + }, + "c4p.error.email": { + "message": "Enter a valid email" + }, + "c4p.error.emailTooLong": { + "message": "Email is too long" + }, + "c4p.error.phone": { + "message": "Enter a valid number" + }, + "c4p.error.city": { + "message": "Enter your city" + }, + "c4p.error.state": { + "message": "Select a state" + }, + "c4p.error.selectOption": { + "message": "Select an option" + }, + "c4p.error.invalid": { + "message": "Invalid value" + }, + "c4p.error.bio": { + "message": "Enter your bio" + }, + "c4p.error.bioTooLong": { + "message": "Bio is too long" + }, + "c4p.error.duration": { + "message": "Select the duration" + }, + "c4p.error.talkTitle": { + "message": "Enter the title" + }, + "c4p.error.talkDescription": { + "message": "Enter the description" + }, + "c4p.error.audienceLevel": { + "message": "Select the level" + }, + "c4p.error.talkReason": { + "message": "Enter the reason" + }, + "c4p.error.requiredFields": { + "message": "Fill in the required fields" + }, + "c4p.submit.configError": { + "message": "Configuration unavailable. Try again later." + }, + "c4p.submit.limit": { + "message": "You have reached the limit of 3 talks." + }, + "c4p.submit.error": { + "message": "Error submitting your proposal. Try again." + }, + "c4p.intro.toastTitle": { + "message": "Your data is saved in your browser." + }, + "c4p.intro.toastDesc": { + "message": "You can pick up where you left off anytime." + }, + "c4p.intro.heading1": { + "message": "Want to speak at this year's JSConf Brasil?" + }, + "c4p.intro.p1": { + "message": "This is your chance to share the stage with some of the most amazing professionals in tech! Fill out our Call4Papers and our team will review and select the submitted proposals." + }, + "c4p.intro.p2": { + "message": "{brand} exists to share knowledge, bring the community together, and strengthen a diverse, inclusive, and collaborative event." + }, + "c4p.intro.dateHeading": { + "message": "JSConf Brasil - November 28, 2026" + }, + "c4p.intro.deadline": { + "message": "We will accept submissions until {date}." + }, + "c4p.intro.deadlineDate": { + "message": "July 30" + }, + "c4p.intro.maxTalks": { + "message": "You can submit up to {count} talks." + }, + "c4p.intro.eventInfo": { + "message": "The event takes place on {date}, at {venue}. There will be talks, panels, interactive activities, an exhibitor fair, and much more." + }, + "c4p.intro.eventDate": { + "message": "November 28, 2026" + }, + "c4p.intro.topicsIntro": { + "message": "These are some of the topics we suggest (starred ones are preferred):" + }, + "c4p.topic.devTooling": { + "message": "Dev Tooling" + }, + "c4p.topic.devEx": { + "message": "Developer Experience" + }, + "c4p.topic.aiDev": { + "message": "AI in Development" + }, + "c4p.topic.architecture": { + "message": "Architecture" + }, + "c4p.topic.devops": { + "message": "DevOps" + }, + "c4p.topic.observability": { + "message": "Observability" + }, + "c4p.topic.performance": { + "message": "Performance" + }, + "c4p.topic.realCases": { + "message": "Real-World Problem Solving with Code" + }, + "c4p.topic.a11yCode": { + "message": "Code for Accessibility" + }, + "c4p.topic.openSource": { + "message": "Open Source" + }, + "c4p.topic.designSystem": { + "message": "Design System" + }, + "c4p.topic.security": { + "message": "Security in Development" + }, + "c4p.exp.0": { + "message": "0 - 1 year" + }, + "c4p.exp.1": { + "message": "2 - 4 years" + }, + "c4p.exp.2": { + "message": "5 - 9 years" + }, + "c4p.exp.3": { + "message": "Over 10 years" + }, + "c4p.duration.0": { + "message": "15 minutes" + }, + "c4p.duration.1": { + "message": "25 minutes" + }, + "c4p.travel.0": { + "message": "I would like the organization to cover my travel and accommodation" + }, + "c4p.travel.1": { + "message": "I can cover the costs myself" + }, + "c4p.gender.0": { + "message": "Man" + }, + "c4p.gender.1": { + "message": "Woman" + }, + "c4p.gender.2": { + "message": "Non-binary" + }, + "c4p.gender.3": { + "message": "Prefer not to say" + }, + "c4p.race.0": { + "message": "White" + }, + "c4p.race.1": { + "message": "Mixed" + }, + "c4p.race.2": { + "message": "Black" + }, + "c4p.race.3": { + "message": "Indigenous" + }, + "c4p.race.4": { + "message": "I don't know" + }, + "c4p.race.5": { + "message": "Other" + }, + "c4p.race.6": { + "message": "Prefer not to say" + }, + "c4p.disability.0": { + "message": "I am blind / have low vision" + }, + "c4p.disability.1": { + "message": "I am deaf / have a hearing impairment" + }, + "c4p.disability.2": { + "message": "I cannot / have difficulty walking or standing without assistance" + }, + "c4p.disability.3": { + "message": "I cannot / have difficulty typing" + }, + "c4p.disability.4": { + "message": "Not applicable" + }, + "c4p.disability.5": { + "message": "Prefer not to say" + }, + "c4p.audience.0": { + "message": "All levels" + }, + "c4p.audience.1": { + "message": "Junior" + }, + "c4p.audience.2": { + "message": "Mid-level" + }, + "c4p.audience.3": { + "message": "Senior" + }, + "c4p.about.name": { + "message": "Your name" + }, + "c4p.about.email": { + "message": "Email" + }, + "c4p.about.phone": { + "message": "Mobile" + }, + "c4p.about.city": { + "message": "City" + }, + "c4p.about.uf": { + "message": "State" + }, + "c4p.about.travelHeading": { + "message": "Travel and accommodation" + }, + "c4p.about.travelDesc": { + "message": "The city where you live may affect the selection, since travel can create additional costs for the event. Depending on our budget, it may not be possible to cover travel and accommodation for every speaker." + }, + "c4p.about.social": { + "message": "Social media" + }, + "c4p.about.website": { + "message": "Personal website" + }, + "c4p.about.experience": { + "message": "Years of experience" + }, + "c4p.about.bio": { + "message": "Mini bio" + }, + "c4p.about.bioDesc": { + "message": "In a single paragraph of up to 280 characters, include information about your background, certifications, current and past roles, research, published articles, or any other professional detail you consider relevant." + }, + "c4p.talk.contentType": { + "message": "Content type" + }, + "c4p.talk.contentTypeDesc": { + "message": "Talks will be 15 or 25 minutes long. Choose the duration you consider most suitable for your proposal." + }, + "c4p.talk.duration": { + "message": "Duration" + }, + "c4p.talk.title": { + "message": "Title" + }, + "c4p.talk.description": { + "message": "Description" + }, + "c4p.talk.descriptionDesc": { + "message": "Provide a summary of your content. This information will be used on our website to promote your talk." + }, + "c4p.talk.audience": { + "message": "Who is this content for?" + }, + "c4p.talk.reason": { + "message": "Why should we consider this content at JSConf Brasil?" + }, + "c4p.success.thanks": { + "message": "Thank you for submitting to the Call4Papers of {brand}. Your proposal was received successfully." + }, + "c4p.success.heading": { + "message": "How will proposals be reviewed?" + }, + "c4p.success.p1": { + "message": "Proposals will be reviewed in two stages." + }, + "c4p.success.p2": { + "message": "First, a co-curation group will anonymously review the title, description, and the indicated knowledge level for each talk." + }, + "c4p.success.p3": { + "message": "Next, the JSConf Brasil team will do a complementary review to check how the content fits the schedule, considering budget, strategy, and topic diversity." + }, + "c4p.success.p4": { + "message": "There is no fixed number of people selected; the choice will depend on the quality and fit of the submitted proposals." + }, + "c4p.success.p5": { + "message": "After submissions close, the entire review process will be completed within two weeks." + }, + "c4p.success.p6": { + "message": "Selected speakers will receive an email to confirm their participation, and if there is no response, we will reach out to the next people on the list. Once all confirmations are finalized, we will also notify those who were not approved." + }, + "c4p.div.intro": { + "message": "JSConf Brasil always strives for diversity, inclusion, and accessibility. If you feel comfortable answering, we would like to know:" + }, + "c4p.div.sensitive": { + "message": "This data is sensitive and optional. It will be used solely to (a) build a diverse speaker line-up, (b) plan event accessibility (sign-language interpreters, physical accessibility, etc.), and (c) generate aggregate statistics about diversity in the C4P. You can revoke this consent at any time by writing to one of {link}." + }, + "c4p.div.volunteers": { + "message": "our volunteers" + }, + "c4p.div.gender": { + "message": "Gender identity" + }, + "c4p.div.race": { + "message": "Which color/race do you identify with?" + }, + "c4p.div.disability": { + "message": "Disability status: which of the options below describes you, if any?" } } diff --git a/i18n/es-419/code.json b/i18n/es-419/code.json index a7d3864..e33539d 100644 --- a/i18n/es-419/code.json +++ b/i18n/es-419/code.json @@ -14,12 +14,21 @@ "navbar.section.team": { "message": "Nuestro Equipo" }, + "navbar.section.account": { + "message": "Cuenta" + }, "navbar.section.sponsors": { "message": "Sé un Patrocinador" }, "navbar.tickets": { "message": "Entradas" }, + "navbar.login": { + "message": "Iniciar sesión" + }, + "navbar.vote": { + "message": "Votar" + }, "navbar.aria.openMenu": { "message": "Abrir menú" }, @@ -364,5 +373,458 @@ }, "supporters.subtitle": { "message": "Estamos agradecidos por el apoyo de nuestros socios que comparten nuestra visión de fortalecer la comunidad JavaScript en Brasil." + }, + "common.loading": { + "message": "Cargando…" + }, + "auth.login": { + "message": "Entrar con guild.host" + }, + "auth.logout": { + "message": "Salir" + }, + "vote.title": { + "message": "Votación - JSConf Brasil 2026" + }, + "vote.heading": { + "message": "Vota por las charlas" + }, + "vote.subheading": { + "message": "Elige las charlas que quieres ver en el escenario." + }, + "vote.hideAuthorsTitle": { + "message": "¿Por qué ocultamos los nombres de los autores?" + }, + "vote.hideAuthorsBody": { + "message": "Para reducir sesgos, ocultamos los nombres de quienes propusieron cada charla. Vota por el contenido de la charla, no por quién la escribió." + }, + "vote.loginHeading": { + "message": "Inicia sesión para votar" + }, + "vote.closedHeading": { + "message": "La votación está cerrada" + }, + "vote.emptyTalks": { + "message": "No hay charlas disponibles para votar en este momento." + }, + "vote.by": { + "message": "por" + }, + "vote.voteAction": { + "message": "Votar" + }, + "vote.votedAction": { + "message": "Votado" + }, + "vote.loginPrompt": { + "message": "Inicia sesión con tu cuenta de guild.host para votar por las charlas." + }, + "vote.loadError": { + "message": "No se pudo cargar la votación. Inténtalo más tarde." + }, + "vote.remaining": { + "message": "Votos restantes: {remaining} de {budget}" + }, + "vote.openUntil": { + "message": "Abierta hasta {date}" + }, + "vote.closed": { + "message": "La votación ha cerrado." + }, + "vote.limitReached": { + "message": "Ya usaste tus {budget} votos." + }, + "vote.submitError": { + "message": "Error al registrar el voto. Inténtalo de nuevo." + }, + "login.error.denied": { + "message": "Inicio de sesión cancelado." + }, + "login.error.state": { + "message": "La sesión de inicio expiró. Inténtalo de nuevo." + }, + "login.error.token": { + "message": "Error al autenticar con guild.host. Inténtalo de nuevo." + }, + "login.error.identity": { + "message": "No se pudo identificar tu cuenta de guild.host." + }, + "login.error.notattendee": { + "message": "No tienes una entrada para el evento." + }, + "login.error.generic": { + "message": "Error al entrar. Inténtalo de nuevo." + }, + "account.title": { + "message": "Cuenta - JSConf Brasil 2026" + }, + "account.loginHeading": { + "message": "Tu cuenta" + }, + "account.idLabel": { + "message": "ID" + }, + "account.loginPrompt": { + "message": "Inicia sesión con tu cuenta de guild.host para acceder a tu cuenta." + }, + "account.heading": { + "message": "Cuenta" + }, + "account.loadError": { + "message": "No se pudo cargar. Inténtalo más tarde." + }, + "c4p.pageTitle": { + "message": "Call4Papers - JSConf Brasil 2026" + }, + "c4p.action.back": { + "message": "Volver" + }, + "c4p.action.continue": { + "message": "Continuar" + }, + "c4p.action.finish": { + "message": "Finalizar" + }, + "c4p.action.submitAnother": { + "message": "Enviar otra charla" + }, + "c4p.status.invalid": { + "message": "Inválido" + }, + "c4p.status.valid": { + "message": "Válido" + }, + "c4p.step.about": { + "message": "Sobre ti" + }, + "c4p.step.diversity": { + "message": "Diversidad e inclusión (opcional)" + }, + "c4p.step.talk": { + "message": "Sobre la charla" + }, + "c4p.step.done": { + "message": "¡Propuesta enviada!" + }, + "c4p.progress": { + "message": "Página {current} de 4" + }, + "c4p.error.name": { + "message": "Ingresa tu nombre" + }, + "c4p.error.email": { + "message": "Ingresa un correo válido" + }, + "c4p.error.emailTooLong": { + "message": "El correo es demasiado largo" + }, + "c4p.error.phone": { + "message": "Ingresa un número válido" + }, + "c4p.error.city": { + "message": "Ingresa tu ciudad" + }, + "c4p.error.state": { + "message": "Selecciona un estado" + }, + "c4p.error.selectOption": { + "message": "Selecciona una opción" + }, + "c4p.error.invalid": { + "message": "Valor no válido" + }, + "c4p.error.bio": { + "message": "Ingresa tu biografía" + }, + "c4p.error.bioTooLong": { + "message": "La biografía es demasiado larga" + }, + "c4p.error.duration": { + "message": "Selecciona la duración" + }, + "c4p.error.talkTitle": { + "message": "Ingresa el título" + }, + "c4p.error.talkDescription": { + "message": "Ingresa la descripción" + }, + "c4p.error.audienceLevel": { + "message": "Selecciona el nivel" + }, + "c4p.error.talkReason": { + "message": "Ingresa el motivo" + }, + "c4p.error.requiredFields": { + "message": "Completa los campos obligatorios" + }, + "c4p.submit.configError": { + "message": "Configuración no disponible. Inténtalo más tarde." + }, + "c4p.submit.limit": { + "message": "Ya alcanzaste el límite de 3 charlas." + }, + "c4p.submit.error": { + "message": "Error al enviar la propuesta. Inténtalo de nuevo." + }, + "c4p.intro.toastTitle": { + "message": "Tus datos se guardan en el navegador." + }, + "c4p.intro.toastDesc": { + "message": "Puedes continuar donde lo dejaste en cualquier momento." + }, + "c4p.intro.heading1": { + "message": "¿Quieres dar una charla en la JSConf Brasil de este año?" + }, + "c4p.intro.p1": { + "message": "¡Esta es tu oportunidad de compartir el escenario con algunos de los profesionales más increíbles del área de tecnología! Completa nuestro Call4Papers y nuestro equipo analizará y seleccionará las propuestas enviadas." + }, + "c4p.intro.p2": { + "message": "{brand} existe para compartir conocimiento, acercar a la comunidad y fortalecer un evento diverso, inclusivo y colaborativo." + }, + "c4p.intro.dateHeading": { + "message": "JSConf Brasil - 28 de noviembre de 2026" + }, + "c4p.intro.deadline": { + "message": "Aceptaremos inscripciones hasta el {date}." + }, + "c4p.intro.deadlineDate": { + "message": "30 de julio" + }, + "c4p.intro.maxTalks": { + "message": "Puedes enviar hasta {count} charlas." + }, + "c4p.intro.eventInfo": { + "message": "El evento se realizará el {date}, en la {venue}. Habrá charlas, paneles, actividades interactivas, feria de expositores y mucho más." + }, + "c4p.intro.eventDate": { + "message": "28 de noviembre de 2026" + }, + "c4p.intro.topicsIntro": { + "message": "Estos son algunos de los temas que sugerimos (los que tienen estrella son los preferidos):" + }, + "c4p.topic.devTooling": { + "message": "Dev Tooling" + }, + "c4p.topic.devEx": { + "message": "Developer Experience" + }, + "c4p.topic.aiDev": { + "message": "IA en el Desarrollo" + }, + "c4p.topic.architecture": { + "message": "Arquitectura" + }, + "c4p.topic.devops": { + "message": "DevOps" + }, + "c4p.topic.observability": { + "message": "Observabilidad" + }, + "c4p.topic.performance": { + "message": "Performance" + }, + "c4p.topic.realCases": { + "message": "Casos Reales de Resolución de Problemas con código" + }, + "c4p.topic.a11yCode": { + "message": "Código para Accesibilidad" + }, + "c4p.topic.openSource": { + "message": "Open Source" + }, + "c4p.topic.designSystem": { + "message": "Design System" + }, + "c4p.topic.security": { + "message": "Seguridad en el Desarrollo" + }, + "c4p.exp.0": { + "message": "0 - 1 año" + }, + "c4p.exp.1": { + "message": "2 - 4 años" + }, + "c4p.exp.2": { + "message": "5 - 9 años" + }, + "c4p.exp.3": { + "message": "Más de 10 años" + }, + "c4p.duration.0": { + "message": "15 minutos" + }, + "c4p.duration.1": { + "message": "25 minutos" + }, + "c4p.travel.0": { + "message": "Me gustaría que la organización pagara mi viaje y hospedaje" + }, + "c4p.travel.1": { + "message": "Puedo cubrir los costos" + }, + "c4p.gender.0": { + "message": "Hombre" + }, + "c4p.gender.1": { + "message": "Mujer" + }, + "c4p.gender.2": { + "message": "No binario" + }, + "c4p.gender.3": { + "message": "Prefiero no decir" + }, + "c4p.race.0": { + "message": "Blanca" + }, + "c4p.race.1": { + "message": "Parda" + }, + "c4p.race.2": { + "message": "Negra" + }, + "c4p.race.3": { + "message": "Indígena" + }, + "c4p.race.4": { + "message": "No sé" + }, + "c4p.race.5": { + "message": "Otro" + }, + "c4p.race.6": { + "message": "Prefiero no decir" + }, + "c4p.disability.0": { + "message": "Soy ciego(a) / tengo baja visión" + }, + "c4p.disability.1": { + "message": "Soy sordo(a) / tengo discapacidad auditiva" + }, + "c4p.disability.2": { + "message": "No puedo / tengo dificultad para caminar o mantenerme de pie sin ayuda" + }, + "c4p.disability.3": { + "message": "No puedo / tengo dificultad para escribir" + }, + "c4p.disability.4": { + "message": "No aplica" + }, + "c4p.disability.5": { + "message": "Prefiero no decir" + }, + "c4p.audience.0": { + "message": "Todos los niveles" + }, + "c4p.audience.1": { + "message": "Junior" + }, + "c4p.audience.2": { + "message": "Semi-senior" + }, + "c4p.audience.3": { + "message": "Senior" + }, + "c4p.about.name": { + "message": "Tu nombre" + }, + "c4p.about.email": { + "message": "Correo electrónico" + }, + "c4p.about.phone": { + "message": "Celular" + }, + "c4p.about.city": { + "message": "Ciudad" + }, + "c4p.about.uf": { + "message": "Estado" + }, + "c4p.about.travelHeading": { + "message": "Sobre el viaje y el hospedaje" + }, + "c4p.about.travelDesc": { + "message": "La ciudad donde vives puede influir en la selección, ya que el desplazamiento puede generar costos adicionales para el evento. Según nuestro presupuesto, quizás no sea posible cubrir el viaje y el hospedaje de todos los ponentes." + }, + "c4p.about.social": { + "message": "Redes sociales" + }, + "c4p.about.website": { + "message": "Sitio personal" + }, + "c4p.about.experience": { + "message": "Tiempo de experiencia" + }, + "c4p.about.bio": { + "message": "Mini biografía" + }, + "c4p.about.bioDesc": { + "message": "En un solo párrafo, de hasta 280 caracteres, incluye información sobre tu formación, certificaciones, cargo actual y anteriores, investigaciones, artículos publicados o cualquier otro dato profesional que consideres relevante." + }, + "c4p.talk.contentType": { + "message": "Tipo de contenido" + }, + "c4p.talk.contentTypeDesc": { + "message": "Las charlas serán de 15 o 25 minutos. Elige la duración que consideres más adecuada para tu propuesta." + }, + "c4p.talk.duration": { + "message": "Duración" + }, + "c4p.talk.title": { + "message": "Título" + }, + "c4p.talk.description": { + "message": "Descripción" + }, + "c4p.talk.descriptionDesc": { + "message": "Proporciona un resumen de tu contenido. Esta información se usará en nuestro sitio para promocionar tu charla." + }, + "c4p.talk.audience": { + "message": "¿Para quién es este contenido?" + }, + "c4p.talk.reason": { + "message": "¿Por qué deberíamos considerar este contenido en la JSConf Brasil?" + }, + "c4p.success.thanks": { + "message": "Gracias por inscribirte en el Call4Papers de {brand}. Tu propuesta se recibió con éxito." + }, + "c4p.success.heading": { + "message": "¿Cómo se evaluarán las propuestas?" + }, + "c4p.success.p1": { + "message": "La evaluación de las propuestas se realizará en dos etapas." + }, + "c4p.success.p2": { + "message": "Primero, un grupo de co-curaduría analizará de forma anónima el título, la descripción y el nivel de conocimiento indicado para cada charla." + }, + "c4p.success.p3": { + "message": "Luego, el equipo de la JSConf Brasil hará un análisis complementario para verificar el encaje del contenido en la programación, considerando presupuesto, estrategia y diversidad temática." + }, + "c4p.success.p4": { + "message": "No existe un número fijo de personas seleccionadas; la elección dependerá de la calidad y adecuación de las propuestas enviadas." + }, + "c4p.success.p5": { + "message": "Tras el cierre de las inscripciones, todo el proceso de evaluación se completará en un plazo de dos semanas." + }, + "c4p.success.p6": { + "message": "Las personas seleccionadas recibirán un correo para confirmar su participación y, si no hay respuesta, contactaremos a las siguientes de la lista. Una vez finalizadas todas las confirmaciones, también informaremos a quienes no fueron aprobadas." + }, + "c4p.div.intro": { + "message": "La JSConf Brasil siempre busca la diversidad, la inclusión y la accesibilidad. Si te sientes cómodo(a) respondiendo, nos gustaría saber:" + }, + "c4p.div.sensitive": { + "message": "Estos datos son sensibles y opcionales. Se usarán exclusivamente para (a) formar un line-up diverso de ponentes, (b) planificar la accesibilidad del evento (intérpretes de lengua de señas, accesibilidad física, etc.) y (c) generar estadísticas agregadas sobre diversidad en el C4P. Puedes revocar este consentimiento en cualquier momento escribiendo a uno de {link}." + }, + "c4p.div.volunteers": { + "message": "nuestros voluntarios" + }, + "c4p.div.gender": { + "message": "Identidad de género" + }, + "c4p.div.race": { + "message": "¿Con qué color/raza te identificas?" + }, + "c4p.div.disability": { + "message": "Situación de discapacidad: ¿cuál de las opciones describe tu caso, si aplica?" } } diff --git a/i18n/pt-BR/code.json b/i18n/pt-BR/code.json index 34a21cf..9260b8a 100644 --- a/i18n/pt-BR/code.json +++ b/i18n/pt-BR/code.json @@ -14,12 +14,21 @@ "navbar.section.team": { "message": "Nosso Time" }, + "navbar.section.account": { + "message": "Conta" + }, "navbar.section.sponsors": { "message": "Seja um Patrocinador" }, "navbar.tickets": { "message": "Ingressos" }, + "navbar.login": { + "message": "Entrar" + }, + "navbar.vote": { + "message": "Votar" + }, "navbar.aria.openMenu": { "message": "Abrir menu" }, @@ -364,5 +373,458 @@ }, "supporters.subtitle": { "message": "Somos gratos pelo apoio de nossos parceiros que compartilham nossa visão de fortalecer a comunidade JavaScript no Brasil." + }, + "common.loading": { + "message": "Carregando…" + }, + "auth.login": { + "message": "Entrar com guild.host" + }, + "auth.logout": { + "message": "Sair" + }, + "vote.title": { + "message": "Votação - JSConf Brasil 2026" + }, + "vote.heading": { + "message": "Vote nas palestras" + }, + "vote.subheading": { + "message": "Escolha as palestras que você quer ver no palco." + }, + "vote.hideAuthorsTitle": { + "message": "Por que escondemos os nomes dos autores?" + }, + "vote.hideAuthorsBody": { + "message": "Para reduzir vieses, os nomes de quem propôs cada palestra ficam ocultos. Vote pelo conteúdo da palestra, não por quem a escreveu." + }, + "vote.loginHeading": { + "message": "Entre para votar" + }, + "vote.closedHeading": { + "message": "A votação está encerrada" + }, + "vote.emptyTalks": { + "message": "Nenhuma palestra disponível para votação no momento." + }, + "vote.by": { + "message": "por" + }, + "vote.voteAction": { + "message": "Votar" + }, + "vote.votedAction": { + "message": "Votado" + }, + "vote.loginPrompt": { + "message": "Entre com sua conta guild.host para votar nas palestras." + }, + "vote.loadError": { + "message": "Não foi possível carregar a votação. Tente mais tarde." + }, + "vote.remaining": { + "message": "Votos restantes: {remaining} de {budget}" + }, + "vote.openUntil": { + "message": "Aberta até {date}" + }, + "vote.closed": { + "message": "A votação foi encerrada." + }, + "vote.limitReached": { + "message": "Você já usou seus {budget} votos." + }, + "vote.submitError": { + "message": "Erro ao registrar voto. Tente novamente." + }, + "login.error.denied": { + "message": "Login cancelado." + }, + "login.error.state": { + "message": "Sessão de login expirada. Tente novamente." + }, + "login.error.token": { + "message": "Falha ao autenticar com guild.host. Tente novamente." + }, + "login.error.identity": { + "message": "Não foi possível identificar sua conta guild.host." + }, + "login.error.notattendee": { + "message": "Você não possui um ingresso para o evento." + }, + "login.error.generic": { + "message": "Erro ao entrar. Tente novamente." + }, + "account.title": { + "message": "Conta - JSConf Brasil 2026" + }, + "account.loginHeading": { + "message": "Sua conta" + }, + "account.idLabel": { + "message": "ID" + }, + "account.loginPrompt": { + "message": "Entre com sua conta guild.host para acessar sua conta." + }, + "account.heading": { + "message": "Conta" + }, + "account.loadError": { + "message": "Não foi possível carregar. Tente mais tarde." + }, + "c4p.pageTitle": { + "message": "Call4Papers - JSConf Brasil 2026" + }, + "c4p.action.back": { + "message": "Voltar" + }, + "c4p.action.continue": { + "message": "Continuar" + }, + "c4p.action.finish": { + "message": "Finalizar" + }, + "c4p.action.submitAnother": { + "message": "Enviar outra palestra" + }, + "c4p.status.invalid": { + "message": "Inválido" + }, + "c4p.status.valid": { + "message": "Válido" + }, + "c4p.step.about": { + "message": "Sobre você" + }, + "c4p.step.diversity": { + "message": "Diversidade e inclusão (opcional)" + }, + "c4p.step.talk": { + "message": "Sobre a Palestra" + }, + "c4p.step.done": { + "message": "Proposta enviada!" + }, + "c4p.progress": { + "message": "Página {current} de 4" + }, + "c4p.error.name": { + "message": "Informe seu nome" + }, + "c4p.error.email": { + "message": "Informe um e-mail válido" + }, + "c4p.error.emailTooLong": { + "message": "E-mail muito longo" + }, + "c4p.error.phone": { + "message": "Informe um número válido" + }, + "c4p.error.city": { + "message": "Informe sua cidade" + }, + "c4p.error.state": { + "message": "Selecione um estado" + }, + "c4p.error.selectOption": { + "message": "Selecione uma opção" + }, + "c4p.error.invalid": { + "message": "Valor inválido" + }, + "c4p.error.bio": { + "message": "Informe sua biografia" + }, + "c4p.error.bioTooLong": { + "message": "Biografia muito longa" + }, + "c4p.error.duration": { + "message": "Selecione a duração" + }, + "c4p.error.talkTitle": { + "message": "Informe o título" + }, + "c4p.error.talkDescription": { + "message": "Informe a descrição" + }, + "c4p.error.audienceLevel": { + "message": "Selecione o nível" + }, + "c4p.error.talkReason": { + "message": "Informe o motivo" + }, + "c4p.error.requiredFields": { + "message": "Preencha os campos obrigatórios" + }, + "c4p.submit.configError": { + "message": "Configuração indisponível. Tente novamente mais tarde." + }, + "c4p.submit.limit": { + "message": "Você já atingiu o limite de 3 palestras." + }, + "c4p.submit.error": { + "message": "Erro ao enviar proposta. Tente novamente." + }, + "c4p.intro.toastTitle": { + "message": "Seus dados ficam salvos no navegador." + }, + "c4p.intro.toastDesc": { + "message": "Você pode continuar de onde parou a qualquer momento." + }, + "c4p.intro.heading1": { + "message": "Quer palestrar na JSConf Brasil deste ano?" + }, + "c4p.intro.p1": { + "message": "Essa é a sua chance de dividir o palco com alguns dos profissionais mais incríveis da área de tecnologia! Preencha o nosso Call4Papers, nossa equipe fará a análise e seleção das propostas enviadas." + }, + "c4p.intro.p2": { + "message": "A {brand} existe para compartilhar conhecimento, aproximar a comunidade e fortalecer um evento diverso, inclusivo e colaborativo." + }, + "c4p.intro.dateHeading": { + "message": "JSConf Brasil - 28 de novembro de 2026" + }, + "c4p.intro.deadline": { + "message": "Aceitaremos inscrições até {date}." + }, + "c4p.intro.deadlineDate": { + "message": "30 de julho" + }, + "c4p.intro.maxTalks": { + "message": "Você pode enviar até {count} palestras." + }, + "c4p.intro.eventInfo": { + "message": "O evento acontecerá no dia {date}, na {venue}. Serão palestras, painéis, atividades interativas, feira de expositores e muito mais." + }, + "c4p.intro.eventDate": { + "message": "28 de novembro de 2026" + }, + "c4p.intro.topicsIntro": { + "message": "Esses são alguns dos tópicos que sugerimos (os que têm estrelinha são os preferidos):" + }, + "c4p.topic.devTooling": { + "message": "Dev Tooling" + }, + "c4p.topic.devEx": { + "message": "Developer Experience" + }, + "c4p.topic.aiDev": { + "message": "IA no Desenvolvimento" + }, + "c4p.topic.architecture": { + "message": "Arquitetura" + }, + "c4p.topic.devops": { + "message": "DevOps" + }, + "c4p.topic.observability": { + "message": "Observabilidade" + }, + "c4p.topic.performance": { + "message": "Performance" + }, + "c4p.topic.realCases": { + "message": "Cases Reais de Resolução de Problemas usando código" + }, + "c4p.topic.a11yCode": { + "message": "Código para Acessibilidade" + }, + "c4p.topic.openSource": { + "message": "Open Source" + }, + "c4p.topic.designSystem": { + "message": "Design System" + }, + "c4p.topic.security": { + "message": "Segurança no Desenvolvimento" + }, + "c4p.exp.0": { + "message": "0 - 1 ano" + }, + "c4p.exp.1": { + "message": "2 - 4 anos" + }, + "c4p.exp.2": { + "message": "5 - 9 anos" + }, + "c4p.exp.3": { + "message": "Acima de 10 anos" + }, + "c4p.duration.0": { + "message": "15 minutos" + }, + "c4p.duration.1": { + "message": "25 minutos" + }, + "c4p.travel.0": { + "message": "Gostaria que a organização pagasse minha viagem e hospedagem" + }, + "c4p.travel.1": { + "message": "Posso arcar com os custos" + }, + "c4p.gender.0": { + "message": "Homem" + }, + "c4p.gender.1": { + "message": "Mulher" + }, + "c4p.gender.2": { + "message": "Não-binário" + }, + "c4p.gender.3": { + "message": "Prefiro não dizer" + }, + "c4p.race.0": { + "message": "Branca" + }, + "c4p.race.1": { + "message": "Parda" + }, + "c4p.race.2": { + "message": "Preta" + }, + "c4p.race.3": { + "message": "Indígena" + }, + "c4p.race.4": { + "message": "Não sei" + }, + "c4p.race.5": { + "message": "Outro" + }, + "c4p.race.6": { + "message": "Prefiro não dizer" + }, + "c4p.disability.0": { + "message": "Sou cego(a) / tenho baixa visão" + }, + "c4p.disability.1": { + "message": "Sou surdo(a) / tenho deficiência auditiva" + }, + "c4p.disability.2": { + "message": "Eu não consigo / tenho dificuldade de andar ou ficar em pé sem assistência" + }, + "c4p.disability.3": { + "message": "Eu não consigo / tenho dificuldade de digitar" + }, + "c4p.disability.4": { + "message": "Não se aplica" + }, + "c4p.disability.5": { + "message": "Prefiro não dizer" + }, + "c4p.audience.0": { + "message": "Todos os níveis" + }, + "c4p.audience.1": { + "message": "Júnior" + }, + "c4p.audience.2": { + "message": "Pleno" + }, + "c4p.audience.3": { + "message": "Sênior" + }, + "c4p.about.name": { + "message": "Seu nome" + }, + "c4p.about.email": { + "message": "E-mail" + }, + "c4p.about.phone": { + "message": "Celular" + }, + "c4p.about.city": { + "message": "Cidade" + }, + "c4p.about.uf": { + "message": "UF" + }, + "c4p.about.travelHeading": { + "message": "Sobre a viagem e hospedagem" + }, + "c4p.about.travelDesc": { + "message": "A cidade onde você mora pode influenciar na seleção, pois o deslocamento pode gerar custos adicionais para o evento. Dependendo do nosso orçamento, talvez não seja possível custear passagem e hospedagem para todos os palestrantes." + }, + "c4p.about.social": { + "message": "Redes sociais" + }, + "c4p.about.website": { + "message": "Site Pessoal" + }, + "c4p.about.experience": { + "message": "Tempo de experiência" + }, + "c4p.about.bio": { + "message": "Mini biografia" + }, + "c4p.about.bioDesc": { + "message": "Em um único parágrafo, com até 280 caracteres, inclua informações sobre sua formação, certificações, cargo atual e anteriores, pesquisas desenvolvidas, artigos publicados ou qualquer outro dado profissional que considere relevante." + }, + "c4p.talk.contentType": { + "message": "Tipo de conteúdo" + }, + "c4p.talk.contentTypeDesc": { + "message": "O formato dos conteúdos será em palestras de 15 ou 25 minutos. Escolha o tempo de duração que você considera mais adequado para a sua proposta." + }, + "c4p.talk.duration": { + "message": "Tempo de duração" + }, + "c4p.talk.title": { + "message": "Título" + }, + "c4p.talk.description": { + "message": "Descrição" + }, + "c4p.talk.descriptionDesc": { + "message": "Forneça um resumo do seu conteúdo. Essa informação será usada em nosso site para divulgação da sua palestra." + }, + "c4p.talk.audience": { + "message": "Para quem é este conteúdo?" + }, + "c4p.talk.reason": { + "message": "Por que deveríamos considerar este conteúdo na JSConf Brasil?" + }, + "c4p.success.thanks": { + "message": "Obrigado por se inscrever no Call4Papers da {brand}. Sua proposta foi recebida com sucesso." + }, + "c4p.success.heading": { + "message": "Como os conteúdos serão avaliados?" + }, + "c4p.success.p1": { + "message": "A avaliação das propostas acontecerá em duas etapas." + }, + "c4p.success.p2": { + "message": "Primeiro, um grupo de co-curadoria analisará anonimamente o título, a descrição e o nível de conhecimento indicado para cada palestra." + }, + "c4p.success.p3": { + "message": "Em seguida, a equipe da JSConf Brasil fará uma análise complementar para verificar o encaixe do conteúdo na programação, considerando orçamento, estratégia e diversidade temática." + }, + "c4p.success.p4": { + "message": "Não existe um número fixo de pessoas selecionadas; a escolha dependerá da qualidade e adequação das propostas enviadas." + }, + "c4p.success.p5": { + "message": "Após o fim das inscrições, todo o processo de avaliação será concluído em até duas semanas." + }, + "c4p.success.p6": { + "message": "As pessoas selecionadas receberão um e-mail para confirmar sua participação e, caso não haja resposta, entraremos em contato com as próximas da lista. Assim que todas as confirmações forem finalizadas, informaremos também as pessoas que não forem aprovadas." + }, + "c4p.div.intro": { + "message": "A JSConf Brasil busca sempre pela diversidade, inclusão e acessibilidade. Caso se sinta confortável em responder, gostaríamos de saber:" + }, + "c4p.div.sensitive": { + "message": "Estes dados são sensíveis e opcionais. Serão usados exclusivamente para (a) compor um line-up diverso de palestrantes, (b) planejar a acessibilidade do evento (intérpretes de Libras, acessibilidade física, etc.) e (c) gerar estatísticas agregadas sobre diversidade no C4P. Você pode revogar esse consentimento a qualquer momento escrevendo para um dos {link}." + }, + "c4p.div.volunteers": { + "message": "nossos voluntários" + }, + "c4p.div.gender": { + "message": "Identidade de Gênero" + }, + "c4p.div.race": { + "message": "Qual cor/raça você se identifica?" + }, + "c4p.div.disability": { + "message": "Situação de deficiência: qual das opções abaixo descreve você, se tiver alguma?" } } diff --git a/package-lock.json b/package-lock.json index d642fd7..12658d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "AGPL-3.0-only", "dependencies": { - "stripe": "^22.0.0" + "jose": "^6.2.3" }, "devDependencies": { "@cloudflare/workers-types": "^5.20260711.1", @@ -8227,7 +8227,7 @@ "version": "25.5.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -13702,6 +13702,15 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -21511,23 +21520,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/stripe": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.0.0.tgz", - "integrity": "sha512-q1UgXXpSfZCmkyzZEh3vFEWT7+ajuaFGqaP9Tsi2NMtwlkigIWNr+KBIUQqtNeNEsreDKgdn+BP5HRW9JDj22Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -22018,7 +22010,7 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unenv": { diff --git a/package.json b/package.json index 8d68360..fdc55c3 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ }, "private": true, "dependencies": { - "stripe": "^22.0.0" + "jose": "^6.2.3" }, "overrides": { "serialize-javascript": "^7.0.5", diff --git a/resources/schema.sql b/resources/schema.sql index c226e04..b4734e7 100644 --- a/resources/schema.sql +++ b/resources/schema.sql @@ -57,3 +57,29 @@ CREATE TABLE IF NOT EXISTS speaker_diversity ( ); CREATE INDEX IF NOT EXISTS idx_speaker_diversity_speaker_id ON speaker_diversity(speaker_id); + +CREATE TABLE IF NOT EXISTS ticket_tiers ( + name VARCHAR(200) PRIMARY KEY, -- guild.host tier name, matched against attendee tier + budget INTEGER NOT NULL CHECK(budget >= 0) +); + +CREATE TABLE IF NOT EXISTS c4p_votes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, -- guild.host user id (from OAuth) + talk_id INTEGER NOT NULL REFERENCES talks(id), + created_at VARCHAR(20) NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, talk_id) +); + +CREATE INDEX IF NOT EXISTS idx_c4p_votes_user ON c4p_votes(user_id); + +-- Single-row store for the manager OAuth token used to read the event attendees list. guild.host +-- rotates refresh tokens on use, so the rotated refresh_token is written back here on every +-- refresh; the access_token is cached until it expires to avoid refreshing (and rotating) often. +CREATE TABLE IF NOT EXISTS manager_oauth ( + id INTEGER PRIMARY KEY CHECK(id = 1), + refresh_token TEXT NOT NULL, + access_token TEXT, + expires_at INTEGER NOT NULL DEFAULT 0, + updated_at VARCHAR(20) NOT NULL DEFAULT (datetime('now')) +); diff --git a/src/server/configs/oauth.ts b/src/server/configs/oauth.ts new file mode 100644 index 0000000..bedf230 --- /dev/null +++ b/src/server/configs/oauth.ts @@ -0,0 +1,21 @@ +// guild.host OAuth endpoints (docs: https://guild.host/docs/developers/oauth) +export const AUTHORIZE_URL = 'https://guild.host/oauth/authorize'; +export const TOKEN_URL = 'https://guild.host/api/oauth/token'; +export const USERINFO_URL = 'https://guild.host/api/oauth/userinfo'; +export const EVENTS_BASE = 'https://guild.host/api/next/events'; + +// Voters only need identity — the tier is read server-side with the manager token, so a normal +// login requests just profile:read. Space-separated per OAuth 2.0; URLSearchParams encodes ' ' as '+'. +export const OAUTH_SCOPE = 'profile:read'; + +// Manager scope, used once to mint GUILD_ORG_REFRESH_TOKEN. Adds attendee-list read (only granted +// for events the account manages). +export const MANAGER_SCOPE = 'profile:read event_attendees:read'; + +// Event slug hardcoded (same one the ticket widget uses, TicketSelection.tsx). +// Move to an Env var if it ever changes per environment. +export const EVENT_SLUG = 'vdc8dh'; + +export const SESSION_COOKIE = 'vote_session'; +export const STATE_COOKIE = 'vote_oauth_state'; +export const SESSION_TTL_SECONDS = 86400; diff --git a/src/server/configs/stripe.ts b/src/server/configs/stripe.ts deleted file mode 100644 index 4cd1d7f..0000000 --- a/src/server/configs/stripe.ts +++ /dev/null @@ -1,9 +0,0 @@ -import Stripe from 'stripe'; - -export const createStripeClient = (apiKey: string): Stripe => - new Stripe(apiKey, { - apiVersion: '2026-03-25.dahlia', - httpClient: Stripe.createFetchHttpClient(), - }); - -export const cryptoProvider = Stripe.createSubtleCryptoProvider(); diff --git a/src/server/configs/vote.ts b/src/server/configs/vote.ts new file mode 100644 index 0000000..6edd6fd --- /dev/null +++ b/src/server/configs/vote.ts @@ -0,0 +1,7 @@ +// Hardcoded voting window. Move to an Env var only if the date needs changing without a deploy. +export const VOTE_CLOSES_AT = '2026-09-01T00:00:00Z'; + +export const isVotingOpen = (now: Date = new Date()): boolean => + now < new Date(VOTE_CLOSES_AT); + +export const VOTABLE_TALK_STATUS = 2; diff --git a/src/server/helpers/cookies.ts b/src/server/helpers/cookies.ts new file mode 100644 index 0000000..361b632 --- /dev/null +++ b/src/server/helpers/cookies.ts @@ -0,0 +1,7 @@ +export const readCookie = (request: Request, name: string): string | null => { + for (const part of (request.headers.get('Cookie') ?? '').split(';')) { + const [k, ...v] = part.trim().split('='); + if (k === name) return v.join('='); + } + return null; +}; diff --git a/src/server/helpers/oauth.ts b/src/server/helpers/oauth.ts new file mode 100644 index 0000000..28f31cc --- /dev/null +++ b/src/server/helpers/oauth.ts @@ -0,0 +1,185 @@ +import type { Database } from '../types.js'; +import { + AUTHORIZE_URL, + EVENTS_BASE, + OAUTH_SCOPE, + TOKEN_URL, + USERINFO_URL, +} from '../configs/oauth.js'; + +export const buildAuthorizeUrl = ( + clientId: string, + redirectUri: string, + state: string, + scope: string = OAUTH_SCOPE +): string => { + const params = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + scope, + state, + }); + return `${AUTHORIZE_URL}?${params.toString()}`; +}; + +export const exchangeCode = async ( + code: string, + clientId: string, + clientSecret: string, + redirectUri: string +): Promise<{ access_token: string; refresh_token?: string } | null> => { + const res = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + }), + }).catch(() => null); + + if (!res || !res.ok) return null; + return (await res.json()) as { access_token: string; refresh_token?: string }; +}; + +// One page of guild's event attendees. The tier lives on each attendee's paid ticket order; +// most attendees (interest-only, no ticket) have `ticketOrder: null`. +type AttendeesPage = { + edges?: { + node?: { + userId?: string; + ticketOrder?: { + eventTicketOrderItems?: { + nodes?: { eventTicketingTier?: { name?: string } | null }[]; + }; + } | null; + }; + }[]; + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }; +}; + +// Walk the attendees list (created_at desc, 100/page) to find this user's ticket tier. Recursive +// so it pages without a `let` cursor. Capped at `pagesLeft` pages so a huge attendee list can't +// hang a login; a fresh voter sorts near the top, so page 1 usually hits. +const attendeeTier = async ( + accessToken: string, + slug: string, + userId: string, + after: string, + pagesLeft: number +): Promise<string | null> => { + if (pagesLeft <= 0) return null; + const query = after ? `&after=${encodeURIComponent(after)}` : ''; + const res = await fetch( + `${EVENTS_BASE}/${slug}/attendees?first=100${query}`, + { headers: { Authorization: `Bearer ${accessToken}` } } + ).catch(() => null); + if (!res || !res.ok) return null; + const data = (await res.json()) as AttendeesPage; + const node = data.edges?.find((edge) => edge.node?.userId === userId)?.node; + if (node) + return ( + node.ticketOrder?.eventTicketOrderItems?.nodes?.[0]?.eventTicketingTier + ?.name ?? null + ); + const next = data.pageInfo?.hasNextPage ? data.pageInfo.endCursor : null; + if (!next) return null; + return attendeeTier(accessToken, slug, userId, next, pagesLeft - 1); +}; + +export const fetchTicketTier = async ( + accessToken: string, + slug: string, + userId: string +): Promise<string | null> => attendeeTier(accessToken, slug, userId, '', 30); + +// Manager access token used to read the attendees list. Cached in D1 (shared across worker +// instances) until it expires; guild rotates the refresh token on use, so the rotated token is +// written back here. At expiry two cold instances could race the refresh and one login would fail +// (a retry succeeds) — fine for low-traffic voting. +export const managerAccessToken = async ( + clientId: string, + clientSecret: string, + bootstrapRefresh: string, + database: Database +): Promise<string | null> => { + const now = Math.floor(Date.now() / 1000); + const { results } = await database + .prepare( + 'SELECT refresh_token, access_token, expires_at FROM manager_oauth WHERE id = 1' + ) + .bind() + .all<{ + refresh_token: string; + access_token: string | null; + expires_at: number; + }>(); + const row = results[0]; + if (row?.access_token && row.expires_at - 60 > now) return row.access_token; + + const refreshToken = row?.refresh_token ?? bootstrapRefresh; + const res = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: clientId, + client_secret: clientSecret, + }), + }).catch(() => null); + if (!res || !res.ok) { + // Logs status + error body only, never the token — useful if guild revokes/rotates unexpectedly. + console.log( + '[manager] status', + res?.status ?? 'fetch-error', + res ? await res.text() : '' + ); + return null; + } + const data = (await res.json()) as { + access_token: string; + refresh_token?: string; + expires_in?: number; + }; + // guild rotates the refresh token on use — persist the rotated one so the next mint still works. + await database + .prepare( + `INSERT INTO manager_oauth (id, refresh_token, access_token, expires_at, updated_at) + VALUES (1, ?, ?, ?, datetime('now')) + ON CONFLICT(id) DO UPDATE SET + refresh_token = excluded.refresh_token, + access_token = excluded.access_token, + expires_at = excluded.expires_at, + updated_at = datetime('now')` + ) + .bind( + data.refresh_token ?? refreshToken, + data.access_token, + now + (data.expires_in ?? 3600) + ) + .run(); + return data.access_token; +}; + +export const fetchUserInfo = async ( + accessToken: string +): Promise<{ id: string; name?: string; photo?: string } | null> => { + const res = await fetch(USERINFO_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + }).catch(() => null); + + if (!res || !res.ok) return null; + const data = (await res.json()) as Record<string, unknown>; + // guild.host userinfo: `sub` is the stable id (UUID), `name` the display name, `picture` the + // avatar URL. Confirmed against a live login. + if (typeof data['sub'] !== 'string') return null; + return { + id: data['sub'], + name: typeof data['name'] === 'string' ? data['name'] : undefined, + photo: typeof data['picture'] === 'string' ? data['picture'] : undefined, + }; +}; diff --git a/src/server/helpers/request.ts b/src/server/helpers/request.ts index 17af224..1514adb 100644 --- a/src/server/helpers/request.ts +++ b/src/server/helpers/request.ts @@ -11,3 +11,29 @@ export const parseBody = (text: string): unknown | undefined => { return; } }; + +type ParseResult<T> = { data: T } | { error: string; status: number }; + +// Runs the JSON request gauntlet (content type, size, JSON, schema) shared by the +// c4p and vote routes. Returns the validated data or an { error, status } to respond with. +export const parseRequest = async <T>( + request: Request, + schema: { + safeParse(input: unknown): { success: true; data: T } | { success: false }; + }, + maxSize: number +): Promise<ParseResult<T>> => { + if (!isJsonContentType(request)) + return { error: 'Unsupported content type.', status: 415 }; + + const text = await request.text(); + if (!isWithinSize(text, maxSize)) + return { error: 'Payload too large.', status: 413 }; + + const body = parseBody(text); + if (body === undefined) return { error: 'Invalid JSON.', status: 400 }; + + const parsed = schema.safeParse(body); + if (!parsed.success) return { error: 'Invalid input.', status: 422 }; + return { data: parsed.data }; +}; diff --git a/src/server/helpers/session.ts b/src/server/helpers/session.ts new file mode 100644 index 0000000..d76ca82 --- /dev/null +++ b/src/server/helpers/session.ts @@ -0,0 +1,71 @@ +import type { Env } from '../types.js'; +import { jwtVerify, SignJWT } from 'jose'; +import { SESSION_COOKIE, SESSION_TTL_SECONDS } from '../configs/oauth.js'; +import { readCookie } from './cookies.js'; + +// Dev-only budget for the X-Dev-User bypass so local voting works without a login. +const DEV_BUDGET = 3; + +export type Session = { + userId: string; + budget: number; + name?: string; + photo?: string; +}; + +const key = (secret: string): Uint8Array => new TextEncoder().encode(secret); + +export const signSession = async ( + userId: string, + budget: number, + secret: string, + ttlSeconds: number = SESSION_TTL_SECONDS, + identity: { name?: string; photo?: string } = {} +): Promise<string> => + new SignJWT({ budget, name: identity.name, photo: identity.photo }) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject(userId) + .setExpirationTime(Math.floor(Date.now() / 1000) + ttlSeconds) + .sign(key(secret)); + +export const verifySession = async ( + token: string, + secret: string +): Promise<Session | null> => { + try { + const { payload } = await jwtVerify(token, key(secret), { + algorithms: ['HS256'], + }); + const budget = payload['budget']; + if (typeof payload.sub !== 'string' || typeof budget !== 'number') + return null; + const session: Session = { userId: payload.sub, budget }; + if (typeof payload['name'] === 'string') session.name = payload['name']; + if (typeof payload['photo'] === 'string') session.photo = payload['photo']; + return session; + } catch { + return null; + } +}; + +// Resolves the voter's session from the signed cookie. Budget is baked into the JWT at login +// (from their own ticket tier), so no per-request guild call. Only when ENVIRONMENT is explicitly +// 'development' an X-Dev-User header is accepted as a fallback (with a default budget) so the +// endpoints are testable without the OAuth round-trip. +export const getSession = async ( + request: Request, + env: Env +): Promise<Session | null> => { + const cookie = readCookie(request, SESSION_COOKIE); + if (cookie && env.SESSION_SECRET) { + const session = await verifySession(cookie, env.SESSION_SECRET); + if (session) return session; + } + // Dev-only header bypass. Fail closed: honored only when ENVIRONMENT is explicitly 'development', + // so an unset/misconfigured ENVIRONMENT in production never activates it. + if (env.ENVIRONMENT === 'development') { + const devUser = request.headers.get('X-Dev-User'); + if (devUser) return { userId: devUser, budget: DEV_BUDGET }; + } + return null; +}; diff --git a/src/server/index.ts b/src/server/index.ts index 8bd8143..823ec4d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -7,12 +7,14 @@ import { routes } from './routes.js'; export default { async fetch(request: Request, env: Env): Promise<Response> { const origin = env.ALLOWED_ORIGIN || '*'; - const cors = { + const cors: Record<string, string> = { 'Access-Control-Allow-Origin': origin, - 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', Vary: 'Origin', }; + // Credentialed requests (vote session cookie) need an explicit origin, never '*'. + if (origin !== '*') cors['Access-Control-Allow-Credentials'] = 'true'; const { method } = request; if (method === 'OPTIONS') @@ -20,9 +22,6 @@ export default { const { pathname } = new URL(request.url); - if (`${method} ${pathname}` === 'POST /api/stripe/webhook') - return routes.stripeWebhook({ request, cors, env }); - const rateLimit = checkRateLimit(request); if (!rateLimit.allowed) return response({ error: 'Rate limit exceeded.' }, 429, { @@ -36,6 +35,18 @@ export default { switch (`${method} ${pathname}`) { case 'POST /api/c4p': return routes.c4p({ request, cors, database: env.DB, ip }); + case 'GET /api/vote/login': + return routes.authLogin({ request, env }); + case 'GET /api/vote/me': + return routes.authMe({ request, cors, env }); + case 'GET /api/vote/logout': + return routes.authLogout({ request, env }); + case 'GET /api/vote/callback': + return routes.authCallback({ request, env, database: env.DB }); + case 'GET /api/vote': + return routes.voteGet({ request, cors, database: env.DB, env }); + case 'POST /api/vote': + return routes.voteSubmit({ request, cors, database: env.DB, env }); case 'POST /api/waitlist': return routes.waitlist({ request, cors, database: env.DB, ip }); default: diff --git a/src/server/repositories/vote.ts b/src/server/repositories/vote.ts new file mode 100644 index 0000000..a509d0b --- /dev/null +++ b/src/server/repositories/vote.ts @@ -0,0 +1,84 @@ +import type { Database } from '../types.js'; +import { VOTABLE_TALK_STATUS } from '../configs/vote.js'; + +export type TalkRow = { + id: number; + title: string; + description: string; + duration: number; + audience_level: number; +}; + +type CastResult = { success: true } | { success: false; reason: 'budget' }; + +export const vote = (database: Database) => { + const listTalks = async (): Promise<TalkRow[]> => { + // No speaker join: the API must never expose who proposed a talk, to keep voting unbiased. + // The frontend shows a fake blurred placeholder name instead, generated client-side. + const { results } = await database + .prepare( + `SELECT id, title, description, duration, audience_level + FROM talks + WHERE status = ? + ORDER BY id` + ) + .bind(VOTABLE_TALK_STATUS) + .all<TalkRow>(); + return results; + }; + + const listUserVotes = async (userId: string): Promise<number[]> => { + const { results } = await database + .prepare('SELECT talk_id FROM c4p_votes WHERE user_id = ?') + .bind(userId) + .all<{ talk_id: number }>(); + return results.map((row) => row.talk_id); + }; + + const castVote = async ( + userId: string, + talkId: number, + budget: number + ): Promise<CastResult> => { + const current = await listUserVotes(userId); + if (current.includes(talkId)) return { success: true }; + if (current.length >= budget) return { success: false, reason: 'budget' }; + + // Budget re-check lives inside the INSERT so two concurrent votes can't both pass the read + // above and overspend: the row is only written while the user is still under budget. + await database + .prepare( + `INSERT OR IGNORE INTO c4p_votes (user_id, talk_id) + SELECT ?1, ?2 + WHERE (SELECT COUNT(*) FROM c4p_votes WHERE user_id = ?1) < ?3` + ) + .bind(userId, talkId, budget) + .run(); + + // A lost budget race leaves the guarded INSERT a no-op; confirm the vote actually landed. + const after = await listUserVotes(userId); + if (!after.includes(talkId)) return { success: false, reason: 'budget' }; + return { success: true }; + }; + + const removeVote = async (userId: string, talkId: number): Promise<void> => { + await database + .prepare('DELETE FROM c4p_votes WHERE user_id = ? AND talk_id = ?') + .bind(userId, talkId) + .run(); + }; + + // Vote budget for a guild.host tier name. Unlisted tier → 1 (every ticket holder gets a vote by + // default; ticket_tiers only stores overrides). + const budgetForTier = async (tier: string): Promise<number> => { + const { results } = await database + .prepare('SELECT budget FROM ticket_tiers WHERE name = ?') + .bind(tier) + .all<{ budget: number }>(); + // Any ticket holder gets 1 vote by default; ticket_tiers only stores the overrides (e.g. a + // higher budget for earlier tiers). The caller already gated non-attendees out. + return results[0]?.budget ?? 1; + }; + + return { listTalks, listUserVotes, castVote, removeVote, budgetForTier }; +}; diff --git a/src/server/routes.ts b/src/server/routes.ts index e17ec77..fa10d43 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -1,9 +1,15 @@ +import { authCallback, authLogin, authLogout, authMe } from './routes/auth.js'; import { c4p } from './routes/c4p.js'; -import { stripeWebhook } from './routes/stripe-webhook.js'; +import { voteGet, voteSubmit } from './routes/vote.js'; import { waitlist } from './routes/waitlist.js'; export const routes = { c4p, waitlist, - stripeWebhook, + voteGet, + voteSubmit, + authLogin, + authLogout, + authMe, + authCallback, }; diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts new file mode 100644 index 0000000..c12d423 --- /dev/null +++ b/src/server/routes/auth.ts @@ -0,0 +1,212 @@ +import type { Database, Env } from '../types.js'; +import { + EVENT_SLUG, + MANAGER_SCOPE, + SESSION_COOKIE, + SESSION_TTL_SECONDS, + STATE_COOKIE, +} from '../configs/oauth.js'; +import { readCookie } from '../helpers/cookies.js'; +import { + buildAuthorizeUrl, + exchangeCode, + fetchTicketTier, + fetchUserInfo, + managerAccessToken, +} from '../helpers/oauth.js'; +import { response } from '../helpers/response.js'; +import { getSession, signSession } from '../helpers/session.js'; +import { vote } from '../repositories/vote.js'; + +type Options = { request: Request; env: Env }; +type CallbackOptions = Options & { database: Database }; +type MeOptions = Options & { cors: Record<string, string> }; + +const randomState = (): string => { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +}; + +const redirectUri = (request: Request, env: Env): string => + env.GUILD_OAUTH_REDIRECT_URI ?? + `${new URL(request.url).origin}/api/vote/callback`; + +const websiteOrigin = (request: Request, env: Env): string => + env.ALLOWED_ORIGIN ?? new URL(request.url).origin; + +const redirect = (location: string, cookies: string[] = []): Response => { + const headers = new Headers({ Location: location }); + for (const cookie of cookies) headers.append('Set-Cookie', cookie); + return new Response(null, { status: 302, headers }); +}; + +// Marks a dev-only manager-capture login so the callback shows the refresh token once instead of +// signing a voting session. +const MANAGER_CAPTURE_COOKIE = 'vote_manager_capture'; + +export const authLogin = async ({ + request, + env, +}: Options): Promise<Response> => { + const url = new URL(request.url); + // Fail closed: dev-only login paths are honored only when ENVIRONMENT is explicitly 'development'. + const dev = env.ENVIRONMENT === 'development'; + + // Dev-only UI login (opt-in via ?dev=1) — signs a session for a fixed user so the + // /vote UI is testable without a live guild.host token exchange. Never honored in production. + if (dev && url.searchParams.has('dev') && env.SESSION_SECRET) { + const session = await signSession( + 'dev-user', + 3, + env.SESSION_SECRET, + SESSION_TTL_SECONDS, + { name: 'Dev User' } + ); + return redirect(`${websiteOrigin(request, env)}/vote`, [ + `${SESSION_COOKIE}=${session}; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=${SESSION_TTL_SECONDS}`, + ]); + } + + if (!env.GUILD_OAUTH_CLIENT_ID) + return new Response('OAuth not configured.', { status: 500 }); + + const state = randomState(); + + // Dev-only one-time capture (opt-in via ?manager=1) — requests the attendee-read scope + // and flags the callback to display the refresh token instead of signing a session. + const manager = dev && url.searchParams.has('manager'); + const cookies = [ + `${STATE_COOKIE}=${state}; HttpOnly; Secure; SameSite=Lax; Path=/api/vote; Max-Age=600`, + ]; + if (manager) + cookies.push( + `${MANAGER_CAPTURE_COOKIE}=1; HttpOnly; Secure; SameSite=Lax; Path=/api/vote; Max-Age=600` + ); + + const authorizeUrl = buildAuthorizeUrl( + env.GUILD_OAUTH_CLIENT_ID, + redirectUri(request, env), + state, + manager ? MANAGER_SCOPE : undefined + ); + return redirect(authorizeUrl, cookies); +}; + +// Returns the logged-in voter's guild.host user id, for the account page. 401 when unauthenticated. +export const authMe = async ({ + request, + env, + cors, +}: MeOptions): Promise<Response> => { + const session = await getSession(request, env); + if (!session) return response({ error: 'Unauthorized.' }, 401, cors); + return response( + { userId: session.userId, name: session.name, photo: session.photo }, + 200, + cors + ); +}; + +export const authLogout = async ({ + request, + env, +}: Options): Promise<Response> => { + // Clear the session cookie. Attributes must match the ones it was set with (Path=/, + // SameSite=None; Secure) so the browser actually drops it. + return redirect(`${websiteOrigin(request, env)}/vote`, [ + `${SESSION_COOKIE}=; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=0`, + ]); +}; + +export const authCallback = async ({ + request, + env, + database, +}: CallbackOptions): Promise<Response> => { + const params = new URL(request.url).searchParams; + const site = websiteOrigin(request, env); + + // On any failure the user is bounced to the site home with an ?error= code; a global handler + // there surfaces the message. Only a clean login lands them on /vote. + if (params.get('error')) return redirect(`${site}/?error=denied`); + + const code = params.get('code'); + const state = params.get('state'); + if (!code || !state || state !== readCookie(request, STATE_COOKIE)) + return redirect(`${site}/?error=state`); + + if ( + !env.GUILD_OAUTH_CLIENT_ID || + !env.GUILD_OAUTH_CLIENT_SECRET || + !env.SESSION_SECRET + ) + return new Response('OAuth not configured.', { status: 500 }); + + const tokens = await exchangeCode( + code, + env.GUILD_OAUTH_CLIENT_ID, + env.GUILD_OAUTH_CLIENT_SECRET, + redirectUri(request, env) + ); + if (!tokens) return redirect(`${site}/?error=token`); + + // Dev-only manager capture — show the refresh token once so it can be copied into + // GUILD_ORG_REFRESH_TOKEN, then remove the ?manager=1 path. Never signs a session. + if ( + env.ENVIRONMENT === 'development' && + readCookie(request, MANAGER_CAPTURE_COOKIE) + ) { + const body = tokens.refresh_token + ? `GUILD_ORG_REFRESH_TOKEN=${tokens.refresh_token}\n\nCopy the value into .dev.vars, tell me, then I remove this capture path.` + : 'guild returned no refresh_token.'; + // no-store: the body carries a refresh token, so it must never be cached. Clear both the capture + // and state cookies with the same attributes they were set with so browsers actually drop them. + const headers = new Headers({ + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + }); + headers.append( + 'Set-Cookie', + `${MANAGER_CAPTURE_COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/api/vote; Max-Age=0` + ); + headers.append( + 'Set-Cookie', + `${STATE_COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/api/vote; Max-Age=0` + ); + return new Response(body, { status: 200, headers }); + } + + const identity = await fetchUserInfo(tokens.access_token); + if (!identity) return redirect(`${site}/?error=identity`); + + // The tier lives on the event attendees list, which only a manager token can read. Mint one from + // the org refresh token, then join the attendee to this voter by id. No tier → not an attendee. + if (!env.GUILD_ORG_REFRESH_TOKEN) + return new Response('Manager token not configured.', { status: 500 }); + const managerToken = await managerAccessToken( + env.GUILD_OAUTH_CLIENT_ID, + env.GUILD_OAUTH_CLIENT_SECRET, + env.GUILD_ORG_REFRESH_TOKEN, + database + ); + // A manager-token failure is an org-auth/config problem, not the voter's identity — use ?error=token. + if (!managerToken) return redirect(`${site}/?error=token`); + + const tier = await fetchTicketTier(managerToken, EVENT_SLUG, identity.id); + if (!tier) return redirect(`${site}/?error=notattendee`); + const budget = await vote(database).budgetForTier(tier); + + const session = await signSession( + identity.id, + budget, + env.SESSION_SECRET, + SESSION_TTL_SECONDS, + { name: identity.name, photo: identity.photo } + ); + // Session cookie is SameSite=None;Secure so it's sent on the website's + // cross-origin fetch to the API subdomain. Both must be HTTPS. + return redirect(`${site}/vote`, [ + `${SESSION_COOKIE}=${session}; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=${SESSION_TTL_SECONDS}`, + `${STATE_COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/api/vote; Max-Age=0`, + ]); +}; diff --git a/src/server/routes/c4p.ts b/src/server/routes/c4p.ts index a24bb3a..648518c 100644 --- a/src/server/routes/c4p.ts +++ b/src/server/routes/c4p.ts @@ -1,9 +1,5 @@ import type { Database } from '../types.js'; -import { - isJsonContentType, - isWithinSize, - parseBody, -} from '../helpers/request.js'; +import { parseRequest } from '../helpers/request.js'; import { response } from '../helpers/response.js'; import { c4p as repository } from '../repositories/c4p.js'; @@ -20,20 +16,11 @@ export const c4p = async ({ database, ip, }: Options): Promise<Response> => { - if (!isJsonContentType(request)) - return response({ error: 'Unsupported content type.' }, 415, cors); - - const text = await request.text(); - if (!isWithinSize(text, 16384)) - return response({ error: 'Payload too large.' }, 413, cors); - - const body = parseBody(text); - if (!body) return response({ error: 'Invalid JSON.' }, 400, cors); - const { schema, submit } = repository(database); - const parsed = schema.safeParse(body); - if (!parsed.success) return response({ error: 'Invalid input.' }, 422, cors); + const parsed = await parseRequest(request, schema, 16384); + if ('error' in parsed) + return response({ error: parsed.error }, parsed.status, cors); // Bot Honeypot if (parsed.data.confirm_email.length > 0) diff --git a/src/server/routes/stripe-webhook.ts b/src/server/routes/stripe-webhook.ts deleted file mode 100644 index 9f761a2..0000000 --- a/src/server/routes/stripe-webhook.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type Stripe from 'stripe'; -import type { Env } from '../types.js'; -import { createStripeClient, cryptoProvider } from '../configs/stripe.js'; -import { response } from '../helpers/response.js'; - -type Options = { - request: Request; - cors: Record<string, string>; - env: Env; -}; - -export const handleEvent = (event: Stripe.Event): boolean => { - switch (event.type) { - case 'checkout.session.completed': - case 'payment_intent.succeeded': - case 'charge.succeeded': - case 'charge.refunded': - return true; - default: - return false; - } -}; - -export const stripeWebhook = async ({ - request, - cors, - env, -}: Options): Promise<Response> => { - const signature = request.headers.get('stripe-signature'); - if (!signature) return response({ error: 'Missing signature.' }, 400, cors); - - const body = await request.text(); - const stripe = createStripeClient(env.STRIPE_SECRET_KEY); - - try { - const event = await stripe.webhooks.constructEventAsync( - body, - signature, - env.STRIPE_WEBHOOK_SECRET, - undefined, - cryptoProvider - ); - - handleEvent(event); - - return response({ received: true }, 200, cors); - } catch { - return response( - { error: 'Webhook signature verification failed.' }, - 400, - cors - ); - } -}; diff --git a/src/server/routes/vote.ts b/src/server/routes/vote.ts new file mode 100644 index 0000000..4883509 --- /dev/null +++ b/src/server/routes/vote.ts @@ -0,0 +1,72 @@ +import type { Database, Env } from '../types.js'; +import { z } from 'zod'; +import { isVotingOpen, VOTE_CLOSES_AT } from '../configs/vote.js'; +import { parseRequest } from '../helpers/request.js'; +import { response } from '../helpers/response.js'; +import { getSession } from '../helpers/session.js'; +import { vote as repository } from '../repositories/vote.js'; + +type Options = { + request: Request; + cors: Record<string, string>; + database: Database; + env: Env; +}; + +const submitSchema = z.object({ + talkId: z.coerce.number().int().positive(), + action: z.enum(['add', 'remove']).default('add'), +}); + +export const voteGet = async ({ + request, + cors, + database, + env, +}: Options): Promise<Response> => { + const session = await getSession(request, env); + if (!session) return response({ error: 'Unauthorized.' }, 401, cors); + const { userId, budget } = session; + + const repo = repository(database); + const [talks, myVotes] = await Promise.all([ + repo.listTalks(), + repo.listUserVotes(userId), + ]); + + return response( + { budget, used: myVotes.length, talks, myVotes, closesAt: VOTE_CLOSES_AT }, + 200, + cors + ); +}; + +export const voteSubmit = async ({ + request, + cors, + database, + env, +}: Options): Promise<Response> => { + const session = await getSession(request, env); + if (!session) return response({ error: 'Unauthorized.' }, 401, cors); + const { userId, budget } = session; + + if (!isVotingOpen()) return response({ error: 'Voting closed.' }, 403, cors); + + const parsed = await parseRequest(request, submitSchema, 1024); + if ('error' in parsed) + return response({ error: parsed.error }, parsed.status, cors); + + const repo = repository(database); + + if (parsed.data.action === 'remove') { + await repo.removeVote(userId, parsed.data.talkId); + return response({ success: true }, 200, cors); + } + + const result = await repo.castVote(userId, parsed.data.talkId, budget); + if (!result.success) + return response({ error: 'Vote limit reached.' }, 422, cors); + + return response({ success: true }, 200, cors); +}; diff --git a/src/server/types.ts b/src/server/types.ts index 164824e..d9977e5 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -10,6 +10,13 @@ export type Database = { export type Env = { DB: Database; ALLOWED_ORIGIN?: string; - STRIPE_SECRET_KEY: string; - STRIPE_WEBHOOK_SECRET: string; + ENVIRONMENT?: string; + // Voting / guild.host OAuth + GUILD_OAUTH_CLIENT_ID?: string; + GUILD_OAUTH_CLIENT_SECRET?: string; + GUILD_OAUTH_REDIRECT_URI?: string; + SESSION_SECRET?: string; + // Manager refresh token (from a one-time manager login); the server exchanges it for an access + // token to read the event attendees list — the only place the ticketing tier is exposed. + GUILD_ORG_REFRESH_TOKEN?: string; }; diff --git a/src/website/components/_partial/AuthButton.tsx b/src/website/components/_partial/AuthButton.tsx new file mode 100644 index 0000000..6de827e --- /dev/null +++ b/src/website/components/_partial/AuthButton.tsx @@ -0,0 +1,76 @@ +import { useEffect, useRef, useState } from 'react'; +import Link from '@docusaurus/Link'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { ChevronDown, User } from 'lucide-react'; +import { Text } from '@site/src/website/components/shared/i18n'; +import { useLocalePath } from '@site/src/website/hooks/useLocalePath'; + +// /api/vote/me returns the session identity. name/photo come from the guild userinfo; when absent +// we fall back to the userId + a generic icon. +type Me = { userId: string; name?: string; photo?: string }; + +export const AuthButton = () => { + const { siteConfig } = useDocusaurusContext(); + const workerDomain = siteConfig.customFields?.['workerDomain'] as + | string + | undefined; + const { localePath } = useLocalePath(); + const [me, setMe] = useState<Me | null>(null); + const [open, setOpen] = useState(false); + const ref = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (!workerDomain) return; + fetch(`${workerDomain}/api/vote/me`, { credentials: 'include' }) + .then((res) => (res.ok ? (res.json() as Promise<Me>) : null)) + .then(setMe) + .catch(() => setMe(null)); + }, [workerDomain]); + + useEffect(() => { + if (!open) return; + const onClick = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) + setOpen(false); + }; + document.addEventListener('click', onClick); + return () => document.removeEventListener('click', onClick); + }, [open]); + + // Misconfigured worker domain: no functional auth endpoints, so render nothing. + if (!workerDomain) return null; + + // Logged out (and during load): a plain "log in" button that starts the OAuth flow. + if (!me) + return ( + <a className='account' href={`${workerDomain}/api/vote/login`}> + <User /> <Text id='navbar.login' /> + </a> + ); + + // Logged in: name + photo toggles a dropdown with Votar / Sair. + return ( + <div className='account-menu' ref={ref}> + <button + type='button' + className='account' + onClick={() => setOpen((value) => !value)} + aria-expanded={open} + > + {me.photo ? <img className='avatar' src={me.photo} alt='' /> : <User />} + <span>{me.name ?? me.userId}</span> + <ChevronDown className='chevron' /> + </button> + {open && ( + <div className='account-dropdown'> + <Link to={localePath('/vote')} onClick={() => setOpen(false)}> + <Text id='navbar.vote' /> + </Link> + <a href={`${workerDomain}/api/vote/logout`}> + <Text id='auth.logout' /> + </a> + </div> + )} + </div> + ); +}; diff --git a/src/website/components/_partial/Navbar.tsx b/src/website/components/_partial/Navbar.tsx index ad696e3..0deb3a0 100644 --- a/src/website/components/_partial/Navbar.tsx +++ b/src/website/components/_partial/Navbar.tsx @@ -5,13 +5,14 @@ import { useLocation } from '@docusaurus/router'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import { ChevronDown, Menu, Ticket, X } from 'lucide-react'; import { IoLanguage } from 'react-icons/io5'; -import { Toaster } from 'sonner'; +import { toast, Toaster } from 'sonner'; import { Text, text } from '@site/src/website/components/shared/i18n'; import { useLocalePath } from '@site/src/website/hooks/useLocalePath'; import { useScrollSpy } from '@site/src/website/hooks/useScrollSpy'; import Logo from '../../assets/img/logo.svg'; import { link } from '../../configs/definitions'; import { SafeLink } from '../shared/SafeLink'; +import { AuthButton } from './AuthButton'; type Section = { id: string; @@ -66,6 +67,16 @@ function setLocaleMenuOpen( } } +// Login callback ?error= code -> translation id. A failed login bounces to the site home with +// one of these codes; the effect below surfaces it as a toast and strips it from the URL. +const LOGIN_ERROR_IDS = { + denied: 'login.error.denied', + state: 'login.error.state', + token: 'login.error.token', + identity: 'login.error.identity', + notattendee: 'login.error.notattendee', +} as const; + export const Navbar = () => { const location = useLocation(); const { i18n } = useDocusaurusContext(); @@ -81,6 +92,24 @@ export const Navbar = () => { const otherLocales = locales.filter((l) => l !== currentLocale); + // Surface a login failure bounced back from the OAuth callback (on any page), then strip it. + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const error = params.get('error'); + if (!error) return; + const id = + LOGIN_ERROR_IDS[error as keyof typeof LOGIN_ERROR_IDS] ?? + 'login.error.generic'; + toast.error(text({ id })); + params.delete('error'); + const query = params.toString(); + window.history.replaceState( + {}, + '', + window.location.pathname + (query ? `?${query}` : '') + ); + }, []); + const handleClickOutside = useCallback((e: MouseEvent) => { if (!localeDropdownRef.current) return; if (!(e.target instanceof Node)) return; @@ -236,6 +265,7 @@ export const Navbar = () => { <SafeLink className='tickets' to={link.tickets}> <Ticket /> <Text id='navbar.tickets' /> </SafeLink> + <AuthButton /> </div> </div> <div ref={menuNode} className='mobile-menu'> diff --git a/src/website/components/shared/i18n.tsx b/src/website/components/shared/i18n.tsx index b380a76..bcb02f2 100644 --- a/src/website/components/shared/i18n.tsx +++ b/src/website/components/shared/i18n.tsx @@ -5,7 +5,7 @@ import OriginalTranslate, { translate as originalTranslate, } from '@docusaurus/Translate'; -type TranslationId = keyof typeof Translation; +export type TranslationId = keyof typeof Translation; type TextProps<Message extends string> = { id: TranslationId; values?: InterpolateValues<Message, ReactNode>; diff --git a/src/website/contexts/c4p/definitions.ts b/src/website/contexts/c4p/definitions.ts index 1767052..0141846 100644 --- a/src/website/contexts/c4p/definitions.ts +++ b/src/website/contexts/c4p/definitions.ts @@ -2,82 +2,71 @@ import type { FormData } from './types'; export const STORAGE_KEY = 'c4p-form-v2'; +// labelId is a translation key resolved via <Text id> / text() at render time (see i18n.tsx). +// value is the stored/submitted value and must never change. export const topics = [ - { name: 'Dev Tooling', preferred: true }, - { name: 'Developer Experience', preferred: true }, - { name: 'IA no Desenvolvimento', preferred: true }, - { name: 'Arquitetura', preferred: true }, - { name: 'DevOps', preferred: true }, - { name: 'Observabilidade', preferred: true }, - { name: 'Performance', preferred: true }, - { - name: 'Cases Reais de Resolução de Problemas usando código', - preferred: true, - }, - { name: 'Código para Acessibilidade', preferred: false }, - { name: 'Open Source', preferred: false }, - { name: 'Design System', preferred: false }, - { name: 'Segurança no Desenvolvimento', preferred: false }, -]; + { labelId: 'c4p.topic.devTooling', preferred: true }, + { labelId: 'c4p.topic.devEx', preferred: true }, + { labelId: 'c4p.topic.aiDev', preferred: true }, + { labelId: 'c4p.topic.architecture', preferred: true }, + { labelId: 'c4p.topic.devops', preferred: true }, + { labelId: 'c4p.topic.observability', preferred: true }, + { labelId: 'c4p.topic.performance', preferred: true }, + { labelId: 'c4p.topic.realCases', preferred: true }, + { labelId: 'c4p.topic.a11yCode', preferred: false }, + { labelId: 'c4p.topic.openSource', preferred: false }, + { labelId: 'c4p.topic.designSystem', preferred: false }, + { labelId: 'c4p.topic.security', preferred: false }, +] as const; export const experienceOptions = [ - { value: '0', label: '0 - 1 ano' }, - { value: '1', label: '2 - 4 anos' }, - { value: '2', label: '5 - 9 anos' }, - { value: '3', label: 'Acima de 10 anos' }, + { value: '0', labelId: 'c4p.exp.0' }, + { value: '1', labelId: 'c4p.exp.1' }, + { value: '2', labelId: 'c4p.exp.2' }, + { value: '3', labelId: 'c4p.exp.3' }, ] as const; export const durationOptions = [ - { value: '0', label: '15 minutos' }, - { value: '1', label: '25 minutos' }, + { value: '0', labelId: 'c4p.duration.0' }, + { value: '1', labelId: 'c4p.duration.1' }, ] as const; export const travelOptions = [ - { - value: '0', - label: 'Gostaria que a organização pagasse minha viagem e hospedagem', - }, - { value: '1', label: 'Posso arcar com os custos' }, + { value: '0', labelId: 'c4p.travel.0' }, + { value: '1', labelId: 'c4p.travel.1' }, ] as const; export const genderOptions = [ - { value: '0', label: 'Homem' }, - { value: '1', label: 'Mulher' }, - { value: '2', label: 'Não-binário' }, - { value: '3', label: 'Prefiro não dizer' }, + { value: '0', labelId: 'c4p.gender.0' }, + { value: '1', labelId: 'c4p.gender.1' }, + { value: '2', labelId: 'c4p.gender.2' }, + { value: '3', labelId: 'c4p.gender.3' }, ] as const; export const raceOptions = [ - { value: '0', label: 'Branca' }, - { value: '1', label: 'Parda' }, - { value: '2', label: 'Preta' }, - { value: '3', label: 'Indígena' }, - { value: '4', label: 'Não sei' }, - { value: '5', label: 'Outro' }, - { value: '6', label: 'Prefiro não dizer' }, + { value: '0', labelId: 'c4p.race.0' }, + { value: '1', labelId: 'c4p.race.1' }, + { value: '2', labelId: 'c4p.race.2' }, + { value: '3', labelId: 'c4p.race.3' }, + { value: '4', labelId: 'c4p.race.4' }, + { value: '5', labelId: 'c4p.race.5' }, + { value: '6', labelId: 'c4p.race.6' }, ] as const; export const disabilityOptions = [ - { value: '0', label: 'Sou cego(a) / tenho baixa visão' }, - { value: '1', label: 'Sou surdo(a) / tenho deficiência auditiva' }, - { - value: '2', - label: - 'Eu não consigo / tenho dificuldade de andar ou ficar em pé sem assistência', - }, - { - value: '3', - label: 'Eu não consigo / tenho dificuldade de digitar', - }, - { value: '4', label: 'Não se aplica' }, - { value: '5', label: 'Prefiro não dizer' }, + { value: '0', labelId: 'c4p.disability.0' }, + { value: '1', labelId: 'c4p.disability.1' }, + { value: '2', labelId: 'c4p.disability.2' }, + { value: '3', labelId: 'c4p.disability.3' }, + { value: '4', labelId: 'c4p.disability.4' }, + { value: '5', labelId: 'c4p.disability.5' }, ] as const; export const audienceLevels = [ - { value: '0', label: 'Todos os níveis' }, - { value: '1', label: 'Júnior' }, - { value: '2', label: 'Pleno' }, - { value: '3', label: 'Sênior' }, + { value: '0', labelId: 'c4p.audience.0' }, + { value: '1', labelId: 'c4p.audience.1' }, + { value: '2', labelId: 'c4p.audience.2' }, + { value: '3', labelId: 'c4p.audience.3' }, ] as const; export const brazilianStates = [ diff --git a/src/website/contexts/c4p/index.tsx b/src/website/contexts/c4p/index.tsx index e68b010..1240175 100644 --- a/src/website/contexts/c4p/index.tsx +++ b/src/website/contexts/c4p/index.tsx @@ -10,6 +10,7 @@ import { useState, } from 'react'; import { toast } from 'sonner'; +import { text } from '@site/src/website/components/shared/i18n'; import { loadFromStorage, saveToStorage } from './helpers'; import { validateStep } from './schema'; @@ -68,7 +69,7 @@ export const C4PProvider: FC<{ children: ReactNode }> = ({ children }) => { setErrors(result); const keys = Object.keys(result); if (keys.length > 0) { - toast.error('Preencha os campos obrigatórios'); + toast.error(text({ id: 'c4p.error.requiredFields' })); return false; } return true; diff --git a/src/website/contexts/c4p/schema.ts b/src/website/contexts/c4p/schema.ts index c04dbd7..e289bf2 100644 --- a/src/website/contexts/c4p/schema.ts +++ b/src/website/contexts/c4p/schema.ts @@ -1,14 +1,19 @@ +import type { TranslationId } from '@site/src/website/components/shared/i18n'; import { z } from 'zod'; +import { text } from '@site/src/website/components/shared/i18n'; +// zod messages are translation KEYS, not final strings — validateStep resolves them with text() +// at validation time (client-side, current locale). Defining them as literal translations here +// would lock the message to the module-load locale. export const aboutSchema = z.object({ - name: z.string().min(1, 'Informe seu nome'), - email: z.email('Informe um e-mail válido').max(254), - phone: z.string().min(8, 'Informe um número válido'), - city: z.string().min(1, 'Informe sua cidade'), - state: z.string().min(2, 'Selecione um estado'), - travelPreference: z.string().min(1, 'Selecione uma opção'), - experienceLevel: z.string().min(1, 'Selecione uma opção'), - bio: z.string().min(1, 'Informe sua biografia').max(280), + name: z.string().min(1, 'c4p.error.name'), + email: z.email('c4p.error.email').max(254, 'c4p.error.emailTooLong'), + phone: z.string().min(8, 'c4p.error.phone'), + city: z.string().min(1, 'c4p.error.city'), + state: z.string().min(2, 'c4p.error.state'), + travelPreference: z.string().min(1, 'c4p.error.selectOption'), + experienceLevel: z.string().min(1, 'c4p.error.selectOption'), + bio: z.string().min(1, 'c4p.error.bio').max(280, 'c4p.error.bioTooLong'), }); export const diversitySchema = z.object({ @@ -18,11 +23,11 @@ export const diversitySchema = z.object({ }); export const talkSchema = z.object({ - duration: z.string().min(1, 'Selecione a duração'), - talkTitle: z.string().min(1, 'Informe o título'), - talkDescription: z.string().min(1, 'Informe a descrição'), - audienceLevel: z.string().min(1, 'Selecione o nível'), - talkReason: z.string().min(1, 'Informe o motivo'), + duration: z.string().min(1, 'c4p.error.duration'), + talkTitle: z.string().min(1, 'c4p.error.talkTitle'), + talkDescription: z.string().min(1, 'c4p.error.talkDescription'), + audienceLevel: z.string().min(1, 'c4p.error.audienceLevel'), + talkReason: z.string().min(1, 'c4p.error.talkReason'), }); const stepSchemas = { @@ -47,7 +52,14 @@ export const validateStep = ( for (const issue of result.error.issues) { const field = issue.path[0]; - if (field && !errors[String(field)]) errors[String(field)] = issue.message; + if (!field || errors[String(field)]) continue; + // Our custom messages are i18n keys (c4p.error.*). Zod's built-in messages — which can appear + // if loadFromStorage() feeds a malformed/legacy shape — are not, so fall back to a generic key + // instead of rendering a raw, untranslated string (and tripping Docusaurus translate warnings). + const id = issue.message.startsWith('c4p.error.') + ? (issue.message as TranslationId) + : ('c4p.error.invalid' as TranslationId); + errors[String(field)] = text({ id }); } return errors; diff --git a/src/website/hooks/c4p/useSubmit.tsx b/src/website/hooks/c4p/useSubmit.tsx index 3fd720d..b5f3de9 100644 --- a/src/website/hooks/c4p/useSubmit.tsx +++ b/src/website/hooks/c4p/useSubmit.tsx @@ -2,6 +2,7 @@ import type { SubmitEvent } from 'react'; import { useRef, useState } from 'react'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import { toast } from 'sonner'; +import { text } from '@site/src/website/components/shared/i18n'; import { useC4P } from '../../contexts/c4p'; export const useSubmit = () => { @@ -16,7 +17,7 @@ export const useSubmit = () => { if (!validate() || submitting.current) return; if (typeof workerDomain !== 'string') { - toast.error('Configuração indisponível. Tente novamente mais tarde.'); + toast.error(text({ id: 'c4p.submit.configError' })); return; } @@ -35,15 +36,15 @@ export const useSubmit = () => { } | null; const message = data?.error === 'Talk limit exceeded.' - ? 'Você já atingiu o limite de 3 palestras.' - : 'Erro ao enviar proposta. Tente novamente.'; + ? text({ id: 'c4p.submit.limit' }) + : text({ id: 'c4p.submit.error' }); toast.error(message); return; } goToStep(5); } catch { - toast.error('Erro ao enviar proposta. Tente novamente.'); + toast.error(text({ id: 'c4p.submit.error' })); } finally { submitting.current = false; } diff --git a/src/website/pages/account/index.tsx b/src/website/pages/account/index.tsx new file mode 100644 index 0000000..ada09b2 --- /dev/null +++ b/src/website/pages/account/index.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState } from 'react'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { LogOut, User } from 'lucide-react'; +import { Text, text } from '@site/src/website/components/shared/i18n'; +import { Page } from '@site/src/website/components/shared/Page'; +import '@site/src/website/scss/pages/voting.scss'; + +const Account = () => { + const { siteConfig } = useDocusaurusContext(); + const workerDomain = siteConfig.customFields?.['workerDomain'] as + | string + | undefined; + + const [userId, setUserId] = useState<string | null>(null); + const [status, setStatus] = useState<'loading' | 'in' | 'out' | 'error'>( + 'loading' + ); + + useEffect(() => { + if (!workerDomain) return setStatus('error'); + fetch(`${workerDomain}/api/vote/me`, { credentials: 'include' }) + .then(async (res) => { + if (res.status === 401) return setStatus('out'); + if (!res.ok) return setStatus('error'); + const data = (await res.json()) as { userId: string }; + setUserId(data.userId); + setStatus('in'); + }) + .catch(() => setStatus('error')); + }, [workerDomain]); + + return ( + <Page title={text({ id: 'account.title' })}> + <div className='account-page page-content'> + <header className='page-hero'> + <h1 className='title'> + <User className='icon' aria-hidden /> + <Text id='account.heading' /> + </h1> + </header> + + {status === 'loading' && ( + <p className='status'> + <Text id='common.loading' /> + </p> + )} + {status === 'error' && ( + <p className='status error'> + <Text id='account.loadError' /> + </p> + )} + {status === 'out' && ( + <div className='login-hero'> + <h2 className='login-title'> + <Text id='account.loginHeading' /> + </h2> + <p className='login-text'> + <Text id='account.loginPrompt' /> + </p> + <a className='login-cta' href={`${workerDomain}/api/vote/login`}> + <Text id='auth.login' /> + </a> + </div> + )} + {status === 'in' && userId && ( + <div className='account-card'> + <div className='account-identity'> + <div className='avatar'> + <User className='icon' aria-hidden /> + </div> + <div> + <p className='account-id-label'> + <Text id='account.idLabel' /> + </p> + <p className='account-id-value'>{userId}</p> + </div> + </div> + <a + className='button button--secondary button--sm logout-link' + href={`${workerDomain}/api/vote/logout`} + > + <LogOut className='icon' aria-hidden /> + <Text id='auth.logout' /> + </a> + </div> + )} + </div> + </Page> + ); +}; + +export default Account; diff --git a/src/website/pages/c4p/_components/about.tsx b/src/website/pages/c4p/_components/about.tsx index 9a5e083..fc77208 100644 --- a/src/website/pages/c4p/_components/about.tsx +++ b/src/website/pages/c4p/_components/about.tsx @@ -12,6 +12,7 @@ import { FaUser, FaYoutube, } from 'react-icons/fa6'; +import { Text, text } from '@site/src/website/components/shared/i18n'; import * as styles from '../_styles'; import { brazilianStates, @@ -55,7 +56,7 @@ export const About = () => { <section className={styles.section}> <div className={styles.field}> <h3 className={styles.fieldLabel}> - Seu nome <FieldStatus field='name' /> + <Text id='c4p.about.name' /> <FieldStatus field='name' /> </h3> <div className={`${styles.inputWithIcon} group`}> <FaUser @@ -64,7 +65,7 @@ export const About = () => { /> <input type='text' - aria-label='Seu nome' + aria-label={text({ id: 'c4p.about.name' })} aria-required='true' className={`${styles.inputWithIconInput(!!formData.name)} ${errors['name'] ? styles.inputError : ''}`} value={formData.name} @@ -76,7 +77,7 @@ export const About = () => { <div className={styles.field}> <h3 className={styles.fieldLabel}> - E-mail <FieldStatus field='email' /> + <Text id='c4p.about.email' /> <FieldStatus field='email' /> </h3> <div className={`${styles.inputWithIcon} group`}> <FaEnvelope @@ -85,7 +86,7 @@ export const About = () => { /> <input type='text' - aria-label='E-mail' + aria-label={text({ id: 'c4p.about.email' })} aria-required='true' className={`${styles.inputWithIconInput(!!formData.email)} ${errors['email'] ? styles.inputError : ''}`} value={formData.email} @@ -97,7 +98,7 @@ export const About = () => { <div className={styles.field}> <h3 className={styles.fieldLabel}> - Celular <FieldStatus field='phone' /> + <Text id='c4p.about.phone' /> <FieldStatus field='phone' /> </h3> <div className={`${styles.inputWithIcon} group`}> <FaPhone @@ -106,7 +107,7 @@ export const About = () => { /> <input type='text' - aria-label='Celular' + aria-label={text({ id: 'c4p.about.phone' })} aria-required='true' className={`${styles.inputWithIconInput(!!formData.phone)} ${errors['phone'] ? styles.inputError : ''}`} value={formData.phone} @@ -118,7 +119,7 @@ export const About = () => { <div className={styles.field}> <h3 className={styles.fieldLabel}> - Cidade <FieldStatus field='city' /> + <Text id='c4p.about.city' /> <FieldStatus field='city' /> </h3> <div className={`${styles.inputWithIcon} group`}> <FaLocationDot @@ -127,7 +128,7 @@ export const About = () => { /> <input type='text' - aria-label='Cidade' + aria-label={text({ id: 'c4p.about.city' })} aria-required='true' className={`${styles.inputWithIconInput(!!formData.city)} ${errors['city'] ? styles.inputError : ''}`} value={formData.city} @@ -139,10 +140,10 @@ export const About = () => { <div className={styles.field}> <h3 className={styles.fieldLabel}> - UF <FieldStatus field='state' /> + <Text id='c4p.about.uf' /> <FieldStatus field='state' /> </h3> <select - aria-label='UF' + aria-label={text({ id: 'c4p.about.uf' })} aria-required='true' className={`${styles.selectInput(!!formData.state)} w-[10rem] ${errors['state'] ? styles.inputError : ''}`} value={formData.state} @@ -162,13 +163,11 @@ export const About = () => { <section className={styles.section}> <div className={styles.field}> <h3 className={styles.fieldLabel}> - Sobre a viagem e hospedagem <FieldStatus field='travelPreference' /> + <Text id='c4p.about.travelHeading' />{' '} + <FieldStatus field='travelPreference' /> </h3> <p className={styles.fieldDescription}> - A cidade onde você mora pode influenciar na seleção, pois o - deslocamento pode gerar custos adicionais para o evento. Dependendo - do nosso orçamento, talvez não seja possível custear passagem e - hospedagem para todos os palestrantes. + <Text id='c4p.about.travelDesc' /> </p> <div className={styles.radioGroup}> {travelOptions.map((option, index) => ( @@ -193,7 +192,9 @@ export const About = () => { checked={formData.travelPreference === option.value} onChange={handleRadioChange('travelPreference', option.value)} /> - <span>{option.label}</span> + <span> + <Text id={option.labelId} /> + </span> </label> ))} </div> @@ -201,7 +202,9 @@ export const About = () => { </section> <section className={styles.section}> - <h3 className={styles.fieldLabel}>Redes sociais</h3> + <h3 className={styles.fieldLabel}> + <Text id='c4p.about.social' /> + </h3> <div className={styles.field}> <label className={styles.subLabel}>LinkedIn</label> @@ -260,12 +263,14 @@ export const About = () => { </div> <div className={styles.field}> - <label className={styles.subLabel}>Site Pessoal</label> + <label className={styles.subLabel}> + <Text id='c4p.about.website' /> + </label> <div className={`${styles.inputWithIcon} group`}> <FaLink className={styles.inputIcon()} aria-hidden /> <input type='text' - aria-label='Site Pessoal' + aria-label={text({ id: 'c4p.about.website' })} className={styles.inputWithIconInput(!!formData.website)} value={formData.website} onChange={handleChange('website')} @@ -277,7 +282,8 @@ export const About = () => { <section className={styles.section}> <div className={styles.field}> <h3 className={styles.fieldLabel}> - Tempo de experiência <FieldStatus field='experienceLevel' /> + <Text id='c4p.about.experience' />{' '} + <FieldStatus field='experienceLevel' /> </h3> <div className={styles.radioGroup}> {experienceOptions.map((option, index) => ( @@ -302,7 +308,9 @@ export const About = () => { checked={formData.experienceLevel === option.value} onChange={handleRadioChange('experienceLevel', option.value)} /> - <span>{option.label}</span> + <span> + <Text id={option.labelId} /> + </span> </label> ))} </div> @@ -312,16 +320,13 @@ export const About = () => { <section className={styles.section}> <div className={styles.field}> <h3 className={styles.fieldLabel}> - Mini biografia <FieldStatus field='bio' /> + <Text id='c4p.about.bio' /> <FieldStatus field='bio' /> </h3> <p className={styles.fieldDescription}> - Em um único parágrafo, com até 280 caracteres, inclua informações - sobre sua formação, certificações, cargo atual e anteriores, - pesquisas desenvolvidas, artigos publicados ou qualquer outro dado - profissional que considere relevante. + <Text id='c4p.about.bioDesc' /> </p> <textarea - aria-label='Mini biografia' + aria-label={text({ id: 'c4p.about.bio' })} aria-required='true' maxLength={280} className={`${styles.textarea(!!formData.bio)} ${errors['bio'] ? styles.inputError : ''}`} @@ -339,10 +344,14 @@ export const About = () => { onClick={handleBack} > <ArrowLeft className='h-[1.6rem] w-[1.6rem]' aria-hidden /> - <span>Voltar</span> + <span> + <Text id='c4p.action.back' /> + </span> </button> <button type='submit' className={styles.submitButton}> - <span>Continuar</span> + <span> + <Text id='c4p.action.continue' /> + </span> <ArrowRight className={styles.submitIcon} aria-hidden /> </button> </div> diff --git a/src/website/pages/c4p/_components/diversity.tsx b/src/website/pages/c4p/_components/diversity.tsx index 9ff2551..aafc194 100644 --- a/src/website/pages/c4p/_components/diversity.tsx +++ b/src/website/pages/c4p/_components/diversity.tsx @@ -2,6 +2,7 @@ import type { SubmitEvent } from 'react'; import type { FormData } from '../../../contexts/c4p'; import Link from '@docusaurus/Link'; import { ArrowLeft, ArrowRight } from 'lucide-react'; +import { Text } from '@site/src/website/components/shared/i18n'; import { useLocalePath } from '@site/src/website/hooks/useLocalePath'; import * as styles from '../_styles'; import { @@ -36,29 +37,32 @@ export const Diversity = () => { <form onSubmit={handleSubmit} className='flex flex-col gap-[0.8rem]'> <section className={styles.section}> <p className={styles.paragraph}> - A JSConf Brasil busca sempre pela diversidade, inclusão e - acessibilidade. Caso se sinta confortável em responder, gostaríamos de - saber: + <Text id='c4p.div.intro' /> </p> <div className='flex flex-col gap-[1.2rem] rounded-[1rem] border border-primary/[0.06] bg-primary/[0.02] !px-[2rem] !py-[1.6rem]'> <p className={styles.fieldDescription}> - Estes dados são sensíveis e opcionais. Serão usados exclusivamente - para (a) compor um line-up diverso de palestrantes, (b) planejar a - acessibilidade do evento (intérpretes de Libras, acessibilidade - física, etc.) e (c) gerar estatísticas agregadas sobre diversidade - no C4P. Você pode revogar esse consentimento a qualquer momento - escrevendo para um dos{' '} - <Link to={localePath('/team')} className='text-primary underline'> - nossos voluntários - </Link> - . + <Text + id='c4p.div.sensitive' + values={{ + link: ( + <Link + to={localePath('/team')} + className='text-primary underline' + > + <Text id='c4p.div.volunteers' /> + </Link> + ), + }} + /> </p> </div> </section> <section className={styles.section}> <div className={styles.field}> - <h3 className={styles.fieldLabel}>Identidade de Gênero</h3> + <h3 className={styles.fieldLabel}> + <Text id='c4p.div.gender' /> + </h3> <div className={styles.radioGroup}> {genderOptions.map((option, index) => ( <label @@ -78,7 +82,9 @@ export const Diversity = () => { checked={formData.gender === option.value} onChange={handleRadioChange('gender', option.value)} /> - <span>{option.label}</span> + <span> + <Text id={option.labelId} /> + </span> </label> ))} </div> @@ -88,7 +94,7 @@ export const Diversity = () => { <section className={styles.section}> <div className={styles.field}> <h3 className={styles.fieldLabel}> - Qual cor/raça você se identifica? + <Text id='c4p.div.race' /> </h3> <div className={styles.radioGroup}> {raceOptions.map((option, index) => ( @@ -107,7 +113,9 @@ export const Diversity = () => { checked={formData.race === option.value} onChange={handleRadioChange('race', option.value)} /> - <span>{option.label}</span> + <span> + <Text id={option.labelId} /> + </span> </label> ))} </div> @@ -117,8 +125,7 @@ export const Diversity = () => { <section className={styles.section}> <div className={styles.field}> <h3 className={styles.fieldLabel}> - Situação de deficiência: qual das opções abaixo descreve você, se - tiver alguma? + <Text id='c4p.div.disability' /> </h3> <div className={styles.radioGroup}> {disabilityOptions.map((option, index) => ( @@ -141,7 +148,9 @@ export const Diversity = () => { checked={formData.disability === option.value} onChange={handleRadioChange('disability', option.value)} /> - <span>{option.label}</span> + <span> + <Text id={option.labelId} /> + </span> </label> ))} </div> @@ -155,10 +164,14 @@ export const Diversity = () => { onClick={handleBack} > <ArrowLeft className='h-[1.6rem] w-[1.6rem]' aria-hidden /> - <span>Voltar</span> + <span> + <Text id='c4p.action.back' /> + </span> </button> <button type='submit' className={styles.submitButton}> - <span>Continuar</span> + <span> + <Text id='c4p.action.continue' /> + </span> <ArrowRight className={styles.submitIcon} aria-hidden /> </button> </div> diff --git a/src/website/pages/c4p/_components/field-status.tsx b/src/website/pages/c4p/_components/field-status.tsx index e4159fd..214f2f8 100644 --- a/src/website/pages/c4p/_components/field-status.tsx +++ b/src/website/pages/c4p/_components/field-status.tsx @@ -1,4 +1,5 @@ import { Check, X } from 'lucide-react'; +import { text } from '@site/src/website/components/shared/i18n'; import * as styles from '../_styles'; import { useC4P } from '../../../contexts/c4p'; @@ -13,8 +14,18 @@ export const FieldStatus = ({ field }: { field: string }) => { } if (errors[field]) { - return <X className={styles.fieldStatusInvalid} aria-label='Inválido' />; + return ( + <X + className={styles.fieldStatusInvalid} + aria-label={text({ id: 'c4p.status.invalid' })} + /> + ); } - return <Check className={styles.fieldStatusValid} aria-label='Válido' />; + return ( + <Check + className={styles.fieldStatusValid} + aria-label={text({ id: 'c4p.status.valid' })} + /> + ); }; diff --git a/src/website/pages/c4p/_components/introduction.tsx b/src/website/pages/c4p/_components/introduction.tsx index fe425db..9486624 100644 --- a/src/website/pages/c4p/_components/introduction.tsx +++ b/src/website/pages/c4p/_components/introduction.tsx @@ -1,5 +1,6 @@ import { ArrowRight } from 'lucide-react'; import { toast } from 'sonner'; +import { Text, text } from '@site/src/website/components/shared/i18n'; import * as styles from '../_styles'; import { topics, useC4P } from '../../../contexts/c4p'; @@ -7,8 +8,8 @@ export const Introduction = () => { const { goToStep } = useC4P(); const handleContinue = () => { - toast.success('Seus dados ficam salvos no navegador.', { - description: 'Você pode continuar de onde parou a qualquer momento.', + toast.success(text({ id: 'c4p.intro.toastTitle' }), { + description: text({ id: 'c4p.intro.toastDesc' }), }); goToStep(2); @@ -18,60 +19,80 @@ export const Introduction = () => { <> <section className={styles.section}> <h2 className={styles.sectionHeading}> - Quer palestrar na JSConf Brasil deste ano? + <Text id='c4p.intro.heading1' /> </h2> <p className={styles.paragraph}> - Essa é a sua chance de dividir o palco com alguns dos profissionais - mais incríveis da área de tecnologia! Preencha o nosso Call4Papers, - nossa equipe fará a análise e seleção das propostas enviadas. + <Text id='c4p.intro.p1' /> </p> <p className={styles.paragraph}> - A <strong className={styles.strong}>JSConf Brasil</strong> existe para - compartilhar conhecimento, aproximar a comunidade e fortalecer um - evento diverso, inclusivo e colaborativo. + <Text + id='c4p.intro.p2' + values={{ + brand: <strong className={styles.strong}>JSConf Brasil</strong>, + }} + /> </p> </section> <section className={styles.section}> <h2 className={styles.sectionHeading}> - JSConf Brasil - 28 de novembro de 2026 + <Text id='c4p.intro.dateHeading' /> </h2> <p className={styles.paragraph}> - Aceitaremos inscrições até{' '} - <span className='text-primary !font-bold'>30 de julho</span>. + <Text + id='c4p.intro.deadline' + values={{ + date: ( + <span className='text-primary !font-bold'> + <Text id='c4p.intro.deadlineDate' /> + </span> + ), + }} + /> </p> <p className={styles.paragraph}> - Você pode enviar até{' '} - <span className='text-primary !font-bold'>3</span> palestras. + <Text + id='c4p.intro.maxTalks' + values={{ + count: <span className='text-primary !font-bold'>3</span>, + }} + /> </p> <p className={styles.paragraph}> - O evento acontecerá no dia{' '} - <strong className={styles.strong}>28 de novembro de 2026</strong>, na{' '} - <strong className={styles.strong}> - Universidade Municipal de São Caetano do Sul, São Caetano do Sul - - SP - </strong> - . Serão palestras, painéis, atividades interativas, feira de - expositores e muito mais. + <Text + id='c4p.intro.eventInfo' + values={{ + date: ( + <strong className={styles.strong}> + <Text id='c4p.intro.eventDate' /> + </strong> + ), + venue: ( + <strong className={styles.strong}> + Universidade Municipal de São Caetano do Sul, São Caetano do + Sul - SP + </strong> + ), + }} + /> </p> </section> <section className={styles.section}> <p className={styles.paragraph}> - Esses são alguns dos tópicos que sugerimos (os que têm estrelinha são - os preferidos): + <Text id='c4p.intro.topicsIntro' /> </p> <ul className='!my-[0.4rem] grid list-none grid-cols-2 gap-x-[2rem] gap-y-[0.4rem] rounded-[1rem] border border-primary/[0.06] bg-primary/[0.02] !px-[2rem] !py-[1.6rem] max-md:grid-cols-1'> {topics.map((topic) => ( <li - key={topic.name} + key={topic.labelId} className={`relative py-[1rem] pl-[2.2rem] text-[1.5rem] leading-[1] before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 ${ topic.preferred ? 'font-bold text-white before:content-["★"] before:text-primary' : 'text-white/60 before:content-["•"] before:text-white/30' }`} > - {topic.name} + <Text id={topic.labelId} /> </li> ))} </ul> @@ -82,7 +103,9 @@ export const Introduction = () => { className={styles.submitButton} onClick={handleContinue} > - <span>Continuar</span> + <span> + <Text id='c4p.action.continue' /> + </span> <ArrowRight className={styles.submitIcon} aria-hidden /> </button> </> diff --git a/src/website/pages/c4p/_components/success.tsx b/src/website/pages/c4p/_components/success.tsx index 743bca7..a480309 100644 --- a/src/website/pages/c4p/_components/success.tsx +++ b/src/website/pages/c4p/_components/success.tsx @@ -1,4 +1,5 @@ import { ArrowRight } from 'lucide-react'; +import { Text } from '@site/src/website/components/shared/i18n'; import * as styles from '../_styles'; import { useC4P } from '../../../contexts/c4p'; @@ -18,42 +19,39 @@ export const Success = () => { <> <section className={styles.section}> <p className={styles.paragraph}> - Obrigado por se inscrever no Call4Papers da{' '} - <strong className={styles.strong}>JSConf Brasil 2026</strong>. Sua - proposta foi recebida com sucesso. + <Text + id='c4p.success.thanks' + values={{ + brand: ( + <strong className={styles.strong}>JSConf Brasil 2026</strong> + ), + }} + /> </p> </section> <section className={styles.section}> <h2 className={styles.sectionHeading}> - Como os conteúdos serão avaliados? + <Text id='c4p.success.heading' /> </h2> <div className='flex flex-col gap-[1.2rem] rounded-[1rem] border border-primary/[0.06] bg-primary/[0.02] !px-[2rem] !py-[1.6rem]'> <p className={styles.paragraph}> - A avaliação das propostas acontecerá em duas etapas. + <Text id='c4p.success.p1' /> </p> <p className={styles.paragraph}> - Primeiro, um grupo de co-curadoria analisará anonimamente o título, - a descrição e o nível de conhecimento indicado para cada palestra. + <Text id='c4p.success.p2' /> </p> <p className={styles.paragraph}> - Em seguida, a equipe da JSConf Brasil fará uma análise complementar - para verificar o encaixe do conteúdo na programação, considerando - orçamento, estratégia e diversidade temática. + <Text id='c4p.success.p3' /> </p> <p className={styles.paragraph}> - Não existe um número fixo de pessoas selecionadas; a escolha - dependerá da qualidade e adequação das propostas enviadas. + <Text id='c4p.success.p4' /> </p> <p className={styles.paragraph}> - Após o fim das inscrições, todo o processo de avaliação será - concluído em até duas semanas. + <Text id='c4p.success.p5' /> </p> <p className={styles.paragraph}> - As pessoas selecionadas receberão um e-mail para confirmar sua - participação e, caso não haja resposta, entraremos em contato com as - próximas da lista. Assim que todas as confirmações forem - finalizadas, informaremos também as pessoas que não forem aprovadas. + <Text id='c4p.success.p6' /> </p> </div> </section> @@ -63,7 +61,9 @@ export const Success = () => { className={styles.submitButton} onClick={handleSubmitAnother} > - <span>Enviar outra palestra</span> + <span> + <Text id='c4p.action.submitAnother' /> + </span> <ArrowRight className={styles.submitIcon} aria-hidden /> </button> </> diff --git a/src/website/pages/c4p/_components/talk.tsx b/src/website/pages/c4p/_components/talk.tsx index 90d29af..362461b 100644 --- a/src/website/pages/c4p/_components/talk.tsx +++ b/src/website/pages/c4p/_components/talk.tsx @@ -2,6 +2,7 @@ import type { ChangeEvent } from 'react'; import type { FormData } from '../../../contexts/c4p'; import { ArrowLeft, ArrowRight } from 'lucide-react'; import { FaHeading } from 'react-icons/fa6'; +import { Text, text } from '@site/src/website/components/shared/i18n'; import * as styles from '../_styles'; import { audienceLevels, @@ -48,17 +49,17 @@ export const Talk = () => { /> <section className={styles.section}> <div className={styles.field}> - <h3 className={styles.fieldLabel}>Tipo de conteúdo</h3> + <h3 className={styles.fieldLabel}> + <Text id='c4p.talk.contentType' /> + </h3> <p className={styles.fieldDescription}> - O formato dos conteúdos será em palestras de 15 ou 25 minutos. - Escolha o tempo de duração que você considera mais adequado para a - sua proposta. + <Text id='c4p.talk.contentTypeDesc' /> </p> </div> <div className={styles.field}> <h3 className={styles.fieldLabel}> - Tempo de duração <FieldStatus field='duration' /> + <Text id='c4p.talk.duration' /> <FieldStatus field='duration' /> </h3> <div className={styles.radioGroup}> {durationOptions.map((option, index) => ( @@ -81,7 +82,9 @@ export const Talk = () => { checked={formData.duration === option.value} onChange={handleRadioChange('duration', option.value)} /> - <span>{option.label}</span> + <span> + <Text id={option.labelId} /> + </span> </label> ))} </div> @@ -91,7 +94,7 @@ export const Talk = () => { <section className={styles.section}> <div className={styles.field}> <h3 className={styles.fieldLabel}> - Título <FieldStatus field='talkTitle' /> + <Text id='c4p.talk.title' /> <FieldStatus field='talkTitle' /> </h3> <div className={`${styles.inputWithIcon} group`}> <FaHeading @@ -100,7 +103,7 @@ export const Talk = () => { /> <input type='text' - aria-label='Título' + aria-label={text({ id: 'c4p.talk.title' })} aria-required='true' className={`${styles.inputWithIconInput(!!formData.talkTitle)} ${errors['talkTitle'] ? styles.inputError : ''}`} value={formData.talkTitle} @@ -114,14 +117,14 @@ export const Talk = () => { <section className={styles.section}> <div className={styles.field}> <h3 className={styles.fieldLabel}> - Descrição <FieldStatus field='talkDescription' /> + <Text id='c4p.talk.description' />{' '} + <FieldStatus field='talkDescription' /> </h3> <p className={styles.fieldDescription}> - Forneça um resumo do seu conteúdo. Essa informação será usada em - nosso site para divulgação da sua palestra. + <Text id='c4p.talk.descriptionDesc' /> </p> <textarea - aria-label='Descrição' + aria-label={text({ id: 'c4p.talk.description' })} aria-required='true' className={`${styles.textarea(!!formData.talkDescription)} ${errors['talkDescription'] ? styles.inputError : ''}`} value={formData.talkDescription} @@ -134,7 +137,8 @@ export const Talk = () => { <section className={styles.section}> <div className={styles.field}> <h3 className={styles.fieldLabel}> - Para quem é este conteúdo? <FieldStatus field='audienceLevel' /> + <Text id='c4p.talk.audience' />{' '} + <FieldStatus field='audienceLevel' /> </h3> <div className={styles.radioGroup}> {audienceLevels.map((option, index) => ( @@ -159,7 +163,9 @@ export const Talk = () => { checked={formData.audienceLevel === option.value} onChange={handleRadioChange('audienceLevel', option.value)} /> - <span>{option.label}</span> + <span> + <Text id={option.labelId} /> + </span> </label> ))} </div> @@ -169,11 +175,10 @@ export const Talk = () => { <section className={styles.section}> <div className={styles.field}> <h3 className={styles.fieldLabel}> - Por que deveríamos considerar este conteúdo na JSConf Brasil?{' '} - <FieldStatus field='talkReason' /> + <Text id='c4p.talk.reason' /> <FieldStatus field='talkReason' /> </h3> <textarea - aria-label='Por que deveríamos considerar este conteúdo na JSConf Brasil?' + aria-label={text({ id: 'c4p.talk.reason' })} aria-required='true' className={`${styles.textarea(!!formData.talkReason)} ${errors['talkReason'] ? styles.inputError : ''}`} value={formData.talkReason} @@ -190,10 +195,14 @@ export const Talk = () => { onClick={handleBack} > <ArrowLeft className='h-[1.6rem] w-[1.6rem]' aria-hidden /> - <span>Voltar</span> + <span> + <Text id='c4p.action.back' /> + </span> </button> <button type='submit' className={styles.submitButton}> - <span>Finalizar</span> + <span> + <Text id='c4p.action.finish' /> + </span> <ArrowRight className={styles.submitIcon} aria-hidden /> </button> </div> diff --git a/src/website/pages/c4p/index.tsx b/src/website/pages/c4p/index.tsx index 8697065..a9be1ba 100644 --- a/src/website/pages/c4p/index.tsx +++ b/src/website/pages/c4p/index.tsx @@ -6,6 +6,7 @@ import { Send, UserPen, } from 'lucide-react'; +import { Text, text } from '@site/src/website/components/shared/i18n'; import { Page } from '@site/src/website/components/shared/Page'; import { C4PProvider, useC4P } from '../../contexts/c4p'; import { About } from './_components/about'; @@ -27,27 +28,33 @@ const stepTitles: Record<number, React.ReactNode> = { 2: ( <> <UserPen className={styles.stepIcon} /> - <span className={styles.stepTitle}>Sobre você</span> + <span className={styles.stepTitle}> + <Text id='c4p.step.about' /> + </span> </> ), 3: ( <> <Handshake className={styles.stepIcon} /> <span className={styles.stepTitle}> - Diversidade e inclusão (opcional) + <Text id='c4p.step.diversity' /> </span> </> ), 4: ( <> <MicVocal className={styles.stepIcon} /> - <span className={styles.stepTitle}>Sobre a Palestra</span> + <span className={styles.stepTitle}> + <Text id='c4p.step.talk' /> + </span> </> ), 5: ( <> <CircleCheckBig className={styles.stepIcon} /> - <span className={styles.stepTitle}>Proposta enviada!</span> + <span className={styles.stepTitle}> + <Text id='c4p.step.done' /> + </span> </> ), }; @@ -66,7 +73,7 @@ const Form = () => { value={currentStep - 1} className='mb-[2rem] h-[0.3rem] w-full appearance-none rounded-[0.2rem] border-none bg-primary/10 [&::-moz-progress-bar]:rounded-[0.2rem] [&::-moz-progress-bar]:bg-primary [&::-webkit-progress-bar]:rounded-[0.2rem] [&::-webkit-progress-bar]:bg-primary/10 [&::-webkit-progress-value]:rounded-[0.2rem] [&::-webkit-progress-value]:bg-primary [&::-webkit-progress-value]:transition-[width] [&::-webkit-progress-value]:duration-400' > - Página {currentStep} de 4 + <Text id='c4p.progress' values={{ current: currentStep }} /> </progress> )} @@ -81,7 +88,7 @@ const Form = () => { }; export default () => ( - <Page title='Call4Papers - JSConf Brasil 2026'> + <Page title={text({ id: 'c4p.pageTitle' })}> <C4PProvider> <Form /> </C4PProvider> diff --git a/src/website/pages/vote/index.tsx b/src/website/pages/vote/index.tsx new file mode 100644 index 0000000..d731c17 --- /dev/null +++ b/src/website/pages/vote/index.tsx @@ -0,0 +1,308 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { + Circle, + CircleCheckBig, + Info, + LogOut, + Vote as VoteIcon, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { Text, text } from '@site/src/website/components/shared/i18n'; +import { Page } from '@site/src/website/components/shared/Page'; +import { + audienceLevels, + durationOptions, +} from '@site/src/website/contexts/c4p/definitions'; +import '@site/src/website/scss/pages/voting.scss'; + +type Talk = { + id: number; + title: string; + description: string; + duration: number; + audience_level: number; +}; + +type Session = { + budget: number; + used: number; + talks: (Talk & { speaker_name: string })[]; + myVotes: number[]; + closesAt: string; +}; + +const LETTERS = 'abcdefghijklmnopqrstuvwxyz'; + +// Fake, name-shaped (not real) placeholder shown blurred instead of the real speaker — the API +// never sends a speaker name, so this generates one purely for display. Computed once per fetch +// (not per render) so it doesn't change while toggling votes. +const fakeName = (): string => + Array.from({ length: 2 + Math.floor(Math.random() * 4) }, () => { + const word = Array.from( + { length: 3 + Math.floor(Math.random() * 8) }, + () => LETTERS[Math.floor(Math.random() * LETTERS.length)] + ).join(''); + return word.charAt(0).toUpperCase() + word.slice(1); + }).join(' '); + +const Vote = () => { + const { siteConfig, i18n } = useDocusaurusContext(); + const locale = i18n.currentLocale; + const workerDomain = siteConfig.customFields?.['workerDomain'] as + | string + | undefined; + + const [session, setSession] = useState<Session | null>(null); + const [status, setStatus] = useState< + 'loading' | 'ready' | 'unauth' | 'error' + >('loading'); + const [votes, setVotes] = useState<Set<number>>(new Set()); + // Queued talkId -> action, flushed 1s after the last toggle (resets on every new toggle) so a + // burst of clicks becomes one wave of requests instead of one per click. + const queueRef = useRef<Map<number, 'add' | 'remove'>>(new Map()); + const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); + + useEffect(() => { + if (!workerDomain) return setStatus('error'); + fetch(`${workerDomain}/api/vote`, { credentials: 'include' }) + .then(async (res) => { + if (res.status === 401 || res.status === 403) + return setStatus('unauth'); + if (!res.ok) return setStatus('error'); + const data = (await res.json()) as Omit<Session, 'talks'> & { + talks: Talk[]; + }; + setSession({ + ...data, + talks: data.talks.map((talk) => ({ + ...talk, + speaker_name: fakeName(), + })), + }); + setVotes(new Set(data.myVotes)); + setStatus('ready'); + }) + .catch(() => setStatus('error')); + }, [workerDomain]); + + const closed = session + ? new Date(session.closesAt).getTime() < Date.now() + : false; + + // Sends every queued talkId's LATEST desired action once the 1s idle window elapses. + const flush = useCallback(async () => { + const actions = Array.from(queueRef.current.entries()); + queueRef.current.clear(); + for (const [talkId, action] of actions) { + const res = await fetch(`${workerDomain}/api/vote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ talkId, action }), + }).catch(() => null); + + if (!res || !res.ok) { + setVotes((current) => { + const copy = new Set(current); + if (action === 'add') copy.delete(talkId); + if (action === 'remove') copy.add(talkId); + return copy; + }); + toast.error(text({ id: 'vote.submitError' })); + } + } + }, [workerDomain]); + + const toggle = useCallback( + (talkId: number) => { + if (!session || closed) return; + const has = votes.has(talkId); + if (!has && votes.size >= session.budget) { + toast.error( + text({ id: 'vote.limitReached' }, { budget: session.budget }) + ); + return; + } + + // Optimistic toggle; queued and debounced (1s idle) so a burst of clicks across + // any number of cards becomes one wave of requests instead of one per click. + const next = new Set(votes); + if (has) next.delete(talkId); + if (!has) next.add(talkId); + setVotes(next); + queueRef.current.set(talkId, has ? 'remove' : 'add'); + + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(flush, 1000); + }, + [session, votes, flush, closed] + ); + + return ( + <Page title={text({ id: 'vote.title' })}> + <div className='vote-page page-content'> + <header className='page-hero'> + <h1 className='title'> + <VoteIcon className='icon' aria-hidden /> + <Text id='vote.heading' /> + </h1> + <p className='subtitle'> + <Text id='vote.subheading' /> + </p> + </header> + + {status === 'loading' && ( + <p className='status'> + <Text id='common.loading' /> + </p> + )} + {status === 'error' && ( + <p className='status error'> + <Text id='vote.loadError' /> + </p> + )} + {status === 'unauth' && ( + <div className='login-hero'> + <h2 className='login-title'> + <Text id='vote.loginHeading' /> + </h2> + <p className='login-text'> + <Text id='vote.loginPrompt' /> + </p> + <a className='login-cta' href={`${workerDomain}/api/vote/login`}> + <Text id='auth.login' /> + </a> + </div> + )} + + {status === 'ready' && session && ( + <> + <div className='budget-bar'> + <div className='budget-info'> + {closed ? ( + <span className='closed-badge'> + <Text id='vote.closedHeading' /> + </span> + ) : ( + <> + <span className='count'>{session.budget - votes.size}</span> + <span className='label'> + <Text + id='vote.remaining' + values={{ + remaining: session.budget - votes.size, + budget: session.budget, + }} + /> + </span> + <span className='deadline'> + <Text + id='vote.openUntil' + values={{ + date: new Date(session.closesAt).toLocaleString( + locale + ), + }} + /> + </span> + </> + )} + </div> + <a + className='button button--secondary button--sm logout-link' + href={`${workerDomain}/api/vote/logout`} + > + <LogOut className='icon' aria-hidden /> + <Text id='auth.logout' /> + </a> + </div> + + <aside className='info-banner'> + <Info className='icon' aria-hidden /> + <div className='info-text'> + <strong> + <Text id='vote.hideAuthorsTitle' /> + </strong> + <p> + <Text id='vote.hideAuthorsBody' /> + </p> + </div> + </aside> + + {closed && ( + <p className='closed-note'> + <Text id='vote.closed' /> + </p> + )} + + {session.talks.length === 0 && ( + <p className='status'> + <Text id='vote.emptyTalks' /> + </p> + )} + + <ul className='talks-grid'> + {session.talks.map((talk) => { + const duration = durationOptions[talk.duration]; + const audience = audienceLevels[talk.audience_level]; + const voted = votes.has(talk.id); + const budgetOut = !voted && votes.size >= session.budget; + return ( + <li + key={talk.id} + className={[ + 'talk-card', + voted && 'voted', + budgetOut && 'budget-out', + ] + .filter(Boolean) + .join(' ')} + > + <div className='talk-head'> + <h3 className='talk-title'>{talk.title}</h3> + <div className='talk-meta'> + {duration && ( + <span> + <Text id={duration.labelId} /> + </span> + )} + {audience && ( + <span> + <Text id={audience.labelId} /> + </span> + )} + </div> + </div> + <p className='talk-speaker'> + <Text id='vote.by' />{' '} + <span className='speaker-name'>{talk.speaker_name}</span> + </p> + <p className='talk-description'>{talk.description}</p> + <button + type='button' + className={voted ? 'vote-toggle voted' : 'vote-toggle'} + disabled={closed || budgetOut} + onClick={() => toggle(talk.id)} + > + {voted ? ( + <CircleCheckBig className='icon' aria-hidden /> + ) : ( + <Circle className='icon' aria-hidden /> + )} + <Text + id={voted ? 'vote.votedAction' : 'vote.voteAction'} + /> + </button> + </li> + ); + })} + </ul> + </> + )} + </div> + </Page> + ); +}; + +export default Vote; diff --git a/src/website/scss/pages/partial/_navbar.scss b/src/website/scss/pages/partial/_navbar.scss index e13170d..bb6f5e5 100644 --- a/src/website/scss/pages/partial/_navbar.scss +++ b/src/website/scss/pages/partial/_navbar.scss @@ -219,6 +219,94 @@ margin-left: 1rem; } + // Logged-in account button + its dropdown (Votar / Sair). Mirrors the locale dropdown. + .account-menu { + position: relative; + + .account { + cursor: pointer; + + .chevron { + width: 1.4rem; + height: 1.4rem; + } + } + + .account-dropdown { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + z-index: 10; + @include flex(column, flex-start, flex-start); + min-width: 14rem; + background-color: rgba(0, 14, 6, 0.95); + border: 0.1rem solid rgba(255, 255, 255, 0.1); + border-radius: 0.5rem; + backdrop-filter: blur(2.5rem); + box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.5); + overflow: hidden; + + a { + @include flex(row, center, flex-start); + width: 100%; + padding: 0.9rem 1.4rem; + font-size: 1.3rem; + font-weight: 600; + color: var(--title-color); + text-decoration: none; + opacity: 0.8; + transition: + opacity 0.15s ease, + background-color 0.15s ease; + + &:hover { + opacity: 1; + background-color: rgba(255, 255, 255, 0.06); + } + } + } + } + + .account { + @include flex(row, center, center); + gap: 0.5rem; + padding: 1rem 1.5rem; + border-radius: 0.75rem; + border: 0.1rem solid var(--ifm-color-primary-darkest); + background-color: transparent; + font-family: var(--ifm-font-family-base); + font-size: 1.2rem; + font-weight: 600; + letter-spacing: 0.05rem; + color: var(--title-color); + text-decoration: none; + white-space: nowrap; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: var(--ifm-color-primary-darkest); + } + + svg { + width: 1.6rem; + height: 1.6rem; + } + + .avatar { + width: 2rem; + height: 2rem; + border-radius: 50%; + object-fit: cover; + } + + span { + max-width: 12rem; + overflow: hidden; + text-overflow: ellipsis; + } + } + .locale-dropdown { position: relative; diff --git a/src/website/scss/pages/voting.scss b/src/website/scss/pages/voting.scss new file mode 100644 index 0000000..dfba101 --- /dev/null +++ b/src/website/scss/pages/voting.scss @@ -0,0 +1,409 @@ +@use '../global/mixins' as *; + +.vote-page, +.account-page { + @include flex(column); + gap: 3rem; + width: 100%; + max-width: 96rem; + color: #e0e0e0; + + // Named .page-hero, not .hero: the latter is Infima's own component class + // (background-color: var(--ifm-hero-background-color)) and colliding with it painted this + // header with the framework's light hero background instead of our transparent one. + .page-hero { + @include flex(column); + gap: 1rem; + + .title { + @include flex(row, center, flex-start); + gap: 1.2rem; + font-family: var(--ifm-font-family-title); + font-size: 3.6rem; + font-weight: 800; + color: var(--title-color); + + .icon { + width: 3.2rem; + height: 3.2rem; + color: var(--ifm-color-primary); + flex-shrink: 0; + } + + @media (max-width: 630px) { + font-size: 2.8rem; + } + } + + .subtitle { + font-size: 1.6rem; + line-height: 1.6; + color: #b0b0b0; + max-width: 64rem; + } + } + + .status { + font-size: 1.6rem; + color: #b0b0b0; + + &.error { + color: #ff8080; + } + } + + .login-hero { + @include flex(column, flex-start, center); + gap: 1.5rem; + padding: 4rem 3rem; + background-image: linear-gradient( + 135deg, + rgba(141, 248, 34, 0.06), + rgba(141, 248, 34, 0.02) + ); + border: 0.15rem solid rgba(141, 248, 34, 0.2); + border-radius: 1.5rem; + text-align: left; + + .login-title { + font-family: var(--ifm-font-family-title); + font-size: 2.4rem; + font-weight: 700; + color: var(--title-color); + margin: 0; + } + + .login-text { + font-size: 1.5rem; + line-height: 1.6; + color: #b0b0b0; + margin: 0; + max-width: 48rem; + } + + // Matches the navbar's tickets/account buttons instead of Infima's stock .button--primary. + .login-cta { + @include flex(row, center, center); + align-self: flex-start; + margin-top: 0.5rem; + padding: 1.1rem 2rem; + border-radius: 0.75rem; + background-color: var(--ifm-color-primary-darkest); + font-family: var(--ifm-font-family-base); + font-size: 1.4rem; + font-weight: 600; + color: var(--title-color); + text-decoration: none; + transition: background-color 0.2s var(--ease-out-quad); + + &:hover { + background-color: var(--ifm-color-primary-darker); + } + } + } + + .budget-bar { + @include flex(row, center, space-between, wrap); + gap: 1.5rem; + padding: 2rem 2.5rem; + background-color: #1f2120; + border-radius: 1.2rem; + border: 0.15rem solid rgba(141, 248, 34, 0.2); + + .budget-info { + @include flex(row, baseline, flex-start, wrap); + gap: 0.8rem 1.2rem; + + .count { + font-family: var(--ifm-font-family-title); + font-size: 3.2rem; + font-weight: 800; + color: var(--ifm-color-primary); + line-height: 1; + } + + .label { + font-size: 1.5rem; + color: #e0e0e0; + } + + .deadline { + flex-basis: 100%; + font-size: 1.3rem; + color: #909090; + } + + .closed-badge { + font-family: var(--ifm-font-family-title); + font-size: 1.8rem; + font-weight: 700; + color: #ff8080; + } + } + + .logout-link { + @include flex(row, center, center); + gap: 0.6rem; + flex-shrink: 0; + padding: 1rem 1.8rem; + font-size: 1.5rem; + + .icon { + width: 1.8rem; + height: 1.8rem; + } + } + } + + .closed-note { + font-size: 1.5rem; + color: #b0b0b0; + } + + .info-banner { + @include flex(row, flex-start, flex-start); + gap: 0.9rem; + padding: 1rem 1.4rem; + border-radius: 0.8rem; + border: 0.1rem solid rgba(255, 208, 0, 0.35); + border-left: 0.3rem solid #ffd000; + background-color: rgba(255, 208, 0, 0.08); + + .icon { + width: 1.8rem; + height: 1.8rem; + color: #ffd000; + flex-shrink: 0; + margin-top: 0.15rem; + } + + .info-text { + @include flex(column); + gap: 0.2rem; + + strong { + font-size: 1.35rem; + color: var(--title-color); + } + + p { + font-size: 1.25rem; + line-height: 1.5; + color: #b0b0b0; + margin: 0; + } + } + } + + .talks-grid { + @include flex(column); + gap: 2rem; + list-style: none; + padding: 0; + margin: 0; + } + + .talk-card { + @include flex(column); + gap: 1.2rem; + padding: 2.5rem; + background-color: #1f2120; + border-radius: 1.2rem; + border: 0.15rem solid transparent; + transition: + border-color 0.3s var(--ease-back-out-soft), + background-color 0.3s var(--ease-back-out-soft); + + &.voted { + background-color: var(--ifm-color-primary-darkest); + border-color: var(--ifm-color-primary); + + // Green-on-green (chips, author) loses contrast on this bg — go gray instead. + .talk-description { + color: var(--title-color); + } + + .talk-speaker { + color: #04140a; + } + } + + &:hover { + border-color: rgba(141, 248, 34, 0.35); + } + + &.budget-out { + opacity: 0.45; + } + + .talk-head { + @include flex(row, flex-start, space-between, wrap); + gap: 1rem; + + .talk-title { + font-family: var(--ifm-font-family-title); + font-size: 2rem; + font-weight: 700; + color: #fff; + margin: 0; + } + + .talk-meta { + @include flex(row, center, flex-start, wrap); + gap: 0.8rem; + flex-shrink: 0; + + span { + @include flex(row, center, center); + gap: 0.4rem; + padding: 0.3rem 0.9rem; + border-radius: 999rem; + background-color: rgba(141, 248, 34, 0.1); + color: var(--ifm-color-primary); + font-size: 1.2rem; + font-weight: 600; + white-space: nowrap; + } + + .icon { + width: 1.3rem; + height: 1.3rem; + } + } + } + + // Placed after .talk-head above so it wins the specificity tie with its base rule. + &.voted .talk-meta span { + background-color: var(--background-color); + } + + .talk-speaker { + font-size: 1.4rem; + font-weight: 600; + color: var(--ifm-color-primary); + margin: 0; + + // The API already sends a fake random name (never the real speaker), so this blur is purely + // cosmetic — it makes the placeholder read as a redacted name instead of a visible fake one. + .speaker-name { + filter: blur(0.5rem); + user-select: none; + } + } + + .talk-description { + font-size: 1.5rem; + line-height: 1.6; + color: #c0c0c0; + margin: 0; + } + + .vote-toggle { + @include flex(row, center, center); + gap: 0.6rem; + align-self: flex-start; + margin-top: 0.4rem; + padding: 1rem 2rem; + border-radius: 0.8rem; + border: 0.15rem solid var(--ifm-color-primary-darkest); + background-color: transparent; + color: var(--title-color); + font-family: var(--ifm-font-family-base); + font-size: 1.4rem; + font-weight: 600; + cursor: pointer; + transition: + background-color 0.2s var(--ease-out-quad), + border-color 0.2s var(--ease-out-quad); + + .icon { + width: 1.6rem; + height: 1.6rem; + } + + &:hover:not(:disabled) { + background-color: var(--ifm-color-primary-darkest); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + // Sits on a now-dark-green card (see .talk-card.voted), so it needs its own contrast: + // gray, not the same green as the card. + &.voted { + background-color: #e0e0e0; + border-color: #e0e0e0; + color: #04140a; + + .icon { + color: #04140a; + } + + &:hover:not(:disabled) { + background-color: #e0e0e0; + } + } + } + } + + .account-card { + @include flex(row, center, space-between, wrap); + gap: 1.5rem; + padding: 2.5rem; + background-color: #1f2120; + border-radius: 1.2rem; + border: 0.15rem solid rgba(141, 248, 34, 0.2); + + .account-identity { + @include flex(row, center, flex-start); + gap: 1.5rem; + + .avatar { + @include flex(row, center, center); + width: 5rem; + height: 5rem; + border-radius: 50%; + background-color: var(--ifm-color-primary-darkest); + flex-shrink: 0; + + .icon { + width: 2.4rem; + height: 2.4rem; + color: var(--title-color); + } + } + + .account-id-label { + font-size: 1.3rem; + text-transform: uppercase; + letter-spacing: 0.05rem; + color: #909090; + margin: 0; + } + + .account-id-value { + font-family: var(--ifm-font-family-title); + font-size: 1.8rem; + font-weight: 700; + color: var(--title-color); + margin: 0; + word-break: break-all; + } + } + + .logout-link { + @include flex(row, center, center); + gap: 0.6rem; + flex-shrink: 0; + padding: 1rem 1.8rem; + font-size: 1.5rem; + + .icon { + width: 1.8rem; + height: 1.8rem; + } + } + } +} diff --git a/test/server/routes/stripe-webhook.test.ts b/test/server/routes/stripe-webhook.test.ts deleted file mode 100644 index 6299150..0000000 --- a/test/server/routes/stripe-webhook.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { Env } from '../../../src/server/types.js'; -import { createHmac } from 'node:crypto'; -import { assert, describe, it } from 'poku'; -import { routes } from '../../../src/server/routes.js'; -import { handleEvent } from '../../../src/server/routes/stripe-webhook.js'; - -const WEBHOOK_SECRET = 'whsec_test_secret'; -const API_KEY = 'sk_test_fake'; - -const cors = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - Vary: 'Origin', -}; - -const mockEnv: Env = { - DB: { - prepare: () => ({ - bind: () => ({ - run: async () => {}, - all: async <T>() => ({ results: [] as T[] }), - }), - }), - }, - STRIPE_SECRET_KEY: API_KEY, - STRIPE_WEBHOOK_SECRET: WEBHOOK_SECRET, -}; - -const makeEventPayload = (type: string, id = 'evt_test_123') => - JSON.stringify({ - id, - object: 'event', - type, - api_version: '2026-03-25.dahlia', - created: Math.floor(Date.now() / 1000), - data: { object: { id: `obj_${id}` } }, - livemode: false, - pending_webhooks: 0, - request: { id: null, idempotency_key: null }, - }); - -const signPayload = (payload: string, secret: string): string => { - const timestamp = Math.floor(Date.now() / 1000); - const sig = createHmac('sha256', secret) - .update(`${timestamp}.${payload}`, 'utf8') - .digest('hex'); - return `t=${timestamp},v1=${sig}`; -}; - -const makeWebhookRequest = (payload: string, signature?: string): Request => - new Request('http://localhost/api/stripe/webhook', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(signature ? { 'stripe-signature': signature } : {}), - }, - body: payload, - }); - -describe('stripe webhook', async () => { - describe('handleEvent', () => { - const makeEvent = (type: string) => - ({ type, data: { object: { id: 'obj_123' } } }) as never; - - it('returns true for checkout.session.completed', () => { - assert.equal(handleEvent(makeEvent('checkout.session.completed')), true); - }); - - it('returns true for payment_intent.succeeded', () => { - assert.equal(handleEvent(makeEvent('payment_intent.succeeded')), true); - }); - - it('returns true for charge.succeeded', () => { - assert.equal(handleEvent(makeEvent('charge.succeeded')), true); - }); - - it('returns true for charge.refunded', () => { - assert.equal(handleEvent(makeEvent('charge.refunded')), true); - }); - - it('returns false for unknown event types', () => { - assert.equal(handleEvent(makeEvent('unknown.event')), false); - }); - }); - - await describe('routes.stripeWebhook', async () => { - await it('returns 400 when stripe-signature header is missing', async () => { - const payload = makeEventPayload('payment_intent.succeeded'); - const request = makeWebhookRequest(payload); - const res = await routes.stripeWebhook({ - request, - cors, - env: mockEnv, - }); - - assert.equal(res.status, 400); - assert.deepEqual(await res.json(), { error: 'Missing signature.' }); - }); - - await it('returns 400 for invalid signature', async () => { - const payload = makeEventPayload('payment_intent.succeeded'); - const request = makeWebhookRequest(payload, 't=123,v1=invalid'); - const res = await routes.stripeWebhook({ - request, - cors, - env: mockEnv, - }); - - assert.equal(res.status, 400); - assert.deepEqual(await res.json(), { - error: 'Webhook signature verification failed.', - }); - }); - - await it('returns 400 for tampered payload', async () => { - const payload = makeEventPayload('payment_intent.succeeded'); - const signature = signPayload(payload, WEBHOOK_SECRET); - const tampered = makeEventPayload('charge.succeeded'); - const request = makeWebhookRequest(tampered, signature); - const res = await routes.stripeWebhook({ - request, - cors, - env: mockEnv, - }); - - assert.equal(res.status, 400); - }); - - await it('returns 200 for a valid signed event', async () => { - const payload = makeEventPayload('payment_intent.succeeded'); - const signature = signPayload(payload, WEBHOOK_SECRET); - const request = makeWebhookRequest(payload, signature); - const res = await routes.stripeWebhook({ - request, - cors, - env: mockEnv, - }); - - assert.equal(res.status, 200); - assert.deepEqual(await res.json(), { received: true }); - }); - }); -}); diff --git a/test/server/routes/vote.test.ts b/test/server/routes/vote.test.ts new file mode 100644 index 0000000..e73a217 --- /dev/null +++ b/test/server/routes/vote.test.ts @@ -0,0 +1,293 @@ +import type { Database, Env } from '../../../src/server/types.js'; +import { assert, describe, it } from 'poku'; +import { isVotingOpen } from '../../../src/server/configs/vote.js'; +import { + signSession, + verifySession, +} from '../../../src/server/helpers/session.js'; +import { vote } from '../../../src/server/repositories/vote.js'; +import { routes } from '../../../src/server/routes.js'; + +const cors = { 'Access-Control-Allow-Origin': '*' }; + +const talks = [ + { id: 1, title: 'A', description: 'da' }, + { id: 2, title: 'B', description: 'db' }, +]; + +type Mock = { database: Database; votes: Set<number> }; + +const makeMock = (seed: number[] = []): Mock => { + const votes = new Set<number>(seed); + return { + votes, + database: { + prepare: (sql: string) => ({ + bind: (...values: unknown[]) => ({ + run: async () => { + if (sql.includes('INTO c4p_votes')) votes.add(values[1] as number); + else if (sql.startsWith('DELETE')) + votes.delete(values[1] as number); + }, + all: async <T>() => { + if (sql.includes('ticket_tiers')) + return { results: [{ budget: 3 }] as T[] }; + if (sql.includes('FROM talks')) return { results: talks as T[] }; + if (sql.includes('FROM c4p_votes')) + return { + results: [...votes].map((id) => ({ talk_id: id })) as T[], + }; + return { results: [] as T[] }; + }, + }), + }), + }, + }; +}; + +// Default to 'development' so the X-Dev-User bypass (fail closed, honored only when ENVIRONMENT is +// exactly 'development') is active for tests that exercise it. +const makeEnv = (environment: string = 'development'): Env => + ({ ENVIRONMENT: environment }) as Env; + +const getReq = (userId?: string): Request => + new Request('http://localhost/api/vote', { + method: 'GET', + headers: userId ? { 'X-Dev-User': userId } : {}, + }); + +const postReq = ( + body: unknown, + init: { userId?: string; contentType?: string | null } = {} +): Request => { + const headers: Record<string, string> = {}; + if (init.contentType !== null) + headers['Content-Type'] = init.contentType ?? 'application/json'; + if (init.userId) headers['X-Dev-User'] = init.userId; + return new Request('http://localhost/api/vote', { + method: 'POST', + headers, + body: JSON.stringify(body), + }); +}; + +describe('configs.vote', async () => { + await it('isVotingOpen is true before the deadline, false after', () => { + assert.equal(isVotingOpen(new Date('2026-01-01T00:00:00Z')), true); + assert.equal(isVotingOpen(new Date('2099-01-01T00:00:00Z')), false); + }); +}); + +describe('helpers.session', async () => { + await it('round-trips a signed session carrying the budget', async () => { + const token = await signSession('user-1', 5, 'secret'); + assert.deepEqual(await verifySession(token, 'secret'), { + userId: 'user-1', + budget: 5, + }); + }); + + await it('rejects a wrong secret and a tampered token', async () => { + const token = await signSession('user-1', 5, 'secret'); + assert.equal(await verifySession(token, 'other'), null); + assert.equal(await verifySession(`${token}x`, 'secret'), null); + }); + + await it('rejects an expired session', async () => { + const token = await signSession('user-1', 5, 'secret', -1); + assert.equal(await verifySession(token, 'secret'), null); + }); +}); + +describe('repositories.vote.budgetForTier', async () => { + await it('returns the tier budget from ticket_tiers', async () => { + const budget = await vote(makeMock().database).budgetForTier('Standard'); + assert.equal(budget, 3); + }); + + await it('defaults to 1 for a tier not in the overrides table', async () => { + const db: Database = { + prepare: () => ({ + bind: () => ({ + run: async () => {}, + all: async <T>() => ({ results: [] as T[] }), + }), + }), + }; + assert.equal(await vote(db).budgetForTier('Nope'), 1); + }); +}); + +describe('routes.authMe', async () => { + await it('returns 401 without a session', async () => { + const res = await routes.authMe({ + request: new Request('http://localhost/api/vote/me'), + cors, + env: makeEnv(), + }); + assert.equal(res.status, 401); + }); + + await it('returns the user id from the session (dev header)', async () => { + const res = await routes.authMe({ + request: new Request('http://localhost/api/vote/me', { + headers: { 'X-Dev-User': 'user-1' }, + }), + cors, + env: makeEnv(), + }); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { userId: 'user-1' }); + }); +}); + +describe('routes.authLogout', async () => { + await it('redirects and clears the session cookie', async () => { + const res = await routes.authLogout({ + request: new Request('http://localhost/api/vote/logout'), + env: { ALLOWED_ORIGIN: 'http://site' } as Env, + }); + assert.equal(res.status, 302); + assert.equal(res.headers.get('Location'), 'http://site/vote'); + const cookie = res.headers.get('Set-Cookie') ?? ''; + assert.equal(cookie.includes('vote_session=;'), true); + assert.equal(cookie.includes('Max-Age=0'), true); + }); +}); + +describe('routes.voteGet', async () => { + await it('returns 401 without a session', async () => { + const res = await routes.voteGet({ + request: getReq(), + cors, + database: makeMock().database, + env: makeEnv(), + }); + assert.equal(res.status, 401); + }); + + await it('ignores the dev header in production (no bypass)', async () => { + const res = await routes.voteGet({ + request: getReq('user-1'), + cors, + database: makeMock().database, + env: makeEnv('production'), + }); + assert.equal(res.status, 401); + }); + + await it('ignores the dev header when ENVIRONMENT is unset (fail closed)', async () => { + const res = await routes.voteGet({ + request: getReq('user-1'), + cors, + database: makeMock().database, + env: {} as Env, + }); + assert.equal(res.status, 401); + }); + + await it('returns budget, used, talks and current votes', async () => { + const res = await routes.voteGet({ + request: getReq('user-1'), + cors, + database: makeMock([1]).database, + env: makeEnv(), + }); + assert.equal(res.status, 200); + const body = (await res.json()) as { + budget: number; + used: number; + talks: unknown[]; + myVotes: number[]; + }; + assert.equal(body.budget, 3); + assert.equal(body.used, 1); + assert.equal(body.talks.length, 2); + assert.deepEqual(body.myVotes, [1]); + }); +}); + +describe('routes.voteSubmit', async () => { + await it('records a vote', async () => { + const mock = makeMock(); + const res = await routes.voteSubmit({ + request: postReq({ talkId: 2 }, { userId: 'user-1' }), + cors, + database: mock.database, + env: makeEnv(), + }); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { success: true }); + assert.equal(mock.votes.has(2), true); + }); + + await it('rejects with 422 when budget is exhausted', async () => { + const mock = makeMock([1, 2, 3]); + const res = await routes.voteSubmit({ + request: postReq({ talkId: 4 }, { userId: 'user-1' }), + cors, + database: mock.database, + env: makeEnv(), + }); + assert.equal(res.status, 422); + assert.deepEqual(await res.json(), { error: 'Vote limit reached.' }); + assert.equal(mock.votes.has(4), false); + }); + + await it('is idempotent when re-voting an existing talk at the limit', async () => { + const mock = makeMock([1, 2, 3]); + const res = await routes.voteSubmit({ + request: postReq({ talkId: 1 }, { userId: 'user-1' }), + cors, + database: mock.database, + env: makeEnv(), + }); + assert.equal(res.status, 200); + assert.equal(mock.votes.size, 3); + }); + + await it('removes a vote', async () => { + const mock = makeMock([1, 2]); + const res = await routes.voteSubmit({ + request: postReq({ talkId: 1, action: 'remove' }, { userId: 'user-1' }), + cors, + database: mock.database, + env: makeEnv(), + }); + assert.equal(res.status, 200); + assert.equal(mock.votes.has(1), false); + }); + + await it('returns 401 without a session', async () => { + const res = await routes.voteSubmit({ + request: postReq({ talkId: 1 }), + cors, + database: makeMock().database, + env: makeEnv(), + }); + assert.equal(res.status, 401); + }); + + await it('returns 415 for non-JSON content type', async () => { + const res = await routes.voteSubmit({ + request: postReq( + { talkId: 1 }, + { userId: 'user-1', contentType: 'text/plain' } + ), + cors, + database: makeMock().database, + env: makeEnv(), + }); + assert.equal(res.status, 415); + }); + + await it('returns 422 for an invalid body', async () => { + const res = await routes.voteSubmit({ + request: postReq({ nope: true }, { userId: 'user-1' }), + cors, + database: makeMock().database, + env: makeEnv(), + }); + assert.equal(res.status, 422); + }); +});