leads: event-level payment config + Stripe key migration

- New /events/[event_id]/leads/config page: administrator UI for
  mod_exhibits_json. Controls leads_require_payment toggle and Stripe
  keys (publishable key + buy button IDs per license tier).

- leads_require_payment (mod_exhibits_json) now gates all billing UI:
  header CreditCard button in exhibit +page.svelte and Licenses & Billing
  accordion in ae_tab__manage.svelte. Default false (client covers costs).

- Stripe keys migrated from site_cfg_json to mod_exhibits_json (per-event).
  ae_comp__exhibit_payment accepts them as optional props; falls back to
  site_cfg_json for events not yet migrated.

- Fixed "My Leads" bug for shared-passcode users: search_params now maps
  licensee_email 'my' → 'shared_passcode' literal (not kv.key passcode
  string) so filters correctly match stored external_person_id values.

- Event settings: Exhibits section replaced with config link + raw JSON
  fallback, matching pres_mgmt/badges pattern.

- Docs updated: README.md, MODULE__AE_Events_Exhibitor_Leads.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-04-03 12:36:36 -04:00
parent 48c5515131
commit 7f79c1857a
7 changed files with 486 additions and 89 deletions

View File

@@ -114,13 +114,12 @@ Exhibit configuration and app settings.
- Sign out button - Sign out button
**Lead Retrieval Config**: **Lead Retrieval Config**:
- Exhibit Leads Licensees — manage staff accounts (`administrator_access` only; gap: should also allow shared-passcode users — see Known Gaps) - Exhibit Leads Licensees — manage staff accounts (`administrator_access` OR signed in via shared exhibit passcode)
- Qualifiers & Questions — custom question config - Qualifiers & Questions — custom question config
- Licenses & Billing — stub (Stripe not yet implemented) - Licenses & Billing — Stripe payment (only shown when `event.mod_exhibits_json.leads_require_payment = true`)
**App Settings**: **App Settings**:
- Auto-hide header/footer toggle - Auto-hide header/footer toggle
- Show Payment Tab toggle
- Show Extra Details toggle - Show Extra Details toggle
- Refresh interval (1120 seconds, default 25s), countdown timer, last-refresh timestamp - Refresh interval (1120 seconds, default 25s), countdown timer, last-refresh timestamp
- Reload App, Clear IDB, Hard Reset (clears localStorage) - Reload App, Clear IDB, Hard Reset (clears localStorage)
@@ -239,17 +238,27 @@ Leads are never hard-deleted. "Remove Lead" sets `enable = false`. Key behaviors
## Known Gaps ## Known Gaps
None currently. See TODO__Agents.md for remaining smoke test items.
## Implemented (previously listed as gaps)
### Payment / Stripe ### Payment / Stripe
`ae_comp__exhibit_payment.svelte` is a stub. The Stripe integration is not implemented. `ae_comp__exhibit_payment.svelte` is fully implemented. Three states: paid (`priority=true` green
The payment tab can be hidden via "Show Payment Tab" in App Settings. confirmation card), Stripe not configured (admin hint), payment form with license tier selector.
Visibility is event-wide: set `event.mod_exhibits_json.leads_require_payment = true` in the event
settings JSON to enable. When `false` (default), both the header CreditCard button and the
"Licenses & Billing" accordion in the Manage tab are hidden. The Stripe component itself is
unchanged — gating is done in `+page.svelte` and `ae_tab__manage.svelte`.
### License Management — Shared Passcode Access ### License Management — Shared Passcode Access
Implemented. The license section in the Manage tab is visible to Aether admins and to anyone Implemented. The license section in the Manage tab is visible to Aether admins and to anyone
signed in via the shared exhibit passcode (`auth_exhibit_kv[exhibit_id].type === 'shared'`). signed in via the shared exhibit passcode (`auth_exhibit_kv[exhibit_id].type === 'shared'`).
Guard in [ae_tab__manage.svelte](src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__manage.svelte):
```svelte ### "My Leads" filter for shared-passcode users
{#if $ae_loc.administrator_access || $events_loc.leads.auth_exhibit_kv?.[exhibit_id]?.type === 'shared'} Fixed. `external_person_id` is stored as the literal `'shared_passcode'` for shared users (not
``` the raw passcode string). The `search_params` derived in `+page.svelte` now checks `kv.type ===
'shared'` and resolves to `'shared_passcode'` instead of `kv.key`, so the "My Leads" filter
correctly returns their captured records.
--- ---

