diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc
index b28d2cb..025c914 100644
Binary files a/backend/app/__pycache__/main.cpython-312.pyc and b/backend/app/__pycache__/main.cpython-312.pyc differ
diff --git a/backend/app/__pycache__/models.cpython-312.pyc b/backend/app/__pycache__/models.cpython-312.pyc
index cdce449..99b089b 100644
Binary files a/backend/app/__pycache__/models.cpython-312.pyc and b/backend/app/__pycache__/models.cpython-312.pyc differ
diff --git a/backend/app/__pycache__/schemas.cpython-312.pyc b/backend/app/__pycache__/schemas.cpython-312.pyc
index f90a25b..7164094 100644
Binary files a/backend/app/__pycache__/schemas.cpython-312.pyc and b/backend/app/__pycache__/schemas.cpython-312.pyc differ
diff --git a/backend/app/api/__pycache__/underwriters.cpython-312.pyc b/backend/app/api/__pycache__/underwriters.cpython-312.pyc
new file mode 100644
index 0000000..578dc1e
Binary files /dev/null and b/backend/app/api/__pycache__/underwriters.cpython-312.pyc differ
diff --git a/backend/app/api/underwriters.py b/backend/app/api/underwriters.py
new file mode 100644
index 0000000..af7c8ca
--- /dev/null
+++ b/backend/app/api/underwriters.py
@@ -0,0 +1,79 @@
+from typing import Optional
+
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.auth import get_current_admin_user
+from app.database import get_session
+from app.models import Underwriter
+from app.schemas import UnderwriterCreate, UnderwriterUpdate, UnderwriterResponse
+from app.user_models import User
+
+router = APIRouter()
+
+
+@router.get("/", response_model=list[UnderwriterResponse])
+async def list_underwriters(
+ active: Optional[bool] = Query(None, description="Filter by active status"),
+ session: AsyncSession = Depends(get_session),
+):
+ query = select(Underwriter).order_by(Underwriter.display_order)
+ if active is not None:
+ query = query.where(Underwriter.active == active)
+ result = await session.execute(query)
+ return list(result.scalars().all())
+
+
+@router.get("/{underwriter_id}", response_model=UnderwriterResponse)
+async def get_underwriter(underwriter_id: int, session: AsyncSession = Depends(get_session)):
+ result = await session.execute(select(Underwriter).where(Underwriter.id == underwriter_id))
+ uw = result.scalar_one_or_none()
+ if not uw:
+ return {"error": "Underwriter not found"}
+ return uw
+
+
+@router.post("/", response_model=UnderwriterResponse, status_code=201)
+async def create_underwriter(
+ payload: UnderwriterCreate,
+ session: AsyncSession = Depends(get_session),
+ _: User = Depends(get_current_admin_user),
+):
+ uw = Underwriter(**payload.model_dump(), active=True)
+ session.add(uw)
+ await session.commit()
+ await session.refresh(uw)
+ return uw
+
+
+@router.put("/{underwriter_id}", response_model=UnderwriterResponse)
+async def update_underwriter(
+ underwriter_id: int,
+ payload: UnderwriterUpdate,
+ session: AsyncSession = Depends(get_session),
+ _: User = Depends(get_current_admin_user),
+):
+ result = await session.execute(select(Underwriter).where(Underwriter.id == underwriter_id))
+ uw = result.scalar_one_or_none()
+ if not uw:
+ return {"error": "Underwriter not found"}
+ for key, value in payload.model_dump(exclude_unset=True).items():
+ setattr(uw, key, value)
+ await session.commit()
+ await session.refresh(uw)
+ return uw
+
+
+@router.delete("/{underwriter_id}", status_code=204)
+async def delete_underwriter(
+ underwriter_id: int,
+ session: AsyncSession = Depends(get_session),
+ _: User = Depends(get_current_admin_user),
+):
+ result = await session.execute(select(Underwriter).where(Underwriter.id == underwriter_id))
+ uw = result.scalar_one_or_none()
+ if not uw:
+ return {"error": "Underwriter not found"}
+ await session.delete(uw)
+ await session.commit()
diff --git a/backend/app/main.py b/backend/app/main.py
index 3742bb9..fa77168 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -6,9 +6,9 @@ from sqlalchemy import func, select
from app.config import settings
from app.database import async_session, engine
-from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight
+from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight, Underwriter
from app.user_models import User
-from app.api import events, auth, station_config, history, team, community, shows, storage
+from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters
async def _ensure_station_config(session):
@@ -93,6 +93,7 @@ app.include_router(team.router, prefix="/api/team", tags=["team"])
app.include_router(community.router, prefix="/api/community", tags=["community"])
app.include_router(shows.router, prefix="/api/shows", tags=["shows"])
app.include_router(storage.router, prefix="/api/storage", tags=["storage"])
+app.include_router(underwriters.router, prefix="/api/underwriters", tags=["underwriters"])
# Dev-only admin router (disabled in production)
if settings.ENV != "production":
diff --git a/backend/app/models.py b/backend/app/models.py
index 2209ecc..1937e5e 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -129,6 +129,18 @@ class CommunityHighlight(Base):
active = Column(Boolean, default=True)
+class Underwriter(Base):
+ __tablename__ = "underwriters"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ name = Column(String(100), nullable=False)
+ logo_url = Column(String(500), nullable=True)
+ description = Column(Text, nullable=False)
+ website_url = Column(String(500), nullable=True)
+ display_order = Column(Integer, nullable=False, default=0)
+ active = Column(Boolean, default=True)
+
+
class StorageBlob(Base):
__tablename__ = "storage_blobs"
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 0020c05..8ca831b 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -184,6 +184,37 @@ class CommunityHighlightResponse(BaseModel):
model_config = {"from_attributes": True}
+# ── Underwriter ────────────────────────────────────────────
+
+class UnderwriterCreate(BaseModel):
+ name: str
+ logo_url: Optional[str] = None
+ description: str
+ website_url: Optional[str] = None
+ display_order: int = 0
+
+
+class UnderwriterUpdate(BaseModel):
+ name: Optional[str] = None
+ logo_url: Optional[str] = None
+ description: Optional[str] = None
+ website_url: Optional[str] = None
+ display_order: Optional[int] = None
+ active: Optional[bool] = None
+
+
+class UnderwriterResponse(BaseModel):
+ id: int
+ name: str
+ logo_url: Optional[str]
+ description: str
+ website_url: Optional[str]
+ display_order: int
+ active: bool
+
+ model_config = {"from_attributes": True}
+
+
# ── ShowSchedule ──────────────────────────────────────────
class ShowScheduleResponse(BaseModel):
diff --git a/backend/seed.py b/backend/seed.py
index 9b2f095..8973089 100644
--- a/backend/seed.py
+++ b/backend/seed.py
@@ -15,7 +15,7 @@ from sqlalchemy import select
from app.database import async_session
from app.models import Show, ShowSchedule, Event, StationConfig
-from app.models import HistoryEntry, TeamMember, CommunityHighlight
+from app.models import HistoryEntry, TeamMember, CommunityHighlight, Underwriter
# ── Seed data ──────────────────────────────────────────────
@@ -300,6 +300,38 @@ COMMUNITY_HIGHLIGHTS: list[dict] = [
]
+UNDERWRITERS: list[dict] = [
+ {
+ "name": "Mountain View Credit Union",
+ "description": "Proudly serving the mountain communities since 1952. Your local financial partner for savings, loans, and community investment.",
+ "logo_url": None,
+ "website_url": "https://example-mvcu.org",
+ "display_order": 1,
+ },
+ {
+ "name": "Highland Medical Center",
+ "description": "Compassionate healthcare for every ridge and valley. From urgent care to wellness programs, we keep our community healthy.",
+ "logo_url": None,
+ "website_url": "https://example-highlandmedical.org",
+ "display_order": 2,
+ },
+ {
+ "name": "Pine Street Hardware",
+ "description": "Family-owned since 1978. Everything you need for home improvement, outdoor living, and mountain-ready repairs.",
+ "logo_url": None,
+ "website_url": "https://example-pinestreet.com",
+ "display_order": 3,
+ },
+ {
+ "name": "Summit Valley Farm",
+ "description": "Organic produce, fresh eggs, and seasonal CSA shares. Bringing farm-to-table flavor to the mountain towns since 2005.",
+ "logo_url": None,
+ "website_url": "https://example-summitvalley.com",
+ "display_order": 4,
+ },
+]
+
+
# ── Helpers ────────────────────────────────────────────────
async def _upsert_event(session, data: dict) -> None:
@@ -399,6 +431,19 @@ async def _upsert_community_highlight(session, data: dict) -> None:
session.add(CommunityHighlight(**data, active=True))
+async def _upsert_underwriter(session, data: dict) -> None:
+ """Get-or-create an underwriter matched on name."""
+ existing = await session.execute(
+ select(Underwriter).where(Underwriter.name == data["name"])
+ )
+ uw = existing.scalar_one_or_none()
+ if uw:
+ for key, value in data.items():
+ setattr(uw, key, value)
+ else:
+ session.add(Underwriter(**data, active=True))
+
+
# ── Main ──────────────────────────────────────────────────
async def seed() -> None:
@@ -428,6 +473,11 @@ async def seed() -> None:
await session.commit()
print(f" ✓ Seeded {len(COMMUNITY_HIGHLIGHTS)} community highlights")
+ for data in UNDERWRITERS:
+ await _upsert_underwriter(session, data)
+ await session.commit()
+ print(f" ✓ Seeded {len(UNDERWRITERS)} underwriters")
+
# Make a copy since _upsert_show mutates the dict (pops 'schedules')
import copy
for data in copy.deepcopy(SHOWS):
@@ -447,6 +497,7 @@ async def truncate_all() -> None:
await session.execute(HistoryEntry.__table__.delete())
await session.execute(TeamMember.__table__.delete())
await session.execute(CommunityHighlight.__table__.delete())
+ await session.execute(Underwriter.__table__.delete())
await session.commit()
print(" ✓ Truncated all tables")
diff --git a/src/app/admin/admin-underwriter-form.component.html b/src/app/admin/admin-underwriter-form.component.html
new file mode 100644
index 0000000..d52b904
--- /dev/null
+++ b/src/app/admin/admin-underwriter-form.component.html
@@ -0,0 +1,67 @@
+
diff --git a/src/app/admin/admin-underwriter-form.component.scss b/src/app/admin/admin-underwriter-form.component.scss
new file mode 100644
index 0000000..0e3b5c0
--- /dev/null
+++ b/src/app/admin/admin-underwriter-form.component.scss
@@ -0,0 +1,168 @@
+@use '../../styles/variables' as *;
+@use '../../styles/mixins' as *;
+
+.form-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ @include flex-center;
+ z-index: $z-modal;
+ @include fade-in;
+}
+
+.form-dialog {
+ background: $neutral-white;
+ border-radius: $radius-lg;
+ padding: $spacing-xl;
+ width: 90%;
+ max-width: 560px;
+ box-shadow: $shadow-xl;
+ max-height: 90vh;
+ overflow-y: auto;
+}
+
+.form-header {
+ @include flex-between;
+ margin-bottom: $spacing-lg;
+
+ h2 {
+ font-family: $font-heading;
+ color: $primary-blue;
+ margin: 0;
+ font-size: 1.4rem;
+ }
+}
+
+.btn-close {
+ background: none;
+ border: none;
+ font-size: 1.5rem;
+ cursor: pointer;
+ color: $neutral-medium;
+ line-height: 1;
+ padding: $spacing-xs;
+
+ &:hover {
+ color: $neutral-dark;
+ }
+}
+
+.form-error {
+ background: rgba($danger-red, 0.1);
+ color: $danger-red;
+ padding: $spacing-sm $spacing-md;
+ border-radius: $radius-md;
+ font-size: 0.875rem;
+ margin-bottom: $spacing-md;
+ text-align: center;
+}
+
+.admin-form {
+ @include url-with-upload;
+
+ .form-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: $spacing-md;
+ }
+
+ .form-group {
+ margin-bottom: $spacing-md;
+
+ label:not(.btn-upload) {
+ display: block;
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: $neutral-dark;
+ margin-bottom: $spacing-xs;
+ }
+
+ input, select, textarea {
+ width: 100%;
+ padding: $spacing-sm $spacing-md;
+ border: 1px solid $neutral-light;
+ border-radius: $radius-md;
+ font-size: 0.95rem;
+ transition: border-color $transition-fast;
+ box-sizing: border-box;
+
+ &:focus {
+ outline: none;
+ border-color: $primary-blue;
+ box-shadow: 0 0 0 3px rgba($primary-blue, 0.15);
+ }
+
+ &.is-invalid {
+ border-color: $danger-red;
+ box-shadow: 0 0 0 3px rgba($danger-red, 0.15);
+ }
+ }
+ }
+
+ .checkbox-group {
+ label {
+ display: flex;
+ align-items: center;
+ gap: $spacing-sm;
+ font-weight: 500;
+ cursor: pointer;
+
+ input[type="checkbox"] {
+ width: auto;
+ margin: 0;
+ }
+ }
+ }
+
+ .form-success {
+ background: rgba(#2e7d32, 0.1);
+ color: #2e7d32;
+ padding: $spacing-sm $spacing-md;
+ border-radius: $radius-md;
+ font-size: 0.875rem;
+ margin-bottom: $spacing-md;
+ text-align: center;
+ }
+
+ .image-preview {
+ display: block;
+ margin-top: $spacing-xs;
+ width: 48px;
+ height: 48px;
+ object-fit: cover;
+ border-radius: $radius-md;
+ border: 1px solid $neutral-light;
+ }
+}
+
+.form-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: $spacing-sm;
+ margin-top: $spacing-md;
+}
+
+.btn-save {
+ @include button-style($primary-blue, $neutral-white, $primary-blue-light);
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+}
+
+.btn-cancel {
+ background: $neutral-light;
+ color: $neutral-dark;
+ border: none;
+ border-radius: $radius-md;
+ padding: $spacing-sm $spacing-lg;
+ font-size: 0.95rem;
+ cursor: pointer;
+ transition: background $transition-fast;
+
+ &:hover {
+ background: $neutral-medium;
+ color: $neutral-white;
+ }
+}
diff --git a/src/app/admin/admin-underwriter-form.component.ts b/src/app/admin/admin-underwriter-form.component.ts
new file mode 100644
index 0000000..fab8977
--- /dev/null
+++ b/src/app/admin/admin-underwriter-form.component.ts
@@ -0,0 +1,154 @@
+import { ChangeDetectorRef, Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { firstValueFrom } from 'rxjs';
+
+import { Underwriter } from '../interfaces/underwriter';
+import { UnderwriterService } from '../services/underwriter.service';
+import { UploadService } from '../services/upload.service';
+import { resolveImageUrl } from '../services/app-config.service';
+
+@Component({
+ selector: 'app-admin-underwriter-form',
+ standalone: true,
+ imports: [CommonModule, FormsModule],
+ templateUrl: './admin-underwriter-form.component.html',
+ styleUrl: './admin-underwriter-form.component.scss',
+})
+export class AdminUnderwriterFormComponent implements OnChanges {
+ private cdr = inject(ChangeDetectorRef);
+ private underwriterService = inject(UnderwriterService);
+ private uploadService = inject(UploadService);
+
+ @Input() editItem: Underwriter | null = null;
+ @Output() saved = new EventEmitter();
+ @Output() closed = new EventEmitter();
+
+ id: number | null = null;
+ name = '';
+ description = '';
+ website_url = '';
+ logo_url = '';
+ display_order = 0;
+ active = true;
+
+ loading = false;
+ uploading = false;
+ error = '';
+ success = '';
+ submitted = false;
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes['editItem'] && this.editItem) {
+ this.edit(this.editItem);
+ }
+ }
+
+ edit(underwriter: Underwriter): void {
+ this.id = underwriter.id;
+ this.name = underwriter.name;
+ this.description = underwriter.description;
+ this.website_url = underwriter.website_url ?? '';
+ this.logo_url = underwriter.logo_url ?? '';
+ this.display_order = underwriter.display_order;
+ this.active = underwriter.active;
+ }
+
+ formatError(err: any): string {
+ if (err?.error?.detail) return err.error.detail;
+ if (err?.error?.message) return err.error.message;
+ if (err?.message) return err.message;
+ return 'Failed to save. Please try again.';
+ }
+
+ reset(): void {
+ this.id = null;
+ this.name = '';
+ this.description = '';
+ this.website_url = '';
+ this.logo_url = '';
+ this.display_order = 0;
+ this.active = true;
+ this.error = '';
+ this.success = '';
+ this.submitted = false;
+ }
+
+ onClose(): void {
+ this.closed.emit();
+ this.reset();
+ }
+
+ /** Handle image file upload and set the logo URL field. */
+ async onImageUpload(event: Event, field: string): Promise {
+ const input = event.target as HTMLInputElement;
+ if (!input?.files?.length) return;
+
+ const file = input.files[0];
+ if (!file.type.startsWith('image/')) {
+ this.error = 'Only image files are allowed.';
+ this.cdr.detectChanges();
+ return;
+ }
+ if (file.size > 5 * 1024 * 1024) {
+ this.error = 'File size exceeds 5 MB limit.';
+ this.cdr.detectChanges();
+ return;
+ }
+
+ this.uploading = true;
+ this.error = '';
+ this.success = '';
+ this.cdr.detectChanges();
+
+ try {
+ const url = await this.uploadService.uploadImage(file);
+ (this as any)[field] = url;
+ this.success = 'Image uploaded successfully.';
+ } catch (err: any) {
+ this.error = this.formatError(err);
+ } finally {
+ this.uploading = false;
+ this.cdr.detectChanges();
+ }
+ }
+
+ /** Resolve a relative uploads/… path to an absolute URL for preview. */
+ getPreviewUrl(url: string): string {
+ return resolveImageUrl(url);
+ }
+
+ async onSubmit(): Promise {
+ this.submitted = true;
+ if (!this.name || !this.description) {
+ this.error = 'Please fill in all required fields.';
+ return;
+ }
+
+ this.loading = true;
+ this.error = '';
+
+ const payload = {
+ name: this.name,
+ description: this.description,
+ website_url: this.website_url || null,
+ logo_url: this.logo_url || null,
+ display_order: this.display_order,
+ active: this.active,
+ };
+
+ try {
+ if (this.id) {
+ await firstValueFrom(this.underwriterService.updateUnderwriter(this.id, payload));
+ } else {
+ await firstValueFrom(this.underwriterService.createUnderwriter(payload));
+ }
+ this.saved.emit();
+ this.reset();
+ } catch (err: any) {
+ this.error = this.formatError(err);
+ } finally {
+ this.loading = false;
+ }
+ }
+}
diff --git a/src/app/admin/admin.component.html b/src/app/admin/admin.component.html
index a18172d..fcbb63e 100644
--- a/src/app/admin/admin.component.html
+++ b/src/app/admin/admin.component.html
@@ -37,6 +37,11 @@
[class.active]="activeTab() === 'station'"
(click)="activeTab.set('station')"
>Station
+
@@ -299,6 +304,54 @@
}
}
+
+
+ @if (activeTab() === 'underwriters') {
+ @if (underwriters$ | async; as underwriters) {
+
+
+
Underwriters ({{ underwriters.length }})
+
+
+
+
+
+
+ | Name |
+ Description |
+ Website |
+ Order |
+ Active |
+ Actions |
+
+
+
+ @for (uw of underwriters; track uw.id) {
+
+ | {{ uw.name }} |
+ {{ uw.description }} |
+
+ @if (uw.website_url) {
+ {{ uw.website_url }}
+ } @else {
+ —
+ }
+ |
+ {{ uw.display_order }} |
+ {{ uw.active ? 'Yes' : 'No' }} |
+
+
+
+ |
+
+ } @empty {
+ | No underwriters yet. Click "Add Underwriter" to create one. |
+ }
+
+
+
+ }
+ }
@@ -320,4 +373,7 @@
@if (showCommunityForm) {
}
+ @if (showUnderwriterForm) {
+
+ }
\ No newline at end of file
diff --git a/src/app/admin/admin.component.ts b/src/app/admin/admin.component.ts
index 749ae52..330d518 100644
--- a/src/app/admin/admin.component.ts
+++ b/src/app/admin/admin.component.ts
@@ -9,20 +9,23 @@ import { StationConfig } from '../interfaces/station-config';
import { HistoryEntry } from '../interfaces/history-entry';
import { TeamMember } from '../interfaces/team-member';
import { CommunityHighlight } from '../interfaces/community-highlight';
+import { Underwriter } from '../interfaces/underwriter';
import { ShowService } from '../services/show.service';
import { EventService } from '../services/event.service';
import { StationConfigService } from '../services/station-config.service';
import { HistoryEntryService } from '../services/history-entry.service';
import { TeamMemberService } from '../services/team-member.service';
import { CommunityHighlightService } from '../services/community-highlight.service';
+import { UnderwriterService } from '../services/underwriter.service';
import { AdminShowFormComponent } from './admin-show-form.component';
import { AdminEventFormComponent } from './admin-event-form.component';
import { AdminStationFormComponent } from './admin-station-form.component';
import { AdminHistoryFormComponent } from './admin-history-form.component';
import { AdminTeamFormComponent } from './admin-team-form.component';
import { AdminCommunityFormComponent } from './admin-community-form.component';
+import { AdminUnderwriterFormComponent } from './admin-underwriter-form.component';
-type TabKey = 'shows' | 'events' | 'station' | 'history' | 'team' | 'community';
+type TabKey = 'shows' | 'events' | 'station' | 'history' | 'team' | 'community' | 'underwriters';
@Component({
selector: 'app-admin',
@@ -37,6 +40,7 @@ type TabKey = 'shows' | 'events' | 'station' | 'history' | 'team' | 'community';
AdminHistoryFormComponent,
AdminTeamFormComponent,
AdminCommunityFormComponent,
+ AdminUnderwriterFormComponent,
],
templateUrl: './admin.component.html',
styleUrl: './admin.component.scss',
@@ -48,6 +52,7 @@ export class AdminComponent implements OnInit {
private historyEntryService = inject(HistoryEntryService);
private teamMemberService = inject(TeamMemberService);
private communityHighlightService = inject(CommunityHighlightService);
+ private underwriterService = inject(UnderwriterService);
activeTab = signal('shows');
@@ -57,6 +62,7 @@ export class AdminComponent implements OnInit {
editingHistory: HistoryEntry | null = null;
editingTeam: TeamMember | null = null;
editingCommunity: CommunityHighlight | null = null;
+ editingUnderwriter: Underwriter | null = null;
/** BehaviorSubjects guarantee change detection via the async pipe — start empty, populated on load. */
readonly shows$ = new BehaviorSubject([]);
@@ -65,6 +71,7 @@ export class AdminComponent implements OnInit {
readonly historyEntries$ = new BehaviorSubject([]);
readonly teamMembers$ = new BehaviorSubject([]);
readonly communityHighlights$ = new BehaviorSubject([]);
+ readonly underwriters$ = new BehaviorSubject([]);
showShowForm = false;
showEventForm = false;
@@ -72,6 +79,7 @@ export class AdminComponent implements OnInit {
showHistoryForm = false;
showTeamForm = false;
showCommunityForm = false;
+ showUnderwriterForm = false;
ngOnInit(): void {
this.loadAll();
@@ -107,6 +115,11 @@ export class AdminComponent implements OnInit {
this.communityHighlights$.next(data);
}
+ async reloadUnderwriters(): Promise {
+ const data = await firstValueFrom(this.underwriterService.getUnderwriters());
+ this.underwriters$.next(data);
+ }
+
async loadAll(): Promise {
await Promise.all([
this.reloadShows(),
@@ -115,6 +128,7 @@ export class AdminComponent implements OnInit {
this.reloadHistoryEntries(),
this.reloadTeamMembers(),
this.reloadCommunityHighlights(),
+ this.reloadUnderwriters(),
]);
}
@@ -277,4 +291,33 @@ export class AdminComponent implements OnInit {
await firstValueFrom(this.communityHighlightService.deleteCommunityHighlight(id));
await this.reloadCommunityHighlights();
}
+
+ // ── Underwriter CRUD ────────────────────────────────────
+
+ openUnderwriterForm(): void {
+ this.editingUnderwriter = null;
+ this.showUnderwriterForm = true;
+ }
+
+ editUnderwriter(underwriter: Underwriter): void {
+ this.editingUnderwriter = underwriter;
+ this.showUnderwriterForm = true;
+ }
+
+ onUnderwriterSaved(): void {
+ this.reloadUnderwriters();
+ this.showUnderwriterForm = false;
+ this.editingUnderwriter = null;
+ }
+
+ onUnderwriterClosed(): void {
+ this.showUnderwriterForm = false;
+ this.editingUnderwriter = null;
+ }
+
+ async deleteUnderwriter(id: number): Promise {
+ if (!confirm('Delete this underwriter?')) return;
+ await firstValueFrom(this.underwriterService.deleteUnderwriter(id));
+ await this.reloadUnderwriters();
+ }
}
diff --git a/src/app/hero/hero.component.html b/src/app/hero/hero.component.html
index b8d6a07..329a888 100644
--- a/src/app/hero/hero.component.html
+++ b/src/app/hero/hero.component.html
@@ -41,4 +41,69 @@
+
+
+ @if (selectedUnderwriter()) {
+ @let uw = selectedUnderwriter()!;
+
+
+
+
+ @if (uw.logo_url) {
+
![]()
+ }
+
{{ uw.name }}
+
{{ uw.description }}
+ @if (uw.website_url) {
+
Visit Website
+ }
+
+
+
+ }
+
+
+@if ((underwriters()).length >= 2) {
+
+
{{ config().callsign }} is brought to you with the generosity of our underwriters.
+
+
+
+ @for (uw of underwriters(); track uw.id) {
+
+ }
+
+ @for (uw of underwriters(); track uw.id + '-dup') {
+
+ }
+
+
+
+}
diff --git a/src/app/hero/hero.component.scss b/src/app/hero/hero.component.scss
index 340be29..555a2c3 100644
--- a/src/app/hero/hero.component.scss
+++ b/src/app/hero/hero.component.scss
@@ -151,13 +151,208 @@
}
}
-// Responsive
+// ── Underwriter Horizontal Carousel ─────────────────────────
+
+.underwriters-section {
+ background: $neutral-cream;
+ border-top: 2px solid $neutral-light;
+ border-bottom: 2px solid $neutral-light;
+}
+
+.underwriters-label {
+ text-align: center;
+ padding: $spacing-lg $spacing-lg $spacing-md;
+ margin: 0;
+ font-family: $font-heading;
+ font-size: clamp(1rem, 2vw, 1.3rem);
+ font-weight: 600;
+ color: $neutral-dark;
+ letter-spacing: 0.02em;
+}
+
+.underwriters-carousel {
+ width: 100%;
+ overflow: hidden;
+ padding: clamp($spacing-xl, 4vh, $spacing-3xl) 0;
+}
+
+.carousel-track {
+ display: flex;
+ gap: clamp($spacing-xl, 4vw, $spacing-3xl);
+ width: max-content;
+ animation: scroll-carousel 30s linear infinite;
+
+ &.paused {
+ animation-play-state: paused;
+ }
+}
+
+@keyframes scroll-carousel {
+ from { transform: translateX(0); }
+ to { transform: translateX(-50%); }
+}
+
+.carousel-slide {
+ flex-shrink: 0;
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+ outline: none;
+
+ &:focus-visible {
+ .carousel-logo,
+ .carousel-badge {
+ outline: 2px solid $accent-orange;
+ outline-offset: 2px;
+ }
+ }
+}
+
+.carousel-logo {
+ display: block;
+ width: clamp(180px, 12vw, 300px);
+ height: clamp(100px, 6.5vw, 170px);
+ object-fit: contain;
+ border-radius: $radius-md;
+ background: rgba(255, 255, 255, 0.95);
+ padding: clamp(10px, 1.2vw, 18px);
+ box-shadow: $shadow-md;
+ transition: transform $transition-fast, box-shadow $transition-fast;
+
+ .carousel-slide:hover & {
+ transform: scale(1.08);
+ box-shadow: $shadow-lg;
+ }
+}
+
+.carousel-badge {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: clamp(100px, 6.5vw, 170px);
+ height: clamp(100px, 6.5vw, 170px);
+ border-radius: $radius-md;
+ background: rgba(255, 255, 255, 0.95);
+ border: 2px solid $neutral-light;
+ box-shadow: $shadow-md;
+ font-family: $font-heading;
+ font-size: clamp(1.8rem, 3vw, 3rem);
+ font-weight: 700;
+ color: $primary-blue;
+ transition: transform $transition-fast, box-shadow $transition-fast;
+
+ .carousel-slide:hover & {
+ transform: scale(1.08);
+ box-shadow: $shadow-lg;
+ }
+}
+
+// ── Takeover Overlay ────────────────────────────────────────
+
+.takeover-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: $z-modal;
+ background: rgba($primary-blue-dark, 0.85);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: $spacing-lg;
+ @include fade-in;
+}
+
+.takeover-dialog {
+ position: relative;
+ background: $neutral-white;
+ border-radius: $radius-xl;
+ padding: $spacing-2xl $spacing-xl $spacing-xl;
+ max-width: 520px;
+ width: 100%;
+ text-align: center;
+ box-shadow: $shadow-xl;
+ animation: takeover-slide-up 0.3s ease-out;
+}
+
+@keyframes takeover-slide-up {
+ from {
+ opacity: 0;
+ transform: translateY(30px) scale(0.97);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+.takeover-close {
+ position: absolute;
+ top: $spacing-md;
+ right: $spacing-md;
+ background: none;
+ border: none;
+ font-size: 1.8rem;
+ line-height: 1;
+ cursor: pointer;
+ color: $neutral-medium;
+ padding: $spacing-xs;
+ transition: color $transition-fast;
+
+ &:hover {
+ color: $neutral-dark;
+ }
+}
+
+.takeover-content {
+ margin-top: $spacing-md;
+}
+
+.takeover-logo {
+ display: block;
+ width: 140px;
+ height: 140px;
+ margin: 0 auto $spacing-md;
+ border-radius: $radius-lg;
+ object-fit: contain;
+ background: $neutral-cream;
+ border: 1px solid $neutral-light;
+}
+
+.takeover-name {
+ font-family: $font-heading;
+ font-size: 1.6rem;
+ color: $primary-blue;
+ margin: 0 0 $spacing-sm;
+}
+
+.takeover-description {
+ font-size: 1.05rem;
+ color: $neutral-dark;
+ line-height: 1.7;
+ margin: 0 0 $spacing-lg;
+ max-width: 420px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.takeover-visit {
+ @include button-style($accent-orange, $neutral-white, $accent-orange-dark);
+ font-size: 1rem;
+ padding: $spacing-sm $spacing-xl;
+ letter-spacing: 0.03em;
+}
+
+// ── Responsive ──────────────────────────────────────────────
+
@media (max-width: #{$bp-md - 1px}) {
.hero-line1 { font-size: 2.5rem; }
.hero-line2 { font-size: 2.2rem; }
.hero-subtitle { font-size: 1rem; }
.hero-flower { width: 70px; height: 70px; }
.hero-actions { flex-direction: column; align-items: center; }
+
+ // Carousel clamp() handles responsive sizing automatically
}
@media (max-width: #{$bp-sm - 1px}) {
diff --git a/src/app/hero/hero.component.ts b/src/app/hero/hero.component.ts
index 4b39e5c..216b6f6 100644
--- a/src/app/hero/hero.component.ts
+++ b/src/app/hero/hero.component.ts
@@ -1,20 +1,62 @@
-import { Component, inject } from '@angular/core';
+import { Component, inject, OnInit, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
+import { CommonModule } from '@angular/common';
+import { firstValueFrom } from 'rxjs';
import { StationConfigService } from '../services/station-config.service';
+import { UnderwriterService } from '../services/underwriter.service';
+import { Underwriter } from '../interfaces/underwriter';
import { resolveImageUrl } from '../services/app-config.service';
@Component({
selector: 'app-hero',
standalone: true,
- imports: [RouterLink],
+ imports: [RouterLink, CommonModule],
templateUrl: './hero.component.html',
styleUrl: './hero.component.scss',
})
-export class HeroComponent {
+export class HeroComponent implements OnInit {
readonly config = inject(StationConfigService).config;
+ private underwriterService = inject(UnderwriterService);
+
+ readonly underwriters = signal([]);
+ readonly selectedUnderwriter = signal(null);
+
+ carouselPaused = false;
+
+ ngOnInit(): void {
+ this.loadUnderwriters();
+ }
+
+ async loadUnderwriters(): Promise {
+ try {
+ const data = await firstValueFrom(this.underwriterService.getUnderwriters(true));
+ this.underwriters.set(data);
+ } catch {
+ // Silently fail — carousel simply won't render
+ }
+ }
resolveImageUrl(url: string): string {
return resolveImageUrl(url);
}
+
+ pauseCarousel(): void {
+ this.carouselPaused = true;
+ }
+
+ resumeCarousel(): void {
+ this.carouselPaused = false;
+ }
+
+ openTakeover(uw: Underwriter): void {
+ this.selectedUnderwriter.set(uw);
+ this.carouselPaused = true;
+ }
+
+ closeTakeover(): void {
+ this.selectedUnderwriter.set(null);
+ this.carouselPaused = false;
+ }
+
}
diff --git a/src/app/interfaces/underwriter.ts b/src/app/interfaces/underwriter.ts
new file mode 100644
index 0000000..f6b12da
--- /dev/null
+++ b/src/app/interfaces/underwriter.ts
@@ -0,0 +1,9 @@
+export interface Underwriter {
+ id: number;
+ name: string;
+ logo_url: string | null;
+ description: string;
+ website_url: string | null;
+ display_order: number;
+ active: boolean;
+}
diff --git a/src/app/services/underwriter.service.ts b/src/app/services/underwriter.service.ts
new file mode 100644
index 0000000..4753f02
--- /dev/null
+++ b/src/app/services/underwriter.service.ts
@@ -0,0 +1,37 @@
+import { Injectable, inject } from '@angular/core';
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Observable } from 'rxjs';
+
+import { Underwriter } from '../interfaces/underwriter';
+import { getAppConfig } from './app-config.service';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class UnderwriterService {
+ private http = inject(HttpClient);
+ private baseUrl = `${getAppConfig().apiBaseUrl}/api/underwriters`;
+
+ /** Fetch underwriters, optionally filtered by active status. */
+ getUnderwriters(active?: boolean): Observable {
+ let params = new HttpParams();
+ if (active !== undefined) params = params.set('active', String(active));
+ return this.http.get(this.baseUrl, { params });
+ }
+
+ getUnderwriter(id: number): Observable {
+ return this.http.get(`${this.baseUrl}/${id}`);
+ }
+
+ createUnderwriter(payload: Omit): Observable {
+ return this.http.post(this.baseUrl, payload);
+ }
+
+ updateUnderwriter(id: number, payload: Partial): Observable {
+ return this.http.put(`${this.baseUrl}/${id}`, payload);
+ }
+
+ deleteUnderwriter(id: number): Observable {
+ return this.http.delete(`${this.baseUrl}/${id}`);
+ }
+}