Adds Underwriter's carousel and admin features
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
@@ -6,9 +6,9 @@ from sqlalchemy import func, select
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import async_session, engine
|
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.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):
|
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(community.router, prefix="/api/community", tags=["community"])
|
||||||
app.include_router(shows.router, prefix="/api/shows", tags=["shows"])
|
app.include_router(shows.router, prefix="/api/shows", tags=["shows"])
|
||||||
app.include_router(storage.router, prefix="/api/storage", tags=["storage"])
|
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)
|
# Dev-only admin router (disabled in production)
|
||||||
if settings.ENV != "production":
|
if settings.ENV != "production":
|
||||||
|
|||||||
@@ -129,6 +129,18 @@ class CommunityHighlight(Base):
|
|||||||
active = Column(Boolean, default=True)
|
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):
|
class StorageBlob(Base):
|
||||||
__tablename__ = "storage_blobs"
|
__tablename__ = "storage_blobs"
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,37 @@ class CommunityHighlightResponse(BaseModel):
|
|||||||
model_config = {"from_attributes": True}
|
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 ──────────────────────────────────────────
|
# ── ShowSchedule ──────────────────────────────────────────
|
||||||
|
|
||||||
class ShowScheduleResponse(BaseModel):
|
class ShowScheduleResponse(BaseModel):
|
||||||
|
|||||||
+52
-1
@@ -15,7 +15,7 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
from app.database import async_session
|
from app.database import async_session
|
||||||
from app.models import Show, ShowSchedule, Event, StationConfig
|
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 ──────────────────────────────────────────────
|
# ── 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 ────────────────────────────────────────────────
|
# ── Helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _upsert_event(session, data: dict) -> None:
|
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))
|
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 ──────────────────────────────────────────────────
|
# ── Main ──────────────────────────────────────────────────
|
||||||
|
|
||||||
async def seed() -> None:
|
async def seed() -> None:
|
||||||
@@ -428,6 +473,11 @@ async def seed() -> None:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
print(f" ✓ Seeded {len(COMMUNITY_HIGHLIGHTS)} community highlights")
|
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')
|
# Make a copy since _upsert_show mutates the dict (pops 'schedules')
|
||||||
import copy
|
import copy
|
||||||
for data in copy.deepcopy(SHOWS):
|
for data in copy.deepcopy(SHOWS):
|
||||||
@@ -447,6 +497,7 @@ async def truncate_all() -> None:
|
|||||||
await session.execute(HistoryEntry.__table__.delete())
|
await session.execute(HistoryEntry.__table__.delete())
|
||||||
await session.execute(TeamMember.__table__.delete())
|
await session.execute(TeamMember.__table__.delete())
|
||||||
await session.execute(CommunityHighlight.__table__.delete())
|
await session.execute(CommunityHighlight.__table__.delete())
|
||||||
|
await session.execute(Underwriter.__table__.delete())
|
||||||
await session.commit()
|
await session.commit()
|
||||||
print(" ✓ Truncated all tables")
|
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()">×</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,11 @@
|
|||||||
[class.active]="activeTab() === 'station'"
|
[class.active]="activeTab() === 'station'"
|
||||||
(click)="activeTab.set('station')"
|
(click)="activeTab.set('station')"
|
||||||
>Station</button>
|
>Station</button>
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
[class.active]="activeTab() === 'underwriters'"
|
||||||
|
(click)="activeTab.set('underwriters')"
|
||||||
|
>Underwriters</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Shows Tab -->
|
<!-- Shows Tab -->
|
||||||
@@ -299,6 +304,54 @@
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Form modals -->
|
<!-- Form modals -->
|
||||||
@@ -320,4 +373,7 @@
|
|||||||
@if (showCommunityForm) {
|
@if (showCommunityForm) {
|
||||||
<app-admin-community-form [editItem]="editingCommunity" (saved)="onCommunitySaved()" (closed)="onCommunityClosed()" />
|
<app-admin-community-form [editItem]="editingCommunity" (saved)="onCommunitySaved()" (closed)="onCommunityClosed()" />
|
||||||
}
|
}
|
||||||
|
@if (showUnderwriterForm) {
|
||||||
|
<app-admin-underwriter-form [editItem]="editingUnderwriter" (saved)="onUnderwriterSaved()" (closed)="onUnderwriterClosed()" />
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
@@ -9,20 +9,23 @@ import { StationConfig } from '../interfaces/station-config';
|
|||||||
import { HistoryEntry } from '../interfaces/history-entry';
|
import { HistoryEntry } from '../interfaces/history-entry';
|
||||||
import { TeamMember } from '../interfaces/team-member';
|
import { TeamMember } from '../interfaces/team-member';
|
||||||
import { CommunityHighlight } from '../interfaces/community-highlight';
|
import { CommunityHighlight } from '../interfaces/community-highlight';
|
||||||
|
import { Underwriter } from '../interfaces/underwriter';
|
||||||
import { ShowService } from '../services/show.service';
|
import { ShowService } from '../services/show.service';
|
||||||
import { EventService } from '../services/event.service';
|
import { EventService } from '../services/event.service';
|
||||||
import { StationConfigService } from '../services/station-config.service';
|
import { StationConfigService } from '../services/station-config.service';
|
||||||
import { HistoryEntryService } from '../services/history-entry.service';
|
import { HistoryEntryService } from '../services/history-entry.service';
|
||||||
import { TeamMemberService } from '../services/team-member.service';
|
import { TeamMemberService } from '../services/team-member.service';
|
||||||
import { CommunityHighlightService } from '../services/community-highlight.service';
|
import { CommunityHighlightService } from '../services/community-highlight.service';
|
||||||
|
import { UnderwriterService } from '../services/underwriter.service';
|
||||||
import { AdminShowFormComponent } from './admin-show-form.component';
|
import { AdminShowFormComponent } from './admin-show-form.component';
|
||||||
import { AdminEventFormComponent } from './admin-event-form.component';
|
import { AdminEventFormComponent } from './admin-event-form.component';
|
||||||
import { AdminStationFormComponent } from './admin-station-form.component';
|
import { AdminStationFormComponent } from './admin-station-form.component';
|
||||||
import { AdminHistoryFormComponent } from './admin-history-form.component';
|
import { AdminHistoryFormComponent } from './admin-history-form.component';
|
||||||
import { AdminTeamFormComponent } from './admin-team-form.component';
|
import { AdminTeamFormComponent } from './admin-team-form.component';
|
||||||
import { AdminCommunityFormComponent } from './admin-community-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({
|
@Component({
|
||||||
selector: 'app-admin',
|
selector: 'app-admin',
|
||||||
@@ -37,6 +40,7 @@ type TabKey = 'shows' | 'events' | 'station' | 'history' | 'team' | 'community';
|
|||||||
AdminHistoryFormComponent,
|
AdminHistoryFormComponent,
|
||||||
AdminTeamFormComponent,
|
AdminTeamFormComponent,
|
||||||
AdminCommunityFormComponent,
|
AdminCommunityFormComponent,
|
||||||
|
AdminUnderwriterFormComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './admin.component.html',
|
templateUrl: './admin.component.html',
|
||||||
styleUrl: './admin.component.scss',
|
styleUrl: './admin.component.scss',
|
||||||
@@ -48,6 +52,7 @@ export class AdminComponent implements OnInit {
|
|||||||
private historyEntryService = inject(HistoryEntryService);
|
private historyEntryService = inject(HistoryEntryService);
|
||||||
private teamMemberService = inject(TeamMemberService);
|
private teamMemberService = inject(TeamMemberService);
|
||||||
private communityHighlightService = inject(CommunityHighlightService);
|
private communityHighlightService = inject(CommunityHighlightService);
|
||||||
|
private underwriterService = inject(UnderwriterService);
|
||||||
|
|
||||||
activeTab = signal<TabKey>('shows');
|
activeTab = signal<TabKey>('shows');
|
||||||
|
|
||||||
@@ -57,6 +62,7 @@ export class AdminComponent implements OnInit {
|
|||||||
editingHistory: HistoryEntry | null = null;
|
editingHistory: HistoryEntry | null = null;
|
||||||
editingTeam: TeamMember | null = null;
|
editingTeam: TeamMember | null = null;
|
||||||
editingCommunity: CommunityHighlight | null = null;
|
editingCommunity: CommunityHighlight | null = null;
|
||||||
|
editingUnderwriter: Underwriter | null = null;
|
||||||
|
|
||||||
/** BehaviorSubjects guarantee change detection via the async pipe — start empty, populated on load. */
|
/** BehaviorSubjects guarantee change detection via the async pipe — start empty, populated on load. */
|
||||||
readonly shows$ = new BehaviorSubject<Show[]>([]);
|
readonly shows$ = new BehaviorSubject<Show[]>([]);
|
||||||
@@ -65,6 +71,7 @@ export class AdminComponent implements OnInit {
|
|||||||
readonly historyEntries$ = new BehaviorSubject<HistoryEntry[]>([]);
|
readonly historyEntries$ = new BehaviorSubject<HistoryEntry[]>([]);
|
||||||
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
|
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
|
||||||
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
|
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
|
||||||
|
readonly underwriters$ = new BehaviorSubject<Underwriter[]>([]);
|
||||||
|
|
||||||
showShowForm = false;
|
showShowForm = false;
|
||||||
showEventForm = false;
|
showEventForm = false;
|
||||||
@@ -72,6 +79,7 @@ export class AdminComponent implements OnInit {
|
|||||||
showHistoryForm = false;
|
showHistoryForm = false;
|
||||||
showTeamForm = false;
|
showTeamForm = false;
|
||||||
showCommunityForm = false;
|
showCommunityForm = false;
|
||||||
|
showUnderwriterForm = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadAll();
|
this.loadAll();
|
||||||
@@ -107,6 +115,11 @@ export class AdminComponent implements OnInit {
|
|||||||
this.communityHighlights$.next(data);
|
this.communityHighlights$.next(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reloadUnderwriters(): Promise<void> {
|
||||||
|
const data = await firstValueFrom(this.underwriterService.getUnderwriters());
|
||||||
|
this.underwriters$.next(data);
|
||||||
|
}
|
||||||
|
|
||||||
async loadAll(): Promise<void> {
|
async loadAll(): Promise<void> {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.reloadShows(),
|
this.reloadShows(),
|
||||||
@@ -115,6 +128,7 @@ export class AdminComponent implements OnInit {
|
|||||||
this.reloadHistoryEntries(),
|
this.reloadHistoryEntries(),
|
||||||
this.reloadTeamMembers(),
|
this.reloadTeamMembers(),
|
||||||
this.reloadCommunityHighlights(),
|
this.reloadCommunityHighlights(),
|
||||||
|
this.reloadUnderwriters(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,4 +291,33 @@ export class AdminComponent implements OnInit {
|
|||||||
await firstValueFrom(this.communityHighlightService.deleteCommunityHighlight(id));
|
await firstValueFrom(this.communityHighlightService.deleteCommunityHighlight(id));
|
||||||
await this.reloadCommunityHighlights();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,4 +41,69 @@
|
|||||||
<div class="hero-divider" aria-hidden="true">
|
<div class="hero-divider" aria-hidden="true">
|
||||||
<img [src]="resolveImageUrl(config().hero_divider_url)" alt="">
|
<img [src]="resolveImageUrl(config().hero_divider_url)" alt="">
|
||||||
</div>
|
</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">×</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>
|
</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>
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}) {
|
@media (max-width: #{$bp-md - 1px}) {
|
||||||
.hero-line1 { font-size: 2.5rem; }
|
.hero-line1 { font-size: 2.5rem; }
|
||||||
.hero-line2 { font-size: 2.2rem; }
|
.hero-line2 { font-size: 2.2rem; }
|
||||||
.hero-subtitle { font-size: 1rem; }
|
.hero-subtitle { font-size: 1rem; }
|
||||||
.hero-flower { width: 70px; height: 70px; }
|
.hero-flower { width: 70px; height: 70px; }
|
||||||
.hero-actions { flex-direction: column; align-items: center; }
|
.hero-actions { flex-direction: column; align-items: center; }
|
||||||
|
|
||||||
|
// Carousel clamp() handles responsive sizing automatically
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: #{$bp-sm - 1px}) {
|
@media (max-width: #{$bp-sm - 1px}) {
|
||||||
|
|||||||
@@ -1,20 +1,62 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
import { StationConfigService } from '../services/station-config.service';
|
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';
|
import { resolveImageUrl } from '../services/app-config.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-hero',
|
selector: 'app-hero',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterLink],
|
imports: [RouterLink, CommonModule],
|
||||||
templateUrl: './hero.component.html',
|
templateUrl: './hero.component.html',
|
||||||
styleUrl: './hero.component.scss',
|
styleUrl: './hero.component.scss',
|
||||||
})
|
})
|
||||||
export class HeroComponent {
|
export class HeroComponent implements OnInit {
|
||||||
readonly config = inject(StationConfigService).config;
|
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 {
|
resolveImageUrl(url: string): string {
|
||||||
return resolveImageUrl(url);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user