@frigear-nu/events 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/dist/module.d.mts +105 -1
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +35 -31
  4. package/dist/runtime/app/components/EventAddonGroupSlideover.d.vue.ts +18 -0
  5. package/dist/runtime/app/components/EventAddonGroupSlideover.vue +67 -0
  6. package/dist/runtime/app/components/EventAddonGroupSlideover.vue.d.ts +18 -0
  7. package/dist/runtime/app/components/EventAddonSlideover.d.vue.ts +20 -0
  8. package/dist/runtime/app/components/EventAddonSlideover.vue +104 -0
  9. package/dist/runtime/app/components/EventAddonSlideover.vue.d.ts +20 -0
  10. package/dist/runtime/app/components/EventAdminAddonGroups.d.vue.ts +1 -9
  11. package/dist/runtime/app/components/EventAdminAddonGroups.vue +7 -48
  12. package/dist/runtime/app/components/EventAdminAddonGroups.vue.d.ts +1 -9
  13. package/dist/runtime/app/components/EventCard.vue +1 -0
  14. package/dist/runtime/app/components/EventFormSlideover.vue +46 -23
  15. package/dist/runtime/app/components/EventTicketCard.vue +60 -62
  16. package/dist/runtime/app/components/EventTicketPicker.vue +1 -0
  17. package/dist/runtime/app/components/EventTicketTypeSlideover.d.vue.ts +20 -0
  18. package/dist/runtime/app/components/EventTicketTypeSlideover.vue +177 -0
  19. package/dist/runtime/app/components/EventTicketTypeSlideover.vue.d.ts +20 -0
  20. package/dist/runtime/app/composables/index.d.ts +2 -0
  21. package/dist/runtime/app/composables/index.js +1 -0
  22. package/dist/runtime/app/composables/useEventActions.d.ts +3 -3
  23. package/dist/runtime/app/composables/useEventActions.js +10 -4
  24. package/dist/runtime/app/composables/useEventCatalogActions.d.ts +33 -0
  25. package/dist/runtime/app/composables/useEventCatalogActions.js +108 -0
  26. package/dist/runtime/app/composables/useEventLinks.js +1 -0
  27. package/dist/runtime/app/composables/useEvents.d.ts +29 -16
  28. package/dist/runtime/app/composables/useEvents.js +7 -6
  29. package/dist/runtime/app/pages/admin-event.vue +72 -136
  30. package/dist/runtime/app/pages/admin.vue +92 -29
  31. package/dist/runtime/app/pages/event.vue +9 -4
  32. package/dist/runtime/app/pages/events.vue +1 -0
  33. package/dist/runtime/app/pages/tickets.vue +5 -0
  34. package/dist/runtime/app/tabs/Attendees.vue +79 -39
  35. package/dist/runtime/app/tabs/Details.d.vue.ts +1 -1
  36. package/dist/runtime/app/tabs/Details.vue +113 -30
  37. package/dist/runtime/app/tabs/Details.vue.d.ts +1 -1
  38. package/dist/runtime/app/tabs/Tickets.vue +103 -139
  39. package/dist/runtime/app/utils/admin-event.d.ts +53 -38
  40. package/dist/runtime/app/utils/admin-event.js +14 -0
  41. package/dist/runtime/app/widgets/UpcomingEvents.vue +3 -1
  42. package/dist/runtime/app/widgets/UpcomingEventsStat.vue +3 -1
  43. package/dist/runtime/i18n/lang/da.json +22 -3
  44. package/dist/runtime/i18n/lang/en.json +22 -3
  45. package/dist/runtime/menus.js +1 -0
  46. package/dist/runtime/server/bootstrap/my-tickets.d.ts +1 -1
  47. package/dist/runtime/server/mcp/schemas.d.ts +4 -4
  48. package/dist/runtime/server/routes/admin/addon-group.delete.js +6 -3
  49. package/dist/runtime/server/routes/admin/addon-group.patch.js +6 -4
  50. package/dist/runtime/server/routes/admin/addon-groups.post.js +8 -6
  51. package/dist/runtime/server/routes/admin/addon.delete.js +6 -3
  52. package/dist/runtime/server/routes/admin/addon.patch.js +8 -5
  53. package/dist/runtime/server/routes/admin/addons.post.js +8 -6
  54. package/dist/runtime/server/routes/admin/attendees.get.js +38 -8
  55. package/dist/runtime/server/routes/{public → admin}/check-in.post.js +4 -3
  56. package/dist/runtime/server/routes/admin/event-clone.post.js +37 -19
  57. package/dist/runtime/server/routes/admin/event.delete.js +7 -4
  58. package/dist/runtime/server/routes/admin/event.get.js +4 -2
  59. package/dist/runtime/server/routes/admin/event.patch.js +4 -2
  60. package/dist/runtime/server/routes/admin/events.get.js +36 -3
  61. package/dist/runtime/server/routes/admin/events.post.js +3 -4
  62. package/dist/runtime/server/routes/admin/membership-options.get.js +3 -2
  63. package/dist/runtime/server/routes/admin/ticket-cancel.post.js +7 -8
  64. package/dist/runtime/server/routes/admin/ticket-refund.post.js +7 -5
  65. package/dist/runtime/server/routes/admin/ticket-type.delete.js +7 -4
  66. package/dist/runtime/server/routes/admin/ticket-type.patch.js +4 -2
  67. package/dist/runtime/server/routes/admin/ticket-types.post.js +10 -7
  68. package/dist/runtime/server/routes/admin/upcoming.get.js +7 -3
  69. package/dist/runtime/server/routes/me/ticket-pay.post.js +13 -0
  70. package/dist/runtime/server/routes/me/ticket.get.js +6 -0
  71. package/dist/runtime/server/routes/public/event.get.js +5 -2
  72. package/dist/runtime/server/routes/public/purchase.post.js +3 -1
  73. package/dist/runtime/server/utils/admin-dto.d.ts +30 -0
  74. package/dist/runtime/server/utils/admin-dto.js +101 -0
  75. package/dist/runtime/server/utils/admin-schemas.js +2 -1
  76. package/dist/runtime/server/utils/admin.d.ts +10 -8
  77. package/dist/runtime/server/utils/admin.js +20 -6
  78. package/dist/runtime/server/utils/auth.d.ts +5 -4
  79. package/dist/runtime/server/utils/auth.js +6 -7
  80. package/dist/runtime/server/utils/tickets.js +6 -11
  81. package/dist/runtime/utils/api.d.ts +48 -0
  82. package/dist/runtime/utils/api.js +51 -0
  83. package/dist/runtime/utils/index.d.ts +2 -1
  84. package/dist/runtime/utils/index.js +1 -0
  85. package/dist/types.d.mts +1 -1
  86. package/package.json +19 -14
  87. package/dist/runtime/app/components/EventAdminAddonDialog.d.vue.ts +0 -11
  88. package/dist/runtime/app/components/EventAdminAddonDialog.vue +0 -112
  89. package/dist/runtime/app/components/EventAdminAddonDialog.vue.d.ts +0 -11
  90. package/dist/runtime/app/components/EventAdminAddonGroupDialog.d.vue.ts +0 -11
  91. package/dist/runtime/app/components/EventAdminAddonGroupDialog.vue +0 -78
  92. package/dist/runtime/app/components/EventAdminAddonGroupDialog.vue.d.ts +0 -11
  93. package/dist/runtime/app/components/EventAdminTicketTypeDialog.d.vue.ts +0 -20
  94. package/dist/runtime/app/components/EventAdminTicketTypeDialog.vue +0 -174
  95. package/dist/runtime/app/components/EventAdminTicketTypeDialog.vue.d.ts +0 -20
  96. package/dist/runtime/server/routes/public/ticket-pay.post.js +0 -10
  97. package/dist/runtime/server/routes/public/ticket.get.js +0 -4
  98. /package/dist/runtime/server/routes/{public → admin}/check-in.post.d.ts +0 -0
  99. /package/dist/runtime/server/routes/{public → me}/ticket-pay.post.d.ts +0 -0
  100. /package/dist/runtime/server/routes/{public → me}/ticket.get.d.ts +0 -0
  101. /package/dist/runtime/server/routes/{public/tickets-me.get.d.ts → me/tickets.get.d.ts} +0 -0
  102. /package/dist/runtime/server/routes/{public/tickets-me.get.js → me/tickets.get.js} +0 -0
