Retires Support/donation/tiers page in favor of simple configurable link
This commit is contained in:
+3
-3
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
## Project overview
|
## Project overview
|
||||||
|
|
||||||
A three-tier web app for a community radio station: Angular 21 frontend, FastAPI backend, PostgreSQL database. Renders a hero page, about page, broadcast schedule, donation tiers, upcoming events, and a contact form — styled with a warm mountain/nature color palette (blue primary + orange accent on cream).
|
A three-tier web app for a community radio station: Angular 21 frontend, FastAPI backend, PostgreSQL database. Renders a hero page, about page, broadcast schedule, upcoming events, and a contact form — styled with a warm mountain/nature color palette (blue primary + orange accent on cream). Donations route to an externally configured URL.
|
||||||
|
|
||||||
## Code style
|
## Code style
|
||||||
|
|
||||||
- **Components:** Standalone only (no NgModules). Every component is a single file with `@Component` decorator, `imports` array, and four standard files (`.ts`, `.html`, `.scss`, component).
|
- **Components:** Standalone only (no NgModules). Every component is a single file with `@Component` decorator, `imports` array, and four standard files (`.ts`, `.html`, `.scss`, component).
|
||||||
- **Data patterns:** Page content (programs, events, tiers) is stored in PostgreSQL and served via the FastAPI backend. The frontend uses HTTP services ([src/app/services/](src/app/services/)) to fetch data at runtime. To add or modify content, interact with the API or run `seed.py` — never hardcode content in components.
|
- **Data patterns:** Page content (programs, events) is stored in PostgreSQL and served via the FastAPI backend. The frontend uses HTTP services ([src/app/services/](src/app/services/)) to fetch data at runtime. To add or modify content, interact with the API or run `seed.py` — never hardcode content in components.
|
||||||
- **SCSS:** All design tokens go in [src/styles/_variables.scss](src/styles/_variables.scss). Mixins in [_mixins.scss](src/styles/_mixins.scss). CSS custom properties exposed in [_themes.scss](src/styles/_themes.scss). Components import via `@use 'variables'` (no relative path needed).
|
- **SCSS:** All design tokens go in [src/styles/_variables.scss](src/styles/_variables.scss). Mixins in [_mixins.scss](src/styles/_mixins.scss). CSS custom properties exposed in [_themes.scss](src/styles/_themes.scss). Components import via `@use 'variables'` (no relative path needed).
|
||||||
- **Routes:** Defined in [src/app/app.routes.ts](src/app/app.routes.ts) using `loadComponent()` lazy loading. Add new pages here after creating the component.
|
- **Routes:** Defined in [src/app/app.routes.ts](src/app/app.routes.ts) using `loadComponent()` lazy loading. Add new pages here after creating the component.
|
||||||
- **TypeScript:** Strict mode enabled. Use `readonly` for constants (config, URLs). Keep data shapes close to their components where used only once.
|
- **TypeScript:** Strict mode enabled. Use `readonly` for constants (config, URLs). Keep data shapes close to their components where used only once.
|
||||||
@@ -24,7 +24,7 @@ See [README.md](README.md) for a full architecture map. Key files:
|
|||||||
- `src/styles/_themes.scss` — SCSS → CSS custom property mapping
|
- `src/styles/_themes.scss` — SCSS → CSS custom property mapping
|
||||||
- `docker-compose.dev.yml` — dev-sidecar compose for VS Code dev container
|
- `docker-compose.dev.yml` — dev-sidecar compose for VS Code dev container
|
||||||
- `backend/app/main.py` — FastAPI entry point (CORS, router registration)
|
- `backend/app/main.py` — FastAPI entry point (CORS, router registration)
|
||||||
- `backend/app/models.py` — SQLAlchemy ORM (Program, Event, DonationTier)
|
- `backend/app/models.py` — SQLAlchemy ORM (Show, Event, StationConfig)
|
||||||
|
|
||||||
## Common tasks
|
## Common tasks
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,7 +2,7 @@ from fastapi import APIRouter
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models import DonationTier, Event, StationConfig, Show, ShowSchedule
|
from app.models import Event, StationConfig, Show, ShowSchedule
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -20,7 +20,6 @@ async def reset_database():
|
|||||||
await session.execute(ShowSchedule.__table__.delete())
|
await session.execute(ShowSchedule.__table__.delete())
|
||||||
await session.execute(Show.__table__.delete())
|
await session.execute(Show.__table__.delete())
|
||||||
await session.execute(Event.__table__.delete())
|
await session.execute(Event.__table__.delete())
|
||||||
await session.execute(DonationTier.__table__.delete())
|
|
||||||
await session.execute(StationConfig.__table__.delete())
|
await session.execute(StationConfig.__table__.delete())
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -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.database import async_session, engine
|
||||||
from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight
|
from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight
|
||||||
from app.user_models import User
|
from app.user_models import User
|
||||||
from app.api import events, tiers, auth, station_config, history, team, community, shows, storage
|
from app.api import events, auth, station_config, history, team, community, shows, storage
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_station_config(session):
|
async def _ensure_station_config(session):
|
||||||
@@ -87,7 +87,6 @@ app.add_middleware(
|
|||||||
# Register routers
|
# Register routers
|
||||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||||
app.include_router(events.router, prefix="/api/events", tags=["events"])
|
app.include_router(events.router, prefix="/api/events", tags=["events"])
|
||||||
app.include_router(tiers.router, prefix="/api/tiers", tags=["tiers"])
|
|
||||||
app.include_router(station_config.router, prefix="/api/station-config", tags=["station-config"])
|
app.include_router(station_config.router, prefix="/api/station-config", tags=["station-config"])
|
||||||
app.include_router(history.router, prefix="/api/history", tags=["history"])
|
app.include_router(history.router, prefix="/api/history", tags=["history"])
|
||||||
app.include_router(team.router, prefix="/api/team", tags=["team"])
|
app.include_router(team.router, prefix="/api/team", tags=["team"])
|
||||||
|
|||||||
+3
-13
@@ -7,10 +7,8 @@ from sqlalchemy import (
|
|||||||
LargeBinary,
|
LargeBinary,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
Numeric,
|
|
||||||
Boolean,
|
Boolean,
|
||||||
Date,
|
Date,
|
||||||
JSON,
|
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
)
|
)
|
||||||
@@ -63,17 +61,6 @@ class Event(Base):
|
|||||||
active = Column(Boolean, default=True)
|
active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
|
||||||
class DonationTier(Base):
|
|
||||||
__tablename__ = "donation_tiers"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
name = Column(String(50), unique=True, nullable=False)
|
|
||||||
amount = Column(Numeric(10, 2), nullable=False)
|
|
||||||
icon = Column(String(10), nullable=False)
|
|
||||||
benefits = Column(JSON, nullable=False)
|
|
||||||
display_order = Column(Integer, nullable=False)
|
|
||||||
|
|
||||||
|
|
||||||
class StationConfig(Base):
|
class StationConfig(Base):
|
||||||
__tablename__ = "station_config"
|
__tablename__ = "station_config"
|
||||||
|
|
||||||
@@ -104,6 +91,9 @@ class StationConfig(Base):
|
|||||||
email = Column(String(100), nullable=False, default="hello@kmountainflower.org")
|
email = Column(String(100), nullable=False, default="hello@kmountainflower.org")
|
||||||
website = Column(String(200), nullable=False, default="kmountainflower.org")
|
website = Column(String(200), nullable=False, default="kmountainflower.org")
|
||||||
|
|
||||||
|
# Donation
|
||||||
|
donation_url = Column(String(500), nullable=False, default="")
|
||||||
|
|
||||||
|
|
||||||
class HistoryEntry(Base):
|
class HistoryEntry(Base):
|
||||||
__tablename__ = "history_entries"
|
__tablename__ = "history_entries"
|
||||||
|
|||||||
+2
-29
@@ -51,35 +51,6 @@ class EventResponse(BaseModel):
|
|||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
# ── DonationTier ─────────────────────────────────────────
|
|
||||||
|
|
||||||
class TierCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
amount: float
|
|
||||||
icon: str
|
|
||||||
benefits: list[str]
|
|
||||||
display_order: int
|
|
||||||
|
|
||||||
|
|
||||||
class TierUpdate(BaseModel):
|
|
||||||
name: Optional[str] = None
|
|
||||||
amount: Optional[float] = None
|
|
||||||
icon: Optional[str] = None
|
|
||||||
benefits: Optional[list[str]] = None
|
|
||||||
display_order: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class TierResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
amount: float
|
|
||||||
icon: str
|
|
||||||
benefits: list[str]
|
|
||||||
display_order: int
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
# ── StationConfig ─────────────────────────────────────────
|
# ── StationConfig ─────────────────────────────────────────
|
||||||
|
|
||||||
class StationConfigResponse(BaseModel):
|
class StationConfigResponse(BaseModel):
|
||||||
@@ -101,6 +72,7 @@ class StationConfigResponse(BaseModel):
|
|||||||
phone: str
|
phone: str
|
||||||
email: str
|
email: str
|
||||||
website: str
|
website: str
|
||||||
|
donation_url: str
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -122,6 +94,7 @@ class StationConfigUpdate(BaseModel):
|
|||||||
phone: Optional[str] = None
|
phone: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
website: Optional[str] = None
|
website: Optional[str] = None
|
||||||
|
donation_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# ── HistoryEntry ──────────────────────────────────────────
|
# ── HistoryEntry ──────────────────────────────────────────
|
||||||
|
|||||||
+2
-51
@@ -14,7 +14,7 @@ from datetime import date
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.database import async_session
|
from app.database import async_session
|
||||||
from app.models import Show, ShowSchedule, Event, DonationTier, StationConfig
|
from app.models import Show, ShowSchedule, Event, StationConfig
|
||||||
from app.models import HistoryEntry, TeamMember, CommunityHighlight
|
from app.models import HistoryEntry, TeamMember, CommunityHighlight
|
||||||
|
|
||||||
# ── Seed data ──────────────────────────────────────────────
|
# ── Seed data ──────────────────────────────────────────────
|
||||||
@@ -193,37 +193,6 @@ EVENTS: list[dict] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
TIERS: list[dict] = [
|
|
||||||
{
|
|
||||||
"name": "Seed",
|
|
||||||
"amount": 5.00,
|
|
||||||
"icon": "🌱",
|
|
||||||
"benefits": ["Digital thank-you card", "Name listed on our website", "Quarterly newsletter"],
|
|
||||||
"display_order": 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Stream",
|
|
||||||
"amount": 15.00,
|
|
||||||
"icon": "🎵",
|
|
||||||
"benefits": ["All Seed benefits", "KMountain Flower pin", "Priority show-request line", "Annual station update call"],
|
|
||||||
"display_order": 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Ridge",
|
|
||||||
"amount": 30.00,
|
|
||||||
"icon": "🏔️",
|
|
||||||
"benefits": ["All Stream benefits", "Official member patch", "Exclusive show invitations", "Annual station tour"],
|
|
||||||
"display_order": 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Summit",
|
|
||||||
"amount": 60.00,
|
|
||||||
"icon": "⭐",
|
|
||||||
"benefits": ["All Ridge benefits", "Featured on the Summit Wall", "Name on-air during anniversary special", "Lifetime patron recognition"],
|
|
||||||
"display_order": 4,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
STATION_CONFIG: dict = {
|
STATION_CONFIG: dict = {
|
||||||
"key": "default",
|
"key": "default",
|
||||||
"callsign": "KMTN",
|
"callsign": "KMTN",
|
||||||
@@ -242,6 +211,7 @@ STATION_CONFIG: dict = {
|
|||||||
"phone": "(970) 555-0198",
|
"phone": "(970) 555-0198",
|
||||||
"email": "hello@kmountainflower.org",
|
"email": "hello@kmountainflower.org",
|
||||||
"website": "kmountainflower.org",
|
"website": "kmountainflower.org",
|
||||||
|
"donation_url": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
HISTORY_ENTRIES: list[dict] = [
|
HISTORY_ENTRIES: list[dict] = [
|
||||||
@@ -348,19 +318,6 @@ async def _upsert_event(session, data: dict) -> None:
|
|||||||
session.add(Event(**data, active=True))
|
session.add(Event(**data, active=True))
|
||||||
|
|
||||||
|
|
||||||
async def _upsert_tier(session, data: dict) -> None:
|
|
||||||
"""Get-or-create a donation tier matched on name."""
|
|
||||||
existing = await session.execute(
|
|
||||||
select(DonationTier).where(DonationTier.name == data["name"])
|
|
||||||
)
|
|
||||||
tier = existing.scalar_one_or_none()
|
|
||||||
if tier:
|
|
||||||
for key, value in data.items():
|
|
||||||
setattr(tier, key, value)
|
|
||||||
else:
|
|
||||||
session.add(DonationTier(**data))
|
|
||||||
|
|
||||||
|
|
||||||
async def _upsert_station_config(session, data: dict) -> None:
|
async def _upsert_station_config(session, data: dict) -> None:
|
||||||
"""Get-or-create the singleton station config (key='default')."""
|
"""Get-or-create the singleton station config (key='default')."""
|
||||||
existing = await session.execute(
|
existing = await session.execute(
|
||||||
@@ -452,11 +409,6 @@ async def seed() -> None:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
print(f" ✓ Seeded {len(EVENTS)} events")
|
print(f" ✓ Seeded {len(EVENTS)} events")
|
||||||
|
|
||||||
for data in TIERS:
|
|
||||||
await _upsert_tier(session, data)
|
|
||||||
await session.commit()
|
|
||||||
print(f" ✓ Seeded {len(TIERS)} donation tiers")
|
|
||||||
|
|
||||||
await _upsert_station_config(session, STATION_CONFIG)
|
await _upsert_station_config(session, STATION_CONFIG)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
print(" ✓ Seeded station config")
|
print(" ✓ Seeded station config")
|
||||||
@@ -491,7 +443,6 @@ async def truncate_all() -> None:
|
|||||||
await session.execute(ShowSchedule.__table__.delete())
|
await session.execute(ShowSchedule.__table__.delete())
|
||||||
await session.execute(Show.__table__.delete())
|
await session.execute(Show.__table__.delete())
|
||||||
await session.execute(Event.__table__.delete())
|
await session.execute(Event.__table__.delete())
|
||||||
await session.execute(DonationTier.__table__.delete())
|
|
||||||
await session.execute(StationConfig.__table__.delete())
|
await session.execute(StationConfig.__table__.delete())
|
||||||
await session.execute(HistoryEntry.__table__.delete())
|
await session.execute(HistoryEntry.__table__.delete())
|
||||||
await session.execute(TeamMember.__table__.delete())
|
await session.execute(TeamMember.__table__.delete())
|
||||||
|
|||||||
@@ -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 -->
|
<!-- Donation CTA -->
|
||||||
|
@if (config().donation_url) {
|
||||||
<div class="about-cta">
|
<div class="about-cta">
|
||||||
<div class="about-flower-divider" aria-hidden="true">
|
<div class="about-flower-divider" aria-hidden="true">
|
||||||
<img [src]="resolveImageUrl(config().logo_url)" alt="">
|
<img [src]="resolveImageUrl(config().logo_url)" alt="">
|
||||||
@@ -84,7 +85,8 @@
|
|||||||
signal strong. Members receive exclusive perks and the satisfaction of
|
signal strong. Members receive exclusive perks and the satisfaction of
|
||||||
knowing they helped make this possible.
|
knowing they helped make this possible.
|
||||||
</p>
|
</p>
|
||||||
<a routerLink="/donate" class="btn">Join the Station</a>
|
<a [href]="config().donation_url" target="_blank" rel="noopener noreferrer" class="btn">Join the Station</a>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
|
||||||
import { AsyncPipe } from '@angular/common';
|
import { AsyncPipe } from '@angular/common';
|
||||||
import { BehaviorSubject } from 'rxjs';
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ import { resolveImageUrl } from '../services/app-config.service';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-about',
|
selector: 'app-about',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterLink, AsyncPipe],
|
imports: [AsyncPipe],
|
||||||
templateUrl: './about.component.html',
|
templateUrl: './about.component.html',
|
||||||
styleUrl: './about.component.scss',
|
styleUrl: './about.component.scss',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -130,6 +130,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Donation -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h3>Donation</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="donation_url">External Donation URL</label>
|
||||||
|
<input id="donation_url" type="url" [(ngModel)]="donation_url" name="donation_url" placeholder="https://example.com/donate">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="button" class="btn btn-cancel" (click)="onClose()">Cancel</button>
|
<button type="button" class="btn btn-cancel" (click)="onClose()">Cancel</button>
|
||||||
<button type="submit" class="btn btn-save" [disabled]="saving">
|
<button type="submit" class="btn btn-save" [disabled]="saving">
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export class AdminStationFormComponent implements OnChanges {
|
|||||||
phone = '';
|
phone = '';
|
||||||
email = '';
|
email = '';
|
||||||
website = '';
|
website = '';
|
||||||
|
donation_url = '';
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
saving = false;
|
saving = false;
|
||||||
@@ -67,6 +68,7 @@ export class AdminStationFormComponent implements OnChanges {
|
|||||||
this.phone = config.phone;
|
this.phone = config.phone;
|
||||||
this.email = config.email;
|
this.email = config.email;
|
||||||
this.website = config.website;
|
this.website = config.website;
|
||||||
|
this.donation_url = config.donation_url;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reset form with current service values. */
|
/** Reset form with current service values. */
|
||||||
@@ -152,6 +154,7 @@ export class AdminStationFormComponent implements OnChanges {
|
|||||||
phone: this.phone,
|
phone: this.phone,
|
||||||
email: this.email,
|
email: this.email,
|
||||||
website: this.website,
|
website: this.website,
|
||||||
|
donation_url: this.donation_url,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
<div class="form-overlay" (click)="onClose()">
|
|
||||||
<div class="form-dialog" (click)="$event.stopPropagation()">
|
|
||||||
<div class="form-header">
|
|
||||||
<h2>{{ id ? 'Edit Tier' : 'Add Tier' }}</h2>
|
|
||||||
<button type="button" class="btn-close" (click)="onClose()">×</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="container">
|
||||||
<div class="admin-header">
|
<div class="admin-header">
|
||||||
<h1>Admin Dashboard</h1>
|
<h1>Admin Dashboard</h1>
|
||||||
<p>Manage shows, events, tiers, history, team, and community content</p>
|
<p>Manage shows, events, history, team, and community content</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
@@ -17,11 +17,6 @@
|
|||||||
[class.active]="activeTab() === 'events'"
|
[class.active]="activeTab() === 'events'"
|
||||||
(click)="activeTab.set('events')"
|
(click)="activeTab.set('events')"
|
||||||
>Events</button>
|
>Events</button>
|
||||||
<button
|
|
||||||
class="tab-btn"
|
|
||||||
[class.active]="activeTab() === 'tiers'"
|
|
||||||
(click)="activeTab.set('tiers')"
|
|
||||||
>Tiers</button>
|
|
||||||
<button
|
<button
|
||||||
class="tab-btn"
|
class="tab-btn"
|
||||||
[class.active]="activeTab() === 'history'"
|
[class.active]="activeTab() === 'history'"
|
||||||
@@ -130,46 +125,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Tiers Tab -->
|
|
||||||
@if (activeTab() === 'tiers') {
|
|
||||||
@if (tiers$ | async; as tiers) {
|
|
||||||
<div class="tab-content">
|
|
||||||
<div class="tab-toolbar">
|
|
||||||
<h2>Donation Tiers ({{ tiers.length }})</h2>
|
|
||||||
<button class="btn btn-add" (click)="openTierForm()">+ Add Tier</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Order</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Amount</th>
|
|
||||||
<th>Benefits</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (tier of tiers; track tier.id) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ tier.display_order }}</td>
|
|
||||||
<td>{{ tier.icon }} {{ tier.name }}</td>
|
|
||||||
<td>${{ tier.amount.toFixed(2) }}</td>
|
|
||||||
<td class="benefits-cell">{{ tier.benefits.join(', ') }}</td>
|
|
||||||
<td class="actions">
|
|
||||||
<button class="btn-icon btn-edit" (click)="editTier(tier)">Edit</button>
|
|
||||||
<button class="btn-icon btn-delete" (click)="deleteTier(tier.id)">Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr><td colspan="5" class="empty-row">No tiers yet. Click "Add Tier" to create one.</td></tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- History Tab -->
|
<!-- History Tab -->
|
||||||
@if (activeTab() === 'history') {
|
@if (activeTab() === 'history') {
|
||||||
@if (historyEntries$ | async; as entries) {
|
@if (historyEntries$ | async; as entries) {
|
||||||
@@ -336,6 +291,10 @@
|
|||||||
<span class="config-label">Website</span>
|
<span class="config-label">Website</span>
|
||||||
<span class="config-value">{{ config.website }}</span>
|
<span class="config-value">{{ config.website }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="config-row">
|
||||||
|
<span class="config-label">Donation URL</span>
|
||||||
|
<span class="config-value">{{ config.donation_url || '(not set)' }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -349,9 +308,6 @@
|
|||||||
@if (showEventForm) {
|
@if (showEventForm) {
|
||||||
<app-admin-event-form [editItem]="editingEvent" (saved)="onEventSaved()" (closed)="onEventClosed()" />
|
<app-admin-event-form [editItem]="editingEvent" (saved)="onEventSaved()" (closed)="onEventClosed()" />
|
||||||
}
|
}
|
||||||
@if (showTierForm) {
|
|
||||||
<app-admin-tier-form [editItem]="editingTier" (saved)="onTierSaved()" (closed)="onTierClosed()" />
|
|
||||||
}
|
|
||||||
@if (showStationForm) {
|
@if (showStationForm) {
|
||||||
<app-admin-station-form [editItem]="stationConfig$ | async" (saved)="onStationSaved()" (closed)="onStationClosed()" />
|
<app-admin-station-form [editItem]="stationConfig$ | async" (saved)="onStationSaved()" (closed)="onStationClosed()" />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,27 +5,24 @@ import { BehaviorSubject, firstValueFrom } from 'rxjs';
|
|||||||
|
|
||||||
import { Show } from '../interfaces/show';
|
import { Show } from '../interfaces/show';
|
||||||
import { Event } from '../interfaces/event';
|
import { Event } from '../interfaces/event';
|
||||||
import { Tier } from '../interfaces/tier';
|
|
||||||
import { StationConfig } from '../interfaces/station-config';
|
import { StationConfig } from '../interfaces/station-config';
|
||||||
import { HistoryEntry } from '../interfaces/history-entry';
|
import { HistoryEntry } from '../interfaces/history-entry';
|
||||||
import { TeamMember } from '../interfaces/team-member';
|
import { TeamMember } from '../interfaces/team-member';
|
||||||
import { CommunityHighlight } from '../interfaces/community-highlight';
|
import { CommunityHighlight } from '../interfaces/community-highlight';
|
||||||
import { ShowService } from '../services/show.service';
|
import { ShowService } from '../services/show.service';
|
||||||
import { EventService } from '../services/event.service';
|
import { EventService } from '../services/event.service';
|
||||||
import { TierService } from '../services/tier.service';
|
|
||||||
import { StationConfigService } from '../services/station-config.service';
|
import { StationConfigService } from '../services/station-config.service';
|
||||||
import { HistoryEntryService } from '../services/history-entry.service';
|
import { HistoryEntryService } from '../services/history-entry.service';
|
||||||
import { TeamMemberService } from '../services/team-member.service';
|
import { TeamMemberService } from '../services/team-member.service';
|
||||||
import { CommunityHighlightService } from '../services/community-highlight.service';
|
import { CommunityHighlightService } from '../services/community-highlight.service';
|
||||||
import { AdminShowFormComponent } from './admin-show-form.component';
|
import { AdminShowFormComponent } from './admin-show-form.component';
|
||||||
import { AdminEventFormComponent } from './admin-event-form.component';
|
import { AdminEventFormComponent } from './admin-event-form.component';
|
||||||
import { AdminTierFormComponent } from './admin-tier-form.component';
|
|
||||||
import { AdminStationFormComponent } from './admin-station-form.component';
|
import { AdminStationFormComponent } from './admin-station-form.component';
|
||||||
import { AdminHistoryFormComponent } from './admin-history-form.component';
|
import { AdminHistoryFormComponent } from './admin-history-form.component';
|
||||||
import { AdminTeamFormComponent } from './admin-team-form.component';
|
import { AdminTeamFormComponent } from './admin-team-form.component';
|
||||||
import { AdminCommunityFormComponent } from './admin-community-form.component';
|
import { AdminCommunityFormComponent } from './admin-community-form.component';
|
||||||
|
|
||||||
type TabKey = 'shows' | 'events' | 'tiers' | 'station' | 'history' | 'team' | 'community';
|
type TabKey = 'shows' | 'events' | 'station' | 'history' | 'team' | 'community';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin',
|
selector: 'app-admin',
|
||||||
@@ -36,7 +33,6 @@ type TabKey = 'shows' | 'events' | 'tiers' | 'station' | 'history' | 'team' | 'c
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
AdminShowFormComponent,
|
AdminShowFormComponent,
|
||||||
AdminEventFormComponent,
|
AdminEventFormComponent,
|
||||||
AdminTierFormComponent,
|
|
||||||
AdminStationFormComponent,
|
AdminStationFormComponent,
|
||||||
AdminHistoryFormComponent,
|
AdminHistoryFormComponent,
|
||||||
AdminTeamFormComponent,
|
AdminTeamFormComponent,
|
||||||
@@ -48,7 +44,6 @@ type TabKey = 'shows' | 'events' | 'tiers' | 'station' | 'history' | 'team' | 'c
|
|||||||
export class AdminComponent implements OnInit {
|
export class AdminComponent implements OnInit {
|
||||||
private showService = inject(ShowService);
|
private showService = inject(ShowService);
|
||||||
private eventService = inject(EventService);
|
private eventService = inject(EventService);
|
||||||
private tierService = inject(TierService);
|
|
||||||
private stationConfigService = inject(StationConfigService);
|
private stationConfigService = inject(StationConfigService);
|
||||||
private historyEntryService = inject(HistoryEntryService);
|
private historyEntryService = inject(HistoryEntryService);
|
||||||
private teamMemberService = inject(TeamMemberService);
|
private teamMemberService = inject(TeamMemberService);
|
||||||
@@ -59,7 +54,6 @@ export class AdminComponent implements OnInit {
|
|||||||
/** Item currently being edited (set by editXxx, cleared on save/close). */
|
/** Item currently being edited (set by editXxx, cleared on save/close). */
|
||||||
editingShow: Show | null = null;
|
editingShow: Show | null = null;
|
||||||
editingEvent: Event | null = null;
|
editingEvent: Event | null = null;
|
||||||
editingTier: Tier | null = null;
|
|
||||||
editingHistory: HistoryEntry | null = null;
|
editingHistory: HistoryEntry | null = null;
|
||||||
editingTeam: TeamMember | null = null;
|
editingTeam: TeamMember | null = null;
|
||||||
editingCommunity: CommunityHighlight | null = null;
|
editingCommunity: CommunityHighlight | null = null;
|
||||||
@@ -67,7 +61,6 @@ export class AdminComponent implements OnInit {
|
|||||||
/** BehaviorSubjects guarantee change detection via the async pipe — start empty, populated on load. */
|
/** BehaviorSubjects guarantee change detection via the async pipe — start empty, populated on load. */
|
||||||
readonly shows$ = new BehaviorSubject<Show[]>([]);
|
readonly shows$ = new BehaviorSubject<Show[]>([]);
|
||||||
readonly events$ = new BehaviorSubject<Event[]>([]);
|
readonly events$ = new BehaviorSubject<Event[]>([]);
|
||||||
readonly tiers$ = new BehaviorSubject<Tier[]>([]);
|
|
||||||
readonly stationConfig$ = new BehaviorSubject<StationConfig | null>(null);
|
readonly stationConfig$ = new BehaviorSubject<StationConfig | null>(null);
|
||||||
readonly historyEntries$ = new BehaviorSubject<HistoryEntry[]>([]);
|
readonly historyEntries$ = new BehaviorSubject<HistoryEntry[]>([]);
|
||||||
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
|
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
|
||||||
@@ -75,7 +68,6 @@ export class AdminComponent implements OnInit {
|
|||||||
|
|
||||||
showShowForm = false;
|
showShowForm = false;
|
||||||
showEventForm = false;
|
showEventForm = false;
|
||||||
showTierForm = false;
|
|
||||||
showStationForm = false;
|
showStationForm = false;
|
||||||
showHistoryForm = false;
|
showHistoryForm = false;
|
||||||
showTeamForm = false;
|
showTeamForm = false;
|
||||||
@@ -95,11 +87,6 @@ export class AdminComponent implements OnInit {
|
|||||||
this.events$.next(data);
|
this.events$.next(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async reloadTiers(): Promise<void> {
|
|
||||||
const data = await firstValueFrom(this.tierService.getTiers());
|
|
||||||
this.tiers$.next(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async reloadStationConfig(): Promise<void> {
|
async reloadStationConfig(): Promise<void> {
|
||||||
await this.stationConfigService.load();
|
await this.stationConfigService.load();
|
||||||
this.stationConfig$.next(this.stationConfigService.config());
|
this.stationConfig$.next(this.stationConfigService.config());
|
||||||
@@ -124,7 +111,6 @@ export class AdminComponent implements OnInit {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.reloadShows(),
|
this.reloadShows(),
|
||||||
this.reloadEvents(),
|
this.reloadEvents(),
|
||||||
this.reloadTiers(),
|
|
||||||
this.reloadStationConfig(),
|
this.reloadStationConfig(),
|
||||||
this.reloadHistoryEntries(),
|
this.reloadHistoryEntries(),
|
||||||
this.reloadTeamMembers(),
|
this.reloadTeamMembers(),
|
||||||
@@ -190,35 +176,6 @@ export class AdminComponent implements OnInit {
|
|||||||
await this.reloadEvents();
|
await this.reloadEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tier CRUD ───────────────────────────────────────────
|
|
||||||
|
|
||||||
openTierForm(): void {
|
|
||||||
this.editingTier = null;
|
|
||||||
this.showTierForm = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
editTier(tier: Tier): void {
|
|
||||||
this.editingTier = tier;
|
|
||||||
this.showTierForm = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
onTierSaved(): void {
|
|
||||||
this.reloadTiers();
|
|
||||||
this.showTierForm = false;
|
|
||||||
this.editingTier = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
onTierClosed(): void {
|
|
||||||
this.showTierForm = false;
|
|
||||||
this.editingTier = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteTier(id: number): Promise<void> {
|
|
||||||
if (!confirm('Delete this tier?')) return;
|
|
||||||
await firstValueFrom(this.tierService.deleteTier(id));
|
|
||||||
await this.reloadTiers();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Station Config ──────────────────────────────────────
|
// ── Station Config ──────────────────────────────────────
|
||||||
|
|
||||||
openStationForm(): void {
|
openStationForm(): void {
|
||||||
|
|||||||
@@ -18,11 +18,6 @@ export const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./schedule/schedule.component').then((m) => m.ScheduleComponent),
|
import('./schedule/schedule.component').then((m) => m.ScheduleComponent),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'donate',
|
|
||||||
loadComponent: () =>
|
|
||||||
import('./donate/donate.component').then((m) => m.DonateComponent),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'events',
|
path: 'events',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
<option value="" disabled selected>Choose a topic</option>
|
<option value="" disabled selected>Choose a topic</option>
|
||||||
<option value="general">General Inquiry</option>
|
<option value="general">General Inquiry</option>
|
||||||
<option value="volunteer">Volunteer</option>
|
<option value="volunteer">Volunteer</option>
|
||||||
<option value="donation">Donation Question</option>
|
|
||||||
<option value="advertising">Community Announcement</option>
|
<option value="advertising">Community Announcement</option>
|
||||||
<option value="song">Song Request</option>
|
<option value="song">Song Request</option>
|
||||||
<option value="technical">Technical Support</option>
|
<option value="technical">Technical Support</option>
|
||||||
|
|||||||
@@ -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="/about">About Us</a></li>
|
||||||
<li><a routerLink="/schedule">Schedule</a></li>
|
<li><a routerLink="/schedule">Schedule</a></li>
|
||||||
<li><a routerLink="/events">Events</a></li>
|
<li><a routerLink="/events">Events</a></li>
|
||||||
<li><a routerLink="/donate">Donate</a></li>
|
@if (config().donation_url) {
|
||||||
|
<li><a [href]="config().donation_url" target="_blank" rel="noopener noreferrer">Donate</a></li>
|
||||||
|
}
|
||||||
<li><a routerLink="/contact">Contact</a></li>
|
<li><a routerLink="/contact">Contact</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-col">
|
<div class="footer-col">
|
||||||
<h4>Support</h4>
|
<h4>Support</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a routerLink="/donate">Become a Member</a></li>
|
@if (config().donation_url) {
|
||||||
|
<li><a [href]="config().donation_url" target="_blank" rel="noopener noreferrer">Become a Member</a></li>
|
||||||
|
}
|
||||||
<li><a href="#">Volunteer</a></li>
|
<li><a href="#">Volunteer</a></li>
|
||||||
<li><a href="#">Corporate Giving</a></li>
|
<li><a href="#">Corporate Giving</a></li>
|
||||||
<li><a href="#">Planned Giving</a></li>
|
<li><a href="#">Planned Giving</a></li>
|
||||||
|
|||||||
@@ -25,7 +25,9 @@
|
|||||||
{{ config().tagline }}
|
{{ config().tagline }}
|
||||||
</p>
|
</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a routerLink="/donate" class="btn btn-hero-primary">Support the Station</a>
|
@if (config().donation_url) {
|
||||||
|
<a [href]="config().donation_url" target="_blank" rel="noopener noreferrer" class="btn btn-hero-primary">Support the Station</a>
|
||||||
|
}
|
||||||
<a routerLink="/schedule" class="btn btn-hero-secondary">View Schedule</a>
|
<a routerLink="/schedule" class="btn btn-hero-secondary">View Schedule</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-listeners">
|
<div class="hero-listeners">
|
||||||
|
|||||||
@@ -17,4 +17,5 @@ export interface StationConfig {
|
|||||||
phone: string;
|
phone: string;
|
||||||
email: string;
|
email: string;
|
||||||
website: 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>
|
<a routerLink="/login" class="btn-sign-in" (click)="closeMenu()">Sign In</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<a routerLink="/donate" class="btn btn-donate-nav" (click)="closeMenu()">Donate Now</a>
|
@if (config().donation_url) {
|
||||||
|
<a [href]="config().donation_url" target="_blank" rel="noopener noreferrer" class="btn btn-donate-nav" (click)="closeMenu()">Donate Now</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const DEFAULT_CONFIG: StationConfig = {
|
|||||||
phone: '(970) 555-0198',
|
phone: '(970) 555-0198',
|
||||||
email: 'hello@kmountainflower.org',
|
email: 'hello@kmountainflower.org',
|
||||||
website: 'kmountainflower.org',
|
website: 'kmountainflower.org',
|
||||||
|
donation_url: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
|
|||||||
@@ -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