From 2295f841c9e9a8971bac303dac6d04f457b5694c Mon Sep 17 00:00:00 2001 From: kfj001 Date: Tue, 23 Jun 2026 06:50:08 +0000 Subject: [PATCH] Adds Underwriter's carousel and admin features --- backend/app/__pycache__/main.cpython-312.pyc | Bin 6335 -> 6500 bytes .../app/__pycache__/models.cpython-312.pyc | Bin 6851 -> 7420 bytes .../app/__pycache__/schemas.cpython-312.pyc | Bin 8945 -> 10100 bytes .../__pycache__/underwriters.cpython-312.pyc | Bin 0 -> 4917 bytes backend/app/api/underwriters.py | 79 +++++++ backend/app/main.py | 5 +- backend/app/models.py | 12 ++ backend/app/schemas.py | 31 +++ backend/seed.py | 53 ++++- .../admin-underwriter-form.component.html | 67 ++++++ .../admin-underwriter-form.component.scss | 168 +++++++++++++++ .../admin/admin-underwriter-form.component.ts | 154 ++++++++++++++ src/app/admin/admin.component.html | 56 +++++ src/app/admin/admin.component.ts | 45 +++- src/app/hero/hero.component.html | 65 ++++++ src/app/hero/hero.component.scss | 197 +++++++++++++++++- src/app/hero/hero.component.ts | 48 ++++- src/app/interfaces/underwriter.ts | 9 + src/app/services/underwriter.service.ts | 37 ++++ 19 files changed, 1018 insertions(+), 8 deletions(-) create mode 100644 backend/app/api/__pycache__/underwriters.cpython-312.pyc create mode 100644 backend/app/api/underwriters.py create mode 100644 src/app/admin/admin-underwriter-form.component.html create mode 100644 src/app/admin/admin-underwriter-form.component.scss create mode 100644 src/app/admin/admin-underwriter-form.component.ts create mode 100644 src/app/interfaces/underwriter.ts create mode 100644 src/app/services/underwriter.service.ts diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc index b28d2cb8ca52a9c7663cd2fec3bd63e65539ccce..025c914d78937cf95dcbde95dc5d8b640ba64180 100644 GIT binary patch delta 1067 zcmZ`$OK1~87@pZ(yX~fVw9RgkrnO1)Xg5h)8xK_}8a!E0Tf`n*6ivI+=Cw(fO(+#X zg1VIlesDVJu#ezpcyoxhP!3xR1e*6F5_wE0feK-3(ZvAMr zN`RiI*NFooQOwL2a6U;2O)zgTEGfQLz zhPtR8s&02xy;S|yRUM(~2-mIhC{;Bs#M7zB0Vl&H^0KRUjC#LzRee--@}b9__w#3# zPVd`}^^hK3hkME{-_9XGQs!0%-$Zr}034=2Xfo$EB3gtxJNJpiIyO@E$p4KtqnlAR zMt1lD6p11%xEhw_ypHe&PL~i?VMACp>AR1lg=@A(fBBzgR`-PpXBneoVjlxPxgn}O zC*dP;5yq;Y#90xZC4X$&aGbodhb>`dY<&_VHM^P^WO;zWIRQ3?k&BW5f|uu#+qQd39@yN zJp|b!;A?_Z9i+a1R5jsRfMnP$$@BH0@ZnInHgomEbZy~I4Gew;8hPf93wLSZzb0+m AEC2ui delta 922 zcmZ`#OKTHR6uu`jHfbKsn3|bHljhO9Cb3Bq!Qg}F&ZbJmMK`0ggwC`k&5Jt;l7hSb zfp~YW1VNA#p=7Bb2zKK}a8U?^Z0yd3(3P&dH!}+bb74Nt`ObH~Gv|CSe9XJPxLkHb z-{(&^YQetC4+s6EE&)=JYO8x2q9RV?`MT8bDgEQ%O`6&dkrLKGb!gnKMRgwX1Li=V z60snSQ(fwU>eeDh!hkpVs1~i=BLYaUMvk3}96U1=K2!02 zrsA@x_yr!0P9a~x3x;daGRrk!_H<_|f~I0PQ?XZw!9SrWZV4;!Y_n`BgS;ojx@;&^4*Ip zk1)tGSY@zGVZ>Eb6~}2LTXBXF{7$Rc#0e%yGPuqlMP3R4L1uMZy}GOE?Mf4uh9|;R z9!li=(k3jDvNr-AvhOXfd#UbVK38d1a~-YHz#_{Rruj~*(dag-ogS7LX`hn%)7CQ` z`xxOgh)xSv_B8A#ycoBx)53-;!F{qOCCsPmpqoly68Go=9aNS$~07Xzw5JM!~EA diff --git a/backend/app/__pycache__/models.cpython-312.pyc b/backend/app/__pycache__/models.cpython-312.pyc index cdce449179afd24a41d6e1fc35bb2f2dd1dc7df5..99b089b21379d5822a46aacac242f871135dc5a4 100644 GIT binary patch delta 426 zcmX?X`p1&*G%qg~0}$A-SY^!?oyaG_Xs}UTno)|0!JQ#Rtc4*(JXK&dGe`vkLzHBt zgr?+XH^vgt$p##pj7KIL2x?DWz{NjVLsWs0X|kuN3L6tcDtik1@W z_XQzWc(oecio`+I6i<%klH7b)Y(697jLFFo)49q)q99_zWEROhb`Z-?Q)+Uh+$}YI zvY!OsWCQ6e_F|w0KTXNW3#BJ0ivih1;vhl-NZjJE$<0qG%}KQ@Y65Z@fw;JTa->X~ RAO~X{;{=tj3?Le;9stZ?AUOa4 diff --git a/backend/app/__pycache__/schemas.cpython-312.pyc b/backend/app/__pycache__/schemas.cpython-312.pyc index f90a25bf03313f3381f5bad4e9d65241fc94d579..716409439bcdf4c51a1638ad9d2dcb4847fab5b7 100644 GIT binary patch delta 1192 zcmZ`&OH30{6rDG12WTzOQs|FPKNg`z&{!1^MU6p3v@s~e)CMYag2>1EMm|;0hL6#O z2J&2BV&cXaKQTnRcIieFHyG0pHnL!0Vw9xDgpJ<&YM?A^^3J>OoICg4o;S1BlnE7n zG#d2+{GMtLUb*O;E3}DK;+v{fazYfEh1lL{Ar@4mR~k$ags_XV`+(h_!^)gJ0Bmgz z>rT`K>QiR%v6;L`Em)d}6oC>n!q!W7k_hvd?5wfukG>O0gy-x@Q6*c@6|!-wmvr)k zqGBieAA_huuhQ_jU8k~8`B)ZT?3I8BT(~lR2}D} zaK2Ii8F_e0h=KeiQ~OX%p`*0#xU@UzxI{k#gdLowg?=Z@MQkHM%RKI`VFw1<_J1PvRb!@0~_ zX;H8a!h2Y12f&QLKImM=Esfcrbq`T^H_=jIk`&z*V|A>~Qh&50$F4R-jb9y76}k)S zqiea7;1PYIRS4Ap=R-8izLu8M^&*4t36_e}f;;(!sphOjg=JZLod4`G*|XNj?19Zm zdQ&fLwS@Gu75g!=!u*adNyU1*v9!JqLxg4a%+W^r_}?)*4{z&7VE`aRTQPIT2oeb2 zV5vp`7$SQgdgF9U*8CBCKK0sJNhDr5+hhzMMOb6M%e%-JZxVMs@+45W0T7}$F&szO zcw^`U3&?IaSNIFVW$sZ8Cf-6A!Ql)^3=ssvX55}t>77={8KX7M(VI6LcY2u3i2f45PLx)J!>#jliG#C_!s zB~hJZ3tpH04uk}jTFbt8m;E~8oG-`-uw{xR;=>rWjJbeR&j&IBY?*TQ)_3GD<*OG7 delta 598 zcmez3_tBN_G%qg~0}w1bWSONaF_BM#=^5ijjc1H3l}4Jzn?so92r+7I{wU(g$QBJ$ zRTMqhMofz_dh$kg$;l03|G8p8f*@kWkuBfD8f= zmnOR_#xoXx9dJN#rfMNbq6kR%6%~V+B_M(cM0kOSQXrwpGkLzGlss6P1tbVo1D4hU zamzqN-efIB8K#E}ldmd;O#f#HXE!^6Ra9x z6^MlvP*or?5b=g#@=28`>>#F}rtIVtRcpqK$qQ7iRlso#j$3dXfm!A zcZ|%KF61O=Y$Rx9ps4*&6n^kS4Xc3rmI4J@J1^LsdZg+Nec6NC4&CDKu@AJ6{wA=Pq(l>$nD^9Fttia@{Y$N14k%>$dlA$q5 zk+vzej1UttVoc0PG0ETwiaq0qISg7I$|A}&RAzA5DOSQm(rC9#)6dC2ovrs+Z7A3PCxXypFAF zw4>$Sth0WCQw}$sFaRe^wmL!XTj+27ep{ZafjLJZ@ zcp8q-=@^iHzRZqivNBVq-s?V+Qs9VzvxR{~QcJzd22?Gf8zH4kY z%g?L1M3Sjvv+QgnKrtmSDOPla`(}$g=_75gV(Tg{ezIQBXyh%C;0e zNs5Ajt$xJTZDC5680T63Jb1d{%z>G#HZYxq_Yt*otT7Gv@gA7!wv?=QnwW}bXBYr5 zJ_92lYZFQ%viJr=Fh5(z6yJ!PHq2s8Z<odPR^ZED6-+ZewbfyxBSAFrhBa5!Ks%x<98mzd6=O!L_e2eYfrQV&Twiimy z7ZyFfxz}ng;{PtSU|<^`qW45PzDc|%MSyE$INT-B;-BakxLYmY(my) zJXv=sNY#Px1x!{aGFeE0%hJvAAcf6x#Vu;O;THV?1-H;viLNMEj-~`8T7bsVwi*bA ze2c`_(AZ*!35Si4Zk$HC5vPpwaOyURN@m7!8 zwkSrPtHW@_`{TCp5d#6kfPfyPBXoR=cyEIP=`9rcWIb+SL*3m!t=BDxwXaLui$!tW zxR;7jQ{1<#XR@#$T5wC(>lgg;35(~U$S3vX!Yt=l#2GE!@31h@=2FS z;0>));aWh@mJV9yHO8W3+0|l)WaO~-0koUoaT~yura1bTBcM*f?Zn3}B$d3(0C6hQ zqMlZnjBmjmTd~^-oi4#`Dx=~Z2JSc-=P%;QF6g38GqtV4IVL#+?7DEC6?Ey{gpy|} zW^$~e*vzcgnK$lVE5MJpYSgcO3Y`J|nxAy_VS)RfQ(q`j4ULpTBX_k@XrvN4Pz_C# zLlb`v9lGkiB3wEBrL%3`Ha~eI_$TK^sFU4?>BIjro;om{KC5}_Vf7Li4`DUw!?2ok zKv<0r!sPx|I_VPckMscj8KILN@iXc`+C{PVVE^b3us2HiHW*qb*%up8_w}i^P)*m3 zI*7{u6m`XV%(cKaT`vyT$`|ma_$_!ag}X2k_z=EV=lXcp?QpwiaIr@SLZ)vNZX$tsf?SGYP2l(@I93GRt-hlql;k;0s;p|3Tn{o-JRqTRFNkpb7U6yrZo@VFCN#g$RE}vI zZt&)}rU3UV9!8FMHk(z93%D+%W;ElXrHi?&s&TBlx{%9j9Fq`#6+3hy$Nwju!b8lo zH@i41(@ z>ioEG&R(+i^kL z4Tn#?PjJAEhiT0QWUU={_Fe9~a^iq8)rZ82TP&t75|RM4j1(= z`Zm-A7{P#=1S9+ir}qi2pvI%15t%(}ZF%fmtI*{Kg0mg@*Sb`B0`olE`QchHj wGM2H(97NTT%aKys;9X%ZQlYzBn(C%~hGrNIQ(;^-6)4oV=dleaejHr)FSgtp5C8xG literal 0 HcmV?d00001 diff --git a/backend/app/api/underwriters.py b/backend/app/api/underwriters.py new file mode 100644 index 0000000..af7c8ca --- /dev/null +++ b/backend/app/api/underwriters.py @@ -0,0 +1,79 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth import get_current_admin_user +from app.database import get_session +from app.models import Underwriter +from app.schemas import UnderwriterCreate, UnderwriterUpdate, UnderwriterResponse +from app.user_models import User + +router = APIRouter() + + +@router.get("/", response_model=list[UnderwriterResponse]) +async def list_underwriters( + active: Optional[bool] = Query(None, description="Filter by active status"), + session: AsyncSession = Depends(get_session), +): + query = select(Underwriter).order_by(Underwriter.display_order) + if active is not None: + query = query.where(Underwriter.active == active) + result = await session.execute(query) + return list(result.scalars().all()) + + +@router.get("/{underwriter_id}", response_model=UnderwriterResponse) +async def get_underwriter(underwriter_id: int, session: AsyncSession = Depends(get_session)): + result = await session.execute(select(Underwriter).where(Underwriter.id == underwriter_id)) + uw = result.scalar_one_or_none() + if not uw: + return {"error": "Underwriter not found"} + return uw + + +@router.post("/", response_model=UnderwriterResponse, status_code=201) +async def create_underwriter( + payload: UnderwriterCreate, + session: AsyncSession = Depends(get_session), + _: User = Depends(get_current_admin_user), +): + uw = Underwriter(**payload.model_dump(), active=True) + session.add(uw) + await session.commit() + await session.refresh(uw) + return uw + + +@router.put("/{underwriter_id}", response_model=UnderwriterResponse) +async def update_underwriter( + underwriter_id: int, + payload: UnderwriterUpdate, + session: AsyncSession = Depends(get_session), + _: User = Depends(get_current_admin_user), +): + result = await session.execute(select(Underwriter).where(Underwriter.id == underwriter_id)) + uw = result.scalar_one_or_none() + if not uw: + return {"error": "Underwriter not found"} + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(uw, key, value) + await session.commit() + await session.refresh(uw) + return uw + + +@router.delete("/{underwriter_id}", status_code=204) +async def delete_underwriter( + underwriter_id: int, + session: AsyncSession = Depends(get_session), + _: User = Depends(get_current_admin_user), +): + result = await session.execute(select(Underwriter).where(Underwriter.id == underwriter_id)) + uw = result.scalar_one_or_none() + if not uw: + return {"error": "Underwriter not found"} + await session.delete(uw) + await session.commit() diff --git a/backend/app/main.py b/backend/app/main.py index 3742bb9..fa77168 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,9 +6,9 @@ from sqlalchemy import func, select from app.config import settings from app.database import async_session, engine -from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight +from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight, Underwriter from app.user_models import User -from app.api import events, auth, station_config, history, team, community, shows, storage +from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters async def _ensure_station_config(session): @@ -93,6 +93,7 @@ app.include_router(team.router, prefix="/api/team", tags=["team"]) app.include_router(community.router, prefix="/api/community", tags=["community"]) app.include_router(shows.router, prefix="/api/shows", tags=["shows"]) app.include_router(storage.router, prefix="/api/storage", tags=["storage"]) +app.include_router(underwriters.router, prefix="/api/underwriters", tags=["underwriters"]) # Dev-only admin router (disabled in production) if settings.ENV != "production": diff --git a/backend/app/models.py b/backend/app/models.py index 2209ecc..1937e5e 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -129,6 +129,18 @@ class CommunityHighlight(Base): active = Column(Boolean, default=True) +class Underwriter(Base): + __tablename__ = "underwriters" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) + logo_url = Column(String(500), nullable=True) + description = Column(Text, nullable=False) + website_url = Column(String(500), nullable=True) + display_order = Column(Integer, nullable=False, default=0) + active = Column(Boolean, default=True) + + class StorageBlob(Base): __tablename__ = "storage_blobs" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 0020c05..8ca831b 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -184,6 +184,37 @@ class CommunityHighlightResponse(BaseModel): model_config = {"from_attributes": True} +# ── Underwriter ──────────────────────────────────────────── + +class UnderwriterCreate(BaseModel): + name: str + logo_url: Optional[str] = None + description: str + website_url: Optional[str] = None + display_order: int = 0 + + +class UnderwriterUpdate(BaseModel): + name: Optional[str] = None + logo_url: Optional[str] = None + description: Optional[str] = None + website_url: Optional[str] = None + display_order: Optional[int] = None + active: Optional[bool] = None + + +class UnderwriterResponse(BaseModel): + id: int + name: str + logo_url: Optional[str] + description: str + website_url: Optional[str] + display_order: int + active: bool + + model_config = {"from_attributes": True} + + # ── ShowSchedule ────────────────────────────────────────── class ShowScheduleResponse(BaseModel): diff --git a/backend/seed.py b/backend/seed.py index 9b2f095..8973089 100644 --- a/backend/seed.py +++ b/backend/seed.py @@ -15,7 +15,7 @@ from sqlalchemy import select from app.database import async_session from app.models import Show, ShowSchedule, Event, StationConfig -from app.models import HistoryEntry, TeamMember, CommunityHighlight +from app.models import HistoryEntry, TeamMember, CommunityHighlight, Underwriter # ── Seed data ────────────────────────────────────────────── @@ -300,6 +300,38 @@ COMMUNITY_HIGHLIGHTS: list[dict] = [ ] +UNDERWRITERS: list[dict] = [ + { + "name": "Mountain View Credit Union", + "description": "Proudly serving the mountain communities since 1952. Your local financial partner for savings, loans, and community investment.", + "logo_url": None, + "website_url": "https://example-mvcu.org", + "display_order": 1, + }, + { + "name": "Highland Medical Center", + "description": "Compassionate healthcare for every ridge and valley. From urgent care to wellness programs, we keep our community healthy.", + "logo_url": None, + "website_url": "https://example-highlandmedical.org", + "display_order": 2, + }, + { + "name": "Pine Street Hardware", + "description": "Family-owned since 1978. Everything you need for home improvement, outdoor living, and mountain-ready repairs.", + "logo_url": None, + "website_url": "https://example-pinestreet.com", + "display_order": 3, + }, + { + "name": "Summit Valley Farm", + "description": "Organic produce, fresh eggs, and seasonal CSA shares. Bringing farm-to-table flavor to the mountain towns since 2005.", + "logo_url": None, + "website_url": "https://example-summitvalley.com", + "display_order": 4, + }, +] + + # ── Helpers ──────────────────────────────────────────────── async def _upsert_event(session, data: dict) -> None: @@ -399,6 +431,19 @@ async def _upsert_community_highlight(session, data: dict) -> None: session.add(CommunityHighlight(**data, active=True)) +async def _upsert_underwriter(session, data: dict) -> None: + """Get-or-create an underwriter matched on name.""" + existing = await session.execute( + select(Underwriter).where(Underwriter.name == data["name"]) + ) + uw = existing.scalar_one_or_none() + if uw: + for key, value in data.items(): + setattr(uw, key, value) + else: + session.add(Underwriter(**data, active=True)) + + # ── Main ────────────────────────────────────────────────── async def seed() -> None: @@ -428,6 +473,11 @@ async def seed() -> None: await session.commit() print(f" ✓ Seeded {len(COMMUNITY_HIGHLIGHTS)} community highlights") + for data in UNDERWRITERS: + await _upsert_underwriter(session, data) + await session.commit() + print(f" ✓ Seeded {len(UNDERWRITERS)} underwriters") + # Make a copy since _upsert_show mutates the dict (pops 'schedules') import copy for data in copy.deepcopy(SHOWS): @@ -447,6 +497,7 @@ async def truncate_all() -> None: await session.execute(HistoryEntry.__table__.delete()) await session.execute(TeamMember.__table__.delete()) await session.execute(CommunityHighlight.__table__.delete()) + await session.execute(Underwriter.__table__.delete()) await session.commit() print(" ✓ Truncated all tables") diff --git a/src/app/admin/admin-underwriter-form.component.html b/src/app/admin/admin-underwriter-form.component.html new file mode 100644 index 0000000..d52b904 --- /dev/null +++ b/src/app/admin/admin-underwriter-form.component.html @@ -0,0 +1,67 @@ +
+
+
+

