diff --git a/Kiosk-v2/.env.example b/Kiosk-v2/.env.example new file mode 100644 index 0000000..5b669b6 --- /dev/null +++ b/Kiosk-v2/.env.example @@ -0,0 +1,3 @@ +# Required for deterministic card digests and migration identifiers. +# Set this as a hosted secret; never commit a real value. +CARD_UID_HMAC_SECRET= diff --git a/Kiosk-v2/.gitignore b/Kiosk-v2/.gitignore new file mode 100644 index 0000000..5c70dec --- /dev/null +++ b/Kiosk-v2/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/.vinext/ +/out/ + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example +*.tsbuildinfo + +# vercel +.vercel + +/dist/ +/.wrangler/ +/outputs/ +/work/ diff --git a/Kiosk-v2/.openai/hosting.json b/Kiosk-v2/.openai/hosting.json new file mode 100644 index 0000000..3abfeb8 --- /dev/null +++ b/Kiosk-v2/.openai/hosting.json @@ -0,0 +1,5 @@ +{ + "project_id": "appgprj_6a7a93698b7481918f4309125f627899", + "d1": "DB", + "r2": null +} diff --git a/Kiosk-v2/README.md b/Kiosk-v2/README.md new file mode 100644 index 0000000..a93d0e6 --- /dev/null +++ b/Kiosk-v2/README.md @@ -0,0 +1,95 @@ +# Scripps Sandbox check-in + +Interactive kiosk prototype and production technical spike for the Scripps Sandbox Makerspace. + +- `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. + +## Prerequisites + +- Node.js `>=22.13.0` + +## Kiosk development + +```bash +npm install +npm run dev +npm run build +``` + +This starter does not use `wrangler.jsonc`. + +When served from `localhost`, the interface automatically looks for the local reader bridge at `ws://127.0.0.1:8765/ws`. The hosted design prototype stays in demo mode. See `bridge/README.md` for Pi setup and hardware-free simulation. + +## Workspace Auth Headers + +Signed-in visitors receive both `oai-authenticated-user-id` and `oai-authenticated-user-email`. Private Sites require every visitor to sign in; public Sites may also have anonymous visitors, for whom neither header is present. + +The user ID is stable for the same user on the same Site and different across Sites. Email and name are intended for display or contact purposes. + +SIWC-authenticated workspace sites may also receive +`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty +`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by +`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`. + +Treat the full name as optional and fall back to email when it is absent: + +```tsx +import { headers } from "next/headers"; + +export default async function Home() { + const requestHeaders = await headers(); + const userId = requestHeaders.get("oai-authenticated-user-id"); + const email = requestHeaders.get("oai-authenticated-user-email"); + const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name"); + const fullName = + encodedFullName && + requestHeaders.get("oai-authenticated-user-full-name-encoding") === + "percent-encoded-utf-8" + ? decodeURIComponent(encodedFullName) + : null; + + const displayName = fullName ?? email; + // ... +} +``` + +## Optional Dispatch-Owned ChatGPT Sign-In + +Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs +optional or required ChatGPT sign-in: + +- Use `getChatGPTUser()` for optional signed-in UI. +- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send + anonymous visitors through Sign in with ChatGPT. +- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for + browser links or actions. +- Pass a same-origin relative `returnTo` path for the destination after sign-in + or sign-out. The helper validates and safely encodes it. +- Mark protected pages with `export const dynamic = "force-dynamic"` because + they depend on per-request identity headers. + +Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the +OAuth cookies, and identity header injection. Do not implement app routes for +those reserved paths. Routes that do not import and call the helper remain +anonymous-compatible. + +SIWC establishes identity only; it does not prove workspace membership. Use the +Sites hosting platform's access policy controls for workspace-wide restrictions, +or enforce explicit server-side membership or allowlist checks. + +Use SIWC for account pages, user-specific dashboards, saved records, and write +actions tied to the current ChatGPT user. Leave public content anonymous. + +## Useful Commands + +- `npm run dev`: start local development +- `npm run build`: verify the vinext build output +- `npm test`: build the starter and verify its rendered loading skeleton +- `npm run db:generate`: generate Drizzle migrations after schema changes + +## Learn More + +- [vinext Documentation](https://github.com/cloudflare/vinext) +- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new) diff --git a/Kiosk-v2/app/api/registrations/route.ts b/Kiosk-v2/app/api/registrations/route.ts new file mode 100644 index 0000000..37c8f6b --- /dev/null +++ b/Kiosk-v2/app/api/registrations/route.ts @@ -0,0 +1,158 @@ +import { getDb } from "@/db"; +import { + auditEvents, + cardLinkSessions, + registrationApplications, + userIdentifiers, + users, + waiverStatuses, +} from "@/db/schema"; +import { + createClaimCode, + digestClaimCode, + id, + makeDisplayName, + normalizeEmail, + normalizeIdentifier, + REGISTRATION_CONSENT_VERSION, + WAIVER_POWERFORM_URL, +} from "@/lib/registration"; + +type RegistrationPayload = { + firstName?: string; + lastName?: string; + preferredName?: string; + userType?: string; + affiliation?: string; + email?: string; + secondaryEmail?: string; + identifierType?: string; + identifierValue?: string; + consent?: boolean; +}; + +const allowedUserTypes = new Set(["student", "staff", "faculty", "postdoc", "visitor", "other"]); +const allowedIdentifierTypes = new Set(["pid", "employee_id", "other"]); + +function validEmail(value: string) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); +} +function errorMessage(error: unknown) { + const message = error instanceof Error ? error.message : "Unexpected registration error"; + if (message.includes("UNIQUE constraint failed")) { + return "An account already exists for that email or UC San Diego ID. Please use the existing-account option at the kiosk or ask staff for help."; + } + if (message.includes("D1 binding") || message.includes("no such table")) { + return "Registration storage is not ready yet. No information was saved."; + } + return "We could not save the registration. Please try again or ask Sandbox staff for help."; +} + +export async function POST(request: Request) { + try { + const payload = (await request.json()) as RegistrationPayload; + const firstName = payload.firstName?.trim() ?? ""; + const lastName = payload.lastName?.trim() ?? ""; + const preferredName = payload.preferredName?.trim() || null; + const userType = payload.userType?.trim().toLowerCase() ?? ""; + const affiliation = payload.affiliation?.trim() ?? ""; + const primaryEmail = normalizeEmail(payload.email ?? ""); + const secondaryEmail = payload.secondaryEmail + ? normalizeEmail(payload.secondaryEmail) + : null; + const identifierType = payload.identifierType?.trim().toLowerCase() ?? ""; + const identifierValue = payload.identifierValue?.trim() ?? ""; + const normalizedIdentifier = normalizeIdentifier(identifierValue); + + if (!firstName || !lastName || !affiliation) { + return Response.json({ error: "Name and affiliation are required." }, { status: 400 }); + } + if (!allowedUserTypes.has(userType) || !allowedIdentifierTypes.has(identifierType)) { + return Response.json({ error: "Choose a valid role and ID type." }, { status: 400 }); + } + 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 (!payload.consent) { + return Response.json({ error: "Consent is required to create an account." }, { status: 400 }); + } + + const userId = id("usr"); + const identifierId = id("uid"); + const waiverId = id("wvr"); + const applicationId = id("reg"); + const linkSessionId = id("lnk"); + const auditId = id("aud"); + const claimCode = createClaimCode(); + const codeDigest = await digestClaimCode(claimCode); + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); + const displayName = makeDisplayName(firstName, lastName, preferredName ?? undefined); + + const db = getDb(); + await db.batch([ + db.insert(users).values({ + id: userId, + firstName, + lastName, + preferredName, + displayName, + userType, + affiliation, + primaryEmail, + secondaryEmail, + status: "pending_waiver", + }), + db.insert(userIdentifiers).values({ + id: identifierId, + userId, + identifierType, + identifierValue, + normalizedValue: normalizedIdentifier, + isPrimary: true, + sourceSystem: "Sandbox website registration", + }), + db.insert(waiverStatuses).values({ + id: waiverId, + userId, + status: "pending", + }), + db.insert(registrationApplications).values({ + id: applicationId, + userId, + consentVersion: REGISTRATION_CONSENT_VERSION, + }), + db.insert(cardLinkSessions).values({ + id: linkSessionId, + userId, + applicationId, + codeDigest, + codeLastTwo: claimCode.slice(-2), + expiresAt, + }), + db.insert(auditEvents).values({ + id: auditId, + actorEmail: primaryEmail, + action: "registration.submitted", + entityType: "registration_application", + entityId: applicationId, + detailJson: JSON.stringify({ source: "website", consentVersion: REGISTRATION_CONSENT_VERSION }), + }), + ]); + + return Response.json( + { + applicationId, + displayName, + claimCode, + claimCodeExpiresAt: expiresAt, + waiverUrl: WAIVER_POWERFORM_URL, + }, + { status: 201 }, + ); + } catch (error) { + return Response.json({ error: errorMessage(error) }, { status: 500 }); + } +} diff --git a/Kiosk-v2/app/chatgpt-auth.ts b/Kiosk-v2/app/chatgpt-auth.ts new file mode 100644 index 0000000..a0ae2ed --- /dev/null +++ b/Kiosk-v2/app/chatgpt-auth.ts @@ -0,0 +1,90 @@ +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; + +export type ChatGPTUser = { + userId: string; + displayName: string; + email: string; + fullName: string | null; +}; + +const USER_ID_HEADER = "oai-authenticated-user-id"; +const USER_EMAIL_HEADER = "oai-authenticated-user-email"; +const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name"; +const USER_FULL_NAME_ENCODING_HEADER = + "oai-authenticated-user-full-name-encoding"; +const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8"; +const SIGN_IN_PATH = "/signin-with-chatgpt"; +const SIGN_OUT_PATH = "/signout-with-chatgpt"; +const CALLBACK_PATH = "/callback"; + +export async function getChatGPTUser(): Promise { + const requestHeaders = await headers(); + const userId = requestHeaders.get(USER_ID_HEADER); + const email = requestHeaders.get(USER_EMAIL_HEADER); + if (!userId || !email) return null; + + const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER); + const fullName = + encodedFullName && + requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8 + ? safeDecodeURIComponent(encodedFullName) + : null; + + return { + userId, + displayName: fullName ?? email, + email, + fullName, + }; +} + +export async function requireChatGPTUser( + returnTo: string, +): Promise { + const user = await getChatGPTUser(); + if (user) return user; + + redirect(chatGPTSignInPath(returnTo)); +} + +export function chatGPTSignInPath(returnTo: string): string { + const safeReturnTo = safeRelativeReturnPath(returnTo); + return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`; +} + +export function chatGPTSignOutPath(returnTo = "/"): string { + const safeReturnTo = safeRelativeReturnPath(returnTo); + return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`; +} + +function safeRelativeReturnPath(value: string): string { + if (!value.startsWith("/") || value.startsWith("//")) return "/"; + + let url: URL; + try { + url = new URL(value, "https://app.local"); + } catch { + return "/"; + } + if (url.origin !== "https://app.local") return "/"; + if (isReservedAuthPath(url.pathname)) return "/"; + + return `${url.pathname}${url.search}${url.hash}`; +} + +function isReservedAuthPath(pathname: string): boolean { + return ( + pathname === SIGN_IN_PATH || + pathname === SIGN_OUT_PATH || + pathname === CALLBACK_PATH + ); +} + +function safeDecodeURIComponent(value: string): string | null { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} diff --git a/Kiosk-v2/app/globals.css b/Kiosk-v2/app/globals.css new file mode 100644 index 0000000..2cfccbd --- /dev/null +++ b/Kiosk-v2/app/globals.css @@ -0,0 +1,625 @@ +@import "tailwindcss"; + +@font-face { + font-family: "Jost"; + src: url("/assets/Jost-VariableFont_wght.ttf") format("truetype"); + font-weight: 100 900; + font-display: swap; +} + +@font-face { + font-family: "Source Sans 3"; + src: url("/assets/SourceSans3-VariableFont_wght.ttf") format("truetype"); + font-weight: 200 900; + font-display: swap; +} + +:root { + --gray: #747678; + --navy: #102b4e; + --orange: #f7931e; + --cyan: #18b9c8; + --cream: #f2eee3; + --ink: #13243c; +} + +* { box-sizing: border-box; } + +html, body { margin: 0; min-height: 100%; background: var(--gray); } + +button, input, textarea { font: inherit; } +button { color: inherit; } + +a { color: inherit; } + +.web-header { + display: flex; + min-height: 92px; + align-items: center; + justify-content: space-between; + padding: 0 clamp(24px, 5vw, 80px); + border-bottom: 1px solid rgba(16, 43, 78, .24); + font-family: "Jost", sans-serif; + font-size: 12px; + font-weight: 750; + letter-spacing: .14em; +} + +.web-brand { text-decoration: none; } +.web-brand span { margin-left: .35em; color: var(--orange); } +.web-eyebrow { margin: 0 0 24px; color: var(--orange); font-family: "Jost", sans-serif; font-size: 13px; font-weight: 800; letter-spacing: .17em; } + +.join-page { min-height: 100svh; background: var(--cream); color: var(--navy); font-family: "Source Sans 3", sans-serif; } +.join-layout { display: grid; min-height: calc(100svh - 92px); grid-template-columns: minmax(360px, .88fr) minmax(480px, 1.12fr); } +.join-intro { position: sticky; top: 0; align-self: start; min-height: calc(100svh - 92px); padding: clamp(62px, 8vw, 124px) clamp(36px, 6vw, 100px); background: var(--navy); color: var(--cream); } +.join-intro::after { position: absolute; right: 8%; bottom: 7%; width: 38%; aspect-ratio: 1; border: 2px solid var(--orange); border-radius: 50%; content: ""; opacity: .8; } +.join-intro h1, .join-complete h1 { margin: 0; font-family: "Jost", sans-serif; font-size: clamp(50px, 5.4vw, 96px); font-weight: 560; letter-spacing: -.055em; line-height: .95; } +.join-intro > p:not(.web-eyebrow) { max-width: 520px; margin: 30px 0 0; font-size: clamp(19px, 1.45vw, 26px); line-height: 1.4; } +.join-steps { position: relative; z-index: 1; margin: clamp(60px, 9vh, 120px) 0 0; padding: 0; list-style: none; } +.join-steps li { display: grid; max-width: 520px; grid-template-columns: 62px 1fr; align-items: center; padding: 18px 0; border-top: 1px solid rgba(242,238,227,.36); } +.join-steps b { color: var(--orange); font-family: "Jost", sans-serif; letter-spacing: .08em; } +.join-steps span { font-size: 20px; } +.join-form-plane { padding: clamp(46px, 7vw, 104px) clamp(28px, 7vw, 112px) 96px; } +.join-form { width: min(100%, 760px); margin: 0 auto; } +.join-form fieldset { margin: 0 0 48px; padding: 0 0 34px; border: 0; border-bottom: 1px solid rgba(16,43,78,.28); } +.join-form legend { margin-bottom: 24px; color: var(--orange); font-family: "Jost", sans-serif; font-size: 13px; font-weight: 850; letter-spacing: .15em; } +.join-form label { display: grid; gap: 8px; margin: 0 0 20px; font-family: "Jost", sans-serif; font-size: 13px; font-weight: 720; letter-spacing: .045em; } +.join-form label small { color: rgba(16,43,78,.6); font-weight: 500; } +.join-form input, .join-form select, .join-form textarea { width: 100%; min-height: 54px; padding: 12px 15px; border: 1px solid rgba(16,43,78,.45); border-radius: 0; background: rgba(255,255,255,.48); color: var(--navy); font-family: "Source Sans 3", sans-serif; font-size: 18px; letter-spacing: 0; } +.join-form input:focus, .join-form select:focus { border-color: var(--orange); outline: 3px solid rgba(247,147,30,.22); } +.field-grid { display: grid; gap: 18px; } +.field-grid.two { grid-template-columns: 1fr 1fr; } +.id-grid { grid-template-columns: minmax(180px,.7fr) 1.3fr; } +.consent-row { display: grid !important; grid-template-columns: 24px 1fr; align-items: start; gap: 14px !important; font-family: "Source Sans 3", sans-serif !important; font-size: 16px !important; font-weight: 500 !important; line-height: 1.45; letter-spacing: 0 !important; } +.consent-row input { width: 22px; min-height: 22px; margin: 1px 0 0; accent-color: var(--navy); } +.form-error { margin: 20px 0; padding: 15px 18px; border-left: 6px solid var(--orange); background: rgba(247,147,30,.13); font-weight: 650; } +.web-primary { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 24px; padding: 14px 20px; border: 0; background: var(--navy); color: var(--cream); font-family: "Jost", sans-serif; font-size: 16px; font-weight: 760; letter-spacing: .025em; text-decoration: none; cursor: pointer; } +.web-primary:disabled { opacity: .55; cursor: wait; } +.web-primary span { color: var(--orange); font-size: 24px; } + +.join-complete { min-height: calc(100svh - 92px); padding: clamp(58px, 8vw, 120px) clamp(28px, 12vw, 190px); background: var(--orange); } +.join-complete h1 { color: var(--navy); } +.complete-lede { max-width: 760px; margin: 28px 0 48px; font-size: clamp(20px, 1.6vw, 28px); line-height: 1.4; } +.join-complete .web-eyebrow { color: var(--navy); } +.claim-poster { display: grid; max-width: 760px; padding: clamp(30px, 4vw, 56px); background: var(--navy); color: var(--cream); box-shadow: 14px 14px 0 rgba(242,238,227,.9); } +.claim-poster > span { font-family: "Jost", sans-serif; font-size: 12px; font-weight: 800; letter-spacing: .16em; } +.claim-poster strong { margin: 16px 0 12px; color: var(--orange); font-family: "Jost", sans-serif; font-size: clamp(48px, 7vw, 88px); letter-spacing: .12em; line-height: 1; } +.claim-poster button { justify-self: start; padding: 8px 0; border: 0; border-bottom: 1px solid var(--cream); background: transparent; cursor: pointer; } +.claim-poster p { max-width: 600px; margin: 28px 0 0; font-size: 17px; line-height: 1.45; } +.waiver-action { display: grid; max-width: 760px; grid-template-columns: 1fr minmax(250px,.8fr); gap: 24px; align-items: end; margin-top: 64px; padding-top: 30px; border-top: 1px solid rgba(16,43,78,.45); } +.waiver-action > div { display: grid; gap: 7px; font-size: 18px; line-height: 1.35; } +.waiver-action > div b { font-family: "Jost", sans-serif; font-size: 12px; letter-spacing: .16em; } +.privacy-note { max-width: 760px; margin: 32px 0 0; font-size: 14px; opacity: .72; } + +.join-embed-page { + position: relative; + display: grid; + width: 100%; + min-height: 100svh; + grid-template-rows: 1fr auto; + overflow: hidden; + background: var(--navy); + color: var(--cream); + font-family: "Source Sans 3", sans-serif; +} +.join-embed-signal { + position: absolute; + top: clamp(22px, 5vw, 52px); + right: clamp(-70px, -4vw, -24px); + width: clamp(190px, 34vw, 410px); + aspect-ratio: 1; + transform: rotate(-12deg); + transform-origin: center; +} +.join-embed-signal::before { + position: absolute; + inset: -13%; + border: clamp(12px, 2.2vw, 28px) solid var(--orange); + border-radius: 50%; + content: ""; +} +.join-embed-signal img { position: relative; z-index: 1; display: block; width: 100%; height: 100%; object-fit: contain; } +.join-embed-copy { + position: relative; + z-index: 2; + width: min(64%, 720px); + align-self: center; + padding: clamp(32px, 7vw, 92px) 0 clamp(24px, 5vw, 60px) clamp(28px, 8vw, 104px); +} +.join-embed-eyebrow { margin: 0 0 clamp(12px, 2vw, 24px); color: var(--orange); font-family: "Jost", sans-serif; font-size: clamp(11px, 1.3vw, 16px); font-weight: 820; letter-spacing: .18em; } +.join-embed-copy h1 { margin: 0; font-family: "Jost", sans-serif; font-size: clamp(40px, 7.4vw, 96px); font-weight: 560; letter-spacing: -.06em; line-height: .88; } +.join-embed-lede { max-width: 590px; margin: clamp(17px, 2.8vw, 34px) 0 0; font-size: clamp(15px, 1.8vw, 23px); line-height: 1.35; } +.join-embed-steps { display: grid; max-width: 590px; grid-template-columns: repeat(3, 1fr); margin: clamp(22px, 4vw, 50px) 0 clamp(20px, 3vw, 38px); padding: 0; border-top: 1px solid rgba(242,238,227,.44); list-style: none; } +.join-embed-steps li { display: grid; gap: 4px; padding: clamp(10px, 1.5vw, 18px) 8px 0 0; } +.join-embed-steps b { color: var(--orange); font-family: "Jost", sans-serif; font-size: clamp(10px, 1.1vw, 13px); letter-spacing: .12em; } +.join-embed-steps span { font-size: clamp(13px, 1.5vw, 18px); } +.join-embed-action { display: flex; width: min(100%, 590px); min-height: clamp(52px, 6.5vw, 72px); align-items: center; justify-content: space-between; gap: 20px; padding: 12px clamp(16px, 2.5vw, 28px); background: var(--orange); color: var(--navy); font-family: "Jost", sans-serif; font-size: clamp(15px, 1.6vw, 20px); font-weight: 780; text-decoration: none; } +.join-embed-action:focus-visible { outline: 4px solid var(--cream); outline-offset: 5px; } +.join-embed-action b { font-size: clamp(22px, 2.8vw, 34px); } +.join-embed-copy > small { display: block; margin-top: 9px; font-size: clamp(10px, 1.1vw, 13px); opacity: .72; } +.join-embed-footer { position: relative; z-index: 2; display: flex; min-height: clamp(48px, 7vw, 74px); align-items: center; justify-content: space-between; gap: 24px; padding: 0 clamp(28px, 8vw, 104px); border-top: 1px solid rgba(242,238,227,.25); font-family: "Jost", sans-serif; font-size: clamp(8px, 1.1vw, 12px); font-weight: 720; letter-spacing: .13em; } +.join-embed-footer b { color: var(--orange); } + +@media (max-aspect-ratio: 1/1) { + .join-embed-copy { width: 78%; padding-left: 28px; } + .join-embed-signal { top: 6%; right: -120px; opacity: .48; } + .join-embed-footer { padding-inline: 28px; } +} + +.staff-app { min-height: 100svh; padding-bottom: 80px; background: #e8e5dd; color: var(--navy); font-family: "Source Sans 3", sans-serif; } +.staff-app-header { display: flex; min-height: 84px; align-items: center; justify-content: space-between; padding: 0 clamp(24px, 5vw, 72px); background: var(--navy); color: var(--cream); } +.staff-app-header > div { display: flex; align-items: center; gap: 12px; font-family: "Jost", sans-serif; font-size: 13px; letter-spacing: .13em; } +.staff-app-header > div b { padding: 5px 8px; background: var(--orange); color: var(--navy); } +.staff-app-header nav { display: flex; align-items: center; gap: 28px; } +.staff-app-header a { font-size: 15px; text-decoration: none; } +.staff-app-header nav button { padding: 10px 13px; border: 1px solid rgba(242,238,227,.55); background: transparent; cursor: pointer; } +.staff-stage-banner { display: grid; grid-template-columns: 190px 1fr; gap: 24px; padding: 14px clamp(24px, 5vw, 72px); background: var(--orange); font-size: 14px; } +.staff-stage-banner b { font-family: "Jost", sans-serif; font-size: 12px; letter-spacing: .1em; } +.staff-hero { display: grid; grid-template-columns: 1fr auto; padding: clamp(52px, 7vw, 96px) clamp(24px, 5vw, 72px) 54px; } +.staff-hero > p { grid-column: 1 / -1; margin: 0 0 12px; color: var(--orange); font-family: "Jost", sans-serif; font-size: 13px; font-weight: 800; letter-spacing: .14em; } +.staff-hero h1 { margin: 0; font-family: "Jost", sans-serif; font-size: clamp(74px, 9vw, 136px); line-height: .75; } +.staff-hero h1 span { display: inline-block; margin-left: 22px; font-size: clamp(28px, 3.6vw, 54px); font-weight: 540; line-height: .87; } +.staff-close-card { display: grid; min-width: 230px; align-self: end; gap: 8px; padding: 20px 24px; border-left: 8px solid var(--orange); background: var(--cream); } +.staff-close-card b { font-family: "Jost", sans-serif; font-size: 12px; letter-spacing: .14em; } +.staff-toast { position: fixed; right: 26px; bottom: 24px; z-index: 50; display: flex; min-width: 320px; justify-content: space-between; gap: 20px; padding: 16px 18px; border: 0; background: var(--navy); color: var(--cream); box-shadow: 8px 8px 0 var(--orange); cursor: pointer; } +.staff-grid { display: grid; grid-template-columns: 1.18fr .82fr; gap: 24px; padding: 0 clamp(24px, 5vw, 72px); } +.staff-card { padding: clamp(24px, 3vw, 42px); background: var(--cream); } +.staff-card-heading { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 28px; } +.staff-card-heading small { color: var(--orange); font-family: "Jost", sans-serif; font-weight: 800; letter-spacing: .14em; } +.staff-card-heading h2 { margin: 5px 0 0; font-family: "Jost", sans-serif; font-size: 36px; font-weight: 580; letter-spacing: -.035em; } +.staff-card-heading > span { display: grid; width: 46px; height: 46px; place-items: center; border: 1px solid rgba(16,43,78,.34); font-family: "Jost", sans-serif; font-size: 21px; font-weight: 750; } +.visitor-list article { display: grid; grid-template-columns: 52px 1fr auto; gap: 16px; align-items: center; padding: 18px 0; border-top: 1px solid rgba(16,43,78,.19); } +.avatar { display: grid; width: 48px; height: 48px; place-items: center; border-radius: 50%; background: var(--cyan); font-family: "Jost", sans-serif; font-weight: 800; } +.visitor-list article > div:nth-child(2) { display: grid; } +.visitor-list article b { font-size: 19px; } +.visitor-list article span { opacity: .75; } +.visitor-list article small { margin-top: 3px; opacity: .6; } +.visitor-list article button { padding: 9px 11px; border: 1px solid var(--navy); background: transparent; cursor: pointer; } +.empty-state { padding: 30px 0; border-top: 1px solid rgba(16,43,78,.19); font-size: 18px; } +.message-card { background: var(--gray); color: var(--cream); } +.message-card .staff-card-heading h2 { color: var(--cream); } +.live-dot { width: 18px !important; height: 18px !important; border: 0 !important; border-radius: 50%; background: rgba(242,238,227,.35); } +.live-dot.on { background: #7fdb9e; box-shadow: 0 0 0 6px rgba(127,219,158,.16); } +.message-card label { display: grid; gap: 8px; font-family: "Jost", sans-serif; font-size: 12px; font-weight: 760; letter-spacing: .1em; } +.message-card textarea { min-height: 118px; padding: 14px; border: 1px solid rgba(242,238,227,.5); border-radius: 0; background: rgba(16,43,78,.2); color: var(--cream); font-size: 18px; line-height: 1.4; resize: vertical; } +.message-presets { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0 24px; } +.message-presets button { padding: 8px 10px; border: 1px solid rgba(242,238,227,.45); background: transparent; font-size: 13px; cursor: pointer; } +.staff-primary { display: flex; width: 100%; justify-content: space-between; padding: 14px 16px; border: 0; background: var(--orange); color: var(--navy); font-family: "Jost", sans-serif; font-weight: 760; cursor: pointer; } +.staff-text-button { margin-top: 16px; padding: 5px 0; border: 0; border-bottom: 1px solid currentColor; background: transparent; cursor: pointer; } +.training-card { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(280px,.7fr) 1.3fr; gap: 20px 40px; } +.training-card .staff-card-heading { grid-column: 1 / -1; margin-bottom: 4px; } +.staff-search { display: grid; grid-column: 1; gap: 7px; font-family: "Jost", sans-serif; font-size: 12px; font-weight: 750; letter-spacing: .08em; } +.staff-search input { min-height: 50px; padding: 11px 13px; border: 1px solid rgba(16,43,78,.35); background: white; font-family: "Source Sans 3", sans-serif; font-size: 17px; } +.staff-people { grid-column: 1; } +.staff-people button { display: grid; width: 100%; gap: 2px; padding: 14px 12px; border: 0; border-top: 1px solid rgba(16,43,78,.18); background: transparent; text-align: left; cursor: pointer; } +.staff-people button.selected { border-left: 6px solid var(--orange); background: rgba(247,147,30,.11); } +.staff-people span { opacity: .65; } +.training-detail { grid-column: 2; grid-row: 2 / span 2; padding: 24px; background: white; } +.training-detail h3 { margin: 0; font-family: "Jost", sans-serif; font-size: 30px; } +.training-detail > p { margin: 6px 0 22px; opacity: .7; } +.training-detail button { display: flex; width: 100%; justify-content: space-between; padding: 15px 0; border: 0; border-top: 1px solid rgba(16,43,78,.18); background: transparent; cursor: pointer; } +.training-detail button b { color: var(--orange); } +.training-detail button:disabled { color: var(--navy); opacity: .62; cursor: default; } +.training-detail button:disabled b { color: var(--navy); } + +@media (max-width: 900px) { + .join-layout { grid-template-columns: 1fr; } + .join-intro { position: relative; min-height: auto; } + .join-form-plane { padding-bottom: 70px; } + .waiver-action { grid-template-columns: 1fr; } + .staff-app-header { align-items: flex-start; gap: 20px; padding-block: 20px; } + .staff-app-header nav { flex-wrap: wrap; justify-content: flex-end; gap: 12px 18px; } + .staff-hero { grid-template-columns: 1fr; gap: 36px; } + .staff-grid { grid-template-columns: 1fr; } + .training-card { grid-column: 1; } +} + +@media (max-width: 620px) { + .web-header { min-height: 72px; } + .web-header > span { display: none; } + .join-intro { padding-block: 54px; } + .field-grid.two, .id-grid { grid-template-columns: 1fr; gap: 0; } + .join-complete { padding-block: 54px; } + .claim-poster strong { letter-spacing: .07em; } + .staff-app-header { display: grid; } + .staff-app-header nav { justify-content: flex-start; } + .staff-stage-banner { grid-template-columns: 1fr; gap: 5px; } + .staff-hero h1 span { display: block; margin: 18px 0 0; } + .staff-grid { padding-inline: 14px; } + .visitor-list article { grid-template-columns: 44px 1fr; } + .visitor-list article button { grid-column: 2; justify-self: start; } + .training-card { display: block; } + .training-card > * { margin-bottom: 20px; } + .training-detail { padding: 18px; } +} + +.kiosk { + position: relative; + min-height: 100svh; + overflow: hidden; + background: var(--gray); + color: var(--cream); + font-family: "Source Sans 3", sans-serif; + isolation: isolate; +} + +.kiosk::after { + position: absolute; + inset: 0; + z-index: -1; + background-image: linear-gradient(115deg, transparent 0 64%, rgba(255,255,255,.035) 64% 64.08%, transparent 64.08%); + content: ""; + pointer-events: none; +} + +.utility-bar { + position: absolute; + top: 0; + right: 0; + left: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: space-between; + padding: clamp(24px, 3vw, 52px) clamp(28px, 4vw, 72px); +} + +.mini-brand, +.demo-toggle, +.staff-toggle { + border: 0; + background: transparent; + cursor: pointer; + font-family: "Jost", sans-serif; + font-size: clamp(10px, .82vw, 15px); + font-weight: 700; + letter-spacing: .12em; +} + +.mini-brand span { margin-left: .35em; color: var(--orange); } +.utility-right { display: flex; align-items: center; gap: 28px; } +.clock { font-variant-numeric: tabular-nums; font-weight: 600; } +.demo-toggle { padding: 9px 13px 8px; border: 1px solid rgba(242,238,227,.6); } +.staff-toggle { padding: 9px 0 8px; border: 0; border-bottom: 1px solid rgba(242,238,227,.6); } +.demo-toggle.active { color: var(--navy); background: var(--cream); } + +.brand-plane { + position: absolute; + inset: 4% auto 9% 0; + display: grid; + width: 53%; + place-items: center; +} + +.logo-tap-target { + position: relative; + width: min(39vw, 70vh); + aspect-ratio: 1; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + transform: rotate(-8deg); + transition: transform .22s ease; +} + +.logo-tap-target:hover { transform: rotate(-8deg) scale(1.018); } +.logo-tap-target:active { transform: rotate(-8deg) scale(.985); } +.logo-tap-target:focus-visible { outline: 4px solid var(--cream); outline-offset: 10px; } +.logo-tap-target img { display: block; width: 100%; height: 100%; object-fit: contain; } + +.orange-tag { + position: absolute; + right: 1%; + bottom: 9%; + padding: clamp(10px, 1.25vw, 22px) clamp(16px, 2.1vw, 38px); + background: var(--orange); + color: var(--navy); + font-family: "Jost", sans-serif; + font-size: clamp(17px, 2.1vw, 38px); + font-weight: 850; + letter-spacing: .035em; + line-height: 1; + box-shadow: 8px 8px 0 var(--navy); +} + +.interaction-plane { + display: grid; + min-height: 100svh; + margin-left: 52%; + padding: clamp(110px, 12vh, 155px) clamp(48px, 6vw, 112px) clamp(100px, 12vh, 145px) clamp(28px, 3.5vw, 64px); + align-items: center; +} + +.screen-content { width: min(100%, 760px); animation: enter .36s cubic-bezier(.22,.72,.26,1) both; } +@keyframes enter { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } } + +.eyebrow { + margin: 0 0 1.2em; + color: var(--orange); + font-family: "Jost", sans-serif; + font-size: clamp(12px, .95vw, 18px); + font-weight: 750; + letter-spacing: .16em; +} + +.eyebrow.warning { color: var(--cream); } +h1 { + margin: 0; + font-family: "Jost", sans-serif; + font-size: clamp(48px, 5vw, 94px); + font-weight: 540; + letter-spacing: -.05em; + line-height: .94; +} +.lede { max-width: 620px; margin: 1.35em 0 0; font-size: clamp(20px, 1.55vw, 30px); line-height: 1.32; } +.lede.compact { margin-top: .8em; } + +.primary-actions { max-width: 680px; margin-top: clamp(40px, 6vh, 78px); border-top: 1px solid rgba(242,238,227,.48); } +.text-action { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: clamp(18px, 2vh, 29px) 2px; + border: 0; + border-bottom: 1px solid rgba(242,238,227,.48); + background: transparent; + cursor: pointer; + text-align: left; + font-size: clamp(18px, 1.4vw, 27px); +} +.text-action small { display: block; margin-bottom: 5px; color: var(--navy); font-family: "Jost"; font-size: .58em; font-weight: 800; letter-spacing: .12em; } +.text-action > span:last-child, .text-action > span[aria-hidden] { font-size: 1.8em; transition: transform .18s ease; } +.text-action:hover > span:last-child { transform: translateX(8px); } +.tap-hint, .demo-note, .reset-note { margin-top: 24px; color: rgba(242,238,227,.72); font-size: clamp(13px, .9vw, 17px); } +.reader-offline-notice { max-width: 680px; margin: 22px 0 0; padding: 12px 15px; border-left: 5px solid var(--orange); background: rgba(16,43,78,.2); font-size: clamp(15px, 1vw, 19px); font-weight: 650; } + +.arrival-notices { display: grid; gap: 8px; max-width: 680px; margin-top: clamp(24px, 3.5vh, 42px); } +.arrival-alert { + position: relative; + display: grid; + grid-template-columns: clamp(58px, 5vw, 88px) 1fr; + align-items: stretch; + min-height: 90px; + background: var(--orange); + color: var(--navy); + box-shadow: 7px 7px 0 var(--navy); + transform: rotate(-.6deg); +} +.arrival-alert + .arrival-alert { transform: rotate(.45deg); } +.alert-index { display: grid; place-items: center; border-right: 2px solid var(--navy); font: 800 clamp(31px, 3vw, 52px)/1 "Jost"; } +.arrival-alert div { display: flex; flex-direction: column; justify-content: center; padding: 14px 20px; } +.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); } +.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); } +.home-copy.has-alert .tap-hint { display: none; } + +.back { + display: inline-flex; + gap: 10px; + align-items: center; + margin-bottom: clamp(28px, 4vh, 52px); + padding: 8px 0; + border: 0; + background: transparent; + cursor: pointer; + font-weight: 650; + letter-spacing: .04em; +} + +form { max-width: 620px; margin-top: clamp(30px, 4vh, 54px); } +label { display: block; margin-bottom: 8px; font-family: "Jost"; font-size: 13px; font-weight: 750; letter-spacing: .13em; } +input { + display: block; + width: 100%; + padding: clamp(15px, 1.4vw, 24px) 0; + border: 0; + border-bottom: 3px solid var(--cream); + outline: 0; + background: transparent; + color: var(--cream); + font-family: "Jost"; + font-size: clamp(30px, 3vw, 54px); + letter-spacing: .025em; +} +input::placeholder { color: rgba(242,238,227,.34); } +input:focus { border-color: var(--orange); } + +.solid-action, .outline-action, .quiet-action { + min-height: 58px; + padding: 15px 25px; + border: 2px solid transparent; + cursor: pointer; + font-family: "Jost"; + font-size: clamp(16px, 1.15vw, 22px); + font-weight: 700; +} +.solid-action { margin-top: 24px; background: var(--orange); color: var(--navy); } +.solid-action:disabled { opacity: .38; cursor: not-allowed; } +.outline-action { background: transparent; border-color: var(--cream); color: var(--cream); } +.quiet-action { background: transparent; color: var(--cream); text-decoration: underline; text-underline-offset: 5px; } +.align-left { padding-left: 0; } +.button-row { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 36px; } +.button-row .solid-action { margin-top: 0; } +.stacked-actions { display: flex; max-width: 480px; flex-direction: column; align-items: stretch; gap: 12px; margin-top: 36px; } +.stacked-actions .solid-action { margin: 0; } + +.signal { display: flex; height: 74px; align-items: flex-end; gap: 9px; margin-bottom: 32px; } +.signal i { display: block; width: 13px; background: var(--orange); animation: signal .75s infinite alternate ease-in-out; } +.signal i:nth-child(1) { height: 24px; } +.signal i:nth-child(2) { height: 47px; animation-delay: .12s; } +.signal i:nth-child(3) { height: 72px; animation-delay: .24s; } +@keyframes signal { to { opacity: .25; transform: scaleY(.7); transform-origin: bottom; } } + +.choice-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; max-width: 680px; margin-top: 34px; background: rgba(242,238,227,.5); } +.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); } + +.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"; } +.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); } + +.success-plane { + position: absolute; + inset: 0; + z-index: 8; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 30px 110px; + background: var(--orange); + color: var(--navy); + text-align: center; + animation: success-in .5s cubic-bezier(.18,.8,.28,1) both; +} +@keyframes success-in { from { clip-path: inset(100% 0 0 0); } to { clip-path: inset(0 0 0 0); } } +.success-plane .eyebrow { color: var(--navy); } +.success-plane h1 { font-size: clamp(62px, 7vw, 124px); } +.success-plane .lede { margin-top: 20px; } +.success-mark { display: grid; width: 74px; height: 74px; margin-bottom: 24px; place-items: center; border: 3px solid var(--navy); border-radius: 50%; font-size: 40px; font-weight: 800; } +.visit-card { display: flex; align-items: center; gap: 26px; margin: 30px 0 2px; padding: 13px 20px; border-top: 1px solid rgba(16,43,78,.45); border-bottom: 1px solid rgba(16,43,78,.45); font-family: "Jost"; } +.visit-card span { font-size: 12px; font-weight: 800; letter-spacing: .12em; } +.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-alert { + display: grid; + grid-template-columns: 72px minmax(0, 430px); + margin-top: 26px; + border: 3px solid var(--navy); + background: var(--cream); + color: var(--navy); + text-align: left; + transform: rotate(-.7deg); +} +.success-closing-alert > span { display: grid; place-items: center; border-right: 3px solid var(--navy); font: 800 34px/1 "Jost"; } +.success-closing-alert p { margin: 0; padding: 13px 17px; font-size: 17px; line-height: 1.22; } +.success-closing-alert b { display: block; margin-bottom: 3px; font: 800 12px/1 "Jost"; letter-spacing: .13em; } +.success-closing-alert + .visit-card { margin-top: 18px; } + +.demo-panel { + position: fixed; + top: 0; + right: 0; + z-index: 50; + display: flex; + width: min(430px, 92vw); + height: 100svh; + flex-direction: column; + padding: 26px; + background: var(--cream); + color: var(--navy); + box-shadow: -18px 0 60px rgba(16,43,78,.25); + animation: panel-in .25s ease-out both; +} +@keyframes panel-in { from { transform: translateX(100%); } } +.demo-heading { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 3px solid var(--navy); } +.demo-heading small, .demo-heading b { display: block; } +.demo-heading small { margin-bottom: 4px; color: #66717c; font-family: "Jost"; letter-spacing: .12em; } +.demo-heading b { font: 600 28px/1.1 "Jost"; } +.demo-heading button { border: 0; background: transparent; cursor: pointer; font-size: 40px; line-height: .7; } +.demo-panel > button { display: flex; justify-content: space-between; padding: 19px 2px; border: 0; border-bottom: 1px solid rgba(16,43,78,.28); background: transparent; cursor: pointer; text-align: left; font-size: 19px; font-weight: 650; } +.demo-panel > button:hover { color: var(--orange); } +.demo-panel > button.reset { margin-top: auto; justify-content: center; border: 2px solid var(--navy); } +.reader-state { display: flex; align-items: center; gap: 12px; margin: -10px 0 14px; padding: 12px 0 17px; border-bottom: 1px solid rgba(16,43,78,.28); } +.reader-state > span { width: 11px; height: 11px; flex: 0 0 auto; border-radius: 50%; background: #8a949d; } +.reader-state.connected > span { background: #2b8a58; box-shadow: 0 0 0 4px rgba(43,138,88,.14); } +.reader-state.connecting > span { background: var(--orange); } +.reader-state.disconnected > span { background: #a63b32; } +.reader-state small, .reader-state b { display: block; } +.reader-state small { color: #66717c; font: 700 10px/1.2 "Jost"; letter-spacing: .12em; } +.reader-state b { margin-top: 2px; font: 650 17px/1.15 "Jost"; text-transform: capitalize; } + +.staff-panel { + position: fixed; + inset: 0; + z-index: 60; + display: grid; + grid-template-columns: minmax(270px, .72fr) minmax(520px, 1.28fr); + background: var(--cream); + color: var(--navy); + animation: staff-in .28s ease-out both; +} +@keyframes staff-in { from { opacity: 0; } } +.staff-poster { position: relative; display: flex; flex-direction: column; justify-content: center; overflow: hidden; padding: 8vw; background: var(--orange); color: var(--navy); } +.staff-poster::after { position: absolute; right: -22%; bottom: -25%; width: 75%; aspect-ratio: 1; border: clamp(28px, 4vw, 72px) solid var(--cyan); border-radius: 50%; content: ""; } +.staff-poster span { position: absolute; top: -4vh; left: 3vw; font: 900 clamp(180px, 26vw, 480px)/1 "Jost"; opacity: .14; } +.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; } +.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; } +.preset-row button:hover { background: var(--navy); color: var(--cream); } +.staff-panel label:not(.message-switch) { margin-top: 20px; } +.staff-panel label span { color: #66717c; font-weight: 500; letter-spacing: 0; text-transform: none; } +.staff-panel input:not([type="checkbox"]), .staff-panel textarea { + display: block; + width: 100%; + padding: 10px 0; + border: 0; + border-bottom: 2px solid var(--navy); + outline: 0; + background: transparent; + color: var(--navy); + font: 600 clamp(23px, 2vw, 34px)/1.12 "Jost"; + letter-spacing: -.02em; +} +.staff-panel textarea { min-height: 86px; resize: vertical; } +.staff-panel input[type="time"] { width: 230px; } +.staff-panel input:focus, .staff-panel textarea:focus { border-color: var(--orange); } +.field-note { max-width: 530px; margin: 9px 0 0; color: #66717c; font-size: 14px; line-height: 1.35; } +.message-switch { display: flex; align-items: center; gap: 12px; margin-top: 23px; cursor: pointer; font-weight: 650; } +.message-switch input { width: 25px; height: 25px; margin: 0; accent-color: var(--orange); } +.staff-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 8px; } +.staff-actions .quiet-action { color: var(--navy); } + +footer { position: absolute; right: clamp(28px, 4vw, 72px); bottom: clamp(22px, 3vw, 48px); left: clamp(28px, 4vw, 72px); z-index: 10; display: flex; align-items: flex-end; justify-content: space-between; pointer-events: none; } +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%); } + +button:focus-visible { outline: 3px solid var(--orange); outline-offset: 4px; } +.success-plane button:focus-visible { outline-color: var(--navy); } + +@media (max-width: 880px) { + .kiosk { overflow: auto; } + .brand-plane { position: relative; inset: auto; width: 100%; min-height: 46svh; padding: 70px 20px 0; } + .logo-tap-target { width: min(68vw, 42svh); } + .interaction-plane { min-height: auto; margin-left: 0; padding: 35px 28px 150px; } + .home-copy h1 { font-size: clamp(48px, 12vw, 80px); } + .utility-bar { position: fixed; padding: 20px; } + .clock { display: none; } + .onboarding-grid { grid-template-columns: 1fr; } + .qr-placeholder { width: 190px; } + .staff-panel { grid-template-columns: 1fr; overflow-y: auto; } + .staff-poster { display: none; } + .staff-panel > form { overflow: visible; } +} + +@media (max-height: 760px) and (min-width: 881px) { + .interaction-plane { padding-top: 90px; padding-bottom: 82px; } + .primary-actions { margin-top: 30px; } + .text-action { padding-block: 14px; } + .tap-hint { margin-top: 14px; } + .arrival-notices { margin-top: 16px; } + .arrival-alert { min-height: 72px; } + .home-copy.has-alert .primary-actions { margin-top: 18px; } + .home-copy.has-alert .text-action { padding-block: 9px; } + .success-closing-alert { margin-top: 14px; } + footer { bottom: 18px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; } +} diff --git a/Kiosk-v2/app/join/embed/page.tsx b/Kiosk-v2/app/join/embed/page.tsx new file mode 100644 index 0000000..d26cb2e --- /dev/null +++ b/Kiosk-v2/app/join/embed/page.tsx @@ -0,0 +1,35 @@ +import Image from "next/image"; + +export default function JoinEmbedPage() { + return ( +
+ + +
+

