feat(leads): implement Stripe payment component for exhibit licenses

Full implementation of ae_comp__exhibit_payment.svelte (was a 9-line stub).
Reads Stripe config from $ae_loc.site_cfg_json per-event. License tier
selector (1/3/6/10 users) uses {#key} remount pattern to work around
stripe-buy-button web component ignoring attribute changes after mount.
Three states: paid confirmation (priority=true), not-configured hint, payment
form. client_reference_id=exhibit_id ties payments to booth records.
TypeScript declaration for stripe-buy-button added to app.d.ts via
svelte/elements augmentation. exhibit_id prop wired in +page.svelte and
ae_tab__manage.svelte.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-27 18:29:12 -04:00
parent a8e9bd6694
commit d89218be15
6 changed files with 206 additions and 11 deletions

View File

@@ -260,3 +260,10 @@ Guard in [ae_tab__manage.svelte](src/routes/events/[event_id]/(leads)/leads/exhi
- Export endpoint: `GET /v3/action/event_exhibit/{id}/tracking_export` — requires `leads_api_access`
- Custom questions are stored per-exhibit in `leads_custom_questions_json` (not global)
- The exhibitor landing page link format: `/events/[event_id]/leads/exhibit/[exhibit_exhibit_id]/`
## Old Files for Reference
@backups/legacy/events_leads_v2/exhibit/[slug]/+page.svelte
@backups/legacy/events_leads_v2/exhibit/[slug]/leads_manage.svelte
@backups/legacy/events_leads_v2/exhibit/[slug]/leads_payment.svelte

View File

@@ -48,7 +48,7 @@ Full audit: `src/routes/events/[event_id]/(leads)/` and `src/lib/ae_events/ae_ev
- Exhibit search/landing (`/leads/`) — SWR, local + API search, sort
- Exhibit detail page — 4-tab layout, sticky header with Add/List toggle, auto-refresh timer
- Tab 1 (Start): sign-in via shared passcode OR licensed user (email + passcode)
- Tab 2 (Add): QR scan (rapid vs. qualify mode) + manual badge search; duplicate detection on both
- Tab 2 (Add): QR scan (confirm mode — replaced rapid/qualify) + manual badge search; duplicate/re-enable detection on both
- Tab 3 (List): SWR lead list, licensee filter (All / My Leads), sort options, export button
- Tab 4 (Manage): admin tools, booth profile edit, passcode, license mgmt, custom questions config, app settings (refresh interval, clear IDB/localStorage, reload)
- Lead detail page: view/edit custom question responses, exhibitor notes (TipTap), priority/enable flags
@@ -64,8 +64,12 @@ Full audit: `src/routes/events/[event_id]/(leads)/` and `src/lib/ae_events/ae_ev
Opt-in model: `allow_tracking` must be explicitly `true` on the badge. Also added `allow_tracking`
and `agree_to_tc` to `ae_EventBadge` in `ae_types.ts`.
**Demo note:** ensure test badges have `allow_tracking = true` or no one can be added.
- [ ] **Payment component**`ae_comp__exhibit_payment.svelte` is a stub (Stripe placeholder only);
omit from demo or hide the payment tab via "Show Payment Tab" toggle in Manage settings
- [x] **Payment component**`ae_comp__exhibit_payment.svelte` fully implemented (2026-03-27).
Reads Stripe config from `$ae_loc.site_cfg_json` (`stripe_publishable_key`, `stripe_btn_1/3/6/10_license`).
License tier selector (1/3/6/10 users) with `{#key}` remount pattern for Stripe web component.
3 states: paid confirmation (priority=true), admin setup hint / "contact organizer" (no Stripe config),
payment form. `client_reference_id=exhibit_id`. TypeScript declaration in `app.d.ts`.
Stripe keys verified visible in `$ae_loc.site_cfg_json` on dev/demo site. Keys need validity check in Stripe dashboard.
- [ ] **End-to-end smoke test** — sign in with shared passcode, scan/search a badge, add a lead,
view detail, add notes/responses, export CSV; verify on mobile (Chrome/Safari PWA)
- [x] **Install prompt** — PWA install nudge implemented (2026-03-16). `pwa_install.svelte.ts`
@@ -81,7 +85,7 @@ Full audit: `src/routes/events/[event_id]/(leads)/` and `src/lib/ae_events/ae_ev
- [x] **Remote deploy script:** `aether_container_env/deploy.sh` — SSH-triggered from workstation via `npm run deploy:remote:test/prod`. Handles git pull (ff-only) + docker build + restart. Tested and working on test env. (2026-03-25)
- [x] **`.env.default` cleanup:** Removed 16 dead variables, added missing `AE_NETWORK_NAME`/`CONTAINER_DOZZLE`/`AE_DOZZLE_PORT`, parameterized all container names (`CONTAINER_MARIADB`, `CONTAINER_PMA`, `CONTAINER_AE_OPS`) with `:-default` fallbacks in compose. ("Dozzle" = log viewer container.) (2026-03-26)
- [ ] **Prod deploy:** Run `npm run deploy:remote:prod` (off-peak). Prerequisites: both repos pushed to Bitbucket ✓; verify `.env.prod` exists in `/srv/apps/prod_aether_app_sveltekit/` on Linode before running.
- [ ] **Bitbucket → API token migration:** Bitbucket is deprecating app passwords — creation disabled 2025-09-09, existing passwords expire 2026-06-09. Migrate git remotes on workstation + Linode to use API tokens before then. See [Bitbucket API tokens docs](https://support.atlassian.com/bitbucket-cloud/docs/api-tokens/).
- [x] **Bitbucket → SSH migration:** Switched all three repos (`aether_app_sveltekit`, `aether_container_env`, `aether_api_fastapi`) to SSH remotes (`git@bitbucket.org`) on workstation. App passwords deprecated — SSH unaffected. (2026-03-27)
- [ ] **Branch strategy cleanup:** All environments (test, prod, bak) currently pull from same branches. `deploy.sh` defaults are `ae_app_3x_llm` / `development` — acceptable for now but should establish proper branch separation (e.g. `main`/`master` for prod).
- [ ] **Tier 2 deploy (Gitea webhook):** Push-triggered deploys via Gitea webhook → listener on Linode → `deploy.sh`. Deferred until Gitea usage is more established.

12
src/app.d.ts vendored
View File

@@ -22,3 +22,15 @@ declare global {
// eslint-disable-next-line no-var
var native_app: any;
}
// Stripe Buy Button web component — needed so Svelte templates accept the element without TS errors.
declare module 'svelte/elements' {
interface IntrinsicElements {
'stripe-buy-button': {
'buy-button-id': string;
'publishable-key': string;
'client-reference-id'?: string;
[attr: string]: any;
};
}
}

View File

@@ -478,7 +478,7 @@ function toggle_manage_tab() {
<Tab_add exhibit_id={page.params.exhibit_id ?? ''} />
{:else if active_tab === 'payment'}
<div class="mx-auto w-full max-w-4xl">
<Comp_exhibit_payment />
<Comp_exhibit_payment exhibit_id={page.params.exhibit_id ?? ''} />
</div>
{:else if active_tab === 'list'}
<div class="flex w-full flex-col space-y-6">

View File

@@ -1,11 +1,183 @@
<script lang="ts">
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_payment.svelte
* Leads Payment Stub.
* ae_comp__exhibit_payment.svelte
* Leads Payment — Stripe Buy Button integration.
*
* Stripe config is read from $ae_loc.site_cfg_json (set per-event by admin in Site Settings):
* stripe_publishable_key — Stripe publishable key (pk_live_... or pk_test_...)
* stripe_btn_1_license — Stripe Buy Button ID for the 1-user license tier
* stripe_btn_3_license — Buy Button ID for 3-user tier
* stripe_btn_6_license — Buy Button ID for 6-user tier
* stripe_btn_10_license — Buy Button ID for 10-user tier
*
* client_reference_id = exhibit_id — ties each Stripe payment back to this booth record.
* Payment status (priority flag) is read live from Dexie (IDB).
*
* WHY {#key btn_payment_id}: stripe-buy-button is a web component that ignores attribute
* changes after its initial mount. Keying on btn_payment_id forces Svelte to fully
* remount the element whenever the selected license tier changes.
*/
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import { ae_loc } from '$lib/stores/ae_stores';
import { AlertTriangle, CheckCircle, CreditCard } from '@lucide/svelte';
interface Props {
exhibit_id: string;
}
let { exhibit_id }: Props = $props();
const lq__exhibit_obj = liveQuery(() => {
if (!exhibit_id) return undefined;
return db_events.exhibit.get(exhibit_id);
});
// Stripe config from site_cfg_json — set per-event by admin in Site Settings.
const stripe_publishable_key = $derived(
($ae_loc.site_cfg_json?.stripe_publishable_key as string | undefined) ?? null
);
const stripe_btn_ids = $derived({
1: ($ae_loc.site_cfg_json?.stripe_btn_1_license as string | undefined) ?? null,
3: ($ae_loc.site_cfg_json?.stripe_btn_3_license as string | undefined) ?? null,
6: ($ae_loc.site_cfg_json?.stripe_btn_6_license as string | undefined) ?? null,
10: ($ae_loc.site_cfg_json?.stripe_btn_10_license as string | undefined) ?? null,
});
const license_tiers = [
{ qty: 1, label: '1 user license', price: '$120' },
{ qty: 3, label: 'Up to 3 user licenses', price: '$330' },
{ qty: 6, label: 'Up to 6 user licenses', price: '$660' },
{ qty: 10, label: 'Up to 10 user licenses', price: '$1,100' },
] as const;
type LicenseQty = 1 | 3 | 6 | 10;
let selected_qty: LicenseQty = $state(1);
const btn_payment_id = $derived(stripe_btn_ids[selected_qty] ?? null);
const is_stripe_configured = $derived(!!stripe_publishable_key);
// Inject the Stripe Buy Button JS once per session (idempotent — skips if already present).
// document.head is outside Svelte's managed DOM tree so this is safe.
$effect(() => {
if (!is_stripe_configured) return;
if (document.querySelector('script[src*="stripe.com/v3/buy-button"]')) return;
const script = document.createElement('script');
script.src = 'https://js.stripe.com/v3/buy-button.js';
script.async = true;
document.head.appendChild(script);
});
</script>
<div class="exhibit-payment card p-4">
<h3 class="h3">Payment & Licensing</h3>
<p>Placeholder for Stripe integration.</p>
<div class="exhibit-payment space-y-6">
{#if $lq__exhibit_obj?.priority}
<!-- Paid Confirmation — shown when admin has marked this booth as paid (priority = true) -->
<div class="card preset-tonal-success border-success-500/30 border p-6">
<div class="mb-3 flex items-center gap-3">
<CheckCircle size="1.5em" class="text-success-500" />
<h4 class="text-success-500 text-lg font-bold">Marked as Paid</h4>
</div>
<p class="text-sm">
Thank you for your payment. You have purchased
<strong>{$lq__exhibit_obj?.license_max ?? 0}</strong> user license(s)
for lead retrieval at this event.
</p>
{#if ($lq__exhibit_obj?.leads_device_sm_qty ?? 0) > 0}
<p class="mt-2 text-sm">
Rental device(s): <strong>{$lq__exhibit_obj?.leads_device_sm_qty}</strong>.
Pick them up at onsite registration.
</p>
{:else}
<p class="mt-2 text-sm opacity-60">
No rental devices. Use your own device(s) with this service.
</p>
{/if}
</div>
{:else if !is_stripe_configured}
<!-- Stripe not configured — show a setup hint only to admins -->
{#if $ae_loc.administrator_access}
<div
class="card preset-tonal-warning border-warning-500/30 flex items-start gap-3 border p-4">
<AlertTriangle size="1.2em" class="text-warning-500 mt-0.5 shrink-0" />
<div class="text-sm">
<p class="font-bold">Stripe not configured for this site.</p>
<p class="mt-1 opacity-70">
Add <code class="font-mono text-xs">stripe_publishable_key</code>,
<code class="font-mono text-xs">stripe_btn_1_license</code>,
<code class="font-mono text-xs">stripe_btn_3_license</code>,
<code class="font-mono text-xs">stripe_btn_6_license</code>, and
<code class="font-mono text-xs">stripe_btn_10_license</code>
to <strong>Site Config JSON</strong> to enable payment.
</p>
</div>
</div>
{:else}
<p class="py-4 text-center text-sm opacity-50">
Online payment is not available at this time. Please contact the event organizer.
</p>
{/if}
{:else}
<!-- Payment Form — Stripe configured, booth not yet marked as paid -->
<div class="space-y-6">
<div class="flex items-center gap-2">
<CreditCard size="1.2em" class="text-success-500" />
<h4 class="text-lg font-bold">Purchase Licenses</h4>
</div>
<div class="card preset-tonal-surface border-surface-500/10 border p-4 text-sm">
<p>
Each person from your booth who will scan attendee badges needs their own user
license. You can use your own smartphone, tablet, or laptop — rental devices are
not required.
</p>
</div>
<!-- License Tier Selector -->
<div class="space-y-1">
<label class="label">
<span class="text-xs font-black tracking-widest uppercase opacity-40"
>Select License Tier</span>
<select class="select mt-2 w-full" bind:value={selected_qty}>
{#each license_tiers as tier (tier.qty)}
<option value={tier.qty}>{tier.label} {tier.price}</option>
{/each}
</select>
</label>
<p class="text-xs opacity-50">One license per team member who will scan badges.</p>
</div>
<!-- Stripe Buy Button -->
<div class="flex flex-col items-center gap-3">
{#if stripe_publishable_key && btn_payment_id}
<!-- {#key} forces full remount when tier changes — stripe-buy-button ignores
attribute updates after initial render. See component comment above. -->
{#key btn_payment_id}
<stripe-buy-button
buy-button-id={btn_payment_id}
publishable-key={stripe_publishable_key}
client-reference-id={exhibit_id}>
</stripe-buy-button>
{/key}
<p class="text-center text-xs opacity-40">
Payment processed securely via Stripe. Verify quantities on the checkout
page.
</p>
<div class="card preset-tonal-warning w-full p-3 text-xs">
<strong>Note:</strong> Payment confirmation may take up to 2 business days
to reflect in your account status. Contact
<a
href="mailto:exhibits@oneskyit.com"
class="font-medium hover:underline">exhibits@oneskyit.com</a>
with questions.
</div>
{:else}
<p class="text-sm opacity-50">
No payment button is configured for this tier. Contact the event organizer.
</p>
{/if}
</div>
</div>
{/if}
</div>

View File

@@ -428,7 +428,7 @@ function handle_signout() {
{#if show_billing}
<div
class="bg-surface-500/5 border-surface-500/10 animate-in fade-in slide-in-from-top-2 border-t p-4">
<Comp_exhibit_payment />
<Comp_exhibit_payment {exhibit_id} />
</div>
{/if}
</div>