diff --git a/backend/main.py b/backend/main.py index c1eac2d..1066bc5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -655,6 +655,13 @@ class FacilityVisibilityRequest(BaseModel): class PlacePageUpsertRequest(BaseModel): factbox_intro_html: Optional[str] = "" + meta_title: Optional[str] = None + meta_description: Optional[str] = None + + +class SitePageSeoUpsertRequest(BaseModel): + meta_title: Optional[str] = None + meta_description: Optional[str] = None class SimulatorOperatorUpsertRequest(BaseModel): @@ -663,6 +670,8 @@ class SimulatorOperatorUpsertRequest(BaseModel): website_url: Optional[str] = None logo_url: Optional[str] = None description: Optional[str] = None + meta_title: Optional[str] = None + meta_description: Optional[str] = None is_published: bool = False @@ -684,6 +693,8 @@ class SimulatorVenueUpsertRequest(BaseModel): phone: Optional[str] = None email: Optional[str] = None image_url: Optional[str] = None + meta_title: Optional[str] = None + meta_description: Optional[str] = None simulator_systems: Optional[List[str]] = [] bay_count: Optional[int] = Field(default=None, ge=0) lessons_available: Optional[bool] = None @@ -817,6 +828,8 @@ FACILITY_ALLOWED_FIELDS = [ 'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer', 'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url', 'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris', 'vtg_datoer', + 'camper_parking', + 'meta_title', 'meta_description', 'guest_requirements', 'scrape_method', 'scrape_status_url', 'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at', 'greenfee_url', 'golfpakker_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector', @@ -853,7 +866,8 @@ FACILITY_VIEW_SEARCH_FIELDS = { 'id', 'slug', 'name', 'architect', 'description', 'city', 'county', 'banetype', 'image_url', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url', 'weather_url', 'lat', 'lng', 'golfamore', 'golfamore_url', 'nsg_url', 'has_golfpakker', - 'vtg_pris', 'vtg_lenke', 'vtg_beskrivelse', 'footnote', 'footnote_updated_at', + 'vtg_pris', 'vtg_lenke', 'vtg_beskrivelse', 'camper_parking', 'meta_title', 'meta_description', + 'footnote', 'footnote_updated_at', 'status_updated_at', 'amenities', 'golfamore_data', 'nsg_data', 'vtg_datoer', 'course_statuses', 'weather_forecast', } @@ -1041,6 +1055,24 @@ def format_place_page_row(row): d[key] = d[key].isoformat() d["factbox_intro_html"] = str(d.get("factbox_intro_html") or "") + d["meta_title"] = str(d.get("meta_title") or "") + d["meta_description"] = str(d.get("meta_description") or "") + return d + + +def format_site_page_seo_row(row): + if row is None: + return None + + d = dict(row) + + for key in ["created_at", "updated_at"]: + if isinstance(d.get(key), (date, datetime)): + d[key] = d[key].isoformat() + + d["page_key"] = str(d.get("page_key") or "") + d["meta_title"] = str(d.get("meta_title") or "") + d["meta_description"] = str(d.get("meta_description") or "") return d @@ -1140,8 +1172,8 @@ async def save_simulator_operator(conn, request: SimulatorOperatorUpsertRequest, return await conn.fetchrow( """ INSERT INTO simulator_operators ( - name, slug, website_url, logo_url, description, is_published - ) VALUES ($1, $2, $3, $4, $5, $6) + name, slug, website_url, logo_url, description, meta_title, meta_description, is_published + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING * """, name, @@ -1149,6 +1181,8 @@ async def save_simulator_operator(conn, request: SimulatorOperatorUpsertRequest, normalize_optional_text(request.website_url), normalize_optional_text(request.logo_url), normalize_optional_text(request.description), + normalize_optional_text(request.meta_title), + normalize_optional_text(request.meta_description), bool(request.is_published), ) @@ -1160,9 +1194,11 @@ async def save_simulator_operator(conn, request: SimulatorOperatorUpsertRequest, website_url = $3, logo_url = $4, description = $5, - is_published = $6, + meta_title = $6, + meta_description = $7, + is_published = $8, updated_at = NOW() - WHERE id = $7 + WHERE id = $9 RETURNING * """, name, @@ -1170,6 +1206,8 @@ async def save_simulator_operator(conn, request: SimulatorOperatorUpsertRequest, normalize_optional_text(request.website_url), normalize_optional_text(request.logo_url), normalize_optional_text(request.description), + normalize_optional_text(request.meta_title), + normalize_optional_text(request.meta_description), bool(request.is_published), operator_id, ) @@ -1243,6 +1281,8 @@ async def save_simulator_venue(conn, request: SimulatorVenueUpsertRequest, venue normalize_optional_text(request.phone), normalize_optional_text(request.email), normalize_optional_text(request.image_url), + normalize_optional_text(request.meta_title), + normalize_optional_text(request.meta_description), json.dumps(normalize_simulator_systems(request.simulator_systems)), request.bay_count, request.lessons_available, @@ -1262,17 +1302,17 @@ async def save_simulator_venue(conn, request: SimulatorVenueUpsertRequest, venue INSERT INTO simulator_venues ( operator_id, facility_id, name, slug, venue_type, description, city, county, address, postal_code, lat, lng, - website_url, booking_url, phone, email, image_url, simulator_systems, - bay_count, lessons_available, club_fitting, food_and_drink, - serves_alcohol, drop_in, membership_required, opening_hours, - price_from, season, is_published + website_url, booking_url, phone, email, image_url, meta_title, meta_description, + simulator_systems, bay_count, lessons_available, club_fitting, + food_and_drink, serves_alcohol, drop_in, membership_required, + opening_hours, price_from, season, is_published ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, - $13, $14, $15, $16, $17, $18::jsonb, - $19, $20, $21, $22, - $23, $24, $25, $26, - $27, $28, $29 + $13, $14, $15, $16, $17, $18, $19, $20::jsonb, + $21, $22, $23, $24, + $25, $26, $27, $28, + $29, $30, $31 ) """ else: @@ -1295,20 +1335,22 @@ async def save_simulator_venue(conn, request: SimulatorVenueUpsertRequest, venue phone = $15, email = $16, image_url = $17, - simulator_systems = $18::jsonb, - bay_count = $19, - lessons_available = $20, - club_fitting = $21, - food_and_drink = $22, - serves_alcohol = $23, - drop_in = $24, - membership_required = $25, - opening_hours = $26, - price_from = $27, - season = $28, - is_published = $29, + meta_title = $18, + meta_description = $19, + simulator_systems = $20::jsonb, + bay_count = $21, + lessons_available = $22, + club_fitting = $23, + food_and_drink = $24, + serves_alcohol = $25, + drop_in = $26, + membership_required = $27, + opening_hours = $28, + price_from = $29, + season = $30, + is_published = $31, updated_at = NOW() - WHERE id = $30 + WHERE id = $32 """ values.append(venue_id) @@ -2276,6 +2318,9 @@ async def ensure_facility_columns(conn): ALTER TABLE facilities ADD COLUMN IF NOT EXISTS is_published BOOLEAN NOT NULL DEFAULT TRUE, ADD COLUMN IF NOT EXISTS footnote_updated_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS camper_parking TEXT, + ADD COLUMN IF NOT EXISTS meta_title TEXT, + ADD COLUMN IF NOT EXISTS meta_description TEXT, ADD COLUMN IF NOT EXISTS golfamore_url TEXT, ADD COLUMN IF NOT EXISTS golfpakker_url TEXT, ADD COLUMN IF NOT EXISTS golfpakker_draft JSONB, @@ -2387,6 +2432,22 @@ async def ensure_place_pages_table(conn): CREATE TABLE IF NOT EXISTS place_pages ( slug VARCHAR(255) PRIMARY KEY, factbox_intro_html TEXT, + meta_title TEXT, + meta_description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + await conn.execute("ALTER TABLE place_pages ADD COLUMN IF NOT EXISTS meta_title TEXT") + await conn.execute("ALTER TABLE place_pages ADD COLUMN IF NOT EXISTS meta_description TEXT") + + +async def ensure_site_page_seo_table(conn): + await conn.execute(""" + CREATE TABLE IF NOT EXISTS site_page_seo ( + page_key VARCHAR(255) PRIMARY KEY, + meta_title TEXT, + meta_description TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) @@ -2555,11 +2616,15 @@ async def ensure_simulator_operator_tables(conn): website_url TEXT, logo_url TEXT, description TEXT, + meta_title TEXT, + meta_description TEXT, is_published BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """) + await conn.execute("ALTER TABLE simulator_operators ADD COLUMN IF NOT EXISTS meta_title TEXT") + await conn.execute("ALTER TABLE simulator_operators ADD COLUMN IF NOT EXISTS meta_description TEXT") await conn.execute(""" CREATE INDEX IF NOT EXISTS simulator_operators_name_idx ON simulator_operators (name) @@ -2587,6 +2652,8 @@ async def ensure_simulator_venue_tables(conn): phone VARCHAR(64), email VARCHAR(255), image_url TEXT, + meta_title TEXT, + meta_description TEXT, simulator_systems JSONB NOT NULL DEFAULT '[]'::jsonb, bay_count INTEGER, lessons_available BOOLEAN, @@ -2619,6 +2686,8 @@ async def ensure_simulator_venue_tables(conn): CREATE INDEX IF NOT EXISTS simulator_venues_public_location_idx ON simulator_venues (is_published, county, city, name) """) + await conn.execute("ALTER TABLE simulator_venues ADD COLUMN IF NOT EXISTS meta_title TEXT") + await conn.execute("ALTER TABLE simulator_venues ADD COLUMN IF NOT EXISTS meta_description TEXT") @asynccontextmanager @@ -2636,6 +2705,7 @@ async def lifespan(app: FastAPI): await ensure_facility_columns(conn) await ensure_vtg_course_tables(conn) await ensure_place_pages_table(conn) + await ensure_site_page_seo_table(conn) await ensure_articles_table(conn) await ensure_public_user_tables(conn) await ensure_simulator_operator_tables(conn) @@ -3462,6 +3532,9 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non pf.vtg_pris, pf.vtg_lenke, pf.vtg_beskrivelse, + pf.camper_parking, + pf.meta_title, + pf.meta_description, pf.amenities, pf.golfamore_data, pf.nsg_data, @@ -3514,6 +3587,9 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non pf.vtg_pris, pf.vtg_lenke, pf.vtg_beskrivelse, + pf.camper_parking, + pf.meta_title, + pf.meta_description, pf.amenities, pf.golfamore_data, pf.nsg_data, @@ -3765,6 +3841,8 @@ async def get_place_page(slug: str, response: Response): return { "slug": normalized_slug, "factbox_intro_html": "", + "meta_title": "", + "meta_description": "", "created_at": None, "updated_at": None, } @@ -3780,6 +3858,33 @@ async def get_place_page(slug: str, response: Response): return payload +VALID_SITE_PAGE_SEO_KEYS = {"golfbaner", "vtg", "medlemskap", "simulatorer"} + + +@app.get("/api/page-seo/{page_key}") +async def get_public_site_page_seo(page_key: str): + normalized_key = str(page_key or "").strip().lower() + if normalized_key not in VALID_SITE_PAGE_SEO_KEYS: + raise HTTPException(status_code=404, detail="SEO-side ikke funnet.") + + async with app.state.pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT * FROM site_page_seo WHERE page_key = $1", + normalized_key, + ) + + if not row: + return { + "page_key": normalized_key, + "meta_title": "", + "meta_description": "", + "created_at": None, + "updated_at": None, + } + + return format_site_page_seo_row(row) + + @app.get("/api/admin/facilities") async def get_admin_facilities(): """Henter alle golfanlegg for admin, også upubliserte.""" @@ -3943,6 +4048,8 @@ async def get_admin_place_page(slug: str): return { "slug": normalized_slug, "factbox_intro_html": "", + "meta_title": "", + "meta_description": "", "created_at": None, "updated_at": None, } @@ -3959,21 +4066,74 @@ async def update_admin_place_page(slug: str, request: PlacePageUpsertRequest): async with app.state.pool.acquire() as conn: row = await conn.fetchrow( """ - INSERT INTO place_pages (slug, factbox_intro_html) - VALUES ($1, $2) + INSERT INTO place_pages (slug, factbox_intro_html, meta_title, meta_description) + VALUES ($1, $2, $3, $4) ON CONFLICT (slug) DO UPDATE SET factbox_intro_html = EXCLUDED.factbox_intro_html, + meta_title = EXCLUDED.meta_title, + meta_description = EXCLUDED.meta_description, updated_at = NOW() RETURNING * """, normalized_slug, request.factbox_intro_html or "", + normalize_optional_text(request.meta_title), + normalize_optional_text(request.meta_description), ) invalidate_public_api_caches(include_place_pages=True) return format_place_page_row(row) +@app.get("/api/admin/page-seo/{page_key}") +async def get_admin_site_page_seo(page_key: str): + normalized_key = str(page_key or "").strip().lower() + if normalized_key not in VALID_SITE_PAGE_SEO_KEYS: + raise HTTPException(status_code=404, detail="SEO-side ikke funnet.") + + async with app.state.pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT * FROM site_page_seo WHERE page_key = $1", + normalized_key, + ) + + if not row: + return { + "page_key": normalized_key, + "meta_title": "", + "meta_description": "", + "created_at": None, + "updated_at": None, + } + + return format_site_page_seo_row(row) + + +@app.put("/api/admin/page-seo/{page_key}") +async def update_admin_site_page_seo(page_key: str, request: SitePageSeoUpsertRequest): + normalized_key = str(page_key or "").strip().lower() + if normalized_key not in VALID_SITE_PAGE_SEO_KEYS: + raise HTTPException(status_code=404, detail="SEO-side ikke funnet.") + + async with app.state.pool.acquire() as conn: + row = await conn.fetchrow( + """ + INSERT INTO site_page_seo (page_key, meta_title, meta_description) + VALUES ($1, $2, $3) + ON CONFLICT (page_key) DO UPDATE + SET meta_title = EXCLUDED.meta_title, + meta_description = EXCLUDED.meta_description, + updated_at = NOW() + RETURNING * + """, + normalized_key, + normalize_optional_text(request.meta_title), + normalize_optional_text(request.meta_description), + ) + + return format_site_page_seo_row(row) + + @app.get("/api/course-visits") async def get_course_visits(): """Henter publiserte Banebesøk-artikler.""" diff --git a/frontend/src/app/FacilitySearch.tsx b/frontend/src/app/FacilitySearch.tsx index 4a56bac..0a991db 100755 --- a/frontend/src/app/FacilitySearch.tsx +++ b/frontend/src/app/FacilitySearch.tsx @@ -86,7 +86,7 @@ const AREA_GROUPS: Record = { "nord-norge": ["finnmark", "troms", "nordland"], "midt-norge": ["trondelag", "nord-trondelag", "sor-trondelag"], vestlandet: ["more-og-romsdal", "sogn-og-fjordane", "hordaland", "rogaland", "vestland"], - sorlandet: ["vest-agder", "aust-agder", "agder"], + sorlandet: ["vest-agder", "aust-agder"], ostlandet: ["telemark", "vestfold", "ostfold", "buskerud", "hedmark", "oppland", "innlandet", "viken", "akershus", "oslo"], "oslo-og-akershus": ["akershus", "oslo", "viken"], }; @@ -112,7 +112,6 @@ const HIERARCHICAL_AREA_OPTIONS = [ { value: "region:sorlandet", label: "Sørlandet" }, { value: "county:vest-agder", label: "\u00A0\u00A0\u00A0Vest-Agder" }, { value: "county:aust-agder", label: "\u00A0\u00A0\u00A0Aust-Agder" }, - { value: "county:agder", label: "\u00A0\u00A0\u00A0Agder" }, { value: "region:ostlandet", label: "Østlandet" }, { value: "county:telemark", label: "\u00A0\u00A0\u00A0Telemark" }, { value: "county:vestfold", label: "\u00A0\u00A0\u00A0Vestfold" }, diff --git a/frontend/src/app/admin/nytt-anlegg/page.tsx b/frontend/src/app/admin/nytt-anlegg/page.tsx index 039c10f..c7ba1c0 100644 --- a/frontend/src/app/admin/nytt-anlegg/page.tsx +++ b/frontend/src/app/admin/nytt-anlegg/page.tsx @@ -14,6 +14,9 @@ const EMPTY_FACILITY = { vtg_datoer: [], cooperating_clubs: [], amenities: {}, + camper_parking: "", + meta_title: "", + meta_description: "", nsg_data: {}, golfamore_data: {}, }; diff --git a/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx b/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx index eb5ad33..a45aa28 100644 --- a/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx +++ b/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx @@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { adminFetch } from "@/config/adminFetch"; import AdminMobileMenu from "@/components/AdminMobileMenu"; +import SeoFieldset, { trimSuggestion } from "@/components/admin/SeoFieldset"; // KOMPONENT 1: MultiSelect for samarbeidende klubber const MultiSelect = ({ label, options, selected, onChange }: { label: string, options: any[], selected: string[], onChange: (s: string[]) => void }) => { @@ -27,7 +28,17 @@ const MultiSelect = ({ label, options, selected, onChange }: { label: string, op }; // KOMPONENT 2: Viser flate JSON-objekter (som fasiliteter) som rader med Nøkkel og Verdi -const KeyValueEditor = ({ label, value, onChange }: { label: string, value: any, onChange: (v: any) => void }) => { +const KeyValueEditor = ({ + label, + value, + onChange, + children, +}: { + label: string, + value: any, + onChange: (v: any) => void, + children?: ReactNode, +}) => { const entries = Object.entries(value || {}); const updateKey = (oldKey: string, newKey: string, val: any) => { @@ -80,6 +91,7 @@ const KeyValueEditor = ({ label, value, onChange }: { label: string, value: any, ))} + {children ?
{children}
: null} ); }; @@ -769,6 +781,18 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini return val; }; + const seoFacilityName = String(formData.name || "").trim(); + const facilityCity = String(formData.city || "").trim(); + const facilitySuggestedTitle = seoFacilityName + ? `${seoFacilityName}: banestatus, greenfee og info` + : ""; + const facilitySuggestedDescription = seoFacilityName + ? trimSuggestion( + `Se banestatus, greenfee, kontaktinfo, kart og praktisk informasjon for ${seoFacilityName}${facilityCity ? ` i ${facilityCity}` : ""} på TeeOff.no.`, + 160, + ) + : ""; + return (
@@ -888,6 +912,23 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini {" "}<strong>, <em>, <a href=\"...\">, <br>, <p>, <ul>, <ol> og <li>.

+ +
+

SEO

+
+ handleChange('meta_title', value)} + descriptionValue={getValue('meta_description', 'textarea')} + onDescriptionChange={(value) => handleChange('meta_description', value)} + suggestedTitle={facilitySuggestedTitle} + suggestedDescription={facilitySuggestedDescription} + titlePlaceholder="Tomt felt bruker automatisk SEO-tittel." + descriptionPlaceholder="Tomt felt bruker automatisk SEO-beskrivelse i Google." + helperText="Forslaget oppdateres fra anleggsnavn og by mens du skriver. Du kan fortsatt overstyre manuelt." + /> +
+
@@ -1242,7 +1283,18 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
- handleChange('amenities', v)} /> + handleChange('amenities', v)}> +
+ +