Skip to content

Commit fe81955

Browse files
JacobCoffeeclaude
andauthored
Add sponsor detail page (#243)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fb7482f commit fe81955

8 files changed

Lines changed: 358 additions & 1 deletion

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { NgModule } from '@angular/core';
2+
import { RouterModule, Routes } from '@angular/router';
3+
4+
import { SponsorDetailPage } from './sponsor-detail';
5+
6+
const routes: Routes = [
7+
{
8+
path: '',
9+
component: SponsorDetailPage
10+
}
11+
];
12+
13+
@NgModule({
14+
imports: [RouterModule.forChild(routes)],
15+
exports: [RouterModule]
16+
})
17+
export class SponsorDetailPageRoutingModule { }
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<ion-header class="ion-no-border" translucent="false">
2+
<ion-toolbar>
3+
<ion-buttons slot="start">
4+
<ion-back-button [defaultHref]="backHref"></ion-back-button>
5+
</ion-buttons>
6+
</ion-toolbar>
7+
</ion-header>
8+
9+
<ion-content class="sponsor-detail">
10+
<div class="sponsor-hero">
11+
<div class="sponsor-logo-container">
12+
<img *ngIf="sponsor?.logo_url" class="sponsor-logo" [src]="sponsor?.logo_url" [alt]="sponsor?.name + ' logo'" />
13+
</div>
14+
</div>
15+
16+
<div class="ion-padding-horizontal sponsor-name">
17+
<h1>{{ sponsor?.name }}</h1>
18+
<span *ngIf="sponsor?.level" class="level-badge">{{ sponsor?.level }}</span>
19+
</div>
20+
21+
<div *ngIf="sponsor?.booth_number" class="ion-padding-horizontal sponsor-booth">
22+
<ion-icon name="location-outline"></ion-icon>
23+
<span>Expo Hall Booth {{ sponsor?.booth_number }}</span>
24+
</div>
25+
26+
<div *ngIf="sponsor?.description" class="ion-padding-horizontal sponsor-description">
27+
<p [innerHtml]="sponsor?.description"></p>
28+
</div>
29+
30+
<div *ngIf="sponsor?.external_url" class="ion-padding-horizontal sponsor-website">
31+
<ion-button expand="block" fill="outline" (click)="openUrl(sponsor.external_url)">
32+
<ion-icon name="globe-outline" slot="start"></ion-icon>
33+
Visit Website
34+
</ion-button>
35+
</div>
36+
37+
<div *ngIf="jobListings.length > 0" class="ion-padding-horizontal sponsor-jobs">
38+
<h2>Open Positions</h2>
39+
<div *ngFor="let listing of jobListings" class="listing-card">
40+
<ion-list *ngIf="listing.roles?.length > 0" class="roles-list" lines="none">
41+
<ion-item *ngFor="let role of listing.roles" class="role-item" [button]="!!role.url" (click)="openUrl(role.url)" [detail]="!!role.url">
42+
<ion-icon name="briefcase-outline" slot="start" color="medium" class="role-icon"></ion-icon>
43+
<ion-label class="role-label">
44+
<span class="role-title">{{ role.title }}</span>
45+
</ion-label>
46+
</ion-item>
47+
</ion-list>
48+
</div>
49+
</div>
50+
51+
<div style="height: 80px"></div>
52+
</ion-content>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { NgModule } from '@angular/core';
2+
import { CommonModule } from '@angular/common';
3+
4+
import { SponsorDetailPage } from './sponsor-detail';
5+
import { SponsorDetailPageRoutingModule } from './sponsor-detail-routing.module';
6+
import { IonicModule } from '@ionic/angular';
7+
8+
@NgModule({
9+
imports: [
10+
CommonModule,
11+
IonicModule,
12+
SponsorDetailPageRoutingModule,
13+
],
14+
declarations: [
15+
SponsorDetailPage,
16+
]
17+
})
18+
export class SponsorDetailModule { }
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*
2+
* Sponsor Hero
3+
*/
4+
5+
ion-header {
6+
background: #3B3EA9;
7+
8+
&::after {
9+
display: none;
10+
}
11+
}
12+
13+
ion-toolbar {
14+
--background: transparent;
15+
--border-color: transparent;
16+
}
17+
18+
ion-toolbar ion-button,
19+
ion-toolbar ion-back-button,
20+
ion-toolbar ion-menu-button {
21+
--color: #ffffff;
22+
}
23+
24+
.sponsor-hero {
25+
background: linear-gradient(180deg, #3B3EA9 23.5%, #101136 53.29%);
26+
display: flex;
27+
align-items: center;
28+
justify-content: center;
29+
padding: 24px 32px 40px;
30+
}
31+
32+
.sponsor-logo-container {
33+
background: #ffffff;
34+
border-radius: 16px;
35+
padding: 20px 24px;
36+
display: flex;
37+
align-items: center;
38+
justify-content: center;
39+
max-width: 280px;
40+
width: 100%;
41+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
42+
}
43+
44+
.sponsor-logo {
45+
max-width: 100%;
46+
max-height: 120px;
47+
object-fit: contain;
48+
}
49+
50+
/*
51+
* Sponsor Info
52+
*/
53+
54+
.sponsor-name {
55+
padding-top: 24px;
56+
text-align: center;
57+
58+
h1 {
59+
margin: 0 0 8px;
60+
font-size: 1.6rem;
61+
font-weight: 700;
62+
}
63+
}
64+
65+
.level-badge {
66+
display: inline-block;
67+
font-size: 0.7rem;
68+
text-transform: uppercase;
69+
letter-spacing: 0.8px;
70+
padding: 3px 12px;
71+
border-radius: 4px;
72+
background: var(--ion-color-step-100, rgba(255, 255, 255, 0.1));
73+
color: var(--ion-color-step-600, rgba(255, 255, 255, 0.6));
74+
font-weight: 600;
75+
}
76+
77+
.sponsor-booth {
78+
display: flex;
79+
align-items: center;
80+
justify-content: center;
81+
gap: 6px;
82+
padding-top: 16px;
83+
font-size: 0.9rem;
84+
opacity: 0.7;
85+
86+
ion-icon {
87+
font-size: 1.1rem;
88+
}
89+
}
90+
91+
.sponsor-description {
92+
padding-top: 20px;
93+
94+
p {
95+
font-size: 0.95rem;
96+
line-height: 1.6;
97+
margin: 0;
98+
}
99+
}
100+
101+
.sponsor-website {
102+
padding-top: 24px;
103+
104+
ion-button {
105+
--border-radius: 10px;
106+
--border-color: var(--ion-color-primary);
107+
--color: var(--ion-color-primary);
108+
font-weight: 600;
109+
letter-spacing: 0.3px;
110+
}
111+
}
112+
113+
/*
114+
* Job Listings
115+
*/
116+
117+
.sponsor-jobs {
118+
padding-top: 32px;
119+
120+
h2 {
121+
font-size: 1.15rem;
122+
font-weight: 700;
123+
margin: 0 0 12px;
124+
}
125+
}
126+
127+
.listing-card {
128+
padding: 0;
129+
}
130+
131+
.roles-list {
132+
background: transparent;
133+
padding: 0;
134+
135+
ion-item {
136+
--background: transparent;
137+
--padding-start: 0;
138+
--padding-end: 0;
139+
--min-height: 44px;
140+
}
141+
}
142+
143+
.role-icon {
144+
font-size: 16px;
145+
margin-right: 8px;
146+
}
147+
148+
.role-title {
149+
font-size: 0.9rem;
150+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { Component } from '@angular/core';
2+
import { ActivatedRoute } from '@angular/router';
3+
import { ConferenceData } from '../../providers/conference-data';
4+
import { LiveUpdateService } from '../../providers/live-update.service';
5+
6+
@Component({
7+
selector: 'page-sponsor-detail',
8+
templateUrl: 'sponsor-detail.html',
9+
styleUrls: ['./sponsor-detail.scss'],
10+
})
11+
export class SponsorDetailPage {
12+
sponsor: any;
13+
jobListings: any[] = [];
14+
backHref = '';
15+
16+
constructor(
17+
private dataProvider: ConferenceData,
18+
private route: ActivatedRoute,
19+
public liveUpdateService: LiveUpdateService,
20+
) {}
21+
22+
ionViewDidEnter() {
23+
this.backHref = this.route.snapshot.queryParamMap.get('prevUrl') || '/app/tabs/sponsors';
24+
}
25+
26+
ionViewWillEnter() {
27+
const sponsorId = this.route.snapshot.paramMap.get('sponsorId');
28+
29+
this.dataProvider.load().subscribe((data: any) => {
30+
if (data && data.conference && data.conference.sponsors) {
31+
for (const [level, sponsors] of Object.entries(data.conference.sponsors)) {
32+
for (const [index, sponsor] of Object.entries(sponsors as any[])) {
33+
if (sponsor && String(sponsor.name).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '') === sponsorId) {
34+
this.sponsor = sponsor;
35+
break;
36+
}
37+
}
38+
if (this.sponsor) break;
39+
}
40+
}
41+
42+
// Find job listings associated with this sponsor
43+
const listings = data['job-listings'] || [];
44+
if (this.sponsor) {
45+
this.jobListings = listings.filter(
46+
(listing: any) => listing.sponsor_name === this.sponsor.name
47+
).map((listing: any) => ({
48+
...listing,
49+
roles: this.parseRoles(listing.description_html),
50+
}));
51+
}
52+
});
53+
}
54+
55+
parseRoles(html: string): {title: string, url: string}[] {
56+
if (!html) return [];
57+
const text = html.replace(/<[^>]+>/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/\s+/g, ' ').trim();
58+
const urlRegex = /https?:\/\/[^\s<>"]+/g;
59+
const urls = html.match(urlRegex) || [];
60+
if (urls.length === 0) {
61+
return text.length > 0 ? [{title: text, url: ''}] : [];
62+
}
63+
const roles: {title: string, url: string}[] = [];
64+
let remaining = text;
65+
for (const url of urls) {
66+
const idx = remaining.indexOf(url);
67+
if (idx >= 0) {
68+
const before = remaining.substring(0, idx).replace(/[-|,]\s*$/, '').trim();
69+
if (before.length > 0) {
70+
const lines = before.split(/\n/).filter(l => l.trim());
71+
const title = lines[lines.length - 1].replace(/^[-|]\s*/, '').trim();
72+
if (title.length > 0 && title.length < 200) {
73+
roles.push({title, url});
74+
} else {
75+
roles.push({title: this.shortenUrl(url), url});
76+
}
77+
} else {
78+
roles.push({title: this.shortenUrl(url), url});
79+
}
80+
remaining = remaining.substring(idx + url.length).replace(/^\s*[-|,]\s*/, '').trim();
81+
}
82+
}
83+
if (roles.length === 0 && text.length > 0) {
84+
return [{title: text, url: ''}];
85+
}
86+
return roles;
87+
}
88+
89+
shortenUrl(url: string): string {
90+
try {
91+
const u = new URL(url);
92+
const path = u.pathname.replace(/\/$/, '');
93+
const parts = path.split('/').filter(p => p);
94+
if (parts.length > 0) {
95+
return parts[parts.length - 1].replace(/-/g, ' ').replace(/^\w/, c => c.toUpperCase());
96+
}
97+
return u.hostname;
98+
} catch {
99+
return url;
100+
}
101+
}
102+
103+
openUrl(url: string) {
104+
if (url) {
105+
window.open(url, '_system', 'location=yes');
106+
}
107+
}
108+
109+
getSponsorSlug(name: string): string {
110+
return name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
111+
}
112+
}

src/app/pages/sponsors/sponsors.page.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
<ion-grid>
1919
<ion-row *ngFor="let sponsorLevel of sponsors | keyvalue : levelOrder">
2020
<ion-col size-xl="3" size-md="4" size-sm="6" size-xs="12" *ngFor="let sponsor of sponsorLevel.value" [hidden]="sponsor.hide">
21-
<ion-card>
21+
<ion-card button="true" [routerLink]="'/app/tabs/sponsors/sponsor-detail/' + getSponsorSlug(sponsor.name)">
2222
<div style="text-align: center; width: 100%; padding-top: 2em;">
2323
<img style="min-width: 128px; max-width: 33%; max-height: 33%;" [src]="sponsor.logo_url" [alt]="sponsor.name + ' logo'" />
2424
</div>

src/app/pages/sponsors/sponsors.page.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export class SponsorsPage implements OnInit {
4545
});
4646
}
4747

48+
getSponsorSlug(name: string): string {
49+
return name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
50+
}
51+
4852
ngOnInit() {
4953
this.reloadSponsors();
5054
}

src/app/pages/tabs-page/tabs-page-routing.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ const routes: Routes = [
5555
{
5656
path: '',
5757
loadChildren: () => import('../sponsors/sponsors.module').then(m => m.SponsorsPageModule)
58+
},
59+
{
60+
path: 'sponsor-detail/:sponsorId',
61+
loadChildren: () => import('../sponsor-detail/sponsor-detail.module').then(m => m.SponsorDetailModule)
5862
}
5963
]
6064
},

0 commit comments

Comments
 (0)