@@ -0,0 +1,108 @@
1
+ import { toValue } from "vue";
2
+ import EventAddonGroupSlideover from "../components/EventAddonGroupSlideover.vue";
3
+ import EventAddonSlideover from "../components/EventAddonSlideover.vue";
4
+ import EventTicketTypeSlideover from "../components/EventTicketTypeSlideover.vue";
5
+ import { eventsApi } from "../../utils/api.js";
6
+ import { $fetch, useAction, useConfirmDialog, useEventsTranslate, useModuleT, useOverlay } from "#imports";
7
+ export function useEventCatalogActions(options = {}) {
8
+ const t = useModuleT("events");
9
+ const tr = useEventsTranslate();
10
+ const overlay = useOverlay();
11
+ const confirm = useConfirmDialog();
12
+ const { run } = useAction();
13
+ async function changed(result) {
14
+ if (result) await options.onChanged?.();
15
+ return result;
16
+ }
17
+ async function openTicketType(ticketType) {
18
+ const eventId = toValue(options.eventId) ?? "";
19
+ if (!ticketType && !eventId) throw new Error("useEventCatalogActions: pass `eventId` to add ticket types.");
20
+ const saved = await overlay.create(EventTicketTypeSlideover, { destroyOnClose: true }).open({ eventId, ticketType, membership: options.membership?.() });
21
+ return changed(saved || false);
22
+ }
23
+ async function openGroup(ticketType, group) {
24
+ const saved = await overlay.create(EventAddonGroupSlideover, { destroyOnClose: true }).open({ ticketTypeId: ticketType.id, group });
25
+ return changed(saved || false);
26
+ }
27
+ async function openAddon(ticketType, group, addon) {
28
+ const saved = await overlay.create(EventAddonSlideover, { destroyOnClose: true }).open({ groupId: group.id, currency: ticketType.currency, addon });
29
+ return changed(saved || false);
30
+ }
31
+ async function remove(request) {
32
+ const confirmed = await confirm({ title: request.title, description: request.description, confirmText: request.confirmText, destructive: true });
33
+ if (!confirmed) return false;
34
+ const done = await run(() => $fetch(request.url, { method: "DELETE" }).then(() => true), { success: request.success });
35
+ return changed(done === true);
36
+ }
37
+ function removeTicketType(ticketType) {
38
+ const name = tr(ticketType.name);
39
+ return remove({
40
+ url: eventsApi.admin.ticketType(ticketType.id),
41
+ title: t("admin.confirmRemoveType", "Delete {name}?", { name }),
42
+ description: t("admin.confirmRemoveTypeDescription", "Its add-on groups and choices are deleted too. A ticket type with active tickets cannot be deleted."),
43
+ confirmText: t("admin.removeType", "Delete ticket type"),
44
+ success: t("admin.typeDeleted", "Deleted {name}", { name })
45
+ });
46
+ }
47
+ function removeGroup(group) {
48
+ const name = tr(group.title);
49
+ return remove({
50
+ url: eventsApi.admin.addonGroup(group.id),
51
+ title: t("admin.confirmRemoveGroup", "Delete {name}?", { name }),
52
+ description: t("admin.confirmRemoveGroupDescription", "Its choices are deleted too. Tickets already bought keep the choices they made."),
53
+ confirmText: t("admin.removeGroup", "Delete add-on group"),
54
+ success: t("admin.groupDeleted", "Deleted {name}", { name })
55
+ });
56
+ }
57
+ function removeAddon(addon) {
58
+ const name = tr(addon.label);
59
+ return remove({
60
+ url: eventsApi.admin.addon(addon.id),
61
+ title: t("admin.confirmRemoveAddon", "Delete {name}?", { name }),
62
+ description: t("admin.confirmRemoveAddonDescription", "Tickets already bought keep the choice they made."),
63
+ confirmText: t("admin.removeAddon", "Delete choice"),
64
+ success: t("admin.addonDeleted", "Deleted {name}", { name })
65
+ });
66
+ }
67
+ function typeMenu(ticketType) {
68
+ return [
69
+ [
70
+ { label: t("admin.editType", "Edit ticket type"), icon: "i-lucide-pencil", onSelect: () => {
71
+ void openTicketType(ticketType);
72
+ } },
73
+ { label: t("admin.addGroup", "Add add-on group"), icon: "i-lucide-plus", onSelect: () => {
74
+ void openGroup(ticketType);
75
+ } }
76
+ ],
77
+ [{ label: t("admin.removeType", "Delete ticket type"), icon: "i-lucide-trash-2", color: "error", onSelect: () => {
78
+ void removeTicketType(ticketType);
79
+ } }]
80
+ ];
81
+ }
82
+ function groupMenu(ticketType, group) {
83
+ return [
84
+ [
85
+ { label: t("admin.editGroup", "Edit add-on group"), icon: "i-lucide-pencil", onSelect: () => {
86
+ void openGroup(ticketType, group);
87
+ } },
88
+ { label: t("admin.addAddon", "Add choice"), icon: "i-lucide-plus", onSelect: () => {
89
+ void openAddon(ticketType, group);
90
+ } }
91
+ ],
92
+ [{ label: t("admin.removeGroup", "Delete add-on group"), icon: "i-lucide-trash-2", color: "error", onSelect: () => {
93
+ void removeGroup(group);
94
+ } }]
95
+ ];
96
+ }
97
+ function addonMenu(ticketType, group, addon) {
98
+ return [
99
+ [{ label: t("admin.editAddon", "Edit choice"), icon: "i-lucide-pencil", onSelect: () => {
100
+ void openAddon(ticketType, group, addon);
101
+ } }],
102
+ [{ label: t("admin.removeAddon", "Delete choice"), icon: "i-lucide-trash-2", color: "error", onSelect: () => {
103
+ void removeAddon(addon);
104
+ } }]
105
+ ];
106
+ }
107
+ return { openTicketType, openGroup, openAddon, removeTicketType, removeGroup, removeAddon, typeMenu, groupMenu, addonMenu };
108
+ }
@@ -1,4 +1,5 @@
1
1
  import { createEventLinks } from "../../utils/links.js";
