diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 0cf1b6f..edb2297 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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 diff --git a/backend/__pycache__/seed.cpython-312.pyc b/backend/__pycache__/seed.cpython-312.pyc index e62c571..de7faa2 100644 Binary files a/backend/__pycache__/seed.cpython-312.pyc and b/backend/__pycache__/seed.cpython-312.pyc differ diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc index d282507..b28d2cb 100644 Binary files a/backend/app/__pycache__/main.cpython-312.pyc and b/backend/app/__pycache__/main.cpython-312.pyc differ diff --git a/backend/app/__pycache__/models.cpython-312.pyc b/backend/app/__pycache__/models.cpython-312.pyc index 66c5815..cdce449 100644 Binary files a/backend/app/__pycache__/models.cpython-312.pyc and b/backend/app/__pycache__/models.cpython-312.pyc differ diff --git a/backend/app/__pycache__/schemas.cpython-312.pyc b/backend/app/__pycache__/schemas.cpython-312.pyc index 3c72926..f90a25b 100644 Binary files a/backend/app/__pycache__/schemas.cpython-312.pyc and b/backend/app/__pycache__/schemas.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/admin.cpython-312.pyc b/backend/app/api/__pycache__/admin.cpython-312.pyc index 372fbff..347410a 100644 Binary files a/backend/app/api/__pycache__/admin.cpython-312.pyc and b/backend/app/api/__pycache__/admin.cpython-312.pyc differ diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index fd6aa89..12ecaa3 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -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() diff --git a/backend/app/api/tiers.py b/backend/app/api/tiers.py deleted file mode 100644 index 15f2cdd..0000000 --- a/backend/app/api/tiers.py +++ /dev/null @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index b58463a..3742bb9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"]) diff --git a/backend/app/models.py b/backend/app/models.py index c683101..2209ecc 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 234d3e5..0020c05 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 ────────────────────────────────────────── diff --git a/backend/seed.py b/backend/seed.py index dd9dd99..9b2f095 100644 --- a/backend/seed.py +++ b/backend/seed.py @@ -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()) diff --git a/retire-donation-page.md b/retire-donation-page.md new file mode 100644 index 0000000..5764000 --- /dev/null +++ b/retire-donation-page.md @@ -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 `` 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 `` + +## 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 \ No newline at end of file diff --git a/src/app/about/about.component.html b/src/app/about/about.component.html index 0c1e79a..76a1c4f 100644 --- a/src/app/about/about.component.html +++ b/src/app/about/about.component.html @@ -74,6 +74,7 @@ } + @if (config().donation_url) {
+ }
diff --git a/src/app/about/about.component.ts b/src/app/about/about.component.ts index 6a0ab90..93c65bc 100644 --- a/src/app/about/about.component.ts +++ b/src/app/about/about.component.ts @@ -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', }) diff --git a/src/app/admin/admin-station-form.component.html b/src/app/admin/admin-station-form.component.html index ebc7c9f..f06d5c4 100644 --- a/src/app/admin/admin-station-form.component.html +++ b/src/app/admin/admin-station-form.component.html @@ -130,6 +130,15 @@ + +
+

Donation

+
+ + +
+
+
-
- - @if (error) { - - } - -
-
-
- - -
- -
- - -
-
- -
-
- - -
- -
- - -
-
- -
- - -
- -
- - -
-
- - diff --git a/src/app/admin/admin-tier-form.component.scss b/src/app/admin/admin-tier-form.component.scss deleted file mode 100644 index 854d9bc..0000000 --- a/src/app/admin/admin-tier-form.component.scss +++ /dev/null @@ -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; - } -} diff --git a/src/app/admin/admin-tier-form.component.ts b/src/app/admin/admin-tier-form.component.ts deleted file mode 100644 index d2dcb95..0000000 --- a/src/app/admin/admin-tier-form.component.ts +++ /dev/null @@ -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(); - @Output() closed = new EventEmitter(); - - 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 { - 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; - } - } -} diff --git a/src/app/admin/admin.component.html b/src/app/admin/admin.component.html index 1dfe7e3..a18172d 100644 --- a/src/app/admin/admin.component.html +++ b/src/app/admin/admin.component.html @@ -2,7 +2,7 @@

Admin Dashboard

-

Manage shows, events, tiers, history, team, and community content

+

Manage shows, events, history, team, and community content

