Ticket waves: manage them, buy from them, stop lying about totals #176

Merged
padreug merged 4 commits from feat/ticket-waves into dev 2026-09-29 07:23:44 +00:00
4 changed files with 909 additions and 287 deletions
Showing only changes of commit d55115d0ac - Show all commits

feat(events): manage ticket waves from the organizer form

Capacity, price, currency and fiat edits made in the webapp were
silently discarded. Since events ext v1.6.8 the backend derives those
event-level fields FROM the waves on every write, so submitting a
changed `amount_tickets` beside an unchanged wave list left the wave's
old number winning. Verified against a running instance before fixing:
999 / 77 went in, 45 / 10.0 came back, HTTP 200.

The dialog's price / capacity / currency fields ARE the primary wave, so
they are now written into it on submit — the same write-through the
LNbits admin dialog performs. A new "Ticket waves" section manages the
tiers after the first, mirroring PromoCodesEditor: v-model over a row
array, with the pure `validateWaveRows` so submit gates on exactly the
rules the backend enforces.

Validation follows `_validate_wave_capacity`, including its subtlety: a
wave already stored on the event may sit at zero capacity, because that
is what sold out looks like and rejecting it would make a sold-out event
uneditable. Only a newly added wave must state a real capacity. Stored
zeroes render as "Sold out" rather than as an error.

One hazard found while wiring the write-through: on CREATE there is no
stored event, so the implied primary wave was synthesized with empty
date strings — which the backend then throws on for every read
(`ValueError: Invalid isoformat string: ''`). The seed now supplies the
same inputs `create_event` uses, so the wave the webapp sends matches
the one the backend would have built. Pinned by a test.

72 tests; vue-tsc and prettier clean.
Padreug 2026-09-29 08:28:48 +02:00

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,195 @@
<script setup lang="ts">
import { computed } from "vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Plus, Trash2 } from "lucide-vue-next";
import { newWaveRow, validateWaveRows } from "../lib/ticketWaves";
import type { TicketWave } from "../types/ticket";
/**
* Additional ticket waves — the tiers after the first.
*
* The primary wave is edited through the dialog's own price / capacity /
* currency fields, because those event-level fields ARE the primary
* wave's (`sync_event_ticket_waves` derives them from it). This editor
* covers waves 2..n, the same split the LNbits admin uses.
*
* Plain `v-model` over a `TicketWave[]`, matching PromoCodesEditor:
* validation is the pure `validateWaveRows` so the parent can gate
* submit on exactly the rules the backend enforces.
*/
const props = defineProps<{
modelValue: TicketWave[];
/** Waves already stored on the event. A stored wave may legitimately
* sit at zero capacity — that is what sold out looks like — while a
* new one must state a real number. */
knownIds?: string[];
/** Currency of the primary wave; new rows inherit it. */
currency?: string;
disabled?: boolean;
}>();
const emit = defineEmits<{ "update:modelValue": [waves: TicketWave[]] }>();
const errors = computed(() =>
validateWaveRows(props.modelValue, new Set(props.knownIds ?? [])),
);
function update(index: number, patch: Partial<TicketWave>) {
emit(
"update:modelValue",
props.modelValue.map((row, i) =>
i === index ? { ...row, ...patch } : row,
),
);
}
function add() {
emit("update:modelValue", [
...props.modelValue,
newWaveRow({ currency: props.currency ?? "sat" }),
]);
}
function remove(index: number) {
emit(
"update:modelValue",
props.modelValue.filter((_, i) => i !== index),
);
}
/** Empty input → 0 rather than NaN, so a cleared field reads as unset. */
function toNumber(value: string | number): number {
const n = Number(String(value).trim());
return Number.isFinite(n) ? n : 0;
}
/** A stored wave at zero has sold out; say so instead of flagging it. */
function isSoldOut(row: TicketWave): boolean {
return row.amount_tickets === 0 && (props.knownIds ?? []).includes(row.id);
}
</script>
<template>
<div class="space-y-3">
<p class="text-xs text-muted-foreground">
Extra pricing tiers, each with its own dates, price and capacity. Buyers
choose between whichever are open; the cheapest open one is what the
public listing advertises.
</p>
<p v-if="modelValue.length === 0" class="text-sm text-muted-foreground">
No additional waves — everything sells at the price above.
</p>
<div
v-for="(row, index) in modelValue"
:key="row.id"
class="rounded-md border border-border p-3 space-y-2"
>
<div class="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
<div class="space-y-1">
<Label :for="`wave-title-${index}`" class="text-xs">Name</Label>
<Input
:id="`wave-title-${index}`"
:model-value="row.title"
placeholder="Early bird"
:disabled="disabled"
@update:model-value="update(index, { title: String($event) })"
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
class="mt-5"
:disabled="disabled"
aria-label="Remove wave"
@click="remove(index)"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label :for="`wave-open-${index}`" class="text-xs">Opens</Label>
<Input
:id="`wave-open-${index}`"
type="date"
:model-value="row.opening_date"
:disabled="disabled"
@update:model-value="
update(index, { opening_date: String($event) })
"
/>
</div>
<div class="space-y-1">
<Label :for="`wave-close-${index}`" class="text-xs">Closes</Label>
<Input
:id="`wave-close-${index}`"
type="date"
:model-value="row.closing_date"
:disabled="disabled"
@update:model-value="
update(index, { closing_date: String($event) })
"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label :for="`wave-price-${index}`" class="text-xs">
Price ({{ row.currency }})
</Label>
<Input
:id="`wave-price-${index}`"
type="number"
inputmode="decimal"
min="0"
step="any"
:model-value="row.price_per_ticket"
:disabled="disabled"
@update:model-value="
update(index, { price_per_ticket: toNumber($event) })
"
/>
</div>
<div class="space-y-1">
<Label :for="`wave-cap-${index}`" class="text-xs">Tickets</Label>
<Input
:id="`wave-cap-${index}`"
type="number"
inputmode="numeric"
min="1"
step="1"
:model-value="row.amount_tickets"
:disabled="disabled"
@update:model-value="
update(index, { amount_tickets: toNumber($event) })
"
/>
<p v-if="isSoldOut(row)" class="text-xs text-muted-foreground">
Sold out
</p>
</div>
</div>
<p v-if="errors[index]" class="text-xs text-destructive">
{{ errors[index] }}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
:disabled="disabled"
@click="add"
>
<Plus class="h-4 w-4 mr-1" />
Add wave
</Button>
</div>
</template>

