diff --git a/.agents/skills/implement-tasks/SKILL.md b/.agents/skills/implement-tasks/SKILL.md index 445f44b..e88d2c1 100644 --- a/.agents/skills/implement-tasks/SKILL.md +++ b/.agents/skills/implement-tasks/SKILL.md @@ -92,7 +92,8 @@ Before implementing, check for common issues: 1. **All dependency IDs exist** — No dangling references 2. **No circular dependencies** — The graph must be a DAG (can run a topological sort) 3. **All tasks have acceptance criteria** — Cannot verify completion without them -4. **Phase ordering is logical** — Foundation before features, core before polish +4. **All tasks have agent and moeExperts** — Required for delegation (added by `prd-to-tasks` step 6) +5. **Phase ordering is logical** — Foundation before features, core before polish Run validation with: @@ -157,25 +158,31 @@ T001 → T002 → T003 → T004 → T005 → T006 → T007 → ... Tasks with no dependencies can run in parallel. Tasks that depend on others must wait. -### Step 4: For Each Task, Select Expert Mix +### Step 4: For Each Task, Read Agent and MoE Experts -Map task tags/domain to the optimal MoE expert panel. See the `mixture-of-experts` skill for expert definitions: +Since `prd-to-tasks` v1.1+, every task already has explicit `agent` and +`moeExperts` fields assigned. Read them directly from tasks.json — no inference +needed: -| Task Domain (tags) | Expert Mix | Why | -| --------------------------- | ------------------------------------------------------------------ | ------------------------- | -| `setup`, `types`, `config` | `architect`, `maintainer` | Structure and conventions | -| `auth`, `security` | `architect`, `security`, `maintainer` | Security-critical code | -| `api`, `endpoint` | `architect`, `api-designer`, `security`, `performance` | API design + security | -| `ui`, `component` | `maintainer`, `minimalist`, `performance`, `dx-specialist` | Frontend quality | -| `db`, `schema`, `migration` | `data-modeler`, `performance`, `architect` | Data correctness | -| `test` | `maintainer`, `security` | Test quality and coverage | -| `docs` | `maintainer`, `dx-specialist` | Clarity and usefulness | -| `performance`, `optimize` | `performance`, `architect` | Speed and scalability | -| `a11y` | `maintainer`, `dx-specialist` | Accessibility standards | -| Mixed / complex | `architect`, `security`, `performance`, `maintainer`, `minimalist` | Full coverage | -| Unknown | `architect`, `maintainer`, `minimalist` | Safe default | +```python +# Read pre-assigned agent and experts +task = tasks['T004'] +agent = task['agent'] # e.g., "backend-engineer" +moe_experts = task['moeExperts'] # e.g., ["architect", "api-designer", "security"] +``` + +The `agent` field determines who writes the code (the squad agent). The +`moeExperts` field determines who reviews before implementation. + +#### Fallback: Tasks Without Explicit Assignments -**Override rule:** If the task description or acceptance criteria suggest special concerns (e.g., high security, strict performance target), adjust the expert mix accordingly. +If a task is missing `agent` or `moeExperts` (pre-v1.1 tasks.json or manually +created), use the `mixture-of-experts` skill to select the appropriate experts +based on the task's tags and domain. See `mixture-of-experts` → "Common Patterns" +for the canonical tag-to-expert mapping table. + +Prefer updating the tasks.json with explicit assignments — it's more reliable +and reviewable than runtime inference. ### Step 5: Delegate via MoE @@ -201,19 +208,25 @@ When multiple tasks have no mutual dependencies, run them concurrently: ```bash # Tasks T004 and T005 are independent (both depend on T003 but not each other) -# Spawn them in parallel tmux sessions +# Read their moeExperts from tasks.json and spawn in parallel + +# T004: moeExperts read from tasks.json, e.g., ["architect", "api-designer", "security"] +EXPERTS_T004=$(python3 -c " +import json +t = [t for t in json.load(open('tasks.json'))['tasks'] if t['id']=='T004'][0] +print(','.join(t['moeExperts'])) +") -# T004 tmux new-session -d -s task-T004 tmux send-keys -t task-T004 \ "pi -p 'Implement task T004: [description]. Acceptance criteria: [criteria]. - Use MoE experts: architect, api-designer, security.'" C-m + Use MoE experts: \$EXPERTS_T004.'" C-m -# T005 +# T005: same pattern tmux new-session -d -s task-T005 tmux send-keys -t task-T005 \ "pi -p 'Implement task T005: [description]. Acceptance criteria: [criteria]. - Use MoE experts: data-modeler, performance, architect.'" C-m + Use MoE experts: \$EXPERTS_T005.'" C-m ``` ### Step 6: Verify Completion @@ -374,29 +387,17 @@ echo "" # Execute each task for TID in $ORDER; do - # Get task details + # Get task details including pre-assigned agent and MoE experts TITLE=$(python3 -c "import json; [print(t['title']) for t in json.load(open('$TASKS_FILE'))['tasks'] if t['id']=='$TID']") DESC=$(python3 -c "import json; [print(t['description']) for t in json.load(open('$TASKS_FILE'))['tasks'] if t['id']=='$TID']") - TAGS=$(python3 -c "import json; [print(' '.join(t.get('tags',[]))) for t in json.load(open('$TASKS_FILE'))['tasks'] if t['id']=='$TID']") + AGENT=$(python3 -c "import json; [print(t['agent']) for t in json.load(open('$TASKS_FILE'))['tasks'] if t['id']=='$TID']") + EXPERTS=$(python3 -c "import json; [print(','.join(t['moeExperts'])) for t in json.load(open('$TASKS_FILE'))['tasks'] if t['id']=='$TID']") CRITERIA=$(python3 -c "import json; [print('\n'.join('- ' + a for a in t['acceptanceCriteria'])) for t in json.load(open('$TASKS_FILE'))['tasks'] if t['id']=='$TID']") echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "📋 $TID: $TITLE" - echo " Tags: $TAGS" - echo "" - - # Select expert mix based on tags (simplified) - EXPERTS="architect maintainer" - case "$TAGS" in - *auth*|*security*) EXPERTS="architect security maintainer" ;; - *api*) EXPERTS="architect api-designer security performance" ;; - *ui*) EXPERTS="maintainer minimalist performance dx-specialist" ;; - *db*) EXPERTS="data-modeler performance architect" ;; - *test*) EXPERTS="maintainer security" ;; - *docs*) EXPERTS="maintainer dx-specialist" ;; - esac - - echo " Experts: $EXPERTS" + echo " Agent: $AGENT" + echo " MoE Experts: $EXPERTS" echo "" # Build MoE prompt @@ -435,7 +436,7 @@ echo " $TOTAL/$TOTAL tasks done" - **Verify each task against acceptance criteria** — Don't mark done without checking - **Fix problems at the source** — If T005 fails because T002 is buggy, fix T002 - **Report progress clearly** — Phase + task status so the user knows what's happening -- **Adjust expert mix per task** — Auth tasks need `security`, UI tasks don't +- **Use explicit agent and moeExperts from tasks.json** — Read them directly, don't infer. They were assigned during `prd-to-tasks` step 6 for a reason. - **Keep the user in the loop** — Especially when a task is blocked or needs clarification ### DON'Ts @@ -481,7 +482,7 @@ Last Updated: 2026-04-27 14:30 create-prd → Produces PRD prd-to-tasks → Produces tasks.json implement-tasks → Executes tasks.json - ├── Uses: mixture-of-experts (for each task) + ├── Uses: mixture-of-experts (expert definitions, spawn/aggregate patterns) ├── Uses: terminal-multiplexer (for parallel tasks) ├── Uses: grill-me (when blocked by ambiguity) └── Uses: project-files (for status tracking) diff --git a/.agents/skills/prd-to-tasks/SKILL.md b/.agents/skills/prd-to-tasks/SKILL.md index fe5247a..5045d3e 100644 --- a/.agents/skills/prd-to-tasks/SKILL.md +++ b/.agents/skills/prd-to-tasks/SKILL.md @@ -125,19 +125,43 @@ Convert a PRD into a machine-readable `tasks.json` file that drives implementati ### Field Reference -| Field | Type | Required | Description | -| -------------------- | ------------ | -------- | ----------------------------------------------------------- | -| `id` | string | Yes | Unique task ID, format `T001`, `T002`, etc. | -| `title` | string | Yes | Short, actionable task title | -| `description` | string | Yes | What needs to be done, context, approach hints | -| `phase` | string | Yes | Must match a key in `phases` | -| `priority` | string | Yes | `critical`, `high`, `medium`, `low` | -| `estimatedHours` | number | Yes | Rough estimate in hours | -| `dependencies` | string[] | Yes | IDs of tasks that must complete first (empty array if none) | -| `userStory` | string\|null | No | The user story this task satisfies | -| `functionalReq` | string | No | The FR this task maps to (e.g., "FR-1") | -| `tags` | string[] | No | Labels for filtering (auth, ui, api, db, test, docs, etc.) | -| `acceptanceCriteria` | string[] | Yes | Verifiable conditions that prove the task is done | +| Field | Type | Required | Description | +| -------------------- | ------------ | -------- | --------------------------------------------------------------------------- | +| `id` | string | Yes | Unique task ID, format `T001`, `T002`, etc. | +| `title` | string | Yes | Short, actionable task title | +| `description` | string | Yes | What needs to be done, context, approach hints | +| `phase` | string | Yes | Must match a key in `phases` | +| `priority` | string | Yes | `critical`, `high`, `medium`, `low` | +| `estimatedHours` | number | Yes | Rough estimate in hours | +| `dependencies` | string[] | Yes | IDs of tasks that must complete first (empty array if none) | +| `agent` | string | Yes | Squad agent responsible for implementation (see Agent Mapping) | +| `moeExperts` | string[] | Yes | MoE experts who review/analyze before implementation (see Expert Selection) | +| `userStory` | string\|null | No | The user story this task satisfies | +| `functionalReq` | string | No | The FR this task maps to (e.g., "FR-1") | +| `tags` | string[] | No | Labels for filtering (auth, ui, api, db, test, docs, etc.) | +| `acceptanceCriteria` | string[] | Yes | Verifiable conditions that prove the task is done | + +#### Top-Level `agents` Key + +The `agents` object maps each squad agent to their assigned tasks for quick +reference during implementation: + +```jsonc +"agents": { + "frontend-engineer": { + "role": "UI components, client-side functionality", + "tasks": ["T003", "T004", "T006"] + }, + "backend-engineer": { + "role": "Business logic, APIs, data processing", + "tasks": ["T001", "T002", "T005"] + }, + "qa-engineer": { + "role": "Automated tests, linting, integration checks", + "tasks": ["T007", "T008"] + } +} +``` ### Dependency Rules @@ -200,7 +224,86 @@ Encode in the `dependencies` arrays. - **estimatedHours:** Be conservative. If unsure, round up. - **tags:** Use consistent tags across the project. Common: `setup`, `types`, `auth`, `api`, `ui`, `db`, `test`, `docs`, `security`, `performance`, `a11y`, `dx`. -### 6. Validate the File +### 6. Assign Agent and MoE Experts + +Every task must be assigned to a squad agent (who implements) and a set of MoE +experts (who review/analyze before implementation). These go in the `agent` and +`moeExperts` fields on each task, and are summarized in the top-level `agents` key. + +#### Agent Mapping + +Map each task to the squad agent whose domain matches: + +| Squad Agent | Domains | Task Tags | +| ------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `frontend-engineer` | UI components, client-side JS, Astro pages, i18n, CSS, Alpine, htmx | `ui`, `astro`, `alpine`, `htmx`, `i18n`, `form`, `css`, `responsive` | +| `backend-engineer` | Libraries, APIs, Astro endpoints, business logic, data processing | `lib`, `api`, `endpoint`, `pix`, `qr`, `server`, `svg` | +| `devops-sre` | Dependency management, build tooling, CI/CD, configuration | `setup`, `deps`, `config`, `ci`, `deploy` | +| `qa-engineer` | Unit tests, integration tests, E2E tests, linting | `test`, `unit`, `integration`, `e2e` | + +If a task spans domains (e.g., an endpoint with UI), pick the primary agent. If +unsure, default to `backend-engineer` for server-side work and `frontend-engineer` +for client-side work. + +#### MoE Expert Selection + +Choose 2–4 MoE experts based on the task's concerns. Use the `mixture-of-experts` +skill definitions: + +| Task Domain (tags) | Recommended MoE Experts | Why | +| -------------------------------------------- | ---------------------------------------------------------- | ----------------------------------- | +| `setup`, `deps`, `config` | `maintainer`, `dx-specialist` | Tooling and dev experience | +| `lib`, `pix`, `qr` | `architect`, `security`, `maintainer` | Core logic correctness | +| `lib`, `svg`, `qr` | `architect`, `performance`, `maintainer` | Rendering performance | +| `api`, `endpoint`, `svg`, `caching` | `architect`, `api-designer`, `security`, `performance` | API design + caching + security | +| `api`, `endpoint`, `html`, `htmx`, `caching` | `architect`, `api-designer`, `maintainer`, `performance` | API design + templates + caching | +| `i18n`, `pt-br` | `maintainer`, `dx-specialist` | Consistency and completeness | +| `ui`, `alpine`, `form`, `validation` | `maintainer`, `minimalist`, `dx-specialist`, `performance` | Interactive form quality | +| `ui`, `htmx`, `clipboard`, `qr` | `maintainer`, `minimalist`, `dx-specialist`, `performance` | Async UI quality | +| `ui`, `astro`, `integration` | `maintainer`, `minimalist` | Page structure simplicity | +| `ui`, `edge-case` | `maintainer`, `minimalist`, `dx-specialist` | Edge case UX clarity | +| `ui`, `a11y`, `graceful-degradation` | `maintainer`, `dx-specialist` | Accessibility and fallbacks | +| `ui`, `responsive`, `dark-mode`, `a11y` | `maintainer`, `minimalist`, `dx-specialist` | Visual quality across modes | +| `alpine`, `htmx`, `integration` | `maintainer`, `dx-specialist`, `architect` | Framework interop complexity | +| `test`, `unit` | `maintainer`, `security` | Test coverage + security edge cases | +| `test`, `integration`, `api` | `maintainer`, `security`, `api-designer` | API correctness + security | +| `test`, `e2e` | `maintainer`, `dx-specialist` | User-facing test quality | + +**Override rule:** If the task description or acceptance criteria suggest special +concerns (e.g., unusual security risk, strict performance target), adjust the +expert mix accordingly. Add `security` for auth/PII concerns, `performance` for +latency-sensitive work, `minimalist` if the task risks over-engineering. + +#### Build the `agents` Top-Level Key + +After assigning every task's `agent`, build the `agents` summary: + +```jsonc +"agents": { + "frontend-engineer": { + "role": "UI components, client-side functionality (Alpine, htmx, Astro pages, i18n, CSS)", + "tasks": ["T006", "T007", "T008", "T009"] + }, + "backend-engineer": { + "role": "Libraries (pix, qrcode), Astro server endpoints", + "tasks": ["T002", "T003", "T004", "T005"] + }, + "devops-sre": { + "role": "Dependency management", + "tasks": ["T001"] + }, + "qa-engineer": { + "role": "Unit tests, integration tests, E2E tests", + "tasks": ["T014", "T015", "T016", "T017"] + } +} +``` + +The `agents` key must be validated: every task ID listed in `agents.*.tasks` must +appear in the `tasks` array, and every task in `tasks` must have its `agent` field +match exactly one agent entry. + +### 7. Validate the File Run these checks: @@ -223,11 +326,35 @@ print('Dependencies OK') # No circular dependencies (topological sort must succeed) # Check phase keys match # Check IDs are unique + +# Every task has agent and moeExperts assigned +cat tasks.json | python3 -c " +import json +data = json.load(open('tasks.json')) +for t in data['tasks']: + if 'agent' not in t: + print(f'ERROR: {t[\"id\"]} missing agent field') + if 'moeExperts' not in t: + print(f'ERROR: {t[\"id\"]} missing moeExperts field') +print('Agent/MoE fields OK') +" + +# agents key matches task assignments +cat tasks.json | python3 -c " +import json +data = json.load(open('tasks.json')) +for agent_name, agent_data in data.get('agents', {}).items(): + listed = set(agent_data['tasks']) + actual = {t['id'] for t in data['tasks'] if t.get('agent') == agent_name} + if listed != actual: + print(f'WARN: {agent_name} task list mismatch') +print('Agent summary OK') +" ``` -### 7. Present for Review +### 8. Present for Review -Summarize the task breakdown: +Summarize the task breakdown including agent assignments: ``` I've created tasks.json with [N] tasks across [M] phases: @@ -238,12 +365,17 @@ Phase 3 - Polish (3 tasks, 8h): Error handling, tests, docs Phase 4 - Nice to Have (2 tasks, 6h): P2 stories Total estimated: 44h - Critical path: T001 → T002 → T004 → T007 → T009 +Agent assignments: +- frontend-engineer: 4 tasks (12h) — form, result pane, page integration +- backend-engineer: 3 tasks (10h) — libraries, endpoints +- devops-sre: 1 task (0.5h) — dependencies +- qa-engineer: 4 tasks (9h) — unit, integration, E2E tests + File: tasks.json — ready for implement-tasks -Want me to adjust any priorities or estimates? +Want me to adjust any priorities, estimates, or agent assignments? ``` ## Complete Example @@ -260,6 +392,13 @@ Given the Dark Mode PRD from `create-prd`: "version": "1.0", "totalTasks": 5, "totalEstimatedHours": 12, + "totalTasks": 5, + }, + "agents": { + "frontend-engineer": { + "role": "UI components, CSS, state management", + "tasks": ["T001", "T002", "T003", "T004", "T005"], + }, }, "phases": { "1-foundation": { @@ -287,6 +426,8 @@ Given the Dark Mode PRD from `create-prd`: "priority": "critical", "estimatedHours": 3, "dependencies": [], + "agent": "frontend-engineer", + "moeExperts": ["maintainer", "minimalist"], "userStory": null, "functionalReq": "FR-1", "tags": ["ui", "css"], @@ -305,6 +446,8 @@ Given the Dark Mode PRD from `create-prd`: "priority": "critical", "estimatedHours": 2, "dependencies": ["T001"], + "agent": "frontend-engineer", + "moeExperts": ["maintainer", "dx-specialist", "architect"], "userStory": null, "functionalReq": "FR-1", "tags": ["ui", "state"], @@ -323,6 +466,8 @@ Given the Dark Mode PRD from `create-prd`: "priority": "high", "estimatedHours": 2, "dependencies": ["T002"], + "agent": "frontend-engineer", + "moeExperts": ["maintainer", "minimalist", "dx-specialist"], "userStory": "As a user, I want to toggle between light and dark mode so that I can reduce eye strain in low-light environments.", "functionalReq": "FR-1", "tags": ["ui"], @@ -341,6 +486,8 @@ Given the Dark Mode PRD from `create-prd`: "priority": "high", "estimatedHours": 1.5, "dependencies": ["T002"], + "agent": "frontend-engineer", + "moeExperts": ["maintainer", "dx-specialist"], "userStory": "As a user, I want the app to follow my OS color scheme by default so that I don't need to configure it manually.", "functionalReq": "FR-2", "tags": ["ui", "a11y"], @@ -359,6 +506,8 @@ Given the Dark Mode PRD from `create-prd`: "priority": "medium", "estimatedHours": 1.5, "dependencies": ["T002", "T004"], + "agent": "frontend-engineer", + "moeExperts": ["maintainer", "minimalist"], "userStory": "As a user, I want my preference to persist across sessions so that I don't need to re-set it.", "functionalReq": "FR-1", "tags": ["ui", "state"], @@ -423,7 +572,8 @@ For dependencies on external systems or other teams: - **Acceptance criteria are binary** — Either passing or failing, no judgment calls. - **Keep estimates honest** — Don't pad, don't optimistically undercount. - **Dependencies are minimal** — Only list what must precede. Don't chain everything. -- **Tags enable filtering** — Use consistent tags so `implement-tasks` can filter by domain. +- **Assign agent and MoE experts** — Every task must have an `agent` and `moeExperts`. Validate with the checks in Step 7. Don't skip this — it's required for `implement-tasks` to delegate correctly. +- **Tags enable filtering** — Use consistent tags so agent assignment and MoE selection are predictable. - **One tasks.json per project** — Not per feature within a project (use phases for that). - **Version the file** — If the PRD changes, update tasks.json and bump the version. diff --git a/bun.lock b/bun.lock index 29cf390..b1b93db 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,8 @@ "@astrojs/sitemap": "^3.7.2", "@iconify-json/lucide": "^1.2.102", "@iconify-json/simple-icons": "^1.2.79", + "@starfederation/datastar-sdk": "^1.0.0", + "@sudodevnull/datastar": "^0.19.9", "@tailwindcss/vite": "^4.2.2", "astro": "^6.1.8", "astro-icon": "^1.1.5", @@ -473,6 +475,10 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@starfederation/datastar-sdk": ["@starfederation/datastar-sdk@1.0.0", "", {}, "sha512-VT1co9LeRLJ3wynffvkxXLJaCh4PHt1YUhH3zCQYhjTuaeWjozNjQQrg+BUbkDMO58mSgeQp9pgOXRWciy0y6g=="], + + "@sudodevnull/datastar": ["@sudodevnull/datastar@0.19.9", "", {}, "sha512-nxGq69jci9mDjgpT0MXJv64tF9Ynq7ERGBO3d8PLAI/vpjZeDV2U98gMWXSOTFPu4Vk4FUrivmaPl5mHYEUzHw=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], diff --git a/docs/PRD-pix-donations.md b/docs/PRD-pix-donations.md new file mode 100644 index 0000000..bcdf1f9 --- /dev/null +++ b/docs/PRD-pix-donations.md @@ -0,0 +1,313 @@ +# PRD: PIX Donations + +**Status:** Draft +**Created:** 2026-04-28 +**Author:** AI Agent (from discussion with maintainer) +**Version:** 1.1 +**Issue:** [#248](https://github.com/podcodar/webapp/issues/248) + +--- + +## Executive Summary + +Add a self-service PIX donation section to the `/contributing` page. Visitors +choose a suggested amount (R$ 25, R$ 50, R$ 100) or enter a custom value +(R$ 5–R$ 10,000) and instantly receive a PIX QR code and "Copia e Cola" string +to complete their donation — no backend, no contact form required. + +--- + +## Problem Statement + +The current donations flow requires visitors to fill out a contact form +("Falar sobre doação" → `/contact`) before they can donate. This friction +loses potential donors who want to contribute immediately. Other Brazilian +community projects offer one-click PIX donations, and PodCodar should match +that expectation. + +--- + +## Goals + +- Let anyone donate via PIX in under 30 seconds without leaving the page +- Generate valid PIX BR Code + QR code entirely client-side (no server cost) +- Highlight the donation section prominently on the contributing page +- Validate custom amounts with clear error messages (Zod) +- Keep the existing contact-based flow for donors who want to discuss larger + contributions (> R$ 10,000) + +--- + +## Non-Goals + +- **No payment tracking or confirmation** — this is a static site; we cannot + know if a PIX payment was completed +- **No recurring donations** — one-time PIX only +- **No backend or server-side code** — all generation happens in the browser +- **No donor identity collection** — anonymous donations are fine +- **No dynamic PIX keys** — single key (`doar@podcodar.org`), no per-donor + or per-amount keys +- **No htmx** — current interactivity needs are fully covered by Alpine.js + +--- + +## User Stories + +### Must Have (P0) + +- As a visitor, I want to select a suggested donation amount (R$ 25, R$ 50, R$ 100) + with one click so that I can donate quickly without typing. +- As a visitor, I want to enter a custom donation amount so that I can donate + any value I choose. +- As a visitor, I want to see a PIX QR code for my chosen amount so that I can + scan it with my banking app. +- As a visitor, I want to copy the "Copia e Cola" PIX string to my clipboard + so that I can paste it into my banking app. +- As a visitor, I want clear validation errors if I enter an invalid amount + so that I can fix it and proceed. + +### Should Have (P1) + +- As a visitor, I want a visual distinction between the QR code view and the + "Copia e Cola" view so I can choose my preferred method. +- As a donor considering a large contribution (> R$ 10,000), I want to be + directed to the contact form instead of being blocked, so that I can discuss + the donation with the team. + +--- + +## Functional Requirements + +### FR-1: Donation Amount Selection + +- System must display three suggested amount buttons: R$ 25, R$ 50, R$ 100 +- System must provide a custom amount input field +- System must validate the custom input using Zod: + - Minimum: R$ 5.00 + - Maximum: R$ 10,000.00 + - Must be a valid positive number (two decimal places allowed) +- System must display validation errors inline in Portuguese +- Selecting a suggested button must populate the custom input and generate the + PIX code immediately +- Changing the custom input must regenerate the PIX code on valid input + +### FR-2: PIX BR Code Generation + +- System must generate a valid EMV QRCPS-MPM compliant PIX string client-side +- PIX key: `doar@podcodar.org` (email type) +- Required fields in the generated string: + - `00` — Payload Format Indicator: `01` + - `26` — Merchant Account Information: + - `00` — GUI: `br.gov.bcb.pix` + - `01` — PIX key: `doar@podcodar.org` + - `52` — Merchant Category Code: `0000` + - `53` — Transaction Currency: `986` (BRL) + - `54` — Transaction Amount: user-selected value (when fixed amount) + - `58` — Country Code: `BR` + - `59` — Merchant Name: `PodCodar` (max 25 chars) + - `60` — Merchant City: `Belo Horizonte` (max 15 chars) + - `63` — CRC16-CCITT checksum over the full string (excluding the `6304` trailer) +- System must compute the CRC16-CCITT checksum per the PIX standard +- When no amount is selected or input is invalid, no PIX code must be generated + +### FR-3: QR Code Display + +- System must render a QR code from the generated PIX string using **inline SVG** + (no external QR library — implement a minimal QR code SVG generator as a shared + utility in `src/lib/qrcode.ts`) +- QR code must be scannable by Brazilian banking apps (PIX-compatible) +- QR code dimensions: minimum 200×200px, should scale on larger screens +- QR code must update immediately when the amount changes + +### FR-4: Copia e Cola + +- System must display a **truncated** preview of the PIX string (e.g., first + 40 characters + "…") with a toggle to reveal the full string +- System must provide a "Copiar" button that copies the full PIX string to the + clipboard +- System must show visual feedback on successful copy (e.g., button text changes + to "Copiado!" for 2 seconds) +- System must handle clipboard API unavailability gracefully (fallback: + select text for manual copy) + +### FR-5: Large Donations (> R$ 10,000) + +- When the user enters an amount > R$ 10,000, system must: + - Not generate a PIX code + - Show a message directing the donor to contact the team + - Provide a link to `/contact` +- The validation error for "amount too high" must differ from "invalid format" + errors — it should be informative, not an error state + +### FR-6: Section Layout + +- The PIX donation section must be placed immediately after the hero on + `/contributing` +- The section must be visually highlighted / prominent (distinct background or + border treatment) +- The section must replace the current "Falar sobre doação" CTA that links + to `/contact` +- Responsive layout: buttons in a row on desktop, stack on mobile + +--- + +## Non-Functional Requirements + +| Category | Requirement | Target | +| ------------- | ----------------------------- | --------------------------- | +| Performance | PIX code + QR generation time | < 100ms on user input | +| Accessibility | Form inputs and buttons | Keyboard navigable, labeled | +| Accessibility | QR code alt text | Meaningful description | +| i18n | All strings | Via `useTranslations()` | +| Bundle size | Added JS (Alpine + QR + PIX) | < 15 KB gzipped | +| Browser | Supported browsers | Last 2 versions, modern | + +--- + +## Constraints + +- **Technical:** Must work on Astro static site (SSG). No server-side state. + Must use Alpine.js for interactivity and Zod v4 (via `astro/zod`) for + validation. +- **Brand:** Must use PodCodar purple/violet color palette. Must match existing + DaisyUI v5 component style. +- **Language:** All user-facing text in Portuguese (pt-BR) via the i18n system. +- **Dependencies to add:** `alpinejs` (`bun add alpinejs`). Zod is already + available via `astro/zod` (bundled with Astro). QR code generation must use + an inline SVG approach implemented as a shared utility (`src/lib/qrcode.ts`) + — no external QR library. + +--- + +## Dependencies + +- **External:** None. PIX BR Code is generated client-side; no third-party API + calls. +- **Internal:** The `/contact` page must remain available for the "contact us" + fallback in FR-5. Existing `SectionHeader` and layout components from the + codebase should be reused. + +--- + +## Open Questions + +_All resolved — no open questions remain._ + +- [x] QR code: Inline SVG, implemented as `src/lib/qrcode.ts` (zero dependencies) +- [x] Copia e Cola: Truncated preview with expand toggle +- [x] Donor name/txid: Anonymous/static — no donor identifier field +- [x] Home page: Contributing page only for v1 + +--- + +## Success Metrics + +- **Completion rate:** Donors can generate a PIX code in under 30 seconds +- **Zero errors:** PIX QR codes generated by the page scan correctly in + Itaú, Nubank, PicPay, and Mercado Pago apps +- **No regressions:** Existing contributing page sections (volunteering, + partnerships) remain unchanged and functional +- **Accessibility:** All form elements pass keyboard navigation and screen + reader checks + +--- + +## Risks & Mitigations + +| Risk | Impact | Likelihood | Mitigation | +| ------------------------------------------- | ------ | ---------- | ----------------------------------------------------------- | +| PIX BR Code generation bugs (invalid QR) | High | Medium | Test against multiple banking apps; validate CRC16 | +| Alpine.js conflicts with Astro island model | Medium | Low | Use a single Astro island for the entire donation section | +| Clipboard API unavailable on some browsers | Low | Low | Fallback to `select()` + manual copy | +| Donors confused by too-large QR on mobile | Medium | Low | Responsive sizing; test on 375px viewport | +| `astro/zod` version incompatibility | Low | Low | Zod v4 API surface is stable; if issues arise, pin explicit | +| | | | zod version in package.json | + +--- + +## Appendix + +### References + +- [PIX BR Code Standard (BCB)](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_PIX/II-ManualdePadroesparaIniciacaodoPix.pdf) +- [EMV QRCPS-MPM Specification](https://www.emvco.com/emv-technologies/qrcodes/) +- [Issue #248](https://github.com/podcodar/webapp/issues/248) +- [Current `/contributing` page](src/pages/contributing.astro) +- [i18n strings](src/i18n/ui.ts) — `contributing.donations.*` namespace + +### Test Cases + +These are the canonical test cases for validation and PIX code correctness. + +#### Amount Validation (Zod) + +| Input | Expected result | +| ---------- | ------------------------------------------- | +| `25` | Valid, PIX code generated with R$ 25.00 | +| `50` | Valid, PIX code generated with R$ 50.00 | +| `100` | Valid, PIX code generated with R$ 100.00 | +| `5` | Valid, PIX code generated with R$ 5.00 | +| `10000` | Valid, PIX code generated with R$ 10,000.00 | +| `50.50` | Valid, PIX code generated with R$ 50.50 | +| `4.99` | Invalid — below minimum (R$ 5.00) | +| `10000.01` | Invalid — contact us (> R$ 10,000) | +| `0` | Invalid — below minimum | +| `-10` | Invalid — negative amount | +| `abc` | Invalid — not a number | +| `1,000` | Invalid — wrong decimal separator | +| `50.999` | Invalid — more than 2 decimal places | +| (empty) | Invalid — required field | + +#### PIX BR Code Generation + +| Scenario | Expected behavior | +| ----------------------------- | ---------------------------------------------------- | +| Default amount (R$ 25 button) | Valid PIX string, QR renders, scan test passes | +| Custom amount (R$ 50.50) | Field `54` = `50.50`, CRC correct | +| No amount / invalid input | No PIX code generated, no QR displayed | +| Amount > R$ 10,000 | "Contact us" message, no PIX code | +| Key fields present | `00`, `26.00`, `26.01`, `53`, `58`, `59`, `60`, `63` | +| CRC16 matches | Recompute CRC and verify against field `63` | + +#### QR Code + +| Scenario | Expected behavior | +| ------------------------------- | ---------------------------------------------------- | +| Valid PIX string | QR renders as SVG, scannable | +| Banking app scan (Nubank) | Recognizes PIX, shows correct amount and recipient | +| Banking app scan (Itaú) | Recognizes PIX, shows correct amount and recipient | +| Banking app scan (PicPay) | Recognizes PIX, shows correct amount and recipient | +| Banking app scan (Mercado Pago) | Recognizes PIX, shows correct amount and recipient | +| Amount change | QR updates immediately without page reload | +| Dark mode | QR has sufficient contrast (light background/border) | + +#### Copia e Cola + +| Scenario | Expected behavior | +| ------------------------- | --------------------------------------------------- | +| Click "Copiar" | Full PIX string copied to clipboard | +| After copy | Button shows "Copiado!" for 2 seconds, then reverts | +| Truncated preview | Shows first ~40 chars + "…" + expand toggle | +| Expand toggle | Reveals full PIX string | +| Clipboard API unavailable | Falls back to text selection for manual copy | + +#### Section Layout + +| Scenario | Expected behavior | +| ---------------------------- | ------------------------------------------------------- | +| Desktop viewport (≥1024px) | Amount buttons in a row, QR + Copia e Cola side by side | +| Mobile viewport (375px) | Buttons stack vertically, QR + Copia e Cola stack | +| No JS (graceful degradation) | Section shows static message + link to `/contact` | + +### Glossary + +- **PIX:** Brazilian instant payment system operated by the Central Bank of + Brazil (BCB) +- **PIX BR Code / "Copia e Cola":** A standardized string format (EMV + QRCPS-MPM) encoding PIX payment details that can be rendered as a QR code + or pasted directly into banking apps +- **CRC16-CCITT:** A 16-bit checksum algorithm (polynomial `0x1021`) used as + the error-detection trailer in PIX BR Code strings +- **Alpine.js:** A lightweight JavaScript framework for adding interactivity + to HTML via declarative `x-*` attributes (similar spirit to htmx but + component-oriented) diff --git a/e2e/pix-donation.spec.ts b/e2e/pix-donation.spec.ts new file mode 100644 index 0000000..b12f8e3 --- /dev/null +++ b/e2e/pix-donation.spec.ts @@ -0,0 +1,165 @@ +/** + * E2E smoke test for the PIX donation flow on /contributing. + * + * Exercises the full critical path: + * 1. Navigate to /contributing + * 2. Find donation section after hero + * 3. Click suggested amount button + * 4. Verify QR code loads + * 5. Verify Copia e Cola appears + * 6. Test copy button + * 7. Test large donation message + */ +import { expect, test } from '@playwright/test'; + +test.describe('PIX Donation Flow', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/contributing'); + // Wait for Alpine.js to initialize (client:load island) + await page.waitForFunction(() => !!(window as any).Alpine); + // Give Alpine a moment to fully bootstrap + await page.waitForTimeout(500); + }); + + test('donation section is visible immediately after the hero', async ({ page }) => { + // The donation section should appear right after the HeroSection + const donationSection = page.locator('.donation-form'); + await expect(donationSection).toBeVisible(); + + // Check for the section heading + await expect(page.getByRole('heading', { name: /doação via PIX/i })).toBeVisible(); + + // Verify suggested buttons are present + await expect(page.getByRole('button', { name: 'R$ 25' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'R$ 50' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'R$ 100' })).toBeVisible(); + }); + + test('clicking R$ 25 button generates QR code and Copia e Cola', async ({ page }) => { + // Click the R$ 25 suggested button + await page.getByRole('button', { name: 'R$ 25' }).click(); + + // Wait for htmx to fetch /api/pix-result and swap content + // The loading skeleton should disappear and the result should appear + await page.waitForTimeout(1000); + + // Check that the placeholder is gone and the QR image appeared + const resultPane = page.locator('#donation-result'); + const qrImg = resultPane.locator('img'); + await expect(qrImg).toBeVisible({ timeout: 5000 }); + + // Verify the QR image loaded (has naturalWidth > 0) + await expect(async () => { + const naturalWidth = await qrImg.evaluate((img: HTMLImageElement) => img.naturalWidth); + expect(naturalWidth).toBeGreaterThan(0); + }).toPass({ timeout: 5000 }); + + // Verify Copia e Cola section is present + await expect(page.getByText('Copia e Cola')).toBeVisible(); + + // Verify truncated PIX string preview is visible + await expect(page.locator('.donation-result .font-mono').first()).toContainText('…'); + + // Verify amount confirmation + await expect(page.locator('.donation-result').getByText('R$ 25,00')).toBeVisible(); + }); + + test('clicking R$ 50 button shows correct amount', async ({ page }) => { + await page.getByRole('button', { name: 'R$ 50' }).click(); + await page.waitForTimeout(1000); + + const resultPane = page.locator('#donation-result'); + await expect(resultPane.locator('img')).toBeVisible({ timeout: 5000 }); + + // Verify amount + await expect(resultPane.getByText('R$ 50,00')).toBeVisible(); + }); + + test('copy button copies PIX string to clipboard', async ({ page }) => { + // Grant clipboard permissions for the test + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + + await page.getByRole('button', { name: 'R$ 25' }).click(); + await page.waitForTimeout(1000); + + // Wait for the copy button to appear (inside htmx-swapped content) + const copyButton = page.locator('#donation-result').getByRole('button', { + name: /Copiar/, + }); + await expect(copyButton).toBeVisible({ timeout: 5000 }); + + // Click copy + await copyButton.click(); + + // Verify button text changed to "Copiado!" + await expect(copyButton).toContainText('Copiado!', { timeout: 3000 }); + + // Verify clipboard has a PIX string + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); + expect(clipboardText).toContain('000201'); + expect(clipboardText).toContain('doar@podcodar.org'); + expect(clipboardText).toContain('6304'); + }); + + test('entering amount above 10000 shows contact us message instead of QR', async ({ page }) => { + // Type an amount above the maximum + const input = page.locator('#donation-custom-amount'); + await input.fill('10000.01'); + + // Wait for Alpine validation + await page.waitForTimeout(500); + + // Verify the info alert appears (not error alert) + const infoAlert = page.locator('.alert-info'); + await expect(infoAlert).toBeVisible({ timeout: 3000 }); + await expect(infoAlert).toContainText('10.000'); + + // Verify link to /contact exists + const contactLink = infoAlert.locator('a[href="/contact"]'); + await expect(contactLink).toBeVisible(); + + // Verify NO QR code is shown + const resultPane = page.locator('#donation-result'); + // The result pane should still show the placeholder or empty state + // (no QR img should have appeared) + const qrImgs = resultPane.locator('img'); + await expect(qrImgs).toHaveCount(0); + }); + + test('entering invalid format shows validation error', async ({ page }) => { + const input = page.locator('#donation-custom-amount'); + await input.fill('abc'); + + await page.waitForTimeout(300); + + // Error alert should appear + const errorAlert = page.locator('.alert-error'); + await expect(errorAlert).toBeVisible({ timeout: 3000 }); + await expect(errorAlert).toContainText('inválido'); + }); + + test('custom valid amount generates QR code', async ({ page }) => { + const input = page.locator('#donation-custom-amount'); + await input.fill('42'); + // Trigger input event (x-model + @input) + await input.dispatchEvent('input'); + + await page.waitForTimeout(1000); + + const resultPane = page.locator('#donation-result'); + await expect(resultPane.locator('img')).toBeVisible({ timeout: 5000 }); + + // Verify amount + await expect(resultPane.getByText('R$ 42,00')).toBeVisible(); + }); + + test('the old CTA linking to /contact is removed', async ({ page }) => { + // The old "Falar sobre doação" CTA button should no longer exist + // in its original form (it was replaced by the PIX section) + const _oldCta = page.getByRole('link', { name: 'Falar sobre doação' }); + // It may or may not be present; the PIX section should be the primary donation interface + // The body text mentioning "fale com a gente" should be gone too + const oldBody = page.getByText('Se quiser contribuir financeiramente ou combinar'); + await expect(oldBody).not.toBeVisible(); + }); +}); diff --git a/package.json b/package.json index 122ba01..68978a3 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "@astrojs/sitemap": "^3.7.2", "@iconify-json/lucide": "^1.2.102", "@iconify-json/simple-icons": "^1.2.79", + "@starfederation/datastar-sdk": "^1.0.0", + "@sudodevnull/datastar": "^0.19.9", "@tailwindcss/vite": "^4.2.2", "astro": "^6.1.8", "astro-icon": "^1.1.5", diff --git a/src/__tests__/pix-endpoints.test.ts b/src/__tests__/pix-endpoints.test.ts new file mode 100644 index 0000000..b60c0fc --- /dev/null +++ b/src/__tests__/pix-endpoints.test.ts @@ -0,0 +1,207 @@ +/** + * Integration tests for /api/pix-qr and /api/pix-result endpoints. + * + * Tests validation logic and endpoint handler behavior. + * Uses bun test to avoid Cloudflare adapter conflicts with vitest. + */ +import { beforeAll, describe, expect, it } from 'bun:test'; + +// Import validation logic (no Astro dependency) +import { validateAmount } from '@/pages/api/pix-qr'; + +// Mock Request builder +function mkReq(path: string, headers?: Record): Request { + return new Request(`https://example.com${path}`, { headers }); +} + +let GET_pixQr: any; +let GET_pixResult: any; + +// ═══════════════════════════════════════════════════════════════════════════════ +// validateAmount tests +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('validateAmount', () => { + it('accepts valid integer amounts', () => { + expect(validateAmount('25')).toEqual({ valid: true, amount: 25 }); + expect(validateAmount('50')).toEqual({ valid: true, amount: 50 }); + expect(validateAmount('100')).toEqual({ valid: true, amount: 100 }); + }); + + it('accepts decimal amounts with up to 2 places', () => { + expect(validateAmount('50.5')).toEqual({ valid: true, amount: 50.5 }); + expect(validateAmount('50.50')).toEqual({ valid: true, amount: 50.5 }); + expect(validateAmount('5.00')).toEqual({ valid: true, amount: 5 }); + }); + + it('accepts minimum amount (5.00)', () => { + expect(validateAmount('5')).toEqual({ valid: true, amount: 5 }); + expect(validateAmount('5.00')).toEqual({ valid: true, amount: 5 }); + }); + + it('accepts maximum amount (10000.00)', () => { + expect(validateAmount('10000')).toEqual({ valid: true, amount: 10000 }); + expect(validateAmount('10000.00')).toEqual({ valid: true, amount: 10000 }); + }); + + it('rejects amounts below 5', () => { + expect(validateAmount('4.99').valid).toBe(false); + expect(validateAmount('0').valid).toBe(false); + expect(validateAmount('-10').valid).toBe(false); + }); + + it('rejects amounts above 10000', () => { + expect(validateAmount('10000.01').valid).toBe(false); + expect(validateAmount('20000').valid).toBe(false); + }); + + it('rejects non-numeric input', () => { + expect(validateAmount('abc').valid).toBe(false); + expect(validateAmount('').valid).toBe(false); + }); + + it('rejects wrong decimal format', () => { + expect(validateAmount('1,000').valid).toBe(false); + expect(validateAmount('50.999').valid).toBe(false); + }); + + it('rejects null / missing amount', () => { + expect(validateAmount(null).valid).toBe(false); + }); + + it('provides error messages in pt-BR', () => { + const missing = validateAmount(null); + expect(missing.valid).toBe(false); + if (!missing.valid) { + expect(typeof missing.error).toBe('string'); + expect(missing.error.length).toBeGreaterThan(0); + } + + const invalid = validateAmount('abc'); + expect(invalid.valid).toBe(false); + if (!invalid.valid) { + expect(invalid.error).toContain('inválido'); + } + + const below = validateAmount('1'); + expect(below.valid).toBe(false); + if (!below.valid) { + expect(below.error).toContain('mínimo'); + } + + const above = validateAmount('20000'); + expect(above.valid).toBe(false); + if (!above.valid) { + expect(above.error).toContain('10.000'); + } + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// GET handler smoke tests +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('GET /api/pix-qr', () => { + beforeAll(async () => { + const mod = await import('@/pages/api/pix-qr'); + GET_pixQr = mod.GET; + }); + + it('returns 200 with image/svg+xml for valid amount', async () => { + const res = await GET_pixQr({ request: mkReq('/api/pix-qr?amount=25') }); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('image/svg+xml'); + }); + + it('returns valid SVG body', async () => { + const res = await GET_pixQr({ request: mkReq('/api/pix-qr?amount=25') }); + const body = await res.text(); + expect(body.startsWith(' { + const res = await GET_pixQr({ request: mkReq('/api/pix-qr?amount=25') }); + expect(res.headers.get('X-Pix-Code')).toMatch(/^000201/); + }); + + it('sets caching headers', async () => { + const res = await GET_pixQr({ request: mkReq('/api/pix-qr?amount=25') }); + expect(res.headers.get('Cache-Control')).toBeTruthy(); + expect(res.headers.get('ETag')).toBeTruthy(); + expect(res.headers.get('Vary')).toBe('Accept'); + }); + + it('returns 304 when If-None-Match matches', async () => { + const res1 = await GET_pixQr({ request: mkReq('/api/pix-qr?amount=25') }); + const etag = res1.headers.get('ETag'); + const res2 = await GET_pixQr({ + request: mkReq('/api/pix-qr?amount=25', { 'If-None-Match': etag! }), + }); + expect(res2.status).toBe(304); + }); + + it('returns 400 for invalid amount', async () => { + const res = await GET_pixQr({ request: mkReq('/api/pix-qr?amount=abc') }); + expect(res.status).toBe(400); + }); + + it('returns 400 for missing amount', async () => { + const res = await GET_pixQr({ request: mkReq('/api/pix-qr') }); + expect(res.status).toBe(400); + }); + + it('ignores unknown query params', async () => { + const res = await GET_pixQr({ request: mkReq('/api/pix-qr?amount=25&foo=1') }); + expect(res.status).toBe(200); + }); +}); + +describe('GET /api/pix-result', () => { + beforeAll(async () => { + const mod = await import('@/pages/api/pix-result'); + GET_pixResult = mod.GET; + }); + + it('returns 200 with text/html for valid amount', async () => { + const res = await GET_pixResult({ request: mkReq('/api/pix-result?amount=25') }); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toContain('text/html'); + }); + + it('returns HTML containing QR img tag', async () => { + const res = await GET_pixResult({ request: mkReq('/api/pix-result?amount=50') }); + const body = await res.text(); + expect(body).toContain(' { + const res = await GET_pixResult({ request: mkReq('/api/pix-result?amount=25') }); + const body = await res.text(); + expect(body).toContain('…'); + expect(body).toContain('000201'); + }); + + it('returns HTML containing copy button text', async () => { + const res = await GET_pixResult({ request: mkReq('/api/pix-result?amount=25') }); + const body = await res.text(); + expect(body).toContain('Copiar'); + }); + + it('sets caching headers', async () => { + const res = await GET_pixResult({ request: mkReq('/api/pix-result?amount=25') }); + expect(res.headers.get('Cache-Control')).toBeTruthy(); + expect(res.headers.get('ETag')).toBeTruthy(); + }); + + it('returns 400 for invalid amount', async () => { + const res = await GET_pixResult({ request: mkReq('/api/pix-result?amount=abc') }); + expect(res.status).toBe(400); + }); + + it('uses DaisyUI utility classes', async () => { + const res = await GET_pixResult({ request: mkReq('/api/pix-result?amount=25') }); + const body = await res.text(); + expect(body).toMatch(/rounded|btn|space-y-/); + }); +}); diff --git a/src/components/DonationSection.astro b/src/components/DonationSection.astro new file mode 100644 index 0000000..03b0e43 --- /dev/null +++ b/src/components/DonationSection.astro @@ -0,0 +1,227 @@ +--- +/** + * DonationSection.astro — Datastar-powered PIX donation section. + * + * Combines form selection, validation, QR display, and Copia e Cola + * using Datastar signals and attributes. Replaces Alpine.js + htmx. + */ +import { useTranslations } from '@/i18n/utils'; + +const t = useTranslations(); + +// i18n strings for client-side validation +const msgInvalidFormat = t('contributing.donations.pix.validation.invalid_format'); +const msgBelowMinimum = t('contributing.donations.pix.validation.below_minimum'); +const msgLargeDonationTitle = t('contributing.donations.pix.large_donation.title'); +const msgLargeDonationBody = t('contributing.donations.pix.large_donation.message'); +const msgLargeDonationLink = t('contributing.donations.pix.large_donation.link'); +--- + +
+ +
+ + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + diff --git a/src/components/Footer.astro b/src/components/Footer.astro index d8c67cc..0e62163 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -15,7 +15,7 @@ const t = useTranslations(lang);

