Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 84 additions & 31 deletions ESP-32/src/scanner.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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");
}


Expand All @@ -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++)
Expand All @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions Kiosk-v2/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
2 changes: 2 additions & 0 deletions Kiosk-v2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 5 additions & 5 deletions Kiosk-v2/app/api/registrations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
id,
makeDisplayName,
normalizeEmail,
normalizeIdentifier,
normalizeRegistrationIdentifier,
REGISTRATION_CONSENT_VERSION,
WAIVER_POWERFORM_URL,
} from "@/lib/registration";
Expand All @@ -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);
Expand Down Expand Up @@ -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 });
Expand All @@ -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 });
Expand Down
16 changes: 13 additions & 3 deletions Kiosk-v2/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -461,13 +461,18 @@ input:focus { border-color: var(--orange); }
.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"; }
.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; }

Expand Down Expand Up @@ -626,3 +631,8 @@ button:focus-visible { outline: 3px solid var(--orange); outline-offset: 4px; }
@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); }
.waiver-required-content .button-row { margin-top: 28px; }
.waiver-qr { justify-self: end; text-align: center; }
@media (max-width: 900px) { .waiver-required-content { grid-template-columns: 1fr 250px; gap: 24px; } .waiver-required-content .qr-code-large { width: 250px; } }
18 changes: 16 additions & 2 deletions Kiosk-v2/app/join/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLFormElement>) {
event.preventDefault();
Expand Down Expand Up @@ -108,13 +109,26 @@ export default function JoinPage() {
</label>
<div className="field-grid id-grid">
<label>ID type
<select name="identifierType" required defaultValue="pid">
<select
name="identifierType"
required
value={identifierType}
onChange={(event) => setIdentifierType(event.target.value)}
>
<option value="pid">Student PID</option>
<option value="tsn">Triton Student Number (TSN)</option>
<option value="employee_id">Employee ID</option>
<option value="other">Other UCSD ID</option>
</select>
</label>
<label>ID number<input name="identifierValue" autoCapitalize="characters" autoComplete="off" placeholder="A12345678" required /></label>
<label>ID number<input
name="identifierValue"
autoCapitalize="characters"
autoComplete="off"
inputMode={identifierType === "tsn" || identifierType === "employee_id" ? "numeric" : "text"}
placeholder={identifierType === "tsn" ? "200010746" : identifierType === "employee_id" ? "000023" : "A12345678"}
required
/></label>
</div>
</fieldset>

Expand Down
Loading