Ticket waves: manage them, buy from them, stop lying about totals #176
4 changed files with 909 additions and 287 deletions
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.
commit
d55115d0ac
File diff suppressed because it is too large
Load diff
195
src/modules/events/components/TicketWavesEditor.vue
Normal file
195
src/modules/events/components/TicketWavesEditor.vue
Normal 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>
|
||||||
|
|
@ -5,8 +5,10 @@ import {
|
||||||
applyFormToPrimaryWave,
|
applyFormToPrimaryWave,
|
||||||
ensureTicketWaves,
|
ensureTicketWaves,
|
||||||
isWaveActive,
|
isWaveActive,
|
||||||
|
newWaveRow,
|
||||||
resolveTicketWave,
|
resolveTicketWave,
|
||||||
syncEventFromWaves,
|
syncEventFromWaves,
|
||||||
|
validateWaveRows,
|
||||||
waveDay,
|
waveDay,
|
||||||
} from "./ticketWaves";
|
} from "./ticketWaves";
|
||||||
import type { TicketWave } from "../types/ticket";
|
import type { TicketWave } from "../types/ticket";
|
||||||
|
|
@ -226,3 +228,102 @@ describe("applyFormToPrimaryWave", () => {
|
||||||
expect(primary.amount_tickets).toBe(7);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -226,3 +226,69 @@ export function applyFormToPrimaryWave(
|
||||||
next[0] = primary;
|
next[0] = primary;
|
||||||
return next;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue