diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index c81fe00..5fe1d3e 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -2,7 +2,7 @@ name: CI Pipeline on: push: - branches: [main, develop, feature/tests-and-ci, feature/multi-admin-roles] + branches: ['**'] pull_request: branches: [main] diff --git a/backend/app/__pycache__/__init__.cpython-312 - Copy.pyc b/backend/app/__pycache__/__init__.cpython-312 - Copy.pyc deleted file mode 100644 index f2d8ded..0000000 Binary files a/backend/app/__pycache__/__init__.cpython-312 - Copy.pyc and /dev/null differ diff --git a/backend/app/__pycache__/__init__.cpython-312.pyc b/backend/app/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 3743842..0000000 Binary files a/backend/app/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/backend/app/__pycache__/auth.cpython-312.pyc b/backend/app/__pycache__/auth.cpython-312.pyc deleted file mode 100644 index 7c01d5e..0000000 Binary files a/backend/app/__pycache__/auth.cpython-312.pyc and /dev/null differ diff --git a/backend/app/__pycache__/config.cpython-312 - Copy.pyc b/backend/app/__pycache__/config.cpython-312 - Copy.pyc deleted file mode 100644 index c96e43d..0000000 Binary files a/backend/app/__pycache__/config.cpython-312 - Copy.pyc and /dev/null differ diff --git a/backend/app/__pycache__/config.cpython-312.pyc b/backend/app/__pycache__/config.cpython-312.pyc deleted file mode 100644 index e68e6b6..0000000 Binary files a/backend/app/__pycache__/config.cpython-312.pyc and /dev/null differ diff --git a/backend/app/__pycache__/database.cpython-312 - Copy.pyc b/backend/app/__pycache__/database.cpython-312 - Copy.pyc deleted file mode 100644 index 9a04cdf..0000000 Binary files a/backend/app/__pycache__/database.cpython-312 - Copy.pyc and /dev/null differ diff --git a/backend/app/__pycache__/database.cpython-312.pyc b/backend/app/__pycache__/database.cpython-312.pyc deleted file mode 100644 index 9a04cdf..0000000 Binary files a/backend/app/__pycache__/database.cpython-312.pyc and /dev/null differ diff --git a/backend/app/__pycache__/log_parser.cpython-312.pyc b/backend/app/__pycache__/log_parser.cpython-312.pyc deleted file mode 100644 index c66f89b..0000000 Binary files a/backend/app/__pycache__/log_parser.cpython-312.pyc and /dev/null differ diff --git a/backend/app/__pycache__/main.cpython-312 - Copy.pyc b/backend/app/__pycache__/main.cpython-312 - Copy.pyc deleted file mode 100644 index a1fc247..0000000 Binary files a/backend/app/__pycache__/main.cpython-312 - Copy.pyc and /dev/null differ diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc deleted file mode 100644 index 8e89514..0000000 Binary files a/backend/app/__pycache__/main.cpython-312.pyc and /dev/null differ diff --git a/backend/app/__pycache__/models.cpython-312 - Copy.pyc b/backend/app/__pycache__/models.cpython-312 - Copy.pyc deleted file mode 100644 index 2d459cc..0000000 Binary files a/backend/app/__pycache__/models.cpython-312 - Copy.pyc and /dev/null differ diff --git a/backend/app/__pycache__/models.cpython-312.pyc b/backend/app/__pycache__/models.cpython-312.pyc deleted file mode 100644 index 6fb50ac..0000000 Binary files a/backend/app/__pycache__/models.cpython-312.pyc and /dev/null differ diff --git a/backend/app/__pycache__/schemas.cpython-312.pyc b/backend/app/__pycache__/schemas.cpython-312.pyc deleted file mode 100644 index 5a3da62..0000000 Binary files a/backend/app/__pycache__/schemas.cpython-312.pyc and /dev/null differ diff --git a/backend/app/__pycache__/theme_export.cpython-312.pyc b/backend/app/__pycache__/theme_export.cpython-312.pyc deleted file mode 100644 index 06d0e08..0000000 Binary files a/backend/app/__pycache__/theme_export.cpython-312.pyc and /dev/null differ diff --git a/backend/app/__pycache__/user_models.cpython-312 - Copy.pyc b/backend/app/__pycache__/user_models.cpython-312 - Copy.pyc deleted file mode 100644 index ed7c844..0000000 Binary files a/backend/app/__pycache__/user_models.cpython-312 - Copy.pyc and /dev/null differ diff --git a/backend/app/__pycache__/user_models.cpython-312.pyc b/backend/app/__pycache__/user_models.cpython-312.pyc deleted file mode 100644 index ed7c844..0000000 Binary files a/backend/app/__pycache__/user_models.cpython-312.pyc and /dev/null differ diff --git a/backend/app/api/sitemap.py b/backend/app/api/sitemap.py new file mode 100644 index 0000000..6f6f419 --- /dev/null +++ b/backend/app/api/sitemap.py @@ -0,0 +1,60 @@ +"""Sitemap & robots.txt API: tenant-aware SEO files served by the backend. + +Because this is a white-label app where each tenant has its own domain +(set via KMTN_WEBSITE_URL), sitemap.xml and robots.txt are generated +on-the-fly from the station config — no static files needed. + +This matches the reviewer feedback: + 'This is a whitelabel app - this file may need to be dynaimcally generated...' + +Follows the same pattern as theme_export.py: content is derived from +KMTN_WEBSITE_URL at runtime, so every tenant gets their own correct URL. +""" + +from typing import Any, Dict + +from fastapi import APIRouter, Depends, Response, status as http_status + +from app.auth import get_current_admin_user +from app.config import settings +from app.sitemap_export import build_robots_txt, build_sitemap_xml +from app.user_models import User + +router = APIRouter() + + +@router.get("/sitemap.xml") +async def get_sitemap() -> Response: + """Return a dynamically-generated sitemap.xml. Public endpoint. + + Uses KMTN_WEBSITE_URL so each tenant gets the correct domain. + """ + xml = build_sitemap_xml(settings.WEBSITE_URL) + return Response(content=xml, media_type="application/xml") + + +@router.get("/robots.txt") +async def get_robots() -> Response: + """Return a dynamically-generated robots.txt. Public endpoint. + + Uses KMTN_WEBSITE_URL so the Sitemap directive points to the + correct tenant domain. + """ + txt = build_robots_txt(settings.WEBSITE_URL) + return Response(content=txt, media_type="text/plain") + + +@router.post("/regenerate", status_code=http_status.HTTP_200_OK) +async def regenerate_sitemap( + _: User = Depends(get_current_admin_user), +) -> Dict[str, Any]: + """Force regenerate the sitemap cache. Admin only. + + In the dynamic model this is a no-op (files are generated per-request), + but kept for API consistency with the theme endpoint. + """ + return { + "status": "ok", + "note": "sitemap and robots.txt are dynamically generated per-request using KMTN_WEBSITE_URL", + "base_url": settings.WEBSITE_URL, + } diff --git a/backend/app/main.py b/backend/app/main.py index 10ca14f..a72a6ed 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,7 +10,7 @@ from app.config import settings from app.database import async_session, engine from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight, Underwriter from app.user_models import User -from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters, stats, mobile_builds, theme +from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters, stats, mobile_builds, theme, sitemap def _cleanup_orphaned_sequences(sync_conn): @@ -78,6 +78,11 @@ def _migrate_add_missing_columns(sync_conn): ("station_config", "color_black", sa.String(7), "#1a1816"), # Multi-admin support: password_hash (nullable — existing rows stay NULL) ("users", "password_hash", sa.String(255), None), + # Social media columns + ("station_config", "social_facebook", sa.String(500), ""), + ("station_config", "social_twitter", sa.String(500), ""), + ("station_config", "social_instagram", sa.String(500), ""), + ("station_config", "social_youtube", sa.String(500), ""), ] # Determine dialect @@ -230,6 +235,7 @@ app.include_router(underwriters.router, prefix="/api/underwriters", tags=["under app.include_router(stats.router, prefix="/api/stats", tags=["stats"]) app.include_router(mobile_builds.router, prefix="/api/mobile-builds", tags=["mobile-builds"]) app.include_router(theme.router, prefix="/api/theme", tags=["theme"]) +app.include_router(sitemap.router, prefix="/api/sitemap", tags=["sitemap"]) # Admin user management (available in all environments) from app.api import users diff --git a/backend/app/models.py b/backend/app/models.py index 8954944..8b26604 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -97,6 +97,12 @@ class StationConfig(Base): # Donation donation_url = Column(String(500), nullable=False, default="") + # Social media + social_facebook = Column(String(500), nullable=False, default="") + social_twitter = Column(String(500), nullable=False, default="") + social_instagram = Column(String(500), nullable=False, default="") + social_youtube = Column(String(500), nullable=False, default="") + # Stream stream_url = Column(String(500), nullable=False, default="") stream_metadata_url = Column(String(500), nullable=False, default="") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 3ac4a84..bccb55b 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -78,6 +78,11 @@ class StationConfigResponse(BaseModel): play_store_icon_url: str play_store_url: str app_store_embed_html: str + # Social media + social_facebook: str + social_twitter: str + social_instagram: str + social_youtube: str # Page content hero_subtitle: str about_intro: str @@ -129,6 +134,11 @@ class StationConfigUpdate(BaseModel): play_store_icon_url: Optional[str] = None play_store_url: Optional[str] = None app_store_embed_html: Optional[str] = None + # Social media + social_facebook: Optional[str] = None + social_twitter: Optional[str] = None + social_instagram: Optional[str] = None + social_youtube: Optional[str] = None # Page content hero_subtitle: Optional[str] = None about_intro: Optional[str] = None diff --git a/backend/app/sitemap_export.py b/backend/app/sitemap_export.py new file mode 100644 index 0000000..13a7ec9 --- /dev/null +++ b/backend/app/sitemap_export.py @@ -0,0 +1,52 @@ +"""Sitemap & robots.txt generation: tenant-aware SEO content. + +Because this is a white-label app where each tenant has its own domain +(set via KMTN_WEBSITE_URL), the sitemap and robots.txt are generated +at runtime — no static files are needed. The FastAPI /api/sitemap/* +endpoints call these functions; Nginx proxies /sitemap.xml and +/robots.txt to those endpoints so search-engine crawlers hit the right +content at the root URL. +""" + +# Public routes included in the sitemap with their change frequency and priority. +SEOMAP_ROUTES = [ + {"path": "/", "changefreq": "daily", "priority": 1.0}, + {"path": "/about", "changefreq": "weekly", "priority": 0.8}, + {"path": "/schedule", "changefreq": "weekly", "priority": 0.8}, + {"path": "/events", "changefreq": "daily", "priority": 0.8}, + {"path": "/contact", "changefreq": "monthly", "priority": 0.5}, +] + + +def build_sitemap_xml(base_url: str) -> str: + """Build a valid sitemap.xml string for the given base URL.""" + url = base_url.rstrip("/") + entries = [] + for route in SEOMAP_ROUTES: + entries.append( + f" \n" + f" {url}{route['path']}\n" + f" {route['changefreq']}\n" + f" {route['priority']}\n" + f" " + ) + return ( + '\n' + '\n' + + "\n".join(entries) + "\n" + "\n" + ) + + +def build_robots_txt(base_url: str) -> str: + """Build a robots.txt string that references the correct sitemap URL.""" + sitemap = base_url.rstrip("/") + "/sitemap.xml" + return ( + "User-agent: *\n" + "Allow: /\n" + "\n" + "Disallow: /admin\n" + "Disallow: /login\n" + "\n" + f"Sitemap: {sitemap}\n" + ) diff --git a/backend/tests/test_sitemap_api.py b/backend/tests/test_sitemap_api.py new file mode 100644 index 0000000..40720c4 --- /dev/null +++ b/backend/tests/test_sitemap_api.py @@ -0,0 +1,55 @@ +"""Tests for sitemap API endpoints — /api/sitemap/sitemap.xml, /api/sitemap/robots.txt, /api/sitemap/regenerate.""" + +import pytest +from httpx import AsyncClient +from starlette.testclient import TestClient + +from app.main import app + + +class TestSitemapEndpoints: + """Tests for sitemap API routes.""" + + def _client(self): + return TestClient(app) + + def test_sitemap_xml_status(self): + client = self._client() + resp = client.get("/api/sitemap/sitemap.xml") + assert resp.status_code == 200 + + def test_sitemap_xml_content_type(self): + client = self._client() + resp = client.get("/api/sitemap/sitemap.xml") + assert resp.headers["content-type"].startswith("application/xml") + + def test_sitemap_xml_contains_routes(self): + client = self._client() + resp = client.get("/api/sitemap/sitemap.xml") + body = resp.text + for path in ["/", "/about", "/schedule", "/events", "/contact"]: + assert f"https://kmountainflower.org{path}" in body + + def test_robots_txt_status(self): + client = self._client() + resp = client.get("/api/sitemap/robots.txt") + assert resp.status_code == 200 + + def test_robots_txt_content_type(self): + client = self._client() + resp = client.get("/api/sitemap/robots.txt") + assert resp.headers["content-type"].startswith("text/plain") + + def test_robots_txt_contains_rules(self): + client = self._client() + resp = client.get("/api/sitemap/robots.txt") + body = resp.text + assert "User-agent: *" in body + assert "Disallow: /admin" in body + assert "Disallow: /login" in body + assert "Sitemap: https://kmountainflower.org/sitemap.xml" in body + + def test_regenerate_requires_auth(self): + client = self._client() + resp = client.post("/api/sitemap/regenerate") + assert resp.status_code in (401, 403) diff --git a/backend/tests/test_sitemap_export.py b/backend/tests/test_sitemap_export.py new file mode 100644 index 0000000..4b5f6f8 --- /dev/null +++ b/backend/tests/test_sitemap_export.py @@ -0,0 +1,113 @@ +"""Tests for sitemap_export — sitemap.xml and robots.txt generation.""" + +import pytest +from xml.etree import ElementTree as ET + +from app.sitemap_export import ( + SEOMAP_ROUTES, + build_sitemap_xml, + build_robots_txt, +) + + +class TestSitemapRoutes: + """Verify the route configuration constants.""" + + def test_route_count(self): + assert len(SEOMAP_ROUTES) == 5 + + def test_home_route(self): + assert SEOMAP_ROUTES[0] == { + "path": "/", + "changefreq": "daily", + "priority": 1.0, + } + + def test_all_paths_present(self): + paths = [r["path"] for r in SEOMAP_ROUTES] + expected = ["/", "/about", "/schedule", "/events", "/contact"] + assert paths == expected + + def test_priority_range(self): + for route in SEOMAP_ROUTES: + assert 0.0 <= route["priority"] <= 1.0 + + def test_valid_changefreq_values(self): + valid = {"always", "hourly", "daily", "weekly", "monthly", "yearly", "never"} + for route in SEOMAP_ROUTES: + assert route["changefreq"] in valid + + +class TestBuildSitemapXml: + """Tests for the sitemap XML builder.""" + + def test_returns_string(self): + result = build_sitemap_xml("https://example.com") + assert isinstance(result, str) + + def test_contains_xml_declaration(self): + result = build_sitemap_xml("https://example.com") + assert '' in result + + def test_contains_urlset_namespace(self): + result = build_sitemap_xml("https://example.com") + assert 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"' in result + + def test_contains_all_routes(self): + result = build_sitemap_xml("https://example.com") + for route in SEOMAP_ROUTES: + assert f"https://example.com{route['path']}" in result + + def test_strips_trailing_slash_from_base_url(self): + result = build_sitemap_xml("https://example.com/") + assert "https://example.com//about" not in result + assert "https://example.com/about" in result + + def test_different_base_url(self): + result = build_sitemap_xml("https://mystation.org") + assert "https://mystation.org/schedule" in result + assert "https://example.com" not in result + + def test_valid_xml(self): + result = build_sitemap_xml("https://example.com") + # Should parse without error + root = ET.fromstring(result) + # The root tag includes the namespace + assert "urlset" in root.tag + + def test_contains_changefreq_and_priority(self): + result = build_sitemap_xml("https://example.com") + assert "daily" in result + assert "1.0" in result + assert "0.5" in result + + +class TestBuildRobotsTxt: + """Tests for the robots.txt builder.""" + + def test_returns_string(self): + result = build_robots_txt("https://example.com") + assert isinstance(result, str) + + def test_user_agent_allows_all(self): + result = build_robots_txt("https://example.com") + assert "User-agent: *" in result + assert "Allow: /" in result + + def test_disallows_admin_and_login(self): + result = build_robots_txt("https://example.com") + assert "Disallow: /admin" in result + assert "Disallow: /login" in result + + def test_sitemap_reference(self): + result = build_robots_txt("https://example.com") + assert "Sitemap: https://example.com/sitemap.xml" in result + + def test_strips_trailing_slash(self): + result = build_robots_txt("https://example.com/") + assert "Sitemap: https://example.com//sitemap.xml" not in result + assert "Sitemap: https://example.com/sitemap.xml" in result + + def test_different_base_url(self): + result = build_robots_txt("https://mystation.org") + assert "Sitemap: https://mystation.org/sitemap.xml" in result diff --git a/nginx.conf.template b/nginx.conf.template index 8f3f265..742c83d 100644 --- a/nginx.conf.template +++ b/nginx.conf.template @@ -24,6 +24,24 @@ server { try_files $uri $uri/ /index.html; } + # Dynamic sitemap.xml + robots.txt — proxied to the backend so each + # white-label tenant gets their KMTN_WEBSITE_URL in the output. + location = /sitemap.xml { + proxy_pass __API_UPSTREAM__/api/sitemap/sitemap.xml; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + } + + location = /robots.txt { + proxy_pass __API_UPSTREAM__/api/sitemap/robots.txt; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + } + # Proxy API calls to the backend service location /api/ { proxy_pass __API_UPSTREAM__; diff --git a/src/app/about/about.component.html b/src/app/about/about.component.html index 114dab7..931389e 100644 --- a/src/app/about/about.component.html +++ b/src/app/about/about.component.html @@ -73,7 +73,7 @@ @if (config().donation_url) {

