+
diff --git a/src/modules/events/components/TicketWavesEditor.vue b/src/modules/events/components/TicketWavesEditor.vue
new file mode 100644
index 0000000..0812886
--- /dev/null
+++ b/src/modules/events/components/TicketWavesEditor.vue
@@ -0,0 +1,195 @@
+
+
+
+
+
+ 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.
+
+
+
+ No additional waves — everything sells at the price above.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Sold out
+
+
+
+
+
+ {{ errors[index] }}
+
+
+
+
+
+
diff --git a/src/modules/events/lib/ticketWaves.spec.ts b/src/modules/events/lib/ticketWaves.spec.ts
index 48501ac..ce2bad2 100644
--- a/src/modules/events/lib/ticketWaves.spec.ts
+++ b/src/modules/events/lib/ticketWaves.spec.ts
@@ -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);
+ });
+});
diff --git a/src/modules/events/lib/ticketWaves.ts b/src/modules/events/lib/ticketWaves.ts
index 2b90644..9c2e710 100644
--- a/src/modules/events/lib/ticketWaves.ts
+++ b/src/modules/events/lib/ticketWaves.ts
@@ -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
;
+
+/**
+ * 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 = new Set(),
+): WaveRowErrors {
+ const errors: WaveRowErrors = {};
+ const seen = new Set();
+
+ 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 {
+ 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,
+ };
+}