Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad

## [Unreleased]

### Added
- **Duplicate an event type.** `POST /v1/event-types/{slug}/duplicate`, and a Duplicate
action on each row of the event-types list. Closes
[#17](https://github.com/Calnode/calnode/issues/17).

The copy carries everything that hangs off the original - intake questions, host
assignments, event-type-specific availability rules, the reminder schedule, and the
custom email subjects and notes - as a single transaction, so a half-built copy can
never be left behind. It is created inactive, under a generated `<slug>-copy` (then
`-copy-2`, `-copy-3`, …) slug, and keeps `price_cents`/`currency` verbatim: zeroing a
copied price is how a paid meeting quietly starts selling for nothing. Bookings are not
copied.

## [0.8.0] - 2026-09-03

### Added
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/routes/event-types/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
let creating = $state(false);
let deleteOpen = $state(false);
let deleteSlug = $state('');
// Slug currently being duplicated, so the row's button can't be double-fired into two
// copies while the request is in flight.
let duplicating = $state('');

let filter = $state<'active' | 'archived'>('active');
const visible = $derived(items.filter((et) => (filter === 'archived' ? !!et.archived : !et.archived)));
Expand Down Expand Up @@ -79,6 +82,22 @@
}
}

// The copy is created inactive, so the toast names the new slug: nothing appears on a
// booking page until the operator edits it and switches it on.
async function duplicateEventType(et: EventType) {
if (duplicating) return;
duplicating = et.slug;
try {
const copy = await api.post<EventType>(`/v1/event-types/${et.slug}/duplicate`);
toast.success(`Duplicated as "${copy.slug}" — inactive until you turn it on`);
await load();
} catch (e: any) {
toast.error(e.message || 'Could not duplicate event type');
} finally {
duplicating = '';
}
}

function del(slug: string) {
deleteSlug = slug;
deleteOpen = true;
Expand Down Expand Up @@ -219,6 +238,17 @@
</Tooltip.Root>

{#if et.owned !== false}
<Tooltip.Root>
<Tooltip.Trigger
class={buttonVariants({ variant: 'ghost', size: 'icon' })}
onclick={() => duplicateEventType(et)}
disabled={duplicating === et.slug}
>
<!-- Copy icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="8" width="14" height="14" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
</Tooltip.Trigger>
<Tooltip.Content>Duplicate</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger
class={buttonVariants({ variant: 'ghost', size: 'icon' })}
Expand Down
Loading
Loading