diff --git a/ESP-32/src/scanner.ino b/ESP-32/src/scanner.ino index 72d15df..35227c6 100644 --- a/ESP-32/src/scanner.ino +++ b/ESP-32/src/scanner.ino @@ -27,6 +27,14 @@ const int daylightOffset_sec = 0; #define GRAY 0x2104 Adafruit_ST7789 tft = Adafruit_ST7789(TFT_CS, TFT_DC, TFT_RST); + + +// Exact kiosk palette, converted from CSS hex to RGB565. +#define KIOSK_CREAM 0xF77C // #F2EEE3 +#define KIOSK_ORANGE 0xF483 // #F7931E +#define KIOSK_GRAY 0x73AF // #747678 +#define KIOSK_CYAN 0x1DD9 // #18B9C8 +#define KIOSK_NAVY 0x1127 // #13243C // PN532 params #define PN532_SS (21) @@ -96,28 +104,80 @@ void setup() { ////////////////////// DISPLAY ///////////////////// //////////////////////////////////////////////////////// +void drawDownArrow(int cx, int top, int width, int height, uint16_t color) { + int shaftWidth = width / 3; + int shoulder = top + height / 2; + tft.fillRect(cx - shaftWidth / 2, top, shaftWidth, height / 2 + 1, color); + tft.fillTriangle(cx - width / 2, shoulder, cx + width / 2, shoulder, + cx, top + height, color); +} + +void drawUpArrow(int cx, int top, int width, int height, uint16_t color) { + int shaftWidth = width / 3; + int shoulder = top + height / 2; + tft.fillTriangle(cx, top, cx - width / 2, shoulder, + cx + width / 2, shoulder, color); + tft.fillRect(cx - shaftWidth / 2, shoulder, shaftWidth, height / 2 + 1, color); +} + void homeScreen() { - tft.setFont(&FreeSerif24pt7b); - tft.fillScreen(ST77XX_BLACK); - tft.setCursor(20, 40); - tft.setTextColor(ST77XX_YELLOW); - tft.setTextSize(1); - tft.println("UC San Diego"); - tft.setCursor(20, 80); - tft.println("Makerspace"); - tft.fillRect(20, 45, tft.width()-95,5, ST77XX_BLUE); - tft.fillRect(tft.width()-52, 45, 20,5, ST77XX_BLUE); - //tft.drawLine(0, 35, tft.width()-100,30, ST77XX_BLUE); - //tft.drawLine(tft.width()-70, 35, tft.width()-30,30, ST77XX_BLUE); - - tft.setTextSize(1); - //tft.setCursor(0, 60); - tft.setCursor(20, 160); - - tft.print("Please Scan"); - tft.setCursor(20, 200); - - tft.print("Your ID card"); + tft.setFont(NULL); + tft.fillScreen(KIOSK_NAVY); + tft.setTextWrap(false); + tft.setTextColor(KIOSK_CREAM); + tft.setTextSize(4); + tft.setCursor(18, 18); + tft.print("TAP ID"); + drawDownArrow(160, 72, 124, 104, KIOSK_ORANGE); + tft.setTextSize(2); + tft.setCursor(82, 214); + tft.print("ON BLUE HAND"); +} + +void animateToLookUp() { + // Only redraw the text and arrow regions; the background never flashes. + tft.fillRect(0, 8, 320, 48, KIOSK_NAVY); + tft.fillRect(0, 205, 320, 35, KIOSK_NAVY); + + for (int frame = 0; frame < 5; frame++) { + int height = 104 - (frame * 23); + int width = 124 - (frame * 5); + tft.fillRect(85, 66, 150, 122, KIOSK_NAVY); + if (height > 12) { + drawDownArrow(160, 72 + (104 - height) / 2, width, height, KIOSK_ORANGE); + } else { + tft.fillRect(108, 126, 104, 10, KIOSK_ORANGE); + } + delay(45); + } + + for (int frame = 1; frame <= 5; frame++) { + int height = 10 + (frame * 19); + int width = 104 + (frame * 4); + tft.fillRect(85, 66, 150, 122, KIOSK_NAVY); + drawUpArrow(160, 126 - height / 2, width, height, KIOSK_CREAM); + delay(45); + } + + // Static hold: professional, attention-directing, and flicker-free. + tft.fillScreen(KIOSK_NAVY); + tft.fillTriangle(0, 0, 112, 0, 86, 34, KIOSK_ORANGE); + tft.setFont(NULL); + tft.setTextWrap(false); + tft.setTextColor(KIOSK_CREAM); + tft.setTextSize(2); + tft.setCursor(15, 9); + tft.print("NEXT"); + drawUpArrow(160, 42, 112, 94, KIOSK_CREAM); + tft.setTextColor(KIOSK_CREAM); + tft.setTextSize(4); + tft.setCursor(76, 146); + tft.print("LOOK UP"); + tft.fillRect(105, 188, 110, 4, KIOSK_ORANGE); + tft.setTextColor(KIOSK_ORANGE); + tft.setTextSize(2); + tft.setCursor(70, 211); + tft.print("FINISH CHECK-IN"); } @@ -140,14 +200,6 @@ void loop() { success = nfc.readPassiveTargetID(PN532_MIFARE_ISO14443A, uid, &uidLength,100); if (success) { - tft.fillScreen(ST77XX_BLACK); - tft.setCursor(0, 30); - - tft.setFont(&FreeSerif24pt7b); - - tft.setTextSize(1); - tft.println("Found Card!\n"); - uint32_t szPos; for (szPos = 0; szPos < uidLength; szPos++) @@ -156,8 +208,9 @@ void loop() { //gmtime_r(&nowSecs, &timeinfo); Serial.println(String(uidStr)); - tft.setTextSize(1); - delay(500); + // The reader confirms only the scan; the kiosk confirms check-in. + animateToLookUp(); + delay(5000); do { versiondata = nfc.getFirmwareVersion(); nfc.SAMConfig(); diff --git a/Kiosk-v2/.env.example b/Kiosk-v2/.env.example index d4f39cf..9909ecd 100644 --- a/Kiosk-v2/.env.example +++ b/Kiosk-v2/.env.example @@ -5,3 +5,9 @@ CARD_UID_HMAC_SECRET= # Public URL of the deployed Apps Script registration web app. The kiosk hides # the registration QR and link until this is set at build time. NEXT_PUBLIC_REGISTRATION_URL= + +# Public DocuSign PowerForm URL displayed on the waiver-required screen. +NEXT_PUBLIC_WAIVER_URL= + +# Normally omitted because localhost uses the loopback scanner bridge. +NEXT_PUBLIC_SCANNER_WS_URL= diff --git a/Kiosk-v2/.env.production b/Kiosk-v2/.env.production index 66ff721..ac9c430 100644 --- a/Kiosk-v2/.env.production +++ b/Kiosk-v2/.env.production @@ -1 +1 @@ -NEXT_PUBLIC_REGISTRATION_URL=https://script.google.com/macros/s/AKfycbyIQrU1P7aIlg8unnRADdi6kpTlChyVEI_mWZ8-oArR8WNRpv1WJZyxwsvshGvpJLLnOw/exec +NEXT_PUBLIC_REGISTRATION_URL=https://script.google.com/macros/s/AKfycbx_8eQVoJtb4sfwwtw8Pa5VvYDvaSHlFOQMNn54_jdvUrbzXSnrm9Q0GjtShZy0QHe-/exec diff --git a/Kiosk-v2/.gitignore b/Kiosk-v2/.gitignore index 5c70dec..1e642ab 100644 --- a/Kiosk-v2/.gitignore +++ b/Kiosk-v2/.gitignore @@ -40,3 +40,8 @@ yarn-error.log* /.wrangler/ /outputs/ /work/ + +# Local Google Apps Script project mappings. Copy the checked-in example in +# each Apps Script folder and fill in the institutional script ID locally. +**/.clasp.json +!**/.clasp.json.example diff --git a/Kiosk-v2/README.md b/Kiosk-v2/README.md index 4f2ef2b..0f51788 100644 --- a/Kiosk-v2/README.md +++ b/Kiosk-v2/README.md @@ -5,6 +5,8 @@ Interactive kiosk prototype and production technical spike for the Scripps Sandb - `app/` contains the 1920×1080 kiosk interface and interactive failure/exception flows. - `bridge/` contains the Raspberry Pi serial-to-browser reader bridge. - `docs/production-spec.md` captures the agreed workflows, permissions, data model, failure behavior, migration, and milestones. +- `docs/recovery-manifest-v1.md` identifies the institutional recovery point. +- `docs/disaster-recovery.md` is the fresh-Pi, ESP32, Apps Script, and data restore runbook. ## Prerequisites diff --git a/Kiosk-v2/app/api/registrations/route.ts b/Kiosk-v2/app/api/registrations/route.ts index 4173ab8..f052281 100644 --- a/Kiosk-v2/app/api/registrations/route.ts +++ b/Kiosk-v2/app/api/registrations/route.ts @@ -12,7 +12,7 @@ import { id, makeDisplayName, normalizeEmail, - normalizeIdentifier, + normalizeRegistrationIdentifier, REGISTRATION_CONSENT_VERSION, WAIVER_POWERFORM_URL, } from "@/lib/registration"; @@ -31,7 +31,7 @@ type RegistrationPayload = { }; const allowedUserTypes = new Set(["student", "staff", "faculty", "postdoc", "visitor", "other"]); -const allowedIdentifierTypes = new Set(["pid", "employee_id", "other"]); +const allowedIdentifierTypes = new Set(["pid", "tsn", "employee_id", "other"]); function validEmail(value: string) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); @@ -66,7 +66,7 @@ export async function POST(request: Request) { : null; const identifierType = payload.identifierType?.trim().toLowerCase() ?? ""; const identifierValue = payload.identifierValue?.trim() ?? ""; - const normalizedIdentifier = normalizeIdentifier(identifierValue); + const normalizedIdentifier = normalizeRegistrationIdentifier(identifierValue, identifierType); if (!firstName || !lastName || !affiliation) { return Response.json({ error: "Name and affiliation are required." }, { status: 400 }); @@ -77,8 +77,8 @@ export async function POST(request: Request) { if (!validEmail(primaryEmail) || (secondaryEmail && !validEmail(secondaryEmail))) { return Response.json({ error: "Enter a valid email address." }, { status: 400 }); } - if (normalizedIdentifier.length < 4 || normalizedIdentifier.length > 32) { - return Response.json({ error: "Enter a valid UC San Diego ID number." }, { status: 400 }); + if (!normalizedIdentifier) { + return Response.json({ error: "Enter a valid PID, TSN, or employee ID." }, { status: 400 }); } if (!payload.consent) { return Response.json({ error: "Consent is required to create an account." }, { status: 400 }); diff --git a/Kiosk-v2/app/globals.css b/Kiosk-v2/app/globals.css index 16dbbf4..c0ce5d5 100644 --- a/Kiosk-v2/app/globals.css +++ b/Kiosk-v2/app/globals.css @@ -394,6 +394,8 @@ h1 { .arrival-alert p { margin: 0 0 3px; font: 800 clamp(11px, .75vw, 14px)/1.1 "Jost"; letter-spacing: .13em; } .arrival-alert strong { font: 650 clamp(17px, 1.25vw, 24px)/1.15 "Jost"; } .closing-alert { background: var(--cream); } +.closing-alert.closed-alert { background: var(--navy); color: var(--cream); box-shadow: 7px 7px 0 var(--orange); } +.closing-alert.closed-alert .alert-index { border-color: var(--cream); color: var(--orange); } .home-copy.has-alert h1 { font-size: clamp(45px, 4.3vw, 78px); } .home-copy.has-alert .lede { margin-top: .85em; } .home-copy.has-alert .primary-actions { margin-top: clamp(24px, 3.5vh, 44px); } @@ -460,14 +462,95 @@ input:focus { border-color: var(--orange); } .choice-grid button { display: flex; justify-content: space-between; gap: 14px; min-height: 70px; padding: 18px 20px; border: 0; background: var(--gray); cursor: pointer; text-align: left; font-size: clamp(16px, 1.15vw, 22px); } .choice-grid button:hover, .choice-grid button.selected { background: var(--cream); color: var(--navy); } +.profile-shell { width: min(100%, 650px); } +.profile-card { + position: relative; + width: 100%; + padding: clamp(30px, 3.5vw, 48px); + border: 3px solid var(--navy); + background: var(--cream); + color: var(--navy); + box-shadow: 12px 12px 0 var(--orange); + animation: profile-card-in .42s cubic-bezier(.2,.82,.24,1) both; +} +@keyframes profile-card-in { + from { opacity: 0; transform: translateY(20px) scale(.975); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +.profile-card-tab { + position: absolute; + top: -19px; + left: 28px; + padding: 8px 13px; + background: var(--navy); + color: var(--cream); + font: 800 11px/1 "Jost"; + letter-spacing: .15em; +} +.profile-card .eyebrow { margin-bottom: .9em; } +.profile-card h1 { font-size: clamp(40px, 3.85vw, 68px); line-height: .96; } +.profile-card .lede { max-width: 590px; color: #35485b; font-size: clamp(18px, 1.3vw, 25px); } +.profile-card .back { margin-bottom: clamp(20px, 2.5vh, 34px); color: var(--navy); } +.profile-card .solid-action { display: inline-flex; align-items: center; gap: 16px; margin-top: 30px; } +.profile-card .quiet-action { margin: 20px 0 0 14px; color: var(--navy); } +.profile-pulse { display: flex; height: 28px; align-items: flex-end; gap: 5px; margin-bottom: 24px; } +.profile-pulse i { width: 8px; background: var(--cyan); animation: profile-pulse 1s ease-in-out infinite alternate; } +.profile-pulse i:nth-child(1) { height: 10px; } +.profile-pulse i:nth-child(2) { height: 19px; animation-delay: .12s; } +.profile-pulse i:nth-child(3) { height: 28px; background: var(--orange); animation-delay: .24s; } +@keyframes profile-pulse { to { opacity: .45; transform: scaleY(.72); transform-origin: bottom; } } + +.profile-form { width: 100%; max-width: none; margin-top: clamp(28px, 3.2vh, 42px); } +.profile-fields { display: grid; gap: 12px; } +.profile-fields label { margin: 0; color: var(--navy); } +.profile-fields select { + display: block; + width: 100%; + min-height: 68px; + padding: 14px 52px 14px 18px; + border: 3px solid var(--navy); + border-radius: 0; + outline: 0; + background: #fffdf7; + color: var(--navy); + font: 650 clamp(18px, 1.3vw, 24px)/1.2 "Jost"; +} +.profile-fields select:focus { border-color: var(--orange); box-shadow: 0 0 0 4px rgba(247,147,30,.2); } +.profile-fields select option { background: #fffdf7; color: var(--navy); } +.profile-fields input { + padding: 16px 14px; + border: 3px solid var(--navy); + background: #fffdf7; + color: var(--navy); + font-size: clamp(22px, 2vw, 34px); + color-scheme: light; +} +.profile-form > .solid-action { margin-top: 34px; } +.profile-card .reader-offline-notice { color: var(--navy); } +.profile-role-grid { max-width: none; margin-top: 26px; gap: 2px; background: transparent; } +.profile-role-grid button { + min-height: 58px; + padding: 13px 16px; + border: 2px solid var(--navy); + background: #fffdf7; + color: var(--navy); + font-size: clamp(15px, 1vw, 19px); +} +.profile-role-grid button:hover, .profile-role-grid button:focus-visible { background: var(--orange); } + .onboarding-grid { display: grid; grid-template-columns: 1fr 210px; gap: clamp(30px, 4vw, 70px); align-items: center; margin: 28px 0; } -.onboarding-grid ol { margin: 30px 0 0; padding: 0; list-style: none; } -.onboarding-grid li { display: flex; gap: 18px; padding: 12px 0; border-top: 1px solid rgba(242,238,227,.4); font-size: 18px; } -.onboarding-grid li b { color: var(--orange); font-family: "Jost"; } +.onboarding-grid ol, .onboarding-primary ol { margin: 24px 0 0; padding: 0; list-style: none; } +.onboarding-grid li, .onboarding-primary li { display: flex; gap: 18px; padding: 10px 0; border-top: 1px solid rgba(242,238,227,.4); font-size: 18px; } +.onboarding-grid li b, .onboarding-primary li b { color: var(--orange); font-family: "Jost"; } +.onboarding-primary { display: grid; grid-template-columns: 318px 1fr; gap: clamp(32px, 5vw, 76px); align-items: center; margin: 26px 0 20px; } +.onboarding-fallback { display: flex; align-items: center; flex-wrap: wrap; gap: 14px 22px; padding-top: 18px; border-top: 1px solid rgba(242,238,227,.35); } +.onboarding-fallback p { flex: 1 1 360px; margin: 0; line-height: 1.45; } +.onboarding-fallback .outline-action, .onboarding-fallback .quiet-action { width: auto; margin: 0; } .qr-placeholder { display: grid; grid-template-columns: repeat(8, 1fr); aspect-ratio: 1; padding: 14px; gap: 2px; background: var(--cream); } .qr-placeholder i { background: var(--navy); } .qr-placeholder i:nth-child(3n), .qr-placeholder i:nth-child(5n), .qr-placeholder i:nth-child(7n) { background: var(--cream); } .qr-code { width: 210px; padding: 14px; background: var(--cream); color: var(--navy); display: grid; place-items: center; gap: 10px; box-sizing: border-box; } +.qr-code-large { width: 318px; padding: 16px; } .qr-code svg { display: block; width: 100%; height: auto; } .qr-code span { font-family: "Jost"; font-size: 13px; font-weight: 800; letter-spacing: .14em; } @@ -495,6 +578,53 @@ input:focus { border-color: var(--orange); } .visit-card b { font-size: 24px; } .solid-action.navy { background: var(--navy); color: var(--cream); } .success-plane .reset-note { color: rgba(16,43,78,.66); } +.success-closing-soon { + background: var(--navy); + color: var(--cream); +} +.success-closing-soon .eyebrow { color: var(--orange); } +.success-closing-soon .lede { max-width: 780px; } +.success-closing-soon .visit-card { border-color: rgba(242,238,227,.45); } +.success-closing-soon .solid-action.navy { background: var(--orange); color: var(--navy); } +.success-closing-soon .reset-note { color: rgba(242,238,227,.7); } +.closing-time-badge { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 22px; + padding: 12px 18px 9px; + border: 3px solid var(--orange); + color: var(--orange); + transform: rotate(-2deg); +} +.closing-time-badge b { font: 850 54px/.8 "Jost"; } +.closing-time-badge span { font: 800 16px/1 "Jost"; letter-spacing: .14em; } +.success-closed { + overflow: hidden; + background: #071827; + color: var(--cream); +} +.success-closed::before { + position: absolute; + inset: 0; + background: repeating-linear-gradient(-45deg, transparent 0 62px, rgba(247,147,30,.075) 62px 124px); + content: ""; +} +.success-closed > * { position: relative; z-index: 1; } +.success-closed .eyebrow { color: var(--orange); } +.success-closed .lede { max-width: 760px; } +.success-closed .visit-card { border-color: rgba(242,238,227,.38); } +.success-closed .solid-action.navy { background: var(--orange); color: var(--navy); } +.success-closed .reset-note { color: rgba(242,238,227,.7); } +.closed-stripe { + margin-bottom: 22px; + padding: 10px 25px; + border: 3px solid var(--orange); + color: var(--orange); + font: 850 clamp(34px, 4vw, 66px)/1 "Jost"; + letter-spacing: .13em; + transform: rotate(-2deg); +} .success-closing-alert { display: grid; grid-template-columns: 72px minmax(0, 430px); @@ -560,6 +690,21 @@ input:focus { border-color: var(--orange); } .staff-poster b { position: relative; z-index: 1; font: 650 clamp(52px, 7vw, 128px)/.82 "Jost"; letter-spacing: -.07em; transform: rotate(-7deg); } .staff-panel > form { width: min(720px, 100%); max-width: none; margin: 0; padding: clamp(34px, 5vh, 70px) clamp(36px, 6vw, 100px); overflow-y: auto; } .staff-panel .demo-heading { margin-bottom: 18px; } +.release-card { + display: grid; + grid-template-columns: 1fr auto; + gap: 5px 24px; + margin: 0 0 22px; + padding: 16px 18px; + border: 2px solid var(--navy); + background: #fffaf0; + box-shadow: 6px 6px 0 var(--orange); +} +.release-card div { display: flex; align-items: baseline; gap: 12px; } +.release-card small { font: 750 10px/1.2 "Jost"; letter-spacing: .14em; } +.release-card strong { font: 750 22px/1 "Jost"; letter-spacing: .02em; } +.release-card time { color: #53606b; font: 650 14px/1.2 "Jost"; } +.release-card p { grid-column: 1 / -1; margin: 0; color: #53606b; font-size: 14px; line-height: 1.3; } .staff-intro { max-width: 610px; margin: 0 0 22px; color: #53606b; font-size: 18px; line-height: 1.4; } .preset-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 26px; } .preset-row button { padding: 9px 13px; border: 1px solid var(--navy); background: transparent; color: var(--navy); cursor: pointer; font-weight: 650; } @@ -591,6 +736,33 @@ footer { position: absolute; right: clamp(28px, 4vw, 72px); bottom: clamp(22px, footer span { font-family: "Jost"; font-size: 11px; font-weight: 700; letter-spacing: .18em; } footer img { width: clamp(150px, 14vw, 250px); height: 70px; object-fit: contain; object-position: right bottom; filter: brightness(0) invert(1); opacity: .88; } .screen-success footer { filter: brightness(0) saturate(100%) invert(13%) sepia(21%) saturate(2873%) hue-rotate(173deg) brightness(92%); } +.screen-success:has(.success-closing-soon) footer, +.screen-success:has(.success-closed) footer { filter: none; } + +.idle-return { + position: fixed; + left: 50%; + bottom: 22px; + z-index: 35; + display: grid; + grid-template-columns: 52px minmax(180px, 1fr) auto; + align-items: center; + gap: 14px; + width: min(620px, calc(100vw - 48px)); + padding: 12px 14px; + border: 2px solid var(--navy); + background: var(--cream); + color: var(--navy); + box-shadow: 7px 7px 0 var(--orange); + transform: translateX(-50%); +} +.idle-ring { --idle-progress: 360deg; position: relative; width: 48px; height: 48px; flex: 0 0 48px; border-radius: 50%; background: conic-gradient(var(--orange) var(--idle-progress), #d9d5cb 0); } +.idle-ring::before { position: absolute; inset: 6px; border-radius: 50%; background: var(--cream); content: ""; } +.idle-ring span { position: absolute; inset: 0; z-index: 1; display: grid; place-items: center; color: var(--navy); font: 750 15px/1 "Jost"; } +.idle-return small, .idle-return b { display: block; } +.idle-return small { margin-bottom: 3px; font: 800 10px/1 "Jost"; letter-spacing: .13em; } +.idle-return b { font: 600 15px/1.2 "Jost"; } +.idle-return button { min-height: 42px; padding: 8px 13px; border: 2px solid var(--navy); background: transparent; color: var(--navy); cursor: pointer; font-weight: 750; } button:focus-visible { outline: 3px solid var(--orange); outline-offset: 4px; } .success-plane button:focus-visible { outline-color: var(--navy); } @@ -608,6 +780,10 @@ button:focus-visible { outline: 3px solid var(--orange); outline-offset: 4px; } .staff-panel { grid-template-columns: 1fr; overflow-y: auto; } .staff-poster { display: none; } .staff-panel > form { overflow: visible; } + .release-card { grid-template-columns: 1fr; } + .release-card time { grid-row: 2; } + .release-card p { grid-column: 1; } + .profile-card { padding: 34px 26px; } } @media (max-height: 760px) and (min-width: 881px) { @@ -620,9 +796,42 @@ button:focus-visible { outline: 3px solid var(--orange); outline-offset: 4px; } .home-copy.has-alert .primary-actions { margin-top: 18px; } .home-copy.has-alert .text-action { padding-block: 9px; } .success-closing-alert { margin-top: 14px; } + .profile-card { padding: 30px 34px; } + .profile-card h1 { font-size: clamp(38px, 4vw, 60px); } + .profile-card .lede { font-size: 18px; } + .profile-card .back { margin-bottom: 16px; } + .profile-role-grid { margin-top: 18px; } + .profile-role-grid button { min-height: 49px; padding: 10px 13px; font-size: 14px; } footer { bottom: 18px; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; } } + +.waiver-required-content { width: min(100%, 840px); display: grid; grid-template-columns: minmax(0, 1fr) 318px; align-items: center; gap: clamp(32px, 5vw, 72px); } +.group-link-content { width: min(100%, 820px); } +.group-link-callout { display: grid; gap: 6px; max-width: 620px; margin: 28px 0; padding: 18px 20px; border-left: 8px solid var(--orange); background: var(--cream); color: var(--navy); box-shadow: 8px 8px 0 rgba(16,43,78,.5); } +.group-link-callout b { font: 850 13px/1 "Jost"; letter-spacing: .14em; } +.group-link-callout span { font-size: 18px; } +.waiver-required-content .button-row { margin-top: 28px; } +.waiver-delay-note { display: grid; gap: 6px; margin-top: 24px; padding: 16px 18px; border-left: 7px solid var(--orange); background: var(--cream); color: var(--navy); } +.waiver-delay-note b { font: 850 14px/1 "Jost"; letter-spacing: .13em; } +.waiver-delay-note span { font-size: clamp(16px, 1.05vw, 20px); line-height: 1.3; } +.waiver-qr { justify-self: end; text-align: center; } +.registration-kiosk { width: 100%; height: 100%; padding: 24px 28px 28px; } +.screen-register-kiosk .interaction-plane { height: 100svh; min-height: 0; margin-left: 0; padding: 82px 22px 18px; align-items: stretch; } +.screen-register-kiosk .registration-kiosk { max-width: none; } +.screen-register-kiosk footer { display: none; } +.registration-kiosk-bar { display: flex; min-height: 52px; align-items: center; justify-content: space-between; gap: 24px; } +.registration-kiosk-bar .back { position: static; } +.registration-kiosk-bar span { font: 700 12px/1 "Jost"; letter-spacing: .1em; } +.registration-kiosk iframe { display: block; width: 100%; height: calc(100% - 52px); border: 2px solid var(--navy); background: var(--cream); } +@media (max-width: 900px) { .waiver-required-content { grid-template-columns: 1fr 250px; gap: 24px; } .waiver-required-content .qr-code-large { width: 250px; } .idle-return { bottom: 18px; grid-template-columns: 46px 1fr; } .idle-return button { grid-column: 1 / -1; } } + +.sync-card { display: grid; grid-template-columns: minmax(170px,.45fr) 1fr; gap: 22px; margin: 0 0 24px; padding: 18px 20px; border: 1px solid rgba(16,43,78,.35); background: rgba(255,255,255,.4); } +.sync-card.pending { border-left: 8px solid var(--orange); } +.sync-card div { display: grid; gap: 5px; } +.sync-card small { font: 800 11px/1 "Jost"; letter-spacing: .13em; } +.sync-card strong { font: 700 22px/1.1 "Jost"; } +.sync-card p { margin: 0; align-self: center; font-size: 15px; line-height: 1.35; } diff --git a/Kiosk-v2/app/join/page.tsx b/Kiosk-v2/app/join/page.tsx index b1903da..6ce25e1 100644 --- a/Kiosk-v2/app/join/page.tsx +++ b/Kiosk-v2/app/join/page.tsx @@ -24,6 +24,7 @@ export default function JoinPage() { const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); const [copied, setCopied] = useState(false); + const [identifierType, setIdentifierType] = useState("pid"); async function submit(event: FormEvent) { event.preventDefault(); @@ -108,13 +109,26 @@ export default function JoinPage() {
- +
diff --git a/Kiosk-v2/app/page.tsx b/Kiosk-v2/app/page.tsx index 46877e4..e83e123 100644 --- a/Kiosk-v2/app/page.tsx +++ b/Kiosk-v2/app/page.tsx @@ -1,7 +1,18 @@ "use client"; -import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; +import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { QRCodeSVG } from "qrcode.react"; +import { + PROFILE_ROLES, + affiliationOptions, + emptyProfile, + nextProfileQuestion, + normalizedProfileAnswer, + profileQuestionForField, + type ProfileField, + type ProfileSnapshot, +} from "@/lib/profile-enrichment"; +import { KIOSK_RELEASE } from "@/lib/kiosk-release"; type Screen = | "home" @@ -16,22 +27,45 @@ type Screen = | "link-authorize" | "link-error" | "card-linked" + | "group-link" + | "group-link-error" | "reader-error" | "profile" | "profile-detail" - | "new-here"; + | "new-here" + | "register-kiosk"; type Announcement = { active: boolean; heading: string; body: string; closingTime: string; + closingDate: string; }; const REGISTRATION_URL = process.env.NEXT_PUBLIC_REGISTRATION_URL?.trim() || ""; +const WAIVER_URL = process.env.NEXT_PUBLIC_WAIVER_URL?.trim() || ""; type ScannerStatus = "demo" | "connecting" | "connected" | "disconnected"; +type ActivitySyncStatus = { + pending: number; + oldest_pending_seconds?: number | null; + last_synced_at?: string | null; + last_error?: string; + durable?: boolean; +}; + +type CalendarAccessStatus = { + mode: "open" | "closing-soon" | "closed" | "unknown"; + minutes_until_close: number | null; + closes_at: string | null; + checked_at: string; + source: string; + stale: boolean; + reason: string; +}; + type ScannerOutcome = | "demo" | "success" @@ -41,7 +75,15 @@ type ScannerOutcome = | "backend_error" | "card_linked" | "staff_unauthorized" - | "card_link_error"; + | "card_link_error" + | "group_card_linked" + | "group_link_error"; + +type GroupLinkRequest = { + request_id: string; + display_name: string; + expires_at: string; +}; type ScannerDetectedEvent = { type: "card_detected"; @@ -58,6 +100,8 @@ type ScannerResultEvent = { read_at: string; sequence: number; processing_ms?: number; + person_id?: string | null; + profile?: Partial; }; type ScannerEvent = ScannerDetectedEvent | ScannerResultEvent; @@ -67,56 +111,122 @@ const emptyAnnouncement: Announcement = { heading: "CHECK IN WITH STAFF", body: "Please check in with me upstairs before starting work.", closingTime: "", + closingDate: "", }; -const affiliations = [ - "Undergraduate", - "Graduate student", - "Postdoc", - "Faculty", - "Staff", - "Visitor", -]; +const INACTIVITY_SECONDS = 45; +const INACTIVITY_NOTICE_SECONDS = 15; +const SUCCESS_SECONDS = 3; + +function localDateKey(date: Date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} function Arrow({ direction = "right" }: { direction?: "right" | "left" }) { return ; } +function ordinal(value: number) { + const modulo100 = value % 100; + const suffix = modulo100 >= 11 && modulo100 <= 13 + ? "th" + : value % 10 === 1 + ? "st" + : value % 10 === 2 + ? "nd" + : value % 10 === 3 + ? "rd" + : "th"; + return String(value) + suffix; +} + export default function Home() { const [screen, setScreen] = useState("home"); const [demoOpen, setDemoOpen] = useState(false); const [pid, setPid] = useState(""); const [checkInMethod, setCheckInMethod] = useState<"card" | "identifier">("card"); - const [affiliation, setAffiliation] = useState(""); - const [detail, setDetail] = useState(""); + const [profile, setProfile] = useState(emptyProfile); + const [profileSessionAvailable, setProfileSessionAvailable] = useState(false); + const [profileAnswer, setProfileAnswer] = useState(""); + const [profileOther, setProfileOther] = useState(""); + const [profileSaving, setProfileSaving] = useState(false); + const [profileError, setProfileError] = useState(""); + const [profileEditingField, setProfileEditingField] = useState(null); const [linkIdentifier, setLinkIdentifier] = useState(""); const [linkTargetName, setLinkTargetName] = useState("Sandbox member"); const [linkError, setLinkError] = useState(""); const [staffCardDetected, setStaffCardDetected] = useState(false); - const [countdown, setCountdown] = useState(8); + const [countdown, setCountdown] = useState(SUCCESS_SECONDS); + const [idleCountdown, setIdleCountdown] = useState(INACTIVITY_SECONDS); const [now, setNow] = useState(null); const [announcement, setAnnouncement] = useState(emptyAnnouncement); const [announcementDraft, setAnnouncementDraft] = useState(emptyAnnouncement); const [staffOpen, setStaffOpen] = useState(false); + const [activitySync, setActivitySync] = useState(null); + const [calendarAccess, setCalendarAccess] = useState(null); const [demoClosingMinutes, setDemoClosingMinutes] = useState(null); const [scannerStatus, setScannerStatus] = useState("demo"); const [scannerResult, setScannerResult] = useState(null); const [welcomeName, setWelcomeName] = useState("Sandbox member"); const [visitCount, setVisitCount] = useState(null); + const [groupLinkRequest, setGroupLinkRequest] = useState(null); + const [groupJustLinked, setGroupJustLinked] = useState(false); + const [registrationComplete, setRegistrationComplete] = useState(false); const demoControlsEnabled = process.env.NEXT_PUBLIC_KIOSK_DEMO === "true"; const screenRef = useRef("home"); const cardDetectedAtRef = useRef(null); + const idleDeadlineRef = useRef(null); + const groupLinkRequestRef = useRef(null); + const registrationFrameRef = useRef(null); + const profileQuestion = useMemo( + () => profileSessionAvailable + ? profileEditingField + ? profileQuestionForField(profileEditingField, profile.role) + : nextProfileQuestion(profile) + : null, + [profile, profileEditingField, profileSessionAvailable], + ); useEffect(() => { screenRef.current = screen; }, [screen]); + useEffect(() => { + groupLinkRequestRef.current = groupLinkRequest; + }, [groupLinkRequest]); + + useEffect(() => { + const registrationMessage = (event: MessageEvent) => { + if (event.source !== registrationFrameRef.current?.contentWindow) return; + let trustedOrigin = false; + try { + const host = new URL(event.origin).hostname; + trustedOrigin = host === "script.google.com" || host.endsWith(".googleusercontent.com"); + } catch { + return; + } + if (trustedOrigin && event.data?.type === "sandbox-registration-complete") { + setRegistrationComplete(true); + } + }; + window.addEventListener("message", registrationMessage); + return () => window.removeEventListener("message", registrationMessage); + }, []); + useEffect(() => { setNow(new Date()); const saved = window.localStorage.getItem("sandbox-kiosk-announcement"); if (saved) { try { const parsed = { ...emptyAnnouncement, ...JSON.parse(saved) }; + if (parsed.closingTime && parsed.closingDate !== localDateKey(new Date())) { + parsed.closingTime = ""; + parsed.closingDate = ""; + window.localStorage.setItem("sandbox-kiosk-announcement", JSON.stringify(parsed)); + } setAnnouncement(parsed); setAnnouncementDraft(parsed); } catch { @@ -149,7 +259,7 @@ export default function Home() { socket.addEventListener("message", (message) => { try { const event = JSON.parse(message.data) as ScannerEvent; - if (event.type === "card_detected" && screenRef.current === "home") { + if (event.type === "card_detected" && screenRef.current !== "link-authorize") { cardDetectedAtRef.current = performance.now(); setScannerResult(null); setCheckInMethod("card"); @@ -158,9 +268,9 @@ export default function Home() { setStaffCardDetected(true); } else if ( event.type === "card_read" && - (screenRef.current === "home" || screenRef.current === "reading") + screenRef.current !== "link-authorize" ) { - if (screenRef.current === "home") { + if (screenRef.current !== "reading") { cardDetectedAtRef.current = performance.now(); setCheckInMethod("card"); setScreen("reading"); @@ -196,6 +306,68 @@ export default function Home() { }; }, []); + useEffect(() => { + const isLocalKiosk = ["localhost", "127.0.0.1"].includes(window.location.hostname); + if (!isLocalKiosk || scannerStatus !== "connected") return; + let stopped = false; + async function pollGroupLink() { + try { + const response = await fetch("http://127.0.0.1:8765/group-link/status", { cache: "no-store" }); + if (!response.ok || stopped) return; + const result = await response.json() as { active?: boolean; request_id?: string; display_name?: string; expires_at?: string }; + if (result.active && result.request_id) { + const request = { request_id: result.request_id, display_name: result.display_name || "Sandbox member", expires_at: result.expires_at || "" }; + setGroupLinkRequest(request); + if (screenRef.current === "home") setScreen("group-link"); + } else { + setGroupLinkRequest(null); + if (screenRef.current === "group-link") setScreen("home"); + } + } catch { + // Normal check-in remains available if the optional staff queue cannot be reached. + } + } + pollGroupLink(); + const interval = window.setInterval(pollGroupLink, 2500); + return () => { stopped = true; window.clearInterval(interval); }; + }, [scannerStatus]); + + useEffect(() => { + if (!staffOpen) return; + let stopped = false; + async function refreshActivitySync() { + try { + const response = await fetch("http://127.0.0.1:8765/health", { cache: "no-store" }); + if (!response.ok || stopped) return; + const health = await response.json() as { activity_sync?: ActivitySyncStatus }; + setActivitySync(health.activity_sync || null); + } catch { + if (!stopped) setActivitySync(null); + } + } + refreshActivitySync(); + const interval = window.setInterval(refreshActivitySync, 5_000); + return () => { stopped = true; window.clearInterval(interval); }; + }, [staffOpen]); + + useEffect(() => { + const isLocalKiosk = ["localhost", "127.0.0.1"].includes(window.location.hostname); + if (!isLocalKiosk) return; + let stopped = false; + async function refreshCalendarAccess() { + try { + const response = await fetch("http://127.0.0.1:8765/access-status", { cache: "no-store" }); + if (!response.ok || stopped) return; + setCalendarAccess(await response.json() as CalendarAccessStatus); + } catch { + if (!stopped) setCalendarAccess(null); + } + } + refreshCalendarAccess(); + const interval = window.setInterval(refreshCalendarAccess, 30_000); + return () => { stopped = true; window.clearInterval(interval); }; + }, []); + useEffect(() => { if (screen !== "reading" || !scannerResult) return; const detectedAt = cardDetectedAtRef.current ?? performance.now(); @@ -203,17 +375,33 @@ export default function Home() { const delay = Math.max(0, minimumFeedbackMs - (performance.now() - detectedAt)); const timer = window.setTimeout(() => { if (scannerResult.outcome === "demo") { + setCountdown(SUCCESS_SECONDS); setScreen("success"); return; } if (scannerResult.outcome === "success") { + const incomingProfile = { ...emptyProfile(), ...(scannerResult.profile || {}) }; + const hasQuestion = Boolean(scannerResult.person_id && nextProfileQuestion(incomingProfile)); + setWelcomeName(scannerResult.display_name || "Sandbox member"); + setVisitCount(scannerResult.visit_count); + setProfile(incomingProfile); + setProfileSessionAvailable(Boolean(scannerResult.person_id)); + setCountdown(SUCCESS_SECONDS); + setScreen(hasQuestion ? "profile" : "success"); + } else if (scannerResult.outcome === "group_card_linked") { setWelcomeName(scannerResult.display_name || "Sandbox member"); setVisitCount(scannerResult.visit_count); + setGroupLinkRequest(null); + setGroupJustLinked(true); + setCountdown(SUCCESS_SECONDS); setScreen("success"); + } else if (scannerResult.outcome === "group_link_error") { + setLinkError(scannerResult.message || "The card could not be connected. Ask staff to try again."); + setScreen("group-link-error"); } else if (scannerResult.outcome === "unknown_card") { setScreen("unknown-card"); - } else if (scannerResult.outcome === "unknown_identifier") { - setScreen("not-found"); + } else if (scannerResult.outcome === "unknown_identifier") { + setScreen("not-found"); } else if (scannerResult.outcome === "waiver_required") { setScreen("waiver-required"); } else { @@ -225,13 +413,12 @@ export default function Home() { useEffect(() => { if (screen !== "success") return; - setCountdown(8); const interval = window.setInterval(() => { setCountdown((value) => { if (value <= 1) { window.clearInterval(interval); setScreen("home"); - return 8; + return SUCCESS_SECONDS; } return value - 1; }); @@ -245,16 +432,35 @@ export default function Home() { [now], ); - const minutesUntilClose = useMemo(() => { - if (demoClosingMinutes !== null) return demoClosingMinutes; - if (!now || !announcement.closingTime) return null; - const [hours, minutes] = announcement.closingTime.split(":").map(Number); - if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null; - const closes = new Date(now); - closes.setHours(hours, minutes, 0, 0); - const difference = Math.ceil((closes.getTime() - now.getTime()) / 60_000); - return difference >= 0 && difference <= 30 ? difference : null; - }, [announcement.closingTime, demoClosingMinutes, now]); + const closingStatus = useMemo(() => { + if (demoClosingMinutes !== null) { + return { + mode: demoClosingMinutes <= 0 ? "closed" as const : "closing-soon" as const, + minutes: Math.max(0, demoClosingMinutes), + }; + } + if (!now) return { mode: "open" as const, minutes: null }; + if (announcement.closingTime && announcement.closingDate === localDateKey(now)) { + const [hours, minutes] = announcement.closingTime.split(":").map(Number); + if (Number.isFinite(hours) && Number.isFinite(minutes)) { + const closes = new Date(now); + closes.setHours(hours, minutes, 0, 0); + const difference = Math.ceil((closes.getTime() - now.getTime()) / 60_000); + if (difference <= 0) return { mode: "closed" as const, minutes: 0 }; + if (difference <= 30) return { mode: "closing-soon" as const, minutes: difference }; + return { mode: "open" as const, minutes: null }; + } + } + if (calendarAccess?.mode === "closed") return { mode: "closed" as const, minutes: 0 }; + if (calendarAccess?.closes_at && ["open", "closing-soon"].includes(calendarAccess.mode)) { + const difference = Math.ceil((new Date(calendarAccess.closes_at).getTime() - now.getTime()) / 60_000); + if (difference <= 0) return { mode: "closed" as const, minutes: 0 }; + if (difference <= 30) return { mode: "closing-soon" as const, minutes: difference }; + } + return { mode: "open" as const, minutes: null }; + }, [announcement.closingDate, announcement.closingTime, calendarAccess, demoClosingMinutes, now]); + + const minutesUntilClose = closingStatus.minutes; const closingLabel = minutesUntilClose === 0 ? "We’re closing now" @@ -274,6 +480,7 @@ export default function Home() { ...announcementDraft, heading: announcementDraft.heading.trim() || emptyAnnouncement.heading, body: announcementDraft.body.trim() || emptyAnnouncement.body, + closingDate: announcementDraft.closingTime ? localDateKey(new Date()) : "", }; setAnnouncement(next); window.localStorage.setItem("sandbox-kiosk-announcement", JSON.stringify(next)); @@ -305,7 +512,19 @@ export default function Home() { setScreen("reading"); } - function reset() { + const reset = useCallback(() => { + const previousScreen = screenRef.current; + const pendingGroupLink = groupLinkRequestRef.current; + if ((previousScreen === "group-link" || previousScreen === "group-link-error") && pendingGroupLink) { + fetch("http://127.0.0.1:8765/group-link/cancel", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ request_id: pendingGroupLink.request_id }), + }).catch(() => undefined); + } + if (!["home", "reading", "success"].includes(previousScreen)) { + fetch("http://127.0.0.1:8765/scanner/allow-repeat", { method: "POST" }).catch(() => undefined); + } cardDetectedAtRef.current = null; setScannerResult(null); setWelcomeName("Sandbox member"); @@ -313,14 +532,69 @@ export default function Home() { setScreen("home"); setCheckInMethod("card"); setPid(""); - setAffiliation(""); - setDetail(""); + setProfile(emptyProfile()); + setProfileSessionAvailable(false); + setProfileAnswer(""); + setProfileOther(""); + setProfileSaving(false); + setProfileError(""); + setProfileEditingField(null); setLinkIdentifier(""); setLinkTargetName("Sandbox member"); setLinkError(""); setStaffCardDetected(false); + setGroupLinkRequest(null); + setGroupJustLinked(false); + setRegistrationComplete(false); setDemoOpen(false); - } + }, []); + + useEffect(() => { + const returnHome = (event: KeyboardEvent) => { + if (event.key === "Escape") reset(); + }; + window.addEventListener("keydown", returnHome); + return () => window.removeEventListener("keydown", returnHome); + }, [reset]); + + const idleTimerActive = screen !== "home" + && screen !== "reading" + && screen !== "success" + && (screen !== "register-kiosk" || registrationComplete) + && !staffOpen; + + const extendIdleTimer = useCallback(() => { + idleDeadlineRef.current = Date.now() + INACTIVITY_SECONDS * 1000; + setIdleCountdown(INACTIVITY_SECONDS); + }, []); + + useEffect(() => { + idleDeadlineRef.current = null; + setIdleCountdown(INACTIVITY_SECONDS); + if (!idleTimerActive) return; + + idleDeadlineRef.current = Date.now() + INACTIVITY_SECONDS * 1000; + let expired = false; + const tick = () => { + const deadline = idleDeadlineRef.current; + if (deadline === null || expired) return; + const remaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1000)); + setIdleCountdown(remaining); + if (remaining === 0) { + expired = true; + window.clearInterval(interval); + if (screen === "link-authorize" || screen === "link-error") { + fetch("http://127.0.0.1:8765/card-link/cancel", { method: "POST" }).catch(() => undefined); + } + reset(); + } + }; + const interval = window.setInterval(tick, 250); + return () => { + expired = true; + window.clearInterval(interval); + }; + }, [idleTimerActive, reset, screen]); async function submitPid(event: FormEvent) { event.preventDefault(); @@ -392,7 +666,81 @@ export default function Home() { reset(); } - const showBrand = screen !== "success"; + async function saveProfileAnswer(field: ProfileField, rawValue: string) { + const value = normalizedProfileAnswer(field, rawValue); + if (!value || profileSaving) return; + setProfileSaving(true); + setProfileError(""); + try { + let updatedProfile: ProfileSnapshot; + if (demoControlsEnabled && !profileSessionAvailable) { + updatedProfile = { ...profile, [field]: value }; + if (field === "role" && profile.role && profile.role !== value) { + updatedProfile.affiliation = ""; + updatedProfile.anticipatedGraduation = ""; + } + } else { + const response = await fetch("http://127.0.0.1:8765/profile", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ field, value }), + }); + const result = await response.json() as { + ok?: boolean; + profile?: Partial; + detail?: string; + message?: string; + }; + if (!response.ok || !result.ok) { + throw new Error(result.detail || result.message || "That answer could not be saved."); + } + updatedProfile = { ...profile, ...(result.profile || {}), [field]: value }; + } + setProfile(updatedProfile); + setProfileEditingField(null); + setProfileAnswer(""); + setProfileOther(""); + if (!nextProfileQuestion(updatedProfile)) { + setCountdown(SUCCESS_SECONDS); + setScreen("success"); + } + } catch (error) { + setProfileError(error instanceof Error ? error.message : "That answer could not be saved."); + } finally { + setProfileSaving(false); + } + } + + function skipProfileQuestions() { + setCountdown(SUCCESS_SECONDS); + setScreen("success"); + } + + function editProfileField(field: ProfileField) { + setProfileEditingField(field); + setProfileError(""); + const currentValue = profile[field]; + if (field === "affiliation" && currentValue.startsWith("Other – ")) { + setProfileAnswer("Other"); + setProfileOther(currentValue.slice("Other – ".length)); + } else { + setProfileAnswer(field === "role" ? "" : currentValue); + setProfileOther(""); + } + } + + function profileBack() { + if (profileQuestion?.field === "anticipatedGraduation") { + editProfileField("affiliation"); + } else if (profileQuestion?.field === "affiliation") { + editProfileField("role"); + } else { + setProfileEditingField(null); + setScreen("profile"); + } + } + + const showBrand = screen !== "success" && screen !== "register-kiosk"; return (
@@ -402,7 +750,13 @@ export default function Home() {
{timeLabel} - + {demoControlsEnabled &&
)} + {screen === "group-link" && groupLinkRequest && ( +
+

CARD CONNECTION STARTED

+

{groupLinkRequest.display_name.split(/\s+/)[0]}, tap your
ID card now.

+

Hold your card at the blue hand until this screen changes. Then keep watching for the result.

+
WAIT FOR “CARD CONNECTED”You are not finished until that confirmation appears. If this is not your name, choose Cancel and ask staff for help.
+ +
+ )} + + {screen === "group-link-error" && ( +
+

CARD NOT CONNECTED

+

Please don’t
leave yet.

+

{linkError} Ask staff to select your name again, then tap once more.

+ +
+ )} + {screen === "pid" && (
- +

CHECK IN WITHOUT A CARD

Enter your ID.

-

Use your PID or UC San Diego employee ID.

+

Use your student PID, nine-digit TSN, or UC San Diego employee ID.

- + Already registered? Designated staff can connect this card after checking your physical ID.

- + - +
)} {screen === "waiver-required" && ( -
+
+

WAIVER REQUIRED

-

One step before
you check in.

-

We found your Sandbox account, but not a current waiver. Please ask staff for help before using the space.

+

Sign the waiver
on your phone.

+

We found your Sandbox account, but no signed waiver is on file. Scan the code and complete the DocuSign waiver.

+
+ ALLOW UP TO 15 MINUTES + Your signed waiver may take a little while to appear here. You do not need to tap repeatedly—return later and tap once. +
- - +
- )} + {WAIVER_URL && ( +
+ + SCAN TO SIGN THE WAIVER +
+ )} +
+ )} - {screen === "backend-error" && ( + {screen === "backend-error" && (

CHECK-IN NOT RECORDED

Please check in
with staff.

The card reader worked, but the attendance log did not. Staff can let you through while the kiosk reconnects.

- +
)} @@ -563,9 +947,9 @@ export default function Home() {

STAFF-ASSISTED CARD LINK

Confirm the
member’s account.

-

Staff: inspect the member’s physical ID, then enter its PID or employee ID.

+

Staff: inspect the member’s physical ID, then enter their PID, TSN, or employee ID. Their new card will replace any card currently connected to the account.

- + Cancel

DESIGNATED STAFF AUTHORIZATION

{staffCardDetected ? <>Checking staff
authorization… : <>Staff: tap your
own ID card.}

-

Connecting the waiting card to {linkTargetName}. Only a designated staff card can approve this change.

-

Do not tap the member’s card again until authorization is complete.

+

Replacing the card for {linkTargetName}. Only a designated staff card can approve this change.

+

Approval disables the account’s previous card. Do not tap the member’s new card again until authorization is complete.

)} @@ -607,7 +991,7 @@ export default function Home() {

CARD CONNECTED

{welcomeName},
you’re ready.

-

Tap the newly connected card again to check in. A waiver is still required before entry.

+

The previous card is disabled. Tap the new card again to check in. A waiver is still required before entry.

)} @@ -618,102 +1002,179 @@ export default function Home() {

The reader didn’t
catch your card.

Hold it flat against the reader for a full second.

- - + +
)} {screen === "profile" && ( -
-

YOU’RE CHECKED IN

-

One quick
question.

-

What best describes your role at UC San Diego?

-
- {affiliations.map((item) => ( - - ))} +
+
+ + +

ONE QUICK UPDATE

+

Your account is missing
a few details.

+

Before the confirmation screen, please answer a few short questions. We’ll only ask for information that’s missing.

+ + +
-
)} {screen === "profile-detail" && ( -
- -

LAST ONE

-

Your program
or department?

-

This helps us understand who the Makerspace serves.

- { event.preventDefault(); setScreen("success"); }}> - - setDetail(event.target.value)} - placeholder="e.g. Scripps Oceanography" - autoFocus - /> - - - +
+
+ + + {profileQuestion ? <> +

{profileQuestion.eyebrow}

+

{profileQuestion.heading}

+

{profileQuestion.prompt}

+ {profileQuestion.field === "role" ? ( +
+ {PROFILE_ROLES.map((item) => ( + + ))} + {profileError &&

{profileError}

} +
+ ) : ( +
{ + event.preventDefault(); + const value = profileAnswer === "Other" ? `Other – ${profileOther}` : profileAnswer; + saveProfileAnswer(profileQuestion.field, value); + }}> +
+ {profileQuestion.field === "anticipatedGraduation" ? ( + <> + + setProfileAnswer(event.target.value)} /> + + ) : ( + <> + + + {profileAnswer === "Other" && ( + setProfileOther(event.target.value)} placeholder="Type your answer" /> + )} + + )} +
+ {profileError &&

{profileError}

} + +
+ )} + : null} + + +
)} {screen === "new-here" && ( -
- -

WELCOME TO THE SANDBOX

-

Make something
unexpected.

-
+
+ +

WELCOME TO THE SANDBOX

+

Start on
your phone.

+ {REGISTRATION_URL ? ( +
+
+ + SCAN TO CREATE YOUR ACCOUNT +
-

Scan with your phone to submit your Sandbox profile. After the waiver, ask staff to finish setup and connect your card.

+

Use your phone to create your account and complete the liability waiver. It takes about three minutes.

    -
  1. 01Submit your profile
  2. -
  3. 02Complete the liability waiver
  4. -
  5. 03Ask staff to activate your account
  6. +
  7. 01Create your Sandbox profile
  8. +
  9. 02Complete the waiver on your phone
  10. +
  11. 03Return here and tap your card
- {REGISTRATION_URL &&
- - SCAN TO JOIN -
}
- {REGISTRATION_URL - ? Open the registration form - :

The registration link is not configured yet. Please ask Sandbox staff for help.

} - + ) : ( +

Phone registration is not configured yet. Please ask Sandbox staff for help.

+ )} +
+

No phone nearby? You can create the account on this kiosk. The waiver must still be completed from a phone or another device.

+ {REGISTRATION_URL && } + +
+
+ )} + + {screen === "register-kiosk" && REGISTRATION_URL && ( +
+
+ + Press Esc at any time to return +
+