{{ id ? 'Edit Underwriter' : 'Add Underwriter' }}

+ +
+ + @if (error) { + + } + @if (success) { + + } + +
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ @if (logo_url) { + + } +
+ +
+ +
+ +
+ + +
+
+
+
diff --git a/src/app/admin/admin-underwriter-form.component.scss b/src/app/admin/admin-underwriter-form.component.scss new file mode 100644 index 0000000..0e3b5c0 --- /dev/null +++ b/src/app/admin/admin-underwriter-form.component.scss @@ -0,0 +1,168 @@ +@use '../../styles/variables' as *; +@use '../../styles/mixins' as *; + +.form-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + @include flex-center; + z-index: $z-modal; + @include fade-in; +} + +.form-dialog { + background: $neutral-white; + border-radius: $radius-lg; + padding: $spacing-xl; + width: 90%; + max-width: 560px; + box-shadow: $shadow-xl; + max-height: 90vh; + overflow-y: auto; +} + +.form-header { + @include flex-between; + margin-bottom: $spacing-lg; + + h2 { + font-family: $font-heading; + color: $primary-blue; + margin: 0; + font-size: 1.4rem; + } +} + +.btn-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: $neutral-medium; + line-height: 1; + padding: $spacing-xs; + + &:hover { + color: $neutral-dark; + } +} + +.form-error { + background: rgba($danger-red, 0.1); + color: $danger-red; + padding: $spacing-sm $spacing-md; + border-radius: $radius-md; + font-size: 0.875rem; + margin-bottom: $spacing-md; + text-align: center; +} + +.admin-form { + @include url-with-upload; + + .form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: $spacing-md; + } + + .form-group { + margin-bottom: $spacing-md; + + label:not(.btn-upload) { + display: block; + font-size: 0.875rem; + font-weight: 600; + color: $neutral-dark; + margin-bottom: $spacing-xs; + } + + input, select, textarea { + width: 100%; + padding: $spacing-sm $spacing-md; + border: 1px solid $neutral-light; + border-radius: $radius-md; + font-size: 0.95rem; + transition: border-color $transition-fast; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: $primary-blue; + box-shadow: 0 0 0 3px rgba($primary-blue, 0.15); + } + + &.is-invalid { + border-color: $danger-red; + box-shadow: 0 0 0 3px rgba($danger-red, 0.15); + } + } + } + + .checkbox-group { + label { + display: flex; + align-items: center; + gap: $spacing-sm; + font-weight: 500; + cursor: pointer; + + input[type="checkbox"] { + width: auto; + margin: 0; + } + } + } + + .form-success { + background: rgba(#2e7d32, 0.1); + color: #2e7d32; + padding: $spacing-sm $spacing-md; + border-radius: $radius-md; + font-size: 0.875rem; + margin-bottom: $spacing-md; + text-align: center; + } + + .image-preview { + display: block; + margin-top: $spacing-xs; + width: 48px; + height: 48px; + object-fit: cover; + border-radius: $radius-md; + border: 1px solid $neutral-light; + } +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: $spacing-sm; + margin-top: $spacing-md; +} + +.btn-save { + @include button-style($primary-blue, $neutral-white, $primary-blue-light); + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +.btn-cancel { + background: $neutral-light; + color: $neutral-dark; + border: none; + border-radius: $radius-md; + padding: $spacing-sm $spacing-lg; + font-size: 0.95rem; + cursor: pointer; + transition: background $transition-fast; + + &:hover { + background: $neutral-medium; + color: $neutral-white; + } +} diff --git a/src/app/admin/admin-underwriter-form.component.ts b/src/app/admin/admin-underwriter-form.component.ts new file mode 100644 index 0000000..fab8977 --- /dev/null +++ b/src/app/admin/admin-underwriter-form.component.ts @@ -0,0 +1,154 @@ +import { ChangeDetectorRef, Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { firstValueFrom } from 'rxjs'; + +import { Underwriter } from '../interfaces/underwriter'; +import { UnderwriterService } from '../services/underwriter.service'; +import { UploadService } from '../services/upload.service'; +import { resolveImageUrl } from '../services/app-config.service'; + +@Component({ + selector: 'app-admin-underwriter-form', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './admin-underwriter-form.component.html', + styleUrl: './admin-underwriter-form.component.scss', +}) +export class AdminUnderwriterFormComponent implements OnChanges { + private cdr = inject(ChangeDetectorRef); + private underwriterService = inject(UnderwriterService); + private uploadService = inject(UploadService); + + @Input() editItem: Underwriter | null = null; + @Output() saved = new EventEmitter(); + @Output() closed = new EventEmitter(); + + id: number | null = null; + name = ''; + description = ''; + website_url = ''; + logo_url = ''; + display_order = 0; + active = true; + + loading = false; + uploading = false; + error = ''; + success = ''; + submitted = false; + + ngOnChanges(changes: SimpleChanges): void { + if (changes['editItem'] && this.editItem) { + this.edit(this.editItem); + } + } + + edit(underwriter: Underwriter): void { + this.id = underwriter.id; + this.name = underwriter.name; + this.description = underwriter.description; + this.website_url = underwriter.website_url ?? ''; + this.logo_url = underwriter.logo_url ?? ''; + this.display_order = underwriter.display_order; + this.active = underwriter.active; + } + + formatError(err: any): string { + if (err?.error?.detail) return err.error.detail; + if (err?.error?.message) return err.error.message; + if (err?.message) return err.message; + return 'Failed to save. Please try again.'; + } + + reset(): void { + this.id = null; + this.name = ''; + this.description = ''; + this.website_url = ''; + this.logo_url = ''; + this.display_order = 0; + this.active = true; + this.error = ''; + this.success = ''; + this.submitted = false; + } + + onClose(): void { + this.closed.emit(); + this.reset(); + } + + /** Handle image file upload and set the logo URL field. */ + async onImageUpload(event: Event, field: string): Promise { + const input = event.target as HTMLInputElement; + if (!input?.files?.length) return; + + const file = input.files[0]; + if (!file.type.startsWith('image/')) { + this.error = 'Only image files are allowed.'; + this.cdr.detectChanges(); + return; + } + if (file.size > 5 * 1024 * 1024) { + this.error = 'File size exceeds 5 MB limit.'; + this.cdr.detectChanges(); + return; + } + + this.uploading = true; + this.error = ''; + this.success = ''; + this.cdr.detectChanges(); + + try { + const url = await this.uploadService.uploadImage(file); + (this as any)[field] = url; + this.success = 'Image uploaded successfully.'; + } catch (err: any) { + this.error = this.formatError(err); + } finally { + this.uploading = false; + this.cdr.detectChanges(); + } + } + + /** Resolve a relative uploads/… path to an absolute URL for preview. */ + getPreviewUrl(url: string): string { + return resolveImageUrl(url); + } + + async onSubmit(): Promise { + this.submitted = true; + if (!this.name || !this.description) { + this.error = 'Please fill in all required fields.'; + return; + } + + this.loading = true; + this.error = ''; + + const payload = { + name: this.name, + description: this.description, + website_url: this.website_url || null, + logo_url: this.logo_url || null, + display_order: this.display_order, + active: this.active, + }; + + try { + if (this.id) { + await firstValueFrom(this.underwriterService.updateUnderwriter(this.id, payload)); + } else { + await firstValueFrom(this.underwriterService.createUnderwriter(payload)); + } + this.saved.emit(); + this.reset(); + } catch (err: any) { + this.error = this.formatError(err); + } finally { + this.loading = false; + } + } +} diff --git a/src/app/admin/admin.component.html b/src/app/admin/admin.component.html index a18172d..fcbb63e 100644 --- a/src/app/admin/admin.component.html +++ b/src/app/admin/admin.component.html @@ -37,6 +37,11 @@ [class.active]="activeTab() === 'station'" (click)="activeTab.set('station')" >Station + @@ -299,6 +304,54 @@ } } + + + @if (activeTab() === 'underwriters') { + @if (underwriters$ | async; as underwriters) { +
+
+

Underwriters ({{ underwriters.length }})

+ +
+ + + + + + + + + + + + + + @for (uw of underwriters; track uw.id) { + + + + + + + + + } @empty { + + } + +
NameDescriptionWebsiteOrderActiveActions
{{ uw.name }}{{ uw.description }} + @if (uw.website_url) { + {{ uw.website_url }} + } @else { + — + } + {{ uw.display_order }}{{ uw.active ? 'Yes' : 'No' }} + + +
No underwriters yet. Click "Add Underwriter" to create one.
+
+ } + } @@ -320,4 +373,7 @@ @if (showCommunityForm) { } + @if (showUnderwriterForm) { + + } \ No newline at end of file diff --git a/src/app/admin/admin.component.ts b/src/app/admin/admin.component.ts index 749ae52..330d518 100644 --- a/src/app/admin/admin.component.ts +++ b/src/app/admin/admin.component.ts @@ -9,20 +9,23 @@ import { StationConfig } from '../interfaces/station-config'; import { HistoryEntry } from '../interfaces/history-entry'; import { TeamMember } from '../interfaces/team-member'; import { CommunityHighlight } from '../interfaces/community-highlight'; +import { Underwriter } from '../interfaces/underwriter'; import { ShowService } from '../services/show.service'; import { EventService } from '../services/event.service'; import { StationConfigService } from '../services/station-config.service'; import { HistoryEntryService } from '../services/history-entry.service'; import { TeamMemberService } from '../services/team-member.service'; import { CommunityHighlightService } from '../services/community-highlight.service'; +import { UnderwriterService } from '../services/underwriter.service'; import { AdminShowFormComponent } from './admin-show-form.component'; import { AdminEventFormComponent } from './admin-event-form.component'; import { AdminStationFormComponent } from './admin-station-form.component'; import { AdminHistoryFormComponent } from './admin-history-form.component'; import { AdminTeamFormComponent } from './admin-team-form.component'; import { AdminCommunityFormComponent } from './admin-community-form.component'; +import { AdminUnderwriterFormComponent } from './admin-underwriter-form.component'; -type TabKey = 'shows' | 'events' | 'station' | 'history' | 'team' | 'community'; +type TabKey = 'shows' | 'events' | 'station' | 'history' | 'team' | 'community' | 'underwriters'; @Component({ selector: 'app-admin', @@ -37,6 +40,7 @@ type TabKey = 'shows' | 'events' | 'station' | 'history' | 'team' | 'community'; AdminHistoryFormComponent, AdminTeamFormComponent, AdminCommunityFormComponent, + AdminUnderwriterFormComponent, ], templateUrl: './admin.component.html', styleUrl: './admin.component.scss', @@ -48,6 +52,7 @@ export class AdminComponent implements OnInit { private historyEntryService = inject(HistoryEntryService); private teamMemberService = inject(TeamMemberService); private communityHighlightService = inject(CommunityHighlightService); + private underwriterService = inject(UnderwriterService); activeTab = signal('shows'); @@ -57,6 +62,7 @@ export class AdminComponent implements OnInit { editingHistory: HistoryEntry | null = null; editingTeam: TeamMember | null = null; editingCommunity: CommunityHighlight | null = null; + editingUnderwriter: Underwriter | null = null; /** BehaviorSubjects guarantee change detection via the async pipe — start empty, populated on load. */ readonly shows$ = new BehaviorSubject([]); @@ -65,6 +71,7 @@ export class AdminComponent implements OnInit { readonly historyEntries$ = new BehaviorSubject([]); readonly teamMembers$ = new BehaviorSubject([]); readonly communityHighlights$ = new BehaviorSubject([]); + readonly underwriters$ = new BehaviorSubject([]); showShowForm = false; showEventForm = false; @@ -72,6 +79,7 @@ export class AdminComponent implements OnInit { showHistoryForm = false; showTeamForm = false; showCommunityForm = false; + showUnderwriterForm = false; ngOnInit(): void { this.loadAll(); @@ -107,6 +115,11 @@ export class AdminComponent implements OnInit { this.communityHighlights$.next(data); } + async reloadUnderwriters(): Promise { + const data = await firstValueFrom(this.underwriterService.getUnderwriters()); + this.underwriters$.next(data); + } + async loadAll(): Promise { await Promise.all([ this.reloadShows(), @@ -115,6 +128,7 @@ export class AdminComponent implements OnInit { this.reloadHistoryEntries(), this.reloadTeamMembers(), this.reloadCommunityHighlights(), + this.reloadUnderwriters(), ]); } @@ -277,4 +291,33 @@ export class AdminComponent implements OnInit { await firstValueFrom(this.communityHighlightService.deleteCommunityHighlight(id)); await this.reloadCommunityHighlights(); } + + // ── Underwriter CRUD ──────────────────────────────────── + + openUnderwriterForm(): void { + this.editingUnderwriter = null; + this.showUnderwriterForm = true; + } + + editUnderwriter(underwriter: Underwriter): void { + this.editingUnderwriter = underwriter; + this.showUnderwriterForm = true; + } + + onUnderwriterSaved(): void { + this.reloadUnderwriters(); + this.showUnderwriterForm = false; + this.editingUnderwriter = null; + } + + onUnderwriterClosed(): void { + this.showUnderwriterForm = false; + this.editingUnderwriter = null; + } + + async deleteUnderwriter(id: number): Promise { + if (!confirm('Delete this underwriter?')) return; + await firstValueFrom(this.underwriterService.deleteUnderwriter(id)); + await this.reloadUnderwriters(); + } } diff --git a/src/app/hero/hero.component.html b/src/app/hero/hero.component.html index b8d6a07..329a888 100644 --- a/src/app/hero/hero.component.html +++ b/src/app/hero/hero.component.html @@ -41,4 +41,69 @@ + + + @if (selectedUnderwriter()) { + @let uw = selectedUnderwriter()!; +
+
+ +
+ @if (uw.logo_url) { + + } +

{{ uw.name }}

+

{{ uw.description }}

+ @if (uw.website_url) { + Visit Website + } +
+
+
+ } + + +@if ((underwriters()).length >= 2) { +
+

{{ config().callsign }} is brought to you with the generosity of our underwriters.

+ +
+} diff --git a/src/app/hero/hero.component.scss b/src/app/hero/hero.component.scss index 340be29..555a2c3 100644 --- a/src/app/hero/hero.component.scss +++ b/src/app/hero/hero.component.scss @@ -151,13 +151,208 @@ } } -// Responsive +// ── Underwriter Horizontal Carousel ───────────────────────── + +.underwriters-section { + background: $neutral-cream; + border-top: 2px solid $neutral-light; + border-bottom: 2px solid $neutral-light; +} + +.underwriters-label { + text-align: center; + padding: $spacing-lg $spacing-lg $spacing-md; + margin: 0; + font-family: $font-heading; + font-size: clamp(1rem, 2vw, 1.3rem); + font-weight: 600; + color: $neutral-dark; + letter-spacing: 0.02em; +} + +.underwriters-carousel { + width: 100%; + overflow: hidden; + padding: clamp($spacing-xl, 4vh, $spacing-3xl) 0; +} + +.carousel-track { + display: flex; + gap: clamp($spacing-xl, 4vw, $spacing-3xl); + width: max-content; + animation: scroll-carousel 30s linear infinite; + + &.paused { + animation-play-state: paused; + } +} + +@keyframes scroll-carousel { + from { transform: translateX(0); } + to { transform: translateX(-50%); } +} + +.carousel-slide { + flex-shrink: 0; + background: none; + border: none; + padding: 0; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + outline: none; + + &:focus-visible { + .carousel-logo, + .carousel-badge { + outline: 2px solid $accent-orange; + outline-offset: 2px; + } + } +} + +.carousel-logo { + display: block; + width: clamp(180px, 12vw, 300px); + height: clamp(100px, 6.5vw, 170px); + object-fit: contain; + border-radius: $radius-md; + background: rgba(255, 255, 255, 0.95); + padding: clamp(10px, 1.2vw, 18px); + box-shadow: $shadow-md; + transition: transform $transition-fast, box-shadow $transition-fast; + + .carousel-slide:hover & { + transform: scale(1.08); + box-shadow: $shadow-lg; + } +} + +.carousel-badge { + display: flex; + align-items: center; + justify-content: center; + width: clamp(100px, 6.5vw, 170px); + height: clamp(100px, 6.5vw, 170px); + border-radius: $radius-md; + background: rgba(255, 255, 255, 0.95); + border: 2px solid $neutral-light; + box-shadow: $shadow-md; + font-family: $font-heading; + font-size: clamp(1.8rem, 3vw, 3rem); + font-weight: 700; + color: $primary-blue; + transition: transform $transition-fast, box-shadow $transition-fast; + + .carousel-slide:hover & { + transform: scale(1.08); + box-shadow: $shadow-lg; + } +} + +// ── Takeover Overlay ──────────────────────────────────────── + +.takeover-overlay { + position: fixed; + inset: 0; + z-index: $z-modal; + background: rgba($primary-blue-dark, 0.85); + display: flex; + align-items: center; + justify-content: center; + padding: $spacing-lg; + @include fade-in; +} + +.takeover-dialog { + position: relative; + background: $neutral-white; + border-radius: $radius-xl; + padding: $spacing-2xl $spacing-xl $spacing-xl; + max-width: 520px; + width: 100%; + text-align: center; + box-shadow: $shadow-xl; + animation: takeover-slide-up 0.3s ease-out; +} + +@keyframes takeover-slide-up { + from { + opacity: 0; + transform: translateY(30px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.takeover-close { + position: absolute; + top: $spacing-md; + right: $spacing-md; + background: none; + border: none; + font-size: 1.8rem; + line-height: 1; + cursor: pointer; + color: $neutral-medium; + padding: $spacing-xs; + transition: color $transition-fast; + + &:hover { + color: $neutral-dark; + } +} + +.takeover-content { + margin-top: $spacing-md; +} + +.takeover-logo { + display: block; + width: 140px; + height: 140px; + margin: 0 auto $spacing-md; + border-radius: $radius-lg; + object-fit: contain; + background: $neutral-cream; + border: 1px solid $neutral-light; +} + +.takeover-name { + font-family: $font-heading; + font-size: 1.6rem; + color: $primary-blue; + margin: 0 0 $spacing-sm; +} + +.takeover-description { + font-size: 1.05rem; + color: $neutral-dark; + line-height: 1.7; + margin: 0 0 $spacing-lg; + max-width: 420px; + margin-left: auto; + margin-right: auto; +} + +.takeover-visit { + @include button-style($accent-orange, $neutral-white, $accent-orange-dark); + font-size: 1rem; + padding: $spacing-sm $spacing-xl; + letter-spacing: 0.03em; +} + +// ── Responsive ────────────────────────────────────────────── + @media (max-width: #{$bp-md - 1px}) { .hero-line1 { font-size: 2.5rem; } .hero-line2 { font-size: 2.2rem; } .hero-subtitle { font-size: 1rem; } .hero-flower { width: 70px; height: 70px; } .hero-actions { flex-direction: column; align-items: center; } + + // Carousel clamp() handles responsive sizing automatically } @media (max-width: #{$bp-sm - 1px}) { diff --git a/src/app/hero/hero.component.ts b/src/app/hero/hero.component.ts index 4b39e5c..216b6f6 100644 --- a/src/app/hero/hero.component.ts +++ b/src/app/hero/hero.component.ts @@ -1,20 +1,62 @@ -import { Component, inject } from '@angular/core'; +import { Component, inject, OnInit, signal } from '@angular/core'; import { RouterLink } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { firstValueFrom } from 'rxjs'; import { StationConfigService } from '../services/station-config.service'; +import { UnderwriterService } from '../services/underwriter.service'; +import { Underwriter } from '../interfaces/underwriter'; import { resolveImageUrl } from '../services/app-config.service'; @Component({ selector: 'app-hero', standalone: true, - imports: [RouterLink], + imports: [RouterLink, CommonModule], templateUrl: './hero.component.html', styleUrl: './hero.component.scss', }) -export class HeroComponent { +export class HeroComponent implements OnInit { readonly config = inject(StationConfigService).config; + private underwriterService = inject(UnderwriterService); + + readonly underwriters = signal([]); + readonly selectedUnderwriter = signal(null); + + carouselPaused = false; + + ngOnInit(): void { + this.loadUnderwriters(); + } + + async loadUnderwriters(): Promise { + try { + const data = await firstValueFrom(this.underwriterService.getUnderwriters(true)); + this.underwriters.set(data); + } catch { + // Silently fail — carousel simply won't render + } + } resolveImageUrl(url: string): string { return resolveImageUrl(url); } + + pauseCarousel(): void { + this.carouselPaused = true; + } + + resumeCarousel(): void { + this.carouselPaused = false; + } + + openTakeover(uw: Underwriter): void { + this.selectedUnderwriter.set(uw); + this.carouselPaused = true; + } + + closeTakeover(): void { + this.selectedUnderwriter.set(null); + this.carouselPaused = false; + } + } diff --git a/src/app/interfaces/underwriter.ts b/src/app/interfaces/underwriter.ts new file mode 100644 index 0000000..f6b12da --- /dev/null +++ b/src/app/interfaces/underwriter.ts @@ -0,0 +1,9 @@ +export interface Underwriter { + id: number; + name: string; + logo_url: string | null; + description: string; + website_url: string | null; + display_order: number; + active: boolean; +} diff --git a/src/app/services/underwriter.service.ts b/src/app/services/underwriter.service.ts new file mode 100644 index 0000000..4753f02 --- /dev/null +++ b/src/app/services/underwriter.service.ts @@ -0,0 +1,37 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { Underwriter } from '../interfaces/underwriter'; +import { getAppConfig } from './app-config.service'; + +@Injectable({ + providedIn: 'root', +}) +export class UnderwriterService { + private http = inject(HttpClient); + private baseUrl = `${getAppConfig().apiBaseUrl}/api/underwriters`; + + /** Fetch underwriters, optionally filtered by active status. */ + getUnderwriters(active?: boolean): Observable { + let params = new HttpParams(); + if (active !== undefined) params = params.set('active', String(active)); + return this.http.get(this.baseUrl, { params }); + } + + getUnderwriter(id: number): Observable { + return this.http.get(`${this.baseUrl}/${id}`); + } + + createUnderwriter(payload: Omit): Observable { + return this.http.post(this.baseUrl, payload); + } + + updateUnderwriter(id: number, payload: Partial): Observable { + return this.http.put(`${this.baseUrl}/${id}`, payload); + } + + deleteUnderwriter(id: number): Observable { + return this.http.delete(`${this.baseUrl}/${id}`); + } +}