View File

@@ -55,7 +55,7 @@ All data is cached in IndexedDB (Dexie.js) for offline use, with background API
| `ae_comp__exhibit_tracking_obj_li.svelte` | Lead list item renderer | | `ae_comp__exhibit_tracking_obj_li.svelte` | Lead list item renderer |
| `ae_comp__exhibit_license_list.svelte` | License slot manager (admin) | | `ae_comp__exhibit_license_list.svelte` | License slot manager (admin) |
| `ae_comp__exhibit_custom_questions.svelte` | Custom question config editor (admin) | | `ae_comp__exhibit_custom_questions.svelte` | Custom question config editor (admin) |
| `ae_comp__exhibit_payment.svelte` | **STUB** — Stripe placeholder, not functional | | `ae_comp__exhibit_payment.svelte` | Stripe payment component (fully implemented) |
| `ae_comp__exhibit_search.svelte` | Exhibit search input on the landing page | | `ae_comp__exhibit_search.svelte` | Exhibit search input on the landing page |
### Lead detail components (within `lead/[exhibit_tracking_id]/`) ### Lead detail components (within `lead/[exhibit_tracking_id]/`)
@@ -114,11 +114,16 @@ Two scan modes (toggled per exhibit):
## Known Gaps / Not Yet Implemented ## Known Gaps / Not Yet Implemented
- **Payment / Stripe** — `ae_comp__exhibit_payment.svelte` is a stub. The payment tab can be - None currently. See `TODO__Agents.md` for the remaining smoke test checklist.
hidden via the "Show Payment Tab" toggle in the Manage tab's App Settings.
## Implemented (previously listed as gaps) ## Implemented (previously listed as gaps)
- **Payment / Stripe** — `ae_comp__exhibit_payment.svelte` is fully implemented. Three states:
paid (`priority=true` green card), Stripe not configured (admin hint), payment form.
Visibility is controlled event-wide by `event.mod_exhibits_json.leads_require_payment`.
Set to `true` in event settings JSON to enable Stripe for an event; default `false` hides
all billing UI (header CreditCard button + Manage tab "Licenses & Billing" accordion).
- **`allow_tracking` gate** — enforced in both `ae_comp__lead_qr_scanner.svelte` and - **`allow_tracking` gate** — enforced in both `ae_comp__lead_qr_scanner.svelte` and
`ae_comp__lead_manual_search.svelte`. Badges without `allow_tracking = true` are blocked. `ae_comp__lead_manual_search.svelte`. Badges without `allow_tracking = true` are blocked.
- **Show/hide hidden records** — toggle in `ae_comp__exhibit_tracking_search.svelte`; filters - **Show/hide hidden records** — toggle in `ae_comp__exhibit_tracking_search.svelte`; filters

View File

