Retires Support/donation/tiers page in favor of simple configurable link
This commit is contained in:
@@ -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