From 992e05701d43af8e28d60186a140dcfc1d7f2e35 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 12 Oct 2026 01:42:10 +0530 Subject: [PATCH 1/7] feat(check-in): guest list with search and a check-in dialog, gated to the event's team Check-in is now allowed for the event team's Owner, Admin, Manager and Frontdesk members only. It used to need the site-wide Frontdesk Manager role plus read access, which let a Frontdesk member of one team check in another team's tickets, and kept Managers out. The check-in page lists the event's guests with search and tabs for everyone, checked in and not checked in. A guest opens a dialog with their ticket tier and a check-in button. Scanning still validates the ticket first, then opens the same dialog and returns to the camera. The event picker lists only the events the user can check in for. --- buzz/api/checkin/__init__.py | 26 ++- buzz/api/checkin/schemas.py | 9 + buzz/api/checkin/services.py | 37 +++- buzz/api/checkin/test_checkin.py | 82 ++++++- buzz/api/events/schemas.py | 3 + buzz/api/events/services.py | 37 +++- buzz/permissions.py | 6 + dashboard/src/components/EventSelector.vue | 20 +- dashboard/src/components/QRScanner.vue | 16 +- .../src/components/TicketDetailsModal.vue | 188 ---------------- .../components/checkin/CheckInGuestList.vue | 206 ++++++++++++++++++ .../components/checkin/GuestCheckInDialog.vue | 113 ++++++++++ .../dashboard/events/EventGuestItem.vue | 1 + dashboard/src/composables/useEventGuests.ts | 11 +- .../src/composables/useTicketValidation.ts | 119 ++-------- dashboard/src/pages/CheckIn.vue | 23 ++ dashboard/src/pages/CheckInScanner.vue | 143 ------------ dashboard/src/router.ts | 2 +- dashboard/src/types.ts | 3 + e2e/pages/check-in.page.ts | 16 +- e2e/tests/check-in.spec.ts | 4 +- 21 files changed, 581 insertions(+), 484 deletions(-) delete mode 100644 dashboard/src/components/TicketDetailsModal.vue create mode 100644 dashboard/src/components/checkin/CheckInGuestList.vue create mode 100644 dashboard/src/components/checkin/GuestCheckInDialog.vue create mode 100644 dashboard/src/pages/CheckIn.vue delete mode 100644 dashboard/src/pages/CheckInScanner.vue diff --git a/buzz/api/checkin/__init__.py b/buzz/api/checkin/__init__.py index 54be4d61..31f2cd08 100644 --- a/buzz/api/checkin/__init__.py +++ b/buzz/api/checkin/__init__.py @@ -1,7 +1,9 @@ import frappe -from buzz.api.checkin.schemas import CheckinResponse -from buzz.api.checkin.services import CheckinService +from buzz.api.checkin.schemas import CheckinEvent, CheckinResponse +from buzz.api.checkin.services import CheckinService, checkin_events, ensure_can_check_in +from buzz.api.events.schemas import EventGuestsResponse +from buzz.api.events.services import GUESTS_PAGE_SIZE, event_guests @frappe.whitelist() @@ -9,6 +11,24 @@ def validate_ticket_for_checkin(ticket_id: str) -> CheckinResponse: return CheckinService(ticket_id).validate() -@frappe.whitelist() +@frappe.whitelist(methods=["POST"]) def checkin_ticket(ticket_id: str) -> CheckinResponse: return CheckinService(ticket_id).checkin() + + +@frappe.whitelist() +def get_checkin_events() -> list[CheckinEvent]: + return checkin_events() + + +@frappe.whitelist() +def get_checkin_guests( + event: str, + search: str | None = None, + checked_in: str | None = None, + start: int = 0, + limit: int = GUESTS_PAGE_SIZE, +) -> EventGuestsResponse: + """The guest list, for someone who can check its guests in.""" + ensure_can_check_in(event) + return event_guests(event, search, start=start, limit=limit, checked_in=checked_in) diff --git a/buzz/api/checkin/schemas.py b/buzz/api/checkin/schemas.py index e875bc41..128f4049 100644 --- a/buzz/api/checkin/schemas.py +++ b/buzz/api/checkin/schemas.py @@ -40,3 +40,12 @@ class CheckinResponse(APIResponse): message: str ticket: TicketCheckinDetails payment_details: PaymentDetails | None = None + + +class CheckinEvent(APIResponse): + name: str + title: str + start_date: date | None + start_time: timedelta | None + end_date: date | None + end_time: timedelta | None diff --git a/buzz/api/checkin/services.py b/buzz/api/checkin/services.py index 1bce4694..6efdbc36 100644 --- a/buzz/api/checkin/services.py +++ b/buzz/api/checkin/services.py @@ -11,13 +11,14 @@ TicketRefunded, ) from buzz.api.checkin.schemas import ( + CheckinEvent, CheckinResponse, PaymentDetails, TicketAddOn, TicketCheckinDetails, ) from buzz.api.exceptions import NotPermitted -from buzz.permissions import has_team_access +from buzz.permissions import CHECKIN_ROLES, can_check_in, is_unrestricted from buzz.ticketing.doctype.event_booking_refund.event_booking_refund import get_committed_tickets if TYPE_CHECKING: @@ -28,19 +29,45 @@ CANCELLED = 2 +def ensure_can_check_in(event: str) -> None: + team = frappe.db.get_value("Buzz Event", event, "team") + if not can_check_in(team, frappe.session.user): + NotPermitted.throw() + + +def checkin_events() -> list[CheckinEvent]: + """Events not yet over, on the teams the user works the front desk for.""" + filters = {"is_published": 1, "end_date": [">=", today()]} + user = frappe.session.user + if not is_unrestricted(user): + teams = frappe.get_all( + "Buzz Team Membership", + filters={"user": user, "enabled": 1, "team_role": ["in", list(CHECKIN_ROLES)]}, + pluck="team", + ) + filters["team"] = ["in", teams or [""]] + + rows = frappe.get_all( + "Buzz Event", + filters=filters, + fields=["name", "title", "start_date", "start_time", "end_date", "end_time"], + order_by="start_date asc", + ignore_permissions=True, + ) + return [CheckinEvent(**{**row, "name": str(row.name)}) for row in rows] + + class CheckinService: - """Front-desk check-in for a single ticket. Restricted to Frontdesk Manager.""" + """Front-desk check-in for a single ticket, by a team member at Frontdesk or above.""" def __init__(self, ticket_id: str): - frappe.only_for("Frontdesk Manager", True) if not frappe.db.exists("Event Ticket", ticket_id): TicketNotFound.throw() self.ticket_id = ticket_id self.date = today() # Both endpoints route through here, so one team check covers them. - if not has_team_access(self.event.team, "read", frappe.session.user): - NotPermitted.throw() + ensure_can_check_in(self.ticket.event) @property def ticket(self) -> "EventTicket": diff --git a/buzz/api/checkin/test_checkin.py b/buzz/api/checkin/test_checkin.py index dcefacde..13848004 100644 --- a/buzz/api/checkin/test_checkin.py +++ b/buzz/api/checkin/test_checkin.py @@ -1,13 +1,22 @@ import frappe from frappe.tests import IntegrationTestCase -from buzz.api.checkin import checkin_ticket, validate_ticket_for_checkin +from buzz.api.checkin import ( + checkin_ticket, + get_checkin_events, + get_checkin_guests, + validate_ticket_for_checkin, +) from buzz.api.checkin.exceptions import AlreadyCheckedIn, TicketCancelled, TicketNotFound +from buzz.api.exceptions import NotPermitted from buzz.tests.factories import ( BuzzEventFactory, + BuzzTeamFactory, + BuzzTeamMembershipFactory, EventBookingFactory, EventPaymentFactory, EventTicketFactory, + UserFactory, ) @@ -86,3 +95,74 @@ def test_validation_rejects_an_already_checked_in_ticket(self): with self.assertRaises(AlreadyCheckedIn): validate_ticket_for_checkin(self.ticket) + + +class TestCheckinAccess(IntegrationTestCase): + """Front-desk work is for the event's own team, at Frontdesk or above.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + owner = UserFactory.create_once("checkin-owner@example.com").name + other_owner = UserFactory.create_once("checkin-other-owner@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(owner).name + cls.other_team = BuzzTeamFactory.create_owned_by(other_owner).name + cls.event = str(BuzzEventFactory.create(team=cls.team).name) + cls.other_event = str(BuzzEventFactory.create(team=cls.other_team).name) + + cls.frontdesk = cls.member("checkin-frontdesk@example.com", "Frontdesk") + cls.manager = cls.member("checkin-manager@example.com", "Manager") + cls.viewer = cls.member("checkin-viewer@example.com", "Viewer") + # Front desk on one team, only a Viewer on the other. + BuzzTeamMembershipFactory.create(team=cls.other_team, user=cls.frontdesk, team_role="Viewer") + + @classmethod + def member(cls, email: str, team_role: str) -> str: + user = UserFactory.create_once(email).name + BuzzTeamMembershipFactory.create(team=cls.team, user=user, team_role=team_role) + return user + + def setUp(self): + frappe.clear_messages() + + def test_frontdesk_and_manager_can_check_in(self): + for user in (self.frontdesk, self.manager): + ticket = EventTicketFactory.create(event=self.event).name + with self.set_user(user): + self.assertTrue(checkin_ticket(ticket).ticket.is_checked_in) + + def test_viewer_cannot_check_in(self): + ticket = EventTicketFactory.create(event=self.event).name + with self.set_user(self.viewer), self.assertRaises(NotPermitted): + validate_ticket_for_checkin(ticket) + + def test_frontdesk_role_does_not_carry_to_another_team(self): + ticket = EventTicketFactory.create(event=self.other_event).name + with self.set_user(self.frontdesk), self.assertRaises(NotPermitted): + checkin_ticket(ticket) + + def test_events_list_only_the_front_desk_teams(self): + with self.set_user(self.frontdesk): + events = {event.name for event in get_checkin_events()} + + self.assertIn(self.event, events) + self.assertNotIn(self.other_event, events) + + def test_viewer_cannot_open_the_guest_list(self): + with self.set_user(self.viewer), self.assertRaises(NotPermitted): + get_checkin_guests(self.event) + + def test_guest_list_splits_on_todays_check_in(self): + event = str(BuzzEventFactory.create(team=self.team).name) + arrived, waiting = (EventTicketFactory.create("submitted", event=event).name for _ in range(2)) + with self.set_user(self.frontdesk): + checkin_ticket(arrived) + everyone = get_checkin_guests(event).__json__() + checked_in = get_checkin_guests(event, checked_in="1").__json__() + not_checked_in = get_checkin_guests(event, checked_in="0").__json__() + + self.assertEqual(everyone["checked_in"], 1) + self.assertEqual([guest["name"] for guest in checked_in["guests"]], [arrived]) + self.assertIsNotNone(checked_in["guests"][0]["checked_in_at"]) + self.assertEqual([guest["name"] for guest in not_checked_in["guests"]], [waiting]) + self.assertEqual(not_checked_in["matched"], 1) diff --git a/buzz/api/events/schemas.py b/buzz/api/events/schemas.py index 17bf7880..a2ac0ce9 100644 --- a/buzz/api/events/schemas.py +++ b/buzz/api/events/schemas.py @@ -103,6 +103,7 @@ class EventGuest(APIResponse): # When the ticket was raised, which is when this guest registered. registered_at: datetime | None = None add_ons: list[GuestAddOn] = Field(default_factory=list) + checked_in_at: datetime | None = None class GuestTicketType(APIResponse): @@ -123,6 +124,8 @@ class EventGuestsResponse(APIResponse): # first when nothing is being searched for. total: int matched: int + # Guests checked in today, which is what the front desk counts against. + checked_in: int = 0 guests: list[EventGuest] has_next_page: bool = False filter_fields: list[FilterField] diff --git a/buzz/api/events/services.py b/buzz/api/events/services.py index b2870a2f..237b0ed7 100644 --- a/buzz/api/events/services.py +++ b/buzz/api/events/services.py @@ -1,8 +1,10 @@ +from datetime import datetime + import frappe from frappe import _ from frappe.query_builder import Case from frappe.query_builder.functions import Count, Date -from frappe.utils import add_days, getdate +from frappe.utils import add_days, getdate, today from buzz.api.booking.guests import email_otp_available, phone_otp_available from buzz.api.booking.services import are_registrations_closed @@ -346,6 +348,7 @@ def event_guests( order: str = "desc", start: int = 0, limit: int = GUESTS_PAGE_SIZE, + checked_in: str | None = None, ) -> EventGuestsResponse: """One page of the people holding a ticket to an event. @@ -353,12 +356,17 @@ def event_guests( access is the bar: this shows the team what it already sees through the doctype. Ordered by when the ticket was registered, newest first by default, so a page is a - stable slice as the caller walks down the list. + stable slice as the caller walks down the list. `checked_in` is "1" or "0" to keep only + the guests who have, or have not, checked in today. """ ensure_event_team_access(event) filter_fields, answer_parents = guest_filter_fields(event) conditions = ListConditions("Event Ticket", filter_fields, answer_parents).frappe_filters(filters) + arrivals = checked_in_today(event) + if checked_in in ("0", "1"): + operator = "in" if checked_in == "1" else "not in" + conditions.append(["name", operator, list(arrivals) or [""]]) query_filters = [["event", "=", event], ["docstatus", "=", 1], *conditions] or_filters = guest_search_filters(search) limit = max(1, min(int(limit), 100)) @@ -385,7 +393,10 @@ def event_guests( ticket["registered_at"] = ticket.pop("creation") ticket["ticket_type"] = type_titles.get(ticket.ticket_type) or ticket.ticket_type - guests = [EventGuest(**ticket, add_ons=by_ticket.get(ticket.name, [])) for ticket in tickets] + guests = [ + EventGuest(**ticket, add_ons=by_ticket.get(ticket.name, []), checked_in_at=arrivals.get(ticket.name)) + for ticket in tickets + ] doc = frappe.get_cached_doc("Buzz Event", event) total = count_tickets({"event": event, "docstatus": 1}) matched = count_tickets(query_filters, or_filters) if or_filters or conditions else total @@ -397,12 +408,32 @@ def event_guests( venue=doc.get_venue_name(), total=total, matched=matched, + checked_in=len(arrivals), guests=guests, has_next_page=start + len(guests) < matched, filter_fields=filter_fields, ) +def checked_in_today(event: str) -> dict[str, datetime]: + """When each of the event's submitted tickets checked in today, keyed by ticket.""" + # Query builder: the team check in the caller is the authorization. + checkin, ticket = frappe.qb.DocType("Event Check In"), frappe.qb.DocType("Event Ticket") + rows = ( + frappe.qb.from_(checkin) + .join(ticket) + .on(ticket.name == checkin.ticket) + .select(checkin.ticket, checkin.creation) + .where( + (checkin.event == event) + & (checkin.date == today()) + & (checkin.docstatus == 1) + & (ticket.docstatus == 1) + ) + ).run(as_dict=True) + return {row.ticket: row.creation for row in rows} + + def guest_filter_fields(event: str) -> tuple[list[FilterField], dict]: """Ticket type, then the event's ticket and booking questions. diff --git a/buzz/permissions.py b/buzz/permissions.py index 9110ce97..3e19ec7e 100644 --- a/buzz/permissions.py +++ b/buzz/permissions.py @@ -7,6 +7,7 @@ WRITE_ROLES = frozenset({"Owner", "Admin", "Manager"}) ADMIN_ROLES = frozenset({"Owner", "Admin"}) +CHECKIN_ROLES = frozenset({"Owner", "Admin", "Manager", "Frontdesk"}) WRITE_PTYPES = frozenset({"write", "create", "submit", "cancel", "amend", "delete"}) # Attendees, sponsors and speakers reach their own rows without any membership. @@ -130,6 +131,11 @@ def has_team_access(team: str | None, ptype: str, user: str) -> bool: return bool(team_role) and role_allows(team_role, ptype) +def can_check_in(team: str | None, user: str) -> bool: + """Front-desk work on a team's events. Unlike has_team_access, an unstamped event fails closed.""" + return is_unrestricted(user) or team_role_of(user, team) in CHECKIN_ROLES + + def team_query_conditions(user: str | None = None, doctype: str | None = None, **kwargs) -> str | None: user = user or frappe.session.user if is_unrestricted(user): diff --git a/dashboard/src/components/EventSelector.vue b/dashboard/src/components/EventSelector.vue index 89a11f7f..e1f1c080 100644 --- a/dashboard/src/components/EventSelector.vue +++ b/dashboard/src/components/EventSelector.vue @@ -48,18 +48,11 @@ diff --git a/dashboard/src/components/checkin/CheckInGuestList.vue b/dashboard/src/components/checkin/CheckInGuestList.vue new file mode 100644 index 00000000..a1f44578 --- /dev/null +++ b/dashboard/src/components/checkin/CheckInGuestList.vue @@ -0,0 +1,206 @@ + + + diff --git a/dashboard/src/components/checkin/GuestCheckInDialog.vue b/dashboard/src/components/checkin/GuestCheckInDialog.vue new file mode 100644 index 00000000..c1d3fa63 --- /dev/null +++ b/dashboard/src/components/checkin/GuestCheckInDialog.vue @@ -0,0 +1,113 @@ + + + diff --git a/dashboard/src/components/dashboard/events/EventGuestItem.vue b/dashboard/src/components/dashboard/events/EventGuestItem.vue index ff382b9e..2f843d58 100644 --- a/dashboard/src/components/dashboard/events/EventGuestItem.vue +++ b/dashboard/src/components/dashboard/events/EventGuestItem.vue @@ -57,6 +57,7 @@ const hidden = computed(() => props.guest.add_ons.slice(VISIBLE_ADD_ONS)) + diff --git a/dashboard/src/composables/useEventGuests.ts b/dashboard/src/composables/useEventGuests.ts index 2ad952d8..d0ae8283 100644 --- a/dashboard/src/composables/useEventGuests.ts +++ b/dashboard/src/composables/useEventGuests.ts @@ -23,6 +23,12 @@ export function useEventGuests( search: Ref, order: Ref, conditions: Ref, + // The check-in page reads the same list through its own, stricter endpoint, narrowed to + // who has or has not arrived ("1" / "0", empty for everyone). + { + url = "/api/v2/method/buzz.api.events.get_event_guests", + checkedIn = ref(""), + }: { url?: string; checkedIn?: Ref } = {}, ) { // Typing rewrites the URL, and every rewrite is a request — wait for the pause. const debouncedSearch = refDebounced(search, 300) @@ -30,7 +36,7 @@ export function useEventGuests( const guests = ref([]) const page = useCall>({ - url: "/api/v2/method/buzz.api.events.get_event_guests", + url, params: () => ({ event, search: debouncedSearch.value.trim(), @@ -38,6 +44,7 @@ export function useEventGuests( order: order.value, start: start.value, limit: GUESTS_PAGE_SIZE, + checked_in: checkedIn.value, }), // Off by default, so without this a new page or search would never be fetched. refetch: true, @@ -56,7 +63,7 @@ export function useEventGuests( // Sync, so the offset is back at zero before useCall's own watcher rebuilds the URL — // a pre-flush reset lets the stale offset go out as a request that is aborted a tick later. watch( - [debouncedSearch, order, conditions], + [debouncedSearch, order, conditions, checkedIn], () => { start.value = 0 guests.value = [] diff --git a/dashboard/src/composables/useTicketValidation.ts b/dashboard/src/composables/useTicketValidation.ts index 848fa0d5..8958568b 100644 --- a/dashboard/src/composables/useTicketValidation.ts +++ b/dashboard/src/composables/useTicketValidation.ts @@ -6,7 +6,7 @@ import beepSound from "@/assets/audio/beep.wav" import type { TicketAddOnValue } from "@/types/Ticketing/TicketAddOnValue" import { serverErrorMessage } from "@/utils/serverError" -interface ValidationTicket { +export interface ValidationTicket { id: string attendee_name: string attendee_email: string @@ -35,138 +35,59 @@ interface PaymentDetails { currency?: string } -interface ValidationResult { +export interface ValidationResult { message?: string error?: string ticket?: ValidationTicket payment_details?: PaymentDetails } -interface TicketValidationState { - isProcessingTicket: Ref - isCheckingIn: Ref - validationResult: Ref - showTicketModal: Ref - validateTicket: (ticketId: string) => void - checkInTicket: () => void - clearResults: () => void - closeModal: () => void -} - -let ticketValidationState: TicketValidationState | null = null - const isProcessingTicket = ref(false) -const isCheckingIn = ref(false) const validationResult = ref(null) -const showTicketModal = ref(false) let lastToastMessage: string | null = null let lastToastTime = 0 const TOAST_DEBOUNCE_MS = 500 -const playSuccessSound = (): void => { - const audio = new Audio(beepSound) - audio.play() -} - -const playErrorSound = (): void => { - const audio = new Audio(beepFailSound) - audio.play() +const playSound = (sound: string): void => { + new Audio(sound).play() } -const showDebouncedToast = (message: string, type: "error" | "success" = "error"): void => { +const showDebouncedToast = (message: string): void => { const now = Date.now() - if (lastToastMessage === message && now - lastToastTime < TOAST_DEBOUNCE_MS) { - return - } + if (lastToastMessage === message && now - lastToastTime < TOAST_DEBOUNCE_MS) return lastToastMessage = message lastToastTime = now - - if (type === "error") { - toast.error(message) - } else { - toast.success(message) - } + toast.error(message) } -// Ticket validation resource const validateTicketResource = useCall({ url: "/api/v2/method/buzz.api.checkin.validate_ticket_for_checkin", method: "POST", immediate: false, onSuccess: (data) => { validationResult.value = data - showTicketModal.value = true - playSuccessSound() isProcessingTicket.value = false + playSound(beepSound) }, onError: (error) => { validationResult.value = null isProcessingTicket.value = false showDebouncedToast(serverErrorMessage(error) || __("Error validating ticket")) - playErrorSound() + playSound(beepFailSound) }, }) -// Check-in resource -const checkInResource = useCall({ - url: "/api/v2/method/buzz.api.checkin.checkin_ticket", - method: "POST", - immediate: false, - onSuccess: (data) => { - validationResult.value = data - showTicketModal.value = false - isCheckingIn.value = false - }, - onError: (error) => { - isCheckingIn.value = false - showDebouncedToast(serverErrorMessage(error) || __("Check-in failed")) - playErrorSound() - }, -}) - -export function useTicketValidation(): TicketValidationState { - if (ticketValidationState) { - return ticketValidationState - } - - // Methods - const validateTicket = (ticketId: string): void => { - isProcessingTicket.value = true - validateTicketResource.submit({ ticket_id: ticketId }) - } - - const checkInTicket = (): void => { - if (!validationResult.value?.ticket?.id) return - - isCheckingIn.value = true - checkInResource.submit({ ticket_id: validationResult.value.ticket.id }) - } - - const clearResults = (): void => { - validationResult.value = null - isProcessingTicket.value = false - isCheckingIn.value = false - showTicketModal.value = false - } - - const closeModal = (): void => { - showTicketModal.value = false - } - - ticketValidationState = { - // State - isProcessingTicket, - isCheckingIn, - validationResult, - showTicketModal, - - // Methods - validateTicket, - checkInTicket, - clearResults, - closeModal, - } +const validateTicket = (ticketId: string): void => { + isProcessingTicket.value = true + validateTicketResource.submit({ ticket_id: ticketId }) +} - return ticketValidationState +/** Scan state shared by the scanner and the page that opens the scanned guest. */ +export function useTicketValidation(): { + isProcessingTicket: Ref + validationResult: Ref + validateTicket: (ticketId: string) => void +} { + return { isProcessingTicket, validationResult, validateTicket } } diff --git a/dashboard/src/pages/CheckIn.vue b/dashboard/src/pages/CheckIn.vue new file mode 100644 index 00000000..ad58e02d --- /dev/null +++ b/dashboard/src/pages/CheckIn.vue @@ -0,0 +1,23 @@ + + + diff --git a/dashboard/src/pages/CheckInScanner.vue b/dashboard/src/pages/CheckInScanner.vue deleted file mode 100644 index 0ee338c2..00000000 --- a/dashboard/src/pages/CheckInScanner.vue +++ /dev/null @@ -1,143 +0,0 @@ - - - diff --git a/dashboard/src/router.ts b/dashboard/src/router.ts index bcc9d046..03336321 100644 --- a/dashboard/src/router.ts +++ b/dashboard/src/router.ts @@ -159,7 +159,7 @@ const routes: RouteRecordRaw[] = [ name: "check-in", props: true, meta: { title: "Check In" }, - component: () => import("@/pages/CheckInScanner.vue"), + component: () => import("@/pages/CheckIn.vue"), }, { path: "/register/:eventRoute", diff --git a/dashboard/src/types.ts b/dashboard/src/types.ts index 4015ae4d..a8d514d6 100644 --- a/dashboard/src/types.ts +++ b/dashboard/src/types.ts @@ -270,6 +270,8 @@ export interface EventGuest { ticket_type: string | null registered_at: string | null add_ons: GuestAddOn[] + // Today's check-in, if the guest has arrived. + checked_in_at?: string | null } // buzz.api.events.get_event_registration_trend @@ -298,6 +300,7 @@ export interface EventGuests { venue: string | null total: number matched: number + checked_in: number guests: EventGuest[] has_next_page: boolean filter_fields: FilterField[] diff --git a/e2e/pages/check-in.page.ts b/e2e/pages/check-in.page.ts index edf5849a..ebca40bf 100644 --- a/e2e/pages/check-in.page.ts +++ b/e2e/pages/check-in.page.ts @@ -4,19 +4,19 @@ export class CheckInPage { private page: Page accessDeniedMessage: Locator manualTicketInput: Locator - lastScanResult: Locator ticketModal: Locator + private scanButton: Locator private checkButton: Locator private checkInButton: Locator constructor(page: Page) { this.page = page - this.accessDeniedMessage = page.getByText("Access Denied") + this.accessDeniedMessage = page.getByText("Access denied") this.manualTicketInput = page.getByPlaceholder("Enter ticket ID manually") + this.scanButton = page.getByRole("button", { name: "Scan", exact: true }) this.checkButton = page.getByRole("button", { name: "Check", exact: true }) this.ticketModal = page.getByRole("dialog") - this.checkInButton = page.getByRole("button", { name: "Check In", exact: true }) - this.lastScanResult = page.getByText("Last Scan Result") + this.checkInButton = page.getByRole("button", { name: "Check in", exact: true }) } async goto(eventName?: string): Promise { @@ -29,20 +29,18 @@ export class CheckInPage { await this.checkButton.click() } - /** - * Clicks Check In and waits for the request to land. The "Last Scan Result" panel is not a - * usable signal — it is already on screen from the preceding validate call. - */ + /** Clicks Check in and waits for the request to land; the scanner then reopens on its own. */ async confirmCheckIn(): Promise { const recorded = this.page.waitForResponse( (response) => response.url().includes("checkin_ticket") && response.status() === 200, ) await this.checkInButton.click() await recorded - await expect(this.ticketModal).toBeHidden() + await expect(this.manualTicketInput).toBeVisible() } async expectScannerVisible(): Promise { + await this.scanButton.click({ timeout: 15000 }) await expect(this.manualTicketInput).toBeVisible({ timeout: 15000 }) await expect(this.accessDeniedMessage).toBeHidden() } diff --git a/e2e/tests/check-in.spec.ts b/e2e/tests/check-in.spec.ts index 0d6a2679..154ce460 100644 --- a/e2e/tests/check-in.spec.ts +++ b/e2e/tests/check-in.spec.ts @@ -115,9 +115,9 @@ test.describe("Check-in scanner", () => { test.describe("Check-in access control", () => { test.use({ storageState: NO_ROLE_STATE_PATH }) - test("a user without the Frontdesk Manager role is refused", async ({ page }) => { + test("a user outside the event's team is refused", async ({ page }) => { const checkInPage = new CheckInPage(page) - await checkInPage.goto() + await checkInPage.goto(eventName) await checkInPage.expectAccessDenied() }) From 4ad21ca4a1246ca3affebf4b341cf41e63c9d75f Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 12 Oct 2026 02:11:05 +0530 Subject: [PATCH 2/7] feat(check-in): express layout with one-tap check-in and a League Gothic heading Each guest row carries its own Check in button, so most arrivals take a single tap. Tapping a name opens the guest's ticket: a dialog on desktop, a bottom sheet on phones. A line under the tabs says what each control does. The page opens on a "Check-in" display heading set in League Gothic, now self-hosted beside Inter, with the event's name, date and venue under it. The underline tabs span the full column, and the "All events" link is gone. --- .../LeagueGothic/LeagueGothic-Regular.woff2 | Bin 0 -> 10456 bytes .../src/assets/LeagueGothic/league-gothic.css | 8 ++ .../components/checkin/CheckInGuestList.vue | 103 ++++++++++---- .../components/checkin/CheckInGuestRow.vue | 62 ++++++++ .../components/checkin/GuestCheckInDialog.vue | 134 ++++++++---------- .../dashboard/events/EventGuestItem.vue | 1 - dashboard/src/index.css | 1 + dashboard/src/pages/CheckIn.vue | 6 +- dashboard/tailwind.config.js | 6 +- 9 files changed, 212 insertions(+), 109 deletions(-) create mode 100644 dashboard/src/assets/LeagueGothic/LeagueGothic-Regular.woff2 create mode 100644 dashboard/src/assets/LeagueGothic/league-gothic.css create mode 100644 dashboard/src/components/checkin/CheckInGuestRow.vue diff --git a/dashboard/src/assets/LeagueGothic/LeagueGothic-Regular.woff2 b/dashboard/src/assets/LeagueGothic/LeagueGothic-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..64216964493ca5d8f52c63acf906821cbe12b7de GIT binary patch literal 10456 zcmV;}C@0r0RR9100000000000000000000 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 O`X3V1=8MaY0{{T{ejTC! literal 0 HcmV?d00001 diff --git a/dashboard/src/assets/LeagueGothic/league-gothic.css b/dashboard/src/assets/LeagueGothic/league-gothic.css new file mode 100644 index 00000000..b7ffcc3c --- /dev/null +++ b/dashboard/src/assets/LeagueGothic/league-gothic.css @@ -0,0 +1,8 @@ +/* League Gothic (SIL Open Font License), Latin subset. Display headings only. */ +@font-face { + font-family: "League Gothic"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("LeagueGothic-Regular.woff2") format("woff2"); +} diff --git a/dashboard/src/components/checkin/CheckInGuestList.vue b/dashboard/src/components/checkin/CheckInGuestList.vue index a1f44578..eafcf403 100644 --- a/dashboard/src/components/checkin/CheckInGuestList.vue +++ b/dashboard/src/components/checkin/CheckInGuestList.vue @@ -1,12 +1,11 @@