View file

@ -5,8 +5,10 @@ import {
applyFormToPrimaryWave,
ensureTicketWaves,
isWaveActive,
newWaveRow,
resolveTicketWave,
syncEventFromWaves,
validateWaveRows,
waveDay,
} from "./ticketWaves";
import type { TicketWave } from "../types/ticket";
@ -226,3 +228,102 @@ describe("applyFormToPrimaryWave", () => {
expect(primary.amount_tickets).toBe(7);
});
});
describe("validateWaveRows", () => {
const valid = wave({ id: "w1", title: "Late", amount_tickets: 5 });
it("accepts a well-formed wave", () => {
expect(validateWaveRows([valid])).toEqual({});
});
it("rejects a new wave with no capacity — there is no unlimited", () => {
expect(validateWaveRows([{ ...valid, amount_tickets: 0 }])[0]).toMatch(
/at least 1/,
);
});
it("lets a wave that sold out stay at zero, so the event stays editable", () => {
const soldOut = { ...valid, amount_tickets: 0 };
expect(validateWaveRows([soldOut], new Set(["w1"]))).toEqual({});
});
it("catches missing names, inverted windows and negative prices", () => {
expect(validateWaveRows([{ ...valid, title: " " }])[0]).toMatch(/name/);
expect(
validateWaveRows([
{ ...valid, opening_date: "2026-07-01", closing_date: "2026-06-01" },
])[0],
).toMatch(/Opens after/);
expect(validateWaveRows([{ ...valid, price_per_ticket: -1 }])[0]).toMatch(
/negative/,
);
});
it("reports the offending row by index", () => {
const errors = validateWaveRows([valid, { ...valid, id: "w2", title: "" }]);
expect(errors[0]).toBeUndefined();
expect(errors[1]).toMatch(/name/);
});
});
describe("newWaveRow", () => {
it("starts at the smallest capacity the backend accepts", () => {
expect(newWaveRow().amount_tickets).toBe(1);
});
it("gives each row a distinct id so the list keys stay stable", () => {
expect(newWaveRow().id).not.toBe(newWaveRow().id);
});
});
describe("the primary-wave write-through a create/edit form performs", () => {
// Composition the CreateEventDialog runs on submit. Pinned here because
// getting it wrong is silent: the backend accepts the payload and then
// either discards the organizer's numbers or throws on every read.
function submitted(
stored: {
time?: string;
closing_date?: string | null;
event_start_date?: string;
extra?: { ticket_waves?: TicketWave[] } | null;
},
form: {
amount_tickets: number;
price_per_ticket: number;
currency: string;
},
) {
return applyFormToPrimaryWave(ensureTicketWaves(stored), form)[0];
}
it("never emits empty wave dates for a brand-new event", () => {
// The seed matters: with no dates at all `ensureTicketWaves` produces
// '' and the backend's date parse raises on every subsequent read.
const primary = submitted(
{ time: "2026-06-15T10:00:00Z", event_start_date: "2026-09-01" },
{ amount_tickets: 50, price_per_ticket: 10, currency: "sat" },
);
expect(primary.opening_date).toBe("2026-06-15");
expect(primary.closing_date).toBe("2026-09-01");
expect(primary.opening_date).not.toBe("");
expect(primary.closing_date).not.toBe("");
});
it("carries the form numbers into the stored primary wave on edit", () => {
const primary = submitted(
{ extra: { ticket_waves: [EARLY, REGULAR] } },
{ amount_tickets: 99, price_per_ticket: 42, currency: "EUR" },
);
expect(primary.id).toBe("early");
expect(primary.amount_tickets).toBe(99);
expect(primary.price_per_ticket).toBe(42);
});
it("leaves the later waves untouched", () => {
const waves = applyFormToPrimaryWave(
ensureTicketWaves({ extra: { ticket_waves: [EARLY, REGULAR] } }),
{ amount_tickets: 99 },
);
expect(waves[1]).toEqual(REGULAR);
});
});