@@ -17,11 +17,6 @@ [class.active]="activeTab() === 'events'" (click)="activeTab.set('events')" >Events - -
- - - - - - - - - - - - - @for (tier of tiers; track tier.id) { - - - - - - - - } @empty { - - } - -
OrderNameAmountBenefitsActions
{{ tier.display_order }}{{ tier.icon }} {{ tier.name }}${{ tier.amount.toFixed(2) }}{{ tier.benefits.join(', ') }} - - -
No tiers yet. Click "Add Tier" to create one.
- - } - } - @if (activeTab() === 'history') { @if (historyEntries$ | async; as entries) { @@ -336,6 +291,10 @@ Website {{ config.website }} +
+ Donation URL + {{ config.donation_url || '(not set)' }} +
} @@ -349,9 +308,6 @@ @if (showEventForm) { } - @if (showTierForm) { - - } @if (showStationForm) { } diff --git a/src/app/admin/admin.component.ts b/src/app/admin/admin.component.ts index deca079..749ae52 100644 --- a/src/app/admin/admin.component.ts +++ b/src/app/admin/admin.component.ts @@ -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([]); readonly events$ = new BehaviorSubject([]); - readonly tiers$ = new BehaviorSubject([]); readonly stationConfig$ = new BehaviorSubject(null); readonly historyEntries$ = new BehaviorSubject([]); readonly teamMembers$ = new BehaviorSubject([]); @@ -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 { - const data = await firstValueFrom(this.tierService.getTiers()); - this.tiers$.next(data); - } - async reloadStationConfig(): Promise { 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 { - if (!confirm('Delete this tier?')) return; - await firstValueFrom(this.tierService.deleteTier(id)); - await this.reloadTiers(); - } - // ── Station Config ────────────────────────────────────── openStationForm(): void { diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 97a311e..f907169 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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: () => diff --git a/src/app/contact/contact.component.html b/src/app/contact/contact.component.html index ae1a6ed..0ece1c3 100644 --- a/src/app/contact/contact.component.html +++ b/src/app/contact/contact.component.html @@ -27,7 +27,6 @@ - diff --git a/src/app/donate/donate.component.html b/src/app/donate/donate.component.html deleted file mode 100644 index c1ab344..0000000 --- a/src/app/donate/donate.component.html +++ /dev/null @@ -1,81 +0,0 @@ - diff --git a/src/app/donate/donate.component.scss b/src/app/donate/donate.component.scss deleted file mode 100644 index 090cb73..0000000 --- a/src/app/donate/donate.component.scss +++ /dev/null @@ -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; } -} diff --git a/src/app/donate/donate.component.ts b/src/app/donate/donate.component.ts deleted file mode 100644 index 5a7e17e..0000000 --- a/src/app/donate/donate.component.ts +++ /dev/null @@ -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 = - this.tierService.getTiers().pipe( - map((tiers) => ({ loading: false, tiers })), - startWith({ loading: true, tiers: [] }), - ); -} diff --git a/src/app/footer/footer.component.html b/src/app/footer/footer.component.html index 7a4f87d..ec17f37 100644 --- a/src/app/footer/footer.component.html +++ b/src/app/footer/footer.component.html @@ -23,14 +23,18 @@
  • About Us
  • Schedule
  • Events
  • -
  • Donate
  • + @if (config().donation_url) { +
  • Donate
  • + }
  • Contact
  • diff --git a/src/app/services/station-config.service.ts b/src/app/services/station-config.service.ts index cc81299..3bf0030 100644 --- a/src/app/services/station-config.service.ts +++ b/src/app/services/station-config.service.ts @@ -25,6 +25,7 @@ const DEFAULT_CONFIG: StationConfig = { phone: '(970) 555-0198', email: 'hello@kmountainflower.org', website: 'kmountainflower.org', + donation_url: '', }; @Injectable({ diff --git a/src/app/services/tier.service.ts b/src/app/services/tier.service.ts deleted file mode 100644 index f3f88a7..0000000 --- a/src/app/services/tier.service.ts +++ /dev/null @@ -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 { - return this.http.get(this.baseUrl); - } - - getTier(id: number): Observable { - return this.http.get(`${this.baseUrl}/${id}`); - } - - createTier(payload: Omit): Observable { - return this.http.post(this.baseUrl, payload); - } - - updateTier(id: number, payload: Partial): Observable { - return this.http.put(`${this.baseUrl}/${id}`, payload); - } - - deleteTier(id: number): Observable { - return this.http.delete(`${this.baseUrl}/${id}`); - } -}