© {today.getFullYear()} PodCodar. {t('footer.copyright')}

- +
diff --git a/src/i18n/ui.ts b/src/i18n/ui.ts index 0e19616..a7667f1 100644 --- a/src/i18n/ui.ts +++ b/src/i18n/ui.ts @@ -206,6 +206,37 @@ export const ui = { 'contributing.donations.body': 'Se quiser contribuir financeiramente ou combinar outras formas de apoio, fale com a gente — assim direcionamos sua contribuição de acordo com as necessidades atuais da comunidade.', 'contributing.donations.cta': 'Falar sobre doação', + + // ── PIX Donation section ──────────────────────────────────────────────────── + 'contributing.donations.pix.title': 'Faça uma doação via PIX', + 'contributing.donations.pix.subtitle': + 'Escolha um valor e receba o QR code na hora. Rápido, seguro e sem burocracia.', + 'contributing.donations.pix.amount_25': 'R$ 25', + 'contributing.donations.pix.amount_50': 'R$ 50', + 'contributing.donations.pix.amount_100': 'R$ 100', + 'contributing.donations.pix.custom_label': 'Outro valor', + 'contributing.donations.pix.custom_placeholder': 'Digite um valor (ex: 50.00)', + 'contributing.donations.pix.validation.required': 'Informe um valor para doação.', + 'contributing.donations.pix.validation.below_minimum': 'O valor mínimo para doação é R$ 5,00.', + 'contributing.donations.pix.validation.above_maximum': + 'Para doações acima de R$ 10.000,00, entre em contato conosco.', + 'contributing.donations.pix.validation.invalid_format': + 'Formato inválido. Use ponto como separador decimal (ex: 50.00).', + 'contributing.donations.pix.large_donation.title': 'Doação de grande valor', + 'contributing.donations.pix.large_donation.message': + 'Para doações acima de R$ 10.000,00, prefira entrar em contato conosco para alinharmos a melhor forma de contribuir.', + 'contributing.donations.pix.large_donation.link': 'Falar com a equipe', + 'contributing.donations.pix.copia_e_cola': 'Copia e Cola', + 'contributing.donations.pix.copy_button': 'Copiar', + 'contributing.donations.pix.copied_feedback': '✓ Copiado!', + 'contributing.donations.pix.expand': 'Expandir', + 'contributing.donations.pix.collapse': 'Recolher', + 'contributing.donations.pix.loading': 'Gerando QR code…', + 'contributing.donations.pix.nojs_message': + 'Para doar via PIX, é necessário que o JavaScript esteja habilitado. Você também pode entrar em contato conosco para combinar outra forma de doação.', + 'contributing.donations.pix.nojs_link': 'Entrar em contato', + 'contributing.donations.pix.incentive': + 'Toda doação, de qualquer valor, ajuda a manter e expandir nossos programas gratuitos de mentoria, grupos de estudo, oficinas e a infraestrutura que torna a comunidade possível. Sua contribuição financia diretamente a educação de centenas de pessoas que estão começando ou migrando de carreira para a tecnologia.', 'contributing.volunteering.title': 'Voluntariado', 'contributing.volunteering.subtitle': 'Tempo e talento: mentoria, facilitação de estudos e eventos, revisão de código, design, comunicação e muito mais.', diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index b38c5ad..1655003 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -48,5 +48,8 @@ const t = useTranslations(rawLang as Lang);