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):
|
||||
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."""
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ const EMPTY_FACILITY = {
|
|||
vtg_datoer: [],
|
||||
cooperating_clubs: [],
|
||||
amenities: {},
|
||||
camper_parking: "",
|
||||
meta_title: "",
|
||||
meta_description: "",
|
||||
nsg_data: {},
|
||||
golfamore_data: {},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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><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>
|
||||
</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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 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>
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
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()}...`;
|
||||
}
|
||||
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
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