2
+ import { useLocaleLink, useRuntimeConfig } from "#imports";
2
3
  export function useEventLinks() {
3
4
  const { localize } = useLocaleLink();
4
5
  return createEventLinks(useRuntimeConfig().public.events?.pages, localize);
@@ -1,21 +1,33 @@
1
- import type { TicketDto, Translated } from '../../../types.js';
2
- export declare function useEvents(): Promise<{
3
- events: any;
4
- grouped: any;
5
- status: any;
6
- refresh: any;
7
- }>;
8
- /** Awaitable: resolves once the event is fetched, so SSR renders the page with data. */
9
- export declare function useEvent(slug: MaybeRefOrGetter<string>): Promise<{
10
- event: any;
11
- status: any;
12
- error: any;
13
- refresh: any;
1
+ import type { EventDetailDto, EventSummaryDto, TicketDto, Translated } from '../../../types.js';
2
+ import type { ComputedRef, MaybeRefOrGetter } from '#imports';
3
+ import type { AsyncData, NuxtError } from 'nuxt/app';
4
+ type EventsAsyncData = Awaited<AsyncData<EventSummaryDto[], NuxtError | undefined>>;
5
+ type EventAsyncData = Awaited<AsyncData<EventDetailDto | undefined, NuxtError | undefined>>;
6
+ /** What `useEvents` resolves with. */
7
+ export interface EventsState {
8
+ events: EventsAsyncData['data'];
9
+ grouped: ComputedRef<{
10
+ ongoing: EventSummaryDto[];
11
+ upcoming: EventSummaryDto[];
12
+ past: EventSummaryDto[];
13
+ }>;
14
+ status: EventsAsyncData['status'];
15
+ refresh: EventsAsyncData['refresh'];
16
+ }
17
+ /** What `useEvent` resolves with. */
18
+ export interface EventState {
19
+ event: EventAsyncData['data'];
20
+ status: EventAsyncData['status'];
21
+ error: EventAsyncData['error'];
22
+ refresh: EventAsyncData['refresh'];
14
23
  purchase: (ticketTypeId: string, addonIds?: string[]) => Promise<{
15
24
  ticket: TicketDto;
16
25
  url?: string;
17
26
  }>;
18
- }>;
27
+ }
28
+ export declare function useEvents(): Promise<EventsState>;
29
+ /** Awaitable: resolves once the event is fetched, so SSR renders the page with data. */
30
+ export declare function useEvent(slug: MaybeRefOrGetter<string>): Promise<EventState>;
19
31
  /**
20
32
  * The signed-in user's tickets. Awaitable: `await useMyTickets()` in a page renders them on the server; a widget can
21
33
  * use it without `await` and show a skeleton while `loading`.
@@ -24,8 +36,9 @@ export declare function useEvent(slug: MaybeRefOrGetter<string>): Promise<{
24
36
  */
25
37
  export declare function useMyTickets(): any;
26
38
  /** Locale for translated texts: `@nuxtjs/i18n`'s when installed, otherwise `en`. */
27
- export declare function useEventsLocale(): any;
39
+ export declare function useEventsLocale(): ComputedRef<string>;
28
40
  /** The site's default locale: shown for texts that have no translation in the current locale. */
29
- export declare function useEventsFallbackLocale(): any;
41
+ export declare function useEventsFallbackLocale(): ComputedRef<string>;
30
42
  /** `tr(value)`: a translated text in the current locale, falling back to the default locale, then `en`, then any. */
31
43
  export declare function useEventsTranslate(): (value: Translated | null | undefined) => string;
44
+ export {};
@@ -1,7 +1,8 @@
1
- import { translate } from "../../utils/index.js";
1
+ import { eventsApi, translate } from "../../utils/index.js";
2
+ import { $fetch, computed, navigateTo, toValue, useCachedFetch, useFetch, useNuxtApp, useState, useUserSession, watch } from "#imports";
2
3
  export async function useEvents() {
3
4
  const now = useState("events:now", () => Date.now());
4
- const { data, status, refresh } = await useFetch("/api/events", { key: "events:list", default: () => [] });
5
+ const { data, status, refresh } = await useFetch(eventsApi.list, { key: "events:list", default: () => [] });
5
6
  const grouped = computed(() => {
6
7
  const start = (e) => new Date(e.startsAt).getTime();
7
8
  const end = (e) => e.endsAt ? new Date(e.endsAt).getTime() : start(e) + 24 * 36e5;
@@ -16,13 +17,13 @@ export async function useEvents() {
16
17
  export async function useEvent(slug) {
17
18
  const { loggedIn } = useUserSession();
18
19
  const locale = useEventsLocale();
19
- const { data, status, error, refresh } = await useFetch(() => `/api/events/${toValue(slug)}`, {
20
+ const { data, status, error, refresh } = await useFetch(() => eventsApi.event(toValue(slug)), {
20
21
  key: `events:detail:${toValue(slug)}`,
21
22
  // Eligibility (membership, limits) depends on the session; refetch quietly, keeping the data on screen.
22
23
  watch: [loggedIn]
23
24
  });
24
25
  async function purchase(ticketTypeId, addonIds = []) {
25
- const result = await $fetch(`/api/events/${toValue(slug)}/purchase`, {
26
+ const result = await $fetch(eventsApi.purchase(toValue(slug)), {
26
27
  method: "POST",
27
28
  body: { ticketTypeId, addonIds, locale: locale.value }
28
29
  });
@@ -35,7 +36,7 @@ export async function useEvent(slug) {
35
36
  const MY_TICKETS_TTL_MS = 60 * 1e3;
36
37
  export function useMyTickets() {
37
38
  const { loggedIn } = useUserSession();
38
- const fetched = useCachedFetch("/api/events/tickets/me", {
39
+ const fetched = useCachedFetch(eventsApi.me.tickets, {
39
40
  key: "events:tickets:mine",
40
41
  ttl: MY_TICKETS_TTL_MS,
41
42
  watch: false
@@ -47,7 +48,7 @@ export function useMyTickets() {
47
48
  });
48
49
  const tickets = computed(() => fetched.data.value ?? []);
49
50
  async function pay(ticketId) {
50
- const { url } = await $fetch(`/api/events/tickets/${ticketId}/pay`, { method: "POST" });
51
+ const { url } = await $fetch(eventsApi.me.pay(ticketId), { method: "POST" });
51
52
  await navigateTo(url, { external: true });
52
53
  }
53
54
  const api = { tickets, status, error, loading, refresh, pay };
@@ -2,162 +2,98 @@
2
2
  import AttendeesTab from "../tabs/Attendees.vue";
3
3
  import DetailsTab from "../tabs/Details.vue";
4
4
  import TicketsTab from "../tabs/Tickets.vue";
5
- import { toLocalInput } from "../utils/admin-event";
5
+ import { eventsApi } from "../../utils/api";
6
+ import { computed, resolveComponent, useEventActions, useEventLinks, useEventsTranslate, useFetch, useFormat, useModuleT, useRoute, useRuntimeConfig } from "#imports";
6
7
  defineOptions({ name: "EventsAdminEventPage" });
7
8
  const t = useModuleT("events");
8
9
  const route = useRoute();
9
10
  const tr = useEventsTranslate();
11
+ const format = useFormat();
10
12
  const links = useEventLinks();
11
13
  const actions = useEventActions();
12
14
  const hasPublicPage = !!useRuntimeConfig().public.events?.pages?.event;
13
15
  const id = computed(() => String(route.params.id));
14
- const { busy, run } = useAction();
15
- const { data: event, refresh } = await useFetch(() => `/api/events/admin/events/${id.value}`);
16
+ const { data: event, error, refresh } = await useFetch(() => eventsApi.admin.event(id.value));
17
+ const notFound = computed(() => error.value?.statusCode === 404);
16
18
  const Tabs = resolveComponent("WidgetTabs");
17
19
  const tabs = typeof Tabs === "string" ? void 0 : Tabs;
18
- const facts = reactive({
19
- status: "draft",
20
- startsAt: "",
21
- endsAt: "",
22
- location: void 0,
23
- slug: ""
24
- });
25
- watch(event, (value) => {
26
- if (!value) return;
27
- Object.assign(facts, {
28
- status: value.status,
29
- startsAt: toLocalInput(value.startsAt),
30
- endsAt: toLocalInput(value.endsAt),
31
- location: value.location ?? void 0,
32
- slug: value.slug
33
- });
34
- }, { immediate: true });
35
- const statuses = computed(() => ["draft", "published", "archived"].map((value) => ({ value, label: t(`admin.statuses.${value}`, value.charAt(0).toUpperCase() + value.slice(1)) })));
36
- const saveFacts = () => run(async () => {
37
- await $fetch(`/api/events/admin/events/${id.value}`, {
38
- method: "PATCH",
39
- body: {
40
- status: facts.status,
41
- startsAt: facts.startsAt,
42
- endsAt: facts.endsAt || null,
43
- location: facts.location ?? null,
44
- slug: facts.slug
45
- }
46
- });
47
- await refresh();
48
- }, { success: t("admin.saved", "Saved.") });
20
+ const facts = computed(() => event.value ? [
21
+ { key: "startsAt", label: t("admin.starts", "Starts"), value: format.dateTime(event.value.startsAt) },
22
+ { key: "endsAt", label: t("admin.ends", "Ends"), value: event.value.endsAt ? format.dateTime(event.value.endsAt) : null },
23
+ { key: "location", label: t("admin.location", "Location"), value: tr(event.value.location) },
24
+ { key: "slug", label: t("admin.slug", "URL name"), value: event.value.slug }
25
+ ] : []);
49
26
  </script>
50
27
 
51
28
  <template>
52
- <div
53
- v-if="event"
54
- class="flex flex-col gap-4"
55
- >
56
- <PageHeading
57
- :title="tr(event.name)"
58
- :breadcrumb="[{ label: t('admin.title', 'Events'), to: links.admin() }, { label: tr(event.name) }]"
59
- >
60
- <template #badge>
61
- <UBadge
62
- variant="subtle"
63
- :color="statusColor(event.status)"
64
- :label="t(`admin.statuses.${event.status}`, event.status)"
65
- />
66
- </template>
67
- <template #actions>
68
- <UButton
69
- v-if="event.status === 'published' && hasPublicPage"
70
- :to="links.event(event.slug)"
71
- variant="outline"
72
- color="neutral"
73
- icon="i-lucide-external-link"
74
- :label="t('admin.viewPublic', 'View public page')"
75
- />
76
- <RowMenu
77
- variant="outline"
78
- :subject="tr(event.name)"
79
- :items="actions.menu(event, { open: false, viewPublic: false, onRemoved: () => navigateTo(links.admin()) })"
80
- />
81
- </template>
82
- </PageHeading>
83
-
84
- <SectionCard :title="t('admin.keyFacts', 'Event')">
85
- <form
86
- id="admin-event-facts"
87
- class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
88
- @submit.prevent="saveFacts"
29
+ <div class="flex flex-col gap-4">
30
+ <template v-if="event">
31
+ <PageHeading
32
+ :title="tr(event.name)"
33
+ :breadcrumb="[{ label: t('admin.title', 'Events'), to: links.admin() }, { label: tr(event.name) }]"
89
34
  >
90
- <UFormField :label="t('admin.status', 'Status')">
91
- <USelect
92
- v-model="facts.status"
93
- :items="statuses"
94
- class="w-full"
35
+ <template #badge>
36
+ <StatusBadge
37
+ :status="event.status"
38
+ :label="t(`admin.statuses.${event.status}`, event.status)"
95
39
  />
96
- </UFormField>
97
- <UFormField :label="t('admin.starts', 'Starts')">
98
- <UInput
99
- v-model="facts.startsAt"
100
- type="datetime-local"
101
- class="w-full"
102
- required
103
- />
104
- </UFormField>
105
- <UFormField :label="t('admin.ends', 'Ends')">
106
- <UInput
107
- v-model="facts.endsAt"
108
- type="datetime-local"
109
- class="w-full"
110
- />
111
- </UFormField>
112
- <UFormField :label="t('admin.location', 'Location')">
113
- <TranslatedInput v-model="facts.location" />
114
- </UFormField>
115
- <UFormField :label="t('admin.slug', 'URL name')">
116
- <UInput
117
- v-model="facts.slug"
118
- class="w-full"
119
- required
120
- />
121
- </UFormField>
122
- </form>
123
- <template #footer>
124
- <div class="flex justify-end">
40
+ </template>
41
+ <template #actions>
125
42
  <UButton
126
- type="submit"
127
- form="admin-event-facts"
128
- :loading="busy"
129
- :label="t('admin.saveEvent', 'Save event')"
43
+ v-if="event.status === 'published' && hasPublicPage"
44
+ :to="links.event(event.slug)"
45
+ variant="outline"
46
+ color="neutral"
47
+ icon="i-lucide-external-link"
48
+ :label="t('admin.viewPublic', 'View public page')"
130
49
  />
131
- </div>
132
- </template>
133
- </SectionCard>
50
+ <RowMenu
51
+ variant="outline"
52
+ :subject="tr(event.name)"
53
+ :items="actions.menu(event, { open: false, viewPublic: false, onRemoved: () => navigateTo(links.admin()) })"
54
+ />
55
+ </template>
56
+ </PageHeading>
134
57
 
135
- <component
136
- :is="tabs"
137
- v-if="tabs"
138
- name="admin.events"
139
- :context="{ event }"
140
- @refresh="refresh"
141
- />
142
- <template v-else>
143
- <DetailsTab
144
- :event="event"
145
- @refresh="refresh"
146
- />
147
- <TicketsTab
148
- :event="event"
149
- @refresh="refresh"
150
- />
151
- <AttendeesTab
152
- :event="event"
58
+ <SectionCard :title="t('admin.keyFacts', 'Event')">
59
+ <DescriptionList :items="facts" />
60
+ </SectionCard>
61
+
62
+ <component
63
+ :is="tabs"
64
+ v-if="tabs"
65
+ name="admin.events"
66
+ :context="{ event }"
153
67
  @refresh="refresh"
154
68
  />
69
+ <template v-else>
70
+ <DetailsTab
71
+ :event="event"
72
+ @refresh="refresh"
73
+ />
74
+ <TicketsTab
75
+ :event="event"
76
+ @refresh="refresh"
77
+ />
78
+ <AttendeesTab
79
+ :event="event"
80
+ @refresh="refresh"
81
+ />
82
+ </template>
155
83
  </template>
84
+
85
+ <UEmpty
86
+ v-else-if="notFound || !error"
87
+ icon="i-lucide-calendar-x"
88
+ :title="t('notFound', 'Event not found')"
89
+ :actions="[{ label: t('admin.allEvents', 'All events'), to: links.admin(), color: 'neutral', variant: 'outline' }]"
90
+ />
91
+ <UEmpty
92
+ v-else
93
+ icon="i-lucide-circle-alert"
94
+ :title="t('admin.eventLoadFailed', 'The event could not be loaded')"
95
+ :description="errorMessage(error, '')"
96
+ :actions="[{ label: t('tryAgain', 'Try again'), icon: 'i-lucide-rotate-ccw', color: 'neutral', variant: 'outline', onClick: () => refresh() }]"
97
+ />
156
98
  </div>
157
- <UEmpty
158
- v-else
159
- icon="i-lucide-calendar-x"
160
- :title="t('notFound', 'Event not found')"
161
- :actions="[{ label: t('admin.allEvents', 'All events'), to: links.admin(), color: 'neutral', variant: 'outline' }]"
162
- />
163
99
  </template>
@@ -1,26 +1,42 @@
1
1
  <script setup>
2
+ import { EVENT_STATUSES } from "../utils/admin-event";
3
+ import { eventsApi } from "../../utils/api";
4
+ import { computed, emptyCell, h, resolveComponent, useEventActions, useEventLinks, useEventsTranslate, useFetch, useFormat, useModuleT, useTableQuery, useTableSort } from "#imports";
2
5
  defineOptions({ name: "EventsAdminPage" });
3
6
  const t = useModuleT("events");
4
7
  const tr = useEventsTranslate();
5
8
  const format = useFormat();
6
9
  const links = useEventLinks();
7
10
  const actions = useEventActions();
8
- const { data: events, status, refresh } = await useFetch("/api/events/admin/events", { default: () => [] });
9
- const { sorting, header } = useTableSort({ id: "startsAt", desc: true });
10
- const UBadge = resolveComponent("UBadge");
11
+ const { page, pageSize, search, sort: sortState, filters, query, filtered, reset } = useTableQuery({
12
+ sort: { id: "startsAt", desc: true },
13
+ filters: { status: "all" }
14
+ });
15
+ const { sorting, header } = useTableSort(null, sortState);
16
+ const { data, status, error, refresh } = await useFetch(eventsApi.admin.events, { query });
17
+ const rows = computed(() => data.value?.items ?? []);
18
+ const pending = computed(() => status.value === "pending");
19
+ const loading = computed(() => pending.value && !data.value);
20
+ const refreshing = computed(() => pending.value && !!data.value);
21
+ const forbidden = computed(() => error.value?.statusCode === 401 || error.value?.statusCode === 403);
22
+ const statusOptions = computed(() => [
23
+ { label: t("admin.allStatuses", "All statuses"), value: "all" },
24
+ ...EVENT_STATUSES.map((value) => ({ label: t(`admin.statuses.${value}`, value.charAt(0).toUpperCase() + value.slice(1)), value }))
25
+ ]);
26
+ const StatusBadge = resolveComponent("StatusBadge");
11
27
  const RowMenu = resolveComponent("RowMenu");
12
28
  const TableIdentity = resolveComponent("TableIdentity");
13
29
  const columns = computed(() => [
14
30
  {
15
31
  id: "name",
16
32
  accessorFn: (row) => tr(row.name),
17
- header: header("name", t("admin.name", "Name")),
33
+ header: t("admin.name", "Name"),
18
34
  cell: ({ row }) => h(TableIdentity, { title: tr(row.original.name), description: tr(row.original.location) })
19
35
  },
20
36
  {
21
37
  accessorKey: "status",
22
38
  header: header("status", t("admin.status", "Status")),
23
- cell: ({ row }) => h(UBadge, { variant: "subtle", color: statusColor(row.original.status), label: t(`admin.statuses.${row.original.status}`, row.original.status) })
39
+ cell: ({ row }) => h(StatusBadge, { status: row.original.status, label: t(`admin.statuses.${row.original.status}`, row.original.status) })
24
40
  },
25
41
  {
26
42
  accessorKey: "startsAt",
@@ -33,10 +49,6 @@ const columns = computed(() => [
33
49
  cell: ({ row }) => h(RowMenu, { items: actions.menu(row.original, { onRemoved: refresh }), subject: tr(row.original.name) })
34
50
  }
35
51
  ]);
36
- async function create() {
37
- const created = await actions.create();
38
- if (created) await navigateTo(links.adminEvent(created.id));
39
- }
40
52
  </script>
41
53
 
42
54
  <template>
@@ -49,32 +61,83 @@ async function create() {
49
61
  <UButton
50
62
  icon="i-lucide-plus"
51
63
  :label="t('admin.create', 'New event')"
52
- @click="create"
64
+ @click="actions.create()"
53
65
  />
54
66
  </template>
55
67
  </PageHeading>
56
68
 
57
- <TableCard
58
- :loading="status === 'pending' && !events.length"
59
- :refreshing="status === 'pending' && !!events.length"
60
- >
61
- <UTable
62
- v-model:sorting="sorting"
63
- :data="events"
64
- :columns="columns"
65
- :ui="tableUi()"
66
- @select="(_, row) => navigateTo(links.adminEvent(row.original.id))"
69
+ <UEmpty
70
+ v-if="error && !data"
71
+ :icon="forbidden ? 'i-lucide-lock' : 'i-lucide-circle-alert'"
72
+ :title="forbidden ? t('admin.forbidden', 'You do not have access to events') : t('admin.loadFailed', 'Events could not be loaded')"
73
+ :description="errorMessage(error, '')"
74
+ :actions="forbidden ? [] : [{ label: t('tryAgain', 'Try again'), icon: 'i-lucide-rotate-ccw', color: 'neutral', variant: 'outline', onClick: () => refresh() }]"
75
+ />
76
+
77
+ <template v-else>
78
+ <PageToolbar>
79
+ <UInput
80
+ v-model="search"
81
+ icon="i-lucide-search"
82
+ :loading="pending"
83
+ :placeholder="t('admin.search', 'Search events')"
84
+ :aria-label="t('admin.search', 'Search events')"
85
+ class="w-full sm:max-w-xs"
86
+ />
87
+ <USelect
88
+ v-model="filters.status"
89
+ :items="statusOptions"
90
+ :aria-label="t('admin.status', 'Status')"
91
+ class="w-44"
92
+ />
93
+ <UButton
94
+ v-if="filtered"
95
+ variant="ghost"
96
+ color="neutral"
97
+ icon="i-lucide-x"
98
+ :label="t('admin.clearFilters', 'Clear filters')"
99
+ @click="reset"
100
+ />
101
+ </PageToolbar>
102
+
103
+ <TableCard
104
+ :loading="loading"
105
+ :refreshing="refreshing"
67
106
  >
68
- <template #empty>
69
- <UEmpty
70
- variant="naked"
71
- icon="i-lucide-calendar"
72
- :title="t('admin.empty', 'No events yet')"
73
- :description="t('admin.emptyDescription', 'Create the first event to start selling tickets.')"
74
- :actions="[{ label: t('admin.create', 'New event'), icon: 'i-lucide-plus', onClick: create }]"
107
+ <UTable
108
+ v-model:sorting="sorting"
109
+ :data="rows"
110
+ :columns="columns"
111
+ :sorting-options="{ manualSorting: true }"
112
+ :ui="tableUi()"
113
+ @select="(_, row) => navigateTo(links.adminEvent(row.original.id))"
114
+ >
115
+ <template #empty>
116
+ <UEmpty
117
+ v-if="filtered"
118
+ variant="naked"
119
+ icon="i-lucide-search"
120
+ :title="t('admin.noMatches', 'No events match your search')"
121
+ :actions="[{ label: t('admin.clearFilters', 'Clear filters'), color: 'neutral', variant: 'outline', onClick: reset }]"
122
+ />
123
+ <UEmpty
124
+ v-else
125
+ variant="naked"
126
+ icon="i-lucide-calendar"
127
+ :title="t('admin.empty', 'No events yet')"
128
+ :description="t('admin.emptyDescription', 'Create the first event to start selling tickets.')"
129
+ :actions="[{ label: t('admin.create', 'New event'), icon: 'i-lucide-plus', onClick: () => actions.create() }]"
130
+ />
131
+ </template>
132
+ </UTable>
133
+ <template #footer>
134
+ <TablePagination
135
+ v-model:page="page"
136
+ v-model:page-size="pageSize"
137
+ :total="data?.total ?? 0"
75
138
  />
76
139
  </template>
77
- </UTable>
78
- </TableCard>
140
+ </TableCard>
141
+ </template>
79
142
  </div>
80
143
  </template>
@@ -1,4 +1,6 @@
1
1
  <script setup>
2
+ import { eventsApi } from "../../utils/api";
3
+ import { $fetch, computed, createError, onMounted, ref, resolveComponent, useAction, useEvent, useEventLinks, useEventsTranslate, useFormat, useModuleT, useRoute, useRuntimeConfig, useSeoMeta, useToast, useUserSession } from "#imports";
2
4
  const hasMdc = typeof resolveComponent("MDC") !== "string";
3
5
  const t = useModuleT("events");
4
6
  const route = useRoute();
@@ -24,7 +26,7 @@ onMounted(async () => {
24
26
  if (payment.value !== "success" || !returnedTicket.value) return;
25
27
  paidState.value = "waiting";
26
28
  for (let attempt = 0; attempt < 20; attempt++) {
27
- const ticket = await $fetch(`/api/events/tickets/${returnedTicket.value}`).catch(() => null);
29
+ const ticket = await $fetch(eventsApi.me.ticket(returnedTicket.value)).catch(() => null);
28
30
  if (ticket?.status === "paid") {
29
31
  paidState.value = "paid";
30
32
  await refresh();
@@ -115,7 +117,10 @@ defineOptions({ name: "EventDetailPage" });
115
117
  </div>
116
118
 
117
119
  <div class="grid gap-3 sm:grid-cols-2">
118
- <div class="bg-elevated/50 ring-default flex items-start gap-3 rounded-lg p-4 ring">
120
+ <div
121
+ class="flex items-start gap-3 p-4"
122
+ :class="surfaceClass"
123
+ >
119
124
  <UIcon
120
125
  name="i-lucide-calendar"
121
126
  class="text-primary mt-0.5 size-5 shrink-0"
@@ -135,8 +140,8 @@ defineOptions({ name: "EventDetailPage" });
135
140
  :href="event.locationUrl || void 0"
136
141
  :target="event.locationUrl ? '_blank' : void 0"
137
142
  :rel="event.locationUrl ? 'noopener' : void 0"
138
- class="bg-elevated/50 ring-default flex items-start gap-3 rounded-lg p-4 ring"
139
- :class="event.locationUrl ? 'hover:bg-elevated transition' : ''"
143
+ class="flex items-start gap-3 p-4"
144
+ :class="[surfaceClass, event.locationUrl ? 'hover:bg-elevated transition' : '']"
140
145
  >
141
146
  <UIcon
142
147
  name="i-lucide-map-pin"