Etter SEO-optimalisering
This commit is contained in:
parent
f5d620db03
commit
228aa3590c
17 changed files with 899 additions and 89 deletions
216
backend/main.py
216
backend/main.py
|
|
@ -655,6 +655,13 @@ class FacilityVisibilityRequest(BaseModel):
|
||||||
|
|
||||||
class PlacePageUpsertRequest(BaseModel):
|
class PlacePageUpsertRequest(BaseModel):
|
||||||
factbox_intro_html: Optional[str] = ""
|
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):
|
class SimulatorOperatorUpsertRequest(BaseModel):
|
||||||
|
|
@ -663,6 +670,8 @@ class SimulatorOperatorUpsertRequest(BaseModel):
|
||||||
website_url: Optional[str] = None
|
website_url: Optional[str] = None
|
||||||
logo_url: Optional[str] = None
|
logo_url: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
meta_title: Optional[str] = None
|
||||||
|
meta_description: Optional[str] = None
|
||||||
is_published: bool = False
|
is_published: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -684,6 +693,8 @@ class SimulatorVenueUpsertRequest(BaseModel):
|
||||||
phone: Optional[str] = None
|
phone: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
image_url: Optional[str] = None
|
image_url: Optional[str] = None
|
||||||
|
meta_title: Optional[str] = None
|
||||||
|
meta_description: Optional[str] = None
|
||||||
simulator_systems: Optional[List[str]] = []
|
simulator_systems: Optional[List[str]] = []
|
||||||
bay_count: Optional[int] = Field(default=None, ge=0)
|
bay_count: Optional[int] = Field(default=None, ge=0)
|
||||||
lessons_available: Optional[bool] = None
|
lessons_available: Optional[bool] = None
|
||||||
|
|
@ -817,6 +828,8 @@ FACILITY_ALLOWED_FIELDS = [
|
||||||
'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
|
'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
|
||||||
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url',
|
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url',
|
||||||
'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris', 'vtg_datoer',
|
'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris', 'vtg_datoer',
|
||||||
|
'camper_parking',
|
||||||
|
'meta_title', 'meta_description',
|
||||||
'guest_requirements', 'scrape_method', 'scrape_status_url',
|
'guest_requirements', 'scrape_method', 'scrape_status_url',
|
||||||
'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at',
|
'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at',
|
||||||
'greenfee_url', 'golfpakker_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector',
|
'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',
|
'id', 'slug', 'name', 'architect', 'description', 'city', 'county', 'banetype',
|
||||||
'image_url', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url',
|
'image_url', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url',
|
||||||
'weather_url', 'lat', 'lng', 'golfamore', 'golfamore_url', 'nsg_url', 'has_golfpakker',
|
'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',
|
'status_updated_at', 'amenities', 'golfamore_data', 'nsg_data', 'vtg_datoer',
|
||||||
'course_statuses', 'weather_forecast',
|
'course_statuses', 'weather_forecast',
|
||||||
}
|
}
|
||||||
|
|
@ -1041,6 +1055,24 @@ def format_place_page_row(row):
|
||||||
d[key] = d[key].isoformat()
|
d[key] = d[key].isoformat()
|
||||||
|
|
||||||
d["factbox_intro_html"] = str(d.get("factbox_intro_html") or "")
|
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
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1140,8 +1172,8 @@ async def save_simulator_operator(conn, request: SimulatorOperatorUpsertRequest,
|
||||||
return await conn.fetchrow(
|
return await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
INSERT INTO simulator_operators (
|
INSERT INTO simulator_operators (
|
||||||
name, slug, website_url, logo_url, description, is_published
|
name, slug, website_url, logo_url, description, meta_title, meta_description, is_published
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
name,
|
name,
|
||||||
|
|
@ -1149,6 +1181,8 @@ async def save_simulator_operator(conn, request: SimulatorOperatorUpsertRequest,
|
||||||
normalize_optional_text(request.website_url),
|
normalize_optional_text(request.website_url),
|
||||||
normalize_optional_text(request.logo_url),
|
normalize_optional_text(request.logo_url),
|
||||||
normalize_optional_text(request.description),
|
normalize_optional_text(request.description),
|
||||||
|
normalize_optional_text(request.meta_title),
|
||||||
|
normalize_optional_text(request.meta_description),
|
||||||
bool(request.is_published),
|
bool(request.is_published),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1160,9 +1194,11 @@ async def save_simulator_operator(conn, request: SimulatorOperatorUpsertRequest,
|
||||||
website_url = $3,
|
website_url = $3,
|
||||||
logo_url = $4,
|
logo_url = $4,
|
||||||
description = $5,
|
description = $5,
|
||||||
is_published = $6,
|
meta_title = $6,
|
||||||
|
meta_description = $7,
|
||||||
|
is_published = $8,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $7
|
WHERE id = $9
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
name,
|
name,
|
||||||
|
|
@ -1170,6 +1206,8 @@ async def save_simulator_operator(conn, request: SimulatorOperatorUpsertRequest,
|
||||||
normalize_optional_text(request.website_url),
|
normalize_optional_text(request.website_url),
|
||||||
normalize_optional_text(request.logo_url),
|
normalize_optional_text(request.logo_url),
|
||||||
normalize_optional_text(request.description),
|
normalize_optional_text(request.description),
|
||||||
|
normalize_optional_text(request.meta_title),
|
||||||
|
normalize_optional_text(request.meta_description),
|
||||||
bool(request.is_published),
|
bool(request.is_published),
|
||||||
operator_id,
|
operator_id,
|
||||||
)
|
)
|
||||||
|
|
@ -1243,6 +1281,8 @@ async def save_simulator_venue(conn, request: SimulatorVenueUpsertRequest, venue
|
||||||
normalize_optional_text(request.phone),
|
normalize_optional_text(request.phone),
|
||||||
normalize_optional_text(request.email),
|
normalize_optional_text(request.email),
|
||||||
normalize_optional_text(request.image_url),
|
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)),
|
json.dumps(normalize_simulator_systems(request.simulator_systems)),
|
||||||
request.bay_count,
|
request.bay_count,
|
||||||
request.lessons_available,
|
request.lessons_available,
|
||||||
|
|
@ -1262,17 +1302,17 @@ async def save_simulator_venue(conn, request: SimulatorVenueUpsertRequest, venue
|
||||||
INSERT INTO simulator_venues (
|
INSERT INTO simulator_venues (
|
||||||
operator_id, facility_id, name, slug, venue_type, description,
|
operator_id, facility_id, name, slug, venue_type, description,
|
||||||
city, county, address, postal_code, lat, lng,
|
city, county, address, postal_code, lat, lng,
|
||||||
website_url, booking_url, phone, email, image_url, simulator_systems,
|
website_url, booking_url, phone, email, image_url, meta_title, meta_description,
|
||||||
bay_count, lessons_available, club_fitting, food_and_drink,
|
simulator_systems, bay_count, lessons_available, club_fitting,
|
||||||
serves_alcohol, drop_in, membership_required, opening_hours,
|
food_and_drink, serves_alcohol, drop_in, membership_required,
|
||||||
price_from, season, is_published
|
opening_hours, price_from, season, is_published
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6,
|
$1, $2, $3, $4, $5, $6,
|
||||||
$7, $8, $9, $10, $11, $12,
|
$7, $8, $9, $10, $11, $12,
|
||||||
$13, $14, $15, $16, $17, $18::jsonb,
|
$13, $14, $15, $16, $17, $18, $19, $20::jsonb,
|
||||||
$19, $20, $21, $22,
|
$21, $22, $23, $24,
|
||||||
$23, $24, $25, $26,
|
$25, $26, $27, $28,
|
||||||
$27, $28, $29
|
$29, $30, $31
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
else:
|
else:
|
||||||
|
|
@ -1295,20 +1335,22 @@ async def save_simulator_venue(conn, request: SimulatorVenueUpsertRequest, venue
|
||||||
phone = $15,
|
phone = $15,
|
||||||
email = $16,
|
email = $16,
|
||||||
image_url = $17,
|
image_url = $17,
|
||||||
simulator_systems = $18::jsonb,
|
meta_title = $18,
|
||||||
bay_count = $19,
|
meta_description = $19,
|
||||||
lessons_available = $20,
|
simulator_systems = $20::jsonb,
|
||||||
club_fitting = $21,
|
bay_count = $21,
|
||||||
food_and_drink = $22,
|
lessons_available = $22,
|
||||||
serves_alcohol = $23,
|
club_fitting = $23,
|
||||||
drop_in = $24,
|
food_and_drink = $24,
|
||||||
membership_required = $25,
|
serves_alcohol = $25,
|
||||||
opening_hours = $26,
|
drop_in = $26,
|
||||||
price_from = $27,
|
membership_required = $27,
|
||||||
season = $28,
|
opening_hours = $28,
|
||||||
is_published = $29,
|
price_from = $29,
|
||||||
|
season = $30,
|
||||||
|
is_published = $31,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $30
|
WHERE id = $32
|
||||||
"""
|
"""
|
||||||
values.append(venue_id)
|
values.append(venue_id)
|
||||||
|
|
||||||
|
|
@ -2276,6 +2318,9 @@ async def ensure_facility_columns(conn):
|
||||||
ALTER TABLE facilities
|
ALTER TABLE facilities
|
||||||
ADD COLUMN IF NOT EXISTS is_published BOOLEAN NOT NULL DEFAULT TRUE,
|
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 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 golfamore_url TEXT,
|
||||||
ADD COLUMN IF NOT EXISTS golfpakker_url TEXT,
|
ADD COLUMN IF NOT EXISTS golfpakker_url TEXT,
|
||||||
ADD COLUMN IF NOT EXISTS golfpakker_draft JSONB,
|
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 (
|
CREATE TABLE IF NOT EXISTS place_pages (
|
||||||
slug VARCHAR(255) PRIMARY KEY,
|
slug VARCHAR(255) PRIMARY KEY,
|
||||||
factbox_intro_html TEXT,
|
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(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_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,
|
website_url TEXT,
|
||||||
logo_url TEXT,
|
logo_url TEXT,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
|
meta_title TEXT,
|
||||||
|
meta_description TEXT,
|
||||||
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_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("""
|
await conn.execute("""
|
||||||
CREATE INDEX IF NOT EXISTS simulator_operators_name_idx
|
CREATE INDEX IF NOT EXISTS simulator_operators_name_idx
|
||||||
ON simulator_operators (name)
|
ON simulator_operators (name)
|
||||||
|
|
@ -2587,6 +2652,8 @@ async def ensure_simulator_venue_tables(conn):
|
||||||
phone VARCHAR(64),
|
phone VARCHAR(64),
|
||||||
email VARCHAR(255),
|
email VARCHAR(255),
|
||||||
image_url TEXT,
|
image_url TEXT,
|
||||||
|
meta_title TEXT,
|
||||||
|
meta_description TEXT,
|
||||||
simulator_systems JSONB NOT NULL DEFAULT '[]'::jsonb,
|
simulator_systems JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
bay_count INTEGER,
|
bay_count INTEGER,
|
||||||
lessons_available BOOLEAN,
|
lessons_available BOOLEAN,
|
||||||
|
|
@ -2619,6 +2686,8 @@ async def ensure_simulator_venue_tables(conn):
|
||||||
CREATE INDEX IF NOT EXISTS simulator_venues_public_location_idx
|
CREATE INDEX IF NOT EXISTS simulator_venues_public_location_idx
|
||||||
ON simulator_venues (is_published, county, city, name)
|
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
|
@asynccontextmanager
|
||||||
|
|
@ -2636,6 +2705,7 @@ async def lifespan(app: FastAPI):
|
||||||
await ensure_facility_columns(conn)
|
await ensure_facility_columns(conn)
|
||||||
await ensure_vtg_course_tables(conn)
|
await ensure_vtg_course_tables(conn)
|
||||||
await ensure_place_pages_table(conn)
|
await ensure_place_pages_table(conn)
|
||||||
|
await ensure_site_page_seo_table(conn)
|
||||||
await ensure_articles_table(conn)
|
await ensure_articles_table(conn)
|
||||||
await ensure_public_user_tables(conn)
|
await ensure_public_user_tables(conn)
|
||||||
await ensure_simulator_operator_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_pris,
|
||||||
pf.vtg_lenke,
|
pf.vtg_lenke,
|
||||||
pf.vtg_beskrivelse,
|
pf.vtg_beskrivelse,
|
||||||
|
pf.camper_parking,
|
||||||
|
pf.meta_title,
|
||||||
|
pf.meta_description,
|
||||||
pf.amenities,
|
pf.amenities,
|
||||||
pf.golfamore_data,
|
pf.golfamore_data,
|
||||||
pf.nsg_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_pris,
|
||||||
pf.vtg_lenke,
|
pf.vtg_lenke,
|
||||||
pf.vtg_beskrivelse,
|
pf.vtg_beskrivelse,
|
||||||
|
pf.camper_parking,
|
||||||
|
pf.meta_title,
|
||||||
|
pf.meta_description,
|
||||||
pf.amenities,
|
pf.amenities,
|
||||||
pf.golfamore_data,
|
pf.golfamore_data,
|
||||||
pf.nsg_data,
|
pf.nsg_data,
|
||||||
|
|
@ -3765,6 +3841,8 @@ async def get_place_page(slug: str, response: Response):
|
||||||
return {
|
return {
|
||||||
"slug": normalized_slug,
|
"slug": normalized_slug,
|
||||||
"factbox_intro_html": "",
|
"factbox_intro_html": "",
|
||||||
|
"meta_title": "",
|
||||||
|
"meta_description": "",
|
||||||
"created_at": None,
|
"created_at": None,
|
||||||
"updated_at": None,
|
"updated_at": None,
|
||||||
}
|
}
|
||||||
|
|
@ -3780,6 +3858,33 @@ async def get_place_page(slug: str, response: Response):
|
||||||
return payload
|
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")
|
@app.get("/api/admin/facilities")
|
||||||
async def get_admin_facilities():
|
async def get_admin_facilities():
|
||||||
"""Henter alle golfanlegg for admin, også upubliserte."""
|
"""Henter alle golfanlegg for admin, også upubliserte."""
|
||||||
|
|
@ -3943,6 +4048,8 @@ async def get_admin_place_page(slug: str):
|
||||||
return {
|
return {
|
||||||
"slug": normalized_slug,
|
"slug": normalized_slug,
|
||||||
"factbox_intro_html": "",
|
"factbox_intro_html": "",
|
||||||
|
"meta_title": "",
|
||||||
|
"meta_description": "",
|
||||||
"created_at": None,
|
"created_at": None,
|
||||||
"updated_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:
|
async with app.state.pool.acquire() as conn:
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
INSERT INTO place_pages (slug, factbox_intro_html)
|
INSERT INTO place_pages (slug, factbox_intro_html, meta_title, meta_description)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3, $4)
|
||||||
ON CONFLICT (slug) DO UPDATE
|
ON CONFLICT (slug) DO UPDATE
|
||||||
SET factbox_intro_html = EXCLUDED.factbox_intro_html,
|
SET factbox_intro_html = EXCLUDED.factbox_intro_html,
|
||||||
|
meta_title = EXCLUDED.meta_title,
|
||||||
|
meta_description = EXCLUDED.meta_description,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
normalized_slug,
|
normalized_slug,
|
||||||
request.factbox_intro_html or "",
|
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)
|
invalidate_public_api_caches(include_place_pages=True)
|
||||||
return format_place_page_row(row)
|
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")
|
@app.get("/api/course-visits")
|
||||||
async def get_course_visits():
|
async def get_course_visits():
|
||||||
"""Henter publiserte Banebesøk-artikler."""
|
"""Henter publiserte Banebesøk-artikler."""
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ const AREA_GROUPS: Record<string, string[]> = {
|
||||||
"nord-norge": ["finnmark", "troms", "nordland"],
|
"nord-norge": ["finnmark", "troms", "nordland"],
|
||||||
"midt-norge": ["trondelag", "nord-trondelag", "sor-trondelag"],
|
"midt-norge": ["trondelag", "nord-trondelag", "sor-trondelag"],
|
||||||
vestlandet: ["more-og-romsdal", "sogn-og-fjordane", "hordaland", "rogaland", "vestland"],
|
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"],
|
ostlandet: ["telemark", "vestfold", "ostfold", "buskerud", "hedmark", "oppland", "innlandet", "viken", "akershus", "oslo"],
|
||||||
"oslo-og-akershus": ["akershus", "oslo", "viken"],
|
"oslo-og-akershus": ["akershus", "oslo", "viken"],
|
||||||
};
|
};
|
||||||
|
|
@ -112,7 +112,6 @@ const HIERARCHICAL_AREA_OPTIONS = [
|
||||||
{ value: "region:sorlandet", label: "Sørlandet" },
|
{ value: "region:sorlandet", label: "Sørlandet" },
|
||||||
{ value: "county:vest-agder", label: "\u00A0\u00A0\u00A0Vest-Agder" },
|
{ value: "county:vest-agder", label: "\u00A0\u00A0\u00A0Vest-Agder" },
|
||||||
{ value: "county:aust-agder", label: "\u00A0\u00A0\u00A0Aust-Agder" },
|
{ value: "county:aust-agder", label: "\u00A0\u00A0\u00A0Aust-Agder" },
|
||||||
{ value: "county:agder", label: "\u00A0\u00A0\u00A0Agder" },
|
|
||||||
{ value: "region:ostlandet", label: "Østlandet" },
|
{ value: "region:ostlandet", label: "Østlandet" },
|
||||||
{ value: "county:telemark", label: "\u00A0\u00A0\u00A0Telemark" },
|
{ value: "county:telemark", label: "\u00A0\u00A0\u00A0Telemark" },
|
||||||
{ value: "county:vestfold", label: "\u00A0\u00A0\u00A0Vestfold" },
|
{ value: "county:vestfold", label: "\u00A0\u00A0\u00A0Vestfold" },
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ const EMPTY_FACILITY = {
|
||||||
vtg_datoer: [],
|
vtg_datoer: [],
|
||||||
cooperating_clubs: [],
|
cooperating_clubs: [],
|
||||||
amenities: {},
|
amenities: {},
|
||||||
|
camper_parking: "",
|
||||||
|
meta_title: "",
|
||||||
|
meta_description: "",
|
||||||
nsg_data: {},
|
nsg_data: {},
|
||||||
golfamore_data: {},
|
golfamore_data: {},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { adminFetch } from "@/config/adminFetch";
|
import { adminFetch } from "@/config/adminFetch";
|
||||||
import AdminMobileMenu from "@/components/AdminMobileMenu";
|
import AdminMobileMenu from "@/components/AdminMobileMenu";
|
||||||
|
import SeoFieldset, { trimSuggestion } from "@/components/admin/SeoFieldset";
|
||||||
|
|
||||||
// KOMPONENT 1: MultiSelect for samarbeidende klubber
|
// KOMPONENT 1: MultiSelect for samarbeidende klubber
|
||||||
const MultiSelect = ({ label, options, selected, onChange }: { label: string, options: any[], selected: string[], onChange: (s: string[]) => void }) => {
|
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
|
// 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 entries = Object.entries(value || {});
|
||||||
|
|
||||||
const updateKey = (oldKey: string, newKey: string, val: any) => {
|
const updateKey = (oldKey: string, newKey: string, val: any) => {
|
||||||
|
|
@ -80,6 +91,7 @@ const KeyValueEditor = ({ label, value, onChange }: { label: string, value: any,
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={addRow} className="btn btn-md btn-secondary mt-2 self-start">+ Legg til ny rad</button>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -769,6 +781,18 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
||||||
return val;
|
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 (
|
return (
|
||||||
<div className="max-w-[1400px] mx-auto p-4 md:p-8 relative z-40 bg-white min-h-screen">
|
<div className="max-w-[1400px] mx-auto p-4 md:p-8 relative z-40 bg-white min-h-screen">
|
||||||
<ScrollToTopButton />
|
<ScrollToTopButton />
|
||||||
|
|
@ -888,6 +912,23 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
||||||
{" "}<code><strong></code>, <code><em></code>, <code><a href=\"...\"></code>, <code><br></code>, <code><p></code>, <code><ul></code>, <code><ol></code> og <code><li></code>.
|
{" "}<code><strong></code>, <code><em></code>, <code><a href=\"...\"></code>, <code><br></code>, <code><p></code>, <code><ul></code>, <code><ol></code> og <code><li></code>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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>
|
<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>
|
</AccordionSection>
|
||||||
|
|
||||||
<div className="mt-8 border-t-2 border-gray-200 pt-8">
|
<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">
|
<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>
|
<label className="text-sm font-black uppercase tracking-widest text-[#11280f]">Norsk Seniorgolf (NSG)</label>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import AdminMobileMenu from "@/components/AdminMobileMenu";
|
import AdminMobileMenu from "@/components/AdminMobileMenu";
|
||||||
|
import SeoFieldset, { trimSuggestion } from "@/components/admin/SeoFieldset";
|
||||||
import { API_URL } from "@/config/constants";
|
import { API_URL } from "@/config/constants";
|
||||||
import { adminFetch } from "@/config/adminFetch";
|
import { adminFetch } from "@/config/adminFetch";
|
||||||
|
|
||||||
|
|
@ -14,6 +15,8 @@ type SimulatorOperator = {
|
||||||
website_url: string | null;
|
website_url: string | null;
|
||||||
logo_url: string | null;
|
logo_url: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
meta_title: string | null;
|
||||||
|
meta_description: string | null;
|
||||||
is_published: boolean;
|
is_published: boolean;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
|
|
@ -46,6 +49,8 @@ type SimulatorVenue = {
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
image_url: string | null;
|
image_url: string | null;
|
||||||
|
meta_title: string | null;
|
||||||
|
meta_description: string | null;
|
||||||
simulator_systems: string[];
|
simulator_systems: string[];
|
||||||
bay_count: number | null;
|
bay_count: number | null;
|
||||||
lessons_available: boolean | null;
|
lessons_available: boolean | null;
|
||||||
|
|
@ -72,6 +77,8 @@ type OperatorFormState = {
|
||||||
website_url: string;
|
website_url: string;
|
||||||
logo_url: string;
|
logo_url: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
meta_title: string;
|
||||||
|
meta_description: string;
|
||||||
is_published: boolean;
|
is_published: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -94,6 +101,8 @@ type VenueFormState = {
|
||||||
phone: string;
|
phone: string;
|
||||||
email: string;
|
email: string;
|
||||||
image_url: string;
|
image_url: string;
|
||||||
|
meta_title: string;
|
||||||
|
meta_description: string;
|
||||||
simulator_systems: string;
|
simulator_systems: string;
|
||||||
bay_count: string;
|
bay_count: string;
|
||||||
lessons_available: boolean;
|
lessons_available: boolean;
|
||||||
|
|
@ -124,6 +133,8 @@ const DEFAULT_OPERATOR_FORM: OperatorFormState = {
|
||||||
website_url: "",
|
website_url: "",
|
||||||
logo_url: "",
|
logo_url: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
meta_title: "",
|
||||||
|
meta_description: "",
|
||||||
is_published: false,
|
is_published: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -146,6 +157,8 @@ const DEFAULT_VENUE_FORM: VenueFormState = {
|
||||||
phone: "",
|
phone: "",
|
||||||
email: "",
|
email: "",
|
||||||
image_url: "",
|
image_url: "",
|
||||||
|
meta_title: "",
|
||||||
|
meta_description: "",
|
||||||
simulator_systems: "",
|
simulator_systems: "",
|
||||||
bay_count: "",
|
bay_count: "",
|
||||||
lessons_available: false,
|
lessons_available: false,
|
||||||
|
|
@ -168,6 +181,8 @@ function operatorToFormState(operator: SimulatorOperator): OperatorFormState {
|
||||||
website_url: operator.website_url || "",
|
website_url: operator.website_url || "",
|
||||||
logo_url: operator.logo_url || "",
|
logo_url: operator.logo_url || "",
|
||||||
description: operator.description || "",
|
description: operator.description || "",
|
||||||
|
meta_title: operator.meta_title || "",
|
||||||
|
meta_description: operator.meta_description || "",
|
||||||
is_published: Boolean(operator.is_published),
|
is_published: Boolean(operator.is_published),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -192,6 +207,8 @@ function venueToFormState(venue: SimulatorVenue): VenueFormState {
|
||||||
phone: venue.phone || "",
|
phone: venue.phone || "",
|
||||||
email: venue.email || "",
|
email: venue.email || "",
|
||||||
image_url: venue.image_url || "",
|
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(", ") : "",
|
simulator_systems: Array.isArray(venue.simulator_systems) ? venue.simulator_systems.join(", ") : "",
|
||||||
bay_count: venue.bay_count == null ? "" : String(venue.bay_count),
|
bay_count: venue.bay_count == null ? "" : String(venue.bay_count),
|
||||||
lessons_available: Boolean(venue.lessons_available),
|
lessons_available: Boolean(venue.lessons_available),
|
||||||
|
|
@ -226,6 +243,47 @@ function splitSystems(value: string): string[] {
|
||||||
.filter(Boolean);
|
.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() {
|
export default function SimulatorAdminClient() {
|
||||||
const [operators, setOperators] = useState<SimulatorOperator[]>([]);
|
const [operators, setOperators] = useState<SimulatorOperator[]>([]);
|
||||||
const [venues, setVenues] = useState<SimulatorVenue[]>([]);
|
const [venues, setVenues] = useState<SimulatorVenue[]>([]);
|
||||||
|
|
@ -237,6 +295,13 @@ export default function SimulatorAdminClient() {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [operatorForm, setOperatorForm] = useState<OperatorFormState>(DEFAULT_OPERATOR_FORM);
|
const [operatorForm, setOperatorForm] = useState<OperatorFormState>(DEFAULT_OPERATOR_FORM);
|
||||||
const [venueForm, setVenueForm] = useState<VenueFormState>(DEFAULT_VENUE_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 () => {
|
const refreshData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -298,6 +363,8 @@ export default function SimulatorAdminClient() {
|
||||||
website_url: operatorForm.website_url,
|
website_url: operatorForm.website_url,
|
||||||
logo_url: operatorForm.logo_url,
|
logo_url: operatorForm.logo_url,
|
||||||
description: operatorForm.description,
|
description: operatorForm.description,
|
||||||
|
meta_title: operatorForm.meta_title,
|
||||||
|
meta_description: operatorForm.meta_description,
|
||||||
is_published: operatorForm.is_published,
|
is_published: operatorForm.is_published,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -353,6 +420,8 @@ export default function SimulatorAdminClient() {
|
||||||
phone: venueForm.phone,
|
phone: venueForm.phone,
|
||||||
email: venueForm.email,
|
email: venueForm.email,
|
||||||
image_url: venueForm.image_url,
|
image_url: venueForm.image_url,
|
||||||
|
meta_title: venueForm.meta_title,
|
||||||
|
meta_description: venueForm.meta_description,
|
||||||
simulator_systems: splitSystems(venueForm.simulator_systems),
|
simulator_systems: splitSystems(venueForm.simulator_systems),
|
||||||
bay_count: parseOptionalInteger(venueForm.bay_count),
|
bay_count: parseOptionalInteger(venueForm.bay_count),
|
||||||
lessons_available: venueForm.lessons_available,
|
lessons_available: venueForm.lessons_available,
|
||||||
|
|
@ -561,6 +630,18 @@ export default function SimulatorAdminClient() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<label className="flex items-center gap-3 rounded-2xl border border-gray-200 px-4 py-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -725,6 +806,18 @@ export default function SimulatorAdminClient() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 className="grid gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">By</label>
|
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">By</label>
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,64 @@ import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import AdminMobileMenu from "@/components/AdminMobileMenu";
|
import AdminMobileMenu from "@/components/AdminMobileMenu";
|
||||||
import TiptapHtmlEditor from "@/components/TiptapHtmlEditor";
|
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 { adminFetch } from "@/config/adminFetch";
|
||||||
import { API_URL } from "@/config/constants";
|
import { API_URL } from "@/config/constants";
|
||||||
|
import SeoFieldset, { trimSuggestion } from "@/components/admin/SeoFieldset";
|
||||||
|
|
||||||
type PlacePageResponse = {
|
type PlacePageResponse = {
|
||||||
slug: string;
|
slug: string;
|
||||||
factbox_intro_html?: string | null;
|
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;
|
updated_at?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_PLACE_SLUG = "norge";
|
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) => {
|
const formatDateTime = (value: string | null | undefined) => {
|
||||||
if (!value) return "Ikke lagret ennå";
|
if (!value) return "Ikke lagret ennå";
|
||||||
|
|
@ -32,12 +79,33 @@ const formatDateTime = (value: string | null | undefined) => {
|
||||||
export default function AdminPlacePagesPage() {
|
export default function AdminPlacePagesPage() {
|
||||||
const [selectedSlug, setSelectedSlug] = useState(DEFAULT_PLACE_SLUG);
|
const [selectedSlug, setSelectedSlug] = useState(DEFAULT_PLACE_SLUG);
|
||||||
const [html, setHtml] = useState("");
|
const [html, setHtml] = useState("");
|
||||||
|
const [metaTitle, setMetaTitle] = useState("");
|
||||||
|
const [metaDescription, setMetaDescription] = useState("");
|
||||||
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
|
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [feedback, setFeedback] = useState("");
|
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 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(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
@ -58,10 +126,14 @@ export default function AdminPlacePagesPage() {
|
||||||
|
|
||||||
const data = (await response.json()) as PlacePageResponse;
|
const data = (await response.json()) as PlacePageResponse;
|
||||||
setHtml(data.factbox_intro_html || "");
|
setHtml(data.factbox_intro_html || "");
|
||||||
|
setMetaTitle(data.meta_title || "");
|
||||||
|
setMetaDescription(data.meta_description || "");
|
||||||
setUpdatedAt(data.updated_at || null);
|
setUpdatedAt(data.updated_at || null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
setHtml("");
|
setHtml("");
|
||||||
|
setMetaTitle("");
|
||||||
|
setMetaDescription("");
|
||||||
setUpdatedAt(null);
|
setUpdatedAt(null);
|
||||||
setFeedback(error instanceof Error ? error.message : "Kunne ikke hente sted-siden.");
|
setFeedback(error instanceof Error ? error.message : "Kunne ikke hente sted-siden.");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -76,6 +148,45 @@ export default function AdminPlacePagesPage() {
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [selectedSlug]);
|
}, [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 () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setFeedback("");
|
setFeedback("");
|
||||||
|
|
@ -85,7 +196,11 @@ export default function AdminPlacePagesPage() {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
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) {
|
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 (
|
return (
|
||||||
<main className="mx-auto min-h-screen max-w-[1100px] bg-white p-4 md:p-8">
|
<main className="mx-auto min-h-screen max-w-[1100px] bg-white p-4 md:p-8">
|
||||||
<AdminMobileMenu />
|
<AdminMobileMenu />
|
||||||
|
|
@ -171,10 +315,93 @@ export default function AdminPlacePagesPage() {
|
||||||
Laster sted-side...
|
Laster sted-side...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<TiptapHtmlEditor
|
<div className="space-y-6">
|
||||||
value={html}
|
<SeoFieldset
|
||||||
onChange={setHtml}
|
titleValue={metaTitle}
|
||||||
placeholder="Skriv HTML-innholdet som skal vises over faktaboksen på denne sted-siden."
|
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 på 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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ export type FacilityRecord = {
|
||||||
vtg_pris?: number | null;
|
vtg_pris?: number | null;
|
||||||
vtg_lenke?: string | null;
|
vtg_lenke?: string | null;
|
||||||
vtg_beskrivelse?: string | null;
|
vtg_beskrivelse?: string | null;
|
||||||
|
meta_title?: string | null;
|
||||||
|
meta_description?: string | null;
|
||||||
amenities?: unknown;
|
amenities?: unknown;
|
||||||
golfamore_data?: unknown;
|
golfamore_data?: unknown;
|
||||||
nsg_data?: unknown;
|
nsg_data?: unknown;
|
||||||
|
|
@ -100,7 +102,7 @@ export const AREA_GROUPS: Record<string, string[]> = {
|
||||||
"nord-norge": ["finnmark", "troms", "nordland"],
|
"nord-norge": ["finnmark", "troms", "nordland"],
|
||||||
"midt-norge": ["trondelag", "nord-trondelag", "sor-trondelag"],
|
"midt-norge": ["trondelag", "nord-trondelag", "sor-trondelag"],
|
||||||
vestlandet: ["more-og-romsdal", "sogn-og-fjordane", "hordaland", "rogaland", "vestland"],
|
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"],
|
ostlandet: ["telemark", "vestfold", "ostfold", "buskerud", "hedmark", "oppland", "innlandet", "viken", "akershus", "oslo"],
|
||||||
"oslo-og-akershus": ["akershus", "oslo", "viken"],
|
"oslo-og-akershus": ["akershus", "oslo", "viken"],
|
||||||
};
|
};
|
||||||
|
|
@ -126,7 +128,6 @@ export const HIERARCHICAL_AREA_OPTIONS = [
|
||||||
{ value: "region:sorlandet", label: "Sørlandet", slug: "sorlandet" },
|
{ value: "region:sorlandet", label: "Sørlandet", slug: "sorlandet" },
|
||||||
{ value: "county:vest-agder", label: "Vest-Agder", slug: "vest-agder" },
|
{ value: "county:vest-agder", label: "Vest-Agder", slug: "vest-agder" },
|
||||||
{ value: "county:aust-agder", label: "Aust-Agder", slug: "aust-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: "region:ostlandet", label: "Østlandet", slug: "ostlandet" },
|
||||||
{ value: "county:telemark", label: "Telemark", slug: "telemark" },
|
{ value: "county:telemark", label: "Telemark", slug: "telemark" },
|
||||||
{ value: "county:vestfold", label: "Vestfold", slug: "vestfold" },
|
{ value: "county:vestfold", label: "Vestfold", slug: "vestfold" },
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,7 @@ export default function FacilityDetailView({
|
||||||
const rawCourses = parseJson(facility.courses, []);
|
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 activeCourses = Array.isArray(rawCourses) ? rawCourses.filter((c: any) => c.holes && (typeof c.holes === 'string' || c.holes.length > 0)) : [];
|
||||||
const amenities = parseJson(facility.amenities, {});
|
const amenities = parseJson(facility.amenities, {});
|
||||||
|
const camperParking = String(facility.camper_parking || "").trim();
|
||||||
const galleryRaw = parseJson(facility.gallery, []);
|
const galleryRaw = parseJson(facility.gallery, []);
|
||||||
const gallery = galleryRaw.length > 0 ? galleryRaw : [facility.image_url || FALLBACK_IMAGE];
|
const gallery = galleryRaw.length > 0 ? galleryRaw : [facility.image_url || FALLBACK_IMAGE];
|
||||||
const shotzoom = parseJson(facility.shotzoom, []);
|
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">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">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">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 */}
|
{/* Golfamore og NSG */}
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-2">
|
<div className="flex justify-between border-b border-gray-50 pb-2">
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ import {
|
||||||
createFacilityJsonLd,
|
createFacilityJsonLd,
|
||||||
createPageMetadata,
|
createPageMetadata,
|
||||||
createVtgCourseJsonLd,
|
createVtgCourseJsonLd,
|
||||||
trimDescription,
|
resolveSeoDescription,
|
||||||
|
resolveSeoTitle,
|
||||||
} from "@/app/seo";
|
} from "@/app/seo";
|
||||||
import { getFacilityEditorialArticles } from "@/content/editorialArticles";
|
import { getFacilityEditorialArticles } from "@/content/editorialArticles";
|
||||||
import FacilityDetailView from "./FacilityDetailView";
|
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 fallbackTitle = `${facility.name}: banestatus, priser og info`;
|
||||||
const fallbackDescription = `${facility.name} på TeeOff med banestatus, priser, kontaktinfo og lenker til nyttige ressurser.`;
|
const fallbackDescription = `Se banestatus, priser, kontaktinfo, kart og praktisk informasjon for ${facility.name} på TeeOff.no.`;
|
||||||
|
|
||||||
return createPageMetadata({
|
return createPageMetadata({
|
||||||
title,
|
title: resolveSeoTitle(facility.meta_title, fallbackTitle),
|
||||||
description: trimDescription(facility.description) || fallbackDescription,
|
description: resolveSeoDescription(facility.meta_description, fallbackDescription),
|
||||||
path: `/golfbaner/${facility.slug}`,
|
path: `/golfbaner/${facility.slug}`,
|
||||||
image: `/golfbaner/${facility.slug}/opengraph-image`,
|
image: `/golfbaner/${facility.slug}/opengraph-image`,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import FacilitySearch from "@/app/FacilitySearch";
|
import FacilitySearch from "@/app/FacilitySearch";
|
||||||
import type { FacilityRecord } from "@/app/facilityData";
|
import type { FacilityRecord } from "@/app/facilityData";
|
||||||
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
||||||
|
import { resolveSitePageSeo } from "@/app/pageSeo";
|
||||||
import {
|
import {
|
||||||
createBreadcrumbJsonLd,
|
createBreadcrumbJsonLd,
|
||||||
createCollectionPageJsonLd,
|
createCollectionPageJsonLd,
|
||||||
|
|
@ -11,25 +12,29 @@ import {
|
||||||
export const revalidate = 900;
|
export const revalidate = 900;
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const pageTitle = "Golfbaner i Norge";
|
const fallbackPageTitle = "Golfbaner i Norge";
|
||||||
const pageDescription =
|
const fallbackPageDescription =
|
||||||
"Finn golfbaner i Norge og filtrer på område, banestatus, antall hull og fasiliteter i TeeOffs samlede oversikt.";
|
"Finn golfbaner i Norge og filtrer på område, banestatus, antall hull og fasiliteter i TeeOffs samlede oversikt.";
|
||||||
|
|
||||||
export const metadata = createPageMetadata({
|
export async function generateMetadata() {
|
||||||
title: pageTitle,
|
const seo = await resolveSitePageSeo("golfbaner", fallbackPageTitle, fallbackPageDescription);
|
||||||
description: pageDescription,
|
return createPageMetadata({
|
||||||
path: "/golfbaner",
|
title: seo.title,
|
||||||
});
|
description: seo.description,
|
||||||
|
path: "/golfbaner",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default async function GolfCoursesIndexPage() {
|
export default async function GolfCoursesIndexPage() {
|
||||||
|
const seo = await resolveSitePageSeo("golfbaner", fallbackPageTitle, fallbackPageDescription);
|
||||||
const safeData = await fetchPublicFacilities<FacilityRecord>("search", revalidate);
|
const safeData = await fetchPublicFacilities<FacilityRecord>("search", revalidate);
|
||||||
const collectionJsonLd = createCollectionPageJsonLd({
|
const collectionJsonLd = createCollectionPageJsonLd({
|
||||||
name: pageTitle,
|
name: seo.title,
|
||||||
description: pageDescription,
|
description: seo.description,
|
||||||
path: "/golfbaner",
|
path: "/golfbaner",
|
||||||
});
|
});
|
||||||
const itemListJsonLd = createItemListJsonLd({
|
const itemListJsonLd = createItemListJsonLd({
|
||||||
name: pageTitle,
|
name: seo.title,
|
||||||
path: "/golfbaner",
|
path: "/golfbaner",
|
||||||
items: safeData
|
items: safeData
|
||||||
.filter((facility) => facility?.slug && facility?.name)
|
.filter((facility) => facility?.slug && facility?.name)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import MembershipExplorer, { type MembershipFacility } from "./MembershipExplorer";
|
import MembershipExplorer, { type MembershipFacility } from "./MembershipExplorer";
|
||||||
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
||||||
|
import { resolveSitePageSeo } from "@/app/pageSeo";
|
||||||
import {
|
import {
|
||||||
createBreadcrumbJsonLd,
|
createBreadcrumbJsonLd,
|
||||||
createCollectionPageJsonLd,
|
createCollectionPageJsonLd,
|
||||||
|
|
@ -9,17 +10,21 @@ import {
|
||||||
export const revalidate = 1800;
|
export const revalidate = 1800;
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const pageTitle = "Medlemskap i norske golfklubber";
|
const fallbackPageTitle = "Medlemskap i norske golfklubber";
|
||||||
const pageDescription =
|
const fallbackPageDescription =
|
||||||
"Sammenlign priser på medlemskap i norske golfklubber, både full spillerett og rimeligste nasjonale alternativ.";
|
"Sammenlign priser på medlemskap i norske golfklubber, både full spillerett og rimeligste nasjonale alternativ.";
|
||||||
|
|
||||||
export const metadata = createPageMetadata({
|
export async function generateMetadata() {
|
||||||
title: pageTitle,
|
const seo = await resolveSitePageSeo("medlemskap", fallbackPageTitle, fallbackPageDescription);
|
||||||
description: pageDescription,
|
return createPageMetadata({
|
||||||
path: "/medlemskap",
|
title: seo.title,
|
||||||
});
|
description: seo.description,
|
||||||
|
path: "/medlemskap",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default async function MembershipPage() {
|
export default async function MembershipPage() {
|
||||||
|
const seo = await resolveSitePageSeo("medlemskap", fallbackPageTitle, fallbackPageDescription);
|
||||||
const facilities = await fetchPublicFacilities<MembershipFacility>("membership", revalidate);
|
const facilities = await fetchPublicFacilities<MembershipFacility>("membership", revalidate);
|
||||||
|
|
||||||
const visibleFacilities = facilities.filter(
|
const visibleFacilities = facilities.filter(
|
||||||
|
|
@ -28,8 +33,8 @@ export default async function MembershipPage() {
|
||||||
typeof facility.rimeligste_alternativ === "number",
|
typeof facility.rimeligste_alternativ === "number",
|
||||||
);
|
);
|
||||||
const collectionJsonLd = createCollectionPageJsonLd({
|
const collectionJsonLd = createCollectionPageJsonLd({
|
||||||
name: pageTitle,
|
name: seo.title,
|
||||||
description: pageDescription,
|
description: seo.description,
|
||||||
path: "/medlemskap",
|
path: "/medlemskap",
|
||||||
});
|
});
|
||||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||||
|
|
|
||||||
36
frontend/src/app/pageSeo.ts
Normal file
36
frontend/src/app/pageSeo.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -80,6 +80,20 @@ export function trimDescription(value: string | null | undefined, maxLength = 16
|
||||||
return `${plain.slice(0, maxLength - 3).trimEnd()}...`;
|
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({
|
export function createPageMetadata({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
|
@ -224,9 +238,11 @@ export function createFacilityJsonLd(facility: FacilitySeoRecord) {
|
||||||
"@type": ["GolfCourse", "SportsActivityLocation"],
|
"@type": ["GolfCourse", "SportsActivityLocation"],
|
||||||
"@id": buildAbsoluteUrl(`/golfbaner/${facility.slug}#golfcourse`),
|
"@id": buildAbsoluteUrl(`/golfbaner/${facility.slug}#golfcourse`),
|
||||||
name: facility.name,
|
name: facility.name,
|
||||||
description:
|
description: resolveSeoDescription(
|
||||||
|
facility.meta_description,
|
||||||
trimDescription(facility.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}`),
|
url: buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
|
||||||
mainEntityOfPage: buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
|
mainEntityOfPage: buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
|
||||||
image: resolveImageUrl(facility.image_url),
|
image: resolveImageUrl(facility.image_url),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { cache } from "react";
|
||||||
import PlaceExplorer from "@/app/sted/[slug]/PlaceExplorer";
|
import PlaceExplorer from "@/app/sted/[slug]/PlaceExplorer";
|
||||||
import {
|
import {
|
||||||
buildPlaceAverageComparison,
|
buildPlaceAverageComparison,
|
||||||
|
|
@ -19,12 +20,16 @@ import {
|
||||||
createCollectionPageJsonLd,
|
createCollectionPageJsonLd,
|
||||||
createItemListJsonLd,
|
createItemListJsonLd,
|
||||||
createPageMetadata,
|
createPageMetadata,
|
||||||
|
resolveSeoDescription,
|
||||||
|
resolveSeoTitle,
|
||||||
} from "@/app/seo";
|
} from "@/app/seo";
|
||||||
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
||||||
|
|
||||||
type PlacePageData = {
|
type PlacePageData = {
|
||||||
slug?: string;
|
slug?: string;
|
||||||
factbox_intro_html?: string | null;
|
factbox_intro_html?: string | null;
|
||||||
|
meta_title?: string | null;
|
||||||
|
meta_description?: string | null;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -94,6 +99,22 @@ const sanitizePlaceRichText = (value: string | null | undefined) => {
|
||||||
export const dynamicParams = true;
|
export const dynamicParams = true;
|
||||||
export const revalidate = 3600;
|
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({
|
export async function generateMetadata({
|
||||||
params,
|
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({
|
return createPageMetadata({
|
||||||
title: place.title,
|
title: resolveSeoTitle(placePage?.meta_title, place.title),
|
||||||
description: `${place.intro} TeeOff samler golfbaner i ${place.label} med oppdatert banestatus og baneprofiler.`,
|
description: resolveSeoDescription(placePage?.meta_description, fallbackDescription),
|
||||||
path: `/sted/${slug}`,
|
path: `/sted/${slug}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -130,15 +154,10 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
|
||||||
const facilities = await fetchPublicFacilities<FacilityRecord>("place", revalidate);
|
const facilities = await fetchPublicFacilities<FacilityRecord>("place", revalidate);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/place-pages/${slug}`, {
|
placePage = await fetchPlacePageData(slug);
|
||||||
cache: "no-store",
|
if (!placePage) {
|
||||||
});
|
throw new Error("API returnerte ingen sted-side.");
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`API returnerte status ${res.status}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
placePage = await res.json();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Kritisk feil ved henting av sted-sideinnhold:", error);
|
console.error("Kritisk feil ved henting av sted-sideinnhold:", error);
|
||||||
placePage = null;
|
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.`
|
? `Det korteste golfhullet ${placePreposition} ${place.label} er ${placeStats.shortestHoleMeters} meter, mens det lengste er ${placeStats.longestHoleMeters} meter.`
|
||||||
: null;
|
: null;
|
||||||
const collectionJsonLd = createCollectionPageJsonLd({
|
const collectionJsonLd = createCollectionPageJsonLd({
|
||||||
name: place.title,
|
name: resolveSeoTitle(placePage?.meta_title, place.title),
|
||||||
description: place.intro,
|
description: resolveSeoDescription(
|
||||||
|
placePage?.meta_description,
|
||||||
|
`${place.intro} TeeOff samler golfbaner i ${place.label} med oppdatert banestatus og baneprofiler.`,
|
||||||
|
),
|
||||||
path: `/sted/${slug}`,
|
path: `/sted/${slug}`,
|
||||||
});
|
});
|
||||||
const itemListJsonLd = createItemListJsonLd({
|
const itemListJsonLd = createItemListJsonLd({
|
||||||
name: place.title,
|
name: resolveSeoTitle(placePage?.meta_title, place.title),
|
||||||
path: `/sted/${slug}`,
|
path: `/sted/${slug}`,
|
||||||
items: facilitiesInPlace
|
items: facilitiesInPlace
|
||||||
.filter((facility) => facility?.slug && facility?.name)
|
.filter((facility) => facility?.slug && facility?.name)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import VtgExplorer from "./VtgExplorer";
|
import VtgExplorer from "./VtgExplorer";
|
||||||
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
||||||
import type { FacilityRecord } from "@/app/facilityData";
|
import type { FacilityRecord } from "@/app/facilityData";
|
||||||
|
import { resolveSitePageSeo } from "@/app/pageSeo";
|
||||||
import {
|
import {
|
||||||
createBreadcrumbJsonLd,
|
createBreadcrumbJsonLd,
|
||||||
createCollectionPageJsonLd,
|
createCollectionPageJsonLd,
|
||||||
|
|
@ -10,22 +11,26 @@ import {
|
||||||
export const revalidate = 1800;
|
export const revalidate = 1800;
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const pageTitle = "Veien til Golf";
|
const fallbackPageTitle = "Veien til Golf";
|
||||||
const pageDescription =
|
const fallbackPageDescription =
|
||||||
"Finn Veien til Golf-kurs etter område, klubb og neste kursdato i TeeOffs VTG-oversikt.";
|
"Finn Veien til Golf-kurs etter område, klubb og neste kursdato i TeeOffs VTG-oversikt.";
|
||||||
|
|
||||||
export const metadata = createPageMetadata({
|
export async function generateMetadata() {
|
||||||
title: pageTitle,
|
const seo = await resolveSitePageSeo("vtg", fallbackPageTitle, fallbackPageDescription);
|
||||||
description: pageDescription,
|
return createPageMetadata({
|
||||||
path: "/vtg",
|
title: seo.title,
|
||||||
});
|
description: seo.description,
|
||||||
|
path: "/vtg",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default async function VtgPage() {
|
export default async function VtgPage() {
|
||||||
|
const seo = await resolveSitePageSeo("vtg", fallbackPageTitle, fallbackPageDescription);
|
||||||
const facilities = await fetchPublicFacilities<FacilityRecord>("vtg", revalidate);
|
const facilities = await fetchPublicFacilities<FacilityRecord>("vtg", revalidate);
|
||||||
|
|
||||||
const collectionJsonLd = createCollectionPageJsonLd({
|
const collectionJsonLd = createCollectionPageJsonLd({
|
||||||
name: pageTitle,
|
name: seo.title,
|
||||||
description: pageDescription,
|
description: seo.description,
|
||||||
path: "/vtg",
|
path: "/vtg",
|
||||||
});
|
});
|
||||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@ const placeGroups = [
|
||||||
{ href: "/sted/sorlandet", label: "Sørlandet" },
|
{ href: "/sted/sorlandet", label: "Sørlandet" },
|
||||||
{ href: "/sted/vest-agder", label: "Vest-Agder" },
|
{ href: "/sted/vest-agder", label: "Vest-Agder" },
|
||||||
{ href: "/sted/aust-agder", label: "Aust-Agder" },
|
{ href: "/sted/aust-agder", label: "Aust-Agder" },
|
||||||
{ href: "/sted/agder", label: "Agder" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
184
frontend/src/components/admin/SeoFieldset.tsx
Normal file
184
frontend/src/components/admin/SeoFieldset.tsx
Normal 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(/ /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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue