From 1aa4642c930e749a4b90f98e1790dbd7ad10a9a1 Mon Sep 17 00:00:00 2001
From: Stefan
Date: Fri, 10 Jul 2026 13:38:17 +0100
Subject: [PATCH 01/11] Add listing attributes
---
src/features/admin/attributes.ts | 334 +++++++++++++++
src/features/admin/dashboard.ts | 64 ++-
src/features/admin/index.ts | 2 +
src/features/admin/listing-choice-post.ts | 37 ++
src/features/admin/listing-page-data.ts | 54 ++-
src/features/admin/listing-page.ts | 12 +
src/features/admin/questions.ts | 23 +-
src/features/public/pages.ts | 14 +-
src/features/public/ticket-submit.ts | 41 +-
src/features/public/types.ts | 3 +
src/locales/en/attributes.json | 33 ++
src/locales/en/entity-pages.json | 1 +
src/locales/en/index.ts | 2 +
src/locales/en/terms.json | 2 +
src/shared/admin-pages.ts | 1 +
src/shared/db/attributes.ts | 317 +++++++++++++++
src/shared/db/listings.ts | 8 +-
src/shared/db/migrations.ts | 3 +
.../2026-07-09_listing_attributes.ts | 19 +
src/shared/db/migrations/schema.ts | 56 ++-
src/shared/listing-attribute-filter.ts | 128 ++++++
src/ui/static/style.scss | 26 +-
src/ui/templates/admin/attributes.tsx | 314 +++++++++++++++
src/ui/templates/admin/dashboard.tsx | 43 +-
.../admin/listing-attribute-filters.ts | 80 ++++
.../templates/admin/listing-panel-frame.tsx | 40 ++
src/ui/templates/admin/questions.tsx | 148 +++----
.../components/aggregate-sections.tsx | 23 ++
src/ui/templates/components/reorder-table.tsx | 82 ++++
src/ui/templates/public/homepage.tsx | 15 +-
src/ui/templates/public/listing-attributes.ts | 19 +
src/ui/templates/public/reservations.tsx | 16 +
test/lib/db/migration-schema-guard.test.ts | 3 +-
test/lib/server-attributes.test.ts | 380 ++++++++++++++++++
test/lib/server-listings-filter.test.ts | 113 ++++++
test/shared/db/attributes.test.ts | 177 ++++++++
test/shared/db/listings/delete.test.ts | 19 +
test/shared/listing-attribute-filter.test.ts | 144 +++++++
test/test-utils.ts | 1 +
test/test-utils/db-helpers/attributes.ts | 49 +++
.../admin/listing-attribute-filters.test.ts | 62 +++
41 files changed, 2740 insertions(+), 168 deletions(-)
create mode 100644 src/features/admin/attributes.ts
create mode 100644 src/features/admin/listing-choice-post.ts
create mode 100644 src/locales/en/attributes.json
create mode 100644 src/shared/db/attributes.ts
create mode 100644 src/shared/db/migrations/2026-07-09_listing_attributes.ts
create mode 100644 src/shared/listing-attribute-filter.ts
create mode 100644 src/ui/templates/admin/attributes.tsx
create mode 100644 src/ui/templates/admin/listing-attribute-filters.ts
create mode 100644 src/ui/templates/admin/listing-panel-frame.tsx
create mode 100644 src/ui/templates/components/reorder-table.tsx
create mode 100644 src/ui/templates/public/listing-attributes.ts
create mode 100644 test/lib/server-attributes.test.ts
create mode 100644 test/shared/db/attributes.test.ts
create mode 100644 test/shared/listing-attribute-filter.test.ts
create mode 100644 test/test-utils/db-helpers/attributes.ts
create mode 100644 test/ui/templates/admin/listing-attribute-filters.test.ts
diff --git a/src/features/admin/attributes.ts b/src/features/admin/attributes.ts
new file mode 100644
index 0000000000..2bbf351314
--- /dev/null
+++ b/src/features/admin/attributes.ts
@@ -0,0 +1,334 @@
+/**
+ * Admin routes for listing attributes.
+ */
+
+/* jscpd:ignore-start */
+import {
+ createConfirmedHandlers,
+ createVerifiedFormRoute,
+} from "#routes/admin/confirmation.ts";
+import { OWNER_FORM, ownerPage, requireOwnerOr } from "#routes/auth.ts";
+import { ownerGetById } from "#routes/entity.ts";
+import {
+ errorRedirect,
+ htmlResponse,
+ notFoundResponse,
+ redirect,
+} from "#routes/response.ts";
+import { defineRoutes } from "#routes/router.ts";
+import {
+ type AuthedHandlerArgs,
+ createAuthedFormRoute,
+ createAuthedHandler,
+} from "#shared/app-forms.ts";
+import { logActivity } from "#shared/db/activityLog.ts";
+import {
+ type AttributeOption,
+ type AttributeWithOptions,
+ assignNextAttributeSortOrder,
+ attributeOptionsTable,
+ attributesTable,
+ deleteAttribute,
+ deleteAttributeOption,
+ getAllAttributesWithOptions,
+ getAttributeWithOptions,
+ getNextAttributeOptionSortOrder,
+ pruneInvalidAttributeOptionIds,
+ setListingAttributeOptions,
+ swapAttributeOptionOrder,
+ swapAttributeOrder,
+} from "#shared/db/attributes.ts";
+import { getFlash } from "#shared/flash-context.ts";
+import { defineForm } from "#shared/forms.tsx";
+import {
+ adminAttributeDeletePage,
+ adminAttributeOptionDeletePage,
+ adminAttributePage,
+ adminAttributesPage,
+ attributeNameFlat,
+} from "#templates/admin/attributes.tsx";
+import { createListingChoicePost } from "./listing-choice-post.ts";
+/* jscpd:ignore-end */
+
+export const attributeNameForm = defineForm({
+ fields: [
+ {
+ label: "Attribute name",
+ name: "name",
+ placeholder: "e.g. Difficulty",
+ required: true,
+ type: "text",
+ },
+ ] as const,
+ id: "attributeName",
+});
+
+export const attributeOptionForm = defineForm({
+ fields: [
+ {
+ label: "Option text",
+ name: "text",
+ placeholder: "e.g. Beginner",
+ required: true,
+ type: "text",
+ },
+ ] as const,
+ id: "attributeOption",
+});
+
+const handleAttributesGet = ownerPage(async (session) => {
+ const flash = getFlash();
+ return adminAttributesPage(
+ await getAllAttributesWithOptions(),
+ session,
+ flash.error,
+ );
+});
+
+const handleAttributesPost = createAuthedFormRoute({
+ auth: OWNER_FORM,
+ form: attributeNameForm,
+ onInvalid: ({ error }) => errorRedirect("/admin/attributes", error),
+ onValid: async ({ values: { name } }) => {
+ const attribute = await attributesTable.insert({ name });
+ await assignNextAttributeSortOrder(attribute.id);
+ await logActivity(`Attribute '${name}' created`);
+ return redirect(
+ `/admin/attributes/${attribute.id}`,
+ "Attribute created",
+ true,
+ );
+ },
+});
+
+const handleAttributeGet = ownerGetById(
+ getAttributeWithOptions,
+ (attribute, session) =>
+ htmlResponse(adminAttributePage(attribute, session, getFlash().error)),
+);
+
+type AttributeParams = { id: number };
+type AttributeOptionParams = { id: number; optionId: number };
+type AttributeOptionContext = {
+ attribute: AttributeWithOptions;
+ option: AttributeOption;
+};
+
+const redirectToAttribute = (args: {
+ error: string;
+ params: AttributeParams;
+}): Response =>
+ errorRedirect(`/admin/attributes/${args.params.id}`, args.error);
+
+const withAttribute = async (
+ id: number,
+ handle: (attribute: AttributeWithOptions) => Promise,
+): Promise => {
+ const attribute = await getAttributeWithOptions(id);
+ return attribute ? handle(attribute) : notFoundResponse();
+};
+
+const handleAttributeEdit = createAuthedFormRoute<
+ { name: string },
+ AttributeParams
+>({
+ auth: OWNER_FORM,
+ form: attributeNameForm,
+ onInvalid: redirectToAttribute,
+ onValid: ({ params, values: { name } }) =>
+ withAttribute(params.id, async () => {
+ await attributesTable.update(params.id, { name });
+ await logActivity(`Attribute '${name}' updated`);
+ return redirect(
+ `/admin/attributes/${params.id}`,
+ "Attribute updated",
+ true,
+ );
+ }),
+});
+
+const handleAddOption = createAuthedFormRoute<
+ { text: string },
+ AttributeParams
+>({
+ auth: OWNER_FORM,
+ form: attributeOptionForm,
+ onInvalid: redirectToAttribute,
+ onValid: ({ params, values: { text } }) =>
+ withAttribute(params.id, async (attribute) => {
+ await attributeOptionsTable.insert({
+ attributeId: params.id,
+ sortOrder: await getNextAttributeOptionSortOrder(params.id),
+ text,
+ });
+ await logActivity(`Attribute option '${text}' added to ${attribute.id}`);
+ return redirect(`/admin/attributes/${params.id}`, "Option added", true);
+ }),
+});
+
+const attributeDelete = createConfirmedHandlers({
+ identifier: (attribute) => attributeNameFlat(attribute.name),
+ identifierLabel: "Attribute name",
+ load: (id) => getAttributeWithOptions(id),
+ onConfirm: async (attribute) => {
+ await deleteAttribute(attribute.id);
+ await logActivity(`Attribute '${attribute.name}' deleted`);
+ },
+ path: "/admin/attributes/:id/delete",
+ render: (attribute, session, error) =>
+ adminAttributeDeletePage(attribute, session, error),
+ successMessage: "Attribute deleted",
+ successRedirect: "/admin/attributes",
+});
+
+const loadAttributeOption = async ({
+ id,
+ optionId,
+}: AttributeOptionParams): Promise => {
+ const attribute = await getAttributeWithOptions(id);
+ const option = attribute?.options.find((item) => item.id === optionId);
+ return attribute && option ? { attribute, option } : null;
+};
+
+const optionRoute =
+ (
+ handler: (
+ attribute: AttributeWithOptions,
+ option: AttributeOption,
+ session: Parameters[2],
+ ) => Response,
+ ) =>
+ (request: Request, params: AttributeOptionParams): Promise =>
+ requireOwnerOr(request, async (session) => {
+ const context = await loadAttributeOption(params);
+ if (!context) return notFoundResponse();
+ return handler(context.attribute, context.option, session);
+ });
+
+const handleDeleteOptionGet = optionRoute((attribute, option, session) =>
+ htmlResponse(
+ adminAttributeOptionDeletePage(
+ attribute,
+ option,
+ session,
+ getFlash().error,
+ ),
+ ),
+);
+
+const optionDeletePath = ({ id, optionId }: AttributeOptionParams): string =>
+ `/admin/attributes/${id}/options/${optionId}/delete`;
+
+const handleDeleteOptionPost = createVerifiedFormRoute<
+ AttributeOptionParams,
+ AttributeOptionContext
+>({
+ actionLabel: "deletion",
+ auth: OWNER_FORM,
+ identifier: ({ option }) => option.text,
+ identifierLabel: "Option text",
+ loadContext: loadAttributeOption,
+ mismatchRedirect: (_context, params) => optionDeletePath(params),
+ onConfirm: async ({ context: { attribute, option } }) => {
+ await deleteAttributeOption(option.id);
+ await logActivity(
+ `Attribute option '${option.text}' deleted from ${attribute.id}`,
+ );
+ return redirect(
+ `/admin/attributes/${attribute.id}`,
+ "Option deleted",
+ true,
+ );
+ },
+});
+
+const editOptionPath = ({ id }: AttributeOptionParams): string =>
+ `/admin/attributes/${id}`;
+
+const handleEditOptionPost = createAuthedFormRoute<
+ { text: string },
+ AttributeOptionParams,
+ AttributeOptionContext
+>({
+ auth: OWNER_FORM,
+ form: attributeOptionForm,
+ loadContext: loadAttributeOption,
+ onInvalid: ({ error, params }) =>
+ errorRedirect(editOptionPath(params), error),
+ onValid: async ({ context: { attribute, option }, values: { text } }) => {
+ await attributeOptionsTable.update(option.id, { text });
+ await logActivity(`Attribute option '${text}' updated in ${attribute.id}`);
+ return redirect(
+ `/admin/attributes/${attribute.id}`,
+ "Option updated",
+ true,
+ );
+ },
+});
+
+const optionActionHandler = (
+ handle: (
+ args: AuthedHandlerArgs,
+ ) => Response | Promise,
+) =>
+ createAuthedHandler({
+ auth: OWNER_FORM,
+ handle,
+ loadContext: loadAttributeOption,
+ });
+
+const moveOptionHandler = (direction: -1 | 1) =>
+ optionActionHandler(async ({ context: { attribute, option } }) => {
+ const index = attribute.options.findIndex((item) => item.id === option.id);
+ const neighbor = attribute.options[index + direction];
+ if (neighbor) await swapAttributeOptionOrder(option.id, neighbor.id);
+ return redirect(`/admin/attributes/${attribute.id}`, "Option moved", true);
+ });
+
+const moveAttributeHandler = (direction: -1 | 1) =>
+ createAuthedHandler({
+ auth: OWNER_FORM,
+ handle: async ({ context: attribute }) => {
+ const attributes = await getAllAttributesWithOptions();
+ const index = attributes.findIndex((item) => item.id === attribute.id);
+ const neighbor = attributes[index + direction];
+ if (neighbor) await swapAttributeOrder(attribute.id, neighbor.id);
+ return redirect("/admin/attributes", "Attribute moved", true);
+ },
+ loadContext: ({ id }) => getAttributeWithOptions(id),
+ });
+
+const handleListingAttributesPost = createListingChoicePost({
+ fieldName: "option_ids",
+ label: "Attributes",
+ noun: "option",
+ readIds: async (form) =>
+ pruneInvalidAttributeOptionIds(
+ await getAllAttributesWithOptions(),
+ form.getNumberArray("option_ids"),
+ ),
+ saveIds: setListingAttributeOptions,
+ tab: "attributes",
+});
+
+export const attributesRoutes = {
+ ...attributeDelete.routes,
+ ...defineRoutes({
+ "GET /admin/attributes": handleAttributesGet,
+ "GET /admin/attributes/:id": handleAttributeGet,
+ "GET /admin/attributes/:id/options/:optionId/delete": handleDeleteOptionGet,
+ "POST /admin/attributes": handleAttributesPost,
+ "POST /admin/attributes/:id/edit": handleAttributeEdit,
+ "POST /admin/attributes/:id/move-down": moveAttributeHandler(1),
+ "POST /admin/attributes/:id/move-up": moveAttributeHandler(-1),
+ "POST /admin/attributes/:id/options": handleAddOption,
+ "POST /admin/attributes/:id/options/:optionId/delete":
+ handleDeleteOptionPost,
+ "POST /admin/attributes/:id/options/:optionId/edit": handleEditOptionPost,
+ "POST /admin/attributes/:id/options/:optionId/move-down":
+ moveOptionHandler(1),
+ "POST /admin/attributes/:id/options/:optionId/move-up":
+ moveOptionHandler(-1),
+ "POST /admin/listing/:id/attributes": handleListingAttributesPost,
+ }),
+};
diff --git a/src/features/admin/dashboard.ts b/src/features/admin/dashboard.ts
index 687917042e..b7cada0a9c 100644
--- a/src/features/admin/dashboard.ts
+++ b/src/features/admin/dashboard.ts
@@ -28,12 +28,17 @@ import {
getActiveListingStats,
getNewestAttendeesRaw,
} from "#shared/db/attendees.ts";
+import { getSelectedAttributesForListings } from "#shared/db/attributes.ts";
import { getHiddenPackageMemberIds } from "#shared/db/groups.ts";
import { getActiveHolidays } from "#shared/db/holidays.ts";
import { getNonStandaloneChildIds } from "#shared/db/listing-parents.ts";
import { getAllListings, getListingNamesByIds } from "#shared/db/listings.ts";
import { settings } from "#shared/db/settings.ts";
import { getFlash } from "#shared/flash-context.ts";
+import {
+ attributeFilterGroupsForListings,
+ selectedAttributeFiltersFromRequest,
+} from "#shared/listing-attribute-filter.ts";
import {
filterListingsByType,
listingTypeFromRequest,
@@ -41,6 +46,7 @@ import {
import { requireRequestPrivateKey } from "#shared/session-private-key.ts";
import { sortListings } from "#shared/sort-listings.ts";
import { todayInTz } from "#shared/timezone.ts";
+import type { ListingWithCount } from "#shared/types.ts";
/* jscpd:ignore-end */
import {
type ActivityLogRefs,
@@ -50,6 +56,7 @@ import {
adminDashboardPage,
adminListingsPage,
} from "#templates/admin/dashboard.tsx";
+import type { ListingAttributeFilterView } from "#templates/admin/listing-attribute-filters.ts";
import { adminLoginPage } from "#templates/admin/login.tsx";
/** Login page response helper */
@@ -76,6 +83,28 @@ const loadSortedListings = async () => {
return sortListings(listings, holidays);
};
+const loadListingAttributeFilterContext = async (
+ request: Request,
+ listings: ListingWithCount[],
+ filterSource: ListingWithCount[],
+): Promise => {
+ const attributesByListing = await getSelectedAttributesForListings(
+ listings.map((listing) => listing.id),
+ );
+ const attributeFilters = attributeFilterGroupsForListings(
+ filterSource.map((listing) => listing.id),
+ attributesByListing,
+ );
+ return {
+ activeAttributeFilters: selectedAttributeFiltersFromRequest(
+ request,
+ attributeFilters,
+ ),
+ attributeFilters,
+ attributesByListing,
+ };
+};
+
/**
* Handle GET /admin/
*/
@@ -107,12 +136,22 @@ const handleAdminGet = (request: Request): Promise =>
// builder emits would be rejected by the server. A `bookable_alone` child
// has its own page, so it stays bookable here.
const listingIds = sortedListings.map((l) => l.id);
- const [childIds, hiddenMemberIds, upcomingServicingEvents] =
- await Promise.all([
- getNonStandaloneChildIds(listingIds),
- getHiddenPackageMemberIds(listingIds),
- getUpcomingServicingEvents(privateKey, todayInTz(settings.timezone)),
- ]);
+ const activeListings = sortedListings.filter((listing) => listing.active);
+ const [
+ childIds,
+ hiddenMemberIds,
+ upcomingServicingEvents,
+ attributeContext,
+ ] = await Promise.all([
+ getNonStandaloneChildIds(listingIds),
+ getHiddenPackageMemberIds(listingIds),
+ getUpcomingServicingEvents(privateKey, todayInTz(settings.timezone)),
+ loadListingAttributeFilterContext(
+ request,
+ sortedListings,
+ activeListings,
+ ),
+ ]);
const unbookableIds = new Set([...childIds, ...hiddenMemberIds]);
return htmlResponse(
adminDashboardPage(
@@ -127,6 +166,7 @@ const handleAdminGet = (request: Request): Promise =>
holidays,
unbookableIds,
upcomingServicingEvents,
+ attributeContext,
),
);
},
@@ -137,13 +177,15 @@ const handleAdminGet = (request: Request): Promise =>
* gated to content roles (staff + editor); the template renders role-aware
* columns/links so editors see no financials or forbidden detail links. */
const handleAdminListingsGet: TypedRouteHandler<"GET /admin/listings"> =
- contentPage(async (session) =>
- adminListingsPage(
- await loadSortedListings(),
+ contentPage(async (session, request) => {
+ const listings = await loadSortedListings();
+ return adminListingsPage(
+ listings,
session,
settings.listingColumnOrder,
- ),
- );
+ await loadListingAttributeFilterContext(request, listings, listings),
+ );
+ });
/** Handle GET /admin/listings/csv — export every listing (filtered by the same
* ?type= category filter the listings views use) as a CSV download. */
diff --git a/src/features/admin/index.ts b/src/features/admin/index.ts
index 4e4ee0aee1..48710fadf4 100644
--- a/src/features/admin/index.ts
+++ b/src/features/admin/index.ts
@@ -12,6 +12,7 @@ import { apiKeysRoutes } from "#routes/admin/api-keys.ts";
import { attendeeNotesRoutes } from "#routes/admin/attendee-notes.ts";
import { attendeeRefundRoutes } from "#routes/admin/attendee-refunds.ts";
import { attendeesRoutes } from "#routes/admin/attendees.ts";
+import { attributesRoutes } from "#routes/admin/attributes.ts";
import { authRoutes } from "#routes/admin/auth.ts";
import { backupRoutes } from "#routes/admin/backup.ts";
import { builderRoutes } from "#routes/admin/builder.ts";
@@ -88,6 +89,7 @@ const adminRouteModules: Record[] = [
bulkEmailRoutes,
holidaysCrud.routes,
imagesRoutes,
+ attributesRoutes,
questionsRoutes,
scannerRoutes,
seedsRoutes,
diff --git a/src/features/admin/listing-choice-post.ts b/src/features/admin/listing-choice-post.ts
new file mode 100644
index 0000000000..01069fba83
--- /dev/null
+++ b/src/features/admin/listing-choice-post.ts
@@ -0,0 +1,37 @@
+import { ownerFormById } from "#routes/entity.ts";
+import { notFoundResponse, redirect } from "#routes/response.ts";
+import { logActivity } from "#shared/db/activityLog.ts";
+import { getListingWithCount } from "#shared/db/listings.ts";
+import type { FormParams } from "#shared/form-data.ts";
+
+type ListingChoicePostConfig = {
+ fieldName: string;
+ label: string;
+ noun: string;
+ readIds?: (form: FormParams) => number[] | Promise;
+ saveIds: (listingId: number, ids: number[]) => Promise;
+ tab: string;
+};
+
+const countLabel = (count: number, noun: string): string =>
+ `${count} ${noun}${count === 1 ? "" : "s"}`;
+
+export const createListingChoicePost = ({
+ fieldName,
+ label,
+ noun,
+ readIds,
+ saveIds,
+ tab,
+}: ListingChoicePostConfig) =>
+ ownerFormById(async (id, _session, form) => {
+ const listing = await getListingWithCount(id);
+ if (!listing) return notFoundResponse();
+ const ids = readIds ? await readIds(form) : form.getNumberArray(fieldName);
+ await saveIds(id, ids);
+ await logActivity(
+ `${label} updated for '${listing.name}' (${countLabel(ids.length, noun)})`,
+ listing,
+ );
+ return redirect(`/admin/listing/${id}/${tab}`, `${label} updated`, true);
+ });
diff --git a/src/features/admin/listing-page-data.ts b/src/features/admin/listing-page-data.ts
index a1d956f0a1..ee52dda0eb 100644
--- a/src/features/admin/listing-page-data.ts
+++ b/src/features/admin/listing-page-data.ts
@@ -24,6 +24,10 @@ import {
decryptAttendees,
getAttendeeNamesByIds,
} from "#shared/db/attendees.ts";
+import {
+ getAllAttributesWithOptions,
+ getListingAttributeOptionIds,
+} from "#shared/db/attributes.ts";
import { getHiddenPackageMemberIds } from "#shared/db/groups.ts";
import { getListingOverviewStats } from "#shared/db/listing-overview-stats.ts";
import {
@@ -56,6 +60,7 @@ import {
type ListingWithCount,
} from "#shared/types.ts";
import { isIsoDate } from "#shared/validation/date.ts";
+import { ListingAttributesPanel } from "#templates/admin/attributes.tsx";
import { ListingQrPanel } from "#templates/admin/listing-qr.tsx";
import { ListingEditPanel } from "#templates/admin/listings/edit-panel.tsx";
import {
@@ -359,24 +364,43 @@ export const loadListingImagesPanel = ({
}: LoadedListing): Promise =>
loadItemImagesPanel("listing", listing.id, `/admin/listing/${listing.id}`);
+const listingChoicePanelLoader =
+ - (
+ loadItems: () => Promise
- ,
+ loadSelectedIds: (listingId: number) => Promise,
+ render: (
+ listing: ListingWithCount,
+ items: Item[],
+ selectedIds: Set,
+ error: string | undefined,
+ ) => JSX.Element,
+ ) =>
+ async ({ listing }: LoadedListing, error?: string): Promise => {
+ const [items, selectedIds] = await Promise.all([
+ loadItems(),
+ loadSelectedIds(listing.id),
+ ]);
+ return render(listing, items, new Set(selectedIds), error);
+ };
+
/** Build the Questions tab: assign the site's questions to this listing. The
* tab is owner-only (matching the route's own gate). `error` is set only on an
* in-place 400 re-render. */
-export const loadListingQuestionsPanel = async (
- { listing }: LoadedListing,
- error?: string,
-): Promise => {
- const [allQuestions, assignedIds] = await Promise.all([
- getAllQuestionsWithAnswers(),
- getListingQuestionIds(listing.id),
- ]);
- return ListingQuestionsPanel({
- allQuestions,
- assignedIds: new Set(assignedIds),
- error,
- listing,
- });
-};
+export const loadListingQuestionsPanel = listingChoicePanelLoader(
+ getAllQuestionsWithAnswers,
+ getListingQuestionIds,
+ (listing, allQuestions, assignedIds, error) =>
+ ListingQuestionsPanel({ allQuestions, assignedIds, error, listing }),
+);
+
+/** Build the Attributes tab: choose the public attributes displayed for this
+ * listing. `error` is set only on an in-place 400 re-render. */
+export const loadListingAttributesPanel = listingChoicePanelLoader(
+ getAllAttributesWithOptions,
+ getListingAttributeOptionIds,
+ (listing, attributes, selectedOptionIds, error) =>
+ ListingAttributesPanel({ attributes, error, listing, selectedOptionIds }),
+);
/** Build the QR tab: the booking-QR generation form. The tab is hidden for a
* child / hidden-package listing (no standalone booking page), so the loader
diff --git a/src/features/admin/listing-page.ts b/src/features/admin/listing-page.ts
index e48f219029..94ca1cefc3 100644
--- a/src/features/admin/listing-page.ts
+++ b/src/features/admin/listing-page.ts
@@ -35,6 +35,7 @@ import {
listingHasEmailableAttendees,
loadListingActivity,
loadListingActivityPreview,
+ loadListingAttributesPanel,
loadListingEditPanel,
loadListingForPage,
loadListingImagesPanel,
@@ -192,6 +193,17 @@ export const listingPage: EntityPage = defineEntityPage({
slug: "images",
visible: () => !isReadOnly() && isStorageEnabled(),
},
+ {
+ labelKey: "entity.tab.attributes",
+ sections: [
+ {
+ kind: "custom",
+ load: (entity) => loadListingAttributesPanel(entity),
+ },
+ ],
+ slug: "attributes",
+ visible: (_entity, session) => session.adminLevel === "owner",
+ },
{
labelKey: "entity.tab.questions",
sections: [
diff --git a/src/features/admin/questions.ts b/src/features/admin/questions.ts
index e758e8f7d5..042624ddb4 100644
--- a/src/features/admin/questions.ts
+++ b/src/features/admin/questions.ts
@@ -28,7 +28,7 @@ import {
createAuthedHandler,
} from "#shared/app-forms.ts";
import { logActivity } from "#shared/db/activityLog.ts";
-import { getAllListings, getListingWithCount } from "#shared/db/listings.ts";
+import { getAllListings } from "#shared/db/listings.ts";
import { getAllModifiers } from "#shared/db/modifiers.ts";
import {
type Answer,
@@ -83,6 +83,7 @@ import { formattingHint } from "#templates/components/formatting-hint.ts";
import { answerAggregateFields } from "#templates/fields/aggregate.ts";
/* jscpd:ignore-end */
+import { createListingChoicePost } from "./listing-choice-post.ts";
export const questionTextForm = defineForm({
fields: [
@@ -552,20 +553,12 @@ const handleMoveQuestionUp = moveQuestionHandler(-1);
/** Handle POST /admin/questions/:id/move-down */
const handleMoveQuestionDown = moveQuestionHandler(1);
-/** Handle POST /admin/listing/:id/questions — the listing entity page's
- * Questions tab (GET) posts here, and the save returns to that tab. */
-const handleListingQuestionsPost = ownerFormById(async (id, _session, form) => {
- const listing = await getListingWithCount(id);
- if (!listing) return notFoundResponse();
- const questionIds = form.getNumberArray("question_ids");
- await setListingQuestions(id, questionIds);
- await logActivity(
- `Questions updated for '${listing.name}' (${questionIds.length} question${
- questionIds.length !== 1 ? "s" : ""
- })`,
- listing,
- );
- return redirect(`/admin/listing/${id}/questions`, "Questions updated", true);
+const handleListingQuestionsPost = createListingChoicePost({
+ fieldName: "question_ids",
+ label: "Questions",
+ noun: "question",
+ saveIds: setListingQuestions,
+ tab: "questions",
});
/** Questions routes */
diff --git a/src/features/public/pages.ts b/src/features/public/pages.ts
index e72505c6b0..cb3494d821 100644
--- a/src/features/public/pages.ts
+++ b/src/features/public/pages.ts
@@ -21,6 +21,7 @@ import {
import { signCsrfToken } from "#shared/csrf.ts";
import { getBookableStartDates, parseIsoDateParam } from "#shared/dates.ts";
import { getListingRemainingForRange } from "#shared/db/attendees.ts";
+import { getSelectedAttributesForListings } from "#shared/db/attributes.ts";
import { getActiveHolidays } from "#shared/db/holidays.ts";
import { settings } from "#shared/db/settings.ts";
import type { FormParams } from "#shared/form-data.ts";
@@ -212,11 +213,13 @@ export const handlePublicListings = (
const requestedDate = parseIsoDateParam(
new URL(request.url).searchParams.get("date"),
);
- const [ticketListings, dateFilter, soldOutPackages] = await Promise.all([
- buildTicketListingsWithGroupCapacity(listings),
- buildDailyDateFilter(listings, requestedDate),
- soldOutPackageIds(groups, requestedDate),
- ]);
+ const [ticketListings, dateFilter, soldOutPackages, attributesByListing] =
+ await Promise.all([
+ buildTicketListingsWithGroupCapacity(listings),
+ buildDailyDateFilter(listings, requestedDate),
+ soldOutPackageIds(groups, requestedDate),
+ getSelectedAttributesForListings(listings.map((listing) => listing.id)),
+ ]);
return htmlResponse(
homepagePage(
applyParentSoldOut(ticketListings, classification),
@@ -230,6 +233,7 @@ export const handlePublicListings = (
nav,
soldOutPackages,
requestedDate,
+ attributesByListing,
),
);
});
diff --git a/src/features/public/ticket-submit.ts b/src/features/public/ticket-submit.ts
index 95c30fd581..9acf4333af 100644
--- a/src/features/public/ticket-submit.ts
+++ b/src/features/public/ticket-submit.ts
@@ -23,6 +23,7 @@ import {
getGroupRemainingByListingId,
getSharedGroupCapacities,
} from "#shared/db/attendees.ts";
+import { getSelectedAttributesForListings } from "#shared/db/attributes.ts";
import { getGroupIdsByListingIds } from "#shared/db/groups.ts";
import { getActiveHolidays } from "#shared/db/holidays.ts";
import { getImagesForItem } from "#shared/db/images.ts";
@@ -345,24 +346,34 @@ const renderCtx = async (ctx: TicketCtx): Promise => {
const children = [...ctx.childrenByParentId.values()]
.flat()
.map((child) => child.listing);
- const [childCaps, childOwnRemaining, holidays, membership, galleryImages] =
- await Promise.all([
- getSharedGroupCapacities(children),
- getGroupRemainingByListingId(children),
- getActiveHolidays(),
- getGroupIdsByListingIds([
- ...ctx.listings.map((l) => l.listing.id),
- ...children.map((c) => c.id),
- ]),
- // The header entity's image gallery — read only here, on the render path,
- // never on the submit/quote/API flows that don't show it.
- ctx.galleryTarget
- ? getImagesForItem(ctx.galleryTarget.type, ctx.galleryTarget.id)
- : Promise.resolve([]),
- ]);
+ const [
+ childCaps,
+ childOwnRemaining,
+ holidays,
+ membership,
+ galleryImages,
+ attributesByListing,
+ ] = await Promise.all([
+ getSharedGroupCapacities(children),
+ getGroupRemainingByListingId(children),
+ getActiveHolidays(),
+ getGroupIdsByListingIds([
+ ...ctx.listings.map((l) => l.listing.id),
+ ...children.map((c) => c.id),
+ ]),
+ // The header entity's image gallery — read only here, on the render path,
+ // never on the submit/quote/API flows that don't show it.
+ ctx.galleryTarget
+ ? getImagesForItem(ctx.galleryTarget.type, ctx.galleryTarget.id)
+ : Promise.resolve([]),
+ getSelectedAttributesForListings(
+ ctx.listings.map((entry) => entry.listing.id),
+ ),
+ ]);
const caps = childCapacityInfo(childCaps, childOwnRemaining, membership);
return {
...ctx,
+ attributesByListing,
galleryImages,
// The PER-GROUP remaining drives the per-parent quantity clamp keyed by the
// SPECIFIC group a parent and child share: a parent sharing a capped
diff --git a/src/features/public/types.ts b/src/features/public/types.ts
index 9698df0d14..bef9acca16 100644
--- a/src/features/public/types.ts
+++ b/src/features/public/types.ts
@@ -7,6 +7,7 @@ import type {
TicketListing,
} from "#shared/booking/model.ts";
import type { PagePackage } from "#shared/booking/page-packages.ts";
+import type { ListingAttributesById } from "#shared/db/attributes.ts";
import type { AddOnOption } from "#shared/db/modifier-resolve.ts";
import type { QuestionWithAnswers } from "#shared/db/question-types.ts";
import type { QuestionListingMap } from "#shared/db/questions/queries.ts";
@@ -90,6 +91,8 @@ export type TicketCtx = TicketSharedContext & {
* path alongside groupRemainingByGroupId so the shared-group quantity clamps
* resolve the group a parent and child actually share. Omitted on submit/quote. */
groupIdsByListingId?: ReadonlyMap;
+ /** Selected listing attributes for display. Present on render paths only. */
+ attributesByListing?: ListingAttributesById;
baseUrl?: string;
prefill?: BookingPrefill | undefined;
};
diff --git a/src/locales/en/attributes.json b/src/locales/en/attributes.json
new file mode 100644
index 0000000000..5477bee396
--- /dev/null
+++ b/src/locales/en/attributes.json
@@ -0,0 +1,33 @@
+{
+ "attributes.title": "Listing Attributes",
+ "attributes.add_submit": "Add Attribute",
+ "attributes.none": "No listing attributes yet.",
+ "attributes.order_column": "Order",
+ "attributes.attribute_column": "Attribute",
+ "attributes.options_column": "Options",
+ "attributes.option_column": "Option",
+ "attributes.detail_title": "Attribute: {name}",
+ "attributes.update": "Update",
+ "attributes.options_heading": "Options",
+ "attributes.add_option": "Add Option",
+ "attributes.no_options": "No options yet.",
+ "attributes.option_text_label": "Option text",
+ "attributes.guide_link": "Listings guide",
+ "attributes.delete.link": "Delete Attribute",
+ "attributes.delete.heading": "Delete Attribute",
+ "attributes.delete.warning": "This will permanently delete the attribute, all its options, and those selections from listings.",
+ "attributes.delete.confirm_label": "Attribute name",
+ "attributes.delete.confirm_prompt": "To delete this attribute, type its name \"{name}\" into the box below:",
+ "attributes.delete.submit": "Delete Attribute",
+ "attributes.delete_option.heading": "Delete Option",
+ "attributes.delete_option.warning": "This will remove \"{option}\" from {attribute} and from every listing that uses it.",
+ "attributes.delete_option.confirm_label": "Option text",
+ "attributes.delete_option.confirm_prompt": "To delete this option, type \"{text}\" into the box below:",
+ "attributes.delete_option.submit": "Delete Option",
+ "attributes.listing.heading": "Attributes for {listing}",
+ "attributes.listing.none": "No attributes created yet.",
+ "attributes.listing.create_first": "Create attributes",
+ "attributes.listing.no_options": "No options for this attribute yet.",
+ "attributes.listing.manage": "Manage attributes",
+ "attributes.filter.all": "All"
+}
diff --git a/src/locales/en/entity-pages.json b/src/locales/en/entity-pages.json
index 84e029aa7e..2c9a28fd54 100644
--- a/src/locales/en/entity-pages.json
+++ b/src/locales/en/entity-pages.json
@@ -7,6 +7,7 @@
"entity.tab.images": "Images",
"entity.tab.ledger": "Ledger",
"entity.tab.attendees": "Attendees",
+ "entity.tab.attributes": "Attributes",
"entity.tab.questions": "Questions",
"entity.tab.qr": "Booking QR",
"entity.tab.activity": "Activity",
diff --git a/src/locales/en/index.ts b/src/locales/en/index.ts
index 3ddcc0fab8..fe668cec98 100644
--- a/src/locales/en/index.ts
+++ b/src/locales/en/index.ts
@@ -5,6 +5,7 @@
import addressLookup from "./address-lookup.json" with { type: "json" };
import admin from "./admin.json" with { type: "json" };
import attendees from "./attendees.json" with { type: "json" };
+import attributes from "./attributes.json" with { type: "json" };
import availability from "./availability.json" with { type: "json" };
import backup from "./backup.json" with { type: "json" };
import builder from "./builder.json" with { type: "json" };
@@ -53,6 +54,7 @@ import users from "./users.json" with { type: "json" };
const en: Record = {
...addressLookup,
...admin,
+ ...attributes,
...availability,
...attendees,
...backup,
diff --git a/src/locales/en/terms.json b/src/locales/en/terms.json
index b586b1f790..fad2f0e434 100644
--- a/src/locales/en/terms.json
+++ b/src/locales/en/terms.json
@@ -3,6 +3,8 @@
"terms.attendees": "Attendees",
"terms.listing": "Listing",
"terms.listings": "Listings",
+ "terms.attribute": "Attribute",
+ "terms.attributes": "Attributes",
"terms.group": "Group",
"terms.groups": "Groups",
"terms.image": "Image",
diff --git a/src/shared/admin-pages.ts b/src/shared/admin-pages.ts
index b89d3e6cf7..c8058ae699 100644
--- a/src/shared/admin-pages.ts
+++ b/src/shared/admin-pages.ts
@@ -267,6 +267,7 @@ const ADMIN_NAV: readonly SectionDef[] = [
},
{ href: "/admin/settings/statuses", labelKey: "nav.sub.statuses" },
{ href: "/admin/privacy", labelKey: "nav.sub.privacy" },
+ { href: "/admin/attributes", labelKey: "terms.attributes" },
{ href: "/admin/questions", labelKey: "terms.questions" },
{ href: "/admin/logistics", labelKey: "nav.logistics" },
{ href: "/admin/emails", labelKey: "nav.emails" },
diff --git a/src/shared/db/attributes.ts b/src/shared/db/attributes.ts
new file mode 100644
index 0000000000..528198dbbe
--- /dev/null
+++ b/src/shared/db/attributes.ts
@@ -0,0 +1,317 @@
+/**
+ * Listing attributes: reusable multiple-choice metadata shown on listing pages.
+ *
+ * Attributes do not participate in booking. A listing stores selected option
+ * ids only; display code resolves those ids back to ordered attribute groups.
+ */
+
+import { map, reduce, unique } from "#fp";
+import { decrypt, encrypt } from "#shared/crypto/encryption.ts";
+import {
+ executeBatch,
+ inPlaceholders,
+ queryAll,
+ queryOne,
+} from "#shared/db/client.ts";
+import { linkTableSide } from "#shared/db/link-table.ts";
+import { swapSortOrder } from "#shared/db/query.ts";
+import { col, defineTable } from "#shared/db/table.ts";
+
+export type Attribute = {
+ id: number;
+ name: string;
+ sort_order: number;
+};
+
+export type AttributeOption = {
+ attribute_id: number;
+ id: number;
+ sort_order: number;
+ text: string;
+};
+
+export type AttributeWithOptions = Attribute & {
+ options: AttributeOption[];
+};
+
+export type ListingAttributesById = Map;
+
+type AttributeInput = {
+ name: string;
+ sortOrder?: number;
+};
+
+type AttributeOptionInput = {
+ attributeId: number;
+ sortOrder: number;
+ text: string;
+};
+
+const generatedId = col.generated();
+const encryptedText = col.encrypted(encrypt, decrypt);
+
+export const attributesTable = defineTable({
+ name: "attributes",
+ primaryKey: "id",
+ schema: {
+ id: generatedId,
+ name: encryptedText,
+ sort_order: col.withDefault(() => 0),
+ },
+});
+
+export const attributeOptionsTable = defineTable<
+ AttributeOption,
+ AttributeOptionInput
+>({
+ name: "attribute_options",
+ primaryKey: "id",
+ schema: {
+ attribute_id: col.simple(),
+ id: generatedId,
+ sort_order: col.withDefault(() => 0),
+ text: encryptedText,
+ },
+});
+
+export const listingAttributeOptions = linkTableSide(
+ "listing_attribute_options",
+ "listing_id",
+ "option_id",
+);
+
+const ATTRIBUTE_COLS = `attribute.id AS attribute_id,
+ attribute.name AS attribute_name,
+ attribute.sort_order AS attribute_sort_order,
+ attributeOption.id AS option_id,
+ attributeOption.attribute_id AS option_attribute_id,
+ attributeOption.text AS option_text,
+ attributeOption.sort_order AS option_sort_order`;
+
+type JoinedAttributeRow = {
+ attribute_id: number;
+ attribute_name: string;
+ attribute_sort_order: number;
+ option_attribute_id: number | null;
+ option_id: number | null;
+ option_sort_order: number | null;
+ option_text: string | null;
+};
+
+type SelectedAttributeRow = JoinedAttributeRow & {
+ listing_id: number;
+};
+
+type AttributeGroup = {
+ name: string;
+ sortOrder: number;
+ options: AttributeOption[];
+};
+
+const rowOption = (row: JoinedAttributeRow): AttributeOption | null =>
+ row.option_id === null
+ ? null
+ : {
+ attribute_id: row.option_attribute_id!,
+ id: row.option_id,
+ sort_order: row.option_sort_order!,
+ text: row.option_text!,
+ };
+
+const collectAttributeRow = (
+ acc: Map,
+ row: JoinedAttributeRow,
+): Map => {
+ const group = acc.get(row.attribute_id) ?? {
+ name: row.attribute_name,
+ options: [],
+ sortOrder: row.attribute_sort_order,
+ };
+ const option = rowOption(row);
+ if (option) group.options.push(option);
+ return acc.set(row.attribute_id, group);
+};
+
+const decryptAttribute = async (
+ id: number,
+ group: AttributeGroup,
+): Promise => {
+ const [attribute, ...options] = await Promise.all([
+ attributesTable.fromDb({
+ id,
+ name: group.name,
+ sort_order: group.sortOrder,
+ }),
+ ...group.options.map((option) => attributeOptionsTable.fromDb(option)),
+ ]);
+ return { ...attribute, options };
+};
+
+const groupAttributeRows = async (
+ rows: JoinedAttributeRow[],
+): Promise =>
+ Promise.all(
+ [
+ ...reduce(collectAttributeRow, new Map())(rows),
+ ].map(([id, group]) => decryptAttribute(id, group)),
+ );
+
+export const getAllAttributesWithOptions = async (): Promise<
+ AttributeWithOptions[]
+> =>
+ groupAttributeRows(
+ await queryAll(
+ `SELECT ${ATTRIBUTE_COLS}
+ FROM attributes AS attribute
+ LEFT JOIN attribute_options AS attributeOption
+ ON attributeOption.attribute_id = attribute.id
+ ORDER BY attribute.sort_order, attribute.id,
+ attributeOption.sort_order, attributeOption.id`,
+ ),
+ );
+
+export const getAttributeWithOptions = async (
+ id: number,
+): Promise => {
+ const rows = await queryAll(
+ `SELECT ${ATTRIBUTE_COLS}
+ FROM attributes AS attribute
+ LEFT JOIN attribute_options AS attributeOption
+ ON attributeOption.attribute_id = attribute.id
+ WHERE attribute.id = ?
+ ORDER BY attributeOption.sort_order, attributeOption.id`,
+ [id],
+ );
+ const [attribute] = await groupAttributeRows(rows);
+ return attribute ?? null;
+};
+
+export const assignNextAttributeSortOrder = async (
+ attributeId: number,
+): Promise => {
+ await executeBatch([
+ {
+ args: [attributeId, attributeId],
+ sql: `UPDATE attributes
+ SET sort_order = COALESCE(
+ (SELECT MAX(sort_order) FROM attributes WHERE id != ?), 0
+ ) + 1
+ WHERE id = ?`,
+ },
+ ]);
+};
+
+export const getNextAttributeOptionSortOrder = async (
+ attributeId: number,
+): Promise =>
+ (await queryOne<{ next_order: number }>(
+ "SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_order FROM attribute_options WHERE attribute_id = ?",
+ [attributeId],
+ ))!.next_order;
+
+export const swapAttributeOrder = (id1: number, id2: number): Promise =>
+ swapSortOrder("attributes", id1, id2);
+
+export const swapAttributeOptionOrder = (
+ id1: number,
+ id2: number,
+): Promise => swapSortOrder("attribute_options", id1, id2);
+
+export const deleteAttributeOption = async (
+ optionId: number,
+): Promise => {
+ await executeBatch([
+ {
+ args: [optionId],
+ sql: "DELETE FROM listing_attribute_options WHERE option_id = ?",
+ },
+ { args: [optionId], sql: "DELETE FROM attribute_options WHERE id = ?" },
+ ]);
+};
+
+export const deleteAttribute = async (attributeId: number): Promise => {
+ await executeBatch([
+ {
+ args: [attributeId],
+ sql:
+ "DELETE FROM listing_attribute_options WHERE option_id IN " +
+ "(SELECT id FROM attribute_options WHERE attribute_id = ?)",
+ },
+ {
+ args: [attributeId],
+ sql: "DELETE FROM attribute_options WHERE attribute_id = ?",
+ },
+ { args: [attributeId], sql: "DELETE FROM attributes WHERE id = ?" },
+ ]);
+};
+
+const selectedOptionRows = (
+ listingIds: number[],
+): Promise =>
+ listingIds.length === 0
+ ? Promise.resolve([])
+ : queryAll(
+ `SELECT listingAttribute.listing_id, ${ATTRIBUTE_COLS}
+ FROM listing_attribute_options AS listingAttribute
+ JOIN attribute_options AS attributeOption
+ ON attributeOption.id = listingAttribute.option_id
+ JOIN attributes AS attribute
+ ON attribute.id = attributeOption.attribute_id
+ WHERE listingAttribute.listing_id IN (${inPlaceholders(listingIds)})
+ ORDER BY listingAttribute.listing_id,
+ attribute.sort_order, attribute.id,
+ attributeOption.sort_order, attributeOption.id`,
+ listingIds,
+ );
+
+const selectedRowsForListing = (
+ rows: SelectedAttributeRow[],
+): Map =>
+ reduce(
+ (acc: Map, row: SelectedAttributeRow) => {
+ const listingRows = acc.get(row.listing_id) ?? [];
+ listingRows.push(row);
+ return acc.set(row.listing_id, listingRows);
+ },
+ new Map(),
+ )(rows);
+
+export const getSelectedAttributesForListings = async (
+ listingIds: number[],
+): Promise => {
+ const rowsByListing = selectedRowsForListing(
+ await selectedOptionRows(unique(listingIds)),
+ );
+ const entries = await Promise.all(
+ [...rowsByListing].map(
+ async ([listingId, rows]) =>
+ [listingId, await groupAttributeRows(rows)] as const,
+ ),
+ );
+ return new Map(entries);
+};
+
+export const getListingAttributeOptionIds = (
+ listingId: number,
+): Promise => listingAttributeOptions.getIds(listingId);
+
+export const setListingAttributeOptions = async (
+ listingId: number,
+ optionIds: number[],
+): Promise =>
+ listingAttributeOptions.setIds(listingId, unique(optionIds));
+
+export const optionIdsForAttributes = (
+ attributes: AttributeWithOptions[],
+): number[] =>
+ attributes.flatMap((attribute) =>
+ map((option: AttributeOption) => option.id)(attribute.options),
+ );
+
+export const pruneInvalidAttributeOptionIds = (
+ allAttributes: AttributeWithOptions[],
+ optionIds: number[],
+): number[] => {
+ const valid = new Set(optionIdsForAttributes(allAttributes));
+ return optionIds.filter((id) => valid.has(id));
+};
diff --git a/src/shared/db/listings.ts b/src/shared/db/listings.ts
index 1148da84ee..8130fed7ff 100644
--- a/src/shared/db/listings.ts
+++ b/src/shared/db/listings.ts
@@ -629,8 +629,8 @@ export const isSlugTaken = (
* Delete a listing and its own bookings in a single database round-trip.
*
* Only the deleted listing's rows are touched: its `listing_attendees` links,
- * its `listing_questions` assignments, its `listing_parents` edges (on either
- * side), its `activity_log` entries, and the listing itself. Attendees are
+ * its `listing_questions`/attribute assignments, its `listing_parents` edges
+ * (on either side), its `activity_log` entries, and the listing itself. Attendees are
* deliberately left alone — an attendee booked
* onto another listing keeps that booking (and all of its answers/payments)
* completely untouched, and an attendee left with no bookings is simply
@@ -655,6 +655,10 @@ export const deleteListing = async (listingId: number): Promise => {
args: [listingId],
sql: "DELETE FROM listing_questions WHERE listing_id = ?",
},
+ {
+ args: [listingId],
+ sql: "DELETE FROM listing_attribute_options WHERE listing_id = ?",
+ },
{
// Remove this listing from both sides of every parent/child edge.
args: [listingId, listingId],
diff --git a/src/shared/db/migrations.ts b/src/shared/db/migrations.ts
index 230531b9a1..8fb541da79 100644
--- a/src/shared/db/migrations.ts
+++ b/src/shared/db/migrations.ts
@@ -99,6 +99,7 @@ import packageSlotIdentityMigration from "./migrations/2026-07-05_package_slot_i
import listingAttendeesEndStartIndexMigration from "./migrations/2026-07-06_listing_attendees_end_start_index.ts";
import newsPostsMigration from "./migrations/2026-07-06_news_posts.ts";
import contactAttendeeTokensMigration from "./migrations/2026-07-07_contact_attendee_tokens.ts";
+import listingAttributesMigration from "./migrations/2026-07-09_listing_attributes.ts";
import { repairLegacyRenames } from "./migrations/rename-utils.ts";
import {
LATEST_UPDATE,
@@ -321,6 +322,8 @@ export const MIGRATIONS: Migration[] = [
listingAttendeesEndStartIndexMigration,
// Add the encrypted per-contact list of booked ticket tokens.
contactAttendeeTokensMigration,
+ // Public listing attributes and their multiple-choice options.
+ listingAttributesMigration,
].map((build) => build(migrationContext));
export const MIGRATION_IDS: string[] = MIGRATIONS.map(
diff --git a/src/shared/db/migrations/2026-07-09_listing_attributes.ts b/src/shared/db/migrations/2026-07-09_listing_attributes.ts
new file mode 100644
index 0000000000..27caa57720
--- /dev/null
+++ b/src/shared/db/migrations/2026-07-09_listing_attributes.ts
@@ -0,0 +1,19 @@
+import { schemaMigration } from "./define.ts";
+
+/**
+ * Public listing attributes: reusable attribute names, reusable multiple-choice
+ * options, and a listing-option link table for display and admin filtering.
+ */
+export default schemaMigration(
+ "2026-07-09_listing_attributes",
+ "Add listing attributes with reusable multiple-choice options.",
+ {
+ indexes: [
+ "idx_attributes_sort_order",
+ "idx_attribute_options_attribute",
+ "idx_listing_attribute_options_pair",
+ "idx_listing_attribute_options_option",
+ ],
+ newTables: ["attributes", "attribute_options", "listing_attribute_options"],
+ },
+);
diff --git a/src/shared/db/migrations/schema.ts b/src/shared/db/migrations/schema.ts
index 3040481c37..b3467ea982 100644
--- a/src/shared/db/migrations/schema.ts
+++ b/src/shared/db/migrations/schema.ts
@@ -34,7 +34,7 @@ export type Trigger = {
// ─── Version — update LATEST_UPDATE to describe each change ─────
export const LATEST_UPDATE =
- "Add idx_listing_attendees_end_start so the Logistics tab's cross-listing overlap query stays bounded.";
+ "Add listing attributes with reusable multiple-choice options.";
// ─── Schema (ordered: tables with no FK deps first) ─────────────
@@ -839,6 +839,60 @@ export const SCHEMA: [name: string, table: Table][] = [
},
],
+ [
+ // Reusable public listing attributes. The attribute name and option text are
+ // encrypted like listing descriptions and custom questions; filtering uses
+ // option ids, so no plaintext index is needed.
+ "attributes",
+ {
+ columns: [
+ ["id", "INTEGER PRIMARY KEY AUTOINCREMENT"],
+ ["name", "TEXT NOT NULL"],
+ ["sort_order", "INTEGER NOT NULL DEFAULT 0"],
+ ],
+ indexes: [{ columns: ["sort_order"], name: "idx_attributes_sort_order" }],
+ },
+ ],
+
+ [
+ "attribute_options",
+ {
+ columns: [
+ ["id", "INTEGER PRIMARY KEY AUTOINCREMENT"],
+ ["attribute_id", "INTEGER NOT NULL"],
+ ["text", "TEXT NOT NULL"],
+ ["sort_order", "INTEGER NOT NULL DEFAULT 0"],
+ ],
+ indexes: [
+ { columns: ["attribute_id"], name: "idx_attribute_options_attribute" },
+ ],
+ },
+ ],
+
+ [
+ // A selected option on a listing. The option points to its attribute, so the
+ // listing does not need a separate attribute row: one path handles one or
+ // many selected options.
+ "listing_attribute_options",
+ {
+ columns: [
+ ["listing_id", "INTEGER NOT NULL"],
+ ["option_id", "INTEGER NOT NULL"],
+ ],
+ indexes: [
+ {
+ columns: ["listing_id", "option_id"],
+ name: "idx_listing_attribute_options_pair",
+ unique: true,
+ },
+ {
+ columns: ["option_id"],
+ name: "idx_listing_attribute_options_option",
+ },
+ ],
+ },
+ ],
+
[
"built_sites",
{
diff --git a/src/shared/listing-attribute-filter.ts b/src/shared/listing-attribute-filter.ts
new file mode 100644
index 0000000000..58717e35ee
--- /dev/null
+++ b/src/shared/listing-attribute-filter.ts
@@ -0,0 +1,128 @@
+import { reduce } from "#fp";
+import type {
+ AttributeOption,
+ AttributeWithOptions,
+ ListingAttributesById,
+} from "#shared/db/attributes.ts";
+import type { ListingWithCount } from "#shared/types.ts";
+import { parsePositiveInt } from "#shared/validation/number.ts";
+
+export type AttributeFilterOption = Pick<
+ AttributeOption,
+ "id" | "sort_order" | "text"
+>;
+
+export type AttributeFilterGroup = {
+ id: number;
+ name: string;
+ options: AttributeFilterOption[];
+ sort_order: number;
+};
+
+export type SelectedAttributeFilters = Map;
+
+export const attributeFilterParam = (attributeId: number): string =>
+ `attribute_${attributeId}`;
+
+type MutableFilterGroup = Omit & {
+ options: Map;
+};
+
+const optionSort = (
+ left: AttributeFilterOption,
+ right: AttributeFilterOption,
+): number => left.sort_order - right.sort_order || left.id - right.id;
+
+const attributeSort = (
+ left: AttributeFilterGroup,
+ right: AttributeFilterGroup,
+): number => left.sort_order - right.sort_order || left.id - right.id;
+
+const addAttributeOptions = (
+ filters: Map,
+ attribute: AttributeWithOptions,
+): Map => {
+ const group = filters.get(attribute.id) ?? {
+ id: attribute.id,
+ name: attribute.name,
+ options: new Map(),
+ sort_order: attribute.sort_order,
+ };
+ for (const option of attribute.options) {
+ group.options.set(option.id, {
+ id: option.id,
+ sort_order: option.sort_order,
+ text: option.text,
+ });
+ }
+ return filters.set(attribute.id, group);
+};
+
+const addListingAttributes = (
+ filters: Map,
+ attributes: AttributeWithOptions[],
+): Map =>
+ reduce(addAttributeOptions, filters)(attributes);
+
+const freezeFilterGroup = (
+ group: MutableFilterGroup,
+): AttributeFilterGroup => ({
+ ...group,
+ options: [...group.options.values()].toSorted(optionSort),
+});
+
+export const attributeFilterGroupsForListings = (
+ listingIds: number[],
+ attributesByListing: ListingAttributesById,
+): AttributeFilterGroup[] =>
+ [
+ ...reduce(
+ (filters: Map, listingId: number) =>
+ addListingAttributes(filters, attributesByListing.get(listingId) ?? []),
+ new Map(),
+ )(listingIds).values(),
+ ]
+ .map(freezeFilterGroup)
+ .filter((group) => group.options.length > 0)
+ .toSorted(attributeSort);
+
+export const selectedAttributeFiltersFromRequest = (
+ request: Request,
+ filters: AttributeFilterGroup[],
+): SelectedAttributeFilters => {
+ const params = new URL(request.url).searchParams;
+ const selected = filters.flatMap((group) => {
+ const value = params.get(attributeFilterParam(group.id));
+ const optionId = value === null ? null : parsePositiveInt(value);
+ return optionId !== null &&
+ group.options.some((option) => option.id === optionId)
+ ? [[group.id, optionId] as const]
+ : [];
+ });
+ return new Map(selected);
+};
+
+const selectedOptionIds = (
+ attributes: AttributeWithOptions[] | undefined,
+): Set =>
+ new Set(
+ (attributes ?? []).flatMap((attribute) =>
+ attribute.options.map((option) => option.id),
+ ),
+ );
+
+export const filterListingsByAttributes =
+ (
+ selected: SelectedAttributeFilters,
+ attributesByListing: ListingAttributesById,
+ ) =>
+ (listings: ListingWithCount[]): ListingWithCount[] => {
+ const required = [...selected.values()];
+ if (required.length === 0) return listings;
+ return listings.filter((listing) => {
+ const listingOptions = selectedOptionIds(
+ attributesByListing.get(listing.id),
+ );
+ return required.every((optionId) => listingOptions.has(optionId));
+ });
+ };
diff --git a/src/ui/static/style.scss b/src/ui/static/style.scss
index 4744e86eeb..eac4b59591 100644
--- a/src/ui/static/style.scss
+++ b/src/ui/static/style.scss
@@ -235,6 +235,27 @@ article > * {
margin-bottom: 0;
}
+.listing-attributes {
+ display: grid;
+ gap: var(--space-xs);
+ margin-block: var(--space-s);
+}
+
+.listing-attributes > div {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0 var(--space-xs);
+}
+
+.listing-attributes dt {
+ color: var(--color-text-secondary);
+ font-weight: 700;
+}
+
+.listing-attributes dd {
+ margin: 0;
+}
+
/* Agent run sheet: the top-level booking list has no bullets or indent; the
nested job/detail lists drop their bullets but keep their padding so they
still read as indented sub-lists. */
@@ -2918,7 +2939,10 @@ a.cal-day-selected:active {
border-color: var(--color-secondary);
}
-.news-gallery-radio:focus-visible + .news-gallery-full + .news-gallery-thumb img {
+.news-gallery-radio:focus-visible
+ + .news-gallery-full
+ + .news-gallery-thumb
+ img {
outline: 2px solid var(--color-secondary);
outline-offset: 2px;
}
diff --git a/src/ui/templates/admin/attributes.tsx b/src/ui/templates/admin/attributes.tsx
new file mode 100644
index 0000000000..e36b86dbe7
--- /dev/null
+++ b/src/ui/templates/admin/attributes.tsx
@@ -0,0 +1,314 @@
+/* jscpd:ignore-start */
+import { t } from "#i18n";
+import { Raw } from "#jsx/jsx-runtime.ts";
+import {
+ attributeNameForm,
+ attributeOptionForm,
+} from "#routes/admin/attributes.ts";
+import type {
+ AttributeOption,
+ AttributeWithOptions,
+} from "#shared/db/attributes.ts";
+import { CsrfForm } from "#shared/forms.tsx";
+import type { AdminSession } from "#shared/types.ts";
+import { errorAdminPage } from "#templates/admin/admin-page.tsx";
+import { ConfirmPage } from "#templates/admin/confirm-page.tsx";
+import {
+ BackButton,
+ GuideFooter,
+ SubmitButton,
+} from "#templates/components/actions.tsx";
+import {
+ FormSections,
+ IdCheckboxLabel,
+} from "#templates/components/aggregate-sections.tsx";
+import {
+ ReorderCell,
+ ReorderTable,
+ reorderLinkTableAt,
+} from "#templates/components/reorder-table.tsx";
+import { colClass } from "#templates/components/table-columns.ts";
+import {
+ type ListingPanelProps,
+ listingChoicePanel,
+} from "./listing-panel-frame.tsx";
+/* jscpd:ignore-end */
+
+export const attributeNameFlat = (name: string): string =>
+ name.replace(/\r?\n/g, " / ");
+
+export const adminAttributesPage = (
+ attributes: AttributeWithOptions[],
+ session: AdminSession,
+ error?: string,
+): string =>
+ errorAdminPage(t("attributes.title"), "/admin/attributes")(session, error)(
+ <>
+
+
+ {t("attributes.add_submit")}
+
+
+ {attributes.length === 0 ? (
+
+ {t("attributes.none")}
+
+ ) : (
+ reorderLinkTableAt(
+ "/admin/attributes",
+ t("attributes.order_column"),
+ <>
+ {t("attributes.attribute_column")} |
+
+ {t("attributes.options_column")}
+ |
+ >,
+ attributes,
+ (attribute) => attributeNameFlat(attribute.name),
+ (attribute) => (
+ {attribute.options.length} |
+ ),
+ )
+ )}
+
+
+ {t("attributes.guide_link")}
+
+ >,
+ );
+
+const OptionRow = ({
+ attribute,
+ option,
+ index,
+}: {
+ attribute: AttributeWithOptions;
+ option: AttributeOption;
+ index: number;
+}): JSX.Element => (
+
+
+ `/admin/attributes/${attribute.id}/options/${option.id}/move-${direction}`
+ }
+ count={attribute.options.length}
+ index={index}
+ />
+ |
+
+
+ {t("common.save")}
+
+ |
+
+
+ {t("common.delete")}
+
+ |
+
+);
+
+export const adminAttributePage = (
+ attribute: AttributeWithOptions,
+ session: AdminSession,
+ error?: string,
+): string =>
+ errorAdminPage(
+ t("attributes.detail_title", { name: attributeNameFlat(attribute.name) }),
+ "/admin/attributes",
+ )(
+ session,
+ error,
+ )(
+ <>
+ {attributeNameFlat(attribute.name)}
+
+
+
+ {t("attributes.update")}
+
+
+ {t("attributes.options_heading")}
+
+
+ {t("attributes.add_option")}
+
+
+ {attribute.options.length === 0 ? (
+
+ {t("attributes.no_options")}
+
+ ) : (
+
+ {t("attributes.option_column")} |
+ {t("common.actions")} |
+ >
+ }
+ orderLabel={t("attributes.order_column")}
+ >
+ {attribute.options.map((option, index) => (
+
+ ))}
+
+ )}
+
+
+
+ {t("attributes.delete.link")}
+
+
+ >,
+ );
+
+const attributeConfirmPage = ({
+ action,
+ buttonText,
+ error,
+ heading,
+ label,
+ name,
+ prompt,
+ session,
+ warning,
+}: {
+ action: string;
+ buttonText: string;
+ error: string | undefined;
+ heading: string;
+ label: string;
+ name: string;
+ prompt: { args: Record; key: string };
+ session: AdminSession;
+ warning: JSX.Element;
+}): string =>
+ ConfirmPage({
+ action,
+ active: "/admin/attributes",
+ buttonText,
+ error,
+ heading,
+ label,
+ name,
+ prompt,
+ session,
+ title: heading,
+ warning,
+ });
+
+export const adminAttributeDeletePage = (
+ attribute: AttributeWithOptions,
+ session: AdminSession,
+ error?: string | undefined,
+): string => {
+ const name = attributeNameFlat(attribute.name);
+ return attributeConfirmPage({
+ action: `/admin/attributes/${attribute.id}/delete`,
+ buttonText: t("attributes.delete.submit"),
+ error,
+ heading: t("attributes.delete.heading"),
+ label: t("attributes.delete.confirm_label"),
+ name,
+ prompt: {
+ args: { name },
+ key: "attributes.delete.confirm_prompt",
+ },
+ session,
+ warning: {t("attributes.delete.warning")}
,
+ });
+};
+
+export const adminAttributeOptionDeletePage = (
+ attribute: AttributeWithOptions,
+ option: AttributeOption,
+ session: AdminSession,
+ error?: string | undefined,
+): string =>
+ attributeConfirmPage({
+ action: `/admin/attributes/${attribute.id}/options/${option.id}/delete`,
+ buttonText: t("attributes.delete_option.submit"),
+ error,
+ heading: t("attributes.delete_option.heading"),
+ label: t("attributes.delete_option.confirm_label"),
+ name: option.text,
+ prompt: {
+ args: { text: option.text },
+ key: "attributes.delete_option.confirm_prompt",
+ },
+ session,
+ warning: (
+
+ {t("attributes.delete_option.warning", {
+ attribute: attributeNameFlat(attribute.name),
+ option: option.text,
+ })}
+
+ ),
+ });
+
+type ListingAttributesPanelProps = ListingPanelProps & {
+ attributes: AttributeWithOptions[];
+ selectedOptionIds: Set;
+};
+
+export const ListingAttributesPanel = (
+ props: ListingAttributesPanelProps,
+): JSX.Element => {
+ const { attributes, error, listing, selectedOptionIds } = props;
+ return listingChoicePanel(
+ t("attributes.listing.heading", { listing: listing.name }),
+ error,
+
+
+ {t("attributes.listing.manage")}
+
+
,
+ attributes,
+ () => (
+
+ {t("attributes.listing.none")}{" "}
+ {t("attributes.listing.create_first")}.
+
+ ),
+ (availableAttributes) => (
+
+ ({
+ children:
+ attribute.options.length === 0 ? (
+
+ {t("attributes.listing.no_options")}
+
+ ) : (
+ attribute.options.map((option) => (
+
+ ))
+ ),
+ legend: attribute.name,
+ }))}
+ />
+ {t("common.save")}
+
+ ),
+ );
+};
diff --git a/src/ui/templates/admin/dashboard.tsx b/src/ui/templates/admin/dashboard.tsx
index a245146c71..cdbc0c1849 100644
--- a/src/ui/templates/admin/dashboard.tsx
+++ b/src/ui/templates/admin/dashboard.tsx
@@ -24,6 +24,7 @@ import type { ServicingEventSummary } from "#shared/db/attendees/servicing.ts";
import type { ActiveListingStats } from "#shared/db/attendees.ts";
import { isReadOnly } from "#shared/env.ts";
import { Raw } from "#shared/jsx/jsx-runtime.ts";
+import { filterListingsByAttributes } from "#shared/listing-attribute-filter.ts";
import {
filterListingsByType,
type ListingFilter,
@@ -38,6 +39,13 @@ import type {
} from "#shared/types.ts";
import { AdminPage, flashAdminPage } from "#templates/admin/admin-page.tsx";
import { HolidayTable } from "#templates/admin/holidays.tsx";
+import {
+ attributeFilterHref,
+ emptyAttributeFilterView,
+ type ListingAttributeFilterView,
+ renderAttributeFilterBars,
+ typeFilterHref,
+} from "#templates/admin/listing-attribute-filters.ts";
import { AttendeeTable } from "#templates/attendee-table.tsx";
import { ActionButton, GuideFooter } from "#templates/components/actions.tsx";
import { escapeHtml } from "#templates/layout.tsx";
@@ -352,6 +360,7 @@ export const adminDashboardPage = (
upcomingHolidays: Holiday[] = [],
unbookableIds: ReadonlySet = new Set(),
upcomingServicingEvents: ServicingEventSummary[] = [],
+ attributeFilterView: ListingAttributeFilterView = emptyAttributeFilterView(),
): string => {
const { columnKeys, filters } = resolveColumnLayout(
listingColumnTemplate ?? "",
@@ -372,13 +381,24 @@ export const adminDashboardPage = (
(e: ListingWithCount) => !unbookableIds.has(e.id),
)(activeListings);
const categories = unique(listings.map(listingCategory));
- const shownListings = filterListingsByType(activeType)(activeListings);
+ const { activeAttributeFilters, attributeFilters, attributesByListing } =
+ attributeFilterView;
+ const shownListings = filterListingsByAttributes(
+ activeAttributeFilters,
+ attributesByListing,
+ )(filterListingsByType(activeType)(activeListings));
const typeFilterHtml =
categories.length > 1
? renderTypeFilter(activeType, categories, (f) =>
- f === "all" ? "/admin/" : `/admin/?type=${f}`,
+ typeFilterHref("/admin/", activeAttributeFilters)(f),
)
: "";
+ const attributeFilterHtml = renderAttributeFilterBars(
+ attributeFilters,
+ activeAttributeFilters,
+ attributeFilterHref("/admin/", activeType, activeAttributeFilters),
+ );
+ const filterHtml = `${typeFilterHtml}${attributeFilterHtml}`;
return flashAdminPage(t("terms.listings"), "/admin/")(
session,
@@ -391,7 +411,7 @@ export const adminDashboardPage = (
@@ -425,6 +445,7 @@ export const adminListingsPage = (
listings: ListingWithCount[],
session: AdminSession,
listingColumnTemplate?: string,
+ attributeFilterView: ListingAttributeFilterView = emptyAttributeFilterView(),
): string => {
// Editors see a money-free, edit-linked table on a fixed order (their saved
// column template is irrelevant and never references the omitted columns), and
@@ -442,6 +463,17 @@ export const adminListingsPage = (
const deactivatedListings = filter((e: ListingWithCount) => !e.active)(
listings,
);
+ const { activeAttributeFilters, attributeFilters, attributesByListing } =
+ attributeFilterView;
+ const filterByAttribute = filterListingsByAttributes(
+ activeAttributeFilters,
+ attributesByListing,
+ );
+ const attributeFilterHtml = renderAttributeFilterBars(
+ attributeFilters,
+ activeAttributeFilters,
+ attributeFilterHref("/admin/listings", "all", activeAttributeFilters),
+ );
return String(
{deactivatedListings.length > 0 && (
@@ -462,7 +495,7 @@ export const adminListingsPage = (
{t("admin.dashboard.deactivated")}
({
+ activeAttributeFilters: new Map(),
+ attributeFilters: [],
+ attributesByListing: new Map(),
+});
+
+const filterParams = (
+ activeType: ListingFilter,
+ activeAttributes: SelectedAttributeFilters,
+): URLSearchParams => {
+ const params = new URLSearchParams();
+ if (activeType !== "all") params.set("type", activeType);
+ for (const [attributeId, optionId] of activeAttributes) {
+ params.set(attributeFilterParam(attributeId), String(optionId));
+ }
+ return params;
+};
+
+const hrefWithParams = (path: string, params: URLSearchParams): string => {
+ const query = params.toString();
+ return query ? `${path}?${query}` : path;
+};
+
+export const typeFilterHref =
+ (path: string, activeAttributes: SelectedAttributeFilters) =>
+ (type: ListingFilter): string =>
+ hrefWithParams(path, filterParams(type, activeAttributes));
+
+export const attributeFilterHref =
+ (
+ path: string,
+ activeType: ListingFilter,
+ activeAttributes: SelectedAttributeFilters,
+ ) =>
+ (attributeId: number, optionId: number | null): string => {
+ const params = filterParams(activeType, activeAttributes);
+ const name = attributeFilterParam(attributeId);
+ if (optionId === null) params.delete(name);
+ else params.set(name, String(optionId));
+ return hrefWithParams(path, params);
+ };
+
+export const renderAttributeFilterBars = (
+ filters: AttributeFilterGroup[],
+ activeFilters: SelectedAttributeFilters,
+ hrefFor: (attributeId: number, optionId: number | null) => string,
+): string =>
+ filters
+ .map((filterGroup) =>
+ renderFilterBar(escapeHtml(filterGroup.name), [
+ {
+ active: !activeFilters.has(filterGroup.id),
+ href: hrefFor(filterGroup.id, null),
+ label: t("attributes.filter.all"),
+ },
+ ...filterGroup.options.map((option) => ({
+ active: activeFilters.get(filterGroup.id) === option.id,
+ href: hrefFor(filterGroup.id, option.id),
+ label: escapeHtml(option.text),
+ })),
+ ]),
+ )
+ .join("");
diff --git a/src/ui/templates/admin/listing-panel-frame.tsx b/src/ui/templates/admin/listing-panel-frame.tsx
new file mode 100644
index 0000000000..55c10694c5
--- /dev/null
+++ b/src/ui/templates/admin/listing-panel-frame.tsx
@@ -0,0 +1,40 @@
+import type { Child } from "#jsx/jsx-runtime.ts";
+import { Flash } from "#shared/forms.tsx";
+import type { ListingWithCount } from "#shared/types.ts";
+
+export type ListingPanelProps = {
+ listing: ListingWithCount;
+ error?: string | undefined;
+};
+
+const ListingPanelFrame = ({
+ children,
+ error,
+ footer,
+ heading,
+}: {
+ children: Child;
+ error?: string | undefined;
+ footer: Child;
+ heading: string;
+}): JSX.Element => (
+ <>
+ {heading}
+
+ {children}
+ {footer}
+ >
+);
+
+export const listingChoicePanel = (
+ heading: string,
+ error: string | undefined,
+ footer: Child,
+ items: T[],
+ renderEmpty: () => Child,
+ renderItems: (items: T[]) => Child,
+): JSX.Element => (
+
+ {items.length === 0 ? renderEmpty() : renderItems(items)}
+
+);
diff --git a/src/ui/templates/admin/questions.tsx b/src/ui/templates/admin/questions.tsx
index 3987467be0..b7a63a8fb4 100644
--- a/src/ui/templates/admin/questions.tsx
+++ b/src/ui/templates/admin/questions.tsx
@@ -4,7 +4,6 @@
import { map } from "#fp";
import { t } from "#i18n";
-import type { Child } from "#jsx/jsx-runtime.ts";
import { Raw } from "#jsx/jsx-runtime.ts";
import { answerTextForm, questionTextForm } from "#routes/admin/questions.ts";
import type { Answer, QuestionWithAnswers } from "#shared/db/question-types.ts";
@@ -12,7 +11,7 @@ import type {
AnswerAggregateField,
AnswerAggregateRecalculation,
} from "#shared/db/questions/aggregates.ts";
-import { CsrfForm, Flash, renderFields } from "#shared/forms.tsx";
+import { CsrfForm, renderFields } from "#shared/forms.tsx";
import type { AdminSession, ListingWithCount } from "#shared/types.ts";
import { errorAdminPage } from "#templates/admin/admin-page.tsx";
import { ConfirmPage } from "#templates/admin/confirm-page.tsx";
@@ -32,18 +31,24 @@ import {
import {
CheckboxForm,
CheckboxLabel,
+ IdCheckboxLabel,
} from "#templates/components/aggregate-sections.tsx";
import {
LinkedItemsCheckboxes,
toLinkedItemOptions,
} from "#templates/components/linked-items.tsx";
import {
- ReorderArrows,
- type ReorderProps,
-} from "#templates/components/reorder.tsx";
+ ReorderCell,
+ ReorderTable,
+ reorderLinkTableAt,
+} from "#templates/components/reorder-table.tsx";
import { SelectField } from "#templates/components/select-field.tsx";
import { colClass } from "#templates/components/table-columns.ts";
import { answerAggregateFields } from "#templates/fields/aggregate.ts";
+import {
+ type ListingPanelProps,
+ listingChoicePanel,
+} from "./listing-panel-frame.tsx";
/** Render question text flat for admin display: line breaks are replaced with
* " / " so the text fits on one line in tables, headings, and confirmation
@@ -53,42 +58,6 @@ import { answerAggregateFields } from "#templates/fields/aggregate.ts";
export const questionTextFlat = (text: string): string =>
text.replace(/\r?\n/g, " / ");
-/** Move-up / move-down reorder controls used as the first column of the
- * question and answer tables. `action` builds the move path for a direction. */
-const ReorderControls = ({
- action,
- index,
- count,
-}: ReorderProps): JSX.Element => (
-
-
- |
-);
-
-/** A reorderable admin table: the scroll wrapper, the shared leading "order"
- * column header, the caller's remaining column headers, and the row body. The
- * question and answer tables differ only in their non-order columns, so this
- * keeps that scaffold (table-scroll → table → thead → order th) in one place. */
-const ReorderTable = ({
- columns,
- children,
-}: {
- columns: Child;
- children: Child;
-}): JSX.Element => (
-
-);
-
/** Listings cell for a question row: a count whose title attribute spells out
* the assigned listing names (comma + space separated), or "All" when the
* question is assigned to every listing. */
@@ -133,40 +102,31 @@ export const adminQuestionsPage = (
{t("questions.no_questions")}
) : (
-
+ {t("questions.question_column")} |
+
+ {t("questions.answers_column")}
+ |
+
+ {t("questions.listings_column")}
+ |
+ >,
+ questions,
+ (q) => questionTextFlat(q.text),
+ (q) => (
<>
- {t("questions.question_column")} |
-
- {t("questions.answers_column")}
- |
-
- {t("questions.listings_column")}
- |
- >
- }
- >
- {questions.map((q, i) => (
-
- `/admin/questions/${q.id}/move-${d}`}
- count={questions.length}
- index={i}
- />
- |
-
- {questionTextFlat(q.text)}
-
- |
{q.answers.length} |
-
- ))}
-
+ >
+ ),
+ )
)}
Questions guide
@@ -248,10 +208,11 @@ export const adminQuestionPage = (
>
}
+ orderLabel={t("questions.order_column")}
>
{question.answers.map((a, i) => (
-
`/admin/questions/${question.id}/answers/${a.id}/move-${d}`
}
@@ -590,37 +551,39 @@ export const adminAnswerDeletePage = (
* Rendered as the listing entity page's Questions tab (owner-only). Carries its
* own error flash for in-place 400 re-renders.
*/
-export const ListingQuestionsPanel = ({
- listing,
- allQuestions,
- assignedIds,
- error,
-}: {
- listing: ListingWithCount;
+type ListingQuestionsPanelProps = ListingPanelProps & {
allQuestions: QuestionWithAnswers[];
assignedIds: Set;
- error?: string | undefined;
-}): JSX.Element => (
- <>
- {t("questions.listing.heading", { listing: listing.name })}
-
+};
- {allQuestions.length === 0 ? (
+export const ListingQuestionsPanel = (
+ props: ListingQuestionsPanelProps,
+): JSX.Element => {
+ const { allQuestions, assignedIds, error, listing } = props;
+ return listingChoicePanel(
+ t("questions.listing.heading", { listing: listing.name }),
+ error,
+
+ {t("questions.listing.manage")}
+
,
+ allQuestions,
+ () => (
No questions created yet.{" "}
Create questions first.
- ) : (
+ ),
+ (questions) => (
{map((q: QuestionWithAnswers) => (
-
{" "}
@@ -630,12 +593,9 @@ export const ListingQuestionsPanel = ({
)}
)
-
- ))(allQuestions)}
+
+ ))(questions)}
- )}
-
- {t("questions.listing.manage")}
-
- >
-);
+ ),
+ );
+};
diff --git a/src/ui/templates/components/aggregate-sections.tsx b/src/ui/templates/components/aggregate-sections.tsx
index 0e0b41439c..1e2b765889 100644
--- a/src/ui/templates/components/aggregate-sections.tsx
+++ b/src/ui/templates/components/aggregate-sections.tsx
@@ -123,6 +123,29 @@ export const CheckboxLabel = ({
);
+export const IdCheckboxLabel = ({
+ checkedIds,
+ children,
+ id,
+ label,
+ name,
+}: {
+ checkedIds: ReadonlySet;
+ children?: Child;
+ id: number;
+ label: Child;
+ name: string;
+}): JSX.Element => (
+
+ {children}
+
+);
+
export const CheckboxesFieldset = ({
fieldName,
noneMessage,
diff --git a/src/ui/templates/components/reorder-table.tsx b/src/ui/templates/components/reorder-table.tsx
new file mode 100644
index 0000000000..2261fc7669
--- /dev/null
+++ b/src/ui/templates/components/reorder-table.tsx
@@ -0,0 +1,82 @@
+import type { Child } from "#jsx/jsx-runtime.ts";
+import {
+ ReorderArrows,
+ type ReorderProps,
+} from "#templates/components/reorder.tsx";
+import { colClass } from "#templates/components/table-columns.ts";
+
+export const ReorderCell = ({
+ action,
+ index,
+ count,
+}: ReorderProps): JSX.Element => (
+
+
+ |
+);
+
+const ReorderLinkRow = ({
+ action,
+ children,
+ count,
+ href,
+ index,
+ label,
+}: ReorderProps & {
+ children?: Child;
+ href: string;
+ label: Child;
+}): JSX.Element => (
+
+
+ |
+ {label}
+ |
+ {children}
+
+);
+
+export const reorderLinkTableAt = (
+ path: string,
+ orderLabel: string,
+ columns: Child,
+ items: T[],
+ label: (item: T) => Child,
+ children: (item: T) => Child,
+): JSX.Element => (
+
+ {items.map((item, index) => (
+ `${path}/${item.id}/move-${direction}`}
+ count={items.length}
+ href={`${path}/${item.id}`}
+ index={index}
+ label={label(item)}
+ >
+ {children(item)}
+
+ ))}
+
+);
+
+export const ReorderTable = ({
+ columns,
+ children,
+ orderLabel,
+}: {
+ columns: Child;
+ children: Child;
+ orderLabel: string;
+}): JSX.Element => (
+
+);
diff --git a/src/ui/templates/public/homepage.tsx b/src/ui/templates/public/homepage.tsx
index 20a106a3f2..500825f559 100644
--- a/src/ui/templates/public/homepage.tsx
+++ b/src/ui/templates/public/homepage.tsx
@@ -3,12 +3,14 @@ import { joinStrings, partition } from "#fp";
import { t } from "#i18n";
import type { TicketListing } from "#shared/booking/model.ts";
import { formatDateLabel, formatDatetimeLabel } from "#shared/dates.ts";
+import type { ListingAttributesById } from "#shared/db/attributes.ts";
import { isReadOnly } from "#shared/env.ts";
import { Raw } from "#shared/jsx/jsx-runtime.ts";
import { renderMarkdown } from "#shared/markdown.ts";
import type { Group } from "#shared/types.ts";
import { Badge } from "#templates/components/badge.tsx";
import { escapeHtml } from "#templates/layout.tsx";
+import { renderListingAttributes } from "./listing-attributes.ts";
import {
compareGroupsByName,
PackagesSection,
@@ -182,6 +184,7 @@ const renderListingCard =
(
childStateOf: (id: number) => ChildCardState,
dateFilter: DailyDateFilter | null,
+ attributesByListing: ListingAttributesById,
) =>
(info: TicketListing): RenderedCard => {
const { listing } = info;
@@ -194,6 +197,9 @@ const renderListingCard =
const descriptionHtml = listing.description
? renderMarkdown(listing.description)
: "";
+ const attributesHtml = renderListingAttributes(
+ attributesByListing.get(listing.id),
+ );
const cta = renderListingCardCta(
info,
childStateOf(listing.id),
@@ -202,7 +208,7 @@ const renderListingCard =
const proseHtml = `${escapeHtml(
listing.name,
- )}
${dateHtml}${locationHtml}${descriptionHtml}${
+ )}${dateHtml}${locationHtml}${descriptionHtml}${attributesHtml}${
cta.insideProse ? cta.html : ""
}`;
return {
@@ -221,7 +227,9 @@ const renderGroupCard = (group: Group, soldOut: boolean): string => {
if (soldOut) {
return `${escapeHtml(
group.name,
- )}
${descriptionHtml}${statusBadgeParagraph(t("public.sold_out"))}`;
+ )}${descriptionHtml}${statusBadgeParagraph(
+ t("public.sold_out"),
+ )}`;
}
const linkHtml = isReadOnly()
? `${t("public.registration_closed")}
`
@@ -246,6 +254,7 @@ export const homepagePage = (
nav: PublicNavProps,
soldOutPackageIds: ReadonlySet,
requestedDate: string | null,
+ attributesByListing: ListingAttributesById = new Map(),
): string => {
const listingsTitle = t("terms.listings");
const title = websiteTitle
@@ -290,7 +299,7 @@ export const homepagePage = (
: partition((g: Group) => !soldOutPackageIds.has(g.id))
)(packageGroups);
const listingCards = listings.map(
- renderListingCard(childStateOf, dateFilter),
+ renderListingCard(childStateOf, dateFilter, attributesByListing),
);
const [availableCards, unavailableCards] = (
requestedDate === null
diff --git a/src/ui/templates/public/listing-attributes.ts b/src/ui/templates/public/listing-attributes.ts
new file mode 100644
index 0000000000..5daada8d52
--- /dev/null
+++ b/src/ui/templates/public/listing-attributes.ts
@@ -0,0 +1,19 @@
+import type { AttributeWithOptions } from "#shared/db/attributes.ts";
+import { escapeHtml } from "#templates/layout.tsx";
+
+const attributeOptionsText = (attribute: AttributeWithOptions): string =>
+ attribute.options.map((option) => option.text).join(", ");
+
+const renderAttribute = (attribute: AttributeWithOptions): string =>
+ `${escapeHtml(attribute.name)}${escapeHtml(
+ attributeOptionsText(attribute),
+ )}`;
+
+export const renderListingAttributes = (
+ attributes: readonly AttributeWithOptions[] | undefined,
+): string =>
+ attributes && attributes.length > 0
+ ? `${attributes
+ .map(renderAttribute)
+ .join("")}
`
+ : "";
diff --git a/src/ui/templates/public/reservations.tsx b/src/ui/templates/public/reservations.tsx
index e7875999a0..e2339b88c5 100644
--- a/src/ui/templates/public/reservations.tsx
+++ b/src/ui/templates/public/reservations.tsx
@@ -46,6 +46,10 @@ import {
formatDateLabel,
formatDatetimeLabel,
} from "#shared/dates.ts";
+import type {
+ AttributeWithOptions,
+ ListingAttributesById,
+} from "#shared/db/attributes.ts";
import type { AddOnOption } from "#shared/db/modifier-resolve.ts";
import type { QuestionWithAnswers } from "#shared/db/question-types.ts";
import type { QuestionListingMap } from "#shared/db/questions/queries.ts";
@@ -81,6 +85,7 @@ import {
} from "#templates/components/question-text.tsx";
import { getTicketFields } from "#templates/fields/ticket.ts";
import { escapeHtml, Layout } from "#templates/layout.tsx";
+import { renderListingAttributes } from "./listing-attributes.ts";
import { PublicImageGallery, renderListingImage } from "./shared.tsx";
/** OpenGraph meta tags for a public listing page. */
export const buildOgTags = (
@@ -1009,6 +1014,8 @@ export type TicketPageOptions = {
/** The header entity's images, shown as the shared CSS gallery above the
* form (empty ⇒ falls back to the single header image). */
galleryImages?: readonly Image[];
+ /** Selected listing attributes, populated only on render paths. */
+ attributesByListing?: ListingAttributesById;
prefill?: BookingPrefill | undefined;
/** Override the