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
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
- **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).
- **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.
@@ -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
- `docker-compose.dev.yml` — dev-sidecar compose for VS Code dev container
- `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
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.database import get_session
from app.models import DonationTier, Event, StationConfig, Show, ShowSchedule
from app.models import Event, StationConfig, Show, ShowSchedule
router = APIRouter()
@@ -20,7 +20,6 @@ async def reset_database():
await session.execute(ShowSchedule.__table__.delete())
await session.execute(Show.__table__.delete())
await session.execute(Event.__table__.delete())
await session.execute(DonationTier.__table__.delete())
await session.execute(StationConfig.__table__.delete())
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.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight
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):
@@ -87,7 +87,6 @@ app.add_middleware(
# Register routers
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
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(history.router, prefix="/api/history", tags=["history"])
app.include_router(team.router, prefix="/api/team", tags=["team"])
+3 -13
View File
@@ -7,10 +7,8 @@ from sqlalchemy import (
LargeBinary,
String,
Text,
Numeric,
Boolean,
Date,
JSON,
ForeignKey,
UniqueConstraint,
)
@@ -63,17 +61,6 @@ class Event(Base):
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):
__tablename__ = "station_config"
@@ -104,6 +91,9 @@ class StationConfig(Base):
email = Column(String(100), nullable=False, default="hello@kmountainflower.org")
website = Column(String(200), nullable=False, default="kmountainflower.org")
# Donation
donation_url = Column(String(500), nullable=False, default="")
class HistoryEntry(Base):
__tablename__ = "history_entries"
+2 -29
View File
@@ -51,35 +51,6 @@ class EventResponse(BaseModel):
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 ─────────────────────────────────────────
class StationConfigResponse(BaseModel):
@@ -101,6 +72,7 @@ class StationConfigResponse(BaseModel):
phone: str
email: str
website: str
donation_url: str
model_config = {"from_attributes": True}
@@ -122,6 +94,7 @@ class StationConfigUpdate(BaseModel):
phone: Optional[str] = None
email: Optional[str] = None
website: Optional[str] = None
donation_url: Optional[str] = None
# ── HistoryEntry ──────────────────────────────────────────
+2 -51
View File
@@ -14,7 +14,7 @@ from datetime import date
from sqlalchemy import select
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
# ── 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 = {
"key": "default",
"callsign": "KMTN",
@@ -242,6 +211,7 @@ STATION_CONFIG: dict = {
"phone": "(970) 555-0198",
"email": "hello@kmountainflower.org",
"website": "kmountainflower.org",
"donation_url": "",
}
HISTORY_ENTRIES: list[dict] = [
@@ -348,19 +318,6 @@ async def _upsert_event(session, data: dict) -> None:
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:
"""Get-or-create the singleton station config (key='default')."""
existing = await session.execute(
@@ -452,11 +409,6 @@ async def seed() -> None:
await session.commit()
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 session.commit()
print(" ✓ Seeded station config")
@@ -491,7 +443,6 @@ async def truncate_all() -> None:
await session.execute(ShowSchedule.__table__.delete())
await session.execute(Show.__table__.delete())
await session.execute(Event.__table__.delete())
await session.execute(DonationTier.__table__.delete())
await session.execute(StationConfig.__table__.delete())
await session.execute(HistoryEntry.__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 -->
@if (config().donation_url) {
<div class="about-cta">
<div class="about-flower-divider" aria-hidden="true">
<img [src]="resolveImageUrl(config().logo_url)" alt="">
@@ -84,7 +85,8 @@
signal strong. Members receive exclusive perks and the satisfaction of
knowing they helped make this possible.
</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>
</section>
+1 -2
View File
@@ -1,5 +1,4 @@
import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { AsyncPipe } from '@angular/common';
import { BehaviorSubject } from 'rxjs';
@@ -15,7 +14,7 @@ import { resolveImageUrl } from '../services/app-config.service';
@Component({
selector: 'app-about',
standalone: true,
imports: [RouterLink, AsyncPipe],
imports: [AsyncPipe],
templateUrl: './about.component.html',
styleUrl: './about.component.scss',
})
@@ -130,6 +130,15 @@
</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">
<button type="button" class="btn btn-cancel" (click)="onClose()">Cancel</button>
<button type="submit" class="btn btn-save" [disabled]="saving">
@@ -37,6 +37,7 @@ export class AdminStationFormComponent implements OnChanges {
phone = '';
email = '';
website = '';
donation_url = '';
loading = false;
saving = false;
@@ -67,6 +68,7 @@ export class AdminStationFormComponent implements OnChanges {
this.phone = config.phone;
this.email = config.email;
this.website = config.website;
this.donation_url = config.donation_url;
}
/** Reset form with current service values. */
@@ -152,6 +154,7 @@ export class AdminStationFormComponent implements OnChanges {
phone: this.phone,
email: this.email,
website: this.website,
donation_url: this.donation_url,
};
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="admin-header">
<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>
<!-- Tabs -->
@@ -17,11 +17,6 @@
[class.active]="activeTab() === 'events'"
(click)="activeTab.set('events')"
>Events</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'tiers'"
(click)="activeTab.set('tiers')"
>Tiers</button>
<button
class="tab-btn"
[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 -->
@if (activeTab() === 'history') {
@if (historyEntries$ | async; as entries) {
@@ -336,6 +291,10 @@
<span class="config-label">Website</span>
<span class="config-value">{{ config.website }}</span>
</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>
}
@@ -349,9 +308,6 @@
@if (showEventForm) {
<app-admin-event-form [editItem]="editingEvent" (saved)="onEventSaved()" (closed)="onEventClosed()" />
}
@if (showTierForm) {
<app-admin-tier-form [editItem]="editingTier" (saved)="onTierSaved()" (closed)="onTierClosed()" />
}
@if (showStationForm) {
<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 { Event } from '../interfaces/event';
import { Tier } from '../interfaces/tier';
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 { ShowService } from '../services/show.service';
import { EventService } from '../services/event.service';
import { TierService } from '../services/tier.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 { AdminShowFormComponent } from './admin-show-form.component';
import { AdminEventFormComponent } from './admin-event-form.component';
import { AdminTierFormComponent } from './admin-tier-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';
type TabKey = 'shows' | 'events' | 'tiers' | 'station' | 'history' | 'team' | 'community';
type TabKey = 'shows' | 'events' | 'station' | 'history' | 'team' | 'community';
@Component({
selector: 'app-admin',
@@ -36,7 +33,6 @@ type TabKey = 'shows' | 'events' | 'tiers' | 'station' | 'history' | 'team' | 'c
FormsModule,
AdminShowFormComponent,
AdminEventFormComponent,
AdminTierFormComponent,
AdminStationFormComponent,
AdminHistoryFormComponent,
AdminTeamFormComponent,
@@ -48,7 +44,6 @@ type TabKey = 'shows' | 'events' | 'tiers' | 'station' | 'history' | 'team' | 'c
export class AdminComponent implements OnInit {
private showService = inject(ShowService);
private eventService = inject(EventService);
private tierService = inject(TierService);
private stationConfigService = inject(StationConfigService);
private historyEntryService = inject(HistoryEntryService);
private teamMemberService = inject(TeamMemberService);
@@ -59,7 +54,6 @@ export class AdminComponent implements OnInit {
/** Item currently being edited (set by editXxx, cleared on save/close). */
editingShow: Show | null = null;
editingEvent: Event | null = null;
editingTier: Tier | null = null;
editingHistory: HistoryEntry | null = null;
editingTeam: TeamMember | 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. */
readonly shows$ = new BehaviorSubject<Show[]>([]);
readonly events$ = new BehaviorSubject<Event[]>([]);
readonly tiers$ = new BehaviorSubject<Tier[]>([]);
readonly stationConfig$ = new BehaviorSubject<StationConfig | null>(null);
readonly historyEntries$ = new BehaviorSubject<HistoryEntry[]>([]);
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
@@ -75,7 +68,6 @@ export class AdminComponent implements OnInit {
showShowForm = false;
showEventForm = false;
showTierForm = false;
showStationForm = false;
showHistoryForm = false;
showTeamForm = false;
@@ -95,11 +87,6 @@ export class AdminComponent implements OnInit {
this.events$.next(data);
}
async reloadTiers(): Promise<void> {
const data = await firstValueFrom(this.tierService.getTiers());
this.tiers$.next(data);
}
async reloadStationConfig(): Promise<void> {
await this.stationConfigService.load();
this.stationConfig$.next(this.stationConfigService.config());
@@ -124,7 +111,6 @@ export class AdminComponent implements OnInit {
await Promise.all([
this.reloadShows(),
this.reloadEvents(),
this.reloadTiers(),
this.reloadStationConfig(),
this.reloadHistoryEntries(),
this.reloadTeamMembers(),
@@ -190,35 +176,6 @@ export class AdminComponent implements OnInit {
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 ──────────────────────────────────────
openStationForm(): void {
-5
View File
@@ -18,11 +18,6 @@ export const routes: Routes = [
loadComponent: () =>
import('./schedule/schedule.component').then((m) => m.ScheduleComponent),
},
{
path: 'donate',
loadComponent: () =>
import('./donate/donate.component').then((m) => m.DonateComponent),
},
{
path: 'events',
loadComponent: () =>
-1
View File
@@ -27,7 +27,6 @@
<option value="" disabled selected>Choose a topic</option>
<option value="general">General Inquiry</option>
<option value="volunteer">Volunteer</option>
<option value="donation">Donation Question</option>
<option value="advertising">Community Announcement</option>
<option value="song">Song Request</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="/schedule">Schedule</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>
</ul>
</div>
<div class="footer-col">
<h4>Support</h4>
<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="#">Corporate Giving</a></li>
<li><a href="#">Planned Giving</a></li>
+3 -1
View File
@@ -25,7 +25,9 @@
{{ config().tagline }}
</p>
<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>
</div>
<div class="hero-listeners">
+1
View File
@@ -17,4 +17,5 @@ export interface StationConfig {
phone: string;
email: 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>
}
</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>
</nav>
@@ -25,6 +25,7 @@ const DEFAULT_CONFIG: StationConfig = {
phone: '(970) 555-0198',
email: 'hello@kmountainflower.org',
website: 'kmountainflower.org',
donation_url: '',
};
@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}`);
}
}