diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 0bf3ea6c..1b09cede 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -50,13 +50,13 @@ export class AppComponent implements OnInit { { title: 'Conference Info', url: '/app/tabs/about-pycon', icon: 'information-circle-outline' }, { title: 'Code of Conduct', url: '/app/tabs/coc', icon: 'shield-checkmark-outline' }, { title: 'Wi-Fi', url: '/app/tabs/wifi', icon: 'wifi-outline' }, - { title: 'Venues & Hours', url: '/app/tabs/venues-hours', icon: 'location-outline' }, + { title: 'Venue & Hours', url: '/app/tabs/venues-hours', icon: 'location-outline' }, { title: 'Session Types', url: '/app/tabs/session-types', icon: 'pricetags-outline' }, ] infoPages = [ { title: 'About The PSF', url: '/app/tabs/about-psf', icon: 'logo-python' }, { title: 'Social', url: '/app/tabs/social-media', icon: 'chatbubbles-outline' }, - { title: 'Conference Map', url: '/app/tabs/conference-map', icon: 'map-outline' }, + { title: 'Conference Maps', url: '/app/tabs/conference-map', icon: 'map-outline' }, { title: 'Help & Safety', url: '/app/tabs/help', icon: 'help-circle-outline' }, ] nickname = null; diff --git a/src/app/expo-hall-map/expo-hall-map.component.ts b/src/app/expo-hall-map/expo-hall-map.component.ts index fb8464bb..04c3f7e7 100644 --- a/src/app/expo-hall-map/expo-hall-map.component.ts +++ b/src/app/expo-hall-map/expo-hall-map.component.ts @@ -1,8 +1,5 @@ -import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; -import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; +import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'; import { IonSearchbar, LoadingController } from '@ionic/angular'; -import { Subscription } from 'rxjs'; -import { filter } from 'rxjs/operators'; import { ConferenceData } from '../providers/conference-data'; @@ -25,7 +22,7 @@ export interface BoothData { templateUrl: './expo-hall-map.component.html', styleUrls: ['./expo-hall-map.component.scss'], }) -export class ExpoHallMapComponent implements OnInit, AfterViewInit, OnDestroy { +export class ExpoHallMapComponent implements OnInit, AfterViewInit { @ViewChild('searchBar') searchBar!: IonSearchbar; @ViewChild('pinchZoom', { read: ElementRef }) pinchZoomEl?: ElementRef; @ViewChild('pinchZoom') pinchZoomCmp?: { @@ -126,8 +123,6 @@ export class ExpoHallMapComponent implements OnInit, AfterViewInit, OnDestroy { constructor( private confData: ConferenceData, private loadingCtrl: LoadingController, - private route: ActivatedRoute, - private router: Router, ) {} ngOnInit() { @@ -144,9 +139,19 @@ export class ExpoHallMapComponent implements OnInit, AfterViewInit, OnDestroy { // popup instead of underneath it. private static readonly DEEPLINK_POPUP_OFFSET_PX = 60; - private querySub?: Subscription; - private routerSub?: Subscription; - private lastZoomedBoothId: string | null = null; + // Booth id queued before pinch-zoom finishes initializing. ngAfterViewInit + // polls for the IvyPinch instance, and the parent ConferenceMapPage may + // call zoomToBoothId() before that polling completes (especially on cold + // entry to the tab when image + pinch-zoom are still hydrating). We hold + // the id here and apply it the moment pinch-zoom is ready. + private pendingBoothId: string | null = null; + private pinchReady = false; + // Token incremented per zoom request so async work (image-load wait) + // belonging to a superseded request can short-circuit instead of + // clobbering the latest zoom — protects against the rare race where the + // user taps two booth pills in fast succession before the floor-plan + // image has finished loading. + private zoomToken = 0; ngAfterViewInit() { // @ciag/ngx-pinch-zoom hardcodes defaultMaxScale=3 and only auto-derives @@ -161,31 +166,12 @@ export class ExpoHallMapComponent implements OnInit, AfterViewInit, OnDestroy { const inner = this.pinchZoomCmp?.pinchZoom; if (inner) { inner.maxScale = 25; - // React to the current ?booth=, and to any future change while - // the component stays mounted (e.g. user pops back to a different - // sponsor and taps that booth's pill — Angular reuses this instance - // and only the query param changes). - this.querySub = this.route.queryParamMap.subscribe(params => { - this.maybeZoomToQueryBooth(params.get('booth')); - }); - // Belt-and-braces: Ionic page caching keeps this component alive - // across nav, and ActivatedRoute.queryParamMap doesn't always - // re-emit when the cached page is re-entered with a new query - // string (sponsor → booth → back to sponsors → other sponsor → - // booth would otherwise leave us pinned to the first booth). On - // every navigation that lands on /expo-hall, parse the live URL - // (NOT route.snapshot, which is set on route activation and stays - // stale when Ionic just shows a cached page) and zoom if the - // booth id changed. - this.routerSub = this.router.events - .pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd)) - .subscribe(e => { - const url = e.urlAfterRedirects || this.router.url; - if (!url.includes('/expo-hall')) return; - const tree = this.router.parseUrl(url); - const wantId = tree.queryParamMap.get('booth'); - this.maybeZoomToQueryBooth(wantId); - }); + this.pinchReady = true; + if (this.pendingBoothId) { + const id = this.pendingBoothId; + this.pendingBoothId = null; + this.zoomToBoothId(id); + } return; } if (Date.now() - start < 2000) { @@ -195,21 +181,36 @@ export class ExpoHallMapComponent implements OnInit, AfterViewInit, OnDestroy { tick(); } - ngOnDestroy() { - this.querySub?.unsubscribe(); - this.routerSub?.unsubscribe(); - } - - private maybeZoomToQueryBooth(wantId: string | null) { - if (!wantId) return; - if (wantId === this.lastZoomedBoothId) return; // already there - const booth = this.booths.find(b => b.id === String(wantId)); + /** + * Public entry point used by ConferenceMapPage to request a zoom-to-booth. + * Driven by Ionic's ionViewWillEnter on the parent page so it fires + * reliably on first nav, cached re-entry with a different ?booth=, the + * same ?booth= twice in a row (re-centers if the user has panned away), + * tab-switch return, and cold-start deeplinks. + * + * No `lastZoomedBoothId` guard: if the parent calls us, it's because the + * user explicitly asked to see this booth — we should always re-center, + * even if the id matches the previous zoom (the user may have panned). + */ + zoomToBoothId(boothId: string | null | undefined) { + if (!boothId) return; + const id = String(boothId); + if (!this.pinchReady) { + this.pendingBoothId = id; + return; + } + const booth = this.booths.find(b => b.id === id); if (!booth) return; - this.lastZoomedBoothId = wantId; - requestAnimationFrame(() => this.zoomToBooth(booth)); + const token = ++this.zoomToken; + requestAnimationFrame(() => { + // Bail if a newer request superseded us between the rAF schedule + // and its callback (extremely unlikely but cheap to guard). + if (token !== this.zoomToken) return; + this.zoomToBooth(booth, token); + }); } - private async zoomToBooth(booth: BoothData) { + private async zoomToBooth(booth: BoothData, token?: number) { const inner = this.pinchZoomCmp?.pinchZoom; const host = this.pinchZoomEl?.nativeElement; if (!inner || !host || !host.offsetWidth) return; @@ -228,6 +229,10 @@ export class ExpoHallMapComponent implements OnInit, AfterViewInit, OnDestroy { }); } + // If a newer zoomToBoothId() arrived while we were waiting on the image, + // abandon this stale request so it doesn't clobber the latest target. + if (token !== undefined && token !== this.zoomToken) return; + // We bypass IvyPinch.setZoom() because it always runs centeringImage() → // limitPanY() afterwards, and that clamp assumes the image fills the // host. Our floor plan PNG is wider than tall (W:H ≈ 1.41:1) inside a diff --git a/src/app/location-map/room-locations.ts b/src/app/location-map/room-locations.ts index f1d083dc..9781bcab 100644 --- a/src/app/location-map/room-locations.ts +++ b/src/app/location-map/room-locations.ts @@ -92,7 +92,6 @@ const ROOM_LOCATIONS_RAW: Record = { '103a': concourse('Room 103A', 39, 50, 'Talk Track / Security Track'), '103b': concourse('Room 103B', 43, 50, 'Talk Track / Security Track'), '103c': concourse('Room 103C', 46, 50, 'Talk Track / Security Track'), - '103ab': concourse('Room 103AB', 41, 50, 'PyLadies Lunch'), '103abc': concourse('Room 103', 43, 50, 'Talk Track / Security Track'), '104a': concourse('Room 104A', 72, 38, 'Talk Track'), '104b': concourse('Room 104B', 80, 35, 'Talk Track'), diff --git a/src/app/pages/about-psf/about-psf.page.html b/src/app/pages/about-psf/about-psf.page.html index 3972f780..9e0cf58b 100644 --- a/src/app/pages/about-psf/about-psf.page.html +++ b/src/app/pages/about-psf/about-psf.page.html @@ -92,7 +92,7 @@