@@ -0,0 +1,317 @@
<script lang="ts">
/**
* Leads / Exhibits Config Page
* Route: /events/[event_id]/leads/config
*
* Admin UI for managing event.mod_exhibits_json (ExhibitsRemoteCfg).
* Access: administrator_access only.
*
* Controls:
* - leads_require_payment — show/hide Stripe billing UI event-wide
* - Stripe keys — publishable key + buy button IDs per license tier
* (previously lived in site_cfg_json; moved here so they
* are per-event rather than per-site)
*
* Save pattern: load → merge draft → PATCH event.mod_exhibits_json via V3 API.
*/
import { untrack } from 'svelte';
import { page } from '$app/state';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import { ae_loc, ae_api } from '$lib/stores/ae_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import { api } from '$lib/api/api';
import {
AlertTriangle,
ArrowLeft,
Check,
ChevronDown,
ChevronUp,
CreditCard,
Info,
Lock,
Save,
Settings
} from '@lucide/svelte';
interface ExhibitsRemoteCfg {
/** When true, Stripe billing UI (header CreditCard button + Manage tab accordion) is visible. */
leads_require_payment: boolean;
/** Stripe publishable key (pk_live_... or pk_test_...). */
stripe_publishable_key: string | null;
/** Stripe Buy Button IDs per license tier. */
stripe_btn_1_license: string | null;
stripe_btn_3_license: string | null;
stripe_btn_6_license: string | null;
stripe_btn_10_license: string | null;
}
let event_id = $derived(page.params.event_id ?? '');
let lq__event_obj = $derived(
liveQuery(async () => {
if (!event_id) return null;
return await db_events.event.get(event_id);
})
);
// ---------------------------------------------------------------------------
// Draft state — initialized from the live event config
// ---------------------------------------------------------------------------
const cfg_defaults: ExhibitsRemoteCfg = {
leads_require_payment: false,
stripe_publishable_key: null,
stripe_btn_1_license: null,
stripe_btn_3_license: null,
stripe_btn_6_license: null,
stripe_btn_10_license: null
};
let draft: ExhibitsRemoteCfg = $state({ ...cfg_defaults });
let draft_initialized = $state(false);
let initial_json = $state('');
$effect(() => {
const event_obj = $lq__event_obj;
if (event_obj && !draft_initialized) {
untrack(() => {
const saved = (event_obj.mod_exhibits_json ?? {}) as Partial<ExhibitsRemoteCfg>;
draft = { ...cfg_defaults, ...saved };
initial_json = JSON.stringify(draft);
draft_initialized = true;
});
}
});
let is_dirty = $derived(draft_initialized && JSON.stringify(draft) !== initial_json);
// Section collapse state
let sections: Record<string, boolean> = $state({
payment: true,
stripe: true
});
function toggle(key: string) {
sections[key] = !sections[key];
}
// ---------------------------------------------------------------------------
// Save
// ---------------------------------------------------------------------------
let save_status: 'idle' | 'saving' | 'success' | 'error' = $state('idle');
async function save() {
if (!event_id) return;
save_status = 'saving';
try {
// Preserve any unknown keys already in mod_exhibits_json (future-proofing).
const current_cfg = ($lq__event_obj?.mod_exhibits_json ?? {}) as any;
const new_cfg = { ...current_cfg, ...draft };
await api.update_ae_obj({
api_cfg: $ae_api,
obj_type: 'event',
obj_id: event_id,
fields: { mod_exhibits_json: new_cfg },
log_lvl: 1
});
// Reload event so consumers pick up the new config immediately.
await events_func.load_ae_obj_id__event({
api_cfg: $ae_api,
event_id: event_id,
log_lvl: 1
});
initial_json = JSON.stringify(draft);
save_status = 'success';
setTimeout(() => (save_status = 'idle'), 3000);
} catch (e) {
console.error('Failed to save exhibits config', e);
save_status = 'error';
setTimeout(() => (save_status = 'idle'), 5000);
}
}
</script>
<svelte:head>
<title>Leads Config</title>
</svelte:head>
{#if !$ae_loc.administrator_access}
<div class="p-8 text-center opacity-50">
<Lock size="3em" class="mx-auto mb-2" />
<p>Administrator access required.</p>
</div>
{:else}
<div class="mx-auto w-full max-w-2xl space-y-4 px-2 py-4">
<!-- Header -->
<header class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<a
href="/events/{event_id}/leads"
class="btn btn-sm preset-tonal-surface"
title="Back to Leads">
<ArrowLeft size="1em" />
</a>
<Settings size="1.2em" class="text-primary-500" />
<h1 class="text-xl font-bold">Leads Config</h1>
</div>
<div class="flex items-center gap-2">
{#if save_status === 'success'}
<span class="badge preset-tonal-success flex items-center gap-1">
<Check size="1em" /> Saved
</span>
{:else if save_status === 'error'}
<span class="badge preset-tonal-error flex items-center gap-1">
<AlertTriangle size="1em" /> Error saving
</span>
{/if}
<button
type="button"
class="btn preset-filled-primary-500"
onclick={save}
disabled={!is_dirty || save_status === 'saving'}>
<Save size="1em" class="mr-1" />
{save_status === 'saving' ? 'Saving...' : 'Save'}
</button>
</div>
</header>
<p class="text-surface-500 text-sm">
Changes here update <code>event.mod_exhibits_json</code> and take effect immediately
for all exhibitors at this event (no re-login required).
</p>
{#if !draft_initialized}
<p class="text-surface-400 italic">Loading event config...</p>
{:else}
<!-- ================================================================ -->
<!-- PAYMENT -->
<!-- ================================================================ -->
<section class="border-surface-200-800 rounded-xl border">
<button
type="button"
class="flex w-full items-center justify-between px-4 py-3 text-left font-semibold"
onclick={() => toggle('payment')}>
<span class="flex items-center gap-2">
<CreditCard size="1em" class="text-success-500" />
Payment
</span>
{#if sections.payment}<ChevronUp size="1em" />{:else}<ChevronDown size="1em" />{/if}
</button>
{#if sections.payment}
<div class="border-surface-200-800 space-y-3 border-t px-4 py-4">
<label class="flex items-start gap-3">
<input
type="checkbox"
class="checkbox mt-0.5"
bind:checked={draft.leads_require_payment} />
<span>
<span class="font-semibold">Require Payment (Stripe)</span>
<span class="text-surface-500 ml-2 text-sm">
Shows the Stripe billing UI to exhibitors — the header CreditCard
button and the "Licenses &amp; Billing" accordion in the Manage tab.
Leave <strong>off</strong> when the client is covering costs.
</span>
</span>
</label>
<div class="flex items-start gap-2 rounded-lg bg-surface-500/5 p-3 text-xs opacity-60">
<Info size="1em" class="mt-0.5 shrink-0" />
<span>
The Stripe payment component code is always present. This toggle only
controls visibility — no code changes needed when switching events.
</span>
</div>
</div>
{/if}
</section>
<!-- ================================================================ -->
<!-- STRIPE CONFIG -->
<!-- ================================================================ -->
<section class="border-surface-200-800 rounded-xl border">
<button
type="button"
class="flex w-full items-center justify-between px-4 py-3 text-left font-semibold"
onclick={() => toggle('stripe')}>
<span>
Stripe Keys
<span class="text-surface-500 text-xs font-normal ml-1">(per-event)</span>
</span>
{#if sections.stripe}<ChevronUp size="1em" />{:else}<ChevronDown size="1em" />{/if}
</button>
{#if sections.stripe}
<div class="border-surface-200-800 space-y-4 border-t px-4 py-4">
<div class="flex items-start gap-2 text-xs opacity-60">
<Info size="1em" class="mt-0.5 shrink-0" />
<span>
These keys apply only to this event's Exhibitor Leads module.
Find them in the Stripe Dashboard → Products → Buy Buttons.
The Buy Button IDs are per license tier (1, 3, 6, or 10 users).
</span>
</div>
<label class="flex flex-col gap-1">
<span class="text-sm font-medium">Publishable Key</span>
<input
type="text"
class="input input-sm font-mono text-xs"
placeholder="pk_live_... or pk_test_..."
bind:value={draft.stripe_publishable_key} />
</label>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label class="flex flex-col gap-1">
<span class="text-sm font-medium">Buy Button — 1 License</span>
<input
type="text"
class="input input-sm font-mono text-xs"
placeholder="buy_btn_..."
bind:value={draft.stripe_btn_1_license} />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-medium">Buy Button — 3 Licenses</span>
<input
type="text"
class="input input-sm font-mono text-xs"
placeholder="buy_btn_..."
bind:value={draft.stripe_btn_3_license} />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-medium">Buy Button — 6 Licenses</span>
<input
type="text"
class="input input-sm font-mono text-xs"
placeholder="buy_btn_..."
bind:value={draft.stripe_btn_6_license} />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-medium">Buy Button — 10 Licenses</span>
<input
type="text"
class="input input-sm font-mono text-xs"
placeholder="buy_btn_..."
bind:value={draft.stripe_btn_10_license} />
</label>
</div>
</div>
{/if}
</section>
<!-- Bottom save -->
<div class="flex justify-end">
<button
type="button"
class="btn preset-filled-primary-500"
onclick={save}
disabled={!is_dirty || save_status === 'saving'}>
<Save size="1em" class="mr-1" />
{save_status === 'saving' ? 'Saving...' : 'Save Config'}
</button>
</div>
{/if}
</div>
{/if}

View File

@@ -134,15 +134,41 @@ const lq__exhibit_obj = liveQuery(() => {
return db_events.exhibit.get(exhibit_id); return db_events.exhibit.get(exhibit_id);
}); });
// Event-level config — read mod_exhibits_json for payment visibility + Stripe keys.
// Configured via /events/[event_id]/leads/config (administrator only).
const lq__event_obj = liveQuery(() => {
const event_id = page.params.event_id;
if (!event_id) return undefined;
return db_events.event.get(event_id);
});
// When false (default), all billing UI is hidden — set true when event uses Stripe payment.
let leads_require_payment = $derived(
($lq__event_obj?.mod_exhibits_json as any)?.leads_require_payment === true
);
// Stripe keys from mod_exhibits_json — passed to the payment component.
// The component falls back to site_cfg_json if these are null (legacy migration path).
let stripe_cfg = $derived({
stripe_publishable_key: ($lq__event_obj?.mod_exhibits_json as any)?.stripe_publishable_key ?? null,
stripe_btn_1_license: ($lq__event_obj?.mod_exhibits_json as any)?.stripe_btn_1_license ?? null,
stripe_btn_3_license: ($lq__event_obj?.mod_exhibits_json as any)?.stripe_btn_3_license ?? null,
stripe_btn_6_license: ($lq__event_obj?.mod_exhibits_json as any)?.stripe_btn_6_license ?? null,
stripe_btn_10_license: ($lq__event_obj?.mod_exhibits_json as any)?.stripe_btn_10_license ?? null,
});
// Standardized Reactive Search Pattern // Standardized Reactive Search Pattern
let search_params = $derived.by(() => { let search_params = $derived.by(() => {
let licensee_email = $events_loc.leads.tracking__qry__licensee_email; let licensee_email = $events_loc.leads.tracking__qry__licensee_email;
// Resolve "My Leads" to actual email // Resolve "My Leads" to the correct identity used when storing leads.
// Shared-passcode users store 'shared_passcode' literal (not the passcode string itself).
// Licensed users store their email. Aether bypass users store $ae_loc.access_type.
if (licensee_email === 'my') { if (licensee_email === 'my') {
licensee_email = const kv = $events_loc.leads.auth_exhibit_kv?.[page.params.exhibit_id ?? ''];
$events_loc.leads.auth_exhibit_kv?.[page.params.exhibit_id ?? ''] licensee_email = kv?.type === 'shared'
?.key || 'all'; ? 'shared_passcode'
: kv?.key || 'all';
} }
return { return {
@@ -440,8 +466,8 @@ function toggle_manage_tab() {
{/if} {/if}
</button> </button>
<!-- Payment (Conditional) --> <!-- Payment (Conditional) — visible only when event-level leads_require_payment is true -->
{#if $ae_loc.show_leads_payment} {#if leads_require_payment}
<button <button
type="button" type="button"
class="btn btn-sm px-2 transition-colors sm:px-3" class="btn btn-sm px-2 transition-colors sm:px-3"
@@ -478,7 +504,7 @@ function toggle_manage_tab() {
<Tab_add exhibit_id={page.params.exhibit_id ?? ''} /> <Tab_add exhibit_id={page.params.exhibit_id ?? ''} />
{:else if active_tab === 'payment'} {:else if active_tab === 'payment'}
<div class="mx-auto w-full max-w-4xl"> <div class="mx-auto w-full max-w-4xl">
<Comp_exhibit_payment exhibit_id={page.params.exhibit_id ?? ''} /> <Comp_exhibit_payment exhibit_id={page.params.exhibit_id ?? ''} {...stripe_cfg} />
</div> </div>
{:else if active_tab === 'list'} {:else if active_tab === 'list'}
<div class="flex w-full flex-col space-y-6"> <div class="flex w-full flex-col space-y-6">
@@ -512,7 +538,7 @@ function toggle_manage_tab() {
</div> </div>
{:else if active_tab === 'manage'} {:else if active_tab === 'manage'}
<div class="mx-auto w-full max-w-4xl"> <div class="mx-auto w-full max-w-4xl">
<Tab_manage /> <Tab_manage {leads_require_payment} {stripe_cfg} />
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -3,12 +3,12 @@
* ae_comp__exhibit_payment.svelte * ae_comp__exhibit_payment.svelte
* Leads Payment — Stripe Buy Button integration. * 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 config priority (first wins):
* stripe_publishable_key — Stripe publishable key (pk_live_... or pk_test_...) * 1. Props passed from the parent (sourced from event.mod_exhibits_json — preferred)
* stripe_btn_1_license — Stripe Buy Button ID for the 1-user license tier * 2. $ae_loc.site_cfg_json fallback (legacy — for events not yet migrated to mod_exhibits_json)
* stripe_btn_3_license — Buy Button ID for 3-user tier *
* stripe_btn_6_license — Buy Button ID for 6-user tier * Props are set in the exhibit +page.svelte from lq__event_obj.mod_exhibits_json and passed
* stripe_btn_10_license — Buy Button ID for 10-user tier * down so this component doesn't need to know the event_id or do its own Dexie query.
* *
* client_reference_id = exhibit_id — ties each Stripe payment back to this booth record. * client_reference_id = exhibit_id — ties each Stripe payment back to this booth record.
* Payment status (priority flag) is read live from Dexie (IDB). * Payment status (priority flag) is read live from Dexie (IDB).
@@ -24,23 +24,38 @@ import { AlertTriangle, CheckCircle, CreditCard } from '@lucide/svelte';
interface Props { interface Props {
exhibit_id: string; exhibit_id: string;
/** From event.mod_exhibits_json — set in the Leads Config page. Falls back to site_cfg_json. */
stripe_publishable_key?: string | null;
stripe_btn_1_license?: string | null;
stripe_btn_3_license?: string | null;
stripe_btn_6_license?: string | null;
stripe_btn_10_license?: string | null;
} }
let { exhibit_id }: Props = $props(); let {
exhibit_id,
stripe_publishable_key: prop_stripe_key = null,
stripe_btn_1_license: prop_btn_1 = null,
stripe_btn_3_license: prop_btn_3 = null,
stripe_btn_6_license: prop_btn_6 = null,
stripe_btn_10_license: prop_btn_10 = null
}: Props = $props();
const lq__exhibit_obj = liveQuery(() => { const lq__exhibit_obj = liveQuery(() => {
if (!exhibit_id) return undefined; if (!exhibit_id) return undefined;
return db_events.exhibit.get(exhibit_id); return db_events.exhibit.get(exhibit_id);
}); });
// Stripe config from site_cfg_json — set per-event by admin in Site Settings. // Stripe config: prefer event-level props (mod_exhibits_json); fall back to site_cfg_json
// for events that haven't been migrated to the per-event config page yet.
const stripe_publishable_key = $derived( const stripe_publishable_key = $derived(
prop_stripe_key ??
($ae_loc.site_cfg_json?.stripe_publishable_key as string | undefined) ?? null ($ae_loc.site_cfg_json?.stripe_publishable_key as string | undefined) ?? null
); );
const stripe_btn_ids = $derived({ const stripe_btn_ids = $derived({
1: ($ae_loc.site_cfg_json?.stripe_btn_1_license as string | undefined) ?? null, 1: prop_btn_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, 3: prop_btn_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, 6: prop_btn_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, 10: prop_btn_10 ?? ($ae_loc.site_cfg_json?.stripe_btn_10_license as string | undefined) ?? null,
}); });
const license_tiers = [ const license_tiers = [

View File

@@ -30,6 +30,23 @@ import {
UserX, UserX,
Users Users
} from '@lucide/svelte'; } from '@lucide/svelte';
interface StripeCfg {
stripe_publishable_key?: string | null;
stripe_btn_1_license?: string | null;
stripe_btn_3_license?: string | null;
stripe_btn_6_license?: string | null;
stripe_btn_10_license?: string | null;
}
// leads_require_payment: event-level flag from mod_exhibits_json.
// When false (default), all billing UI is hidden — client is covering costs.
// Set mod_exhibits_json.leads_require_payment = true in event settings to enable Stripe.
// stripe_cfg: Stripe keys from mod_exhibits_json, passed through to the payment component.
let {
leads_require_payment = false,
stripe_cfg = {}
}: { leads_require_payment?: boolean; stripe_cfg?: StripeCfg } = $props();
const exhibit_id = $derived(page.params.exhibit_id ?? ''); const exhibit_id = $derived(page.params.exhibit_id ?? '');
let lq__exhibit_obj = $derived( let lq__exhibit_obj = $derived(
@@ -397,41 +414,43 @@ function handle_signout() {
{/if} {/if}
</div> </div>
<!-- Billing --> <!-- Billing — only shown when event-level leads_require_payment is enabled -->
<div class="p-0"> {#if leads_require_payment}
<button <div class="p-0">
class="hover:bg-surface-500/5 group flex w-full items-center justify-between p-4 transition-colors" <button
onclick={() => (show_billing = !show_billing)}> class="hover:bg-surface-500/5 group flex w-full items-center justify-between p-4 transition-colors"
<div class="flex items-center gap-4"> onclick={() => (show_billing = !show_billing)}>
<div <div class="flex items-center gap-4">
class="bg-success-500/10 text-success-500 rounded-lg p-2"> <div
<CreditCard size="1.2em" /> class="bg-success-500/10 text-success-500 rounded-lg p-2">
</div> <CreditCard size="1.2em" />
<div class="text-left">
<div class="text-sm font-bold">
Licenses & Billing
</div> </div>
<div class="text-xs opacity-50"> <div class="text-left">
Review licenses and manage payment <div class="text-sm font-bold">
Licenses & Billing
</div>
<div class="text-xs opacity-50">
Review licenses and manage payment
</div>
</div> </div>
</div> </div>
</div> {#if show_billing}
{#if show_billing} <ChevronDown size="1.2em" class="opacity-20" />
<ChevronDown size="1.2em" class="opacity-20" /> {:else}
{:else} <ChevronRight
<ChevronRight size="1.2em"
size="1.2em" class="opacity-20 transition-transform group-hover:translate-x-1" />
class="opacity-20 transition-transform group-hover:translate-x-1" /> {/if}
{/if} </button>
</button>
{#if show_billing} {#if show_billing}
<div <div
class="bg-surface-500/5 border-surface-500/10 animate-in fade-in slide-in-from-top-2 border-t p-4"> 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 {exhibit_id} /> <Comp_exhibit_payment {exhibit_id} {...stripe_cfg} />
</div> </div>
{/if} {/if}
</div> </div>
{/if}
</div> </div>
</section> </section>
@@ -462,14 +481,6 @@ function handle_signout() {
class="checkbox" class="checkbox"
bind:checked={$ae_loc.auto_hide_nav} /> bind:checked={$ae_loc.auto_hide_nav} />
</label> </label>
<label
class="hover:bg-surface-500/10 flex cursor-pointer items-center justify-between rounded-lg p-2 transition-colors">
<span class="text-sm">Show Payment Tab</span>
<input
type="checkbox"
class="checkbox"
bind:checked={$ae_loc.show_leads_payment} />
</label>
<label <label
class="hover:bg-surface-500/10 flex cursor-pointer items-center justify-between rounded-lg p-2 transition-colors"> class="hover:bg-surface-500/10 flex cursor-pointer items-center justify-between rounded-lg p-2 transition-colors">
<span class="text-sm">Show Extra Details</span> <span class="text-sm">Show Extra Details</span>

View File

@@ -383,24 +383,38 @@ async function handle_save(field_name: string, data: any) {
</details> </details>
<details class="details"> <details class="details">
<summary class="summary">Exhibits (mod_exhibits_json)</summary> <summary class="summary">Exhibits / Leads (mod_exhibits_json)</summary>
<div class="p-4"> <div class="p-4 space-y-3">
<AE_Comp_Editor_CodeMirror <p class="text-sm text-surface-500">
readonly={false} Configure Leads payment visibility and Stripe keys for this event.
content={tmp_exhibits_json_str} </p>
bind:new_content={tmp_exhibits_json_str} <a
show_line_numbers={true} href="/events/{event_id}/leads/config"
placeholder="JSON config" class="btn preset-tonal-primary">
class_li="p-1 preset-outlined-success-400-600 shadow-lg rounded-lg" /> Go to Leads Config →
<button </a>
type="button" <!-- Raw JSON fallback for debugging / emergency edits -->
class="btn preset-tonal-primary" <details class="mt-2">
onclick={() => { <summary class="cursor-pointer text-xs text-surface-400">Raw JSON (advanced)</summary>
handle_save( <div class="mt-2 space-y-2">
'mod_exhibits_json', <AE_Comp_Editor_CodeMirror
tmp_exhibits_json_str readonly={false}
); content={tmp_exhibits_json_str}
}}>Save</button> bind:new_content={tmp_exhibits_json_str}
show_line_numbers={true}
placeholder="JSON config"
class_li="p-1 preset-outlined-success-400-600 shadow-lg rounded-lg" />
<button
type="button"
class="btn preset-tonal-primary"
onclick={() => {
handle_save(
'mod_exhibits_json',
tmp_exhibits_json_str
);
}}>Save Raw JSON</button>
</div>
</details>
</div> </div>
</details> </details>