-
- 1
+
Lead Scanner
@@ -13,120 +12,95 @@
-
-
-
- Scanning for: {{ selectedSponsor.name }}
- Tap to change sponsor
-
-
-
+
+
-
-
-
- No sponsor selected
- Select a sponsor before scanning
-
- Select
-
+
-
-
- Recent Scans
-
-
-
-
-
-
-
- Uh oh..
- Camera Permissions Not Granted
-
-
- Looks like you did not grant this application permission to access your camera.
-
- You can resolve this by opening
- Settings > Privacy & Security > Camera > PyCon US 2026
- Settings > Apps > PyCon US 2026 > Permissions > Camera
- And enabling camera permssions for this app.
-
-
-
-
-
+
+
+
+ Camera Permission Required
+ Open Settings > Privacy > Camera > PyCon USSettings > Apps > PyCon US > Camera and enable access.
+
+
+
+
+
+
+
+
No scans yet
+
Tap "Start Scanner" to begin scanning badges
+
+
+
+ 0" lines="full" class="scan-list">
+
+
-
-
-
-
-
-
- {{(scan.status === 'captured')? scan.first_name : scan.access_code}}
-
- Scanned {{scan.scanned_at|dateAgo}}
- Captured {{scan.scanned_at|dateAgo}}
- Pending capture when online...
- Invalid code.
-
-
-
-
-
- Note
-
-
-
-
-
+ {{ (scan.status === 'captured') ? scan.first_name : scan.access_code }}
+
+
+ {{ scan.scanned_at | dateAgo }}
+ Pending
+ Failed
+
+
-
-
-
-
-
-
-
-
-
- {{(last_scan.status === 'captured')? last_scan.first_name : last_scan.access_code}}
-
-
-
-
-
-
-
- Note
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ {{ (last_scan.status === 'captured') ? last_scan.first_name : last_scan.access_code }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Simulate Scan
+
+
+
+ Start Scanner
+
+
+
+ Stop Scanner
+
+
diff --git a/src/app/pages/map/map.scss b/src/app/pages/map/map.scss
index 95a9e486..f2bacdeb 100644
--- a/src/app/pages/map/map.scss
+++ b/src/app/pages/map/map.scss
@@ -1,20 +1,264 @@
-.footer-buttons {
+ion-header {
+ background: linear-gradient(180deg, #3B3EA9 0%, #3B3EA9 100%);
+ &::after { display: none; }
+}
+
+ion-toolbar {
+ --background: transparent;
+ --border-color: transparent;
+ --color: #ffffff;
+}
+
+ion-toolbar ion-menu-button {
+ --color: #ffffff;
+}
+
+/* Sponsor banner */
+.sponsor-banner {
+ display: flex;
+ align-items: center;
+ background: #3B3EA9;
+ color: #fff;
+
+ &.sponsor-banner-warning {
+ background: linear-gradient(135deg, #e6a817, #c48800);
+ }
+}
+
+.sponsor-banner-main {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 8px 12px 16px;
+ flex: 1;
+ min-width: 0;
+ cursor: pointer;
+}
+
+.sponsor-banner-filter {
display: flex;
align-items: center;
justify-content: center;
+ padding: 12px 14px;
+ border-left: 1px solid rgba(255,255,255,0.15);
+ cursor: pointer;
+
+ ion-icon {
+ font-size: 20px;
+ opacity: 0.5;
+ transition: opacity 0.2s;
+
+ &.active {
+ opacity: 1;
+ color: #FFD779;
+ }
+ }
+}
+
+.sponsor-banner-logo {
+ width: 36px;
+ height: 36px;
+ object-fit: contain;
+ background: #fff;
+ border-radius: 8px;
+ padding: 4px;
+ flex-shrink: 0;
+}
+
+.sponsor-banner-icon {
+ font-size: 28px;
+ flex-shrink: 0;
+ opacity: 0.8;
+
+ &.warning {
+ color: #fff;
+ opacity: 1;
+ }
+}
+
+.sponsor-banner-text {
+ flex: 1;
+ min-width: 0;
+
+ strong {
+ display: block;
+ font-size: 0.95rem;
+ font-weight: 600;
+ }
+
+ span {
+ font-size: 0.75rem;
+ opacity: 0.7;
+ }
+}
+
+.sponsor-banner-chevron {
+ font-size: 20px;
+ opacity: 0.5;
+}
+
+/* Permission card */
+.permission-card {
+ margin: 16px;
+ border-radius: 12px;
+
+ h3 {
+ margin: 0 0 8px;
+ font-weight: 700;
+ }
+
+ code {
+ background: rgba(255,255,255,0.2);
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ }
+}
+
+
+/* Empty state */
+.empty-state {
+ display: flex;
flex-direction: column;
- height: 100%;
+ align-items: center;
+ justify-content: center;
+ padding: 64px 32px;
+ text-align: center;
+
+ .empty-icon {
+ font-size: 56px;
+ color: var(--ion-color-step-300, #ccc);
+ margin-bottom: 16px;
+ }
+
+ h3 {
+ margin: 0 0 6px;
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--ion-text-color);
+ }
+
+ p {
+ margin: 0;
+ font-size: 0.85rem;
+ color: var(--ion-color-medium);
+ }
+}
+
+/* Scan list */
+.scan-list {
+ padding-top: 0;
+}
+
+.scan-item {
+ --padding-start: 12px;
+ --inner-padding-end: 4px;
+}
+
+.scan-status-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ margin-inline-end: 12px;
+ background: var(--ion-color-medium);
+
+ &.captured { background: var(--ion-color-success); }
+ &.pending { background: var(--ion-color-warning); }
+ &.failed { background: var(--ion-color-danger); }
+}
+
+.scan-name {
+ font-size: 1rem;
+ font-weight: 600;
+ margin: 0;
+}
+
+.scan-sponsor {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ font-size: 0.7rem;
+ color: #5833E9;
+ font-weight: 500;
+ margin: 2px 0 0;
+
+ ion-icon { font-size: 0.7rem; }
+}
+
+.scan-time {
+ font-size: 0.75rem;
+ color: var(--ion-color-medium);
+ margin: 2px 0 0;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.scan-badge {
+ font-size: 0.6rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ padding: 1px 6px;
+ border-radius: 4px;
+
+ &.pending {
+ background: rgba(var(--ion-color-warning-rgb), 0.15);
+ color: var(--ion-color-warning-shade);
+ }
+
+ &.failed {
+ background: rgba(var(--ion-color-danger-rgb), 0.15);
+ color: var(--ion-color-danger);
+ }
+}
+
+/* Last scan bar */
+.last-scan-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 16px;
+ background: var(--ion-color-step-50, #f8f8f8);
+ border-top: 1px solid var(--ion-color-step-100, #eee);
+}
+
+.last-scan-info {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ ion-icon { font-size: 22px; }
+}
+
+.last-scan-name {
+ font-size: 0.95rem;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: 4px;
}
-.footer-button {
- position: absolute;
+.last-scan-spinner {
+ width: 16px;
+ height: 16px;
}
-.footer-toolbar {
- padding-top: 1em;
- padding-bottom: 1em;
+
+/* Action buttons */
+.scanner-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ padding: 4px 16px 8px;
+
+ .scan-btn-main {
+ --border-radius: 14px;
+ font-weight: 700;
+ font-size: 1rem;
+ letter-spacing: 0.02em;
+ }
}
-.header-info {
- font-size: .6em;
- padding-bottom: .5em;
+.scan-note-icon {
+ font-size: 18px;
}
diff --git a/src/app/pages/map/map.ts b/src/app/pages/map/map.ts
index ff12f861..3f597307 100644
--- a/src/app/pages/map/map.ts
+++ b/src/app/pages/map/map.ts
@@ -9,6 +9,7 @@ import { PyConAPI } from '../../providers/pycon-api';
import { UserData } from '../../providers/user-data';
import { LiveUpdateService } from '../../providers/live-update.service';
import { LeadNoteModalComponent } from '../../lead-note-modal/lead-note-modal.component';
+import { environment } from '../../../environments/environment';
@Component({
@@ -32,6 +33,8 @@ export class MapPage implements OnInit, OnDestroy {
isStaffScanner: boolean = false;
selectedSponsor: any = null;
sponsorList: any[] = [];
+ isDev: boolean = !environment.production;
+ filterBySponsor: boolean = false;
constructor(
public confData: ConferenceData,
@@ -47,7 +50,15 @@ export class MapPage implements OnInit, OnDestroy {
) {}
sortScans() {
- return this.scan_presentation.sort(function(a, b) {
+ let scans = [...this.scan_presentation];
+ if (this.isStaffScanner && this.filterBySponsor && this.selectedSponsor) {
+ const filterName = this.selectedSponsor.name;
+ scans = scans.filter(s => {
+ const name = (s.sponsor_name || '').replace(/^"|"$/g, '');
+ return name === filterName;
+ });
+ }
+ return scans.sort(function(a, b) {
var x = new Date(a.scanned_at);
var y = new Date(b.scanned_at);
return ((x > y) ? -1 : ((x < y) ? 1 : 0));
@@ -56,13 +67,19 @@ export class MapPage implements OnInit, OnDestroy {
refresh_presentation = async () => {
var allScans = [];
- this.storage.forEach((value, key, index) => {
+ const selectedId = this.selectedSponsor ? String(this.selectedSponsor.id) : null;
+ const keys = await this.storage.keys();
+ for (const key of keys) {
+ const value = await this.storage.get(key);
+ if (!value) continue;
if (key.startsWith("pending-scan-")) {
allScans.push({
"status": "pending",
"scanned_at": value.scannedAt,
"access_code": value.scanData.split(":")[0],
"note": value.note,
+ "sponsor_name": value.sponsorName ? String(value.sponsorName).replace(/^"|"$/g, '') : null,
+ "sponsor_id": value.sponsorId || null,
})
} else if (key.startsWith("synced-scan-")) {
allScans.push({
@@ -71,6 +88,8 @@ export class MapPage implements OnInit, OnDestroy {
"access_code": value.scanData.split(":")[0],
"first_name": value.data.first_name,
"note": value.note,
+ "sponsor_name": value.sponsorName ? String(value.sponsorName).replace(/^"|"$/g, '') : null,
+ "sponsor_id": value.sponsorId || null,
})
} else if (key.startsWith("failed-scan-")) {
allScans.push({
@@ -78,10 +97,17 @@ export class MapPage implements OnInit, OnDestroy {
"scanned_at": value.scannedAt,
"access_code": value.scanData.split(":")[0],
"note": value.note,
+ "sponsor_name": value.sponsorName ? String(value.sponsorName).replace(/^"|"$/g, '') : null,
+ "sponsor_id": value.sponsorId || null,
})
}
- });
+ }
this.scan_presentation = allScans;
+ this.detectorRef.detectChanges();
+ }
+
+ toggleSponsorFilter() {
+ this.detectorRef.detectChanges();
}
openNoteModal = async (accessCode, scan) => {
@@ -233,6 +259,29 @@ export class MapPage implements OnInit, OnDestroy {
this.content_visibility = '';
}
+ async simulateScan() {
+ const fakeNames = [
+ 'Guido van Rossum', 'Carol Willing', 'Brett Cannon', 'Mariatta Wijaya',
+ 'Pablo Galindo', 'Dustin Ingram', 'Sumana Harihareswara', 'Ned Batchelder',
+ 'Lynn Root', 'Russell Keith-Magee', 'Hynek Schlawack', 'Łukasz Langa',
+ 'Deb Nicholson', 'Thomas Wouters', 'Barry Warsaw', 'Savannah Ostrowski',
+ ];
+ const name = fakeNames[Math.floor(Math.random() * fakeNames.length)];
+ const fakeCode = 'SIM' + Math.floor(Math.random() * 99999);
+ const scanDate = new Date();
+
+ const sponsorName = this.selectedSponsor?.name ? String(this.selectedSponsor.name).replace(/^"|"$/g, '') : null;
+ await this.storage.set('synced-scan-' + fakeCode, {
+ scanData: fakeCode + ':SIMULATED',
+ scannedAt: scanDate.toISOString(),
+ sponsorName: sponsorName,
+ sponsorId: this.selectedSponsor ? String(this.selectedSponsor.id) : null,
+ data: { first_name: name, captured: true, captured_date: scanDate.toISOString() },
+ });
+ this.refresh_presentation();
+ this.detectorRef.detectChanges();
+ }
+
ionViewWillLeave() {
this.stopScan();
}
@@ -251,7 +300,15 @@ export class MapPage implements OnInit, OnDestroy {
const hasDoorCheck = await this.userData.checkHasDoorCheck();
if (!hasLeadRetrieval && hasDoorCheck) {
this.isStaffScanner = true;
- this.showSponsorSelector();
+ // Restore previously selected sponsor from storage
+ const savedSponsorId = await this.storage.get('staff-sponsor-id');
+ const savedSponsorName = await this.storage.get('staff-sponsor-name');
+ const savedSponsorLogo = await this.storage.get('staff-sponsor-logo');
+ if (savedSponsorId && savedSponsorName) {
+ this.selectedSponsor = { id: savedSponsorId, name: String(savedSponsorName).replace(/^"|"$/g, ''), logo_url: savedSponsorLogo || null };
+ } else {
+ this.showSponsorSelector();
+ }
}
}
@@ -328,6 +385,9 @@ export class MapPage implements OnInit, OnDestroy {
if (sponsorId) {
this.selectedSponsor = this.sponsorList.find(s => String(s.id) === sponsorId);
this.pycon.setStaffSponsorId(sponsorId);
+ this.storage.set('staff-sponsor-name', this.selectedSponsor?.name || null);
+ this.storage.set('staff-sponsor-logo', this.selectedSponsor?.logo_url || null);
+ this.refresh_presentation();
}
}
}
diff --git a/src/app/pages/schedule/schedule.html b/src/app/pages/schedule/schedule.html
index 0b5dd195..d54063ed 100644
--- a/src/app/pages/schedule/schedule.html
+++ b/src/app/pages/schedule/schedule.html
@@ -94,9 +94,31 @@
-
- Schedule not loaded, if this persists check your network.
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Couldn't load schedule
+
Check your network connection and try again. The schedule needs an internet connection to load for the first time.
+
+
+ Try Again
+
+
diff --git a/src/app/pages/schedule/schedule.scss b/src/app/pages/schedule/schedule.scss
index 646fe51f..1a827efd 100644
--- a/src/app/pages/schedule/schedule.scss
+++ b/src/app/pages/schedule/schedule.scss
@@ -228,3 +228,58 @@ $tracks: (
cursor: default;
}
}
+
+/*
+ * Error state — schedule not loaded
+ */
+.error-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding: 48px 32px;
+ min-height: 50vh;
+}
+
+.error-graphic {
+ position: relative;
+ margin-bottom: 24px;
+
+ .python-logo-error {
+ width: 100px;
+ height: 100px;
+ opacity: 0.15;
+ }
+
+ .error-cloud-icon {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 48px;
+ color: #3B3EA9;
+ }
+}
+
+.error-title {
+ font-size: 1.3rem;
+ font-weight: 700;
+ margin: 0 0 8px;
+ color: var(--ion-text-color);
+}
+
+.error-message {
+ font-size: 0.9rem;
+ line-height: 1.5;
+ color: var(--ion-color-medium);
+ margin: 0 0 24px;
+ max-width: 300px;
+}
+
+.error-retry-btn {
+ --border-radius: 12px;
+ --border-color: #3B3EA9;
+ --color: #3B3EA9;
+ font-weight: 600;
+}
diff --git a/src/app/pages/tabs-page/tabs-page-routing.module.ts b/src/app/pages/tabs-page/tabs-page-routing.module.ts
index c1f4b0ca..4c61df1e 100644
--- a/src/app/pages/tabs-page/tabs-page-routing.module.ts
+++ b/src/app/pages/tabs-page/tabs-page-routing.module.ts
@@ -226,6 +226,15 @@ const routes: Routes = [
path: 'login',
loadChildren: () => import('../login/login.module').then(m => m.LoginModule)
},
+ {
+ path: 'dev-tools',
+ children: [
+ {
+ path: '',
+ loadChildren: () => import('../dev-tools/dev-tools.module').then(m => m.DevToolsPageModule)
+ }
+ ]
+ },
{
path: '',
redirectTo: '/app/tabs/schedule',
diff --git a/src/app/providers/pycon-api.ts b/src/app/providers/pycon-api.ts
index c05e56bc..d6579027 100644
--- a/src/app/providers/pycon-api.ts
+++ b/src/app/providers/pycon-api.ts
@@ -386,9 +386,11 @@ export class PyConAPI {
return;
} else {
const scanDate = new Date();
+ const sponsorId = await this.storage.get('staff-sponsor-id');
+ const sponsorName = await this.storage.get('staff-sponsor-name');
return this.storage.set(
'pending-scan-' + accessCode,
- {scanData: scanData, scannedAt: scanDate.toISOString()}
+ {scanData: scanData, scannedAt: scanDate.toISOString(), sponsorId: sponsorId, sponsorName: sponsorName}
).then(() => {
console.log('Scanned ' + accessCode);
this.syncScan(accessCode);
diff --git a/src/environments/environment.dev.ts b/src/environments/environment.dev.ts
index 66e5aa6d..9e4a8dc8 100644
--- a/src/environments/environment.dev.ts
+++ b/src/environments/environment.dev.ts
@@ -1,7 +1,7 @@
export const environment = {
name: 'development',
production: false,
- baseUrl: 'http://localhost:8000',
+ baseUrl: 'http://127.0.0.1:8000',
storageKey: '__pycon_us_mobile_development_2026',
timezone: 'America/Los_Angeles',
utcOffset: -7,