Retires Support/donation/tiers page in favor of simple configurable link
This commit is contained in:
+3
-3
@@ -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.
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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,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()">×</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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()" />
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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…</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">📻</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">🎙</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">🌍</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">🎓</span>
|
||||
<strong>Youth Programs</strong>
|
||||
<p>Free radio training for 200+ local students each year</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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: [] }),
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -17,4 +17,5 @@ export interface StationConfig {
|
||||
phone: string;
|
||||
email: string;
|
||||
website: string;
|
||||
donation_url: string;
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export interface Tier {
|
||||
id: number;
|
||||
name: string;
|
||||
amount: number;
|
||||
icon: string;
|
||||
benefits: string[];
|
||||
display_order: number;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user