Become a Member

{{ config().about_donation_text }}

diff --git a/src/app/admin/admin-station-form.component.html b/src/app/admin/admin-station-form.component.html index 469d777..b6bc1d5 100644 --- a/src/app/admin/admin-station-form.component.html +++ b/src/app/admin/admin-station-form.component.html @@ -184,6 +184,31 @@
+ +
+

Social Media

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

Page Content

diff --git a/src/app/admin/admin-station-form.component.ts b/src/app/admin/admin-station-form.component.ts index 65bb4bb..6f11ae3 100644 --- a/src/app/admin/admin-station-form.component.ts +++ b/src/app/admin/admin-station-form.component.ts @@ -43,6 +43,10 @@ export class AdminStationFormComponent implements OnChanges { play_store_icon_url = ''; play_store_url = ''; app_store_embed_html = ''; + social_facebook = ''; + social_twitter = ''; + social_instagram = ''; + social_youtube = ''; hero_subtitle = ''; about_intro = ''; about_donation_text = ''; @@ -84,6 +88,10 @@ export class AdminStationFormComponent implements OnChanges { this.play_store_icon_url = config.play_store_icon_url; this.play_store_url = config.play_store_url; this.app_store_embed_html = config.app_store_embed_html; + this.social_facebook = config.social_facebook; + this.social_twitter = config.social_twitter; + this.social_instagram = config.social_instagram; + this.social_youtube = config.social_youtube; this.hero_subtitle = config.hero_subtitle; this.about_intro = config.about_intro; this.about_donation_text = config.about_donation_text; @@ -180,6 +188,10 @@ export class AdminStationFormComponent implements OnChanges { play_store_icon_url: this.play_store_icon_url, play_store_url: this.play_store_url, app_store_embed_html: this.app_store_embed_html, + social_facebook: this.social_facebook, + social_twitter: this.social_twitter, + social_instagram: this.social_instagram, + social_youtube: this.social_youtube, hero_subtitle: this.hero_subtitle, about_intro: this.about_intro, about_donation_text: this.about_donation_text, diff --git a/src/app/events/events.component.html b/src/app/events/events.component.html index 3b820d6..42ec0d7 100644 --- a/src/app/events/events.component.html +++ b/src/app/events/events.component.html @@ -29,7 +29,7 @@ @if (event.rsvp_url) { RSVP → } @else { - Learn More → + Learn More → }
diff --git a/src/app/events/events.component.ts b/src/app/events/events.component.ts index d795f23..ec96c21 100644 --- a/src/app/events/events.component.ts +++ b/src/app/events/events.component.ts @@ -1,5 +1,6 @@ import { Component, inject } from '@angular/core'; import { AsyncPipe, DatePipe, NgFor } from '@angular/common'; +import { RouterLinkWithHref } from '@angular/router'; import { map, Observable, startWith } from 'rxjs'; import { EventService } from '../services/event.service'; @@ -14,7 +15,7 @@ interface EventsState { @Component({ selector: 'app-events', standalone: true, - imports: [AsyncPipe, DatePipe, NgFor], + imports: [AsyncPipe, DatePipe, NgFor, RouterLinkWithHref], templateUrl: './events.component.html', styleUrl: './events.component.scss', }) diff --git a/src/app/footer/footer.component.html b/src/app/footer/footer.component.html index ec17f37..d272a6f 100644 --- a/src/app/footer/footer.component.html +++ b/src/app/footer/footer.component.html @@ -35,18 +35,26 @@ @if (config().donation_url) {
  • Become a Member
  • } -
  • Volunteer
  • -
  • Corporate Giving
  • -
  • Planned Giving
  • +
  • Volunteer
  • +
  • Corporate Giving
  • +
  • Planned Giving
  • diff --git a/src/app/footer/footer.component.ts b/src/app/footer/footer.component.ts index f005716..676fbdd 100644 --- a/src/app/footer/footer.component.ts +++ b/src/app/footer/footer.component.ts @@ -1,6 +1,6 @@ import { Component, inject, computed } from '@angular/core'; import { SlicePipe } from '@angular/common'; -import { RouterLink } from '@angular/router'; +import { RouterLinkWithHref } from '@angular/router'; import { StationConfigService } from '../services/station-config.service'; import { resolveImageUrl } from '../services/app-config.service'; @@ -8,7 +8,7 @@ import { resolveImageUrl } from '../services/app-config.service'; @Component({ selector: 'app-footer', standalone: true, - imports: [RouterLink, SlicePipe], + imports: [RouterLinkWithHref, SlicePipe], templateUrl: './footer.component.html', styleUrl: './footer.component.scss', }) diff --git a/src/app/interfaces/station-config.ts b/src/app/interfaces/station-config.ts index 9d7b60c..131d2ff 100644 --- a/src/app/interfaces/station-config.ts +++ b/src/app/interfaces/station-config.ts @@ -23,6 +23,11 @@ export interface StationConfig { play_store_icon_url: string; play_store_url: string; app_store_embed_html: string; + // Social media + social_facebook: string; + social_twitter: string; + social_instagram: string; + social_youtube: string; // Page content hero_subtitle: string; about_intro: string; diff --git a/src/app/services/station-config.service.ts b/src/app/services/station-config.service.ts index 4eabf6c..983e1ff 100644 --- a/src/app/services/station-config.service.ts +++ b/src/app/services/station-config.service.ts @@ -32,6 +32,11 @@ const DEFAULT_CONFIG: StationConfig = { play_store_icon_url: '', play_store_url: '', app_store_embed_html: '', + // Social media + social_facebook: '', + social_twitter: '', + social_instagram: '', + social_youtube: '', // Page content hero_subtitle: 'Your community-powered, nonprofit radio station — bringing music, stories, and connection to the mountain towns and beyond since 1987.', about_intro: 'For nearly four decades, KMountain Flower Radio has been the voice of our community — a nonprofit, listener-supported radio station dedicated to music, local news, and the stories that bind mountain towns together.',