Python Software Foundation

Learn how you can help the PSF and the greater Python community!

- + Sponsors @@ -113,7 +113,7 @@

Python Software Foundation

Organizations partnering with the PSF to support the Python community worldwide.

- + Volunteer at PyCon US @@ -189,6 +189,14 @@

@ThePSF

PSF on X / Twitter

+ + + + +

Python Software Foundation

+

PSF on LinkedIn

+
+
diff --git a/src/app/pages/about-pycon/about-pycon.page.html b/src/app/pages/about-pycon/about-pycon.page.html index 9c50e92d..2c87bd87 100644 --- a/src/app/pages/about-pycon/about-pycon.page.html +++ b/src/app/pages/about-pycon/about-pycon.page.html @@ -11,7 +11,7 @@

PyCon US 2026

-

Long Beach, CA • May 14-18

+

Long Beach, CA • May 13-19

diff --git a/src/app/pages/conference-map/conference-map.page.html b/src/app/pages/conference-map/conference-map.page.html index 3547e596..5c829ce1 100644 --- a/src/app/pages/conference-map/conference-map.page.html +++ b/src/app/pages/conference-map/conference-map.page.html @@ -21,6 +21,9 @@ Expo Hall + + Job Fair + @@ -64,4 +67,22 @@ + + +
+ Job Fair & Community Showcase floor plan +
+
+
+ diff --git a/src/app/pages/conference-map/conference-map.page.scss b/src/app/pages/conference-map/conference-map.page.scss index 62043a38..695d6181 100644 --- a/src/app/pages/conference-map/conference-map.page.scss +++ b/src/app/pages/conference-map/conference-map.page.scss @@ -67,3 +67,23 @@ font-size: 13px; color: var(--ion-color-medium); } + +.job-fair-zoom { + width: 100%; + height: 100%; + background: #ffffff; +} + +.job-fair-canvas { + position: relative; + width: 100%; + display: block; +} + +.job-fair-canvas img { + width: 100%; + height: auto; + display: block; + -webkit-user-drag: none; + user-select: none; +} diff --git a/src/app/pages/conference-map/conference-map.page.ts b/src/app/pages/conference-map/conference-map.page.ts index 000e8966..6b51cd91 100644 --- a/src/app/pages/conference-map/conference-map.page.ts +++ b/src/app/pages/conference-map/conference-map.page.ts @@ -6,7 +6,7 @@ import { ExpoHallMapComponent } from '../../expo-hall-map/expo-hall-map.componen import { FloorPlanModalComponent } from '../../floor-plan-modal/floor-plan-modal.component'; import { LiveUpdateService } from '../../providers/live-update.service'; -type MapView = 'floor-plans' | '3d-tour' | 'expo-hall'; +type MapView = 'floor-plans' | '3d-tour' | 'expo-hall' | 'job-fair'; interface FloorPlan { id: string; @@ -85,6 +85,28 @@ export class ConferenceMapPage implements OnInit { } } + // Single source of truth for "go zoom to booth X". Driven by Ionic's + // ionViewWillEnter, which (unlike Angular's ActivatedRoute observables) + // is GUARANTEED to fire on every entry — first nav, cached re-entry with + // a new ?booth= value, tab-switch return, and cold-start deeplink. This + // replaces the previous brittle dual-subscription (queryParamMap + + // router.events) inside the child map component, which could miss + // re-entries when Angular treated the activation as a no-op. + ionViewWillEnter() { + const boothId = this.route.snapshot.queryParamMap.get('booth'); + if (!boothId) return; + // Force the segment to expo-hall when arriving via ?booth=, even if the + // user had switched the segment to floor-plans before leaving the tab. + // Without this, the *ngIf gate keeps the map component unmounted and + // the zoom request would be dropped. + this.mapView = 'expo-hall'; + // The expo map *ngIf may not have rendered the child yet on this tick + // (cold entry, or just-flipped segment). Defer to next macrotask so + // @ViewChild has resolved before we call into it; the component + // queues the request internally if pinch-zoom isn't ready yet. + setTimeout(() => this.expoMap?.zoomToBoothId(boothId), 0); + } + toggleExpoSearch() { this.expoMap?.toggleSearch(); } diff --git a/src/app/pages/help/help.page.html b/src/app/pages/help/help.page.html index 80a2b03f..7f87dd6a 100644 --- a/src/app/pages/help/help.page.html +++ b/src/app/pages/help/help.page.html @@ -34,7 +34,7 @@

Help & Safety

-

Full Conference Map

+

Full Conference Maps

Browse all floor plans and the expo hall

diff --git a/src/app/pages/job-listings/job-listings.module.ts b/src/app/pages/job-listings/job-listings.module.ts index cb8b29dc..39c62649 100644 --- a/src/app/pages/job-listings/job-listings.module.ts +++ b/src/app/pages/job-listings/job-listings.module.ts @@ -7,13 +7,15 @@ import { IonicModule } from '@ionic/angular'; import { JobListingsPageRoutingModule } from './job-listings-routing.module'; import { JobListingsPage } from './job-listings.page'; +import { FloorPlanModalModule } from '../../floor-plan-modal/floor-plan-modal.module'; @NgModule({ imports: [ CommonModule, FormsModule, IonicModule, - JobListingsPageRoutingModule + JobListingsPageRoutingModule, + FloorPlanModalModule ], declarations: [JobListingsPage] }) diff --git a/src/app/pages/job-listings/job-listings.page.html b/src/app/pages/job-listings/job-listings.page.html index 88dbf4e4..eb331dbe 100644 --- a/src/app/pages/job-listings/job-listings.page.html +++ b/src/app/pages/job-listings/job-listings.page.html @@ -22,6 +22,14 @@

Job Listings

+
+ +
+

No job listings have been posted yet. Check back closer to the conference!

diff --git a/src/app/pages/job-listings/job-listings.page.scss b/src/app/pages/job-listings/job-listings.page.scss index 07db8c61..eb6e3da0 100644 --- a/src/app/pages/job-listings/job-listings.page.scss +++ b/src/app/pages/job-listings/job-listings.page.scss @@ -54,6 +54,77 @@ ion-title { margin-top: 12px; } +.job-fair-floor-plan-wrapper { + padding: 12px 16px 0; +} + +.job-fair-floor-plan-card { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + height: 80px; + padding: 10px 14px 10px 10px; + margin: 0 0 16px; + background: rgba(59, 62, 169, 0.08); + border: 1px solid rgba(59, 62, 169, 0.18); + border-radius: 12px; + text-align: left; + cursor: pointer; + transition: background 0.15s ease; + + &:active { + background: rgba(59, 62, 169, 0.16); + } + + .job-fair-floor-plan-thumb { + width: 60px; + height: 60px; + border-radius: 8px; + object-fit: cover; + flex-shrink: 0; + box-shadow: 0 1px 4px rgba(16, 17, 54, 0.15); + } + + .job-fair-floor-plan-label { + flex: 1; + font-size: 0.95rem; + font-weight: 600; + color: #3B3EA9; + line-height: 1.3; + } + + .job-fair-floor-plan-chevron { + font-size: 1.1rem; + color: #3B3EA9; + flex-shrink: 0; + } +} + +:host-context(.dark-theme) .job-fair-floor-plan-card { + background: rgba(139, 143, 212, 0.12); + border-color: rgba(139, 143, 212, 0.28); + + &:active { + background: rgba(139, 143, 212, 0.22); + } + + .job-fair-floor-plan-label, + .job-fair-floor-plan-chevron { + color: #b4b7e8; + } +} + +:host-context(.high-contrast-theme) .job-fair-floor-plan-card { + background: var(--ion-card-background); + border: 2px solid var(--ion-color-primary); + + .job-fair-floor-plan-label, + .job-fair-floor-plan-chevron { + color: var(--ion-color-primary); + } +} + .listings-container { padding: 0 16px; } diff --git a/src/app/pages/job-listings/job-listings.page.ts b/src/app/pages/job-listings/job-listings.page.ts index 01402102..9a663a39 100644 --- a/src/app/pages/job-listings/job-listings.page.ts +++ b/src/app/pages/job-listings/job-listings.page.ts @@ -1,8 +1,9 @@ import { Component, OnInit, ChangeDetectorRef, ViewChild } from '@angular/core'; -import { IonContent } from '@ionic/angular'; +import { IonContent, ModalController } from '@ionic/angular'; import { ConferenceData } from '../../providers/conference-data'; import { LiveUpdateService } from '../../providers/live-update.service'; +import { FloorPlanModalComponent } from '../../floor-plan-modal/floor-plan-modal.component'; @Component({ selector: 'app-job-listings', @@ -20,8 +21,21 @@ export class JobListingsPage implements OnInit { private confData: ConferenceData, private changeDetection: ChangeDetectorRef, public liveUpdateService: LiveUpdateService, + private modalCtrl: ModalController, ) {} + async openJobFairFloorPlan() { + const modal = await this.modalCtrl.create({ + component: FloorPlanModalComponent, + componentProps: { + title: 'Job Fair & Community Showcase', + imageSrc: 'assets/img/floor-plans/job-fair.jpg', + altText: 'Job Fair & Community Showcase floor plan', + }, + }); + await modal.present(); + } + onScroll(event: any) { this.showTitle = event.detail.scrollTop > 100; } diff --git a/src/app/pages/room-detail/room-detail.page.html b/src/app/pages/room-detail/room-detail.page.html index 8f711e69..c7ab0750 100644 --- a/src/app/pages/room-detail/room-detail.page.html +++ b/src/app/pages/room-detail/room-detail.page.html @@ -54,6 +54,7 @@

{{ session.name }}

{{ name }},

{{ session.track }} + Pre-registration required diff --git a/src/app/pages/session-detail/session-detail.html b/src/app/pages/session-detail/session-detail.html index 39931fd3..3b28ef1f 100644 --- a/src/app/pages/session-detail/session-detail.html +++ b/src/app/pages/session-detail/session-detail.html @@ -52,6 +52,12 @@

{{session.name}}

+ +
diff --git a/src/app/pages/session-detail/session-detail.module.ts b/src/app/pages/session-detail/session-detail.module.ts index b112e54b..1867b9fd 100644 --- a/src/app/pages/session-detail/session-detail.module.ts +++ b/src/app/pages/session-detail/session-detail.module.ts @@ -5,13 +5,15 @@ import { SessionDetailPage } from './session-detail'; import { SessionDetailPageRoutingModule } from './session-detail-routing.module'; import { IonicModule } from '@ionic/angular'; import { PipesModule } from '../../pipes/pipes.module'; +import { FloorPlanModalModule } from '../../floor-plan-modal/floor-plan-modal.module'; @NgModule({ imports: [ CommonModule, IonicModule, SessionDetailPageRoutingModule, - PipesModule + PipesModule, + FloorPlanModalModule ], declarations: [ SessionDetailPage, diff --git a/src/app/pages/session-detail/session-detail.scss b/src/app/pages/session-detail/session-detail.scss index 985acfab..34e525b0 100644 --- a/src/app/pages/session-detail/session-detail.scss +++ b/src/app/pages/session-detail/session-detail.scss @@ -198,6 +198,73 @@ ion-toolbar ion-button { padding: 16px 16px 0; } +.job-fair-floor-plan-card { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + height: 80px; + padding: 10px 14px 10px 10px; + margin: 0 0 16px; + background: rgba(59, 62, 169, 0.08); + border: 1px solid rgba(59, 62, 169, 0.18); + border-radius: 12px; + text-align: left; + cursor: pointer; + transition: background 0.15s ease; + + &:active { + background: rgba(59, 62, 169, 0.16); + } + + .job-fair-floor-plan-thumb { + width: 60px; + height: 60px; + border-radius: 8px; + object-fit: cover; + flex-shrink: 0; + box-shadow: 0 1px 4px rgba(16, 17, 54, 0.15); + } + + .job-fair-floor-plan-label { + flex: 1; + font-size: 0.95rem; + font-weight: 600; + color: #3B3EA9; + line-height: 1.3; + } + + .job-fair-floor-plan-chevron { + font-size: 1.1rem; + color: #3B3EA9; + flex-shrink: 0; + } +} + +:host-context(.dark-theme) .job-fair-floor-plan-card { + background: rgba(139, 143, 212, 0.12); + border-color: rgba(139, 143, 212, 0.28); + + &:active { + background: rgba(139, 143, 212, 0.22); + } + + .job-fair-floor-plan-label, + .job-fair-floor-plan-chevron { + color: #b4b7e8; + } +} + +:host-context(.high-contrast-theme) .job-fair-floor-plan-card { + background: var(--ion-card-background); + border: 2px solid var(--ion-color-primary); + + .job-fair-floor-plan-label, + .job-fair-floor-plan-chevron { + color: var(--ion-color-primary); + } +} + .session-speakers-section { margin-bottom: 20px; diff --git a/src/app/pages/session-detail/session-detail.ts b/src/app/pages/session-detail/session-detail.ts index 74753b8a..21af3e3f 100644 --- a/src/app/pages/session-detail/session-detail.ts +++ b/src/app/pages/session-detail/session-detail.ts @@ -1,6 +1,7 @@ import { Component, OnDestroy } from '@angular/core'; import { InAppBrowser, DefaultWebViewOptions } from '@capacitor/inappbrowser'; import { CapacitorCalendar } from '@ebarooni/capacitor-calendar'; +import { ModalController, Platform, ToastController } from '@ionic/angular'; import { ConferenceData } from '../../providers/conference-data'; import { ActivatedRoute } from '@angular/router'; import { UserData } from '../../providers/user-data'; @@ -8,6 +9,7 @@ import { LiveUpdateService } from '../../providers/live-update.service'; import { Location } from '@angular/common'; import { Subscription } from 'rxjs'; import { environment } from '../../../environments/environment'; +import { FloorPlanModalComponent } from '../../floor-plan-modal/floor-plan-modal.component'; interface KeynoteAbstract { match: string[]; @@ -27,6 +29,7 @@ export class SessionDetailPage implements OnDestroy { isOpenSpace = false; isKeynote = false; isPosters = false; + isJobFair = false; posters: any[] = []; keynoteData: any[] = []; keynoteAbstract: KeynoteAbstract | null = null; @@ -94,6 +97,9 @@ export class SessionDetailPage implements OnDestroy { private route: ActivatedRoute, public liveUpdateService: LiveUpdateService, private location: Location, + private platform: Platform, + private toastCtrl: ToastController, + private modalCtrl: ModalController, ) { } ionViewWillEnter() { @@ -129,6 +135,14 @@ export class SessionDetailPage implements OnDestroy { // individual poster session-detail pages show their own description. this.isPosters = this.session?.track === 'Poster' && this.session?.name === 'Posters'; this.posters = this.isPosters ? (data.posters || []) : []; + const jobFairHaystack = [ + this.session?.name, + this.session?.content_override, + ] + .filter((v): v is string => typeof v === 'string') + .join(' ') + .toLowerCase(); + this.isJobFair = jobFairHaystack.includes('job fair'); this.keynoteData = []; this.keynoteAbstract = null; @@ -172,20 +186,85 @@ export class SessionDetailPage implements OnDestroy { async addToCalendar() { if (!this.session) return; - await CapacitorCalendar.requestWriteOnlyCalendarAccess(); + // Validate session timestamps up front; the native plugin and the Google + // Calendar URL fallback both produce broken results if these are NaN. + const startMs = new Date(this.session.startUtc).getTime(); + const endMs = new Date(this.session.endUtc).getTime(); + if ( + !this.session.startUtc || + !this.session.endUtc || + Number.isNaN(startMs) || + Number.isNaN(endMs) + ) { + await this.presentToast('Couldn’t read this session’s time'); + return; + } const speakers = this.session.speakers?.map((s: any) => s.name).join(', ') || ''; - const description = speakers ? `Speakers: ${speakers}` : ''; - - await CapacitorCalendar.createEventWithPrompt({ - title: this.session.name, - location: this.session.location || '', - description, - startDate: new Date(this.session.startUtc).getTime(), - endDate: new Date(this.session.endUtc).getTime(), - isAllDay: false, - url: environment.baseUrl + '/2026/schedule/presentation/' + this.session.id + '/', + const sessionUrl = environment.baseUrl + '/2026/schedule/presentation/' + this.session.id + '/'; + const descriptionParts: string[] = []; + if (speakers) descriptionParts.push(`Speakers: ${speakers}`); + descriptionParts.push(sessionUrl); + const description = descriptionParts.join('\n\n'); + + // On web/PWA the @ebarooni/capacitor-calendar plugin is a no-op and + // silently resolves, so jump straight to the Google Calendar fallback. + if (!this.platform.is('hybrid')) { + this.openGoogleCalendarFallback(startMs, endMs, description, sessionUrl); + return; + } + + try { + await CapacitorCalendar.requestWriteOnlyCalendarAccess(); + await CapacitorCalendar.createEventWithPrompt({ + title: this.session.name, + location: this.session.location || '', + description, + startDate: startMs, + endDate: endMs, + isAllDay: false, + url: sessionUrl, + }); + } catch (err) { + console.error('Native calendar prompt failed, falling back to web', err); + await this.presentToast('Opening Google Calendar instead…'); + this.openGoogleCalendarFallback(startMs, endMs, description, sessionUrl); + } + } + + private openGoogleCalendarFallback( + startMs: number, + endMs: number, + description: string, + _sessionUrl: string, + ) { + const dates = `${this.formatGoogleCalendarDate(new Date(startMs))}/${this.formatGoogleCalendarDate(new Date(endMs))}`; + const params = [ + 'action=TEMPLATE', + `text=${encodeURIComponent(this.session.name || '')}`, + `dates=${dates}`, + `details=${encodeURIComponent(description)}`, + `location=${encodeURIComponent(this.session.location || '')}`, + ].join('&'); + const url = `https://calendar.google.com/calendar/render?${params}`; + window.open(url, '_system'); + } + + private formatGoogleCalendarDate(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return ( + `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` + + `T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z` + ); + } + + private async presentToast(message: string) { + const toast = await this.toastCtrl.create({ + message, + duration: 2500, + position: 'bottom', }); + await toast.present(); } onDescriptionClick(event: Event) { @@ -207,4 +286,16 @@ export class SessionDetailPage implements OnDestroy { shareSession() { console.log('Clicked share session'); } + + async openJobFairFloorPlan() { + const modal = await this.modalCtrl.create({ + component: FloorPlanModalComponent, + componentProps: { + title: 'Job Fair & Community Showcase', + imageSrc: 'assets/img/floor-plans/job-fair.jpg', + altText: 'Job Fair & Community Showcase floor plan', + }, + }); + await modal.present(); + } } diff --git a/src/app/pages/session-types/session-types.page.html b/src/app/pages/session-types/session-types.page.html index d29f8393..a163e321 100644 --- a/src/app/pages/session-types/session-types.page.html +++ b/src/app/pages/session-types/session-types.page.html @@ -21,7 +21,7 @@

Session Types

Talk
Standard Presentations -

30-minute talks on a wide range of Python topics

+

30-45 minute talks on a wide range of Python topics

@@ -101,7 +101,7 @@

Session Types

Open Space
Unconference -

Attendee-organized sessions — sign up on the board!

+

Attendee-organized sessions — sign up on the digital board!

diff --git a/src/app/pages/social-media/social-media.page.html b/src/app/pages/social-media/social-media.page.html index 88490abc..8ea90de3 100644 --- a/src/app/pages/social-media/social-media.page.html +++ b/src/app/pages/social-media/social-media.page.html @@ -38,11 +38,11 @@

Mastodon

- +

Bluesky

-

@pycon.org

+

@pycon.us

diff --git a/src/app/pages/sponsor-detail/sponsor-detail.ts b/src/app/pages/sponsor-detail/sponsor-detail.ts index 44c8f562..71519a4e 100644 --- a/src/app/pages/sponsor-detail/sponsor-detail.ts +++ b/src/app/pages/sponsor-detail/sponsor-detail.ts @@ -41,18 +41,31 @@ export class SponsorDetailPage { ionViewWillEnter() { const sponsorId = this.route.snapshot.paramMap.get('sponsorId'); + // CRITICAL: reset sponsor + jobListings on every entry. Ionic + Angular's + // IonicRouteStrategy reuses this component instance across different + // :sponsorId values (the routeConfig is identical), so class fields + // persist from the previous sponsor. The old `if (this.sponsor) break` + // outer-loop guard would short-circuit on a stale match from the prior + // sponsor — if the new sponsor's level was iterated AFTER the previous + // sponsor's level, we'd never reach it and the page would render the + // OLD sponsor's data (logo, name, AND booth number). Tapping the booth + // pill then navigates to the wrong booth. + this.sponsor = undefined; + this.jobListings = []; + this.dataProvider.load().subscribe((data: any) => { + let found: any = null; if (data && data.conference && data.conference.sponsors) { - for (const [level, sponsors] of Object.entries(data.conference.sponsors)) { - for (const [index, sponsor] of Object.entries(sponsors as any[])) { - if (sponsor && String(sponsor.name).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '') === sponsorId) { - this.sponsor = sponsor; - break; + outer: for (const sponsors of Object.values(data.conference.sponsors)) { + for (const sponsor of sponsors as any[]) { + if (sponsor && this.getSponsorSlug(String(sponsor.name)) === sponsorId) { + found = sponsor; + break outer; } } - if (this.sponsor) break; } } + this.sponsor = found; // Find job listings associated with this sponsor const listings = data['job-listings'] || []; diff --git a/src/app/pages/sprints/sprints.page.html b/src/app/pages/sprints/sprints.page.html index b172b1af..f89d407f 100644 --- a/src/app/pages/sprints/sprints.page.html +++ b/src/app/pages/sprints/sprints.page.html @@ -18,6 +18,18 @@

Development Sprints

Contribute to open source after the conference

+
+

+ Development Sprints are dedicated days for collaborative coding on open + source projects. Join existing teams, work on your own project, or just + hang out — everyone is welcome regardless of experience level. +

+ + + Visit Sprints Page + +
+ diff --git a/src/app/pages/sprints/sprints.page.ts b/src/app/pages/sprints/sprints.page.ts index f26776f7..cfd223f2 100644 --- a/src/app/pages/sprints/sprints.page.ts +++ b/src/app/pages/sprints/sprints.page.ts @@ -75,6 +75,10 @@ export class SprintsPage implements OnInit { window.open(`${environment.baseUrl}/2026/sprints/project/submit/`, '_system', 'location=yes'); } + openSprintsPage() { + window.open('https://us.pycon.org/2026/sprints/', '_system', 'location=yes'); + } + ngOnInit() { this.loadSprints(); this.userData.isLoggedIn().then((resp) => { diff --git a/src/app/pages/venues-hours/venues-hours.page.html b/src/app/pages/venues-hours/venues-hours.page.html index 11a8652d..ad52bcf6 100644 --- a/src/app/pages/venues-hours/venues-hours.page.html +++ b/src/app/pages/venues-hours/venues-hours.page.html @@ -3,7 +3,7 @@ - Venues & Hours + Venue & Hours @@ -11,9 +11,9 @@
-

Venues & Hours

+

Venue & Hours

-

Long Beach Convention Center

+

Long Beach Convention & Entertainment Center

300 E Ocean Blvd, Long Beach, CA 90802

@@ -31,6 +31,18 @@

Venues & Hours

[pinYPct]="section.location.pinYPct"> + + + + Sponsored by + {{ section.sponsor.name }} + + + @@ -46,7 +58,7 @@

Get Directions

-

Conference Map

+

Conference Maps

Indoor venue layout

diff --git a/src/app/pages/venues-hours/venues-hours.page.scss b/src/app/pages/venues-hours/venues-hours.page.scss index eafb4aa3..3b1332cc 100644 --- a/src/app/pages/venues-hours/venues-hours.page.scss +++ b/src/app/pages/venues-hours/venues-hours.page.scss @@ -62,6 +62,56 @@ ion-title { margin: 8px 0 16px; } +.venues-section-sponsor { + display: flex; + align-items: center; + gap: 12px; + margin: -4px 0 20px; + padding: 10px 12px; + border-radius: 10px; + background: rgba(88, 51, 233, 0.06); + text-decoration: none; + color: inherit; + + .venues-section-sponsor-logo { + width: 44px; + height: 44px; + object-fit: contain; + flex-shrink: 0; + background: #fff; + border-radius: 8px; + padding: 4px; + } + + .venues-section-sponsor-text { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + line-height: 1.2; + } + + .venues-section-sponsor-eyebrow { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ion-color-medium, #6c6c80); + margin-bottom: 2px; + } + + .venues-section-sponsor-name { + font-size: 0.95rem; + font-weight: 700; + color: #5833E9; + } + + .venues-section-sponsor-chevron { + color: var(--ion-color-medium, #6c6c80); + font-size: 1rem; + flex-shrink: 0; + } +} + .venues-content { h2 { font-size: 1.15rem; diff --git a/src/app/pages/venues-hours/venues-hours.page.ts b/src/app/pages/venues-hours/venues-hours.page.ts index 89c12680..9511674b 100644 --- a/src/app/pages/venues-hours/venues-hours.page.ts +++ b/src/app/pages/venues-hours/venues-hours.page.ts @@ -1,13 +1,21 @@ import { Component, OnInit, ChangeDetectorRef, ViewChild } from '@angular/core'; import { IonContent } from '@ionic/angular'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { forkJoin } from 'rxjs'; import { ConferenceData } from '../../providers/conference-data'; import { LiveUpdateService } from '../../providers/live-update.service'; import { VENUE_LOCATIONS, VenueLocation } from '../../location-map/venue-locations'; +interface VenueSectionSponsor { + name: string; + logo_url: string; + slug: string; +} + interface VenueSection { html: SafeHtml; location?: VenueLocation; + sponsor?: VenueSectionSponsor; } @Component({ @@ -37,14 +45,43 @@ export class VenuesHoursPage implements OnInit { } ngOnInit() { - this.confData.getContent().subscribe((content: any) => { + forkJoin({ + content: this.confData.getContent(), + sponsors: this.confData.getSponsors(), + }).subscribe(({ content, sponsors }: { content: any; sponsors: any }) => { this.content = content; - this.sections = this.buildSections(content?.['venues-hours'] || ''); + const flatSponsors = this.flattenSponsors(sponsors); + this.sections = this.buildSections(content?.['venues-hours'] || '', flatSponsors); this.changeDetection.detectChanges(); }); } - private buildSections(html: string): VenueSection[] { + private flattenSponsors(sponsors: any): any[] { + if (!sponsors) return []; + const flat: any[] = []; + Object.values(sponsors).forEach((level: any) => { + if (Array.isArray(level)) { + flat.push(...level); + } + }); + return flat; + } + + private getSponsorSlug(name: string): string { + return name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); + } + + private findSponsorByName(sponsors: any[], needle: string): VenueSectionSponsor | undefined { + const match = sponsors.find((s) => (s?.name || '').toLowerCase().includes(needle)); + if (!match) return undefined; + return { + name: match.name, + logo_url: match.logo_url, + slug: this.getSponsorSlug(match.name), + }; + } + + private buildSections(html: string, sponsors: any[] = []): VenueSection[] { if (!html) return []; const parser = new DOMParser(); @@ -61,10 +98,17 @@ export class VenuesHoursPage implements OnInit { if (current.innerHTML.trim()) { const heading = current.querySelector('h1, h2, h3, h4, h5, h6'); const text = (heading?.textContent || '').toLowerCase(); - sections.push({ + const section: VenueSection = { html: this.sanitizer.bypassSecurityTrustHtml(current.innerHTML), location: this.matchLocation(text), - }); + }; + if (text.includes('quiet room')) { + const sponsor = this.findSponsorByName(sponsors, 'google'); + if (sponsor) { + section.sponsor = sponsor; + } + } + sections.push(section); } }; diff --git a/src/app/pages/wifi/wifi.page.html b/src/app/pages/wifi/wifi.page.html index e6fe70ca..8f41e2ee 100644 --- a/src/app/pages/wifi/wifi.page.html +++ b/src/app/pages/wifi/wifi.page.html @@ -39,10 +39,25 @@

Connect to Wi-Fi

+ + + +
+ Wi-Fi sponsored by + {{ wifiSponsor.name }} +
+
+
+
-

Available throughout the Long Beach Convention Center

+

Available throughout the Long Beach Convention & Entertainment Center

diff --git a/src/app/pages/wifi/wifi.page.scss b/src/app/pages/wifi/wifi.page.scss index 875d01d2..32f144d5 100644 --- a/src/app/pages/wifi/wifi.page.scss +++ b/src/app/pages/wifi/wifi.page.scss @@ -95,6 +95,55 @@ ion-title { margin-top: 16px; } +.wifi-sponsor-card { + margin: 16px 20px 0; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(16, 17, 54, 0.08); + + .wifi-sponsor-content { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 16px; + } + + .wifi-sponsor-logo { + flex: 0 0 auto; + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + } + + .wifi-sponsor-text { + display: flex; + flex-direction: column; + line-height: 1.2; + } + + .wifi-sponsor-label { + font-size: 0.72rem; + font-weight: 600; + color: var(--ion-color-medium); + text-transform: uppercase; + letter-spacing: 0.06em; + } + + .wifi-sponsor-name { + font-size: 1rem; + font-weight: 700; + color: #5833E9; + margin-top: 2px; + } +} + .wifi-note { margin-top: 24px; diff --git a/src/app/pages/wifi/wifi.page.ts b/src/app/pages/wifi/wifi.page.ts index 10aa7470..d9c33d5b 100644 --- a/src/app/pages/wifi/wifi.page.ts +++ b/src/app/pages/wifi/wifi.page.ts @@ -1,5 +1,6 @@ -import { Component, ViewChild } from '@angular/core'; +import { Component, OnInit, ViewChild } from '@angular/core'; import { IonContent, Platform, ToastController } from '@ionic/angular'; +import { ConferenceData } from '../../providers/conference-data'; import { LiveUpdateService } from '../../providers/live-update.service'; @Component({ @@ -7,16 +8,40 @@ import { LiveUpdateService } from '../../providers/live-update.service'; templateUrl: './wifi.page.html', styleUrls: ['./wifi.page.scss'], }) -export class WifiPage { +export class WifiPage implements OnInit { @ViewChild(IonContent) content: IonContent; showTitle = false; + wifiSponsor: any = null; constructor( private platform: Platform, private toastCtrl: ToastController, + private confData: ConferenceData, public liveUpdateService: LiveUpdateService, ) {} + ngOnInit() { + this.confData.getSponsors().subscribe((sponsors: any) => { + if (!sponsors) { + return; + } + // sponsors is keyed by level; iterate values across all levels + for (const level of Object.keys(sponsors)) { + const list = sponsors[level]; + if (!Array.isArray(list)) { + continue; + } + const match = list.find( + (s: any) => s && typeof s.name === 'string' && s.name.toLowerCase().includes('temporal'), + ); + if (match) { + this.wifiSponsor = match; + return; + } + } + }); + } + onScroll(event: any) { this.showTitle = event.detail.scrollTop > 100; } @@ -32,11 +57,41 @@ export class WifiPage { toast.present(); } + getSponsorSlug(name: string): string { + return name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); + } + + private async showToast(message: string, duration = 3000, color = 'medium') { + const toast = await this.toastCtrl.create({ + message, + duration, + position: 'bottom', + color, + }); + toast.present(); + } + openWifiSettings() { if (this.platform.is('ios')) { - window.open('App-prefs:WIFI', '_system'); + try { + // Apple does not allow deep-linking to the Wi-Fi pane from third-party + // apps. `app-settings:` opens Settings.app on this app's settings panel; + // the user can then back out one level to reach Wi-Fi. + window.open('app-settings:', '_system'); + this.showToast('Tap Wi-Fi from Settings to connect to PyConUS26', 3000); + } catch (err) { + this.showToast('Open your device settings → Wi-Fi to join PyConUS26'); + } + } else if (this.platform.is('android')) { + try { + // Intent URL form invokes Settings.ACTION_WIFI_SETTINGS via the OS + // URL handler from a WebView. + window.open('intent:#Intent;action=android.settings.WIFI_SETTINGS;end', '_system'); + } catch (err) { + this.showToast('Open your device settings → Wi-Fi to join PyConUS26'); + } } else { - window.open('android.settings.WIFI_SETTINGS', '_system'); + this.showToast('Open your device settings → Wi-Fi to join PyConUS26'); } } } diff --git a/src/app/providers/conference-data.ts b/src/app/providers/conference-data.ts index 4c58ad0f..069cb7b0 100644 --- a/src/app/providers/conference-data.ts +++ b/src/app/providers/conference-data.ts @@ -340,7 +340,7 @@ export class ConferenceData { // transform any markdown slot names to regular text slot.name = markdownToTxt(slot.name); - slot.preRegistered = (slot.name.includes('pre-registration') || slot.kind === "tutorial")? true : false; + slot.preRegistered = (slot.name.includes('pre-registration') || slot.kind === "tutorial" || slot.kind === "summit") ? true : false; if (slot.preRegistered) { slot.name = slot.name.split(', pre-registration')[0]; } diff --git a/src/assets/img/floor-plans/job-fair-thumb.jpg b/src/assets/img/floor-plans/job-fair-thumb.jpg new file mode 100644 index 00000000..f6a80de3 Binary files /dev/null and b/src/assets/img/floor-plans/job-fair-thumb.jpg differ diff --git a/src/assets/img/floor-plans/job-fair.jpg b/src/assets/img/floor-plans/job-fair.jpg new file mode 100644 index 00000000..4f331b71 Binary files /dev/null and b/src/assets/img/floor-plans/job-fair.jpg differ