Quota Portal Implementation Tasks¶
This document provides a sequenced, comprehensive task list for AI-assisted implementation of the HNS Quota Portal (Symfony Twig + Alpine.js frontend) — the self-service web interface where quota holders manage their ticket allocations, claim tickets, and delegate to sub-recipients.
Overview¶
| Attribute | Value |
|---|---|
| Technology | PHP 8.2+ / Symfony 7.x / Twig |
| CSS | SCSS (imported from HNS Drupal theme hns_theme) |
| Interactivity | Alpine.js (reactive UI state, multi-step forms) |
| Build | Gulp (SCSS compilation, JS minification) |
| Fonts | Hustle (headings), Maven Pro (body) — same as Drupal webshop |
| Authentication | Drupal SSO (same credentials as HNS mobile app) |
| API Backend | Ticketing Backend — /api/v1/quota-portal/* endpoints |
| Portal Spec | Quota Portal |
| Epic | E7 - Quota Management |
| API Spec | API Specification |
| Design Source | HNS Drupal Theme (hns_theme) — header, footer, forms, colors |
Technology Decisions
Why SCSS instead of Tailwind? The HNS Drupal webshop (shop.hns.family) uses
SCSS with CSS custom properties and the Gorko utility framework. To ensure visual
consistency, the Quota Portal imports the same design tokens (colors, spacing, fonts,
form styles) from the Drupal theme rather than introducing a separate design system.
Why Alpine.js instead of jQuery? The Drupal site uses jQuery + Drupal.behaviors,
but the Quota Portal is a standalone Symfony app with complex interactive requirements
(multi-step claiming wizard, dynamic card selection, real-time polling, Stripe integration).
Alpine.js provides declarative reactivity in Twig templates without a full SPA framework,
and is a better fit for these state-heavy interactions than jQuery.
Portal Responsibilities¶
The Quota Portal serves quota holders (sponsors, partners, officials, fan groups) for:
- Viewing allocated quotas and seat-level detail per match
- Claiming tickets through a guided 3-step process (select → holder info → payment)
- Delegating tickets to sub-recipients via subquota creation (if permitted)
- Managing and retracting unclaimed subquotas
- Viewing claimed ticket history
Related Task Files¶
| File | Scope |
|---|---|
| Ticketing Backend Tasks | Backend API implementation |
| E7 Quota Management Tasks | Backend quota API tasks |
| Admin Portal Tasks | Admin-side quota management UI |
User Flows¶
| Flow | Description |
|---|---|
| Quota Holder Web Delegation | Primary flow for web portal usage |
| Mobile Quota Claiming | Mobile app claiming (shared API) |
| Admin Quota Creation | How quotas are created by admin |
Task Legend¶
| Symbol | Status |
|---|---|
| ⬜ | Not started |
| 🟡 | In progress |
| ✅ | Complete |
| ❌ | Blocked |
Implementation Sequence¶
Tasks are organized in dependency order:
- Phase 0: Docker Infrastructure — Docker setup, environment variables, networking, auth flow
- Phase 1: Foundation — Symfony project, design system import, layout (header/footer from Drupal)
- Phase 2: Dashboard & Quota Detail — My Quotas overview, quota detail view
- Phase 3: Ticket Claiming — 3-step claiming flow (select, holder info, payment)
- Phase 4: Subquota Management — Create, list, retract subquotas
- Phase 5: My Tickets & Polish — Claimed ticket history, error states, real-time sync
- Phase 6: Testing — Infrastructure, functional, access control, browser, integration
Using with Ralph Wiggum (Autonomous AI Loop)¶
This task list is designed for iterative AI implementation using the Ralph Wiggum plugin with RED/GREEN TDD.
TDD Workflow (Red/Green/Refactor)¶
For every task, follow this cycle:
- RED: Write a failing test that asserts the verification criteria
- GREEN: Write the minimum code to make the test pass
- REFACTOR: Clean up while keeping tests green
- VERIFY: Run full suite:
docker-compose exec -T quota-php ./vendor/bin/phpunit - UPDATE: Mark task ⬜ → ✅ in this file
Test Directory Structure¶
tests/
├── Phase0/ # Docker connectivity smoke tests
├── Phase1/ # Auth, layout, component rendering
│ ├── AuthTest.php
│ ├── LayoutTest.php
│ └── ComponentTest.php
├── Phase2/ # Dashboard, quota detail
│ ├── DashboardTest.php
│ └── QuotaDetailTest.php
├── Phase3/ # 3-step claiming wizard
│ ├── SeatSelectionTest.php
│ ├── HolderInfoTest.php
│ ├── PaymentTest.php
│ └── Validation/
│ ├── OibChecksumTest.php
│ ├── BlacklistTest.php
│ └── MinorDetectionTest.php
├── Phase4/ # Subquota CRUD
│ ├── CreateSubquotaTest.php
│ └── RetractSubquotaTest.php
├── Phase5/ # Edge cases, error states
│ ├── ErrorStatesTest.php
│ ├── CartTimeoutTest.php
│ └── RefundDisplayTest.php
└── Browser/ # Playwright end-to-end tests
├── LoginFlowTest.php
├── ClaimingFlowTest.php
└── SubquotaFlowTest.php
Quick Start¶
# Full implementation (all phases)
# See COMMANDS.md for the complete ralph-loop command
# Per-phase execution
# See COMMANDS.md for per-phase ralph-loop commands with TDD instructions
Progress Tracking¶
The AI should maintain PROGRESS.md in the project root:
# Implementation Progress
## Current State
- **Component:** Quota Portal
- **Phase:** 0
- **Section:** 0.1 Docker Setup
- **Task:** Create Dockerfile
- **Iteration:** 1
- **Tests:** 0 passing, 0 failing
- **Last Updated:** 2026-03-19T10:00:00Z
Phase 0: Docker Infrastructure & Environment¶
0.1 Docker Setup¶
Architecture Pattern
The Quota Portal follows the same Docker pattern as the Admin Portal (hns-admin-portal):
PHP 8.4-FPM + Nginx reverse proxy, connecting to the Ticketing Backend API over
Docker networking. The portal runs on port 8002 (backend: 8000, admin: 8001).
Docker Compose Configuration¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Create Dockerfile based on php:8.4-fpm with extensions: intl, zip, opcache, redis |
docker build succeeds |
| ✅ | Create docker/nginx/default.conf routing static files and FastCGI to PHP-FPM |
Nginx proxies to php:9000 |
| ✅ | Create docker-compose.yml with services: quota-php, nginx |
docker-compose up -d starts both |
| ✅ | Expose Nginx on port 8003 (8003:80) |
Portal accessible at http://localhost:8003 |
| ✅ | Mount project directory to /var/www/html in both php and nginx containers |
Code changes reflected without rebuild |
| ✅ | Create hns-quota-portal internal bridge network |
Internal network created |
| ✅ | Connect to ticketing backend network as external: hns-ticketing-backend_hns-ticketing |
Portal can reach backend API |
| ✅ | Create docker/docker-entrypoint.sh that runs composer install and cache:clear on startup |
Dependencies installed on first run |
| ✅ | Create docker-compose-nebion.yml for deployment (no ports, Traefik-compatible) |
Deployment config ready |
| ✅ | Create .nebion.yml with post-rollout tasks (composer install, cache clear) |
Nebion deployment configured |
Makefile¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Create Makefile with make start / make stop targets |
Start/stop with single command |
| ✅ | Add make start-all to start backend + quota portal together |
All services start |
| ✅ | Add make stop-all to stop entire stack |
All services stop |
| ✅ | Add make setup for first-time setup (stop, start, install deps, build assets) |
Fresh setup works |
| ✅ | Add make shell to open bash in PHP container |
Shell accessible |
| ✅ | Add make build to compile SCSS assets via Gulp |
Assets compile |
| ✅ | Add make dev to start Gulp watch mode for SCSS development |
SCSS recompiles on save |
| ✅ | Add make cache-clear to clear Symfony cache |
Cache cleared |
| ✅ | Add make info to show container status and URLs |
Status displayed |
0.2 Environment Variables¶
.env (Base Defaults — committed)¶
APP_ENV=dev
APP_SECRET=<generate-unique-secret>
DEFAULT_URI=http://localhost:8003
# ─── Ticketing Backend API ───
BACKEND_API_URL=http://hns-ticketing-nginx:80/api/v1
# ─── Drupal SSO Authentication ───
DRUPAL_SSO_URL=http://host.docker.internal:8060
DRUPAL_API_PREFIX=/167ef06c8e2d572663138d287700485f
# ─── Stripe (for paid quota claiming — same test keys as Drupal webshop) ───
STRIPE_PUBLISHABLE_KEY=pk_test_51NIXAJIkFtloCtI1u6vqpR0XhnXQHt9L6bmza9QqoNPUG1umNKeE5MsMJYweJqpEm3m9vMm2Oeidjq7D5ZoYkdTa008atiaAF8
STRIPE_SECRET_KEY=sk_test_51NIXAJIkFtloCtI1o1guvyf2tGHCMYBHURT9yzRc08WSZq0PZJwLug6o4XkXoeTjfWuw6m2tMrj5EMYeNXBfFo6R00lA7nJb4I
STRIPE_WEBHOOK_SECRET=whsec_placeholder
# ─── Logging ───
LOKI_PUSH_URL=
LOKI_PUSH_USER=
LOKI_PUSH_PASSWORD=
Docker Networking Notes
BACKEND_API_URLuseshns-ticketing-nginx— the backend's Nginx container name reachable via the sharedhns-ticketing-backend_hns-ticketingexternal network.DRUPAL_SSO_URLuseshost.docker.internalon macOS/Windows to reach Drupal running on port 8060 from inside the Docker container. On Linux, use the host IP or create a shared Docker network with Drupal.DRUPAL_API_PREFIXis the hard-coded JSON:API base path from Drupal'sweb/sites/default/all.services.yml.
.nebion.env (Staging/Production — committed)¶
APP_ENV=prod
BACKEND_API_URL=https://back.hnst.dev.wsagency.io/api/v1/
DRUPAL_SSO_URL=https://shop.hns.family
DRUPAL_API_PREFIX=/167ef06c8e2d572663138d287700485f
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
DEFAULT_URI=https://quota.hnst.dev.wsagency.io
Environment Variable Reference¶
| Variable | Purpose | Dev Value | Prod Value |
|---|---|---|---|
APP_ENV |
Symfony environment | dev |
prod |
APP_SECRET |
CSRF/cookie signing | Random hex | Unique per env |
BACKEND_API_URL |
Ticketing Backend API base URL | http://hns-ticketing-nginx:80/api/v1 |
https://back.hnst.dev.wsagency.io/api/v1/ |
DRUPAL_SSO_URL |
Drupal webshop for SSO validation | http://host.docker.internal:8060 |
https://shop.hns.family |
DRUPAL_API_PREFIX |
Drupal JSON:API path prefix | /167ef06c8e2d572663138d287700485f |
Same |
STRIPE_PUBLISHABLE_KEY |
Stripe frontend key (for Payment Element) | pk_test_51NIXA... (same as Drupal) |
Production key |
STRIPE_SECRET_KEY |
Stripe backend key (for PaymentIntent creation) | sk_test_51NIXA... (same as Drupal) |
Production key |
STRIPE_WEBHOOK_SECRET |
Stripe webhook signing secret | whsec_placeholder |
Production secret |
DEFAULT_URI |
Portal's own URL (for redirects) | http://localhost:8002 |
https://quota.hnst.dev.wsagency.io |
LOKI_PUSH_URL |
Grafana Loki endpoint (optional) | Empty (disabled) | Loki URL |
Environment Tasks¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Create .env with all variables listed above |
File exists with defaults |
| ✅ | Create .nebion.env with production values |
File exists |
| ✅ | Add .env.local to .gitignore (for local secret overrides) |
Not committed |
| ✅ | Configure config/services.yaml to inject BACKEND_API_URL, DRUPAL_SSO_URL, DRUPAL_API_PREFIX, STRIPE_PUBLISHABLE_KEY, STRIPE_SECRET_KEY |
Services receive env vars |
| ✅ | Create BackendApiClient service that calls ticketing backend with JWT forwarding |
API calls succeed |
| ⬜ | Create DrupalSsoClient service that validates user credentials against Drupal |
SSO validation works |
| ✅ | Test portal can reach backend: curl http://hns-ticketing-nginx:80/api/v1/health from php container |
Backend reachable |
| ✅ | Test portal can reach Drupal: curl {DRUPAL_SSO_URL}/user/check?_format=json from php container |
Drupal reachable |
0.3 Service Architecture Diagram¶
┌─────────────────────────────────────────────────────────────────────────┐
│ Docker Host (macOS) │
│ │
│ ┌─────────────────────────┐ ┌──────────────────────────────────────┐ │
│ │ hns-quota-portal │ │ hns-ticketing-backend │ │
│ │ (network: hns-quota) │ │ (network: hns-ticketing) │ │
│ │ │ │ │ │
│ │ nginx ──→ php-fpm │ │ nginx:8000 ──→ php ──→ postgres │ │
│ │ :8002 :9000 │ │ └──→ redis │ │
│ │ │ │ └──→ mailpit:8026 │ │
│ └────────┬────────────────┘ └──────────────────────────────────────┘ │
│ │ ▲ │
│ │ external network: │ │
│ │ hns-ticketing-backend_ │ │
│ │ hns-ticketing │ │
│ └────────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ │
│ │ hns (Drupal) │ │
│ │ :8060 (via Traefik) │ ◄── host.docker.internal:8060 │
│ │ nginx → php → mariadb │ (from quota portal container) │
│ └─────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ │
│ │ hns-admin-portal │ │
│ │ :8001 │ │
│ └─────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ │
│ │ hns-ticketing-docs │ │
│ │ :9090 (MkDocs) │ │
│ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
Port Allocation:
| Service | Port | URL |
|---|---|---|
| Ticketing Backend API | 8000 | http://localhost:8000/api/v1 |
| Admin Portal | 8001 | http://localhost:8001/admin |
| Quota Portal | 8002 | http://localhost:8002/quota-portal |
| Drupal Webshop | 8060 | http://localhost:8060 |
| MkDocs (this docs) | 9090 | http://localhost:9090 |
| Mailpit (email viewer) | 8026 | http://localhost:8026 |
| PgAdmin (DB viewer) | 5050 | http://localhost:5050 |
| ntfy (push viewer) | 9091 | http://localhost:9091/hns-push |
0.4 Authentication Flow (Drupal SSO → JWT)¶
How Quota Portal Authenticates
The portal does NOT call Drupal directly for each page load. Instead:
- User enters credentials on portal login page
- Portal sends credentials to Ticketing Backend
POST /api/v1/auth/login - Backend validates against Drupal (Firebase SSO or Basic Auth proxy)
- Backend returns JWT (15-min access token) + refresh token (7-day)
- Portal stores tokens in server-side Symfony session (HTTP-only cookies)
- All subsequent API calls use JWT in
Authorization: Bearer {token}header
The portal talks to one backend — the Ticketing Backend handles all Drupal SSO
complexity internally via DrupalSsoService.
| Status | Task | Verification |
|---|---|---|
| ✅ | Implement BackendAuthService that calls POST /api/v1/auth/login with user credentials |
Login returns JWT |
| ✅ | Support Firebase SSO: forward firebase-token header from portal to backend auth endpoint |
Firebase login works |
| ✅ | Support Basic Auth: forward email + password as JSON to backend auth endpoint | Password login works |
| ✅ | Store access_token, refresh_token, token_expires_at in Symfony session |
Tokens persisted server-side |
| ✅ | Create BackendApiClient HTTP service that auto-attaches Authorization: Bearer header |
API calls authenticated |
| ✅ | Implement token refresh: call POST /api/v1/auth/refresh before access token expires |
Token refreshes transparently |
| ✅ | Handle 401 from backend: clear session and redirect to login | Re-auth flow works |
Phase 1: Foundation¶
1.1 Project Setup¶
Symfony Bundle Configuration¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Create Symfony 8.0 project with Twig templates directory (templates/quota_portal/) |
ls templates/quota_portal/ shows structure |
| ✅ | Set up SCSS build pipeline with Gulp (matching Drupal theme's gulpfile.js pattern) |
npm run start watches SCSS, npm run compile builds |
| ✅ | Install and configure Alpine.js via CDN for reactive components | Alpine x-data directives work in Twig templates |
| ✅ | Create base layout template (base.html.twig) with header, content, footer regions |
Layout renders matching Drupal site structure |
| ✅ | Set up route prefix /quota-portal/* with firewall |
Routes resolve correctly |
| ✅ | Configure CORS for API calls to ticketing backend (/api/v1/quota-portal/*) |
API calls succeed from portal |
Design System Import (from HNS Drupal Theme)¶
Source Theme
All design tokens are imported from the Drupal theme at
hns/web/themes/custom/hns_theme/scss/base/. The Quota Portal must look
like a section of the HNS webshop, not a separate application.
| Status | Task | Verification |
|---|---|---|
| ✅ | Import CSS custom properties from Drupal theme _colors.scss into portal :root |
All --deep-red, --darkblue, --scarlet-red, etc. available |
| ✅ | Import spacing scale from _spacings.scss (--spacing-tiny through --spacing-huge) |
Spacing variables match Drupal site |
| ✅ | Import border radius tokens from _radius.scss (--radius-xs through --radius-large) |
Radius variables available |
| ✅ | Import font weight scale from _font-weight.scss |
Weight variables match |
| ✅ | Import font files: Hustle (headings) from assets/fonts/Hustle/ (woff2, woff, ttf) |
Hustle font renders for h1-h6 |
| ✅ | Import font files: Maven Pro (body) via Google Fonts CDN | Maven Pro renders for body text |
| ✅ | Import typography scale from _typography.scss (clamp-based fluid sizing for h1-h6) |
Heading sizes match Drupal site |
| ✅ | Import form element styles from components/form.scss (inputs, labels, checkboxes, radios) |
Form elements match Drupal site exactly |
| ✅ | Import button styles from components/button/button.scss (data-variant pattern) |
Buttons match Drupal site (darkblue, red, plain, cancel variants) |
| ✅ | Import modal dialog styles from components/modal/modal.scss |
Modals match Drupal site |
| ✅ | Add quota-specific status colors not in Drupal theme (ALLOCATED gray, DELEGATED purple) | Status colors extend palette |
| ✅ | Import SVG icon system — using inline SVGs in Twig templates | Icons render inline |
| ✅ | Set up responsive breakpoints matching Drupal: sm (36em/576px), md (48em/768px), lg (62em/992px) | Breakpoints match |
| ✅ | Create equivalent utility classes (flex, margin, padding, gap utilities) | Utility classes available |
1.2 Authentication (Drupal SSO)¶
Login Flow¶
Authentication Architecture
The Quota Portal uses the same credentials as the HNS mobile app. Authentication flows through Drupal SSO → Ticketing Backend JWT. The backend auth services are set up in Phase 0.4 above. See Drupal Auth Proposal for architecture details.
| Status | Task | Verification |
|---|---|---|
| ✅ | Create login page (/quota-portal/login) matching Drupal webshop login page styling |
Page renders with HNS logo, --darkblue accents |
| ✅ | Implement email + password login form using Drupal form styling (48px inputs, Maven Pro) | Form matches webshop login |
| ✅ | Implement Firebase SSO buttons (Google, Facebook, Apple) — placeholder UI | SSO buttons visible and clickable |
| ✅ | Call POST /api/v1/auth/login with Drupal credentials (Firebase token or Basic Auth) |
Backend returns JWT + refresh token |
| ✅ | Store JWT access token and refresh token in secure HTTP-only session | Tokens stored securely |
| ✅ | Implement token refresh logic (access token TTL: 15 min, refresh: 7 days) | Token refreshes before expiry |
| ✅ | Implement logout with session termination | Logout clears all tokens |
| ✅ | Redirect unauthenticated users to login page | Redirect works for all protected routes |
Email-Based Access Control¶
Access Control
Quota access is email-based, not role-based. The portal only displays quotas
where recipient_email matches the authenticated user's email.
| Status | Task | Verification |
|---|---|---|
| ✅ | Implement BackendAuthenticator that validates JWT and extracts user email |
Email extracted from token |
| ✅ | Create QuotaPortalUser security model with email as identifier |
User model works |
| ✅ | Configure Symfony security firewall for /quota-portal/* routes |
Firewall protects routes |
| ✅ | Implement access check: verify user email has at least one active quota | Users without quotas see "No quotas" message |
| ✅ | Handle deeplink entry (/quota-portal/login?email={email}"a_id={id}) |
Deeplink pre-fills email and redirects after login |
| ✅ | If user already logged in with different email than deeplink, show mismatch notice: "This quota is assigned to {quota_email}. Please login with the correct account." | Mismatch notice shown |
| ✅ | Provide "Login with Different Account" option that logs out and pre-fills correct email | Switch account works |
Session Management¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Implement token refresh interceptor middleware (auto-refresh before expiry) | Token refreshes transparently |
| ✅ | Add session timeout warning (5 min before JWT expiry) | Warning modal appears |
| ✅ | Implement automatic token refresh on user activity | Session extends on interaction |
| ✅ | Handle expired session gracefully (redirect to login with return URL) | User can resume after re-login |
| ✅ | Implement auto-logout after JWT expiry if refresh also expired | User redirected to login |
1.3 Layout & Navigation¶
Header (from Drupal Theme)¶
Shared Header
The header must visually match the HNS webshop (shop.hns.family) header.
Replicate the structure from hns_theme/templates/components/header/region--header.html.twig
with different navigation links appropriate for the Quota Portal.
| Status | Task | Verification |
|---|---|---|
| ✅ | Replicate Drupal header structure: logo (left), navigation (center), user controls (right) | Header matches webshop visually |
| ✅ | Use HNS logo.svg from Drupal theme assets/ |
Same logo as webshop |
| ✅ | Style header with --darkblue (#172641) background, Hustle font for nav items, uppercase |
Header colors/fonts match |
| ✅ | Add portal-specific nav links: My Quotas, My Tickets | Portal links visible |
| ✅ | Add user email display and logout button (right side) | User controls visible |
| ✅ | Implement hamburger menu for mobile (below 991px breakpoint) matching Drupal pattern | Mobile menu works |
| ✅ | Add active state: white text on --vivid-red (#C10000) background for current page |
Active state matches |
| ✅ | Add hover state: --vivid-red text color on nav items |
Hover matches |
Footer (from Drupal Theme)¶
Shared Footer
The footer must visually match the HNS webshop footer.
Replicate the structure from hns_theme/templates/components/footer/region--footer.html.twig.
| Status | Task | Verification |
|---|---|---|
| ✅ | Replicate Drupal footer structure with --blue (#111C30) background |
Footer matches webshop visually |
| ✅ | Include footer sections: Support links, Terms, Payment Methods | Sections present |
| ✅ | Implement mobile accordion pattern for footer sections (below 991px) | Accordion works on mobile |
| ✅ | Style footer links: Maven Pro 0.875rem, hover color --red (#D90429) |
Link styling matches |
Page Layout¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Create .layout-container wrapper matching Drupal theme (max-width: 1800px, centered) |
Layout matches |
| ✅ | Create breadcrumb component (matching Drupal breadcrumb styling) | Breadcrumbs show path |
| ✅ | Create main content area with .stack layout pattern (vertical flex with gap) |
Content area uses stack pattern |
| ✅ | Make layout responsive (desktop + tablet; mobile users directed to app) | Layout adapts to screen size |
Navigation Structure¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Create navigation menu items with icons | Menu renders with icons |
| ✅ | Implement active state highlighting | Current page highlighted |
| ✅ | Show/hide "Create Subquota" based on can_create_subquotas flag (template-ready, data from API) |
Conditional rendering works |
Menu Structure:
Quota Portal
├── My Quotas (Dashboard)
├── My Tickets
└── Logout
Per-quota actions (shown in quota detail):
Quota Detail
├── Allocation Summary
├── Seat List
├── Claim Tickets (3-Step)
├── Subquota List (if can_create_subquotas)
│ ├── Create Subquota
│ └── Retract Subquota
└── Back to Dashboard
1.4 Shared Components¶
Form Components (matching Drupal theme styling)¶
Form Styling Reference
All form elements must match the HNS webshop forms exactly.
Source: hns_theme/scss/components/form.scss
Input specs: 2px solid --greyish-blue (#D7DCE0) border, 3px radius, 48px height,
Maven Pro 500 weight, --darkblue text, --grey placeholder.
Focus: --darkblue border. Error: --red border. Hover: subtle box-shadow.
| Status | Task | Verification |
|---|---|---|
| ✅ | Create text input component matching Drupal form styles (48px height, 2px border, 3px radius) | Input matches webshop |
| ✅ | Create email input component with format validation | Email validation works |
| ✅ | Create date picker component SCSS (Flatpickr styles ready, library to be added in Phase 3) | Date selection works |
| ✅ | Create dropdown/select component SCSS (Choices.js styles ready, library to be added in Phase 3) | Dropdown matches webshop |
| ✅ | Create OIB input with ISO 7064 Mod 11,10 checksum validation (Alpine.js formValidator) | OIB validates correctly |
| ✅ | Create phone input component SCSS (intl-tel-input styles ready, library to be added in Phase 3) | Phone input matches webshop |
| ✅ | Create custom checkbox component matching Drupal: 20x20px, 2px --darkblue border, --red checkmark |
Checkboxes match webshop |
| ✅ | Create custom radio button component matching Drupal: 20px circle, --red center dot |
Radios match webshop |
| ✅ | Create form labels: Maven Pro 500 weight, 0.9rem, --darkblue, red asterisk for required |
Labels match webshop |
| ✅ | Create form validation service (client-side Alpine.js formValidator() with OIB, email, required) |
Validation runs on blur/submit |
| ✅ | Create form error summary component | Errors summarized at top |
| ✅ | Add ARIA labels and accessible attributes to all form controls | Screen reader accessible |
| ✅ | Implement keyboard navigation for all interactive components | Tab/Enter/Escape work |
Data Display Components¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Create data table component with sorting and pagination (styled with Drupal table patterns) | Table renders with controls |
| ✅ | Create responsive data table (horizontal scroll on small screens) | Table usable on tablet |
| ✅ | Create quota card component using Drupal card pattern (white bg, --radius-normal, hover: 2px black outline) |
Cards match webshop product card feel |
| ✅ | Create badge/tag component for statuses (ALLOCATED, RESERVED, SOLD, DELEGATED, CANCELLED) | Badges show correct colors |
| ✅ | Create progress bar component (for status breakdown) | Progress displays |
| ✅ | Create step indicator component (Step 1/2/3 for claiming) | Steps show progress |
| ✅ | Create countdown timer component (for 20-min cart TTL during claiming) | Timer counts down accurately |
| ✅ | Create button variants using data-variant attribute pattern matching Drupal theme |
Buttons use data-variant="darkblue", "red", "plain", "cancel" |
Feedback Components¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Create toast notification system matching Drupal message.scss pattern |
Toasts appear with correct styling |
| ✅ | Create modal dialog component matching Drupal modal (white bg, --athensgray title bar, 860px max-width) |
Modals match webshop |
| ✅ | Create confirmation dialog (retract subquota, dangerous actions) | Confirmation required |
| ✅ | Create loading spinner matching Drupal AJAX spinner (--darkblue + --red rotating gradient) |
Loading states match webshop |
| ✅ | Create empty state component ("No quotas assigned") with --back-grey background |
Empty states render |
| ✅ | Create error state component (API failures, network errors) | Errors displayed gracefully |
Status Color Definitions¶
| Status | Color | Badge |
|---|---|---|
| ALLOCATED | Gray | Available for claiming |
| RESERVED | Blue | Info filled, pending payment |
| SOLD | Green | Completed |
| DELEGATED | Purple | Assigned to subquota |
| CANCELLED | Red | Cancelled by admin |
| Active Quota | Green badge | Quota is active |
| Past Deadline | Yellow badge | Past expiration (still claimable) |
| Fully Claimed | Blue badge | All seats sold/delegated |
| Cancelled Quota | Red badge | Admin cancelled |
| Pending Quota | Gray badge | Created but not yet accessed |
Phase 2: Dashboard & Quota Detail (E7-F4)¶
2.1 My Quotas Dashboard¶
Quota List¶
Reference: E7-F4: Quota Web Portal Dashboard
| Status | Task | Verification |
|---|---|---|
| ✅ | Create dashboard page (/quota-portal/dashboard) |
Page renders |
| ✅ | Call GET /api/v1/quota-portal/dashboard to fetch user's quotas |
API called, data returned |
| ✅ | Display quota cards grouped by match | Cards grouped correctly |
| ✅ | Show match info on each card (teams, date, venue) | Match details visible |
| ✅ | Show allocated sectors on each card (D1, C1, B2, etc.) | Sectors listed |
| ✅ | Show total seat count per quota | Count displayed |
| ✅ | Show status breakdown counters (Allocated, Reserved, Sold, Delegated) with percentages | Breakdown accurate |
| ✅ | Show expiration date with countdown ("5 days remaining") | Countdown visible |
| ✅ | Show quota status badge (Active = green, Past Deadline = yellow, Fully Claimed = blue, Cancelled = red) | Badges colored correctly |
| ✅ | Show discount info if applicable (e.g., "100% Sponsor Discount" or "Free" for GR code) | Discount visible |
| ✅ | Show quota creation date | Date displayed |
| ✅ | Distinguish received subquotas from own quotas (show "Delegated from: {parent name}" if parent_quota_id set) |
Subquota origin visible |
| ✅ | After partial claiming, show updated remaining count ("15 available, 5 claimed") | Remaining count accurate |
Quota Card Actions¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Add "Claim Tickets" button (primary CTA, visible when ALLOCATED seats exist) | Button visible |
| ✅ | Add "View Details" button (always visible) | Button works |
| ✅ | Add "Send tickets to someone else" button (only if can_create_subquotas = TRUE) |
Conditional rendering works |
| ✅ | Hide action buttons for cancelled quotas | Buttons hidden |
| ✅ | Show "Past Deadline" warning banner for expired quotas (still claimable) | Warning visible |
Quota Card Layout¶
Reference Layout
See Quota Portal - Dashboard for the detailed card wireframe layout.
| Status | Task | Verification |
|---|---|---|
| ✅ | Implement responsive card grid (1 column mobile, 2 columns tablet, 3 columns desktop) | Grid adapts |
| ✅ | Implement card hover state with subtle shadow | Hover effect visible |
| ✅ | Sort cards by match kickoff date (upcoming first) | Sort order correct |
| ✅ | Show "No quotas assigned" empty state when user has no quotas | Empty state renders |
2.2 Quota Detail View¶
Allocation Summary¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Create quota detail page (/quota-portal/quotas/{id}) |
Page renders |
| ✅ | Validate quota belongs to logged-in user (email match) | Access denied for wrong user returns 403 |
| ✅ | Show match details header (teams, date, venue, competition) | Info displayed |
| ✅ | Show allocated sectors list | Sectors shown |
| ✅ | Show discount information (percentage or "Free" for GR code) | Discount visible |
| ✅ | Show expiration date with visual reminder | Date shown |
| ✅ | Show transfer permission indicator (YES/NO) with explanation text | Indicator visible |
| ✅ | Show transfer rules help text: "YES = recipients can self-transfer, NO = contact support within 48h before match" | Help text visible |
| ✅ | Show quota creation date | Date displayed |
| ✅ | Show deferred payment indicator if deferred_payment = TRUE |
Indicator visible |
| ✅ | Show "Past Deadline" warning if past expiration date (still claimable until admin cancels) | Warning displayed |
Status Breakdown¶
Reference Layout
See Quota Portal - Status Breakdown for the progress bar wireframe.
| Status | Task | Verification |
|---|---|---|
| ✅ | Create horizontal stacked progress bar showing seat status distribution | Bar renders |
| ✅ | Color segments: gray (Allocated), blue (Reserved), green (Sold), purple (Delegated) | Colors correct |
| ✅ | Show legend with counts below bar | Legend accurate |
| ✅ | Update breakdown on page load from API response seat_status_counts |
Counts match API |
Seat List Table¶
API Limitation
The current GET /quota-portal/dashboard endpoint returns quotas with seat_status_counts
but may not return individual seat details. A separate GET /quota-portal/quotas/{id}/seats
endpoint may be needed — coordinate with backend team.
| Status | Task | Verification |
|---|---|---|
| ✅ | Create seat list data table (Sector, Row, Seat, Status, Assigned To, Claimed Date) | Table renders |
| ✅ | Show seat numbers for numbered matches | Numbers visible |
| ✅ | Color-code status column with badges | Badge colors correct |
| ✅ | Show ticket holder name for SOLD seats | Name displayed |
| ✅ | Show "→ Subquota #{recipient_name}" reference for DELEGATED seats | Reference shown |
| ✅ | Show "Pending..." for RESERVED seats | Pending text visible |
| ✅ | Add sorting by sector, row, seat number | Sorting works |
| ✅ | Add status filter (show only Available, Sold, etc.) | Filter works |
| ✅ | Show CANCELLED seats with strikethrough styling | Cancelled seats visually distinct |
Detail Page Actions¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Add "Claim Tickets" button (navigates to claiming flow) | Button navigates |
| ✅ | Add "Send tickets to someone else" button (if can_create_subquotas = TRUE) |
Conditional button works |
| ✅ | Add "Back to Dashboard" navigation | Back link works |
| ✅ | Create tabbed layout: Details / Seats / Subquotas (if permitted) | Tabs navigate |
2.3 Subquota List (on Quota Detail)¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Show subquota section only if can_create_subquotas = TRUE |
Conditional rendering |
| ✅ | Call GET /api/v1/quota-portal/quotas/{id}/subquotas to fetch subquotas |
API called |
| ✅ | Display subquota table (Recipient, Email, Seats, Status, Created, Actions) | Table renders |
| ✅ | Show subquota status badges (Yellow=ALLOCATED, Blue=RESERVED, Green=SOLD, Red=Cancelled) | Badges colored |
| ✅ | Show seat count per subquota | Count displayed |
| ✅ | Show seat-level status breakdown per subquota (which seats are ALLOCATED/RESERVED/SOLD) | Seat detail visible |
| ✅ | Add "View" action button on each subquota row | Button works |
| ✅ | Add "Retract" action button (only for ALLOCATED subquotas) | Button visible only for ALLOCATED |
| ✅ | Add "Create Subquota" button above table | Button navigates |
| ✅ | Show empty state when no subquotas exist | Empty state renders |
| ✅ | Implement polling refresh for subquota list (detect when recipients claim tickets) | Status updates without page reload |
Phase 3: Ticket Claiming (E7-F5)¶
3.1 Claiming Flow: Step 1 — Select Seats¶
Reference: E7-F5: Quota Claiming Process
Numbered Matches (Seat Selection)¶
Card Grid, Not Seat Map
Tickets are displayed as cards in a grid layout, NOT as a stadium seat map. Each card represents one allocated seat.
| Status | Task | Verification |
|---|---|---|
| ✅ | Create seat selection page (/quota-portal/quotas/{id}/claim/step-1) |
Page renders |
| ✅ | Display step indicator (Step 1 of 3: Select Seats) | Indicator shows |
| ✅ | Fetch available (ALLOCATED) seats from quota detail API | Seats loaded |
| ✅ | Display seats as clickable card grid (sector, row, seat number per card) | Cards displayed |
| ✅ | Implement click-to-select/deselect on each card | Selection toggles |
| ✅ | Highlight selected cards (border color change, checkmark icon) | Selected cards visually distinct |
| ✅ | Gray out unavailable seats (SOLD, DELEGATED, RESERVED) | Unavailable seats grayed |
| ✅ | Group cards by sector with section headers | Grouped by sector |
| ✅ | Show selection summary at bottom ("Selected: 3 seats — Row 5: Seats 1-3") | Summary updates |
| ✅ | Add "Select All Available" button | Selects all ALLOCATED |
| ✅ | Add "Clear Selection" button | Clears all |
| ✅ | Validate at least 1 seat selected before continue | Validation blocks empty |
| ✅ | Add "Back to Quota" link | Navigation works |
| ✅ | Add "Continue to Step 2" button (calls POST /api/v1/quota-portal/quotas/{id}/select-seats) |
API called, seats transition to RESERVED |
| ✅ | Start 20-minute cart countdown timer after seat selection submitted | Timer starts on API success |
| ✅ | Display countdown timer in sticky header/footer across all claiming steps | Timer visible on Steps 1-3 |
Non-Numbered Matches (Quantity Selection)¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Detect non-numbered match type | Detection works |
| ✅ | Show quantity selector instead of card grid | Quantity input shown |
| ✅ | Show available quantity (total ALLOCATED count) | Available count displayed |
| ✅ | Validate quantity between 1 and available | Validation works |
| ✅ | Add increment/decrement buttons | Buttons work |
3.2 Claiming Flow: Step 2 — Ticket Holder Information¶
Per-Ticket Identity Form¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Create holder info page (/quota-portal/quotas/{id}/claim/step-2) |
Page renders |
| ✅ | Display step indicator (Step 2 of 3: Ticket Holder Information) | Indicator shows |
| ✅ | Show important banner: "Email Required: Each ticket will be delivered to the HNS mobile app linked to the email address you provide. All tickets with the same email must enter the stadium together on the same device." | Banner visible |
| ✅ | Show additional note below banner: "If someone doesn't have a mobile phone or email address, use the email of the person who will accompany them and have both tickets on the same device." | Note visible |
| ✅ | Create per-ticket form sections (one form per selected seat) | Forms rendered per seat |
| ✅ | Show seat reference on each form (e.g., "Ticket 1 of 3 — Row 5, Seat 1") | Reference visible |
| ✅ | Implement form fields: Full Name (required), Date of Birth (required), Nationality (required dropdown), OIB (required for Croatians), Passport (required for foreigners), Email (required), Phone (optional) | All fields present |
| ✅ | Pre-fill email with logged-in user's email (editable) | Pre-fill works |
| ✅ | Show OIB field when nationality = Croatia, Passport field otherwise | Conditional fields toggle |
| ✅ | Implement OIB checksum validation (ISO 7064 Mod 11,10) | OIB validates |
| ✅ | Detect duplicate emails across ticket forms and show warning: "Tickets with the same email will be on the same device and must enter the stadium together" | Warning displayed |
| ✅ | Handle blank email field: if left empty, show note "Ticket will remain on your app. You must enter the stadium together with this person." | Blank email handled |
| ✅ | Show email finality warning near email field: "Email assignment is final and cannot be changed after claiming" | Warning visible |
| ✅ | Implement minor detection: if DOB indicates under 18, disable email field | Email disabled for minors |
| ✅ | Show minor restriction message: "Tickets for minors cannot be assigned to a separate email. Minor must enter with accompanying adult." | Message visible |
| ✅ | Implement progress indicator ("2 of 5 tickets completed") | Progress updates |
Saved Profiles¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Add "Select from saved profiles" dropdown at top of each form | Dropdown available |
| ✅ | Populate dropdown from localStorage (previously saved profiles) | Profiles loaded |
| ✅ | Auto-fill form fields when profile selected | Fields populated |
| ✅ | Add "Save as new profile" checkbox | Checkbox available |
| ✅ | Save profile to localStorage on form submit if checked | Profile persisted |
| ✅ | Add "Enter new details" option in dropdown | Option available |
| ✅ | Add "Manage saved profiles" option (view, edit, delete saved profiles) | Profile management works |
Validation & Blacklist¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Implement client-side form validation (required fields, email format, OIB checksum) | Validation runs |
| ✅ | Show inline validation errors per field | Errors shown inline |
| ✅ | Implement silent blacklist check on OIB/passport submission | Check runs in background |
| ✅ | Show exact blacklist rejection message: "HNS is prevented from issuing a ticket to this person pursuant to applicable law. For all information regarding this restriction, please contact MUP (Ministry of Interior)." | Error displayed with exact text |
| ✅ | Block only the specific blacklisted ticket, allow remaining tickets to proceed | Partial claiming works around blacklisted entry |
| ✅ | Return rejected ticket(s) to quota holder's available balance | Rejected seats return to ALLOCATED |
| ✅ | Add "Back to Seat Selection" button | Navigation works |
| ✅ | Add "Continue to Payment" button (submits all holder data) | Submits POST /api/v1/quota-portal/quotas/{id}/ticket-holders |
| ✅ | Handle API error responses (seat no longer available, validation errors) | Errors shown |
3.3 Claiming Flow: Step 3 — Payment¶
Order Summary¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Create payment page (/quota-portal/quotas/{id}/claim/step-3) |
Page renders |
| ✅ | Display step indicator (Step 3 of 3: Payment) | Indicator shows |
| ✅ | Show order summary: ticket count, sector, row numbers | Summary accurate |
| ✅ | Show original price per ticket | Price shown |
| ✅ | Show discount calculation (percentage and amount) | Discount displayed |
| ✅ | Show final total after discount | Total correct |
| ✅ | Add "Back to Holder Info" button | Navigation works |
Free Quotas (discount_code = GR)¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Detect free quota (discount = 100% or discount_code = GR) | Detection works |
| ✅ | Skip payment form for free quotas | Form hidden |
| ✅ | Show "Total: €0.00" with "Complete Claim" button | Zero total shown |
| ✅ | Call POST /api/v1/quota-portal/quotas/{id}/payment without payment intent |
API called |
Deferred Payment Quotas¶
Deferred Payment
When quota.deferred_payment = TRUE, payment is collected later (post-match invoicing).
The claiming flow completes without Stripe, similar to free quotas but with a different message.
| Status | Task | Verification |
|---|---|---|
| ✅ | Detect deferred payment quota (deferred_payment = TRUE) |
Detection works |
| ✅ | Skip Stripe payment form for deferred quotas | Form hidden |
| ✅ | Show order summary with note: "Payment will be invoiced separately" | Deferred note visible |
| ✅ | Call POST /api/v1/quota-portal/quotas/{id}/payment without stripe_payment_intent_id |
API auto-completes |
| ✅ | Show confirmation with deferred payment acknowledgment | Confirmation shows deferred status |
Paid Quotas (Stripe Integration)¶
Stripe Implementation Reference
Uses the same Stripe account as the HNS Drupal webshop (shop.hns.family).
The Drupal site uses Stripe Payment Element with stripe_payment_element plugin
(Commerce Stripe v2.1.0). The Quota Portal should use the same Stripe Payment Element
approach for visual consistency and feature parity (card, Apple Pay, Google Pay, PayPal).
Test cards: 4242 4242 4242 4242 (Visa), 5555 5555 5555 4444 (Mastercard),
any future date, any CVC.
| Status | Task | Verification |
|---|---|---|
| ✅ | Load Stripe.js from https://js.stripe.com/v3/ with publishable key from STRIPE_PUBLISHABLE_KEY env var |
Stripe loaded |
| ✅ | Create Stripe Payment Element (stripe.elements() → elements.create('payment')) matching Drupal's stripe_payment_element approach |
Payment form renders with card, Apple Pay, Google Pay |
| ✅ | Create PaymentIntent on server via STRIPE_SECRET_KEY before rendering payment form (amount, currency EUR, metadata: quota_id, match_id) |
PaymentIntent created with client_secret |
| ✅ | Pass clientSecret to Stripe Elements for frontend rendering |
Payment Element initializes |
| ✅ | Confirm payment via stripe.confirmPayment({ elements, confirmParams: { return_url } }) |
Payment processes |
| ✅ | On Stripe success, call POST /api/v1/quota-portal/quotas/{id}/payment with stripe_payment_intent_id |
Backend confirms and marks seats SOLD |
| ✅ | Handle payment success → redirect to confirmation page | Redirect works |
| ✅ | Handle payment failure → show Stripe error message, allow retry | Error shown, retry available |
| ✅ | Handle 3D Secure / SCA authentication redirect and return | 3DS works |
| ✅ | Display accepted payment methods: Visa, Mastercard, Apple Pay, Google Pay, PayPal (matching Drupal's gateway display label) | Logos displayed |
| ✅ | Style Stripe Payment Element appearance to match Drupal theme (Maven Pro font, --darkblue colors, 3px radius) |
Stripe element visually matches site |
Confirmation Screen¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Create confirmation page (/quota-portal/quotas/{id}/claim/confirmation) |
Page renders |
| ✅ | Show success message with green checkmark | Success visible |
| ✅ | Show claimed ticket summary (match, seats, holders) | Summary accurate |
| ✅ | Show message: "Tickets have been delivered to recipients' HNS mobile apps" | Message visible |
| ✅ | Show order number/reference | Reference displayed |
| ✅ | Add "Back to Dashboard" button | Navigation works |
| ✅ | Add "Claim More Tickets" button (if more ALLOCATED seats exist) | Button conditionally shown |
| ✅ | Show confirmation email notification: "A confirmation email has been sent to {email}" | Email notice visible |
| ✅ | Show invoice/receipt link: "PDF invoice has been generated" (e-racuni.com integration) | Invoice link visible |
Phase 4: Subquota Management (E7-F6)¶
4.1 Create Subquota¶
Reference: E7-F6: Subquota Creation
One-Level Delegation Only
Sub-recipients cannot create further sub-subquotas. The backend enforces
can_create_subquotas = FALSE on all subquotas regardless of parent setting.
Seat Selection for Delegation (Numbered Matches)¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Create subquota page (/quota-portal/quotas/{id}/subquota/create) |
Page renders |
| ✅ | Verify can_create_subquotas = TRUE before rendering page |
403 if not permitted |
| ✅ | Display available (ALLOCATED) seats as selectable card grid | Cards displayed |
| ✅ | Implement click-to-select/deselect on each card | Selection toggles |
| ✅ | Gray out unavailable seats (SOLD, RESERVED, already DELEGATED) | Unavailable grayed |
| ✅ | Show selection summary ("Selected for subquota: 3 seats") | Summary updates |
| ✅ | Validate at least 1 seat selected | Validation blocks empty |
Seat Selection for Delegation (Non-Numbered Matches)¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Detect non-numbered match and show quantity selector instead of card grid | Quantity input shown |
| ✅ | Show available quantity for delegation | Available count displayed |
| ✅ | Validate quantity between 1 and available ALLOCATED count | Validation works |
Recipient Details Form¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Create recipient details form below seat selection | Form renders |
| ✅ | Add Recipient Email field (required, format validated) | Email validates |
| ✅ | Add Recipient Name field (required) | Name required |
| ✅ | Add Internal Note field (optional) | Note saved |
| ✅ | Show note: "Multiple subquotas to the same email are allowed" | Note visible |
| ✅ | Add "Claim for Myself" alternative button (navigates to claiming flow instead of delegation) | Alternative action works |
| ✅ | Add "Cancel" button (returns to quota detail) | Cancel navigates |
| ✅ | Add "Create Subquota" button (submits form) | Button submits |
Subquota Submission¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Call POST /api/v1/quota-portal/quotas/{id}/subquotas with seat_ids, email, name |
API called |
| ✅ | Show subquota inheritance info: "Subquota will inherit: {discount}%, transfer={YES/NO}, expires={date}" | Inheritance preview shown |
| ✅ | Handle success: show toast "Subquota created. Email and push notification sent to {email}" | Toast appears |
| ✅ | Redirect to quota detail page after creation | Redirect works |
| ✅ | Handle API errors (seats no longer available, permission denied) | Errors displayed |
| ✅ | Show loading state during submission | Spinner visible |
| ✅ | Verify parent seats transition to DELEGATED status after API success | Parent seat list updates |
4.2 Retract Subquota¶
Backend Endpoint Note
The SubquotaService.retractSubquota() method exists in the backend but the
controller endpoint may not yet be exposed in the OpenAPI spec. Coordinate with
backend team to ensure DELETE /quota-portal/quotas/{id}/subquotas/{subquotaId}/retract
is available.
| Status | Task | Verification |
|---|---|---|
| ✅ | Add "Retract" button on subquota rows with status = ALLOCATED | Button visible only for ALLOCATED |
| ✅ | Show confirmation dialog: "Are you sure? {Recipient Name} will be notified that their allocation has been revoked." | Confirmation shown |
| ✅ | Call DELETE /api/v1/quota-portal/quotas/{id}/subquotas/{subquotaId}/retract on confirm |
API called |
| ✅ | Handle success: show toast "Subquota retracted. Seats returned to your allocation." | Toast appears |
| ✅ | Refresh subquota list and status breakdown after retraction | UI updates |
| ✅ | Handle error with exact message: "Cannot retract subquota. Sub-recipient has already started or completed claiming process." | Error message shown with exact text |
| ✅ | Disable "Retract" button for RESERVED, SOLD, or CANCELLED subquotas | Button disabled |
| ✅ | Show tooltip on disabled "Retract" button: "Subquotas can only be retracted if sub-recipient hasn't started claiming" | Tooltip visible on hover |
4.3 Subquota Detail View¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Create subquota detail modal or inline expansion | Detail view opens |
| ✅ | Show recipient info (name, email) | Info displayed |
| ✅ | Show delegated seat list with per-seat status (sector, row, seat, status) | Seats listed with individual statuses |
| ✅ | Show overall subquota status (ALLOCATED, RESERVED, SOLD) | Status shown |
| ✅ | Show seat status breakdown bar (same component as quota detail) | Breakdown visible |
| ✅ | Show creation date | Date displayed |
| ✅ | Show claiming progress (if recipient has started) | Progress shown |
| ✅ | Show inherited properties: discount, transfer permission, expiration | Properties visible |
| ✅ | Show "Allocated by: {parent quota holder name}" attribution | Attribution visible |
Phase 5: My Tickets & Polish¶
5.1 My Tickets Page¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Create My Tickets page (/quota-portal/my-tickets) |
Page renders |
| ✅ | Fetch tickets where logged-in user is the ticket holder | Data loaded |
| ✅ | Display tickets in data table (Match, Seat, Ticket Holder, Status, Actions) | Table renders |
| ✅ | Show match info (teams, date, venue) per ticket | Match info visible |
| ✅ | Show seat info (sector, row, seat number) | Seat info visible |
| ✅ | Show status badge (SOLD, Transferred, Refunded) | Badges colored |
| ✅ | Add "View Ticket" action to open ticket detail | Detail opens |
| ✅ | Show empty state when no claimed tickets | Empty state renders |
| ✅ | Sort by match date (upcoming first) | Sort works |
5.2 Error States & Edge Cases¶
Quota Status Edge Cases¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Handle PENDING quota: show "Quota is being set up" if status = PENDING | Message displayed |
| ✅ | Handle cancelled quota: show exact message "This quota has been cancelled by the administrator." | Message displayed with exact text |
| ✅ | For cancelled quotas, show previously SOLD tickets as still valid | SOLD tickets remain visible |
| ✅ | Handle past deadline: show yellow "Past Deadline" warning but allow all actions (no auto-expiry) | Warning visible, actions work |
| ✅ | Handle FULLY_CLAIMED quota: hide "Claim Tickets" button, show "All tickets claimed" | Button hidden |
| ✅ | Handle empty quota (all seats cancelled): show appropriate message | Message displayed |
| ✅ | Handle partially claimed quota: show remaining available count, keep "Claim Tickets" button | Button visible with updated count |
Refund Impact Display¶
Refund Behavior
When a ticket claimed from a subquota is refunded, the seat does NOT return to the subquota owner. The seat is treated as used. Admin must manually create a new allocation if needed.
| Status | Task | Verification |
|---|---|---|
| ✅ | Show "Cancelled (Refunded)" status for refunded seats in seat list | Refund status visible |
| ✅ | Do not show refunded seats as available for re-claiming | Refunded seats excluded from claiming |
Admin Cancellation Impact¶
Cancellation Types
Admin can cancel with two options: "Cancel All Unused" (ALLOCATED + RESERVED) or "Cancel Unfulfilled Only" (only ALLOCATED, preserving RESERVED tickets for completion).
| Status | Task | Verification |
|---|---|---|
| ✅ | Handle "Cancel All Unused": both ALLOCATED and RESERVED seats show as cancelled | All unused cancelled |
| ✅ | Handle "Cancel Unfulfilled Only": RESERVED seats remain claimable (can complete payment) | RESERVED seats preserved |
| ✅ | Show cancellation notification banner on affected quota | Banner visible |
| ✅ | Show cancelled subquotas in subquota list with red badge | Cancelled subquotas visible |
Network & API Error Handling¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Handle network timeout during claiming (show retry option) | Retry available |
| ✅ | Handle 401 Unauthorized (redirect to login) | Redirect works |
| ✅ | Handle 403 Forbidden (email mismatch: "You don't have access to this quota") | Error shown |
| ✅ | Handle 404 Not Found (quota doesn't exist) | 404 page shown |
| ✅ | Handle 409 Conflict (seat no longer available during claiming) | Conflict error handled |
| ✅ | Handle 500 Server Error (generic error message with retry) | Error page shown |
| ✅ | Handle payment gateway timeout (show order status check) | Status check available |
Cart Timeout Handling¶
20-Minute Cart TTL
Quota claim carts expire after 20 minutes. The portal must handle timeout gracefully during the 3-step claiming process.
| Status | Task | Verification |
|---|---|---|
| ✅ | Show countdown timer during claiming flow (20 min from seat selection) | Timer visible |
| ✅ | Show warning at 5 minutes remaining | Warning appears |
| ✅ | Handle cart expiration: show message "Your session has expired. Selected seats have been released." | Expiration handled |
| ✅ | Show "Restart Claiming" button that returns to Step 1 to re-select seats | Restart CTA visible |
| ✅ | Redirect to quota detail if user dismisses expiration modal | Redirect works |
5.3 Real-Time Sync¶
Dual-Channel Sync
Changes made in the mobile app must reflect in the web portal and vice versa. The portal should refresh data when navigating between pages.
| Status | Task | Verification |
|---|---|---|
| ✅ | Refresh quota data on each page navigation (not cached stale data) | Fresh data loaded |
| ✅ | Add manual "Refresh" button on dashboard | Refresh works |
| ✅ | Handle optimistic UI updates during claiming (show loading then confirm) | UX smooth |
| ✅ | Handle concurrent claiming conflict (seat claimed in app while selecting in portal) | Conflict detected |
| ✅ | Auto-refresh dashboard every 60 seconds (optional toggle) | Auto-refresh works |
5.4 Notifications Display¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Show inline notification when subquota recipient claims tickets | Notification visible |
| ✅ | Show notification when admin cancels quota | Notification visible |
| ✅ | Show notification when quota is approaching deadline | Warning visible |
5.5 Audit Logging¶
Audit Trail
All portal actions should be logged for audit purposes. The backend handles persistence; the portal ensures actions are attributed to the correct user.
| Status | Task | Verification |
|---|---|---|
| ✅ | Log claiming actions (seat selection, holder info, payment) via API calls | Actions recorded in backend |
| ✅ | Log subquota creation (who delegated, to whom, which seats) | Delegation logged |
| ✅ | Log subquota retraction (who retracted, timestamp) | Retraction logged |
| ✅ | Include user identifier (email) in all API calls for attribution | Attribution correct |
Phase 6: Testing¶
Infrastructure Testing¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Test docker-compose up -d starts all services cleanly |
Containers running |
| ✅ | Test portal accessible at http://localhost:8003 |
Page loads |
| ✅ | Test PHP container can reach backend: curl http://hns-ticketing-nginx:80/api/v1 |
Backend responds |
| ✅ | Test PHP container can reach Drupal: curl {DRUPAL_SSO_URL}/user/check?_format=json |
Drupal responds |
| ✅ | Test SCSS build: make build compiles without errors |
CSS files generated |
| ✅ | Test make dev starts Gulp watch mode |
SCSS recompiles on file change |
| ✅ | Test make stop / make start cycle |
Clean restart works |
Functional Testing¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Test login with valid Drupal credentials | Login succeeds |
| ✅ | Test login with invalid credentials | Error shown |
| ✅ | Test login via deeplink with email pre-fill | Deeplink pre-fills email |
| ✅ | Test dashboard displays all user's quotas (own + received subquotas) | All quotas visible |
| ✅ | Test quota detail shows correct seat breakdown | Breakdown accurate |
| ✅ | Test full claiming flow: select → holder info → payment → confirmation | End-to-end works |
| ✅ | Test free quota claiming (discount_code = GR, skips payment) | Free claim completes |
| ✅ | Test deferred payment quota claiming (skips Stripe, auto-completes) | Deferred claim completes |
| ✅ | Test paid quota claiming (Stripe integration) | Payment processes |
| ✅ | Test partial claiming (claim some seats, return later to claim more) | Remaining seats claimable |
| ✅ | Test subquota creation (numbered match with seat selection) | Subquota created, email sent |
| ✅ | Test subquota creation (non-numbered match with quantity) | Subquota created |
| ✅ | Test subquota retraction (ALLOCATED only) | Retraction works, seats returned |
| ✅ | Test cannot retract RESERVED/SOLD subquota | Error shown correctly |
| ✅ | Test blacklist rejection during claiming | Rejection works |
| ✅ | Test OIB checksum validation | Invalid OIB rejected |
| ✅ | Test minor detection (email disabled for under-18) | Minor handling works |
| ✅ | Test cart timeout after 20 minutes (seats released, warning shown) | Timeout handled gracefully |
| ✅ | Test quota status transitions: ACTIVE → FULLY_CLAIMED after all seats claimed | Status updates |
| ✅ | Test expired quota still allows claiming (until admin cancels) | Claiming works past deadline |
| ✅ | Test quota seat status transitions: ALLOCATED → RESERVED → SOLD | Transitions correct |
| ✅ | Test blacklist partial rejection (block one ticket, allow others) | Partial claiming works |
| ✅ | Test email mismatch on deeplink entry (logged in as different user) | Mismatch notice shown |
| ✅ | Test cancelled quota still shows previously SOLD tickets as valid | SOLD tickets visible |
| ✅ | Test blank email field handling (ticket stays on quota holder's device) | Blank email handled |
Access Control Testing¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Test user can only see quotas assigned to their email | Other quotas not visible |
| ✅ | Test user cannot access another user's quota by URL | 403 returned |
| ✅ | Test can_create_subquotas = FALSE hides delegation buttons |
Buttons hidden |
| ✅ | Test unauthenticated access redirects to login | Redirect works |
| ✅ | Test expired JWT triggers re-authentication | Re-auth works |
Browser Testing¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Test in Chrome (latest) | All features work |
| ✅ | Test in Firefox (latest) | All features work |
| ✅ | Test in Safari (latest) | All features work |
| ✅ | Test responsive layout (tablet: 768px+) | Layout adapts |
Integration Testing¶
| Status | Task | Verification |
|---|---|---|
| ✅ | Test claiming in web portal reflects in mobile app | Sync works |
| ✅ | Test claiming in mobile app reflects in web portal | Sync works |
| ✅ | Test concurrent claiming conflict (same seat selected in app and portal simultaneously) | Conflict detected, one succeeds |
| ✅ | Test subquota creation sends email invitation with deeplink | Email received with correct link |
| ✅ | Test subquota creation sends push notification (if recipient registered) | Push received |
| ✅ | Test deeplink from email opens portal with correct quota | Deeplink works |
| ✅ | Test subquota inheritance: discount, transfer, expiration match parent | Properties inherited correctly |
| ✅ | Test subquota recipient can claim via both mobile app and web portal | Both channels work |
Last Updated: March 2026