diff --git a/astro.config.mjs b/astro.config.mjs index b01dce2..9ace6f6 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -2,6 +2,18 @@ import { defineConfig } from 'astro/config'; import { localCfpProxy } from './scripts/local-cfp-proxy.mjs'; +const previewBackend = 'https://devcon26-full-test-events-management.admins-a7d.workers.dev'; +// Workers Builds replaces the branch alias on every push. Set both values before +// Astro reads public env variables so the page and CSP use the same test backend. +if (process.env.WORKERS_CI === '1' + && process.env.WORKERS_CI_BRANCH === 'feature/devcon26-flexible-ticket-checkout') { + process.env.PUBLIC_DEVCON26_COMPLETE_TEST = 'true'; + process.env.PUBLIC_DEVCON26_API_ORIGIN = previewBackend; +} + +const previewConnectSource = process.env.PUBLIC_DEVCON26_COMPLETE_TEST === 'true' + && process.env.PUBLIC_DEVCON26_API_ORIGIN === previewBackend ? ` ${previewBackend}` : ''; + export default defineConfig({ site: 'https://devcongress.org', base: '/', @@ -21,7 +33,7 @@ export default defineConfig({ "form-action 'self' https://em.devcongress.org", "img-src 'self' data: blob: https:", "font-src 'self' https://fonts.gstatic.com", - "connect-src 'self' https://em.devcongress.org https://dvevcongress.goatcounter.com https://gc.zgo.at https://challenges.cloudflare.com", + `connect-src 'self' https://em.devcongress.org https://dvevcongress.goatcounter.com https://gc.zgo.at https://challenges.cloudflare.com${previewConnectSource}`, "frame-src https://www.youtube-nocookie.com https://www.youtube.com https://challenges.cloudflare.com https://pages.inttegro.com", "worker-src 'self' blob:", 'upgrade-insecure-requests', diff --git a/content/site.yaml b/content/site.yaml index 237a90e..37dc718 100644 --- a/content/site.yaml +++ b/content/site.yaml @@ -47,7 +47,7 @@ eyebrow: "Africa's tech community" headline: "Where Africa's builders connect, collaborate and grow." cta_primary: - text: "Explore DevCon26" + text: "Buy tickets" color: "yellow" cta_secondary: text: "Join the community" diff --git a/docs/changelog.md b/docs/changelog.md index f33afc8..8c9195e 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,73 @@ # Changelog +## 2026-10-25 — Grouped community tickets + +- Render organizer-assigned community tickets as one quantity group without recipient email inputs, including a grouped order-summary line. +- Grow community-only orders as community tickets; retain explicit additional tickets and per-ticket choices for mixed personal, gift and shareable orders. Changing one grouped ticket’s use preserves the rest. +- Keep gift emails attached to their allocations when community counts change, enforce total ticket limits, and retain keyboard focus at the bounds. +- Keep the community heading inside a continuous card border, use concise explanatory copy and a clearly labeled quantity row, and reduce the change-use action to a quiet secondary control. Wrap controls on narrow phones, with minimum 44px targets and reduced-motion-aware press feedback. Preserve the existing per-seat API contract; no migration or backend setting changes. + +## 2026-10-25 — Provider-aware receipt guidance + +- Gate receipt actions on code-reviewed provider capabilities: a validated provider URL takes precedence, otherwise confirmed customer receipt email guidance, otherwise payment details only. +- Keep Paystack receipt links and email promises disabled until supported URL origins or mode-specific merchant email settings are verified. Clear stale receipt links whenever verification restarts or leaves the verified state. +- Preserve removal of the unreliable browser-print button. No provider settings or production deployments changed. +- Refresh legacy checkout assertions for the current homepage-to-registration journey, flexible ticket form, dotted badge roles, shared badge renderer, and compact footer; retain payment safety and non-admission checks. + +## 2026-10-25 — Remove unreliable payment-record action + +- Remove the payment-confirmation Print or save action, which invoked browser printing rather than a provider receipt. Keep verified payment details and the non-admission caption; native browser printing remains available. No provider receipt link is currently supplied by this flow. + +## 2026-10-24 — Leave-dialog focus polish + +- Focus the payment-confirmation leave-dialog heading on opening so touch users do not see a second outline around Keep editing. Preserve a restrained pink keyboard-focus indicator on both dialog actions. + +## 2026-10-24 — Consistent desktop checkout progress + +- Match desktop checkout progress to the compact mobile step title/count and three slim segments. Retain accessible, keyboard-operable completed-step navigation without the oversized tinted tabs. + +## 2026-10-24 — Compact mobile badge actions + +- Keep Edit, Download and Share on one mobile row with shorter labels, and reduce footer separation and bottom padding while preserving touch targets and safe-area spacing. + +## 2026-10-24 — Restore badge photo adjustment + +- Restore automatic photo adjustment after upload and an Adjust photo action in both editor steps. Save zoom and position with the correct seat, wait for photo uploads, restore saved crops, and keep failed saves retryable. +- Use side-by-side controls on desktop and a scrollable, touch-friendly mobile dialog. Keep mobile footer links together and explicitly color the download action to avoid Safari's default blue. +- Verification: production build, badge renderer and payment-confirmation checks passed; local browser fixtures exercised crop save, retry, Escape and a 390px mobile layout. The legacy checkout check still stops at its unrelated landing-page assertion. + +## 2026-10-24 — Clear ticket purchase entry points + +- Send homepage Buy tickets actions to the DevCon26 ticket section before checkout. Show the GH₵150 per-person price, QR PDF email and badge link, and multi-ticket guidance. Align navigation and FAQs; remove stale registration-opening copy. + +## 2026-10-24 — Payment badge renderer and minimal footer + +- Select the blended-photo badge renderer for the payment-confirmation editor as well as registration, including matching name validation, photo defaults and artwork loading. Preview and download now share the approved composition. +- Simplify the page footer to muted links separated by a dot, with pink hover and no arrows or underlines. + +## 2026-10-24 — Conference badge and action polish + +- Preserve the blended-photo physical badge and original name typography; use dotted uppercase attendee/speaker labels in canvas and SVG. +- Remove testing banners from badge artwork and simplify confirmation copy while retaining test-payment isolation and disabled public publication. +- Place compact badge actions beneath the editor, group download/share on narrow screens, and move page links into a quiet footer. Preserve multi-seat navigation and deferred-edit eligibility. + +## 2026-10-21 — Clear public checkout availability + +- Remove the one-ticket payment rehearsal and its test-email/Turnstile code from public checkout. +- Replace the disabled form with a loading or unavailable panel and retry action until EMS returns a valid ticket catalog. Clear stale availability and provider details on retry. + +## 2026-10-21 — Separate sponsor handover from public checkout + +- Remove sponsor handover from the public DevCon26 per-ticket choices. Additional tickets require an explicit gift recipient or community allocation before payment. +- Keep existing order-management actions for historical buyer-managed purchases; sponsor allocations are arranged in EMS. + +## 2026-10-20 — Flexible DevCon26 ticket checkout + +- Replace the closed checkout redirect with a responsive quantity and per-seat allocation form. The server catalog owns the unit price, payment provider, maximum quantity, and payment availability. +- Unify checkout into one continuous form surface and replace browser-native allocation selects with an accessible in-page choice list that explains each handoff option. +- Add a separate one-ticket Paystack test rehearsal that requires the exact-origin preview-email catalog flag and a Turnstile challenge. Test checkout cannot issue real admission. +- Verify flexible payments using their own session-scoped grant and show a payment record without admission credentials. Add private buyer-management and recipient-claim destinations for the lifecycle emails. + ## 2026-10-19 — Consolidate dependency updates from #66 - Include Astro 7.4.0, Zod 4.6.7, Cloudflare Workers types 5.20261016.1 and Wrangler 4.151.0 in the donation follow-up, preserving Inttegro 0.5.8 and the donation recovery build checks. Raise the Node engine minimum to 22.22.0 to match Astro 7.4.0. @@ -192,3 +260,85 @@ - Unified the DevCon26 mobile section navigation with the homepage header frame: a sticky 68px cream header, homepage-sized controls, a full-width white menu, and safe anchor offsets for the dedicated page’s section links. + +### 2026-10-21 — Full-flow test preview + +- Select complete-test checkout and confirmation using an explicit preview build flag, with test notices and preview-only human verification. +- Preserve private ticket/claim/badge flows while visibly marking test badge downloads and disabling public badge publication. +- Reset the human check after checkout errors and back-navigation; preserve ordinary live checkout copy. + +### 2026-10-21 — Preview backend CSP + +- Allow the exact EMS preview backend only for the explicit full-test build; preserve ordinary production CSP. +- Verified the deployed form loads GH₵150 pricing and opens a GH₵300 Paystack TEST checkout for self and gift tickets. + +### 2026-10-21 — Preview checkout presentation + +- Removed the prominent test-checkout banner at the user’s request to bring the preview layout closer to production. Test payment routing and backend safeguards remain active. + +## 2026-10-21 — Clean conference checkout + +- Redesigned checkout with compact numbered sections, an integrated quantity stepper, app-style inputs and restrained focus rings. +- Replaced the image summary with a branded conference order summary; it appears before the form on mobile so totals are visible before payment. +- Kept checkout routing, allocations, test payments, ticket/badge delivery, and shared ticket-access styles unchanged. +- Verified the Astro build and preview quantity/allocation controls. + +## 2026-10-21 — Local checkout stepper + +- Added Tickets, Your details, and Payment steps with contextual order information on the right, per-step validation, preserved back-navigation state, and reduced-motion-aware transitions. +- Render the payment challenge only when Payment is visible; earlier Continue buttons do not initialize payment. +- Tightened the ticket layout to fit a 1280×720 viewport for one ticket, and position assignment menus upward when needed. Small viewports and larger orders retain natural scrolling. +- Verified local catalog-fixture navigation, missing gift/contact validation, retained inputs, summary totals, menu bounds, build, and diff whitespace checks. No payment or email side effects; not pushed or deployed. + +## 2026-10-21 — Local ticket choice and boarding-pass refinement + +- Replaced the allocation dropdown with native radio tiles, selected-choice explanations, one-self availability, and conditional gift email validation. +- Added a masked decorative Accra-inspired vector illustration, perforated divider, and ticket notches to the order preview; no issued ticket or QR is implied. +- Changed checkout actions to brand yellow and progress to connected segments with current/completed states. +- Checked keyboard radio selection, gift validation/removal, step advancement, local viewport fit, and the production build. This refinement remains local, unpushed, and undeployed. + +## 2026-10-21 — Pink checkout interaction accents + +- Switched primary checkout actions, current-step indicators and selected ticket choices to pink; retained yellow on the illustrated order preview. Used a deeper pink for readable white button text. Local only; no push or deployment. + +### Restrained pink refinement + +- Reserved pink for primary actions and focus feedback; restored neutral active-step and selected-ticket styling. Yellow remains on the ticket artwork. Local only. +- Softened the ticket illustration to 42% opacity with fading edge masks, preserving the existing arch clip. + +- Restored the previous pink active-step and selected-ticket accents at user request; retained the illustration’s 42% opacity and fading mask. Local only. + +### 2026-10-21 — Quantity input focus + +- Replace the checkout quantity field’s heavy outer focus outline with a thin inset pink border and a soft tint. Preserve a visible system-color outline in forced-colors mode. + +### 2026-10-22 — Persist preview checkout build settings + +- Bind the DevCon26 feature branch’s Cloudflare builds to the test checkout backend before Astro generates HTML and CSP, preventing later pushes from reverting the preview to production checkout. Main and other branch builds retain existing settings. + +### 2026-10-22 — Payment provider and navigation consistency + +- Use the same Back/Continue action row on contact and payment steps. +- Show Paystack as the sole selected provider in a reusable, vertically expanding provider list with a locally served logo; payment routing remains unchanged. Logo source: https://commons.wikimedia.org/wiki/File:Paystack_Logo.svg. + +### 2026-10-22 — Compact step actions + +- Size contact and payment Continue buttons to their labels, with Back aligned left and the primary action right. Allow wrapping on narrow screens without stretching the primary button. + +## 2026-10-22 — Complete-test badge editor after payment + +- Open the saved conference badge editor after verified preview payment for a self ticket. +- Address the actual issued seat for name/photo edits and mark preview badge downloads as test-only. +- Preserve gift-only confirmation and add a runtime confirmation regression check. + +## 2026-10-24 — Mobile checkout polish + +- Added mobile-specific step progress, sticky ticket controls, per-ticket removal, and a collapsed summary below the form. +- Preserved recipient details when removing a middle ticket; refined radio focus, quantity colors and gift labels. +- Verified production build and browser removal/navigation behavior; physical iPhone checks remain pending. Local changes only. + +### Mobile ticket quantity alignment + +- Use an explicit two-column mobile quantity row so the label stays centered and the stepper sits at the right edge. Keep 44px button targets and allow the label to wrap on narrow screens. + +- Added a softly masked community photo behind the expanded mobile order summary at 10% opacity, preserving text contrast and the compact collapsed state. diff --git a/docs/devcon26-badges.md b/docs/devcon26-badges.md index 38dc1f3..71f2d87 100644 --- a/docs/devcon26-badges.md +++ b/docs/devcon26-badges.md @@ -1,8 +1,7 @@ # DevCon26 badges The physical conference badge is shared by immediate local registration previews and -verified attendee/speaker private-card previews and downloads. The role label is -44px on the 1080×1350 export, right-aligned opposite `/images/logo.png` in the +verified attendee/speaker private-card previews and downloads. The role label uses shared dotted uppercase glyphs on the 1080×1350 export, right-aligned opposite `/images/logo.png` in the footer. The backing reuses the homepage V11 artwork, including its metal clip and paper outline; a clean sample of its paper texture replaces baked-in lettering inside the personalized area. The homepage artwork itself is unchanged. @@ -36,3 +35,7 @@ Run `pnpm build`, `node scripts/check-devcon26-checkout.mjs`, and `node scripts/check-devcon26-badge.mjs`. Mocked browser checks cover both roles, private photos, invalid links, name changes, and preview/download parity without creating registrations, sending email, or publishing real cards. + +Payment confirmation places compact edit, download and share actions beneath the badge. +Names retain their original display font. Badge artwork omits testing banners; +test payment and public-publication restrictions remain enforced by the existing flow. diff --git a/docs/devcon26-flexible-checkout.md b/docs/devcon26-flexible-checkout.md new file mode 100644 index 0000000..dcb0f2f --- /dev/null +++ b/docs/devcon26-flexible-checkout.md @@ -0,0 +1,100 @@ +# DevCon26 flexible checkout website + +The /devcon26/checkout/ page accepts any whole ticket quantity from one to the +live EMS catalog's flexible_checkout.max_per_payment. The page displays the +server's GHS unit amount and selects Paystack automatically only when EMS +returns the single Paystack provider. It keeps the payment action disabled +until the live catalog reports flexible_checkout.mode: live. The homepage +links to the page; unavailable checkout is explained before showing buyer fields. + +Each seat has one allocation: self (at most one), private gift (recipient +email), organizer-managed community pool, or buyer-shareable claim. New tickets +default to a private gift, which requires an address or an explicit change of +allocation before payment. Sponsor ticket handover is arranged in EMS, not +through this public checkout. + +Initialization posts the full cart and a request UUID to +/api/public/annual-conference/2026/checkout/flexible/initialize. +Purchaser details and allocation emails are JSON body fields, never URL +parameters. The server validates the cart and amount; the website checks the +response against its catalog snapshot before leaving for Paystack. The +checkout grant and verifier remain in this browser's session storage, scoped +to the returned reference. A Paystack callback carries only the reference. + +The quantity, allocation, purchaser details, and payment action share one +continuous form surface. Allocation choices use an in-page listbox rather +than the operating system's select popup, with visible explanations for each +choice and keyboard support for arrow keys, Home, End, Enter, Space, Escape, +and Tab. The listbox keeps the at-most-one self allocation rule visible and +unavailable on the other tickets. Mobile layouts stack the counter, fields, +and order summary without introducing a nested scrolling region. + +The payment confirmation page recognizes a matching flexible grant, verifies +with /checkout/flexible/verify, and shows a payment record for verified +payment only. The verify response has no admission QR or claim capability, so +the receipt never pretends to be a ticket. Existing standard live and sandbox +confirmation paths remain separate. + +After fulfillment, EMS emails the buyer a private +/devcon26/tickets/manage/#capability=... link. That page calls the order +endpoint and can create or replace a shareable admission claim or reissue a gift +invitation. Historical buyer-managed orders retain their existing handover +actions. The page constructs links only from a capability returned by the matching action. +Gift and shareable recipients use /devcon26/tickets/claim/#capability=...; +a badge link additionally has purpose=badge. A successful claim reports +the server's delivery state and never displays a QR on the website. + +Public checkout contains no payment rehearsal or test-email form. While the catalog +loads, a status panel replaces the form. If availability cannot be confirmed, +the panel explains that checkout is unavailable and offers a retry. The form +and order summary appear only after a valid catalog arrives; retry clears stale +provider, price, and availability text. The separate EMS sandbox remains unchanged. + +The checkout, management, and claim routes are noindex static pages. They use +the existing site CSP, processed Astro scripts, and the EMS exact-origin CORS +gate. Website source and a successful static build do not activate sales, +apply EMS migrations, or prove a Paystack round trip. + +Desktop and mobile captures of the static gated state are available for +[checkout](./screenshots/devcon26/checkout-desktop.png), +[ticket management](./screenshots/devcon26/manage-desktop.png), and +[ticket claims](./screenshots/devcon26/claim-desktop.png). The corresponding +mobile captures are [checkout](./screenshots/devcon26/checkout-mobile.png), +[management](./screenshots/devcon26/manage-mobile.png), and +[claims](./screenshots/devcon26/claim-mobile.png). These show responsive layout +with sales closed; they do not demonstrate payment or admission. + +## Full-flow test preview + +Build the feature preview with `PUBLIC_DEVCON26_COMPLETE_TEST=true`. This selects the separate complete-test catalog, initialization, and verification endpoints and displays a test-payment notice. Omit the flag for the public website build. The main checkout includes a Turnstile check bound to `devcon26_complete_test_checkout`; its widget must allow the exact preview hostname. + +Paystack test payments drive real private management/claim links, QR email attachments, and badge creation. EMS restricts recipients to the enabled tester list and enforces quotas. Test QR PNGs and downloaded badges visibly state that they are not valid for admission. Test badges cannot publish publicly. + +EMS migration `20261021150000_complete_test_ticket_checkout.sql` must be reviewed and applied before activating this path. It retains test provenance and blocks live launch while test orders remain. No migration or preview activation was performed as part of the review-fix pass; shared capacity cleanup requires separate approval. + +The test build's CSP adds only +`https://devcon26-full-test-events-management.admins-a7d.workers.dev` when both +`PUBLIC_DEVCON26_COMPLETE_TEST=true` and `PUBLIC_DEVCON26_API_ORIGIN` exactly +match that backend. No wildcard or production CSP change is required. + +### Checkout presentation + +The purchase page imports `devcon26-checkout-design.css` after the shared ticket-access stylesheet. Its `.checkout-design` scope keeps claim and management screens unchanged. Controls follow the EMS form palette and 8px radii, with 44px minimum button targets and a subtle pink input focus ring. The order summary precedes the form in source/mobile reading order and occupies the right column on desktop. Gift email disclosure and the single continuous form retain the existing checkout controller. + +### Local stepper refinement + +Checkout now presents three steps within one preserved form. Continue validates only the current step; final submission reveals the earliest invalid prior step before reporting browser validity. Earlier-step edits require advancing through validation again. The right context panel uses text-only rendering for allocation and purchaser details, with totals always visible. Turnstile uses explicit rendering when Payment is shown. The local UI was checked with a read-only catalog fixture; payment and email testing still belongs to the authorized preview journey after deployment approval. + +The local allocation refinement uses native radio groups within a fieldset per ticket. Changing a selection restores focus to the corresponding replacement radio, and the other tickets disable the personal allocation with a visible explanation. The order preview has decorative masked SVG artwork and ticket notches; it remains explicitly an order preview. Primary actions use accessible deep pink with white text; current-step and selected-choice accents also use pink, while the illustrated ticket retains yellow and softened, fading artwork. The earlier custom dropdown and its keyboard/event state have been removed from the purchase controller. + +### Persistent Cloudflare preview builds + +Cloudflare Workers Builds supplies `WORKERS_CI=1` and `WORKERS_CI_BRANCH`. For the exact `feature/devcon26-flexible-ticket-checkout` branch, Astro config sets `PUBLIC_DEVCON26_COMPLETE_TEST=true` and the fixed EMS preview API origin before generating HTML and CSP. Every automatic push build therefore retains test routing. No GitHub variables or Worker runtime secrets are needed for these public build settings. Main and other branches retain their existing configuration. Manual builds outside Workers Builds still require the two explicit preview variables. + +### Mobile checkout controls (2026-10-24) + +Below 821px, progress uses the current step name, step count and three segments. Progress and ticket quantity stay sticky below the site header; Back/Continue navigate steps. Additional tickets can be removed in place without losing the other recipients. The summary follows the form and starts collapsed on mobile, expanded on desktop. + +### Post-payment photo adjustment + +The payment badge editor opens the photo adjuster after a new image is selected and exposes Adjust photo in both editing and preview steps. Zoom and horizontal/vertical position use the existing seat card crop API (`scale`, `x`, `y`). Saving waits for the selected seat's upload, persists its crop, and preserves retry controls on failure. Previously saved crops are restored when reopening the editor. Desktop uses side-by-side controls; mobile stacks the preview and 44px controls within a scrollable dialog. diff --git a/docs/devcon26-test-checkout.md b/docs/devcon26-test-checkout.md index c105587..fcca43e 100644 --- a/docs/devcon26-test-checkout.md +++ b/docs/devcon26-test-checkout.md @@ -107,7 +107,7 @@ the ticket tier, quantity, GHS amount, and payment status; local tier metadata supplies only the matching label and photo. Only a valid `mode: "test"`, `status: "verified"` response reveals the payment -summary, full neutral reference, optional print/save action, and next steps. +summary, full neutral reference, capability-gated provider receipt guidance, and next steps. Pending, failed, missing/unknown reference, invalid response, timeout, and connection-error states never display successful payment or a usable ticket. Retries only check the existing payment. No JavaScript means no verified @@ -129,8 +129,7 @@ and subtle side notches make it easier to retain without implying admission. The record keeps the existing server-supplied amount, tier, quantity, coupon, and neutral payment-reference hooks; no payment, EMS, or Paystack behavior changed. -The action now accurately says **Print or save payment record**. Its print -stylesheet renders only the record, with a white fallback-safe surface, 150mm +The former **Print or save payment record** button has since been removed because embedded mobile browsers may not support printing. Native browser printing still uses the stylesheet to render only the record, with a white fallback-safe surface, 150mm maximum width, exact-color adjustment, and legacy/modern break-avoid rules to keep the receipt together. @@ -407,8 +406,8 @@ The completed-payment summary keeps a single outer border without the stacked bottom shadow. Internal row rules, the dashed footer divider, and its full-bleed tinted band are removed. Amount-first typography and additional spacing around ticket selection and the payment reference provide the hierarchy instead. -Desktop and mobile retain comfortable padding, and the save action and -"Payment record only. Not an admission ticket." caption remain intact. +Desktop and mobile retain comfortable padding and the +"Payment record only. Not an admission ticket." caption. The former save action was subsequently removed. The confirmation venue block no longer includes the building photo or its mask. Venue information and directions remain; the selected ticket photo inside the @@ -439,3 +438,41 @@ the script; no Downloads folder or external source file is required. These checks neither call the payment API nor replace browser layout, native form validation, or Paystack round-trip verification. No dependency or separate test runner is added. + + +## Provider receipt capabilities + +The confirmation page uses the code-owned registry in `src/lib/payment-receipts.ts`. +After payment verification succeeds, it displays a returned `receipt_url` only +when it is an HTTPS URL on an explicitly approved provider origin, with no +credentials or control characters. It never constructs a receipt link from a +reference, receipt number, or checkout authorization URL. Otherwise it shows +email guidance only for a payment mode whose merchant customer-receipt setting +has been operationally confirmed. Without either capability, payment details +and the reference remain visible without a receipt promise. + +Paystack currently has no allowed receipt origins and no confirmed email modes. +Its transaction API documents `receipt_number`, not a customer receipt URL: +https://paystack.com/docs/api/transaction/ +Customer emails are a merchant preference and must be checked in the actual +account and applicable mode before enabling guidance: +https://support.paystack.com/en/articles/2131010 + +For a future provider, review its documented receipt URL format/origins, add a +server adapter that returns the transaction's real receipt URL, and update the +trusted provider context and provider label in the confirmation flow. Add +origins to the registry only after that review. Record current merchant-setting +evidence before adding a mode to `customerEmailConfirmedModes`; supported email +functionality alone does not prove emails are enabled or delivered. Recheck +this configuration when the provider/account settings change. Existing +DevCongress ticket PDF and badge emails are independent and unchanged. + +Run `node scripts/check-payment-receipts.mjs` for the focused provider-policy and rendering checks. + +### Grouped community allocations + +Organizer-assigned (`community_managed`) places appear as one quantity group and one summary line. They never require recipient emails. Increasing the total of a community-only order adds more community places; mixed orders retain the existing gift default for additional places. “Add another ticket” creates an individually editable ticket, and “Change one ticket’s use” converts one community place to an individually editable gift without changing the total. + +The API still receives one allocation per seat. Only gifts carry `recipient_email`; personal and shareable tickets retain their existing behavior. Community quantity changes preserve other allocations and gift details, with the catalog limit applied to the whole order. + +Verification: `node scripts/check-community-tickets.mjs`, the full checkout checker and the preview build; local in-app browser checks with a synthetic catalog for community-only and mixed orders. No payment or email is initiated by these checks. Delivery targets the PR branch preview only; production remains unchanged. diff --git a/docs/screenshots/devcon26/checkout-desktop.png b/docs/screenshots/devcon26/checkout-desktop.png new file mode 100644 index 0000000..392c47f Binary files /dev/null and b/docs/screenshots/devcon26/checkout-desktop.png differ diff --git a/docs/screenshots/devcon26/checkout-mobile.png b/docs/screenshots/devcon26/checkout-mobile.png new file mode 100644 index 0000000..9151ec1 Binary files /dev/null and b/docs/screenshots/devcon26/checkout-mobile.png differ diff --git a/docs/screenshots/devcon26/claim-desktop.png b/docs/screenshots/devcon26/claim-desktop.png new file mode 100644 index 0000000..48345a6 Binary files /dev/null and b/docs/screenshots/devcon26/claim-desktop.png differ diff --git a/docs/screenshots/devcon26/claim-mobile.png b/docs/screenshots/devcon26/claim-mobile.png new file mode 100644 index 0000000..2b01cbc Binary files /dev/null and b/docs/screenshots/devcon26/claim-mobile.png differ diff --git a/docs/screenshots/devcon26/manage-desktop.png b/docs/screenshots/devcon26/manage-desktop.png new file mode 100644 index 0000000..9a366bc Binary files /dev/null and b/docs/screenshots/devcon26/manage-desktop.png differ diff --git a/docs/screenshots/devcon26/manage-mobile.png b/docs/screenshots/devcon26/manage-mobile.png new file mode 100644 index 0000000..3261953 Binary files /dev/null and b/docs/screenshots/devcon26/manage-mobile.png differ diff --git a/public/images/payment-providers/paystack.svg b/public/images/payment-providers/paystack.svg new file mode 100644 index 0000000..3fe7778 --- /dev/null +++ b/public/images/payment-providers/paystack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/check-community-tickets.mjs b/scripts/check-community-tickets.mjs new file mode 100644 index 0000000..52ec499 --- /dev/null +++ b/scripts/check-community-tickets.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import vm from 'node:vm'; + +const require = createRequire(import.meta.url); +const astroRequire = createRequire(require.resolve('astro/package.json')); +const { transform } = astroRequire('esbuild'); +const source = readFileSync('src/lib/devcon26-flexible-checkout.ts', 'utf8'); +const { code } = await transform(source, { loader: 'ts', format: 'cjs' }); + +function extractFunction(name) { + const start = code.indexOf(`function ${name}(`); + const open = code.indexOf('{', start); + let depth = 1; + let end = open + 1; + + while (depth && end < code.length) { + if (code[end] === '{') depth++; + if (code[end] === '}') depth--; + end++; + } + + assert.ok(start >= 0 && depth === 0); + return code.slice(start, end); +} + +const state = { + allocations: [{ kind: 'community_managed' }], + catalog: { maxPerPayment: 20 }, + liveBusy: false, + quantity: { value: '1', setCustomValidity() {}, reportValidity() {} }, + invalidateLiveRequest() {}, + renderRows() {}, + rows: { querySelector: () => ({ focus() {}, disabled: false }) }, + page: { querySelector: () => ({ textContent: '' }) }, +}; +vm.createContext(state); +vm.runInContext(extractFunction('setQuantity') + '\n' + extractFunction('changeCommunityQuantity'), state); + +state.setQuantity(5); +assert.equal(state.allocations.length, 5); +assert.ok(state.allocations.every((ticket) => ticket.kind === 'community_managed' && !('recipient_email' in ticket))); + +state.allocations.push({ kind: 'self' }, { kind: 'gift', recipient_email: 'gift@example.test' }, { kind: 'community_shareable' }); +state.changeCommunityQuantity(1); +state.changeCommunityQuantity(-1); +assert.deepEqual(Array.from(state.allocations.slice(5), (ticket) => ({ ...ticket })), [ + { kind: 'self' }, + { kind: 'gift', recipient_email: 'gift@example.test' }, + { kind: 'community_shareable' }, +]); +state.setQuantity(9); +assert.equal(state.allocations[8].kind, 'gift'); +assert.equal(state.allocations[6].recipient_email, 'gift@example.test'); + +for (let i = 0; i < 5; i++) state.changeCommunityQuantity(-1); +assert.equal(state.allocations.filter((ticket) => ticket.kind === 'community_managed').length, 0); +assert.equal(state.allocations[1].recipient_email, 'gift@example.test'); +state.setQuantity(20); +state.changeCommunityQuantity(1); +assert.equal(state.allocations.length, 20); +state.setQuantity(21); +assert.equal(state.allocations.length, 20); +state.allocations = [{ kind: 'community_managed' }]; +state.changeCommunityQuantity(-1); +state.setQuantity(0); +assert.equal(state.allocations.length, 1); +state.liveBusy = true; +state.changeCommunityQuantity(1); +assert.equal(state.allocations.length, 1); +console.log('PASS: community-only growth, mixed allocations and gift preservation, group removal, quantity bounds and busy guard.'); diff --git a/scripts/check-devcon26-badge.mjs b/scripts/check-devcon26-badge.mjs index 99bc547..9bcd1a7 100644 --- a/scripts/check-devcon26-badge.mjs +++ b/scripts/check-devcon26-badge.mjs @@ -54,7 +54,7 @@ for (role of ['attendee', 'speaker']) { assert.equal(preview.status, 200); assert.ok(svg.includes('viewBox="0 0 1080 1350"')); - assert.ok(svg.includes('font-size="44"')); + assert.ok(svg.includes(` ({ ...quote, status: 'refund_required', amount_minor: 18999 }) }); await tick(); assert.equal(attentionRoot.dataset.paymentState, 'refund_required'); assert.equal(attentionElements['payment-receipt'].hidden, true); assert.equal(attentionElements['payment-attention'].hidden, false); assert.equal(attentionElements['payment-title'].textContent, 'Your payment needs attention.'); -await attentionElements['payment-print'].fire('click'); assert.equal(printed, false); async function readyFixture(options) { @@ -684,34 +687,27 @@ const checkoutHtml = readFileSync(root + '/dist/devcon26/checkout/index.html', ' assert.ok(!html.includes('ticket-checkout-dialog'), 'The landing page no longer contains a checkout modal'); -assert.ok(html.includes('href="/devcon26/register/"'), 'The landing page leads to current registration availability'); +assert.ok(homeHtml.includes('href="/devcon26/#tickets"'), 'Homepage ticket actions lead to the conference registration section'); +assert.ok(html.includes('id="tickets"') && html.includes('GH₵150'), 'Registration section explains the individual ticket price'); +assert.ok(html.includes('href="/devcon26/checkout/"'), 'Registration section continues to ticket checkout'); assert.ok(html.includes('href="/devcon26/speak/"'), 'The landing page links the annual conference CFP'); -assert.ok(!html.includes('data-ticket-checkout='), 'Group ticket sales are no longer advertised'); -assert.ok(readFileSync(root + '/dist/devcon26/register/index.html', 'utf8').includes('name="email"')); - -assert.ok(checkoutHtml.includes('data-checkout-page')); -assert.ok(checkoutHtml.includes('Complete your booking.')); +assert.ok(!html.includes('data-ticket-checkout='), 'Legacy group ticket sales are not advertised'); +assert.ok(checkoutHtml.includes('data-flexible-checkout'), 'Checkout uses the flexible individual-ticket flow'); assert.ok(checkoutHtml.includes('noindex, nofollow')); -assert.ok(checkoutHtml.includes('data-checkout-fields disabled'), 'Buyer fields are disabled without JavaScript'); +assert.match(checkoutHtml, /data-checkout-layout hidden/, 'Checkout form stays unavailable until the catalog is loaded'); +assert.match(checkoutHtml, /data-pay[^>]*disabled/, 'Payment cannot begin without JavaScript and verified availability'); assert.ok(!/name="(?:purchaser_name|purchaser_email|coupon_code)"/.test(checkoutHtml), 'Native form fallback cannot send buyer details in a URL'); -assert.ok(!checkoutHtml.includes(' 0); -assert.equal((checkoutHtml.match(/class="checkout-icon"/g) || []).length, 9, 'Navigation, ticket details, coupon, payment, and security icons render inline'); -assert.ok(checkoutHtml.includes('data-checkout-payment-label'), 'Payment label has its own node so icons survive state changes'); -assert.ok(checkoutHtml.includes('data-checkout-retry'), 'Price verification retry is separate from the payment action'); -assert.match(checkoutSource, /\.checkout-backdrop\s*\{[^}]*position: absolute;[^}]*height: 960px;/, 'Background geometry does not depend on the expanding coupon content'); -assert.match(checkoutSource, /\.checkout-backdrop img\s*\{[^}]*aspect-ratio: 5 \/ 4;[^}]*mask-image: radial-gradient/, 'Large photo keeps its crop and soft edge mask'); -assert.match(checkoutSource, /ellipse 50% 50% at 50% 50%,[\s\S]*?transparent 96%/, 'An inscribed centred ellipse fades completely before all four image edges'); -assert.match(checkoutSource, /\.checkout-backdrop img\s*\{[^}]*width: min\(1100px, 100%\);/, 'The photo stays within its container instead of clipping an oversized crop'); -assert.ok(!checkoutSource.includes('coupon-chevron'), 'Coupon disclosure no longer uses a chevron'); -assert.ok(checkoutSource.includes('CheckoutIcon name="plus"'), 'Coupon disclosure renders a plus icon'); -assert.match(checkoutSource, /\.coupon\[open\] \.coupon-symbol :global\(path:last-child\)\s*\{\s*opacity: 0;/, 'Open disclosure hides only the vertical stroke to show a minus'); -assert.match(checkoutSource, /\.coupon-symbol :global\(path:last-child\)\s*\{\s*transition: opacity 140ms/, 'The plus-to-minus change uses a brief interruptible opacity transition'); -assert.match(checkoutSource, /input:focus\s*\{[^}]*border-color: var\(--checkout-focus\);[^}]*outline: 2px solid var\(--checkout-focus\);[^}]*outline-offset: -1px;/, 'Focus uses a crisp subdued edge that remains visible without stacked black outlines'); -assert.ok(!/overflow-y: ?(?:auto|scroll)|max-height:/.test(checkoutSource), 'Checkout uses natural page scrolling, not a nested scroll viewport'); -assert.ok(checkoutSource.includes('grid-template-columns: minmax(0, 1fr);'), 'Mobile stacks buyer details and summary'); +assert.ok(checkoutHtml.includes('data-quantity') && checkoutHtml.includes('data-allocation-list'), 'Each individual ticket has a quantity and allocation'); +assert.ok(checkoutHtml.includes('data-provider') && checkoutHtml.includes('value="paystack"'), 'Available provider selection retains Paystack'); +assert.ok(checkoutHtml.includes('data-retry') && checkoutHtml.includes('data-status'), 'Availability retry and actionable checkout feedback remain available'); +assert.ok(checkoutHtml.includes('data-mobile-step-name') && checkoutHtml.includes('data-mobile-step-count'), 'Checkout has compact progress labels'); +assert.equal((checkoutHtml.match(/data-step-link=/g) || []).length, 3, 'Checkout retains three named navigable steps'); +assert.ok(checkoutHtml.includes('data-unit-price') && checkoutHtml.includes('data-total'), 'Order summary uses runtime price and total'); +const checkoutStyles = readFileSync(root + '/src/styles/devcon26-flexible-checkout.css', 'utf8'); +assert.ok(checkoutStyles.includes(':focus-visible'), 'Keyboard focus remains visible'); + assert.ok(!html.includes('partner-symbol'), 'The decorative pink plus signs are removed'); assert.equal((sponsorSection.match(/class="sponsor-item"/g) || []).length, 1); @@ -883,27 +879,35 @@ for (const name of ['Ada', 'Your Name', 'Alexandra Nana Akua Mensah', 'Jean-Luc assert.equal(digitalBadgeModule.exports.layoutDigitalBadgeName(digitalNameContext, 'W'.repeat(60)).valid, false, 'Unbreakable names fail visibly rather than clip'); +const rolePatterns = new Map(); + for (const role of [null, 'attendee', 'speaker']) { const text = []; const images = []; + const dots = []; const context = new Proxy({ ...digitalNameContext, fillText(value) { text.push(value); }, drawImage(...args) { images.push(args); }, + arc(x, y, radius) { if (this.fillStyle === '#ed008c') dots.push([x, y, radius]); }, createLinearGradient() { return { addColorStop() {} }; }, }, { get(target, key) { return key in target ? target[key] : () => {}; } }); const canvas = { getContext() { return context; } }; await digitalBadgeModule.exports.paintDigitalBadge(canvas, 'Your Name', null, undefined, role); - assert.equal(text.includes('ATTENDEE'), role === 'attendee'); - assert.equal(text.includes('SPEAKER'), role === 'speaker'); + assert.ok(text.includes('Your Name'), 'Attendee name remains standard readable text'); + assert.ok(dots.length > 0, 'Role is rendered as dotted lettering'); + rolePatterns.set(role, JSON.stringify(dots)); assert.equal(text.includes('COMMUNITY'), false, 'No placeholder footer role'); assert.equal(images.at(-1)[0].src, '/images/logo.png', 'Uses the real shared DevCongress logo'); assert.ok(images[0][0].src.endsWith('devcon26-v11-base-1080x1350.png'), 'Uses the homepage physical badge artwork'); assert.equal(canvas.width, 1080); assert.equal(canvas.height, 1350); } -assert.match(socialCardSource, /isRegistration \? layoutDigitalBadgeName\(context, value\) : layoutDevcon26CardName\(context, value\)/, 'Validation follows the selected renderer'); +assert.equal(rolePatterns.get(null), rolePatterns.get('attendee'), 'Default badge role is attendee'); +assert.notEqual(rolePatterns.get('attendee'), rolePatterns.get('speaker'), 'Speaker and attendee dot patterns differ'); + +assert.match(socialCardSource, /usesDigitalBadge \? layoutDigitalBadgeName\(context, value\) : layoutDevcon26CardName\(context, value\)/, 'Validation follows the selected renderer'); assert.match(socialCardSource, /if \(digital\) return \{ base: null, backdrop: null \}/, 'Digital badge does not depend on legacy bitmap artwork'); assert.ok(socialCardSource.includes("paintDigitalBadge(workCanvas, card.name, card.photo, card.crop, 'attendee')"), 'Registration sharing cards display ATTENDEE'); const registrationCardSource = readFileSync(root + '/src/components/Devcon26RegistrationCard.astro', 'utf8'); @@ -946,14 +950,14 @@ assert.match(confirmationRule('.payment-details'), /display:\s*grid;/, 'Receipt assert.match(confirmationRule('.payment-details'), /row-gap:\s*6px;/, 'Receipt details retain their row spacing'); assert.match(confirmationRule('.payment-details > div'), /padding-block:\s*8px;/, 'Receipt rows retain breathing room without separators'); assert.ok(!/border/.test(confirmationRule('.payment-details > div')), 'Receipt rows use hierarchy instead of separator lines'); -assert.match(confirmationRule('.receipt-stub'), /display:\s*grid;/, 'Save action and disclaimer retain their grouped layout'); -assert.match(confirmationRule('.receipt-stub'), /gap:\s*8px;/, 'Receipt footer retains spacing between the save action and disclaimer'); +assert.match(confirmationRule('.receipt-stub'), /display:\s*grid;/, 'Provider receipt guidance and disclaimer retain their grouped layout'); +assert.match(confirmationRule('.receipt-stub'), /gap:\s*8px;/, 'Receipt footer retains spacing between provider guidance and disclaimer'); assert.match(confirmationRule('.receipt-stub'), /margin-top:\s*28px;/, 'Receipt footer remains separated by whitespace'); assert.match(confirmationRule('.receipt-stub'), /border-top:\s*1px dashed/, 'Receipt footer has a restrained perforation divider'); assert.match(confirmationRule('.payment-details .amount-detail'), /padding-block:\s*12px 20px;/, 'Amount remains the leading receipt group'); assert.ok(confirmationRule('.payment-details .ticket-detail').includes('margin-top: 16px'), 'Ticket selection begins a distinct spacing group'); assert.ok(confirmationRule('.payment-details .reference-detail').includes('margin-top: 12px'), 'Payment reference begins a distinct spacing group'); -assert.ok(confirmationHtml.includes('data-payment-print') && confirmationHtml.includes('Print or save payment record') && confirmationHtml.includes('Payment record only. Not an admission ticket.'), 'Print/save action and non-admission boundary remain'); +assert.ok(!confirmationHtml.includes('data-payment-print') && confirmationHtml.includes('Payment record only. Not an admission ticket.'), 'No unreliable print action; non-admission boundary remains'); assert.ok(confirmationHtml.includes('data-social-card-preview') && confirmationHtml.includes('data-social-card-share') && confirmationHtml.includes('data-social-card-download'), 'Verified checkout presents an editable ticket-style social card with share and download actions'); assert.match(confirmationHtml, /id="social-card-title"[^>]*>I’m attending ]*>DevCon26\.<\/em>/, 'Social-card editor uses the attendee announcement framing'); assert.ok(confirmationHtml.includes('data-social-ticket-canvas') && !confirmationHtml.includes('social-ticket-header'), 'Social-card preview uses one V11 canvas instead of a separate HTML yellow ticket'); @@ -990,7 +994,7 @@ assert.match(socialCardSource, /\* 1\.04/, 'Photo cover crop includes the approv assert.match(socialCardSource, /DEFAULT_PHOTO_CROP: PhotoCrop = \{ zoom: 1, x: 0\.5, y: 0\.18 \}/, 'Default photo crop preserves approved zoom and 18 percent top overflow'); assert.match(socialCardSource, /crop: PhotoCrop = DEFAULT_PHOTO_CROP/, 'Existing callers retain the approved crop'); assert.match(socialCardSource, /\(height - PHOTO_BOX\.height\) \* crop\.y/, 'Photo position uses scaled overflow so it never exposes blank edges'); -assert.match(socialCardSource, /isRegistration \? root\.querySelector\('\[data-photo-dialog\]'\) : null/, 'Photo adjustment UI is registration-only'); +assert.ok(socialCardSource.includes("const photoDialog = root.querySelector('[data-photo-dialog]')"), 'Photo adjustment is available wherever its dialog is rendered'); assert.match(socialCardSource, /NAME_TEXT = \{ x: 190, textWidth: 698 \}/, 'Name uses the approved V11 editorial width'); assert.match(socialCardSource, /const baselines = layout\.layout\.lines\.length === 1 \? \[1092\] : \[1060, 1104\]/, 'Name uses the approved V11 single- and two-line baselines'); assert.doesNotMatch(socialCardSource, /NAME_LABEL|context\.fillRect\(/, 'V11 draws editorial name text without a name plate or fill rectangle'); @@ -1012,8 +1016,8 @@ assert.match(socialCardSource, /if \(photoUploadVersions\[seatIndex\] !== upload assert.match(confirmationRule('.social-ticket'), /aspect-ratio:\s*4\s*\/\s*5;/, 'Social-card preview uses a portrait feed-friendly format'); assert.match(confirmationRule('.social-preview-step .social-ticket'), /width:\s*440px;/, 'Share preview uses the additional vertical space'); assert.ok(!/zoom\s*:/.test(confirmationSource), 'Preview is not reduced with CSS zoom'); -assert.match(confirmationSource, /@media \(min-width: 1281px\)[\s\S]*?grid-auto-columns: minmax\(0, 1fr\);/, 'Desktop card actions use equal-width columns'); -assert.match(confirmationSource, /\.confirmation-actions \.payment-actions\s*\{\s*margin-top: 0;/, 'Left and right actions align in a shared footer'); +assert.match(confirmationRule('.social-step-navigation'), /display:\s*flex;/, 'Badge actions use compact flex layout'); +assert.match(confirmationRule('.confirmation-actions .payment-actions'), /margin:\s*0;/, 'Minimal footer removes excess action margins'); assert.match(socialCardSource, /preview.hidden = true;\s*navigation.hidden = true;/, 'Deferring hides the detached editor controls'); assert.match(socialCardSource, /share\.hidden = step !== 2;\s*download\.hidden = step !== 2;/, 'Export actions only appear in the share step'); assert.match(socialCardSource, /canvas\.width = CARD_WIDTH;[\s\S]*canvas\.height = CARD_HEIGHT;/, 'Downloaded social image uses a 4:5 portrait canvas'); @@ -1037,7 +1041,7 @@ assert.match(confirmationSource, /\.social-card-fields input\[type="text"\]:focu assert.match(confirmationSource, /\.social-card-fields input\[type="text"\],\s*\.social-card-fields \.social-photo-button\s*\{\s*box-sizing: border-box;\s*height: 48px;\s*min-height: 48px;/, 'Name and photo controls share a 48px border-box height'); assert.match(confirmationRule('.social-card-fields .social-photo-button'), /grid-row: 2;/, 'Photo picker aligns with the name input, not its label'); assert.match(confirmationSource, /@media \(max-width: 760px\)[\s\S]*?\.social-card-fields\s*\{\s*grid-template-columns:\s*minmax\(0, 1fr\);/, 'Social-card controls return to a single column on mobile'); -assert.match(confirmationSource, /@media print\s*\{[\s\S]*?\.payment-header, \.payment-status, \.payment-attention, \.payment-actions, \.print-button, \.skip-link, \.receipt-photo, \.next-steps, \.no-script\s*\{\s*display: none !important;/, 'Print hides the surrounding confirmation experience'); +assert.match(confirmationSource, /@media print\s*\{[\s\S]*?\.payment-header, \.payment-status, \.payment-attention, \.payment-actions, \.provider-receipt-link, \.skip-link, \.receipt-photo, \.next-steps, \.no-script\s*\{\s*display: none !important;/, 'Print hides the surrounding confirmation experience'); assert.match(confirmationSource, /@media print\s*\{[\s\S]*?max-width:\s*150mm;/, 'Print constrains the record to a one-page receipt width'); assert.match(confirmationSource, /@media print\s*\{[\s\S]*?(?:^|\n)\s+print-color-adjust:\s*exact;/m, 'Print preserves the receipt hierarchy when background graphics are available'); assert.match(confirmationSource, /@media print\s*\{[\s\S]*?page-break-inside:\s*avoid;/, 'Print keeps the payment record together'); diff --git a/scripts/check-devcon26-payment-confirmation.mjs b/scripts/check-devcon26-payment-confirmation.mjs new file mode 100644 index 0000000..3d98b3d --- /dev/null +++ b/scripts/check-devcon26-payment-confirmation.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import vm from 'node:vm'; + +const require = createRequire(import.meta.url); +const astroRequire = createRequire(require.resolve('astro/package.json')); +const { build } = astroRequire('esbuild'); +const bundle = await build({ entryPoints: ['src/lib/devcon26-payment-confirmation.ts'], bundle: true, format: 'cjs', platform: 'browser', write: false }); + +for (const hasSelf of [true, false]) { + const reference = 'devcon26-complete-test-' + 'a'.repeat(32); + const nodes = new Map(); + const root = { + dataset: { checkoutApiOrigin: 'https://preview-api.example.test' }, + querySelector(selector) { + if (!nodes.has(selector)) nodes.set(selector, { setAttribute() {}, removeAttribute() {}, addEventListener() {} }); + return nodes.get(selector); + }, + }; + const module = { exports: {} }; + let requests = 0; + let badgeCalls = 0; + vm.runInNewContext(bundle.outputFiles[0].text, { + module, URL, URLSearchParams, AbortController, console, + document: {}, + window: { location: { search: '?reference=' + reference, hash: '', pathname: '/devcon26/payment/' }, + history: { replaceState() {} }, setTimeout, clearTimeout, addEventListener() {} }, + sessionStorage: { getItem: () => JSON.stringify({ reference, checkout_grant: 'fixture', checkout_verifier: 'fixture' }) }, + fetch: async url => { + requests += 1; + assert.ok(url.endsWith('/complete-test-checkout/verify')); + return { ok: true, status: 200, json: async () => ({ mode: 'test', status: 'verified', quantity: 1, + amount_paid_minor: 15000, ticket_amount_minor: 15000, currency: 'GHS', + card_quantity: hasSelf ? 1 : 0, ...(hasSelf ? { buyer_grant: 'scoped-badge-grant' } : {}) }) }; + }, + }); + module.exports.initializeDevcon26PaymentConfirmation(root, (_root, quantity, grant) => { + badgeCalls += 1; + assert.equal(quantity, 1); + assert.equal(grant, 'scoped-badge-grant'); + }); + await new Promise(resolve => setTimeout(resolve, 0)); + assert.equal(requests, 1); + assert.equal(root.dataset.paymentState, 'verified'); + assert.equal(badgeCalls, hasSelf ? 1 : 0); +} +console.log('Complete-test confirmation: verified self badge and gift-only paths passed.'); diff --git a/scripts/check-payment-receipts.mjs b/scripts/check-payment-receipts.mjs new file mode 100644 index 0000000..1dfb2c1 --- /dev/null +++ b/scripts/check-payment-receipts.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import vm from 'node:vm'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const require = createRequire(root + 'package.json'); +const astroRequire = createRequire(require.resolve('astro/package.json')); +const { build } = astroRequire('esbuild'); + +async function bundle(entry) { + const result = await build({ + entryPoints: [root + entry], + bundle: true, + platform: 'browser', + format: 'cjs', + write: false, + logLevel: 'silent', + }); + + return result.outputFiles[0].text; +} + +const receiptModule = { exports: {} }; + +vm.runInNewContext(await bundle('src/lib/payment-receipts.ts'), { module: receiptModule, URL, Object }); +const { paymentReceiptPresentation, renderPaymentReceipt } = receiptModule.exports; +const receiptCapabilities = { + example: { providerName: 'Example', receiptOrigins: ['https://receipts.example.com'], customerEmailConfirmedModes: ['live'] }, +}; +const receiptPolicy = (url, mode = 'live') => paymentReceiptPresentation('example', mode, url, receiptCapabilities); + +assert.equal(receiptPolicy('https://receipts.example.com/receipt/123').kind, 'link'); +assert.equal(receiptPolicy('https://receipts.example.com/receipt/123').url, 'https://receipts.example.com/receipt/123'); +assert.equal(receiptPolicy(undefined).kind, 'email'); +assert.equal(receiptPolicy(undefined, 'test').kind, 'reference'); +assert.equal(receiptPolicy('https://receipts.example.com/receipt/123', 'unknown').kind, 'reference'); +assert.equal(paymentReceiptPresentation('unknown', 'live', 'https://receipts.example.com/receipt/123', receiptCapabilities).kind, 'reference'); +assert.equal(paymentReceiptPresentation('toString', 'live', undefined, receiptCapabilities).kind, 'reference'); + +for (const invalidUrl of [ + 'http://receipts.example.com/123', 'https://receipts.example.com.evil.example/123', + 'https://receipts.example.com:8443/123', 'https://user:password@receipts.example.com/123', + 'javascript:alert(1)', '/receipt/123', 'https://receipts.example.com/\n123', + 'https://receipts.example.com/\\123', 'https://evil.example/123', 123, +]) { + assert.equal(receiptPolicy(invalidUrl).kind, 'email', 'Invalid URLs fall back only to confirmed email'); + assert.equal(receiptPolicy(invalidUrl, 'test').kind, 'reference'); +} + +for (const mode of ['test', 'live']) { + assert.equal(paymentReceiptPresentation('paystack', mode, 'https://paystack.com/receipt/123').kind, 'reference'); + assert.equal(paymentReceiptPresentation('paystack', mode, undefined).kind, 'reference'); +} + +class Element { + hidden = false; + textContent = ''; + attributes = new Map(); + + setAttribute(name, value) { + this.attributes.set(name, value); + } + + removeAttribute(name) { + this.attributes.delete(name); + } +} + +const receiptLink = new Element(); +const receiptEmail = new Element(); + +renderPaymentReceipt(receiptLink, receiptEmail, receiptPolicy('https://receipts.example.com/receipt/123')); +assert.equal(receiptLink.hidden, false); +assert.equal(receiptEmail.hidden, true); +renderPaymentReceipt(receiptLink, receiptEmail, receiptPolicy(undefined)); +assert.equal(receiptLink.hidden, true); +assert.equal(receiptLink.attributes.has('href'), false, 'Email fallback clears a previously rendered URL'); +assert.equal(receiptEmail.hidden, false); +renderPaymentReceipt(receiptLink, receiptEmail, { kind: 'reference' }); +assert.equal(receiptEmail.hidden, true); +assert.equal(receiptEmail.textContent, ''); +assert.equal(receiptLink.attributes.has('href'), false, 'Retries and non-verified outcomes cannot retain a receipt URL'); + +console.log('PASS: provider receipt capabilities, URL safety, mode-specific email fallback, and stale receipt reset.'); + +const confirmationSource = readFileSync(root + 'src/pages/devcon26/payment-confirmation.astro', 'utf8'); + +assert.match(confirmationSource, /@media print[\s\S]*?\.provider-receipt-link[^{]*\{\s*display: none !important;/, 'Native printing hides provider receipt actions'); diff --git a/src/components/Devcon26Feature.astro b/src/components/Devcon26Feature.astro index 965eda6..a55764f 100644 --- a/src/components/Devcon26Feature.astro +++ b/src/components/Devcon26Feature.astro @@ -7,7 +7,7 @@ · Ghana Digital Center, Accra

