Adds Underwriter's carousel and admin features

This commit is contained in:
2026-06-23 06:50:08 +00:00
parent 9b95bef9c6
commit 2295f841c9
19 changed files with 1018 additions and 8 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+79
View File
@@ -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()
+3 -2
View File
@@ -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":
+12
View File
@@ -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"
+31
View File
@@ -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):
+52 -1
View File
@@ -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")
@@ -0,0 +1,67 @@
<div class="form-overlay" (click)="onClose()">
<div class="form-dialog" (click)="$event.stopPropagation()">
<div class="form-header">
<h2>{{ id ? 'Edit Underwriter' : 'Add Underwriter' }}</h2>
<button type="button" class="btn-close" (click)="onClose()">&times;</button>
</div>
@if (error) {
<div class="form-error" role="alert">{{ error }}</div>
}
@if (success) {
<div class="form-success" role="alert">{{ success }}</div>
}
<form (ngSubmit)="onSubmit()" class="admin-form">
<div class="form-row">
<div class="form-group">
<label for="name">Name</label>
<input id="name" type="text" [(ngModel)]="name" name="name" placeholder="Company name" required [class.is-invalid]="submitted && !name">
</div>
<div class="form-group">
<label for="display_order">Display Order</label>
<input id="display_order" type="number" [(ngModel)]="display_order" name="display_order">
</div>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" [(ngModel)]="description" name="description" rows="3" placeholder="Short paragraph about the underwriter..." required [class.is-invalid]="submitted && !description"></textarea>
</div>
<div class="form-group">
<label for="website_url">Website URL (optional)</label>
<input id="website_url" type="url" [(ngModel)]="website_url" name="website_url" placeholder="https://example.com">
</div>
<div class="form-group">
<label for="logo_url">Logo URL (optional)</label>
<div class="url-with-upload">
<input id="logo_url" type="text" [(ngModel)]="logo_url" name="logo_url" placeholder="uploads/logo.png">
<label class="btn btn-upload">
Upload
<input type="file" accept="image/*" (change)="onImageUpload($event, 'logo_url')" [disabled]="uploading">
</label>
</div>
@if (logo_url) {
<img [src]="getPreviewUrl(logo_url)" [alt]="name + ' logo'" class="image-preview" (error)="$event.target.style.display='none'">
}
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" [(ngModel)]="active" name="active">
Active
</label>
</div>
<div class="form-actions">
<button type="button" class="btn btn-cancel" (click)="onClose()">Cancel</button>
<button type="submit" class="btn btn-save" [disabled]="loading">
{{ loading ? 'Saving…' : (id ? 'Update' : 'Create') }}
</button>
</div>
</form>
</div>
</div>
@@ -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;
}
}
@@ -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<void>();
@Output() closed = new EventEmitter<void>();
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<void> {
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<void> {
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;
}
}
}
+56
View File
@@ -37,6 +37,11 @@
[class.active]="activeTab() === 'station'"
(click)="activeTab.set('station')"
>Station</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'underwriters'"
(click)="activeTab.set('underwriters')"
>Underwriters</button>
</div>
<!-- Shows Tab -->
@@ -299,6 +304,54 @@
</div>
}
}
<!-- Underwriters Tab -->
@if (activeTab() === 'underwriters') {
@if (underwriters$ | async; as underwriters) {
<div class="tab-content">
<div class="tab-toolbar">
<h2>Underwriters ({{ underwriters.length }})</h2>
<button class="btn btn-add" (click)="openUnderwriterForm()">+ Add Underwriter</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Website</th>
<th>Order</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (uw of underwriters; track uw.id) {
<tr>
<td>{{ uw.name }}</td>
<td class="description-cell">{{ uw.description }}</td>
<td>
@if (uw.website_url) {
<a [href]="uw.website_url" target="_blank" rel="noopener">{{ uw.website_url }}</a>
} @else {
}
</td>
<td>{{ uw.display_order }}</td>
<td>{{ uw.active ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="editUnderwriter(uw)">Edit</button>
<button class="btn-icon btn-delete" (click)="deleteUnderwriter(uw.id)">Delete</button>
</td>
</tr>
} @empty {
<tr><td colspan="6" class="empty-row">No underwriters yet. Click "Add Underwriter" to create one.</td></tr>
}
</tbody>
</table>
</div>
}
}
</div>
<!-- Form modals -->
@@ -320,4 +373,7 @@
@if (showCommunityForm) {
<app-admin-community-form [editItem]="editingCommunity" (saved)="onCommunitySaved()" (closed)="onCommunityClosed()" />
}
@if (showUnderwriterForm) {
<app-admin-underwriter-form [editItem]="editingUnderwriter" (saved)="onUnderwriterSaved()" (closed)="onUnderwriterClosed()" />
}
</section>
+44 -1
View File
@@ -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<TabKey>('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<Show[]>([]);
@@ -65,6 +71,7 @@ export class AdminComponent implements OnInit {
readonly historyEntries$ = new BehaviorSubject<HistoryEntry[]>([]);
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
readonly underwriters$ = new BehaviorSubject<Underwriter[]>([]);
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<void> {
const data = await firstValueFrom(this.underwriterService.getUnderwriters());
this.underwriters$.next(data);
}
async loadAll(): Promise<void> {
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<void> {
if (!confirm('Delete this underwriter?')) return;
await firstValueFrom(this.underwriterService.deleteUnderwriter(id));
await this.reloadUnderwriters();
}
}
+65
View File
@@ -41,4 +41,69 @@
<div class="hero-divider" aria-hidden="true">
<img [src]="resolveImageUrl(config().hero_divider_url)" alt="">
</div>
<!-- Underwriter takeover overlay -->
@if (selectedUnderwriter()) {
@let uw = selectedUnderwriter()!;
<div class="takeover-overlay" (click)="closeTakeover()">
<div class="takeover-dialog" (click)="$event.stopPropagation()">
<button type="button" class="takeover-close" (click)="closeTakeover()" aria-label="Close">&times;</button>
<div class="takeover-content">
@if (uw.logo_url) {
<img [src]="resolveImageUrl(uw.logo_url)" [alt]="uw.name + ' logo'" class="takeover-logo">
}
<h2 class="takeover-name">{{ uw.name }}</h2>
<p class="takeover-description">{{ uw.description }}</p>
@if (uw.website_url) {
<a [href]="uw.website_url" target="_blank" rel="noopener noreferrer" class="btn btn-hero-primary takeover-visit">Visit Website</a>
}
</div>
</div>
</div>
}
</section>
<!-- Underwriter carousel — below hero, full-width row -->
@if ((underwriters()).length >= 2) {
<div class="underwriters-section">
<p class="underwriters-label">{{ config().callsign }} is brought to you with the generosity of our underwriters.</p>
<div
class="underwriters-carousel"
(mouseenter)="pauseCarousel()"
(mouseleave)="resumeCarousel()"
>
<div class="carousel-track" [class.paused]="carouselPaused">
<!-- First set -->
@for (uw of underwriters(); track uw.id) {
<button
type="button"
class="carousel-slide"
(click)="openTakeover(uw)"
[attr.aria-label]="'View ' + uw.name"
>
@if (uw.logo_url) {
<img [src]="resolveImageUrl(uw.logo_url)" [alt]="uw.name + ' logo'" class="carousel-logo">
} @else {
<span class="carousel-badge">{{ uw.name.charAt(0) }}</span>
}
</button>
}
<!-- Duplicate set for seamless loop -->
@for (uw of underwriters(); track uw.id + '-dup') {
<button
type="button"
class="carousel-slide"
(click)="openTakeover(uw)"
[attr.aria-label]="'View ' + uw.name"
>
@if (uw.logo_url) {
<img [src]="resolveImageUrl(uw.logo_url)" [alt]="uw.name + ' logo'" class="carousel-logo">
} @else {
<span class="carousel-badge">{{ uw.name.charAt(0) }}</span>
}
</button>
}
</div>
</div>
</div>
}
+196 -1
View File
@@ -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}) {
+45 -3
View File
@@ -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<Underwriter[]>([]);
readonly selectedUnderwriter = signal<Underwriter | null>(null);
carouselPaused = false;
ngOnInit(): void {
this.loadUnderwriters();
}
async loadUnderwriters(): Promise<void> {
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;
}
}
+9
View File
@@ -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;
}
+37
View File
@@ -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<Underwriter[]> {
let params = new HttpParams();
if (active !== undefined) params = params.set('active', String(active));
return this.http.get<Underwriter[]>(this.baseUrl, { params });
}
getUnderwriter(id: number): Observable<Underwriter> {
return this.http.get<Underwriter>(`${this.baseUrl}/${id}`);
}
createUnderwriter(payload: Omit<Underwriter, 'id'>): Observable<Underwriter> {
return this.http.post<Underwriter>(this.baseUrl, payload);
}
updateUnderwriter(id: number, payload: Partial<Underwriter>): Observable<Underwriter> {
return this.http.put<Underwriter>(`${this.baseUrl}/${id}`, payload);
}
deleteUnderwriter(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}