FIRST VISIT?

+

Make before
you arrive.

+

+ Create your Sandbox account and sign the liability waiver. Connect your UC San Diego ID when you get here. +

+ +
    +
  1. 01Account
  2. +
  3. 02Waiver
  4. +
  5. 03One tap
  6. +
+ + + Start your account + + Opens the secure form in a new tab · about 3 minutes +
+ +
+ SCRIPPS SANDBOX MAKERSPACE + UC SAN DIEGO +
+
+ ); +} diff --git a/Kiosk-v2/app/join/page.tsx b/Kiosk-v2/app/join/page.tsx new file mode 100644 index 0000000..2de80e6 --- /dev/null +++ b/Kiosk-v2/app/join/page.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { FormEvent, useState } from "react"; + +type RegistrationResult = { + applicationId: string; + displayName: string; + claimCode: string; + claimCodeExpiresAt: string; + waiverUrl: string; +}; + +const roles = [ + ["student", "UC San Diego student"], + ["staff", "UC San Diego staff"], + ["faculty", "UC San Diego faculty"], + ["postdoc", "Postdoctoral scholar"], + ["visitor", "Visitor or external affiliate"], + ["other", "Other"], +]; + +export default function JoinPage() { + const [result, setResult] = useState(null); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [copied, setCopied] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + setError(""); + setSubmitting(true); + const form = new FormData(event.currentTarget); + const payload = Object.fromEntries(form.entries()); + + try { + const response = await fetch("/api/registrations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...payload, consent: form.get("consent") === "on" }), + }); + const data = (await response.json()) as RegistrationResult & { error?: string }; + if (!response.ok) throw new Error(data.error || "Registration could not be saved."); + setResult(data); + window.scrollTo({ top: 0, behavior: "smooth" }); + } catch (submissionError) { + setError(submissionError instanceof Error ? submissionError.message : "Registration could not be saved."); + } finally { + setSubmitting(false); + } + } + + async function copyCode() { + if (!result) return; + await navigator.clipboard.writeText(result.claimCode); + setCopied(true); + window.setTimeout(() => setCopied(false), 1800); + } + + return ( +
+
+ SCRIPPS SANDBOX MAKERSPACE + FIRST VISIT +
+ + {!result ? ( +
+
+

BEFORE YOU MAKE

+

Create your
Sandbox account.

+

Tell us who you are, then complete the liability waiver. When you arrive, one quick card connection is all that remains.

+
    +
  1. 01Create your account
  2. +
  3. 02Sign the liability waiver
  4. +
  5. 03Connect your UC San Diego ID
  6. +
+
+ +
+
+
+ YOUR NAME +
+ + +
+ +
+ +
+ YOUR CONNECTION TO UC SAN DIEGO + + +
+ + +
+
+ +
+ CONTACT + + +
+ + + + {error &&

{error}

} + +
+
+
+ ) : ( +
+

ACCOUNT STARTED

+

One signature.
Then one tap.

+

Thanks, {result.displayName}. Your account will remain pending until your waiver appears in Waiver Signatures SIO.

+ +
+ YOUR CARD-CONNECTION CODE + {result.claimCode} + +

Save this code. At the Sandbox, tap your ID and enter it to connect the card. Staff can also connect or replace a card after checking your physical ID.

+
+ +
+
NEXTComplete the Scripps Sandbox liability waiver using the same name, email, and ID number.
+ Open liability waiver +
+ +

Do not enter your card number online. Card identifiers are connected only while the physical card is present at the makerspace.

+
+ )} +
+ ); +} diff --git a/Kiosk-v2/app/layout.tsx b/Kiosk-v2/app/layout.tsx new file mode 100644 index 0000000..c2dd48b --- /dev/null +++ b/Kiosk-v2/app/layout.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Scripps Sandbox Makerspace", + description: "Check in, create a Sandbox account, and manage makerspace operations.", + openGraph: { + title: "Scripps Sandbox Makerspace", + description: "Check-in, first-visit registration, and staff operations for the Scripps Sandbox.", + images: ["/scripps-sandbox-prototype-og.png"], + }, +}; + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + {children} + + ); +} diff --git a/Kiosk-v2/app/page.tsx b/Kiosk-v2/app/page.tsx new file mode 100644 index 0000000..a67a00e --- /dev/null +++ b/Kiosk-v2/app/page.tsx @@ -0,0 +1,553 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; + +type Screen = + | "home" + | "reading" + | "success" + | "pid" + | "not-found" + | "unknown-card" + | "link-card" + | "reader-error" + | "profile" + | "profile-detail" + | "new-here"; + +type Announcement = { + active: boolean; + heading: string; + body: string; + closingTime: string; +}; + +type ScannerStatus = "demo" | "connecting" | "connected" | "disconnected"; + +type ScannerEvent = { + type: "card_read"; + read_at: string; + sequence: number; +}; + +const emptyAnnouncement: Announcement = { + active: false, + heading: "CHECK IN WITH STAFF", + body: "Please check in with me upstairs before starting work.", + closingTime: "", +}; + +const affiliations = [ + "Undergraduate", + "Graduate student", + "Postdoc", + "Faculty", + "Staff", + "Visitor", +]; + +function Arrow({ direction = "right" }: { direction?: "right" | "left" }) { + return ; +} + +export default function Home() { + const [screen, setScreen] = useState("home"); + const [demoOpen, setDemoOpen] = useState(false); + const [pid, setPid] = useState(""); + const [affiliation, setAffiliation] = useState(""); + const [detail, setDetail] = useState(""); + const [claimCode, setClaimCode] = useState(""); + const [countdown, setCountdown] = useState(8); + const [now, setNow] = useState(null); + const [announcement, setAnnouncement] = useState(emptyAnnouncement); + const [announcementDraft, setAnnouncementDraft] = useState(emptyAnnouncement); + const [staffOpen, setStaffOpen] = useState(false); + const [demoClosingMinutes, setDemoClosingMinutes] = useState(null); + const [scannerStatus, setScannerStatus] = useState("demo"); + const screenRef = useRef("home"); + + useEffect(() => { + screenRef.current = screen; + }, [screen]); + + useEffect(() => { + setNow(new Date()); + const saved = window.localStorage.getItem("sandbox-kiosk-announcement"); + if (saved) { + try { + const parsed = { ...emptyAnnouncement, ...JSON.parse(saved) }; + setAnnouncement(parsed); + setAnnouncementDraft(parsed); + } catch { + window.localStorage.removeItem("sandbox-kiosk-announcement"); + } + } + const clock = window.setInterval(() => setNow(new Date()), 30_000); + return () => window.clearInterval(clock); + }, []); + + useEffect(() => { + const configuredUrl = process.env.NEXT_PUBLIC_SCANNER_WS_URL?.trim(); + const isLocalKiosk = ["localhost", "127.0.0.1"].includes(window.location.hostname); + const scannerUrl = configuredUrl || (isLocalKiosk ? "ws://127.0.0.1:8765/ws" : ""); + + if (!scannerUrl) { + return; + } + + let socket: WebSocket | null = null; + let reconnectTimer: number | null = null; + let stopped = false; + + function connect() { + if (stopped) return; + setScannerStatus("connecting"); + socket = new WebSocket(scannerUrl); + + socket.addEventListener("open", () => setScannerStatus("connected")); + socket.addEventListener("message", (message) => { + try { + const event = JSON.parse(message.data) as ScannerEvent; + if (event.type === "card_read" && screenRef.current === "home") { + setScreen("reading"); + } + } catch { + // Ignore malformed local bridge messages; the bridge will keep listening. + } + }); + socket.addEventListener("close", () => { + if (stopped) return; + setScannerStatus("disconnected"); + reconnectTimer = window.setTimeout(connect, 3_000); + }); + socket.addEventListener("error", () => socket?.close()); + } + + connect(); + return () => { + stopped = true; + if (reconnectTimer !== null) window.clearTimeout(reconnectTimer); + socket?.close(); + }; + }, []); + + useEffect(() => { + if (screen !== "reading") return; + const timer = window.setTimeout(() => setScreen("success"), 950); + return () => window.clearTimeout(timer); + }, [screen]); + + useEffect(() => { + if (screen !== "success") return; + setCountdown(8); + const interval = window.setInterval(() => { + setCountdown((value) => { + if (value <= 1) { + window.clearInterval(interval); + setScreen("home"); + return 8; + } + return value - 1; + }); + }, 1000); + return () => window.clearInterval(interval); + }, [screen]); + + const timeLabel = useMemo( + () => + now?.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) ?? "", + [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 closingLabel = minutesUntilClose === 0 + ? "We’re closing now" + : minutesUntilClose !== null + ? `We close in ${minutesUntilClose} minute${minutesUntilClose === 1 ? "" : "s"}` + : ""; + + function openStaffEditor() { + setAnnouncementDraft(announcement); + setDemoOpen(false); + setStaffOpen(true); + } + + function saveAnnouncement(event: FormEvent) { + event.preventDefault(); + const next = { + ...announcementDraft, + heading: announcementDraft.heading.trim() || emptyAnnouncement.heading, + body: announcementDraft.body.trim() || emptyAnnouncement.body, + }; + setAnnouncement(next); + window.localStorage.setItem("sandbox-kiosk-announcement", JSON.stringify(next)); + setStaffOpen(false); + setScreen("home"); + } + + function clearAnnouncement() { + const next = { ...announcement, active: false }; + setAnnouncement(next); + setAnnouncementDraft(next); + window.localStorage.setItem("sandbox-kiosk-announcement", JSON.stringify(next)); + } + + function reset() { + setScreen("home"); + setPid(""); + setAffiliation(""); + setDetail(""); + setClaimCode(""); + setDemoOpen(false); + } + + function submitPid(event: FormEvent) { + event.preventDefault(); + const normalized = pid.trim().toUpperCase(); + if (normalized === "A12345678" || normalized === "12345678") { + setScreen("reading"); + } else if (normalized === "A87654321" || normalized === "87654321") { + setScreen("profile"); + } else { + setScreen("not-found"); + } + } + + const showBrand = screen !== "success"; + + return ( +
+
+ +
+ {timeLabel} + + +
+
+ + {showBrand && ( +
+ +
+ )} + +
+ {screen === "home" && ( +
+

SCRIPPS SANDBOX MAKERSPACE

+

Tap your
UC San Diego ID.

+

Hold your card near the reader to check in.

+ {scannerStatus === "disconnected" && ( +

Card reader unavailable. Use your PID or employee ID below.

+ )} + {(announcement.active || minutesUntilClose !== null) && ( +
+ {announcement.active && ( +
+ +
+

{announcement.heading}

+ {announcement.body} +
+
+ )} + {minutesUntilClose !== null && ( +
+ +
+

CLOSING SOON

+ {closingLabel}. Please plan your work accordingly. +
+
+ )} +
+ )} +
+ + +
+

For this mockup, tap the large Sandbox mark—or open DEMO.

+
+ )} + + {screen === "reading" && ( +
+ +

CARD DETECTED

+

Reading
your ID…

+

Keep your card near the reader.

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

CHECK IN WITHOUT A CARD

+

Enter your ID.

+

Use your PID or UC San Diego employee ID.

+
+ + setPid(event.target.value)} + autoCapitalize="characters" + autoComplete="off" + placeholder="A12345678" + autoFocus + /> + +
+

Try A12345678 for a complete profile or A87654321 for a missing-info flow.

+
+ )} + + {screen === "not-found" && ( +
+ +

WE COULDN’T FIND THAT ID

+

Check the number
and try again.

+

If this is your first visit, choose “Get started.”

+
+ + +
+
+ )} + + {screen === "unknown-card" && ( +
+

CARD NOT CONNECTED

+

We don’t know
this card yet.

+

Already registered online? Use your card-connection code. Existing members can use their PID or employee ID.

+
+ + + + +
+
+ )} + + {screen === "link-card" && ( +
+ +

CONNECT THIS CARD

+

Enter your
connection code.

+

It’s the eight-character code shown after you created your Sandbox account online.

+
{ event.preventDefault(); setScreen("reading"); }}> + + setClaimCode(event.target.value.toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 8))} + autoCapitalize="characters" + autoComplete="off" + placeholder="7MK4Q2HP" + autoFocus + /> + +
+ +
+ )} + + {screen === "reader-error" && ( +
+

TRY THAT AGAIN

+

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) => ( + + ))} +
+ +
+ )} + + {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 + /> + +
+ +
+ )} + + {screen === "new-here" && ( +
+ +

WELCOME TO THE SANDBOX

+

Make something
unexpected.

+
+
+

Scan to create your profile, sign the waiver, and see orientation times.

+
    +
  1. 01Create your profile
  2. +
  3. 02Complete orientation
  4. +
  5. 03Start making
  6. +
+
+
+ {Array.from({ length: 64 }, (_, index) => )} +
+
+ Open the registration form on this screen + +
+ )} +
+ + {screen === "success" && ( +
+ +

CHECK-IN COMPLETE

+

Welcome back,
Maya.

+

You’re checked in to the Scripps Sandbox.

+ {minutesUntilClose !== null && ( +
+ {String(minutesUntilClose).padStart(2, "0")} +

CLOSING SOON{closingLabel}. Please choose a project you can stop safely before then.

+
+ )} +
+ TODAY + {timeLabel} + VISIT 24 +
+ +

Returning home in {countdown} seconds

+
+ )} + + {demoOpen && ( + + )} + + {staffOpen && ( +