- Registration opens soon + Buy tickets diff --git a/src/components/Devcon26HomeLink.astro b/src/components/Devcon26HomeLink.astro new file mode 100644 index 0000000..727b645 --- /dev/null +++ b/src/components/Devcon26HomeLink.astro @@ -0,0 +1,56 @@ +--- +--- + Back to DevCongress + + diff --git a/src/components/Devcon26PhotoAdjuster.astro b/src/components/Devcon26PhotoAdjuster.astro new file mode 100644 index 0000000..183985a --- /dev/null +++ b/src/components/Devcon26PhotoAdjuster.astro @@ -0,0 +1,155 @@ + +
+

Adjust your photo

+

Position your photo inside the badge. Your preview and download use the same crop.

+
+
+ +
+ + + + + + +
+
+

+
+ + + +
+
+ + diff --git a/src/components/HeroSection.astro b/src/components/HeroSection.astro index f55661d..0695586 100644 --- a/src/components/HeroSection.astro +++ b/src/components/HeroSection.astro @@ -42,7 +42,7 @@ const colorClass: Record = {
{hero.cta_primary.text} diff --git a/src/components/Navbar.astro b/src/components/Navbar.astro index d53230d..0bbe8fb 100644 --- a/src/components/Navbar.astro +++ b/src/components/Navbar.astro @@ -11,7 +11,7 @@ const links = conference ? [ { href: '/devcon26/#about', label: 'About' }, { href: '/devcon26/#the-day', label: 'The day' }, { href: '/devcon26/#speakers', label: 'Speakers' }, - { href: '/devcon26/#tickets', label: 'Register' }, + { href: '/devcon26/#tickets', label: 'Tickets' }, { href: '/devcon26/#faqs', label: 'FAQs' }, ] : [ { href: '/#community', label: 'Community' }, diff --git a/src/lib/devcon26-badge-role.ts b/src/lib/devcon26-badge-role.ts new file mode 100644 index 0000000..4768d02 --- /dev/null +++ b/src/lib/devcon26-badge-role.ts @@ -0,0 +1,31 @@ +// A small dot-matrix alphabet keeps role lettering identical in canvas and SVG +// exports, without relying on a remotely loaded display font. +const GLYPHS: Record = { + A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'], + T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'], + E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'], + N: ['10001', '11001', '11001', '10101', '10011', '10011', '10001'], + D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'], + S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'], + P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'], + K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'], + R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'], +}; + +export function badgeRoleDots(role: 'attendee' | 'speaker'): Array<{ x: number; y: number; radius: number }> { + const label = role.toUpperCase(); + const pitch = 7; + const radius = 2.35; + const left = 944 - ((label.length * 6 - 2) * pitch + radius); + const dots: Array<{ x: number; y: number; radius: number }> = []; + + Array.from(label).forEach((letter, index) => { + GLYPHS[letter].forEach((row, rowIndex) => { + Array.from(row).forEach((pixel, column) => { + if (pixel === '1') dots.push({ x: left + (index * 6 + column) * pitch, y: 1195 + rowIndex * pitch, radius }); + }); + }); + }); + + return dots; +} diff --git a/src/lib/devcon26-digital-badge.ts b/src/lib/devcon26-digital-badge.ts index db0a584..8786b6e 100644 --- a/src/lib/devcon26-digital-badge.ts +++ b/src/lib/devcon26-digital-badge.ts @@ -1,3 +1,5 @@ +import { badgeRoleDots } from './devcon26-badge-role'; + type Crop = { zoom: number; x: number; y: number }; export type DigitalBadgeRole = 'attendee' | 'speaker'; type NameLayout = { @@ -255,14 +257,11 @@ export async function paintDigitalBadge( }); } - const roleLabel = role === 'attendee' ? 'ATTENDEE' : role === 'speaker' ? 'SPEAKER' : null; - - if (roleLabel) { - context.fillStyle = PINK; - context.font = `600 44px ${FONT}`; - context.textAlign = 'right'; - context.fillText(roleLabel, 944, 1234); - context.textAlign = 'left'; + context.fillStyle = PINK; + for (const dot of badgeRoleDots(role ?? 'attendee')) { + context.beginPath(); + context.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2); + context.fill(); } context.fillStyle = '#d4cfc2'; diff --git a/src/lib/devcon26-flexible-checkout.ts b/src/lib/devcon26-flexible-checkout.ts new file mode 100644 index 0000000..431d729 --- /dev/null +++ b/src/lib/devcon26-flexible-checkout.ts @@ -0,0 +1,719 @@ +import { devcon26Amount } from './devcon26-checkout-contract'; + +const COMPLETE_TEST_PATH = '/api/public/annual-conference/2026/complete-test-checkout'; +const LIVE_PATH = '/api/public/annual-conference/2026/checkout'; +export const FLEXIBLE_GRANT_SESSION_KEY = 'devcon26-flexible-checkout-grant-v1'; + +type AllocationKind = 'self' | 'gift' | 'community_managed' | 'community_shareable'; + +interface Allocation { + kind: AllocationKind; + recipient_email?: string; +} + +interface AllocationChoice { + kind: AllocationKind; + label: string; + description: string; +} + +interface LiveCatalog { + unitAmountMinor: number; + maxPerPayment: number; + provider: 'paystack'; +} + +const ALLOCATION_CHOICES: AllocationChoice[] = [ + { kind: 'self', label: 'My ticket', description: 'Keep this place for yourself' }, + { kind: 'gift', label: 'Private gift', description: 'Send it directly by email' }, + { kind: 'community_managed', label: 'Community pool', description: 'Let the organizers assign this place' }, + { kind: 'community_shareable', label: 'Shareable claim', description: 'Get a private link to share later' }, +]; + +function apiOrigin(value: string): string { + const url = new URL(value); + const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); + + if ((url.protocol !== 'https:' && !(loopback && url.protocol === 'http:')) + || url.username || url.password || url.pathname !== '/' || url.search || url.hash) { + throw new Error('Checkout API origin is invalid.'); + } + + return url.origin; +} + +class CheckoutResponseError extends Error {} + +async function api(origin: string, path: string, body?: Record): Promise> { + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 15000); + + try { + const response = await fetch(new URL(path, origin), { + method: body ? 'POST' : 'GET', + credentials: 'omit', + cache: 'no-store', + headers: body ? { 'Content-Type': 'application/json' } : { Accept: 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + const payload: unknown = await response.json().catch(() => null); + + if (!response.ok && path === COMPLETE_TEST_PATH + '/initialize' + && payload && typeof payload === 'object' && !Array.isArray(payload) + && 'error' in payload && typeof payload.error === 'string' && payload.error.trim() + && payload.error.length <= 500) { + throw new CheckoutResponseError(payload.error); + } + + if (!response.ok || !payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('Checkout response unavailable (HTTP ' + response.status + ').'); + } + + return payload as Record; + } finally { + window.clearTimeout(timeout); + } +} + +function liveCatalog(payload: Record, mode: 'live' | 'test'): LiveCatalog | null { + const flexible = payload.flexible_checkout as Record | undefined; + const providers = payload.payment_providers; + + if (payload.mode !== mode || payload.currency !== 'GHS' || !flexible || flexible.mode !== mode + || !Number.isSafeInteger(flexible.unit_amount_minor) || (flexible.unit_amount_minor as number) <= 0 + || !Number.isSafeInteger(flexible.max_per_payment) || (flexible.max_per_payment as number) < 1 + || !Array.isArray(providers) || providers.length !== 1 || providers[0]?.key !== 'paystack') return null; + + return { + unitAmountMinor: flexible.unit_amount_minor as number, + maxPerPayment: flexible.max_per_payment as number, + provider: 'paystack', + }; +} + +function paystackUrl(value: unknown): URL { + if (typeof value !== 'string') throw new Error('Invalid payment destination.'); + + const url = new URL(value); + + if (url.protocol !== 'https:' || url.hostname !== 'checkout.paystack.com' + || url.username || url.password || url.port) throw new Error('Invalid payment destination.'); + + return url; +} + +export function initializeFlexibleCheckout(page: HTMLElement): void { + const completeTest = page.dataset.completeTest === 'true'; + const mode = completeTest ? 'test' : 'live'; + const catalogPath = completeTest ? COMPLETE_TEST_PATH : LIVE_PATH; + const initializePath = completeTest ? COMPLETE_TEST_PATH + '/initialize' : LIVE_PATH + '/flexible/initialize'; + const form = page.querySelector('[data-live-form]')!; + const quantity = page.querySelector('[data-quantity]')!; + const rows = page.querySelector('[data-allocation-list]')!; + const name = page.querySelector('[data-name]')!; + const email = page.querySelector('[data-email]')!; + const pay = page.querySelector('[data-pay]')!; + const retry = page.querySelector('[data-retry]')!; + const status = page.querySelector('[data-status]')!; + const availability = page.querySelector('[data-availability]')!; + const availabilityHeading = page.querySelector('[data-availability-heading]')!; + const availabilityMessage = page.querySelector('[data-availability-message]')!; + const checkoutLayout = page.querySelector('[data-checkout-layout]')!; + const steps = Array.from(page.querySelectorAll('[data-checkout-step]')); + const stepLinks = Array.from(page.querySelectorAll('[data-step-link]')); + let currentStep = 0; + let challengeRendered = false; + let origin: string; + let catalog: LiveCatalog | null = null; + let allocations: Allocation[] = [{ kind: 'self' }]; + let liveBusy = false; + let liveRequestKey: string | undefined; + + try { + origin = apiOrigin(page.dataset.apiOrigin ?? ''); + } catch { + status.textContent = 'Checkout configuration is unavailable.'; + availabilityHeading.textContent = 'Ticket checkout is unavailable'; + availabilityMessage.textContent = 'We couldn’t load checkout. Please try again later.'; + + return; + } + + function invalidateLiveRequest(): void { + liveRequestKey = undefined; + updateStepContext(); + } + + function updateStepContext(): void { + const titles = ['Your ticket choices', 'Your order contact', 'Ready to check out']; + const descriptions = [ + 'Each ticket follows the choice you make here.', + 'Your order confirmation goes to this contact. Gift tickets go to their recipients.', + 'Check your contact and ticket choices. Ticket and badge delivery follows payment.', + ]; + const items: string[] = []; + + if (currentStep > 0) { + items.push(name.value.trim() || 'Your name'); + items.push(email.value.trim() || 'Your email address'); + } + + if (currentStep !== 1) { + const communityCount = allocations.filter((allocation) => allocation.kind === 'community_managed').length; + + if (communityCount) items.push(`Community · ${communityCount} ${communityCount === 1 ? 'ticket' : 'tickets'}`); + + allocations.forEach((allocation, index) => { + if (allocation.kind === 'community_managed') return; + + const choice = ALLOCATION_CHOICES.find((option) => option.kind === allocation.kind)!; + const recipient = allocation.kind === 'gift' && allocation.recipient_email + ? ' · ' + allocation.recipient_email : ''; + + items.push('Ticket ' + (index + 1) + ' · ' + choice.label + recipient); + }); + } + + page.querySelector('[data-context-label]')!.textContent = titles[currentStep]; + page.querySelector('[data-context-description]')!.textContent = descriptions[currentStep]; + const list = page.querySelector('[data-context-items]')!; + + list.replaceChildren(...items.map((text) => { + const item = document.createElement('li'); + + item.textContent = text; + + return item; + })); + stepLinks.forEach((button, index) => { + button.disabled = liveBusy || !catalog || index > currentStep; + }); + page.querySelectorAll('[data-step-next], [data-step-back]').forEach((button) => { + button.disabled = liveBusy || !catalog; + }); + } + + function renderChallenge(): void { + if (!completeTest || currentStep !== 2 || challengeRendered) return; + + const challenge = page.querySelector('[data-checkout-challenge]'); + const turnstile = (window as unknown as { + turnstile?: { render: (container: HTMLElement, options: { sitekey: string; action: string }) => string }; + }).turnstile; + + if (!challenge || !turnstile) return; + + turnstile.render(challenge, { + sitekey: challenge.dataset.sitekey!, + action: 'devcon26_complete_test_checkout', + }); + challengeRendered = true; + } + + document.querySelector('script[src*="challenges.cloudflare.com/turnstile/"]') + ?.addEventListener('load', renderChallenge); + + function showStep(index: number, focus = true): void { + const direction = index >= currentStep ? 1 : -1; + + currentStep = index; + page.querySelector('[data-mobile-step-name]')!.textContent = ['Tickets', 'Your details', 'Payment'][index]; + page.querySelector('[data-mobile-step-count]')!.textContent = `Step ${index + 1} of 3`; + page.querySelectorAll('.mobile-progress-track i').forEach((segment, position) => { + segment.dataset.current = String(position <= index); + }); + page.querySelector('[data-ticket-controls]')!.hidden = index !== 0; + steps.forEach((section, position) => { + section.hidden = position !== index; + }); + stepLinks.forEach((button, position) => { + button.dataset.complete = String(position < index); + const marker = button.querySelector('span'); + + if (marker) marker.textContent = position < index ? '✓' : String(position + 1).padStart(2, '0'); + if (position === index) button.setAttribute('aria-current', 'step'); + else button.removeAttribute('aria-current'); + }); + updateStepContext(); + renderChallenge(); + + if (focus) { + const heading = index === 0 ? page.querySelector('#quantity-heading') : steps[index].querySelector('h2'); + + heading?.focus({ preventScroll: true }); + steps[index].scrollIntoView({ block: 'nearest' }); + + if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + steps[index].getAnimations().forEach((animation) => animation.cancel()); + steps[index].animate([ + { opacity: 0, transform: 'translateX(' + direction * 8 + 'px)' }, + { opacity: 1, transform: 'translateX(0)' }, + ], { duration: 180, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' }); + } + } + } + + function validateStep(index: number): boolean { + if (index === 0 && Number(quantity.value) !== allocations.length) setQuantity(Number(quantity.value)); + if (index === 1) name.setCustomValidity(name.value.trim() ? '' : 'Enter your full name.'); + + const inputs = Array.from(steps[index].querySelectorAll('input:not([type="hidden"])')); + if (index === 0) inputs.unshift(quantity); + + const invalid = inputs.find((input) => !input.checkValidity()); + + if (invalid) { + showStep(index, false); + invalid.reportValidity(); + + return false; + } + + return index !== 0 || Number(quantity.value) === allocations.length; + } + + function nextStep(): void { + if (!catalog || liveBusy || currentStep >= 2 || !validateStep(currentStep)) return; + + showStep(currentStep + 1); + } + + page.querySelectorAll('[data-step-next]').forEach((button) => { + button.addEventListener('click', nextStep); + }); + page.querySelectorAll('[data-step-back]').forEach((button) => { + button.addEventListener('click', () => { + if (!liveBusy && currentStep > 0) showStep(currentStep - 1); + }); + }); + stepLinks.forEach((button, index) => { + button.addEventListener('click', () => { + if (!liveBusy && catalog && index < currentStep) showStep(index); + }); + }); + + function chooseAllocation(index: number, kind: AllocationKind): void { + if (!catalog || liveBusy) return; + if (kind === 'self' && allocations.some((allocation, other) => other !== index && allocation.kind === 'self')) return; + + const restoreFocus = document.activeElement?.matches(':focus-visible'); + const previous = allocations[index]; + + allocations[index] = kind === 'gift' + ? { kind, recipient_email: previous.recipient_email ?? '' } + : { kind }; + invalidateLiveRequest(); + renderRows(); + if (restoreFocus) { + const target = kind === 'community_managed' + ? rows.querySelector('[data-community-increase]:not(:disabled), .community-change-ticket:not(:disabled)') + : rows.querySelector(`[data-choice-ticket="${index}"][value="${kind}"]`); + + target?.focus({ preventScroll: true }); + } + } + + function removeTicket(index: number): void { + if (!catalog || liveBusy || index < 1 || allocations.length <= 1) return; + + allocations.splice(index, 1); + quantity.value = String(allocations.length); + quantity.setCustomValidity(''); + invalidateLiveRequest(); + renderRows(); + const nextIndex = Math.min(index, allocations.length - 1); + + rows.querySelector(`[data-choice-ticket="${nextIndex}"]:checked`)?.focus({ preventScroll: true }); + page.querySelector('[data-ticket-announcement]')!.textContent = `Ticket ${index + 1} removed. ${allocations.length} tickets remaining.`; + } + + function changeCommunityQuantity(delta: number): void { + if (!catalog || liveBusy) return; + if (delta > 0 && allocations.length >= catalog.maxPerPayment) return; + if (delta < 0 && allocations.length <= 1) return; + + if (delta > 0) { + allocations.push({ kind: 'community_managed' }); + } else { + const index = allocations.map((allocation) => allocation.kind).lastIndexOf('community_managed'); + + if (index < 0) return; + allocations.splice(index, 1); + } + + quantity.value = String(allocations.length); + quantity.setCustomValidity(''); + invalidateLiveRequest(); + renderRows(); + const target = rows.querySelector(delta > 0 ? '[data-community-increase]' : '[data-community-decrease]'); + + if (target && !target.disabled) target.focus({ preventScroll: true }); + else rows.querySelector('[data-community-decrease]:not(:disabled), .community-change-ticket:not(:disabled), [data-community-add-other]:not(:disabled)')?.focus({ preventScroll: true }); + page.querySelector('[data-ticket-announcement]')!.textContent = `${allocations.filter((allocation) => allocation.kind === 'community_managed').length} community tickets. ${allocations.length} tickets in total.`; + } + + const summaryDetails = page.querySelector('.summary-details')!; + const mobileLayout = window.matchMedia('(max-width: 820px)'); + const syncSummary = () => { summaryDetails.open = !mobileLayout.matches; }; + + syncSummary(); + mobileLayout.addEventListener('change', syncSummary); + + function renderRows(): void { + const fragment = document.createDocumentFragment(); + const selfIndex = allocations.findIndex((allocation) => allocation.kind === 'self'); + const communityCount = allocations.filter((allocation) => allocation.kind === 'community_managed').length; + + allocations.forEach((allocation, index) => { + if (allocation.kind === 'community_managed') return; + + const row = document.createElement('fieldset'); + const legend = document.createElement('legend'); + const choices = document.createElement('div'); + const helper = document.createElement('p'); + const helperId = 'ticket-choice-help-' + index; + const shortLabels: Record = { + self: 'For me', + gift: 'A gift', + community_managed: 'Community', + community_shareable: 'Share a link', + }; + + row.className = 'ticket-choice-row'; + legend.textContent = 'Ticket ' + (index + 1); + choices.className = 'ticket-choices'; + helper.className = 'ticket-choice-help'; + helper.id = helperId; + helper.textContent = ALLOCATION_CHOICES.find((choice) => choice.kind === allocation.kind)!.description + '.'; + + if (selfIndex !== -1 && selfIndex !== index) { + helper.textContent += ' Your own place is Ticket ' + (selfIndex + 1) + '.'; + } + + ALLOCATION_CHOICES.forEach((choice) => { + const label = document.createElement('label'); + const radio = document.createElement('input'); + const labelText = document.createElement('span'); + const unavailable = choice.kind === 'self' && selfIndex !== -1 && selfIndex !== index; + + label.className = 'ticket-choice'; + radio.type = 'radio'; + radio.name = 'ticket-choice-' + index; + radio.value = choice.kind; + radio.checked = choice.kind === allocation.kind; + radio.disabled = !catalog || liveBusy || unavailable; + radio.dataset.choiceTicket = String(index); + radio.setAttribute('aria-describedby', helperId); + labelText.textContent = shortLabels[choice.kind]; + + if (unavailable) { + radio.setAttribute('aria-label', 'For me — already assigned to another ticket'); + label.title = 'You already have a ticket for yourself.'; + } + + radio.addEventListener('change', () => { + if (radio.checked) chooseAllocation(index, choice.kind); + }); + label.append(radio, labelText); + choices.append(label); + }); + + row.append(legend); + + if (index > 0) { + const remove = document.createElement('button'); + + remove.type = 'button'; + remove.className = 'remove-ticket'; + remove.textContent = 'Remove'; + remove.setAttribute('aria-label', `Remove ticket ${index + 1}`); + remove.disabled = !catalog || liveBusy; + remove.addEventListener('click', () => removeTicket(index)); + legend.append(remove); + } + + row.append(choices, helper); + + if (allocation.kind === 'gift') { + const gift = document.createElement('input'); + + gift.className = 'gift-email'; + gift.type = 'email'; + gift.maxLength = 254; + gift.required = true; + gift.placeholder = 'Recipient email address'; + gift.autocomplete = 'off'; + gift.value = allocation.recipient_email ?? ''; + gift.setAttribute('aria-label', 'Gift recipient email for ticket ' + (index + 1)); + gift.addEventListener('input', () => { + allocations[index].recipient_email = gift.value.trim().toLowerCase(); + invalidateLiveRequest(); + }); + const giftLabel = document.createElement('label'); + + gift.id = `gift-email-${index}`; + giftLabel.htmlFor = gift.id; + giftLabel.className = 'gift-email-label'; + giftLabel.textContent = 'Recipient email'; + row.append(giftLabel, gift); + } + + fragment.append(row); + }); + + if (communityCount) { + const group = document.createElement('div'); + const legend = document.createElement('h4'); + const footer = document.createElement('div'); + const quantityLabel = document.createElement('span'); + const description = document.createElement('p'); + const controls = document.createElement('div'); + const count = document.createElement('output'); + const decrease = document.createElement('button'); + const increase = document.createElement('button'); + const change = document.createElement('button'); + + group.className = 'community-ticket-group'; + group.setAttribute('role', 'group'); + group.setAttribute('aria-labelledby', 'community-ticket-heading'); + group.setAttribute('aria-describedby', 'community-ticket-description'); + legend.id = 'community-ticket-heading'; + legend.textContent = 'Support the community'; + description.id = 'community-ticket-description'; + description.textContent = 'Organizers assign these tickets to community members. No recipient details needed.'; + footer.className = 'community-ticket-controls'; + quantityLabel.className = 'community-quantity-label'; + quantityLabel.textContent = 'Community tickets'; + controls.className = 'quantity-control'; + count.textContent = String(communityCount); + count.setAttribute('aria-label', 'Community tickets'); + decrease.type = increase.type = change.type = 'button'; + decrease.textContent = '−'; + increase.textContent = '+'; + decrease.dataset.communityDecrease = ''; + increase.dataset.communityIncrease = ''; + decrease.setAttribute('aria-label', 'Remove one community ticket'); + increase.setAttribute('aria-label', 'Add one community ticket'); + decrease.disabled = liveBusy || allocations.length <= 1; + increase.disabled = liveBusy || allocations.length >= (catalog?.maxPerPayment ?? 1); + decrease.addEventListener('click', () => changeCommunityQuantity(-1)); + increase.addEventListener('click', () => changeCommunityQuantity(1)); + change.className = 'community-change-ticket'; + change.textContent = 'Change one ticket’s use'; + change.disabled = liveBusy; + change.addEventListener('click', () => { + const index = allocations.findIndex((allocation) => allocation.kind === 'community_managed'); + + chooseAllocation(index, 'gift'); + rows.querySelector(`[data-choice-ticket="${index}"]:checked`)?.focus({ preventScroll: true }); + }); + controls.append(decrease, count, increase); + footer.append(quantityLabel, controls); + group.append(legend, description, footer, change); + fragment.append(group); + } + + { + const add = document.createElement('button'); + + add.type = 'button'; + add.className = 'community-add-other'; + add.dataset.communityAddOther = ''; + add.textContent = 'Add another ticket'; + add.disabled = liveBusy || !catalog || allocations.length >= catalog.maxPerPayment; + add.addEventListener('click', () => { + if (!catalog || liveBusy || allocations.length >= catalog.maxPerPayment) return; + + allocations.push({ kind: 'gift', recipient_email: '' }); + quantity.value = String(allocations.length); + quantity.setCustomValidity(''); + invalidateLiveRequest(); + renderRows(); + rows.querySelector(`[data-choice-ticket="${allocations.length - 1}"]:checked`)?.focus({ preventScroll: true }); + }); + fragment.append(add); + } + + rows.replaceChildren(fragment); + updateStepContext(); + page.querySelector('[data-summary-quantity]')!.textContent = String(allocations.length); + page.querySelector('[data-total]')!.textContent = catalog + ? devcon26Amount(catalog.unitAmountMinor * allocations.length) : '—'; + page.querySelector('[data-mobile-total]')!.textContent = catalog + ? devcon26Amount(catalog.unitAmountMinor * allocations.length) : '—'; + page.querySelector('[data-decrease]')!.disabled = !catalog || liveBusy || allocations.length <= 1; + page.querySelector('[data-increase]')!.disabled = !catalog || liveBusy || allocations.length >= (catalog?.maxPerPayment ?? 1); + } + + function setQuantity(value: number): void { + if (!catalog || !Number.isSafeInteger(value) || value < 1 || value > catalog.maxPerPayment) { + quantity.setCustomValidity('Choose between 1 and ' + (catalog?.maxPerPayment ?? 1) + ' tickets.'); + quantity.reportValidity(); + + return; + } + + quantity.setCustomValidity(''); + const communityOnly = allocations.every((allocation) => allocation.kind === 'community_managed'); + + while (allocations.length < value) { + allocations.push(communityOnly ? { kind: 'community_managed' } : { kind: 'gift', recipient_email: '' }); + } + allocations = allocations.slice(0, value); + quantity.value = String(value); + invalidateLiveRequest(); + renderRows(); + } + + page.querySelector('[data-decrease]')!.addEventListener('click', () => setQuantity(allocations.length - 1)); + page.querySelector('[data-increase]')!.addEventListener('click', () => setQuantity(allocations.length + 1)); + quantity.addEventListener('change', () => setQuantity(Number(quantity.value))); + name.addEventListener('input', () => { + name.setCustomValidity(''); + invalidateLiveRequest(); + }); + email.addEventListener('input', invalidateLiveRequest); + + async function checkLive(): Promise { + const retryRequested = document.activeElement === retry; + + catalog = null; + pay.disabled = true; + retry.hidden = !retryRequested; + retry.disabled = true; + quantity.disabled = true; + name.disabled = true; + email.disabled = true; + status.textContent = 'Checking whether checkout is open…'; + checkoutLayout.hidden = true; + availability.hidden = false; + availabilityHeading.textContent = 'Checking ticket availability…'; + availabilityMessage.textContent = 'We’re checking prices and whether checkout is ready.'; + page.querySelector('[data-provider]')!.hidden = true; + page.querySelector('[data-quantity-limit]')!.textContent = 'Checking ticket availability…'; + page.querySelector('[data-unit-price]')!.textContent = 'Confirming…'; + renderRows(); + + try { + const result = liveCatalog(await api(origin, catalogPath), mode); + + if (!result) throw new Error('Flexible checkout is not open.'); + + catalog = result; + quantity.max = String(result.maxPerPayment); + quantity.disabled = false; + name.disabled = false; + email.disabled = false; + pay.disabled = false; + page.querySelector('[data-unit-price]')!.textContent = devcon26Amount(result.unitAmountMinor); + page.querySelector('[data-quantity-limit]')!.textContent = 'Up to ' + result.maxPerPayment + ' tickets in one payment.'; + page.querySelector('[data-provider]')!.hidden = false; + retry.hidden = true; + availability.hidden = true; + checkoutLayout.hidden = false; + status.textContent = 'Checkout is open. Review your choices before continuing.'; + renderRows(); + + if (retryRequested) quantity.focus(); + } catch (error) { + console.warn('Checkout availability check failed:', error instanceof Error ? error.message : 'Unknown error'); + availabilityHeading.textContent = 'Ticket checkout is unavailable'; + availabilityMessage.textContent = 'We can’t load ticket checkout right now. Try again shortly.'; + status.textContent = 'Ticket checkout is unavailable. Try again shortly.'; + page.querySelector('[data-quantity-limit]')!.textContent = 'Ticket availability could not be confirmed.'; + page.querySelector('[data-unit-price]')!.textContent = 'Unavailable'; + page.querySelector('[data-total]')!.textContent = '—'; + retry.hidden = false; + retry.disabled = false; + + if (retryRequested) retry.focus(); + } + } + + form.addEventListener('submit', async (event) => { + event.preventDefault(); + + if (!catalog || liveBusy) return; + + if (currentStep < 2) { + nextStep(); + + return; + } + + if (!validateStep(0) || !validateStep(1)) return; + if (allocations.filter((allocation) => allocation.kind === 'self').length > 1) return; + + const challengeToken = completeTest ? String(new FormData(form).get('cf-turnstile-response') ?? '') : ''; + + if (completeTest && !challengeToken) { + status.textContent = 'Please complete the human check before continuing.'; + + return; + } + + const cart = { + quantity: allocations.length, + payment_provider: catalog.provider, + allocations: allocations.map((allocation) => allocation.kind === 'gift' + ? { kind: allocation.kind, recipient_email: allocation.recipient_email?.trim().toLowerCase() } + : { kind: allocation.kind }), + }; + const expectedAmount = catalog.unitAmountMinor * allocations.length; + + liveBusy = true; + liveRequestKey ||= crypto.randomUUID(); + pay.disabled = true; + status.textContent = 'Opening secure Paystack checkout…'; + renderRows(); + + try { + const result = await api(origin, initializePath, { + checkout_request_key: liveRequestKey, + purchaser_name: name.value.trim(), + purchaser_email: email.value.trim().toLowerCase(), + cart, + ...(completeTest ? { turnstile_token: challengeToken, turnstile_action: 'devcon26_complete_test_checkout' } : {}), + }); + + if (result.mode !== mode || result.currency !== 'GHS' + || result.quantity !== allocations.length || result.amount_minor !== expectedAmount + || typeof result.reference !== 'string' || !(completeTest ? /^devcon26-complete-test-[a-f0-9]{32}$/ : /^devcon26_[a-f0-9]{32}$/).test(result.reference) + || typeof result.checkout_grant !== 'string' || !result.checkout_grant + || typeof result.checkout_verifier !== 'string' || !/^[A-Za-z0-9_-]{40,100}$/.test(result.checkout_verifier)) { + throw new Error('The payment details could not be confirmed.'); + } + + const destination = paystackUrl(result.authorization_url); + + sessionStorage.setItem(FLEXIBLE_GRANT_SESSION_KEY, JSON.stringify({ + reference: result.reference, + checkout_grant: result.checkout_grant, + checkout_verifier: result.checkout_verifier, + })); + window.location.assign(destination.href); + } catch (error) { + status.textContent = completeTest && error instanceof CheckoutResponseError + ? error.message + : 'Checkout could not start. Try again; we’ll keep this same request.'; + if (completeTest) (window as unknown as { turnstile?: { reset: () => void } }).turnstile?.reset(); + liveBusy = false; + pay.disabled = false; + renderRows(); + } + }); + + retry.addEventListener('click', () => void checkLive()); + + window.addEventListener('pageshow', (event) => { + if (event.persisted) { + liveBusy = false; + if (completeTest) (window as unknown as { turnstile?: { reset: () => void } }).turnstile?.reset(); + void checkLive(); + } + }); + + renderRows(); + void checkLive(); +} diff --git a/src/lib/devcon26-flexible-ticket-access.ts b/src/lib/devcon26-flexible-ticket-access.ts new file mode 100644 index 0000000..b3fa242 --- /dev/null +++ b/src/lib/devcon26-flexible-ticket-access.ts @@ -0,0 +1,275 @@ +const ACCESS_PATH = '/api/public/annual-conference/2026/checkout/flexible/tickets'; +const CAPABILITY = /^[0-9a-f]{64}$/; +const ALLOCATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +interface ManagedAllocation { + allocation_id: string; + seat_number: number; + kind: 'self' | 'gift' | 'buyer_managed' | 'community_managed' | 'community_shareable'; + fulfillment_status: string; + ticket_status: string; + claim_status: string; + can_rotate_claim: boolean; +} + +function origin(value: string): string { + const url = new URL(value); + const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); + + if ((url.protocol !== 'https:' && !(loopback && url.protocol === 'http:')) + || url.username || url.password || url.pathname !== '/' || url.search || url.hash) { + throw new Error('Invalid ticket API origin.'); + } + + return url.origin; +} + +function capabilityFromHash(): string | null { + const parameters = new URLSearchParams(window.location.hash.replace(/^#/, '')); + const values = parameters.getAll('capability'); + + return values.length === 1 && CAPABILITY.test(values[0]) ? values[0] : null; +} + +async function post(apiOrigin: string, path: string, body: Record): Promise> { + const response = await fetch(new URL(ACCESS_PATH + path, apiOrigin), { + method: 'POST', + credentials: 'omit', + cache: 'no-store', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(15000), + }); + const result: unknown = await response.json().catch(() => null); + + if (!response.ok || !result || typeof result !== 'object' || Array.isArray(result)) { + throw new Error('This ticket action could not be completed.'); + } + + return result as Record; +} + +function actionButton(label: string, action: () => void): HTMLButtonElement { + const button = document.createElement('button'); + + button.type = 'button'; + button.className = 'ticket-action'; + button.textContent = label; + button.addEventListener('click', action); + + return button; +} + +function managedLabel(kind: ManagedAllocation['kind']): string { + return { + self: 'Your ticket', + gift: 'Private gift', + buyer_managed: 'Sponsor and hand over', + community_managed: 'Community pool', + community_shareable: 'Shareable claim', + }[kind]; +} + +export function initializeTicketManagement(page: HTMLElement): void { + const status = page.querySelector('[data-access-status]')!; + const list = page.querySelector('[data-management-list]')!; + const retry = page.querySelector('[data-access-retry]')!; + const capability = capabilityFromHash(); + let apiOrigin: string; + + try { + apiOrigin = origin(page.dataset.apiOrigin ?? ''); + } catch { + status.textContent = 'Ticket access is not configured.'; + + return; + } + + if (!capability) { + status.textContent = 'This private order link is missing or invalid. Open the original email link, or contact the event team.'; + + return; + } + + async function loadOrder(): Promise { + retry.hidden = true; + status.textContent = 'Loading your tickets…'; + list.replaceChildren(); + + try { + const result = await post(apiOrigin, '/order', { capability }); + + if (!Array.isArray(result.allocations) || !result.allocations.length) throw new Error('Invalid order.'); + + const allocations = result.allocations as ManagedAllocation[]; + const fragment = document.createDocumentFragment(); + + allocations.forEach((allocation) => { + if (!ALLOCATION_ID.test(allocation.allocation_id) || !Number.isSafeInteger(allocation.seat_number)) { + throw new Error('Invalid allocation.'); + } + + const card = document.createElement('article'); + const heading = document.createElement('h2'); + const detail = document.createElement('p'); + const actions = document.createElement('div'); + const resultLine = document.createElement('p'); + + card.className = 'managed-ticket'; + heading.textContent = 'Ticket ' + allocation.seat_number + ' · ' + managedLabel(allocation.kind); + detail.textContent = 'Ticket: ' + allocation.ticket_status.replaceAll('_', ' ') + + ' · Handoff: ' + allocation.claim_status.replaceAll('_', ' '); + actions.className = 'managed-actions'; + resultLine.className = 'ticket-result'; + resultLine.setAttribute('role', 'status'); + resultLine.setAttribute('aria-live', 'polite'); + + async function perform(path: string, body: Record, button: HTMLButtonElement, purpose?: 'admission' | 'badge'): Promise { + button.disabled = true; + resultLine.textContent = 'Working on ticket ' + allocation.seat_number + '…'; + + try { + const result = await post(apiOrigin, path, body); + + if (purpose) { + if (result.status !== 'rotated' || typeof result.claim_capability !== 'string' + || !CAPABILITY.test(result.claim_capability)) throw new Error('Invalid claim link.'); + + const url = new URL('/devcon26/tickets/claim/', window.location.origin); + + url.hash = 'capability=' + result.claim_capability + (purpose === 'badge' ? '&purpose=badge' : ''); + const link = document.createElement('a'); + + link.href = url.href; + link.textContent = 'Open the new private claim link'; + link.className = 'private-claim-link'; + const copy = actionButton('Copy link', () => { + void navigator.clipboard.writeText(url.href).then(() => { + copy.textContent = 'Copied'; + }).catch(() => { + copy.textContent = 'Copy failed — open the link to share it'; + }); + }); + + resultLine.replaceChildren(document.createTextNode('New private link created. Replacing it again revokes this link. '), link, document.createTextNode(' '), copy); + } else if (result.status === 'queued') { + resultLine.textContent = 'A new private gift invitation was queued for the recipient email.'; + } else { + throw new Error('Unexpected ticket response.'); + } + } catch { + resultLine.textContent = 'This ticket action is unavailable. Please try again.'; + } finally { + button.disabled = false; + } + } + + if (allocation.kind === 'community_shareable' && allocation.can_rotate_claim) { + const button = actionButton('Create or replace claim link', () => void perform('/claims/rotate', { + order_capability: capability, + allocation_id: allocation.allocation_id, + purpose: 'admission', + }, button, 'admission')); + + actions.append(button); + } + + if (allocation.kind === 'buyer_managed' && allocation.can_rotate_claim) { + const button = actionButton('Create or replace badge link', () => void perform('/claims/rotate', { + order_capability: capability, + allocation_id: allocation.allocation_id, + purpose: 'badge', + }, button, 'badge')); + + actions.append(button); + } + + if (allocation.kind === 'gift' && allocation.claim_status !== 'used') { + const button = actionButton('Reissue gift invitation', () => void perform('/gifts/reissue', { + order_capability: capability, + allocation_id: allocation.allocation_id, + }, button)); + + actions.append(button); + } + + card.append(heading, detail, actions, resultLine); + fragment.append(card); + }); + + list.replaceChildren(fragment); + status.textContent = 'Your order contains ' + allocations.length + (allocations.length === 1 ? ' ticket.' : ' tickets.'); + } catch { + status.textContent = 'Your tickets could not be loaded. Check this private link or try again.'; + retry.hidden = false; + } + } + + retry.addEventListener('click', () => void loadOrder()); + window.addEventListener('hashchange', () => window.location.reload()); + void loadOrder(); +} + +export function initializeTicketClaim(page: HTMLElement): void { + const form = page.querySelector('[data-claim-form]')!; + const name = form.querySelector('#claim-name')!; + const email = form.querySelector('#claim-email')!; + const submit = form.querySelector('[data-claim-submit]')!; + const status = form.querySelector('[data-access-status]')!; + const capability = capabilityFromHash(); + const parameters = new URLSearchParams(window.location.hash.replace(/^#/, '')); + const badge = parameters.getAll('purpose').length === 1 && parameters.get('purpose') === 'badge'; + let apiOrigin: string; + + try { + apiOrigin = origin(page.dataset.apiOrigin ?? ''); + } catch { + status.textContent = 'Ticket access is not configured.'; + submit.disabled = true; + + return; + } + + if (!capability) { + status.textContent = 'This private invitation link is missing or invalid. Open the original email link or ask the person who shared it for a new one.'; + submit.disabled = true; + + return; + } + + if (badge) { + email.required = true; + submit.firstChild!.textContent = 'Create my card '; + } + + form.addEventListener('submit', async (event) => { + event.preventDefault(); + + if (submit.disabled || !form.reportValidity()) return; + + submit.disabled = true; + status.textContent = badge ? 'Creating your card…' : 'Claiming your ticket…'; + + try { + const result = await post(apiOrigin, badge ? '/badge' : '/claim', { + capability, + name: name.value.trim(), + ...(email.value.trim() ? { email: email.value.trim().toLowerCase() } : {}), + }); + + if (result.status !== 'claimed' || !Number.isSafeInteger(result.seat_number)) { + throw new Error('Unexpected claim result.'); + } + + status.textContent = badge + ? 'Your card request is confirmed. ' + (result.delivery_queued === true + ? 'Check your email for delivery details.' : 'Contact the event team if you need delivery details.') + : 'Ticket ' + result.seat_number + ' is claimed. ' + (result.delivery_queued === true + ? 'Check your email for ticket details.' : 'Contact the event team if you need delivery details.'); + form.querySelectorAll('input').forEach((input) => { input.disabled = true; }); + } catch { + status.textContent = 'This invitation could not be claimed. It may have been used or replaced. Ask for a fresh link or contact the event team.'; + submit.disabled = false; + } + }); +} diff --git a/src/lib/devcon26-payment-confirmation.ts b/src/lib/devcon26-payment-confirmation.ts index 6e02584..0c744c5 100644 --- a/src/lib/devcon26-payment-confirmation.ts +++ b/src/lib/devcon26-payment-confirmation.ts @@ -1,9 +1,15 @@ import { DEVCON26_BUYER_GRANT_SESSION_KEY, DEVCON26_PUBLIC_CHECKOUT_PATH, devcon26Amount, devcon26Quote } from './devcon26-checkout-contract'; +import { paymentReceiptPresentation, renderPaymentReceipt } from './payment-receipts'; +import { FLEXIBLE_GRANT_SESSION_KEY } from './devcon26-flexible-checkout'; + +const COMPLETE_TEST_REFERENCE = /^devcon26-complete-test-[a-f0-9]{32}$/; +const COMPLETE_TEST_VERIFY_PATH = '/api/public/annual-conference/2026/complete-test-checkout/verify'; const TEST_REFERENCE = /^devcon26-test-[a-f0-9]{32}$/; const BUYER_REFERENCE = /^DC26-[A-F0-9]{32}$/; const LIVE_REFERENCE = /^devcon26_[a-f0-9]{32}$/; const PUBLIC_VERIFY_PATH = DEVCON26_PUBLIC_CHECKOUT_PATH + '/verify'; +const FLEXIBLE_VERIFY_PATH = DEVCON26_PUBLIC_CHECKOUT_PATH + '/flexible/verify'; const VERIFY_PATH = '/api/public/annual-conference/2026/test-checkout/verify'; const TICKET_PRESENTATION = { @@ -39,15 +45,17 @@ export interface TestPaymentSummary { hasDiscount: boolean; photo: string; buyerGrant?: string; + cardQuantity?: number; } export function devcon26ConfirmationUrl(reference: string): string { - if (!TEST_REFERENCE.test(reference) && !LIVE_REFERENCE.test(reference)) throw new Error('Invalid payment reference.'); + if (!TEST_REFERENCE.test(reference) && !LIVE_REFERENCE.test(reference) && !COMPLETE_TEST_REFERENCE.test(reference)) throw new Error('Invalid payment reference.'); return '/devcon26/payment-confirmation/#reference=' + encodeURIComponent(reference); } export function devcon26BuyerReference(reference: string): string { + if (COMPLETE_TEST_REFERENCE.test(reference)) return 'TEST-DC26-' + reference.slice('devcon26-complete-test-'.length).toUpperCase(); if (LIVE_REFERENCE.test(reference)) return 'DC26-' + reference.slice('devcon26_'.length).toUpperCase(); if (!TEST_REFERENCE.test(reference)) throw new Error('Invalid payment reference.'); @@ -66,6 +74,7 @@ function devcon26ReferenceFromParameters(parameters: URLSearchParams): string | const values = parameters.getAll('reference'); if (values.length !== 1) return null; + if (COMPLETE_TEST_REFERENCE.test(values[0])) return values[0]; if (TEST_REFERENCE.test(values[0])) return values[0]; if (LIVE_REFERENCE.test(values[0])) return values[0]; if (BUYER_REFERENCE.test(values[0])) return 'devcon26-test-' + values[0].slice('DC26-'.length).toLowerCase(); @@ -148,6 +157,34 @@ function livePaymentSummary(input: unknown): (TestPaymentSummary & { buyerGrant: }; } +function flexiblePaymentSummary(input: unknown, mode: 'live' | 'test' = 'live'): TestPaymentSummary | null { + if (!input || typeof input !== 'object' || Array.isArray(input)) return null; + + const result = input as Record; + + if (result.mode !== mode || result.status !== 'verified' || result.currency !== 'GHS' + || !Number.isSafeInteger(result.quantity) || (result.quantity as number) < 1 + || !Number.isSafeInteger(result.ticket_amount_minor) || (result.ticket_amount_minor as number) <= 0 + || !Number.isSafeInteger(result.amount_paid_minor) + || (result.amount_paid_minor as number) < (result.ticket_amount_minor as number)) return null; + + return { + status: 'verified', + buyerGrant: mode === 'test' && result.card_quantity === 1 && typeof result.buyer_grant === 'string' + && result.buyer_grant.length <= 2000 ? result.buyer_grant : undefined, + cardQuantity: mode === 'test' && result.card_quantity === 1 ? 1 : 0, + ticketName: 'DevCon26 ticket', + quantity: result.quantity as number, + amount: devcon26Amount(result.amount_paid_minor as number), + baseAmount: devcon26Amount(result.ticket_amount_minor as number), + feeAmount: devcon26Amount((result.amount_paid_minor as number) - (result.ticket_amount_minor as number)), + discountAmount: 'GHS 0.00', + coupon: null, + hasDiscount: false, + photo: '/images/devcon26/ticket-community.webp', + }; +} + export function initializeDevcon26PaymentConfirmation( root: HTMLElement, initializeCardPreview: (root: HTMLElement, quantity: number, buyerGrant?: string | null, canFinishLater?: boolean) => void = () => undefined, @@ -158,11 +195,15 @@ export function initializeDevcon26PaymentConfirmation( const receipt = root.querySelector('[data-payment-receipt]')!; const attention = root.querySelector('[data-payment-attention]')!; const retry = root.querySelector('[data-payment-retry]')!; - const print = root.querySelector('[data-payment-print]')!; + const providerReceipt = root.querySelector('[data-provider-receipt]')!; + const providerReceiptEmail = root.querySelector('[data-provider-receipt-email]')!; let operation = 0; let abort: AbortController | undefined; function showState(state: PageState, heading: string, description: string, canRetry = false): void { + if (state !== 'verified') { + renderPaymentReceipt(providerReceipt, providerReceiptEmail, { kind: 'reference' }); + } root.dataset.paymentState = state; title.textContent = heading; copy.textContent = description; @@ -180,7 +221,7 @@ export function initializeDevcon26PaymentConfirmation( abort = controller; const current = ++operation; - const reference = devcon26ConfirmationQueryReference(window.location.search) + const reference = devcon26ConfirmationQueryReference(window.location.search) ?? devcon26ConfirmationReference(window.location.hash); if (!reference) { @@ -200,10 +241,28 @@ export function initializeDevcon26PaymentConfirmation( const timeout = window.setTimeout(() => controller.abort(), 15000); try { + const completeTest = COMPLETE_TEST_REFERENCE.test(reference); const liveCheckout = LIVE_REFERENCE.test(reference); + let flexibleGrant: { reference: string; checkout_grant: string; checkout_verifier: string } | null = null; let buyerGrant: { reference: string; buyer_grant: string; buyer_verifier: string } | null = null; - if (liveCheckout) { + if (liveCheckout || completeTest) { + try { + const stored = JSON.parse(sessionStorage.getItem(FLEXIBLE_GRANT_SESSION_KEY) ?? 'null') as Record | null; + + if (stored && stored.reference === reference && typeof stored.checkout_grant === 'string' + && typeof stored.checkout_verifier === 'string') { + flexibleGrant = { + reference, + checkout_grant: stored.checkout_grant, + checkout_verifier: stored.checkout_verifier, + }; + } + } catch { + flexibleGrant = null; + } + + if (!flexibleGrant) { try { const stored = JSON.parse(sessionStorage.getItem(DEVCON26_BUYER_GRANT_SESSION_KEY) ?? 'null') as Record | null; @@ -218,18 +277,18 @@ export function initializeDevcon26PaymentConfirmation( } catch { buyerGrant = null; } + } - if (!buyerGrant) throw new Error('Secure ticket-card access is unavailable in this browser session.'); + if (!buyerGrant && !flexibleGrant) throw new Error('Secure checkout access is unavailable in this browser session.'); } - const response = await fetch(devcon26VerificationUrl(root.dataset.checkoutApiOrigin!).replace(VERIFY_PATH, liveCheckout ? PUBLIC_VERIFY_PATH : VERIFY_PATH), { + const verifyPath = completeTest ? COMPLETE_TEST_VERIFY_PATH : flexibleGrant ? FLEXIBLE_VERIFY_PATH : liveCheckout ? PUBLIC_VERIFY_PATH : VERIFY_PATH; + const response = await fetch(devcon26VerificationUrl(root.dataset.checkoutApiOrigin!).replace(VERIFY_PATH, verifyPath), { method: 'POST', credentials: 'omit', cache: 'no-store', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(liveCheckout - ? { ...buyerGrant, reference } - : { reference }), + body: JSON.stringify(flexibleGrant ?? (liveCheckout ? { ...buyerGrant, reference } : { reference })), signal: controller.signal, }); @@ -243,7 +302,7 @@ export function initializeDevcon26PaymentConfirmation( const payload = await response.json(); - if (liveCheckout && payload && typeof payload === 'object' && (payload as Record).mode === 'live') { + if ((liveCheckout || completeTest) && payload && typeof payload === 'object' && (payload as Record).mode === (completeTest ? 'test' : 'live')) { const liveStatus = (payload as Record).status; if (liveStatus === 'refund_required') { @@ -265,12 +324,18 @@ export function initializeDevcon26PaymentConfirmation( return; } } - const summary = liveCheckout ? livePaymentSummary(payload) : devcon26TestPaymentSummary(payload); + const summary = flexibleGrant ? flexiblePaymentSummary(payload, completeTest ? 'test' : 'live') + : liveCheckout ? livePaymentSummary(payload) : devcon26TestPaymentSummary(payload); if (current !== operation) return; if (!summary) throw new Error('Invalid verification result.'); if (summary.status === 'verified') { + // These verification routes currently support Paystack only. Mode comes + // from the validated checkout route, never receipt flags in the response. + renderPaymentReceipt(providerReceipt, providerReceiptEmail, + paymentReceiptPresentation('paystack', liveCheckout && !completeTest ? 'live' : 'test', payload.receipt_url)); + root.querySelector('[data-payment-ticket]')!.textContent = summary.ticketName; root.querySelector('[data-payment-quantity]')!.textContent = summary.quantity + (summary.quantity === 1 ? ' person' : ' people'); root.querySelector('[data-payment-amount]')!.textContent = summary.amount; @@ -282,8 +347,16 @@ export function initializeDevcon26PaymentConfirmation( root.querySelector('[data-payment-discount-row]')!.hidden = !summary.hasDiscount; root.querySelector('[data-payment-reference]')!.textContent = devcon26BuyerReference(reference); root.querySelector('[data-payment-photo]')!.src = summary.photo; - initializeCardPreview(root, summary.quantity, liveCheckout ? summary.buyerGrant : null, liveCheckout && summary.canFinishLater); - showState('verified', 'Payment confirmed.', 'All done. Your payment was successful, and the details are right here whenever you need them.'); + if (completeTest && summary.buyerGrant && summary.cardQuantity) { + initializeCardPreview(root, summary.cardQuantity, summary.buyerGrant, true); + } else if (!flexibleGrant) { + initializeCardPreview(root, summary.quantity, liveCheckout ? summary.buyerGrant : null, liveCheckout && summary.canFinishLater); + } + showState('verified', 'Payment confirmed.', completeTest + ? 'Personalize your conference badge below. Your ticket PDF and private links will also arrive by email.' + : flexibleGrant + ? 'Your payment is verified. Keep this payment record for your reference; it is not an admission ticket.' + : 'All done. Your payment was successful, and the details are right here whenever you need them.'); } else if (summary.status === 'refund_required') { root.querySelector('[data-attention-reference]')!.textContent = devcon26BuyerReference(reference); root.querySelector('[data-attention-amount]')!.textContent = summary.amount; @@ -306,11 +379,6 @@ export function initializeDevcon26PaymentConfirmation( if (root.dataset.paymentState !== 'loading') void verifyPayment(); }); - print.hidden = false; - print.addEventListener('click', () => { - if (root.dataset.paymentState === 'verified') window.print(); - }); - window.addEventListener('hashchange', () => void verifyPayment()); window.addEventListener('pagehide', () => { operation += 1; diff --git a/src/lib/devcon26-public-badge.ts b/src/lib/devcon26-public-badge.ts index d56cfb2..4f52937 100644 --- a/src/lib/devcon26-public-badge.ts +++ b/src/lib/devcon26-public-badge.ts @@ -1,3 +1,4 @@ +import { badgeRoleDots } from './devcon26-badge-role'; import type { PublicDevcon26SocialCard } from './live-devcon26-social-cards'; import { PHYSICAL_BADGE_PATH } from './devcon26-digital-badge'; @@ -49,7 +50,7 @@ export function renderPublicBadgeSvg(card: PublicDevcon26SocialCard, photoUrl: s ${photoUrl ? `` : ''} ${name} - ${role} + ${badgeRoleDots(card.role === 'speaker' ? 'speaker' : 'attendee').map(dot => ``).join('')} diff --git a/src/lib/devcon26-social-card-preview.ts b/src/lib/devcon26-social-card-preview.ts index 7e04d2a..7329cb9 100644 --- a/src/lib/devcon26-social-card-preview.ts +++ b/src/lib/devcon26-social-card-preview.ts @@ -3,7 +3,7 @@ import { layoutDigitalBadgeName, paintDigitalBadge } from './devcon26-digital-ba type PhotoCrop = { zoom: number; x: number; y: number }; type SeatCard = { name: string; photo: File | null; crop?: PhotoCrop }; const DEFAULT_PHOTO_CROP: PhotoCrop = { zoom: 1, x: 0.5, y: 0.18 }; -type BuyerSeat = { seat_number: number; card: { display_name: string; photo_available: boolean } }; +type BuyerSeat = { seat_number: number; card: { display_name: string; photo_available: boolean; is_test?: boolean; crop?: { x: number; y: number; scale: number } } }; type CardAssets = { base: HTMLImageElement | null; backdrop: HTMLImageElement | null }; const BUYER_CARD_PATH = '/api/public/annual-conference/2026/social-cards/buyer/collection'; const CARD_WIDTH = 1080; @@ -203,6 +203,7 @@ export function initializeDevcon26SocialCardPreview( ): void { const preview = root.querySelector('[data-social-card-preview]'); const isRegistration = root.dataset.cardContext === 'registration'; + const usesDigitalBadge = isRegistration || root.hasAttribute('data-payment-confirmation'); if (!preview || !Number.isSafeInteger(quantity) || quantity < 1 || quantity > 5) return; @@ -229,8 +230,10 @@ export function initializeDevcon26SocialCardPreview( const resume = root.querySelector('[data-social-card-resume]')!; const leaveDialog = root.querySelector('[data-social-leave-dialog]')!; const leaveCopy = root.querySelector('[data-social-leave-copy]')!; - const photoDialog = isRegistration ? root.querySelector('[data-photo-dialog]') : null; - const adjustPhoto = root.querySelector('[data-photo-adjust]'); + const photoDialog = root.querySelector('[data-photo-dialog]'); + const adjustPhotos = Array.from(root.querySelectorAll('[data-photo-adjust]')); + let photoDialogTrigger: HTMLElement | null = null; + let cropBeforeAdjusting: PhotoCrop | null = null; const adjustCanvas = photoDialog?.querySelector('[data-photo-adjust-canvas]'); const cropInputs = Array.from(photoDialog?.querySelectorAll('[data-photo-crop]') ?? []); let cropRenderTimer: number | undefined; @@ -238,6 +241,8 @@ export function initializeDevcon26SocialCardPreview( const exported = Array.from({ length: quantity }, () => false); const photoLoadVersions = Array.from({ length: quantity }, () => 0); const photoUploadVersions = Array.from({ length: quantity }, () => 0); + const photoSaved = Array.from({ length: quantity }, () => false); + let savingCrop = false; const photoUploadQueues = Array.from({ length: quantity }, () => Promise.resolve()); let step = quantity > 1 ? 0 : 1; let allowLeave = false; @@ -293,6 +298,21 @@ export function initializeDevcon26SocialCardPreview( download.hidden = step !== 2; next.hidden = step === 2 && quantity === 1; next.textContent = step === 2 ? 'Other cards' : step === 1 ? 'Preview card' : 'Personalize this card'; + if (navigation.hasAttribute('data-badge-actions')) { + navigation.dataset.actionStep = String(step); + later.hidden = step === 2 || (Boolean(buyerGrant) && !canFinishLater); + const setLabel = (button: HTMLButtonElement, label: string, arrow: string, leading = false) => { + const icon = document.createElement('span'); + + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = arrow; + button.replaceChildren(...(leading ? [icon, document.createTextNode(label)] : [document.createTextNode(label), icon])); + }; + + setLabel(back, step === 2 ? 'Edit badge' : 'Back', '←', true); + setLabel(next, step === 2 ? 'Other cards' : step === 1 ? 'Preview badge' : 'Personalize badge', '→'); + } + if (step === 2) next.setAttribute('aria-label', 'Choose another card'); else next.removeAttribute('aria-label'); if (step === 0) arrangeSeatCards(); @@ -390,6 +410,7 @@ export function initializeDevcon26SocialCardPreview( }); function updatePhotoPicker(url: string | null): void { + adjustPhotos.forEach((button) => { button.disabled = busy || !url; }); photoThumbnail.hidden = !url; photoThumbnail.removeAttribute('src'); @@ -404,7 +425,7 @@ export function initializeDevcon26SocialCardPreview( const context = ticketCanvas.getContext('2d'); if (!context) return false; - const result = isRegistration ? layoutDigitalBadgeName(context, value) : layoutDevcon26CardName(context, value); + const result = usesDigitalBadge ? layoutDigitalBadgeName(context, value) : layoutDevcon26CardName(context, value); const message = required && !result.normalized ? 'Add a display name before previewing or sharing your card.' : result.valid ? '' : result.message; @@ -457,7 +478,7 @@ export function initializeDevcon26SocialCardPreview( const version = ++renderVersion; const workCanvas = document.createElement('canvas'); const card = cards[activeSeat]; - const result = isRegistration + const result = usesDigitalBadge ? await paintDigitalBadge(workCanvas, card.name, card.photo, card.crop, 'attendee') : await paintCard(workCanvas, cardAssets.base!, cardAssets.backdrop!, card.name, card.photo, card.crop); @@ -498,13 +519,77 @@ export function initializeDevcon26SocialCardPreview( }, 60); } - adjustPhoto?.addEventListener('click', () => { + function openPhotoAdjuster(trigger: HTMLElement | null = null): void { if (busy || !photoDialog || !cards[activeSeat].photo) return; + + photoDialogTrigger = trigger; + cropBeforeAdjusting = { ...(cards[activeSeat].crop ?? DEFAULT_PHOTO_CROP) }; syncCropInputs(); - photoDialog.showModal(); + const message = photoDialog.querySelector('[data-photo-adjust-status]'); + + if (message) message.textContent = ''; + if (!photoDialog.open) photoDialog.showModal(); + repaintCrop(); + } + + adjustPhotos.forEach((button) => button.addEventListener('click', () => openPhotoAdjuster(button))); + function restorePhotoCrop() { + if (cropBeforeAdjusting) { + cards[activeSeat].crop = { ...cropBeforeAdjusting }; + repaintCrop(); + } + } + + photoDialog?.querySelector('[data-photo-cancel]')?.addEventListener('click', () => { + if (savingCrop) return; + + restorePhotoCrop(); + photoDialog.close(); + }); + + photoDialog?.addEventListener('cancel', (event) => { + if (savingCrop) { + event.preventDefault(); + return; + } + restorePhotoCrop(); + }); + photoDialog?.addEventListener('close', () => { + const target = photoDialogTrigger ?? adjustPhotos.find((button) => button.offsetParent !== null); + + target?.focus({ preventScroll: true }); + }); + photoDialog?.querySelector('[data-photo-done]')?.addEventListener('click', async () => { + if (busy || !photoDialog) return; + + const seatIndex = activeSeat; + const crop = { ...(cards[seatIndex].crop ?? DEFAULT_PHOTO_CROP) }; + const message = photoDialog.querySelector('[data-photo-adjust-status]'); + + savingCrop = true; + setBusy(true); + if (message) message.textContent = buyerGrant ? 'Saving photo position…' : ''; + + try { + await photoUploadQueues[seatIndex]; + if (buyerGrant) { + if (!photoSaved[seatIndex]) throw new Error('Choose your photo again so it can be saved.'); + await buyerRequest(`/seats/${remoteSeats[seatIndex].seat_number}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ crop: { x: crop.x, y: crop.y, scale: crop.zoom } }), + }); + } + await paintPreview(); + photoDialog.close(); + status.textContent = buyerGrant ? 'Photo and position saved to your badge.' : 'Photo position updated.'; + } catch { + if (message) message.textContent = 'Your photo position could not be saved. Check your connection and try again, or choose your photo again if the upload failed.'; + } finally { + savingCrop = false; + setBusy(false); + } }); - photoDialog?.addEventListener('close', () => { adjustPhoto?.focus(); }); - photoDialog?.querySelector('[data-photo-done]')?.addEventListener('click', () => { photoDialog.close(); }); photoDialog?.querySelector('[data-photo-reset]')?.addEventListener('click', () => { if (busy) return; cards[activeSeat].crop = { ...DEFAULT_PHOTO_CROP }; @@ -577,11 +662,24 @@ export function initializeDevcon26SocialCardPreview( }); if (!remoteSeats.length || remoteSeats.length !== quantity) throw new Error('Your ticket cards are not ready yet.'); - cards.forEach((card, index) => { card.name = remoteSeats[index].card.display_name; }); + cards.forEach((card, index) => { + const saved = remoteSeats[index].card; + + card.name = saved.display_name; + photoSaved[index] = saved.photo_available; + if (saved.crop && [saved.crop.x, saved.crop.y, saved.crop.scale].every(Number.isFinite)) { + card.crop = { + x: Math.max(0, Math.min(1, saved.crop.x)), + y: Math.max(0, Math.min(1, saved.crop.y)), + zoom: Math.max(1, Math.min(2.5, saved.crop.scale)), + }; + } + }); } async function loadRemotePhoto(seatNumber: number): Promise { const seatIndex = seatNumber - 1; + const remoteSeatNumber = remoteSeats[seatIndex]?.seat_number; if (!buyerGrant || !apiOrigin || !remoteSeats[seatIndex]?.card.photo_available || cards[seatIndex].photo) return false; const loadVersion = ++photoLoadVersions[seatIndex]; @@ -589,7 +687,7 @@ export function initializeDevcon26SocialCardPreview( let response: Response; try { - response = await fetch(new URL(`${BUYER_CARD_PATH}/seats/${seatNumber}/photo`, apiOrigin), { + response = await fetch(new URL(`${BUYER_CARD_PATH}/seats/${remoteSeatNumber}/photo`, apiOrigin), { headers: buyerHeaders(), credentials: 'omit', cache: 'no-store', @@ -666,7 +764,7 @@ export function initializeDevcon26SocialCardPreview( back.disabled = value; next.disabled = value; resume.disabled = value; - if (adjustPhoto) adjustPhoto.disabled = value; + adjustPhotos.forEach((button) => { button.disabled = value || !cards[activeSeat].photo; }); cropInputs.forEach((input) => { input.disabled = value; }); photoDialog?.querySelectorAll('button').forEach((button) => { button.disabled = value; }); seatList.querySelectorAll('button').forEach((button) => { button.disabled = value; }); @@ -732,7 +830,7 @@ export function initializeDevcon26SocialCardPreview( const card = cards[activeSeat]; if (!buyerGrant || !card.name.trim() || !validateName(card.name, true)) return; - const seatNumber = activeSeat + 1; + const seatNumber = remoteSeats[activeSeat]?.seat_number; void buyerRequest(`/seats/${seatNumber}`, { method: 'PATCH', @@ -755,8 +853,9 @@ export function initializeDevcon26SocialCardPreview( return; } + photoSaved[activeSeat] = false; cards[activeSeat].photo = file; - if (isRegistration) cards[activeSeat].crop = { ...DEFAULT_PHOTO_CROP }; + if (usesDigitalBadge) cards[activeSeat].crop = { ...DEFAULT_PHOTO_CROP }; exported[activeSeat] = false; photoLoadVersions[activeSeat] += 1; photoInput.setCustomValidity(''); @@ -765,7 +864,7 @@ export function initializeDevcon26SocialCardPreview( render(true); if (buyerGrant) { const seatIndex = activeSeat; - const seatNumber = seatIndex + 1; + const seatNumber = remoteSeats[seatIndex]?.seat_number; const uploadVersion = ++photoUploadVersions[seatIndex]; const previousUpload = photoUploadQueues[seatIndex]; @@ -778,6 +877,12 @@ export function initializeDevcon26SocialCardPreview( body.set('photo', file, file.name); await buyerRequest(`/seats/${seatNumber}/photo`, { method: 'POST', body }); + await buyerRequest(`/seats/${seatNumber}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ crop: { x: DEFAULT_PHOTO_CROP.x, y: DEFAULT_PHOTO_CROP.y, scale: DEFAULT_PHOTO_CROP.zoom } }), + }); + if (photoUploadVersions[seatIndex] === uploadVersion) photoSaved[seatIndex] = true; if (photoUploadVersions[seatIndex] === uploadVersion && activeSeat === seatIndex) { status.textContent = 'Photo saved to your social card.'; @@ -788,6 +893,7 @@ export function initializeDevcon26SocialCardPreview( } }); } + if (!isRegistration) openPhotoAdjuster(); }); async function prepareImage(): Promise { @@ -851,7 +957,7 @@ export function initializeDevcon26SocialCardPreview( later.hidden = Boolean(buyerGrant) && !canFinishLater; status.textContent = 'Loading the DevCon26 card artwork…'; setBusy(true); - void loadCardAssets(isRegistration).then((assets) => { + void loadCardAssets(usesDigitalBadge).then((assets) => { cardAssets = assets; showStep(step, false); if (!buyerGrant) { diff --git a/src/lib/devcon26-social-card.ts b/src/lib/devcon26-social-card.ts index 79629bd..a0e74dd 100644 --- a/src/lib/devcon26-social-card.ts +++ b/src/lib/devcon26-social-card.ts @@ -3,6 +3,7 @@ import { layoutDigitalBadgeName, loadDigitalBadgeFont, paintDigitalBadge, type D type Role = DigitalBadgeRole; type Card = { + is_test?: boolean; display_name: string; role: Role; crop: { x: number; y: number; scale: number }; @@ -65,8 +66,10 @@ function fileError(file: File): string | null { export function initializeDevcon26SocialCard(page: HTMLElement): void { const origin = validApiOrigin(page.dataset.socialCardApiOrigin ?? ''); const fragment = window.location.hash.slice(1); + const buyerGrant = new URLSearchParams(fragment).get('buyer_grant'); const collectionCapability = new URLSearchParams(fragment).get('collection'); - const capability = collectionCapability ? '' : fragment; + const collectionMode = Boolean(collectionCapability || buyerGrant); + const capability = collectionMode ? '' : fragment; const content = page.querySelector('[data-social-card-content]')!; const error = page.querySelector('[data-social-card-error]')!; const status = page.querySelector('[data-social-card-status]')!; @@ -111,20 +114,22 @@ export function initializeDevcon26SocialCard(page: HTMLElement): void { } function headers(): HeadersInit { + if (buyerGrant) return { 'X-Social-Card-Buyer-Grant': buyerGrant }; + return collectionCapability ? { 'X-Social-Card-Collection-Capability': collectionCapability } : { 'X-Social-Card-Capability': capability }; } function managementPath(path: string): string { - if (!collectionCapability) return '/manage' + path; + if (!collectionMode) return '/manage' + path; if (activeSeatNumber === null) throw new Error('Choose a ticket card first.'); - return `/collection/seats/${activeSeatNumber}${path}`; + return `${buyerGrant ? '/buyer' : ''}/collection/seats/${activeSeatNumber}${path}`; } async function request(path: string, init: RequestInit = {}): Promise { - if (!origin || (!collectionCapability && !capability) || capability.length > 200 || (collectionCapability && collectionCapability.length > 200)) throw new Error('This card link isn’t available.'); + if (!origin || (!collectionMode && !capability) || capability.length > 200 || (collectionCapability && collectionCapability.length > 200) || (buyerGrant && buyerGrant.length > 2000)) throw new Error('This card link isn’t available.'); const response = await fetch(endpoint(origin, managementPath(path)), { ...init, headers: { ...headers(), ...(init.headers ?? {}) }, @@ -156,7 +161,7 @@ export function initializeDevcon26SocialCard(page: HTMLElement): void { y.value = String(Math.round(crop.y * 100)); scale.value = String(crop.scale); cropControls.disabled = !selectedFile; - publish.hidden = card.public_enabled; + publish.hidden = card.public_enabled || Boolean(card.is_test); revoke.hidden = !card.public_enabled; share.hidden = !card.public_enabled; const publicUrl = new URL('/devcon26/cards/' + encodeURIComponent(card.public_slug), window.location.origin).toString(); @@ -195,6 +200,7 @@ export function initializeDevcon26SocialCard(page: HTMLElement): void { badgeCanvas.width = canvas.width; badgeCanvas.height = canvas.height; context.drawImage(canvas, 0, 0); + badgeCanvas.setAttribute('aria-label', `${snapshot.display_name} — ${snapshot.role} at DevCon26`); badgeCaption.textContent = `${snapshot.role === 'speaker' ? 'Speaker' : 'Attendee'} badge · For sharing, not admission.`; badgeCanvas.hidden = false; @@ -217,18 +223,18 @@ export function initializeDevcon26SocialCard(page: HTMLElement): void { async function load(): Promise { try { - if (collectionCapability) { + if (collectionMode) { await loadCollection(); } else { card = await request(''); } history.replaceState(null, '', window.location.pathname); - if (!collectionCapability) { + if (!collectionMode) { await loadPrivatePhoto(); render(); } content.hidden = false; - setStatus(collectionCapability ? 'Choose a ticket card to personalize.' : 'Your card is ready to personalise.'); + setStatus(collectionMode ? 'Choose a ticket card to personalize.' : 'Your card is ready to personalise.'); } catch { error.hidden = false; setStatus(''); @@ -236,8 +242,8 @@ export function initializeDevcon26SocialCard(page: HTMLElement): void { } async function loadCollection(): Promise { - if (!origin || !collectionCapability) throw new Error('This collection link isn’t available.'); - const response = await fetch(endpoint(origin, '/collection'), { headers: headers(), credentials: 'omit', cache: 'no-store' }); + if (!origin || !collectionMode) throw new Error('This collection link isn’t available.'); + const response = await fetch(endpoint(origin, buyerGrant ? '/buyer/collection' : '/collection'), { headers: headers(), credentials: 'omit', cache: 'no-store' }); const payload = await response.json().catch(() => null) as { seats?: Array<{ seat_number?: unknown }>; error?: string } | null; if (!response.ok || !Array.isArray(payload?.seats) || !payload.seats.length) { diff --git a/src/lib/payment-receipts.ts b/src/lib/payment-receipts.ts new file mode 100644 index 0000000..1ad4f6d --- /dev/null +++ b/src/lib/payment-receipts.ts @@ -0,0 +1,78 @@ +type PaymentMode = 'live' | 'test'; + +export interface ReceiptCapability { + providerName: string; + receiptOrigins: readonly string[]; + customerEmailConfirmedModes: readonly PaymentMode[]; +} + +export type ReceiptPresentation = + | { kind: 'link'; url: string } + | { kind: 'email'; message: string } + | { kind: 'reference' }; + +// Code-owned capabilities, never populated from a query parameter or API flags. +// Paystack documents receipt_number, not a customer receipt URL. Its merchant +// email preference has not been confirmed for either payment mode. +export const RECEIPT_CAPABILITIES: Readonly> = { + paystack: { + providerName: 'Paystack', + receiptOrigins: [], + customerEmailConfirmedModes: [], + }, +}; + +export function paymentReceiptPresentation( + provider: string, + mode: string, + returnedReceiptUrl: unknown, + capabilities: Readonly> = RECEIPT_CAPABILITIES, +): ReceiptPresentation { + if (!Object.hasOwn(capabilities, provider) || (mode !== 'live' && mode !== 'test')) { + return { kind: 'reference' }; + } + + const capability = capabilities[provider]; + + if (typeof returnedReceiptUrl === 'string' && returnedReceiptUrl.length <= 2048 + && !/[\u0000-\u0020\u007f\\]/.test(returnedReceiptUrl)) { + try { + const url = new URL(returnedReceiptUrl); + + if (url.protocol === 'https:' && !url.username && !url.password + && capability.receiptOrigins.includes(url.origin)) { + return { kind: 'link', url: url.href }; + } + } catch { + // Missing or malformed provider URLs fall through to confirmed email. + } + } + + if (capability.customerEmailConfirmedModes.includes(mode)) { + return { + kind: 'email', + message: capability.providerName + ' sends payment receipts to your checkout email.', + }; + } + + return { kind: 'reference' }; +} + +export function renderPaymentReceipt( + link: HTMLAnchorElement, + emailNote: HTMLElement, + presentation: ReceiptPresentation, +): void { + link.hidden = true; + link.removeAttribute('href'); + emailNote.hidden = true; + emailNote.textContent = ''; + + if (presentation.kind === 'link') { + link.setAttribute('href', presentation.url); + link.hidden = false; + } else if (presentation.kind === 'email') { + emailNote.textContent = presentation.message; + emailNote.hidden = false; + } +} diff --git a/src/pages/devcon26/checkout.astro b/src/pages/devcon26/checkout.astro index 86789fd..9efd4c1 100644 --- a/src/pages/devcon26/checkout.astro +++ b/src/pages/devcon26/checkout.astro @@ -1,12 +1,181 @@ - - - - - - - DevCon26 registration - - - Continue to DevCon26 registration updates - - +--- +import { EVENT_SUBMISSION_TURNSTILE_SITE_KEY } from '../../lib/events'; +import Base from '../../layouts/Base.astro'; +import '../../styles/devcon26-flexible-checkout.css'; +import '../../styles/devcon26-checkout-design.css'; + +const completeTest = import.meta.env.PUBLIC_DEVCON26_COMPLETE_TEST === 'true'; +const apiOrigin = import.meta.env.PUBLIC_DEVCON26_API_ORIGIN || 'https://em.devcongress.org'; +--- + + + + +
+
+
+

DevCon26 / Registration

+

See you at DevCon26.

+

Your ticket. Your community. A day of building what’s next.

+
+ +
+

Checking ticket availability…

+

We’re checking prices and whether checkout is ready.

+ +
+ + + +
+
+ {completeTest && } + + + diff --git a/src/pages/devcon26/index.astro b/src/pages/devcon26/index.astro index 38fda1e..f868ea5 100644 --- a/src/pages/devcon26/index.astro +++ b/src/pages/devcon26/index.astro @@ -1,6 +1,7 @@ --- import { getCollection } from 'astro:content'; import Base from '../../layouts/Base.astro'; +import Devcon26HomeLink from '../../components/Devcon26HomeLink.astro'; const speakerSlots = ['01', '02', '03']; const heroScenes = [ @@ -121,8 +122,8 @@ heroSlots[0].photos.push({ const faqs = [ ['Who is DevCon26 for?', 'For the people building technology across Africa: developers, designers, founders, product people, researchers, and students.'], ['When and where is it?', 'Meet us at Ghana Digital Center (formerly Accra Digital Center), Accra, Ghana on 19 December 2026. The programme will be announced here.'], - ['Can I come with my team?', 'Yes. When registration opens, each person will register separately with their own name and email.'], - ['How do I register?', 'Registration opens soon. Check back here for updates.'], + ['Can I come with my team?', 'Yes. Buy individual tickets together in one checkout, then choose who each ticket is for. Each ticket admits one person.'], + ['How do I get a ticket?', 'Go to ticket checkout, choose your tickets, and complete payment. Your ticket email includes a QR ticket PDF and a link to personalize your conference badge.'], ]; const sponsorIds = new Set(['kweku_tech']); @@ -213,7 +214,7 @@ function packageEnquiryLink(sponsorship: (typeof sponsorshipPackages)[number]) {

- Back to DevCongress + DevCongress annual conference 2026 @@ -222,7 +223,7 @@ function packageEnquiryLink(sponsorship: (typeof sponsorshipPackages)[number]) {

A year together.
One big gathering.

Meetups, ideas, and new connections all year. Join the community at DevCon26.

- Register now + Buy tickets

19 December 2026
Ghana Digital Center (formerly Accra Digital Center), Accra

@@ -347,11 +348,14 @@ function packageEnquiryLink(sponsorship: (typeof sponsorshipPackages)[number]) {

DevCon26

-

Registration opens soon.

+

A place at DevCon26.

Join us at DevCon26. A day of technology, community, and ideas.

-

We’re getting registration ready. Check back here for updates.

+

GH₵150per person

+

One ticket admits one person. After payment, receive your QR ticket PDF by email and a link to create your conference badge.

+

Coming with others? Buy multiple individual tickets in one checkout and choose who each ticket is for.

+ Continue to checkout
@@ -649,50 +653,6 @@ function packageEnquiryLink(sponsorship: (typeof sponsorshipPackages)[number]) { margin-left: auto; } - .event-home-link { - display: inline-flex; - align-items: center; - gap: 6px; - min-height: 44px; - color: #b50068; - font-size: 14px; - font-weight: 600; - white-space: nowrap; - text-decoration: none; - } - - .event-home-link > span { - font-size: 16px; - line-height: 1; - transition: transform 160ms cubic-bezier(0.16, 1, 0.3, 1); - } - - @media (hover: hover) and (pointer: fine) { - .event-home-link:hover { - color: #85004d; - text-decoration: none; - } - - .event-home-link:hover > span { - transform: translateX(-3px); - } - } - - @media (prefers-reduced-motion: reduce) { - .event-home-link > span { - transition: none; - } - - .event-home-link:hover > span { - transform: none; - } - } - - .event-home-link:focus-visible { - outline: 2px solid var(--dc-pink); - outline-offset: 4px; - } - .event-label-copy { min-width: 0; letter-spacing: 0.06em; @@ -1205,6 +1165,31 @@ function packageEnquiryLink(sponsorship: (typeof sponsorshipPackages)[number]) { margin-top: 32px; } + .registration-price { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 12px; + margin-top: 24px; + color: var(--dc-ink); + } + + .registration-price strong { + font-family: var(--font-display); + font-size: clamp(36px, 4vw, 48px); + line-height: 1.1; + font-weight: 600; + } + + .registration-price span { + color: var(--dc-muted); + font-size: var(--dc-text-meta); + } + + .registration-action > .button { + margin-top: 24px; + } + .registration-note { max-width: 48ch; margin-top: 20px; diff --git a/src/pages/devcon26/payment-confirmation.astro b/src/pages/devcon26/payment-confirmation.astro index 30139eb..4195d49 100644 --- a/src/pages/devcon26/payment-confirmation.astro +++ b/src/pages/devcon26/payment-confirmation.astro @@ -1,5 +1,7 @@ --- import Base from '../../layouts/Base.astro'; +import Devcon26PhotoAdjuster from '../../components/Devcon26PhotoAdjuster.astro'; +import Devcon26HomeLink from '../../components/Devcon26HomeLink.astro'; const checkoutApiOrigin = import.meta.env.PUBLIC_DEVCON26_API_ORIGIN || 'https://em.devcongress.org'; --- @@ -9,6 +11,7 @@ const checkoutApiOrigin = import.meta.env.PUBLIC_DEVCON26_API_ORIGIN || 'https:/
+

DevCon26 Good company awaits.

@@ -86,7 +89,8 @@ const checkoutApiOrigin = import.meta.env.PUBLIC_DEVCON26_API_ORIGIN || 'https:/
- + +

Payment record only. Not an admission ticket.

@@ -113,6 +117,7 @@ const checkoutApiOrigin = import.meta.env.PUBLIC_DEVCON26_API_ORIGIN || 'https:/
+

The place to meet

@@ -144,23 +159,19 @@ const checkoutApiOrigin = import.meta.env.PUBLIC_DEVCON26_API_ORIGIN || 'https:/
- Back to DevCon26 - Need a hand? Contact us -
- +
+ @@ -177,6 +188,30 @@ const checkoutApiOrigin = import.meta.env.PUBLIC_DEVCON26_API_ORIGIN || 'https:/