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
@@ -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}`);
}
}