View file

@ -226,3 +226,69 @@ export function applyFormToPrimaryWave(
next[0] = primary;
return next;
}
/** Per-row problems, keyed by wave index. Empty means the list is valid. */
export type WaveRowErrors = Record<number, string>;
/**
* Validate organizer-entered waves, mirroring `_validate_wave_capacity`
* plus the shape rules the backend model enforces.
*
* `knownIds` are the waves already stored on the event. Selling out is
* the legitimate route to zero, so an existing wave may sit at 0 and
* still be saved; only a wave new to the event must state real capacity.
* Rejecting stored zeroes would make a sold-out event uneditable.
*/
export function validateWaveRows(
rows: TicketWave[],
knownIds: ReadonlySet<string> = new Set(),
): WaveRowErrors {
const errors: WaveRowErrors = {};
const seen = new Set<string>();
rows.forEach((row, index) => {
if (!row.title?.trim()) {
errors[index] = "Give this wave a name";
} else if (!row.opening_date || !row.closing_date) {
errors[index] = "Set both an opening and a closing date";
} else if (waveDay(row.opening_date) > waveDay(row.closing_date)) {
errors[index] = "Opens after it closes";
} else if (
!Number.isFinite(row.price_per_ticket) ||
row.price_per_ticket < 0
) {
errors[index] = "Price cannot be negative";
} else if (
!knownIds.has(row.id) &&
(!Number.isFinite(row.amount_tickets) || row.amount_tickets < 1)
) {
// Matches the backend: a zero-capacity wave can never be active, so
// creating one produces a tier that refuses every purchase.
errors[index] = "Needs a capacity of at least 1";
} else if (row.amount_tickets < 0) {
errors[index] = "Capacity cannot be negative";
} else if (seen.has(row.id)) {
errors[index] = "Duplicate wave";
}
seen.add(row.id);
});
return errors;
}
/** A blank wave row for the editor, opening today and priced like the event. */
export function newWaveRow(defaults: Partial<TicketWave> = {}): TicketWave {
const today = todayKey();
return {
id: `w${Math.random().toString(36).slice(2, 10)}`,
title: "",
opening_date: today,
closing_date: today,
currency: "sat",
// 1, not 0: the smallest capacity the backend accepts. There is no
// unlimited (aiolabs/events#34).
amount_tickets: 1,
price_per_ticket: 0,
...defaults,
};
}