Retires Support/donation/tiers page in favor of simple configurable link

This commit is contained in:
2026-06-23 00:30:47 +00:00
parent 86fbfa56bf
commit 9b95bef9c6
34 changed files with 124 additions and 924 deletions
+3 -3
View File
@@ -2,12 +2,12 @@
## Project overview ## Project overview
A three-tier web app for a community radio station: Angular 21 frontend, FastAPI backend, PostgreSQL database. Renders a hero page, about page, broadcast schedule, donation tiers, upcoming events, and a contact form — styled with a warm mountain/nature color palette (blue primary + orange accent on cream). A three-tier web app for a community radio station: Angular 21 frontend, FastAPI backend, PostgreSQL database. Renders a hero page, about page, broadcast schedule, upcoming events, and a contact form — styled with a warm mountain/nature color palette (blue primary + orange accent on cream). Donations route to an externally configured URL.
## Code style ## Code style
- **Components:** Standalone only (no NgModules). Every component is a single file with `@Component` decorator, `imports` array, and four standard files (`.ts`, `.html`, `.scss`, component). - **Components:** Standalone only (no NgModules). Every component is a single file with `@Component` decorator, `imports` array, and four standard files (`.ts`, `.html`, `.scss`, component).
- **Data patterns:** Page content (programs, events, tiers) is stored in PostgreSQL and served via the FastAPI backend. The frontend uses HTTP services ([src/app/services/](src/app/services/)) to fetch data at runtime. To add or modify content, interact with the API or run `seed.py` — never hardcode content in components. - **Data patterns:** Page content (programs, events) is stored in PostgreSQL and served via the FastAPI backend. The frontend uses HTTP services ([src/app/services/](src/app/services/)) to fetch data at runtime. To add or modify content, interact with the API or run `seed.py` — never hardcode content in components.
- **SCSS:** All design tokens go in [src/styles/_variables.scss](src/styles/_variables.scss). Mixins in [_mixins.scss](src/styles/_mixins.scss). CSS custom properties exposed in [_themes.scss](src/styles/_themes.scss). Components import via `@use 'variables'` (no relative path needed). - **SCSS:** All design tokens go in [src/styles/_variables.scss](src/styles/_variables.scss). Mixins in [_mixins.scss](src/styles/_mixins.scss). CSS custom properties exposed in [_themes.scss](src/styles/_themes.scss). Components import via `@use 'variables'` (no relative path needed).
- **Routes:** Defined in [src/app/app.routes.ts](src/app/app.routes.ts) using `loadComponent()` lazy loading. Add new pages here after creating the component. - **Routes:** Defined in [src/app/app.routes.ts](src/app/app.routes.ts) using `loadComponent()` lazy loading. Add new pages here after creating the component.
- **TypeScript:** Strict mode enabled. Use `readonly` for constants (config, URLs). Keep data shapes close to their components where used only once. - **TypeScript:** Strict mode enabled. Use `readonly` for constants (config, URLs). Keep data shapes close to their components where used only once.
@@ -24,7 +24,7 @@ See [README.md](README.md) for a full architecture map. Key files:
- `src/styles/_themes.scss` — SCSS → CSS custom property mapping - `src/styles/_themes.scss` — SCSS → CSS custom property mapping
- `docker-compose.dev.yml` — dev-sidecar compose for VS Code dev container - `docker-compose.dev.yml` — dev-sidecar compose for VS Code dev container
- `backend/app/main.py` — FastAPI entry point (CORS, router registration) - `backend/app/main.py` — FastAPI entry point (CORS, router registration)
- `backend/app/models.py` — SQLAlchemy ORM (Program, Event, DonationTier) - `backend/app/models.py` — SQLAlchemy ORM (Show, Event, StationConfig)
## Common tasks ## Common tasks
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -2
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter
from app.config import settings from app.config import settings
from app.database import get_session from app.database import get_session
from app.models import DonationTier, Event, StationConfig, Show, ShowSchedule from app.models import Event, StationConfig, Show, ShowSchedule
router = APIRouter() router = APIRouter()
@@ -20,7 +20,6 @@ async def reset_database():
await session.execute(ShowSchedule.__table__.delete()) await session.execute(ShowSchedule.__table__.delete())
await session.execute(Show.__table__.delete()) await session.execute(Show.__table__.delete())
await session.execute(Event.__table__.delete()) await session.execute(Event.__table__.delete())
await session.execute(DonationTier.__table__.delete())
await session.execute(StationConfig.__table__.delete()) await session.execute(StationConfig.__table__.delete())
await session.commit() await session.commit()
-73
View File
@@ -1,73 +0,0 @@
from fastapi import APIRouter, Depends
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 DonationTier
from app.schemas import TierCreate, TierUpdate, TierResponse
from app.user_models import User
router = APIRouter()
@router.get("/", response_model=list[TierResponse])
async def list_tiers(session: AsyncSession = Depends(get_session)):
result = await session.execute(
select(DonationTier).order_by(DonationTier.display_order)
)
return list(result.scalars().all())
@router.get("/{tier_id}", response_model=TierResponse)
async def get_tier(tier_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id))
tier = result.scalar_one_or_none()
if not tier:
return {"error": "Tier not found"}
return tier
@router.post("/", response_model=TierResponse, status_code=201)
async def create_tier(
payload: TierCreate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
tier = DonationTier(**payload.model_dump())
session.add(tier)
await session.commit()
await session.refresh(tier)
return tier
@router.put("/{tier_id}", response_model=TierResponse)
async def update_tier(
tier_id: int,
payload: TierUpdate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id))
tier = result.scalar_one_or_none()
if not tier:
return {"error": "Tier not found"}
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(tier, key, value)
await session.commit()
await session.refresh(tier)
return tier
@router.delete("/{tier_id}", status_code=204)
async def delete_tier(
tier_id: int,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id))
tier = result.scalar_one_or_none()
if not tier:
return {"error": "Tier not found"}
await session.delete(tier)
await session.commit()
+1 -2
View File
@@ -8,7 +8,7 @@ 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
from app.user_models import User from app.user_models import User
from app.api import events, tiers, auth, station_config, history, team, community, shows, storage from app.api import events, auth, station_config, history, team, community, shows, storage
async def _ensure_station_config(session): async def _ensure_station_config(session):
@@ -87,7 +87,6 @@ app.add_middleware(
# Register routers # Register routers
app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(events.router, prefix="/api/events", tags=["events"]) app.include_router(events.router, prefix="/api/events", tags=["events"])
app.include_router(tiers.router, prefix="/api/tiers", tags=["tiers"])
app.include_router(station_config.router, prefix="/api/station-config", tags=["station-config"]) app.include_router(station_config.router, prefix="/api/station-config", tags=["station-config"])
app.include_router(history.router, prefix="/api/history", tags=["history"]) app.include_router(history.router, prefix="/api/history", tags=["history"])
app.include_router(team.router, prefix="/api/team", tags=["team"]) app.include_router(team.router, prefix="/api/team", tags=["team"])
+3 -13
View File
@@ -7,10 +7,8 @@ from sqlalchemy import (
LargeBinary, LargeBinary,
String, String,
Text, Text,
Numeric,
Boolean, Boolean,
Date, Date,
JSON,
ForeignKey, ForeignKey,
UniqueConstraint, UniqueConstraint,
) )
@@ -63,17 +61,6 @@ class Event(Base):
active = Column(Boolean, default=True) active = Column(Boolean, default=True)
class DonationTier(Base):
__tablename__ = "donation_tiers"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(50), unique=True, nullable=False)
amount = Column(Numeric(10, 2), nullable=False)
icon = Column(String(10), nullable=False)
benefits = Column(JSON, nullable=False)
display_order = Column(Integer, nullable=False)
class StationConfig(Base): class StationConfig(Base):
__tablename__ = "station_config" __tablename__ = "station_config"
@@ -104,6 +91,9 @@ class StationConfig(Base):
email = Column(String(100), nullable=False, default="hello@kmountainflower.org") email = Column(String(100), nullable=False, default="hello@kmountainflower.org")
website = Column(String(200), nullable=False, default="kmountainflower.org") website = Column(String(200), nullable=False, default="kmountainflower.org")
# Donation
donation_url = Column(String(500), nullable=False, default="")
class HistoryEntry(Base): class HistoryEntry(Base):
__tablename__ = "history_entries" __tablename__ = "history_entries"
+2 -29
View File
@@ -51,35 +51,6 @@ class EventResponse(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
# ── DonationTier ─────────────────────────────────────────
class TierCreate(BaseModel):
name: str
amount: float
icon: str
benefits: list[str]
display_order: int
class TierUpdate(BaseModel):
name: Optional[str] = None
amount: Optional[float] = None
icon: Optional[str] = None
benefits: Optional[list[str]] = None
display_order: Optional[int] = None
class TierResponse(BaseModel):
id: int
name: str
amount: float
icon: str
benefits: list[str]
display_order: int
model_config = {"from_attributes": True}
# ── StationConfig ───────────────────────────────────────── # ── StationConfig ─────────────────────────────────────────
class StationConfigResponse(BaseModel): class StationConfigResponse(BaseModel):
@@ -101,6 +72,7 @@ class StationConfigResponse(BaseModel):
phone: str phone: str
email: str email: str
website: str website: str
donation_url: str
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -122,6 +94,7 @@ class StationConfigUpdate(BaseModel):
phone: Optional[str] = None phone: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
website: Optional[str] = None website: Optional[str] = None
donation_url: Optional[str] = None
# ── HistoryEntry ────────────────────────────────────────── # ── HistoryEntry ──────────────────────────────────────────
+2 -51
View File
@@ -14,7 +14,7 @@ from datetime import date
from sqlalchemy import select from sqlalchemy import select
from app.database import async_session from app.database import async_session
from app.models import Show, ShowSchedule, Event, DonationTier, StationConfig from app.models import Show, ShowSchedule, Event, StationConfig
from app.models import HistoryEntry, TeamMember, CommunityHighlight from app.models import HistoryEntry, TeamMember, CommunityHighlight
# ── Seed data ────────────────────────────────────────────── # ── Seed data ──────────────────────────────────────────────
@@ -193,37 +193,6 @@ EVENTS: list[dict] = [
}, },
] ]
TIERS: list[dict] = [
{
"name": "Seed",
"amount": 5.00,
"icon": "🌱",
"benefits": ["Digital thank-you card", "Name listed on our website", "Quarterly newsletter"],
"display_order": 1,
},
{
"name": "Stream",
"amount": 15.00,
"icon": "🎵",
"benefits": ["All Seed benefits", "KMountain Flower pin", "Priority show-request line", "Annual station update call"],
"display_order": 2,
},
{
"name": "Ridge",
"amount": 30.00,
"icon": "🏔️",
"benefits": ["All Stream benefits", "Official member patch", "Exclusive show invitations", "Annual station tour"],
"display_order": 3,
},
{
"name": "Summit",
"amount": 60.00,
"icon": "",
"benefits": ["All Ridge benefits", "Featured on the Summit Wall", "Name on-air during anniversary special", "Lifetime patron recognition"],
"display_order": 4,
},
]
STATION_CONFIG: dict = { STATION_CONFIG: dict = {
"key": "default", "key": "default",
"callsign": "KMTN", "callsign": "KMTN",
@@ -242,6 +211,7 @@ STATION_CONFIG: dict = {
"phone": "(970) 555-0198", "phone": "(970) 555-0198",
"email": "hello@kmountainflower.org", "email": "hello@kmountainflower.org",
"website": "kmountainflower.org", "website": "kmountainflower.org",
"donation_url": "",
} }
HISTORY_ENTRIES: list[dict] = [ HISTORY_ENTRIES: list[dict] = [
@@ -348,19 +318,6 @@ async def _upsert_event(session, data: dict) -> None:
session.add(Event(**data, active=True)) session.add(Event(**data, active=True))
async def _upsert_tier(session, data: dict) -> None:
"""Get-or-create a donation tier matched on name."""
existing = await session.execute(
select(DonationTier).where(DonationTier.name == data["name"])
)
tier = existing.scalar_one_or_none()
if tier:
for key, value in data.items():
setattr(tier, key, value)
else:
session.add(DonationTier(**data))
async def _upsert_station_config(session, data: dict) -> None: async def _upsert_station_config(session, data: dict) -> None:
"""Get-or-create the singleton station config (key='default').""" """Get-or-create the singleton station config (key='default')."""
existing = await session.execute( existing = await session.execute(
@@ -452,11 +409,6 @@ async def seed() -> None:
await session.commit() await session.commit()
print(f" ✓ Seeded {len(EVENTS)} events") print(f" ✓ Seeded {len(EVENTS)} events")
for data in TIERS:
await _upsert_tier(session, data)
await session.commit()
print(f" ✓ Seeded {len(TIERS)} donation tiers")
await _upsert_station_config(session, STATION_CONFIG) await _upsert_station_config(session, STATION_CONFIG)
await session.commit() await session.commit()
print(" ✓ Seeded station config") print(" ✓ Seeded station config")
@@ -491,7 +443,6 @@ async def truncate_all() -> None:
await session.execute(ShowSchedule.__table__.delete()) await session.execute(ShowSchedule.__table__.delete())
await session.execute(Show.__table__.delete()) await session.execute(Show.__table__.delete())
await session.execute(Event.__table__.delete()) await session.execute(Event.__table__.delete())
await session.execute(DonationTier.__table__.delete())
await session.execute(StationConfig.__table__.delete()) await session.execute(StationConfig.__table__.delete())
await session.execute(HistoryEntry.__table__.delete()) await session.execute(HistoryEntry.__table__.delete())
await session.execute(TeamMember.__table__.delete()) await session.execute(TeamMember.__table__.delete())
+76
View File
@@ -0,0 +1,76 @@
# Retire Donation Page, Add Configurable External Donation URL
## Context
The donation page (`/donate`) is being retired — it was a tier-based landing page with calls to action. All donation links will instead point to an external URL (e.g., a nonprofit payment processor). Admins configure this URL in the existing Station Config screen.
## Approach
1. Add `donation_url` field to the existing `StationConfig` singleton (backend model + schema, frontend interface + admin form)
2. Replace all donation links across the site with external `<a href>` links driven by `config().donation_url`, hidden when empty
3. Delete the donation page, tier service, tier admin UI, and all backend tier infrastructure
## Step-by-Step
### Phase 1 — Add `donation_url` to StationConfig (backend)
- **`backend/app/models.py`** — Add `donation_url = Column(String(500), nullable=False, default="")` to `StationConfig` class
- **`backend/app/schemas.py`** — Add `donation_url: str` to `StationConfigResponse`; add `donation_url: Optional[str] = None` to `StationConfigUpdate`
- **`backend/seed.py`** — Add `"donation_url": ""` to `STATION_CONFIG` dict
### Phase 2 — Add `donation_url` to StationConfig (frontend)
- **`src/app/interfaces/station-config.ts`** — Add `donation_url: string`
- **`src/app/services/station-config.service.ts`** — Add `donation_url: ''` to `DEFAULT_CONFIG`
- **`src/app/admin/admin-station-form.component.ts`** — Add `donation_url` property, wire it in `populate()` and `onSubmit()` payload
- **`src/app/admin/admin-station-form.component.html`** — Add a "Donation" form section with a URL input field
- **`src/app/admin/admin.component.html`** — Show `donation_url` in the station config summary view
### Phase 3 — Replace donation links with external links
All links switch from `routerLink="/donate"` to `[href]="config().donation_url"` with `target="_blank" rel="noopener noreferrer"`, wrapped in `@if (config().donation_url)` so they hide when empty.
- **`src/app/navbar/navbar.component.html`** — "Donate Now" button (line 37)
- **`src/app/hero/hero.component.html`** — "Support the Station" button (line 28)
- **`src/app/about/about.component.html`** — "Become a Member" CTA section (lines 76-88), wrap entire block in `@if`
- **`src/app/footer/footer.component.html`** — "Donate" and "Become a Member" links (lines 26, 33)
### Phase 4 — Delete donation page
- Remove `/donate` route from **`src/app/app.routes.ts`** (lines 22-25)
- Delete **`src/app/donate/`** directory (3 files: `.ts`, `.html`, `.scss`)
### Phase 5 — Remove tier infrastructure (frontend)
- Delete **`src/app/interfaces/tier.ts`**
- Delete **`src/app/services/tier.service.ts`**
- Delete **`src/app/admin/admin-tier-form.component.*`** (3 files)
- **`src/app/admin/admin.component.ts`** — Remove `Tier`/`TierService`/`AdminTierFormComponent` imports, `TabKey` entry, injected service, `tiers$` BehaviorSubject, `editingTier`, `showTierForm`, `reloadTiers()`, and all tier CRUD methods
- **`src/app/admin/admin.component.html`** — Remove Tiers tab button, Tiers tab content panel, tier form modal
### Phase 6 — Remove tier infrastructure (backend)
- Delete **`backend/app/api/tiers.py`**
- **`backend/app/models.py`** — Delete `DonationTier` class
- **`backend/app/schemas.py`** — Delete `TierCreate`, `TierUpdate`, `TierResponse`
- **`backend/app/main.py`** — Remove `tiers` import and router registration
- **`backend/app/api/admin.py`** — Remove `DonationTier` from reset truncation
- **`backend/seed.py`** — Remove `DonationTier` import, `TIERS` list, `_upsert_tier()` helper, tier seeding, tier truncation
### Phase 7 — Contact form cleanup
- **`src/app/contact/contact.component.html`** — Remove `<option value="donation">Donation Question</option>`
## Execution Order
1 → 2 → 3 → 4 → 5 → 6 → 7
## Verification
1. Grep for remaining references to `Tier`, `tier`, `DonationTier`, `donate`, `/donate` — none should remain in live code
2. `ng build` — no import errors, no unresolved references
3. Backend starts without import errors
4. Admin station form shows the new "External Donation URL" field
5. Setting a URL → all 4 link locations render external links with `target="_blank"`
6. Clearing the URL → all 4 link locations hide
7. Navigate to `/donate` → 404 or wildcard redirect to home
+3 -1
View File
@@ -74,6 +74,7 @@
} }
<!-- Donation CTA --> <!-- Donation CTA -->
@if (config().donation_url) {
<div class="about-cta"> <div class="about-cta">
<div class="about-flower-divider" aria-hidden="true"> <div class="about-flower-divider" aria-hidden="true">
<img [src]="resolveImageUrl(config().logo_url)" alt=""> <img [src]="resolveImageUrl(config().logo_url)" alt="">
@@ -84,7 +85,8 @@
signal strong. Members receive exclusive perks and the satisfaction of signal strong. Members receive exclusive perks and the satisfaction of
knowing they helped make this possible. knowing they helped make this possible.
</p> </p>
<a routerLink="/donate" class="btn">Join the Station</a> <a [href]="config().donation_url" target="_blank" rel="noopener noreferrer" class="btn">Join the Station</a>
</div> </div>
}
</div> </div>
</section> </section>
+1 -2
View File
@@ -1,5 +1,4 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { AsyncPipe } from '@angular/common'; import { AsyncPipe } from '@angular/common';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
@@ -15,7 +14,7 @@ import { resolveImageUrl } from '../services/app-config.service';
@Component({ @Component({
selector: 'app-about', selector: 'app-about',
standalone: true, standalone: true,
imports: [RouterLink, AsyncPipe], imports: [AsyncPipe],
templateUrl: './about.component.html', templateUrl: './about.component.html',
styleUrl: './about.component.scss', styleUrl: './about.component.scss',
}) })
@@ -130,6 +130,15 @@
</div> </div>
</div> </div>
<!-- Donation -->
<div class="form-section">
<h3>Donation</h3>
<div class="form-group">
<label for="donation_url">External Donation URL</label>
<input id="donation_url" type="url" [(ngModel)]="donation_url" name="donation_url" placeholder="https://example.com/donate">
</div>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn btn-cancel" (click)="onClose()">Cancel</button> <button type="button" class="btn btn-cancel" (click)="onClose()">Cancel</button>
<button type="submit" class="btn btn-save" [disabled]="saving"> <button type="submit" class="btn btn-save" [disabled]="saving">
@@ -37,6 +37,7 @@ export class AdminStationFormComponent implements OnChanges {
phone = ''; phone = '';
email = ''; email = '';
website = ''; website = '';
donation_url = '';
loading = false; loading = false;
saving = false; saving = false;
@@ -67,6 +68,7 @@ export class AdminStationFormComponent implements OnChanges {
this.phone = config.phone; this.phone = config.phone;
this.email = config.email; this.email = config.email;
this.website = config.website; this.website = config.website;
this.donation_url = config.donation_url;
} }
/** Reset form with current service values. */ /** Reset form with current service values. */
@@ -152,6 +154,7 @@ export class AdminStationFormComponent implements OnChanges {
phone: this.phone, phone: this.phone,
email: this.email, email: this.email,
website: this.website, website: this.website,
donation_url: this.donation_url,
}; };
try { try {
@@ -1,50 +0,0 @@
<div class="form-overlay" (click)="onClose()">
<div class="form-dialog" (click)="$event.stopPropagation()">
<div class="form-header">
<h2>{{ id ? 'Edit Tier' : 'Add Tier' }}</h2>
<button type="button" class="btn-close" (click)="onClose()">&times;</button>
</div>
@if (error) {
<div class="form-error" role="alert">{{ error }}</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="Tier name" required [class.is-invalid]="submitted && !name">
</div>
<div class="form-group">
<label for="amount">Amount ($)</label>
<input id="amount" type="number" step="0.01" min="0" [(ngModel)]="amount" name="amount" required [class.is-invalid]="submitted && amount <= 0">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="icon">Icon</label>
<input id="icon" type="text" [(ngModel)]="icon" name="icon" placeholder="⭐" maxlength="10">
</div>
<div class="form-group">
<label for="display_order">Display Order</label>
<input id="display_order" type="number" min="1" [(ngModel)]="display_order" name="display_order" required>
</div>
</div>
<div class="form-group">
<label for="benefits">Benefits (comma-separated)</label>
<textarea id="benefits" [(ngModel)]="benefits_text" name="benefits" rows="3" placeholder="Benefit 1, Benefit 2, Benefit 3"></textarea>
</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>
@@ -1,129 +0,0 @@
@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;
}
.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 {
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-md;
}
.form-group {
margin-bottom: $spacing-md;
label {
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);
}
}
}
}
.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;
}
}
-109
View File
@@ -1,109 +0,0 @@
import { 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 { Tier } from '../interfaces/tier';
import { TierService } from '../services/tier.service';
@Component({
selector: 'app-admin-tier-form',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './admin-tier-form.component.html',
styleUrl: './admin-tier-form.component.scss',
})
export class AdminTierFormComponent implements OnChanges {
private tierService = inject(TierService);
@Input() editItem: Tier | null = null;
@Output() saved = new EventEmitter<void>();
@Output() closed = new EventEmitter<void>();
id: number | null = null;
name = '';
amount = 0;
icon = '⭐';
benefits_text = '';
display_order = 1;
loading = false;
error = '';
submitted = false;
ngOnChanges(changes: SimpleChanges): void {
if (changes['editItem'] && this.editItem) {
this.edit(this.editItem);
}
}
edit(tier: Tier): void {
this.id = tier.id;
this.name = tier.name;
this.amount = tier.amount;
this.icon = tier.icon;
this.benefits_text = tier.benefits.join(', ');
this.display_order = tier.display_order;
}
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.amount = 0;
this.icon = '⭐';
this.benefits_text = '';
this.display_order = 1;
this.error = '';
this.submitted = false;
}
onClose(): void {
this.closed.emit();
this.reset();
}
async onSubmit(): Promise<void> {
this.submitted = true;
if (!this.name || this.amount <= 0) {
this.error = 'Please fill in all required fields.';
return;
}
this.loading = true;
this.error = '';
const benefits = this.benefits_text
.split(',')
.map((b) => b.trim())
.filter((b) => b.length > 0);
const payload = {
name: this.name,
amount: this.amount,
icon: this.icon,
benefits: benefits,
display_order: this.display_order,
};
try {
if (this.id) {
await firstValueFrom(this.tierService.updateTier(this.id, payload));
} else {
await firstValueFrom(this.tierService.createTier(payload));
}
this.saved.emit();
this.reset();
} catch (err: any) {
this.error = this.formatError(err);
} finally {
this.loading = false;
}
}
}
+5 -49
View File
@@ -2,7 +2,7 @@
<div class="container"> <div class="container">
<div class="admin-header"> <div class="admin-header">
<h1>Admin Dashboard</h1> <h1>Admin Dashboard</h1>
<p>Manage shows, events, tiers, history, team, and community content</p> <p>Manage shows, events, history, team, and community content</p>
</div> </div>
<!-- Tabs --> <!-- Tabs -->
@@ -17,11 +17,6 @@
[class.active]="activeTab() === 'events'" [class.active]="activeTab() === 'events'"
(click)="activeTab.set('events')" (click)="activeTab.set('events')"
>Events</button> >Events</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'tiers'"
(click)="activeTab.set('tiers')"
>Tiers</button>
<button <button
class="tab-btn" class="tab-btn"
[class.active]="activeTab() === 'history'" [class.active]="activeTab() === 'history'"
@@ -130,46 +125,6 @@
} }
} }
<!-- Tiers Tab -->
@if (activeTab() === 'tiers') {
@if (tiers$ | async; as tiers) {
<div class="tab-content">
<div class="tab-toolbar">
<h2>Donation Tiers ({{ tiers.length }})</h2>
<button class="btn btn-add" (click)="openTierForm()">+ Add Tier</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Order</th>
<th>Name</th>
<th>Amount</th>
<th>Benefits</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (tier of tiers; track tier.id) {
<tr>
<td>{{ tier.display_order }}</td>
<td>{{ tier.icon }} {{ tier.name }}</td>
<td>${{ tier.amount.toFixed(2) }}</td>
<td class="benefits-cell">{{ tier.benefits.join(', ') }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="editTier(tier)">Edit</button>
<button class="btn-icon btn-delete" (click)="deleteTier(tier.id)">Delete</button>
</td>
</tr>
} @empty {
<tr><td colspan="5" class="empty-row">No tiers yet. Click "Add Tier" to create one.</td></tr>
}
</tbody>
</table>
</div>
}
}
<!-- History Tab --> <!-- History Tab -->
@if (activeTab() === 'history') { @if (activeTab() === 'history') {
@if (historyEntries$ | async; as entries) { @if (historyEntries$ | async; as entries) {
@@ -336,6 +291,10 @@
<span class="config-label">Website</span> <span class="config-label">Website</span>
<span class="config-value">{{ config.website }}</span> <span class="config-value">{{ config.website }}</span>
</div> </div>
<div class="config-row">
<span class="config-label">Donation URL</span>
<span class="config-value">{{ config.donation_url || '(not set)' }}</span>
</div>
</div> </div>
</div> </div>
} }
@@ -349,9 +308,6 @@
@if (showEventForm) { @if (showEventForm) {
<app-admin-event-form [editItem]="editingEvent" (saved)="onEventSaved()" (closed)="onEventClosed()" /> <app-admin-event-form [editItem]="editingEvent" (saved)="onEventSaved()" (closed)="onEventClosed()" />
} }
@if (showTierForm) {
<app-admin-tier-form [editItem]="editingTier" (saved)="onTierSaved()" (closed)="onTierClosed()" />
}
@if (showStationForm) { @if (showStationForm) {
<app-admin-station-form [editItem]="stationConfig$ | async" (saved)="onStationSaved()" (closed)="onStationClosed()" /> <app-admin-station-form [editItem]="stationConfig$ | async" (saved)="onStationSaved()" (closed)="onStationClosed()" />
} }
+1 -44
View File
@@ -5,27 +5,24 @@ import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { Show } from '../interfaces/show'; import { Show } from '../interfaces/show';
import { Event } from '../interfaces/event'; import { Event } from '../interfaces/event';
import { Tier } from '../interfaces/tier';
import { StationConfig } from '../interfaces/station-config'; 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 { ShowService } from '../services/show.service'; import { ShowService } from '../services/show.service';
import { EventService } from '../services/event.service'; import { EventService } from '../services/event.service';
import { TierService } from '../services/tier.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 { 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 { AdminTierFormComponent } from './admin-tier-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';
type TabKey = 'shows' | 'events' | 'tiers' | 'station' | 'history' | 'team' | 'community'; type TabKey = 'shows' | 'events' | 'station' | 'history' | 'team' | 'community';
@Component({ @Component({
selector: 'app-admin', selector: 'app-admin',
@@ -36,7 +33,6 @@ type TabKey = 'shows' | 'events' | 'tiers' | 'station' | 'history' | 'team' | 'c
FormsModule, FormsModule,
AdminShowFormComponent, AdminShowFormComponent,
AdminEventFormComponent, AdminEventFormComponent,
AdminTierFormComponent,
AdminStationFormComponent, AdminStationFormComponent,
AdminHistoryFormComponent, AdminHistoryFormComponent,
AdminTeamFormComponent, AdminTeamFormComponent,
@@ -48,7 +44,6 @@ type TabKey = 'shows' | 'events' | 'tiers' | 'station' | 'history' | 'team' | 'c
export class AdminComponent implements OnInit { export class AdminComponent implements OnInit {
private showService = inject(ShowService); private showService = inject(ShowService);
private eventService = inject(EventService); private eventService = inject(EventService);
private tierService = inject(TierService);
private stationConfigService = inject(StationConfigService); private stationConfigService = inject(StationConfigService);
private historyEntryService = inject(HistoryEntryService); private historyEntryService = inject(HistoryEntryService);
private teamMemberService = inject(TeamMemberService); private teamMemberService = inject(TeamMemberService);
@@ -59,7 +54,6 @@ export class AdminComponent implements OnInit {
/** Item currently being edited (set by editXxx, cleared on save/close). */ /** Item currently being edited (set by editXxx, cleared on save/close). */
editingShow: Show | null = null; editingShow: Show | null = null;
editingEvent: Event | null = null; editingEvent: Event | null = null;
editingTier: Tier | null = null;
editingHistory: HistoryEntry | null = null; editingHistory: HistoryEntry | null = null;
editingTeam: TeamMember | null = null; editingTeam: TeamMember | null = null;
editingCommunity: CommunityHighlight | null = null; editingCommunity: CommunityHighlight | null = null;
@@ -67,7 +61,6 @@ export class AdminComponent implements OnInit {
/** 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[]>([]);
readonly events$ = new BehaviorSubject<Event[]>([]); readonly events$ = new BehaviorSubject<Event[]>([]);
readonly tiers$ = new BehaviorSubject<Tier[]>([]);
readonly stationConfig$ = new BehaviorSubject<StationConfig | null>(null); readonly stationConfig$ = new BehaviorSubject<StationConfig | null>(null);
readonly historyEntries$ = new BehaviorSubject<HistoryEntry[]>([]); readonly historyEntries$ = new BehaviorSubject<HistoryEntry[]>([]);
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]); readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
@@ -75,7 +68,6 @@ export class AdminComponent implements OnInit {
showShowForm = false; showShowForm = false;
showEventForm = false; showEventForm = false;
showTierForm = false;
showStationForm = false; showStationForm = false;
showHistoryForm = false; showHistoryForm = false;
showTeamForm = false; showTeamForm = false;
@@ -95,11 +87,6 @@ export class AdminComponent implements OnInit {
this.events$.next(data); this.events$.next(data);
} }
async reloadTiers(): Promise<void> {
const data = await firstValueFrom(this.tierService.getTiers());
this.tiers$.next(data);
}
async reloadStationConfig(): Promise<void> { async reloadStationConfig(): Promise<void> {
await this.stationConfigService.load(); await this.stationConfigService.load();
this.stationConfig$.next(this.stationConfigService.config()); this.stationConfig$.next(this.stationConfigService.config());
@@ -124,7 +111,6 @@ export class AdminComponent implements OnInit {
await Promise.all([ await Promise.all([
this.reloadShows(), this.reloadShows(),
this.reloadEvents(), this.reloadEvents(),
this.reloadTiers(),
this.reloadStationConfig(), this.reloadStationConfig(),
this.reloadHistoryEntries(), this.reloadHistoryEntries(),
this.reloadTeamMembers(), this.reloadTeamMembers(),
@@ -190,35 +176,6 @@ export class AdminComponent implements OnInit {
await this.reloadEvents(); await this.reloadEvents();
} }
// ── Tier CRUD ───────────────────────────────────────────
openTierForm(): void {
this.editingTier = null;
this.showTierForm = true;
}
editTier(tier: Tier): void {
this.editingTier = tier;
this.showTierForm = true;
}
onTierSaved(): void {
this.reloadTiers();
this.showTierForm = false;
this.editingTier = null;
}
onTierClosed(): void {
this.showTierForm = false;
this.editingTier = null;
}
async deleteTier(id: number): Promise<void> {
if (!confirm('Delete this tier?')) return;
await firstValueFrom(this.tierService.deleteTier(id));
await this.reloadTiers();
}
// ── Station Config ────────────────────────────────────── // ── Station Config ──────────────────────────────────────
openStationForm(): void { openStationForm(): void {
-5
View File
@@ -18,11 +18,6 @@ export const routes: Routes = [
loadComponent: () => loadComponent: () =>
import('./schedule/schedule.component').then((m) => m.ScheduleComponent), import('./schedule/schedule.component').then((m) => m.ScheduleComponent),
}, },
{
path: 'donate',
loadComponent: () =>
import('./donate/donate.component').then((m) => m.DonateComponent),
},
{ {
path: 'events', path: 'events',
loadComponent: () => loadComponent: () =>
-1
View File
@@ -27,7 +27,6 @@
<option value="" disabled selected>Choose a topic</option> <option value="" disabled selected>Choose a topic</option>
<option value="general">General Inquiry</option> <option value="general">General Inquiry</option>
<option value="volunteer">Volunteer</option> <option value="volunteer">Volunteer</option>
<option value="donation">Donation Question</option>
<option value="advertising">Community Announcement</option> <option value="advertising">Community Announcement</option>
<option value="song">Song Request</option> <option value="song">Song Request</option>
<option value="technical">Technical Support</option> <option value="technical">Technical Support</option>
-81
View File
@@ -1,81 +0,0 @@
<section class="donate">
<!-- Hero banner -->
<div class="donate-banner">
<div class="donate-banner-overlay">
<div class="container donate-banner-content">
<img [src]="resolveImageUrl(config().hero_icon_url)" alt="" class="donate-flower-hero" aria-hidden="true">
<h2>Keep the Music <span class="text-accent">Free</span></h2>
<p class="donate-subtitle">
{{ config().name_primary }} {{ config().name_secondary }} Radio is 100% listener-funded and independent.
Your gift keeps our signal strong and our airwaves free for everyone.
</p>
</div>
</div>
</div>
<div class="container">
<!-- Donation tiers -->
<div class="donate-tiers">
<h3>Choose Your Level of Support</h3>
@if (tiers$ | async; as state) {
@if (state.loading) {
<div class="schedule-loading">Loading tiers&hellip;</div>
} @else if (state.tiers.length === 0) {
<div class="schedule-empty">No donation tiers available. Check back soon!</div>
} @else {
<div class="tiers-grid">
<div class="tier-card" *ngFor="let tier of state.tiers">
<div class="tier-icon">{{ tier.icon }}</div>
<div class="tier-amount">${{ tier.amount }}</div>
<div class="tier-period">per month</div>
<h4>{{ tier.name }}</h4>
<ul class="tier-benefits">
<li *ngFor="let benefit of tier.benefits">{{ benefit }}</li>
</ul>
<a href="#" class="btn btn-tier">Select {{ tier.name }}</a>
</div>
</div>
}
}
</div>
<!-- One-time donation -->
<div class="donate-one-time">
<div class="donate-flower-divider" aria-hidden="true">
<img [src]="resolveImageUrl(config().logo_url)" alt="">
</div>
<h3>Or Make a One-Time Gift</h3>
<p class="section-intro">
Every dollar matters — from $5 to $5,000. No amount is too small to make
a difference. All donations are tax-deductible (EIN: {{ config().ein }}).
</p>
<a href="#" class="btn btn-lg">Give Any Amount</a>
</div>
<!-- Why donate -->
<div class="donate-why">
<div class="why-grid">
<div class="why-item">
<span class="why-icon">&#x1F4FB;</span>
<strong>{{ config().frequency }} Signal</strong>
<p>Covering 3 mountain counties and the valleys between</p>
</div>
<div class="why-item">
<span class="why-icon">&#x1F399;</span>
<strong>24/7 On-Air</strong>
<p>Over 15,000 hours of programming per year</p>
</div>
<div class="why-item">
<span class="why-icon">&#x1F30D;</span>
<strong>Worldwide Stream</strong>
<p>Accessible from every corner of the globe, free forever</p>
</div>
<div class="why-item">
<span class="why-icon">&#x1F393;</span>
<strong>Youth Programs</strong>
<p>Free radio training for 200+ local students each year</p>
</div>
</div>
</div>
</div>
</section>
-197
View File
@@ -1,197 +0,0 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
.donate {
background: var(--color-cream);
}
// Banner
.donate-banner {
position: relative;
margin-bottom: $spacing-3xl;
}
.donate-banner-overlay {
background: $mixed-gradient;
padding: $spacing-3xl $spacing-lg;
text-align: center;
}
.donate-banner-content {
position: relative;
z-index: 1;
}
.donate-flower-hero {
width: 80px;
height: 80px;
margin: 0 auto $spacing-md;
opacity: 0.7;
animation: float 4s ease-in-out infinite;
}
.donate-banner h2 {
color: var(--color-white);
font-size: 2.4rem;
margin-bottom: $spacing-md;
}
.donate-subtitle {
color: rgba(255, 255, 255, 0.85);
font-size: 1.1rem;
max-width: 550px;
margin: 0 auto;
line-height: 1.7;
}
// Tiers
.donate-tiers {
text-align: center;
margin-bottom: $spacing-3xl;
h3 {
font-size: 1.8rem;
margin-bottom: $spacing-xl;
}
}
.tiers-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: $spacing-lg;
margin-bottom: $spacing-xl;
}
.tier-card {
@include card-style;
padding: $spacing-xl;
text-align: center;
background: var(--color-white);
border-top: 4px solid var(--color-accent);
.tier-icon {
font-size: 2rem;
margin-bottom: $spacing-sm;
}
.tier-amount {
font-size: 2.8rem;
font-weight: 700;
color: var(--color-accent);
line-height: 1;
}
.tier-period {
font-size: 0.85rem;
color: var(--color-medium);
margin-bottom: $spacing-md;
}
h4 {
font-size: 1.2rem;
color: var(--color-primary);
margin-bottom: $spacing-md;
}
.tier-benefits {
list-style: none;
text-align: left;
margin-bottom: $spacing-lg;
li {
padding: $spacing-xs 0;
font-size: 0.9rem;
color: var(--color-dark);
border-bottom: 1px solid var(--color-light);
&::before {
content: '';
color: $success-green;
font-weight: 700;
}
}
}
.btn-tier {
@include button-style($accent-orange, $neutral-white, $accent-orange-dark);
display: inline-block;
width: 100%;
text-align: center;
}
}
// One-time
.donate-one-time {
text-align: center;
padding: $spacing-2xl $spacing-lg;
background: var(--color-primary);
border-radius: $radius-xl;
margin-bottom: $spacing-3xl;
.donate-flower-divider {
width: 40px;
height: 40px;
margin: 0 auto $spacing-md;
img {
width: 100%;
filter: brightness(1.2);
}
}
h3 {
color: var(--color-white);
font-size: 1.6rem;
margin-bottom: $spacing-sm;
}
.section-intro {
color: rgba(255, 255, 255, 0.8);
margin-bottom: $spacing-lg;
}
.btn-lg {
@include button-style($accent-orange, $neutral-white, $accent-orange-dark);
font-size: 1.2rem;
padding: $spacing-md $spacing-2xl;
}
}
// Why
.donate-why {
padding-bottom: $spacing-xl;
}
.why-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: $spacing-lg;
.why-item {
text-align: center;
padding: $spacing-lg;
.why-icon {
display: block;
font-size: 2.5rem;
margin-bottom: $spacing-sm;
}
strong {
display: block;
color: var(--color-primary);
margin-bottom: $spacing-xs;
font-size: 1.05rem;
}
p {
font-size: 0.9rem;
color: var(--color-medium);
}
}
}
@media (max-width: #{$bp-md - 1px}) {
.donate-banner h2 { font-size: 1.8rem; }
.tiers-grid { grid-template-columns: 1fr; max-width: 320px; margin-left: auto; margin-right: auto; }
}
-36
View File
@@ -1,36 +0,0 @@
import { Component, inject } from '@angular/core';
import { AsyncPipe, NgFor } from '@angular/common';
import { map, Observable, startWith } from 'rxjs';
import { TierService } from '../services/tier.service';
import { Tier } from '../interfaces/tier';
import { StationConfigService } from '../services/station-config.service';
import { resolveImageUrl } from '../services/app-config.service';
interface TiersState {
loading: boolean;
tiers: Tier[];
}
@Component({
selector: 'app-donate',
standalone: true,
imports: [AsyncPipe, NgFor],
templateUrl: './donate.component.html',
styleUrl: './donate.component.scss',
})
export class DonateComponent {
private tierService = inject(TierService);
readonly config = inject(StationConfigService).config;
resolveImageUrl(url: string): string {
return resolveImageUrl(url);
}
/** Async pipe drives the template — guarantees change detection on every emission. */
readonly tiers$: Observable<TiersState> =
this.tierService.getTiers().pipe(
map((tiers) => ({ loading: false, tiers })),
startWith({ loading: true, tiers: [] }),
);
}
+6 -2
View File
@@ -23,14 +23,18 @@
<li><a routerLink="/about">About Us</a></li> <li><a routerLink="/about">About Us</a></li>
<li><a routerLink="/schedule">Schedule</a></li> <li><a routerLink="/schedule">Schedule</a></li>
<li><a routerLink="/events">Events</a></li> <li><a routerLink="/events">Events</a></li>
<li><a routerLink="/donate">Donate</a></li> @if (config().donation_url) {
<li><a [href]="config().donation_url" target="_blank" rel="noopener noreferrer">Donate</a></li>
}
<li><a routerLink="/contact">Contact</a></li> <li><a routerLink="/contact">Contact</a></li>
</ul> </ul>
</div> </div>
<div class="footer-col"> <div class="footer-col">
<h4>Support</h4> <h4>Support</h4>
<ul> <ul>
<li><a routerLink="/donate">Become a Member</a></li> @if (config().donation_url) {
<li><a [href]="config().donation_url" target="_blank" rel="noopener noreferrer">Become a Member</a></li>
}
<li><a href="#">Volunteer</a></li> <li><a href="#">Volunteer</a></li>
<li><a href="#">Corporate Giving</a></li> <li><a href="#">Corporate Giving</a></li>
<li><a href="#">Planned Giving</a></li> <li><a href="#">Planned Giving</a></li>
+3 -1
View File
@@ -25,7 +25,9 @@
{{ config().tagline }} {{ config().tagline }}
</p> </p>
<div class="hero-actions"> <div class="hero-actions">
<a routerLink="/donate" class="btn btn-hero-primary">Support the Station</a> @if (config().donation_url) {
<a [href]="config().donation_url" target="_blank" rel="noopener noreferrer" class="btn btn-hero-primary">Support the Station</a>
}
<a routerLink="/schedule" class="btn btn-hero-secondary">View Schedule</a> <a routerLink="/schedule" class="btn btn-hero-secondary">View Schedule</a>
</div> </div>
<div class="hero-listeners"> <div class="hero-listeners">
+1
View File
@@ -17,4 +17,5 @@ export interface StationConfig {
phone: string; phone: string;
email: string; email: string;
website: string; website: string;
donation_url: string;
} }
-8
View File
@@ -1,8 +0,0 @@
export interface Tier {
id: number;
name: string;
amount: number;
icon: string;
benefits: string[];
display_order: number;
}
+3 -1
View File
@@ -34,7 +34,9 @@
<a routerLink="/login" class="btn-sign-in" (click)="closeMenu()">Sign In</a> <a routerLink="/login" class="btn-sign-in" (click)="closeMenu()">Sign In</a>
} }
</div> </div>
<a routerLink="/donate" class="btn btn-donate-nav" (click)="closeMenu()">Donate Now</a> @if (config().donation_url) {
<a [href]="config().donation_url" target="_blank" rel="noopener noreferrer" class="btn btn-donate-nav" (click)="closeMenu()">Donate Now</a>
}
</div> </div>
</div> </div>
</nav> </nav>
@@ -25,6 +25,7 @@ const DEFAULT_CONFIG: StationConfig = {
phone: '(970) 555-0198', phone: '(970) 555-0198',
email: 'hello@kmountainflower.org', email: 'hello@kmountainflower.org',
website: 'kmountainflower.org', website: 'kmountainflower.org',
donation_url: '',
}; };
@Injectable({ @Injectable({
-35
View File
@@ -1,35 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Tier } from '../interfaces/tier';
import { getAppConfig } from './app-config.service';
@Injectable({
providedIn: 'root',
})
export class TierService {
private http = inject(HttpClient);
private baseUrl = `${getAppConfig().apiBaseUrl}/api/tiers`;
/** Fetch all tiers ordered by display_order. */
getTiers(): Observable<Tier[]> {
return this.http.get<Tier[]>(this.baseUrl);
}
getTier(id: number): Observable<Tier> {
return this.http.get<Tier>(`${this.baseUrl}/${id}`);
}
createTier(payload: Omit<Tier, 'id'>): Observable<Tier> {
return this.http.post<Tier>(this.baseUrl, payload);
}
updateTier(id: number, payload: Partial<Tier>): Observable<Tier> {
return this.http.put<Tier>(`${this.baseUrl}/${id}`, payload);
}
deleteTier(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}