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.
This commit is contained in:
parent
afa34c90c2
commit
d55115d0ac
4 changed files with 909 additions and 287 deletions
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,
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue