Etter SEO-optimalisering

This commit is contained in:
Erol Haagenrud 2026-04-28 07:42:49 +02:00
parent f5d620db03
commit 228aa3590c
17 changed files with 899 additions and 89 deletions

View file

@ -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."""

View file

@ -86,7 +86,7 @@ const AREA_GROUPS: Record<string, string[]> = {
"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" },

View file

@ -14,6 +14,9 @@ const EMPTY_FACILITY = {
vtg_datoer: [],
cooperating_clubs: [],
amenities: {},
camper_parking: "",
meta_title: "",
meta_description: "",
nsg_data: {},
golfamore_data: {},
};

View file

@ -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,
))}
</div>
<button onClick={addRow} className="btn btn-md btn-secondary mt-2 self-start">+ Legg til ny rad</button>
{children ? <div className="mt-2">{children}</div> : null}
</div>
);
};
@ -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 (
<div className="max-w-[1400px] mx-auto p-4 md:p-8 relative z-40 bg-white min-h-screen">
<ScrollToTopButton />
@ -888,6 +912,23 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
{" "}<code>&lt;strong&gt;</code>, <code>&lt;em&gt;</code>, <code>&lt;a href=\"...\"&gt;</code>, <code>&lt;br&gt;</code>, <code>&lt;p&gt;</code>, <code>&lt;ul&gt;</code>, <code>&lt;ol&gt;</code> og <code>&lt;li&gt;</code>.
</p>
</div>
<div className="col-span-1 md:col-span-2 rounded-[2rem] border border-gray-200 bg-gray-50 p-6 shadow-sm mb-8">
<p className="text-xs font-black uppercase tracking-widest text-gray-500">SEO</p>
<div className="mt-4">
<SeoFieldset
titleValue={getValue('meta_title', 'text')}
onTitleChange={(value) => 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."
/>
</div>
</div>
<div className="flex flex-col gap-2 mb-8">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Banetype (f.eks Park/Skog)</label>
@ -1242,7 +1283,18 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
</AccordionSection>
<div className="mt-8 border-t-2 border-gray-200 pt-8">
<KeyValueEditor label="Fasiliteter (Proshop, Kafé etc.)" value={formData.amenities} onChange={(v) => handleChange('amenities', v)} />
<KeyValueEditor label="Fasiliteter (Proshop, Kafé etc.)" value={formData.amenities} onChange={(v) => handleChange('amenities', v)}>
<div className="flex flex-col gap-2">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Bobilparkering</label>
<textarea
className="w-full p-4 rounded-xl border-2 border-gray-300 text-base font-medium text-black bg-white focus:border-[#8bc34a] outline-none shadow-sm"
rows={4}
value={getValue('camper_parking', 'textarea')}
onChange={e => handleChange('camper_parking', e.target.value)}
placeholder="Beskriv pris, strøm, vilkår eller avtale i nærheten. Du kan bruke lenker samt <strong>, <em> og linjeskift. Tomt felt vises offentlig som 'Ikke beskrevet'."
/>
</div>
</KeyValueEditor>
<div className="flex flex-col gap-4 mb-8 bg-gray-100 p-6 md:p-8 rounded-[2rem] border border-gray-200 shadow-sm">
<label className="text-sm font-black uppercase tracking-widest text-[#11280f]">Norsk Seniorgolf (NSG)</label>
<div className="flex flex-col gap-2">

View file

@ -4,6 +4,7 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import AdminMobileMenu from "@/components/AdminMobileMenu";
import SeoFieldset, { trimSuggestion } from "@/components/admin/SeoFieldset";
import { API_URL } from "@/config/constants";
import { adminFetch } from "@/config/adminFetch";
@ -14,6 +15,8 @@ type SimulatorOperator = {
website_url: string | null;
logo_url: string | null;
description: string | null;
meta_title: string | null;
meta_description: string | null;
is_published: boolean;
created_at: string | null;
updated_at: string | null;
@ -46,6 +49,8 @@ type SimulatorVenue = {
phone: string | null;
email: string | null;
image_url: string | null;
meta_title: string | null;
meta_description: string | null;
simulator_systems: string[];
bay_count: number | null;
lessons_available: boolean | null;
@ -72,6 +77,8 @@ type OperatorFormState = {
website_url: string;
logo_url: string;
description: string;
meta_title: string;
meta_description: string;
is_published: boolean;
};
@ -94,6 +101,8 @@ type VenueFormState = {
phone: string;
email: string;
image_url: string;
meta_title: string;
meta_description: string;
simulator_systems: string;
bay_count: string;
lessons_available: boolean;
@ -124,6 +133,8 @@ const DEFAULT_OPERATOR_FORM: OperatorFormState = {
website_url: "",
logo_url: "",
description: "",
meta_title: "",
meta_description: "",
is_published: false,
};
@ -146,6 +157,8 @@ const DEFAULT_VENUE_FORM: VenueFormState = {
phone: "",
email: "",
image_url: "",
meta_title: "",
meta_description: "",
simulator_systems: "",
bay_count: "",
lessons_available: false,
@ -168,6 +181,8 @@ function operatorToFormState(operator: SimulatorOperator): OperatorFormState {
website_url: operator.website_url || "",
logo_url: operator.logo_url || "",
description: operator.description || "",
meta_title: operator.meta_title || "",
meta_description: operator.meta_description || "",
is_published: Boolean(operator.is_published),
};
}
@ -192,6 +207,8 @@ function venueToFormState(venue: SimulatorVenue): VenueFormState {
phone: venue.phone || "",
email: venue.email || "",
image_url: venue.image_url || "",
meta_title: venue.meta_title || "",
meta_description: venue.meta_description || "",
simulator_systems: Array.isArray(venue.simulator_systems) ? venue.simulator_systems.join(", ") : "",
bay_count: venue.bay_count == null ? "" : String(venue.bay_count),
lessons_available: Boolean(venue.lessons_available),
@ -226,6 +243,47 @@ function splitSystems(value: string): string[] {
.filter(Boolean);
}
function buildOperatorSeoTitle(name: string) {
const normalizedName = name.trim();
return normalizedName ? `${normalizedName}: simulatorer og info` : "";
}
function buildOperatorSeoDescription(name: string) {
const normalizedName = name.trim();
if (!normalizedName) return "";
return trimSuggestion(
`Se simulatorsteder, systemer og praktisk informasjon for ${normalizedName} på TeeOff.no.`,
160,
);
}
function buildVenueSeoTitle(name: string, venueType: string) {
const normalizedName = name.trim();
if (!normalizedName) return "";
const typeLabel =
venueType === "golfanlegg"
? "simulator og info"
: venueType === "pub"
? "simulatorpub og info"
: venueType === "butikk"
? "simulator og butikkinfo"
: "simulator, booking og info";
return `${normalizedName}: ${typeLabel}`;
}
function buildVenueSeoDescription(name: string, city: string) {
const normalizedName = name.trim();
const normalizedCity = city.trim();
if (!normalizedName) return "";
return trimSuggestion(
`Se systemer, booking, fasiliteter og praktisk informasjon for ${normalizedName}${normalizedCity ? ` i ${normalizedCity}` : ""} på TeeOff.no.`,
160,
);
}
export default function SimulatorAdminClient() {
const [operators, setOperators] = useState<SimulatorOperator[]>([]);
const [venues, setVenues] = useState<SimulatorVenue[]>([]);
@ -237,6 +295,13 @@ export default function SimulatorAdminClient() {
const [error, setError] = useState<string | null>(null);
const [operatorForm, setOperatorForm] = useState<OperatorFormState>(DEFAULT_OPERATOR_FORM);
const [venueForm, setVenueForm] = useState<VenueFormState>(DEFAULT_VENUE_FORM);
const operatorSuggestedTitle = buildOperatorSeoTitle(operatorForm.name);
const operatorSuggestedDescription = buildOperatorSeoDescription(operatorForm.name);
const venueSuggestedTitle = buildVenueSeoTitle(
venueForm.name,
venueForm.venue_type,
);
const venueSuggestedDescription = buildVenueSeoDescription(venueForm.name, venueForm.city);
const refreshData = async () => {
setLoading(true);
@ -298,6 +363,8 @@ export default function SimulatorAdminClient() {
website_url: operatorForm.website_url,
logo_url: operatorForm.logo_url,
description: operatorForm.description,
meta_title: operatorForm.meta_title,
meta_description: operatorForm.meta_description,
is_published: operatorForm.is_published,
};
@ -353,6 +420,8 @@ export default function SimulatorAdminClient() {
phone: venueForm.phone,
email: venueForm.email,
image_url: venueForm.image_url,
meta_title: venueForm.meta_title,
meta_description: venueForm.meta_description,
simulator_systems: splitSystems(venueForm.simulator_systems),
bay_count: parseOptionalInteger(venueForm.bay_count),
lessons_available: venueForm.lessons_available,
@ -561,6 +630,18 @@ export default function SimulatorAdminClient() {
/>
</div>
<SeoFieldset
titleValue={operatorForm.meta_title}
onTitleChange={(value) => handleOperatorFieldChange("meta_title", value)}
descriptionValue={operatorForm.meta_description}
onDescriptionChange={(value) => handleOperatorFieldChange("meta_description", value)}
suggestedTitle={operatorSuggestedTitle}
suggestedDescription={operatorSuggestedDescription}
titlePlaceholder="Klar for fremtidig offentlig simulatorside."
descriptionPlaceholder="Klar for fremtidig offentlig simulatorside."
helperText="Forslaget oppdateres fra operatørnavnet. Feltene brukes når simulatorflatene blir koblet offentlig."
/>
<label className="flex items-center gap-3 rounded-2xl border border-gray-200 px-4 py-3">
<input
type="checkbox"
@ -725,6 +806,18 @@ export default function SimulatorAdminClient() {
/>
</div>
<SeoFieldset
titleValue={venueForm.meta_title}
onTitleChange={(value) => handleVenueFieldChange("meta_title", value)}
descriptionValue={venueForm.meta_description}
onDescriptionChange={(value) => handleVenueFieldChange("meta_description", value)}
suggestedTitle={venueSuggestedTitle}
suggestedDescription={venueSuggestedDescription}
titlePlaceholder="Klar for fremtidig offentlig simulatorside."
descriptionPlaceholder="Klar for fremtidig offentlig simulatorside."
helperText="Forslaget oppdateres fra navn, stedtype og by. Feltene er klare for offentlig simulatorside senere."
/>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">By</label>

View file

@ -4,17 +4,64 @@ import { useEffect, useState } from "react";
import Link from "next/link";
import AdminMobileMenu from "@/components/AdminMobileMenu";
import TiptapHtmlEditor from "@/components/TiptapHtmlEditor";
import { HIERARCHICAL_AREA_OPTIONS, getPlaceConfigFromSlug } from "@/app/facilityData";
import {
HIERARCHICAL_AREA_OPTIONS,
getPlaceConfigFromSlug,
getPlacePreposition,
} from "@/app/facilityData";
import { adminFetch } from "@/config/adminFetch";
import { API_URL } from "@/config/constants";
import SeoFieldset, { trimSuggestion } from "@/components/admin/SeoFieldset";
type PlacePageResponse = {
slug: string;
factbox_intro_html?: string | null;
meta_title?: string | null;
meta_description?: string | null;
updated_at?: string | null;
};
type SitePageSeoResponse = {
page_key: string;
meta_title?: string | null;
meta_description?: string | null;
updated_at?: string | null;
};
const DEFAULT_PLACE_SLUG = "norge";
const DEFAULT_PAGE_KEY = "golfbaner";
const SITE_PAGE_OPTIONS = [
{ key: "golfbaner", label: "/golfbaner" },
{ key: "vtg", label: "/vtg" },
{ key: "medlemskap", label: "/medlemskap" },
{ key: "simulatorer", label: "/simulatorer (fremtidig)" },
];
const SITE_PAGE_SEO_SUGGESTIONS: Record<
string,
{ title: string; description: string }
> = {
golfbaner: {
title: "Golfbaner i Norge",
description:
"Finn golfbaner i Norge og filtrer på område, banestatus, antall hull og fasiliteter i TeeOffs samlede oversikt.",
},
vtg: {
title: "Veien til Golf",
description:
"Finn Veien til Golf-kurs etter område, klubb og neste kursdato i TeeOffs VTG-oversikt.",
},
medlemskap: {
title: "Medlemskap i norske golfklubber",
description:
"Sammenlign priser på medlemskap i norske golfklubber, både full spillerett og rimeligste nasjonale alternativ.",
},
simulatorer: {
title: "Golfsimulatorer i Norge",
description:
"Finn golfsimulatorer, indoor golf og simulatorsteder i Norge når TeeOffs simulatoroversikt lanseres.",
},
};
const formatDateTime = (value: string | null | undefined) => {
if (!value) return "Ikke lagret ennå";
@ -32,12 +79,33 @@ const formatDateTime = (value: string | null | undefined) => {
export default function AdminPlacePagesPage() {
const [selectedSlug, setSelectedSlug] = useState(DEFAULT_PLACE_SLUG);
const [html, setHtml] = useState("");
const [metaTitle, setMetaTitle] = useState("");
const [metaDescription, setMetaDescription] = useState("");
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [feedback, setFeedback] = useState("");
const [selectedPageKey, setSelectedPageKey] = useState(DEFAULT_PAGE_KEY);
const [pageMetaTitle, setPageMetaTitle] = useState("");
const [pageMetaDescription, setPageMetaDescription] = useState("");
const [pageSeoUpdatedAt, setPageSeoUpdatedAt] = useState<string | null>(null);
const [isLoadingPageSeo, setIsLoadingPageSeo] = useState(true);
const [isSavingPageSeo, setIsSavingPageSeo] = useState(false);
const [pageSeoFeedback, setPageSeoFeedback] = useState("");
const selectedPlace = getPlaceConfigFromSlug(selectedSlug);
const selectedPlacePreposition = selectedPlace ? getPlacePreposition(selectedPlace.label) : "i";
const placeSuggestedTitle = selectedPlace ? `${selectedPlace.title}: golfbaner og banestatus` : "";
const placeSuggestedDescription = selectedPlace
? trimSuggestion(
`Finn golfbaner ${selectedPlacePreposition} ${selectedPlace.label} med oppdatert banestatus, priser og baneprofiler på TeeOff.no.`,
160,
)
: "";
const sitePageSuggestion = SITE_PAGE_SEO_SUGGESTIONS[selectedPageKey] || {
title: "",
description: "",
};
useEffect(() => {
const controller = new AbortController();
@ -58,10 +126,14 @@ export default function AdminPlacePagesPage() {
const data = (await response.json()) as PlacePageResponse;
setHtml(data.factbox_intro_html || "");
setMetaTitle(data.meta_title || "");
setMetaDescription(data.meta_description || "");
setUpdatedAt(data.updated_at || null);
} catch (error) {
if (controller.signal.aborted) return;
setHtml("");
setMetaTitle("");
setMetaDescription("");
setUpdatedAt(null);
setFeedback(error instanceof Error ? error.message : "Kunne ikke hente sted-siden.");
} finally {
@ -76,6 +148,45 @@ export default function AdminPlacePagesPage() {
return () => controller.abort();
}, [selectedSlug]);
useEffect(() => {
const controller = new AbortController();
const loadPageSeo = async () => {
setIsLoadingPageSeo(true);
setPageSeoFeedback("");
try {
const response = await adminFetch(`${API_URL}/admin/page-seo/${selectedPageKey}`, {
credentials: "include",
signal: controller.signal,
});
if (!response.ok) {
throw new Error("Kunne ikke hente side-SEO.");
}
const data = (await response.json()) as SitePageSeoResponse;
setPageMetaTitle(data.meta_title || "");
setPageMetaDescription(data.meta_description || "");
setPageSeoUpdatedAt(data.updated_at || null);
} catch (error) {
if (controller.signal.aborted) return;
setPageMetaTitle("");
setPageMetaDescription("");
setPageSeoUpdatedAt(null);
setPageSeoFeedback(error instanceof Error ? error.message : "Kunne ikke hente side-SEO.");
} finally {
if (!controller.signal.aborted) {
setIsLoadingPageSeo(false);
}
}
};
loadPageSeo();
return () => controller.abort();
}, [selectedPageKey]);
const handleSave = async () => {
setIsSaving(true);
setFeedback("");
@ -85,7 +196,11 @@ export default function AdminPlacePagesPage() {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ factbox_intro_html: html }),
body: JSON.stringify({
factbox_intro_html: html,
meta_title: metaTitle,
meta_description: metaDescription,
}),
});
if (!response.ok) {
@ -102,6 +217,35 @@ export default function AdminPlacePagesPage() {
}
};
const handleSavePageSeo = async () => {
setIsSavingPageSeo(true);
setPageSeoFeedback("");
try {
const response = await adminFetch(`${API_URL}/admin/page-seo/${selectedPageKey}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
meta_title: pageMetaTitle,
meta_description: pageMetaDescription,
}),
});
if (!response.ok) {
throw new Error("Kunne ikke lagre side-SEO.");
}
const data = (await response.json()) as SitePageSeoResponse;
setPageSeoUpdatedAt(data.updated_at || null);
setPageSeoFeedback("Lagret.");
} catch (error) {
setPageSeoFeedback(error instanceof Error ? error.message : "Kunne ikke lagre side-SEO.");
} finally {
setIsSavingPageSeo(false);
}
};
return (
<main className="mx-auto min-h-screen max-w-[1100px] bg-white p-4 md:p-8">
<AdminMobileMenu />
@ -171,10 +315,93 @@ export default function AdminPlacePagesPage() {
Laster sted-side...
</div>
) : (
<TiptapHtmlEditor
value={html}
onChange={setHtml}
placeholder="Skriv HTML-innholdet som skal vises over faktaboksen på denne sted-siden."
<div className="space-y-6">
<SeoFieldset
titleValue={metaTitle}
onTitleChange={setMetaTitle}
descriptionValue={metaDescription}
onDescriptionChange={setMetaDescription}
suggestedTitle={placeSuggestedTitle}
suggestedDescription={placeSuggestedDescription}
titlePlaceholder="Tomt felt bruker automatisk tittel for stedet."
descriptionPlaceholder="Tomt felt bruker automatisk beskrivelse for stedet."
helperText="Forslaget oppdateres ut fra valgt stedside. Du kan fortsatt skrive helt fritt."
/>
<TiptapHtmlEditor
value={html}
onChange={setHtml}
placeholder="Skriv HTML-innholdet som skal vises over faktaboksen på denne sted-siden."
/>
</div>
)}
</div>
</div>
</section>
<section className="mt-8 rounded-[2rem] border border-gray-200 bg-gray-50 p-6 shadow-sm md:p-8">
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-black uppercase tracking-[0.2em] text-[#7ca982]">Samlesider</p>
<h2 className="mt-2 text-3xl font-black tracking-tight text-[#11280f]">SEO for /golfbaner, /vtg, /medlemskap og /simulatorer</h2>
<p className="mt-3 max-w-3xl text-sm leading-6 text-[#536256]">
Her kan du overstyre meta title og meta description de store landingssidene uten å endre H1 eller innholdstekst.
</p>
</div>
<button
type="button"
onClick={handleSavePageSeo}
disabled={isLoadingPageSeo || isSavingPageSeo}
className="btn btn-md btn-primary disabled:opacity-50"
>
{isSavingPageSeo ? "Lagrer..." : "Lagre side-SEO"}
</button>
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
<div className="space-y-4">
<label className="flex flex-col gap-2">
<span className="text-xs font-black uppercase tracking-widest text-gray-600">Side</span>
<select
value={selectedPageKey}
onChange={(event) => setSelectedPageKey(event.target.value)}
className="rounded-2xl border-2 border-gray-300 bg-white px-4 py-4 text-base font-bold text-black outline-none focus:border-[#8bc34a]"
>
{SITE_PAGE_OPTIONS.map((option) => (
<option key={option.key} value={option.key}>
{option.label}
</option>
))}
</select>
</label>
<div className="rounded-[1.5rem] border border-[#112015]/8 bg-white p-5">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Aktiv side</p>
<p className="mt-2 text-xl font-black text-[#112015]">{SITE_PAGE_OPTIONS.find((option) => option.key === selectedPageKey)?.label || selectedPageKey}</p>
<p className="mt-4 text-xs font-bold uppercase tracking-widest text-gray-500">
Sist lagret: {formatDateTime(pageSeoUpdatedAt)}
</p>
{pageSeoFeedback ? (
<p className="mt-3 text-sm font-bold text-[#11280f]">{pageSeoFeedback}</p>
) : null}
</div>
</div>
<div>
{isLoadingPageSeo ? (
<div className="rounded-[1.75rem] border border-[#112015]/8 bg-white px-5 py-12 text-sm font-bold text-[#536256]">
Laster side-SEO...
</div>
) : (
<SeoFieldset
titleValue={pageMetaTitle}
onTitleChange={setPageMetaTitle}
descriptionValue={pageMetaDescription}
onDescriptionChange={setPageMetaDescription}
suggestedTitle={sitePageSuggestion.title}
suggestedDescription={sitePageSuggestion.description}
titlePlaceholder="Tomt felt bruker automatisk tittel på samlesiden."
descriptionPlaceholder="Tomt felt bruker automatisk beskrivelse på samlesiden."
helperText="Forslaget følger standardteksten for valgt samleside. Du kan bruke det direkte eller redigere videre."
/>
)}
</div>

View file

@ -38,6 +38,8 @@ export type FacilityRecord = {
vtg_pris?: number | null;
vtg_lenke?: string | null;
vtg_beskrivelse?: string | null;
meta_title?: string | null;
meta_description?: string | null;
amenities?: unknown;
golfamore_data?: unknown;
nsg_data?: unknown;
@ -100,7 +102,7 @@ export const AREA_GROUPS: Record<string, string[]> = {
"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"],
};
@ -126,7 +128,6 @@ export const HIERARCHICAL_AREA_OPTIONS = [
{ value: "region:sorlandet", label: "Sørlandet", slug: "sorlandet" },
{ value: "county:vest-agder", label: "Vest-Agder", slug: "vest-agder" },
{ value: "county:aust-agder", label: "Aust-Agder", slug: "aust-agder" },
{ value: "county:agder", label: "Agder", slug: "agder" },
{ value: "region:ostlandet", label: "Østlandet", slug: "ostlandet" },
{ value: "county:telemark", label: "Telemark", slug: "telemark" },
{ value: "county:vestfold", label: "Vestfold", slug: "vestfold" },

View file

@ -233,6 +233,7 @@ export default function FacilityDetailView({
const rawCourses = parseJson(facility.courses, []);
const activeCourses = Array.isArray(rawCourses) ? rawCourses.filter((c: any) => c.holes && (typeof c.holes === 'string' || c.holes.length > 0)) : [];
const amenities = parseJson(facility.amenities, {});
const camperParking = String(facility.camper_parking || "").trim();
const galleryRaw = parseJson(facility.gallery, []);
const gallery = galleryRaw.length > 0 ? galleryRaw : [facility.image_url || FALLBACK_IMAGE];
const shotzoom = parseJson(facility.shotzoom, []);
@ -568,6 +569,7 @@ export default function FacilityDetailView({
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Bilutleie:</span><span className="text-right ml-4">{renderValue(amenities.bilutleie, 'Nei')}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Simulator:</span><span className="text-right ml-4">{renderValue(amenities.simulator)}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Kafé:</span><span className="text-right ml-4">{renderValue(amenities.kafe)}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Bobilparkering:</span><span className="text-right ml-4">{renderValue(camperParking, 'Ikke beskrevet')}</span></div>
{/* Golfamore og NSG */}
<div className="flex justify-between border-b border-gray-50 pb-2">

View file

@ -10,7 +10,8 @@ import {
createFacilityJsonLd,
createPageMetadata,
createVtgCourseJsonLd,
trimDescription,
resolveSeoDescription,
resolveSeoTitle,
} from "@/app/seo";
import { getFacilityEditorialArticles } from "@/content/editorialArticles";
import FacilityDetailView from "./FacilityDetailView";
@ -67,12 +68,12 @@ export async function generateMetadata({ params }: GolfCoursePageProps): Promise
});
}
const title = `${facility.name}${facility.city ? ` i ${facility.city}` : ""}`;
const fallbackDescription = `${facility.name} på TeeOff med banestatus, priser, kontaktinfo og lenker til nyttige ressurser.`;
const fallbackTitle = `${facility.name}: banestatus, priser og info`;
const fallbackDescription = `Se banestatus, priser, kontaktinfo, kart og praktisk informasjon for ${facility.name} på TeeOff.no.`;
return createPageMetadata({
title,
description: trimDescription(facility.description) || fallbackDescription,
title: resolveSeoTitle(facility.meta_title, fallbackTitle),
description: resolveSeoDescription(facility.meta_description, fallbackDescription),
path: `/golfbaner/${facility.slug}`,
image: `/golfbaner/${facility.slug}/opengraph-image`,
});

View file

@ -1,6 +1,7 @@
import FacilitySearch from "@/app/FacilitySearch";
import type { FacilityRecord } from "@/app/facilityData";
import { fetchPublicFacilities } from "@/app/publicFacilities";
import { resolveSitePageSeo } from "@/app/pageSeo";
import {
createBreadcrumbJsonLd,
createCollectionPageJsonLd,
@ -11,25 +12,29 @@ import {
export const revalidate = 900;
export const dynamic = "force-dynamic";
const pageTitle = "Golfbaner i Norge";
const pageDescription =
const fallbackPageTitle = "Golfbaner i Norge";
const fallbackPageDescription =
"Finn golfbaner i Norge og filtrer på område, banestatus, antall hull og fasiliteter i TeeOffs samlede oversikt.";
export const metadata = createPageMetadata({
title: pageTitle,
description: pageDescription,
path: "/golfbaner",
});
export async function generateMetadata() {
const seo = await resolveSitePageSeo("golfbaner", fallbackPageTitle, fallbackPageDescription);
return createPageMetadata({
title: seo.title,
description: seo.description,
path: "/golfbaner",
});
}
export default async function GolfCoursesIndexPage() {
const seo = await resolveSitePageSeo("golfbaner", fallbackPageTitle, fallbackPageDescription);
const safeData = await fetchPublicFacilities<FacilityRecord>("search", revalidate);
const collectionJsonLd = createCollectionPageJsonLd({
name: pageTitle,
description: pageDescription,
name: seo.title,
description: seo.description,
path: "/golfbaner",
});
const itemListJsonLd = createItemListJsonLd({
name: pageTitle,
name: seo.title,
path: "/golfbaner",
items: safeData
.filter((facility) => facility?.slug && facility?.name)

View file

@ -1,5 +1,6 @@
import MembershipExplorer, { type MembershipFacility } from "./MembershipExplorer";
import { fetchPublicFacilities } from "@/app/publicFacilities";
import { resolveSitePageSeo } from "@/app/pageSeo";
import {
createBreadcrumbJsonLd,
createCollectionPageJsonLd,
@ -9,17 +10,21 @@ import {
export const revalidate = 1800;
export const dynamic = "force-dynamic";
const pageTitle = "Medlemskap i norske golfklubber";
const pageDescription =
const fallbackPageTitle = "Medlemskap i norske golfklubber";
const fallbackPageDescription =
"Sammenlign priser på medlemskap i norske golfklubber, både full spillerett og rimeligste nasjonale alternativ.";
export const metadata = createPageMetadata({
title: pageTitle,
description: pageDescription,
path: "/medlemskap",
});
export async function generateMetadata() {
const seo = await resolveSitePageSeo("medlemskap", fallbackPageTitle, fallbackPageDescription);
return createPageMetadata({
title: seo.title,
description: seo.description,
path: "/medlemskap",
});
}
export default async function MembershipPage() {
const seo = await resolveSitePageSeo("medlemskap", fallbackPageTitle, fallbackPageDescription);
const facilities = await fetchPublicFacilities<MembershipFacility>("membership", revalidate);
const visibleFacilities = facilities.filter(
@ -28,8 +33,8 @@ export default async function MembershipPage() {
typeof facility.rimeligste_alternativ === "number",
);
const collectionJsonLd = createCollectionPageJsonLd({
name: pageTitle,
description: pageDescription,
name: seo.title,
description: seo.description,
path: "/medlemskap",
});
const breadcrumbJsonLd = createBreadcrumbJsonLd([

View file

@ -0,0 +1,36 @@
import { cache } from "react";
import { API_URL } from "@/config/constants";
import { resolveSeoDescription, resolveSeoTitle } from "@/app/seo";
export type SitePageSeoRecord = {
page_key?: string;
meta_title?: string | null;
meta_description?: string | null;
};
export type ResolvedPageSeo = {
title: string;
description: string;
};
export const fetchSitePageSeo = cache(async (pageKey: string): Promise<SitePageSeoRecord | null> => {
try {
const response = await fetch(`${API_URL}/page-seo/${pageKey}`, { cache: "no-store" });
if (!response.ok) return null;
return (await response.json()) as SitePageSeoRecord;
} catch {
return null;
}
});
export async function resolveSitePageSeo(
pageKey: string,
fallbackTitle: string,
fallbackDescription: string,
): Promise<ResolvedPageSeo> {
const seo = await fetchSitePageSeo(pageKey);
return {
title: resolveSeoTitle(seo?.meta_title, fallbackTitle),
description: resolveSeoDescription(seo?.meta_description, fallbackDescription),
};
}

View file

@ -80,6 +80,20 @@ export function trimDescription(value: string | null | undefined, maxLength = 16
return `${plain.slice(0, maxLength - 3).trimEnd()}...`;
}
export function resolveSeoTitle(customTitle: string | null | undefined, fallbackTitle: string) {
const normalized = stripHtml(customTitle);
return normalized || fallbackTitle;
}
export function resolveSeoDescription(
customDescription: string | null | undefined,
fallbackDescription: string,
maxLength = 160,
) {
const normalized = trimDescription(customDescription, maxLength);
return normalized || trimDescription(fallbackDescription, maxLength);
}
export function createPageMetadata({
title,
description,
@ -224,9 +238,11 @@ export function createFacilityJsonLd(facility: FacilitySeoRecord) {
"@type": ["GolfCourse", "SportsActivityLocation"],
"@id": buildAbsoluteUrl(`/golfbaner/${facility.slug}#golfcourse`),
name: facility.name,
description:
description: resolveSeoDescription(
facility.meta_description,
trimDescription(facility.description) ||
`${facility.name} er en golfbane på TeeOff med oppdatert banestatus og praktisk klubbinfo.`,
`${facility.name} er en golfbane på TeeOff med oppdatert banestatus og praktisk klubbinfo.`,
),
url: buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
mainEntityOfPage: buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
image: resolveImageUrl(facility.image_url),

View file

@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { cache } from "react";
import PlaceExplorer from "@/app/sted/[slug]/PlaceExplorer";
import {
buildPlaceAverageComparison,
@ -19,12 +20,16 @@ import {
createCollectionPageJsonLd,
createItemListJsonLd,
createPageMetadata,
resolveSeoDescription,
resolveSeoTitle,
} from "@/app/seo";
import { fetchPublicFacilities } from "@/app/publicFacilities";
type PlacePageData = {
slug?: string;
factbox_intro_html?: string | null;
meta_title?: string | null;
meta_description?: string | null;
updated_at?: string | null;
};
@ -94,6 +99,22 @@ const sanitizePlaceRichText = (value: string | null | undefined) => {
export const dynamicParams = true;
export const revalidate = 3600;
const fetchPlacePageData = cache(async (slug: string): Promise<PlacePageData | null> => {
try {
const res = await fetch(`${API_URL}/place-pages/${slug}`, {
cache: "no-store",
});
if (!res.ok) {
return null;
}
return (await res.json()) as PlacePageData;
} catch {
return null;
}
});
export async function generateMetadata({
params,
}: {
@ -110,9 +131,12 @@ export async function generateMetadata({
});
}
const placePage = await fetchPlacePageData(slug);
const fallbackDescription = `${place.intro} TeeOff samler golfbaner i ${place.label} med oppdatert banestatus og baneprofiler.`;
return createPageMetadata({
title: place.title,
description: `${place.intro} TeeOff samler golfbaner i ${place.label} med oppdatert banestatus og baneprofiler.`,
title: resolveSeoTitle(placePage?.meta_title, place.title),
description: resolveSeoDescription(placePage?.meta_description, fallbackDescription),
path: `/sted/${slug}`,
});
}
@ -130,15 +154,10 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
const facilities = await fetchPublicFacilities<FacilityRecord>("place", revalidate);
try {
const res = await fetch(`${API_URL}/place-pages/${slug}`, {
cache: "no-store",
});
if (!res.ok) {
throw new Error(`API returnerte status ${res.status}`);
placePage = await fetchPlacePageData(slug);
if (!placePage) {
throw new Error("API returnerte ingen sted-side.");
}
placePage = await res.json();
} catch (error) {
console.error("Kritisk feil ved henting av sted-sideinnhold:", error);
placePage = null;
@ -173,12 +192,15 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
? `Det korteste golfhullet ${placePreposition} ${place.label} er ${placeStats.shortestHoleMeters} meter, mens det lengste er ${placeStats.longestHoleMeters} meter.`
: null;
const collectionJsonLd = createCollectionPageJsonLd({
name: place.title,
description: place.intro,
name: resolveSeoTitle(placePage?.meta_title, place.title),
description: resolveSeoDescription(
placePage?.meta_description,
`${place.intro} TeeOff samler golfbaner i ${place.label} med oppdatert banestatus og baneprofiler.`,
),
path: `/sted/${slug}`,
});
const itemListJsonLd = createItemListJsonLd({
name: place.title,
name: resolveSeoTitle(placePage?.meta_title, place.title),
path: `/sted/${slug}`,
items: facilitiesInPlace
.filter((facility) => facility?.slug && facility?.name)

View file

@ -1,6 +1,7 @@
import VtgExplorer from "./VtgExplorer";
import { fetchPublicFacilities } from "@/app/publicFacilities";
import type { FacilityRecord } from "@/app/facilityData";
import { resolveSitePageSeo } from "@/app/pageSeo";
import {
createBreadcrumbJsonLd,
createCollectionPageJsonLd,
@ -10,22 +11,26 @@ import {
export const revalidate = 1800;
export const dynamic = "force-dynamic";
const pageTitle = "Veien til Golf";
const pageDescription =
const fallbackPageTitle = "Veien til Golf";
const fallbackPageDescription =
"Finn Veien til Golf-kurs etter område, klubb og neste kursdato i TeeOffs VTG-oversikt.";
export const metadata = createPageMetadata({
title: pageTitle,
description: pageDescription,
path: "/vtg",
});
export async function generateMetadata() {
const seo = await resolveSitePageSeo("vtg", fallbackPageTitle, fallbackPageDescription);
return createPageMetadata({
title: seo.title,
description: seo.description,
path: "/vtg",
});
}
export default async function VtgPage() {
const seo = await resolveSitePageSeo("vtg", fallbackPageTitle, fallbackPageDescription);
const facilities = await fetchPublicFacilities<FacilityRecord>("vtg", revalidate);
const collectionJsonLd = createCollectionPageJsonLd({
name: pageTitle,
description: pageDescription,
name: seo.title,
description: seo.description,
path: "/vtg",
});
const breadcrumbJsonLd = createBreadcrumbJsonLd([

View file

@ -48,7 +48,6 @@ const placeGroups = [
{ href: "/sted/sorlandet", label: "Sørlandet" },
{ href: "/sted/vest-agder", label: "Vest-Agder" },
{ href: "/sted/aust-agder", label: "Aust-Agder" },
{ href: "/sted/agder", label: "Agder" },
],
},
{

View file

@ -0,0 +1,184 @@
"use client";
type SeoFieldsetProps = {
titleValue: string;
onTitleChange: (value: string) => void;
descriptionValue: string;
onDescriptionChange: (value: string) => void;
titlePlaceholder?: string;
descriptionPlaceholder?: string;
suggestedTitle?: string;
suggestedDescription?: string;
helperText?: string;
};
type CountTone = {
badgeClassName: string;
textClassName: string;
message: string;
};
const TITLE_MIN = 50;
const TITLE_MAX = 60;
const DESCRIPTION_MIN = 120;
const DESCRIPTION_MAX = 160;
export function toPlainText(value: string | null | undefined) {
return String(value || "")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/\s+/g, " ")
.trim();
}
export function trimSuggestion(value: string | null | undefined, maxLength: number) {
const plain = toPlainText(value);
if (plain.length <= maxLength) return plain;
return `${plain.slice(0, maxLength - 3).trimEnd()}...`;
}
function getCountTone(length: number, min: number, max: number): CountTone {
if (length === 0) {
return {
badgeClassName: "bg-gray-100 text-gray-500",
textClassName: "text-gray-500",
message: `Anbefalt ${min}-${max} tegn.`,
};
}
if (length < min) {
return {
badgeClassName: "bg-amber-100 text-amber-800",
textClassName: "text-amber-700",
message: `Litt kort. Sikt mot ${min}-${max} tegn.`,
};
}
if (length > max) {
return {
badgeClassName: "bg-red-100 text-red-800",
textClassName: "text-red-700",
message: `Litt lang. Sikt mot ${min}-${max} tegn.`,
};
}
return {
badgeClassName: "bg-green-100 text-green-800",
textClassName: "text-green-700",
message: `Bra lengde for søkeresultater.`,
};
}
function SuggestionBox({
label,
value,
onUse,
}: {
label: string;
value: string;
onUse: () => void;
}) {
if (!value) return null;
return (
<div className="rounded-2xl border border-[#8bc34a]/30 bg-[#f4faef] p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6d8e57]">
Forslag
</p>
<p className="mt-2 text-sm leading-6 text-[#213127]">
<span className="font-black text-[#11280f]">{label}:</span> {value}
</p>
</div>
<button type="button" onClick={onUse} className="btn btn-sm btn-secondary shrink-0">
Bruk forslag
</button>
</div>
</div>
);
}
export default function SeoFieldset({
titleValue,
onTitleChange,
descriptionValue,
onDescriptionChange,
titlePlaceholder,
descriptionPlaceholder,
suggestedTitle,
suggestedDescription,
helperText,
}: SeoFieldsetProps) {
const normalizedTitle = titleValue.trim();
const normalizedDescription = descriptionValue.trim();
const titleSuggestion = suggestedTitle?.trim() || "";
const descriptionSuggestion = suggestedDescription?.trim() || "";
const titleTone = getCountTone(normalizedTitle.length, TITLE_MIN, TITLE_MAX);
const descriptionTone = getCountTone(
normalizedDescription.length,
DESCRIPTION_MIN,
DESCRIPTION_MAX,
);
return (
<div className="rounded-[1.75rem] border border-gray-200 bg-white p-5">
<div className="grid gap-5">
<div className="flex flex-col gap-2">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">
Meta title
</label>
<input
value={titleValue}
onChange={(event) => onTitleChange(event.target.value)}
className="rounded-2xl border-2 border-gray-300 bg-white px-4 py-4 text-base font-bold text-black outline-none focus:border-[#8bc34a]"
placeholder={titlePlaceholder}
/>
<div className="flex flex-wrap items-center justify-between gap-3 text-xs">
<p className={titleTone.textClassName}>{titleTone.message}</p>
<span className={`rounded-full px-3 py-1 font-black ${titleTone.badgeClassName}`}>
{normalizedTitle.length} tegn
</span>
</div>
{titleSuggestion && titleSuggestion !== normalizedTitle ? (
<SuggestionBox
label="Meta title"
value={titleSuggestion}
onUse={() => onTitleChange(titleSuggestion)}
/>
) : null}
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">
Meta description
</label>
<textarea
value={descriptionValue}
onChange={(event) => onDescriptionChange(event.target.value)}
rows={4}
className="rounded-2xl border-2 border-gray-300 bg-white px-4 py-4 text-base text-black outline-none focus:border-[#8bc34a]"
placeholder={descriptionPlaceholder}
/>
<div className="flex flex-wrap items-center justify-between gap-3 text-xs">
<p className={descriptionTone.textClassName}>{descriptionTone.message}</p>
<span
className={`rounded-full px-3 py-1 font-black ${descriptionTone.badgeClassName}`}
>
{normalizedDescription.length} tegn
</span>
</div>
{descriptionSuggestion && descriptionSuggestion !== normalizedDescription ? (
<SuggestionBox
label="Meta description"
value={descriptionSuggestion}
onUse={() => onDescriptionChange(descriptionSuggestion)}
/>
) : null}
</div>
{helperText ? <p className="text-xs leading-6 text-gray-500">{helperText}</p> : null}
</div>
</div>
);
}