Før endringer i utslagssteder
This commit is contained in:
parent
3375535366
commit
0270855436
17 changed files with 1907 additions and 517 deletions
BIN
2026-04-28 14.52.19 teeoff.no dcb2c926e3f9.jpg
Normal file
BIN
2026-04-28 14.52.19 teeoff.no dcb2c926e3f9.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 250 KiB |
BIN
2026-04-28_142102.png
Normal file
BIN
2026-04-28_142102.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
|
|
@ -3,6 +3,7 @@ SMTP_PORT=465
|
|||
SMTP_USER=teeoff@example.com
|
||||
SMTP_PASS=replace-with-your-smtp-password
|
||||
COMMENT_NOTIFICATION_TO_EMAIL=teeoff@example.com
|
||||
FACILITY_RATING_NOTIFICATION_TO_EMAIL=teeoff@example.com
|
||||
EMAIL_TO=ops@example.com
|
||||
GEMINI_API_KEY=replace-with-your-gemini-api-key
|
||||
DATABASE_URL=postgresql://teeoff_admin:replace-with-your-postgres-password@db:5432/teeoff
|
||||
|
|
|
|||
540
backend/main.py
540
backend/main.py
|
|
@ -83,6 +83,10 @@ COMMENT_NOTIFICATION_TO_EMAIL = os.getenv(
|
|||
"COMMENT_NOTIFICATION_TO_EMAIL",
|
||||
CONTACT_FORM_TO_EMAIL,
|
||||
).strip()
|
||||
FACILITY_RATING_NOTIFICATION_TO_EMAIL = os.getenv(
|
||||
"FACILITY_RATING_NOTIFICATION_TO_EMAIL",
|
||||
COMMENT_NOTIFICATION_TO_EMAIL,
|
||||
).strip()
|
||||
INDEXNOW_KEY = os.getenv("INDEXNOW_KEY", "").strip()
|
||||
INDEXNOW_KEY_LOCATION = os.getenv("INDEXNOW_KEY_LOCATION", "").strip()
|
||||
INDEXNOW_ENDPOINT = os.getenv("INDEXNOW_ENDPOINT", "https://api.indexnow.org/indexnow").strip()
|
||||
|
|
@ -100,6 +104,7 @@ PUBLIC_FACILITIES_CACHE_TTLS = {
|
|||
}
|
||||
PUBLIC_FACILITY_DETAIL_CACHE_TTL_SECONDS = 900
|
||||
PUBLIC_PLACE_PAGE_CACHE_TTL_SECONDS = 3600
|
||||
PUBLIC_SITE_PAGE_CACHE_TTL_SECONDS = 3600
|
||||
|
||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||
|
||||
|
|
@ -146,6 +151,7 @@ def initialize_public_api_caches() -> None:
|
|||
app.state.public_facilities_cache = {}
|
||||
app.state.public_facility_detail_cache = {}
|
||||
app.state.public_place_page_cache = {}
|
||||
app.state.public_site_page_cache = {}
|
||||
|
||||
|
||||
def get_public_facilities_cache_ttl(view: str | None) -> int:
|
||||
|
|
@ -183,7 +189,7 @@ def apply_public_cache_headers(response: Response, ttl_seconds: int) -> None:
|
|||
response.headers["Cache-Control"] = f"public, max-age=60, s-maxage={ttl}, stale-while-revalidate=60"
|
||||
|
||||
|
||||
def invalidate_public_api_caches(*, include_place_pages: bool = False) -> None:
|
||||
def invalidate_public_api_caches(*, include_place_pages: bool = False, include_site_pages: bool = False) -> None:
|
||||
facilities_cache = getattr(app.state, "public_facilities_cache", None)
|
||||
if isinstance(facilities_cache, dict):
|
||||
facilities_cache.clear()
|
||||
|
|
@ -197,6 +203,11 @@ def invalidate_public_api_caches(*, include_place_pages: bool = False) -> None:
|
|||
if isinstance(place_page_cache, dict):
|
||||
place_page_cache.clear()
|
||||
|
||||
if include_site_pages:
|
||||
site_page_cache = getattr(app.state, "public_site_page_cache", None)
|
||||
if isinstance(site_page_cache, dict):
|
||||
site_page_cache.clear()
|
||||
|
||||
|
||||
def get_configured_public_base_url() -> str:
|
||||
for env_name in ("PUBLIC_BASE_URL", "NEXT_PUBLIC_SITE_URL"):
|
||||
|
|
@ -616,6 +627,65 @@ async def send_comment_notification_email(
|
|||
await asyncio.to_thread(_send)
|
||||
|
||||
|
||||
async def send_facility_rating_notification_email(
|
||||
*,
|
||||
facility_name: str,
|
||||
facility_url: str,
|
||||
reviewer_name: str,
|
||||
reviewer_email: str | None,
|
||||
quality_rating: int,
|
||||
conditions_rating: int,
|
||||
hospitality_rating: int,
|
||||
overall_rating: float,
|
||||
rating_count: int,
|
||||
quality_average: float | None,
|
||||
conditions_average: float | None,
|
||||
hospitality_average: float | None,
|
||||
overall_average: float | None,
|
||||
is_new_rating: bool,
|
||||
ip_hash: str | None,
|
||||
) -> None:
|
||||
if not (is_magic_link_configured() and FACILITY_RATING_NOTIFICATION_TO_EMAIL):
|
||||
return
|
||||
|
||||
subject = f"[TeeOff Vurdering] {facility_name}"
|
||||
body = (
|
||||
"Ny brukervurdering på TeeOff.no\n\n"
|
||||
f"Golfanlegg: {facility_name}\n"
|
||||
f"Lenke: {facility_url}\n"
|
||||
f"Hendelse: {'Ny vurdering' if is_new_rating else 'Oppdatert vurdering'}\n"
|
||||
f"Bruker: {reviewer_name}\n"
|
||||
f"E-post: {reviewer_email or 'ikke tilgjengelig'}\n"
|
||||
f"IP-hash: {ip_hash or 'ukjent'}\n\n"
|
||||
"Innsendt vurdering:\n"
|
||||
f"Kvalitet på anlegg: {quality_rating}/5\n"
|
||||
f"Forhold: {conditions_rating}/5\n"
|
||||
f"Gjestfrihet: {hospitality_rating}/5\n"
|
||||
f"Snitt for denne vurderingen: {overall_rating:.1f}/5\n\n"
|
||||
"Oppsummert etter lagring:\n"
|
||||
f"Antall vurderinger: {rating_count}\n"
|
||||
f"Gjennomsnitt kvalitet på anlegg: {quality_average if quality_average is not None else 'ikke tilgjengelig'}/5\n"
|
||||
f"Gjennomsnitt forhold: {conditions_average if conditions_average is not None else 'ikke tilgjengelig'}/5\n"
|
||||
f"Gjennomsnitt gjestfrihet: {hospitality_average if hospitality_average is not None else 'ikke tilgjengelig'}/5\n"
|
||||
f"Totalt gjennomsnitt: {overall_average if overall_average is not None else 'ikke tilgjengelig'}/5\n"
|
||||
)
|
||||
|
||||
def _send() -> None:
|
||||
mail = EmailMessage()
|
||||
mail["From"] = PUBLIC_FROM_EMAIL
|
||||
mail["To"] = FACILITY_RATING_NOTIFICATION_TO_EMAIL
|
||||
if reviewer_email:
|
||||
mail["Reply-To"] = reviewer_email
|
||||
mail["Subject"] = subject
|
||||
mail.set_content(body)
|
||||
|
||||
with smtplib.SMTP_SSL(SMTP_SERVER, int(SMTP_PORT)) as server:
|
||||
server.login(SMTP_USER, SMTP_PASS)
|
||||
server.send_message(mail)
|
||||
|
||||
await asyncio.to_thread(_send)
|
||||
|
||||
|
||||
async def validate_admin_session_token(token: str) -> str:
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
|
|
@ -673,6 +743,16 @@ class SitePageSeoUpsertRequest(BaseModel):
|
|||
meta_description: Optional[str] = None
|
||||
|
||||
|
||||
class SitePageUpsertRequest(BaseModel):
|
||||
eyebrow: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
hero_image_url: Optional[str] = None
|
||||
intro_html: Optional[str] = ""
|
||||
body_html: Optional[str] = ""
|
||||
meta_title: Optional[str] = None
|
||||
meta_description: Optional[str] = None
|
||||
|
||||
|
||||
class SimulatorOperatorUpsertRequest(BaseModel):
|
||||
name: str
|
||||
slug: Optional[str] = None
|
||||
|
|
@ -1109,6 +1189,268 @@ def format_site_page_seo_row(row):
|
|||
return d
|
||||
|
||||
|
||||
LEGACY_SITE_PAGE_ARTICLE_SLUGS = {
|
||||
"turneringer": "note-to-self-lenker-til-viktige-turneringer-i-golfbox",
|
||||
}
|
||||
|
||||
SITE_PAGE_CONFIGS: dict[str, dict[str, str]] = {
|
||||
"turneringer": {
|
||||
"path": "/turneringer",
|
||||
"eyebrow": "Turneringer",
|
||||
"title": "Lenker til viktige turneringer i Golfbox",
|
||||
"hero_image_url": "",
|
||||
"intro_html": (
|
||||
"<p>Her er alle turneringene vi ikke vet hvordan vi skal finne i Golfbox "
|
||||
"(og andre steder). God golfsesong!</p>"
|
||||
),
|
||||
"body_html": "",
|
||||
"meta_title": "Golfturneringer i Norge: Oversikt og terminlister | TeeOff.no",
|
||||
"meta_description": (
|
||||
"Vanskelig å finne frem i Golfbox? Vi samler terminlister for Narvesen Tour "
|
||||
"og regionale golfturneringer i hele Norge på ett sted. Finn din neste turnering her!"
|
||||
),
|
||||
},
|
||||
"klubbnummer": {
|
||||
"path": "/klubbnummer",
|
||||
"eyebrow": "Klubbnummer",
|
||||
"title": "Klubbnummer i Golfbox",
|
||||
"hero_image_url": "",
|
||||
"intro_html": (
|
||||
"<p>I booking-vinduet i Golfbox (eller Gimmie) er det ofte vanskelig å se "
|
||||
"hvilken klubb spillere er fra. Om du ønsker kan du da bruke denne tabellen som oppslagsverk.</p>"
|
||||
),
|
||||
"body_html": "",
|
||||
"meta_title": "NGF Klubbnummer: Oversikt for Golfbox og Gimmie | TeeOff.no",
|
||||
"meta_description": (
|
||||
"Hvilken klubb tilhører nummeret? Se komplett og sorterbar oversikt over alle norske "
|
||||
"golfklubber og deres NGF-klubbnummer for bruk i Golfbox og Gimmie."
|
||||
),
|
||||
},
|
||||
"om": {
|
||||
"path": "/om",
|
||||
"eyebrow": "FAQ / Om",
|
||||
"title": "Hva TeeOff er, og hvorfor siden finnes",
|
||||
"hero_image_url": "",
|
||||
"intro_html": (
|
||||
"<p>Kortversjonen er fortsatt den samme: TeeOff bruker mye tid på å samle, rydde "
|
||||
"og presentere norske golfanlegg på en ordentlig måte, slik at det blir lettere "
|
||||
"å finne ut hvor man faktisk har lyst til å spille.</p>"
|
||||
),
|
||||
"body_html": """
|
||||
<section>
|
||||
<h2>Hvorfor dette nettstedet?</h2>
|
||||
<p>TeeOff startet i 2015 fordi det var unødvendig vanskelig å finne ut hvilke golfbaner som ligger hvor, hva de tilbyr, og hvilken bane som faktisk passer til turen du vurderer å ta. Ambisjonen var enkel: samle norske golfanlegg på ett sted og gjøre dem lettere å finne, forstå og sammenligne.</p>
|
||||
<p>Kjernen er den samme i dag: kjærlighet til golf, lysten til å vise hvor bra det er å spille i Norge, og behovet for en oversikt som faktisk er nyttig for greenfeespillere, klubbfolk og andre som vil oppdage nye baner.</p>
|
||||
<p>TeeOff er ikke laget for å erstatte klubbenes egne nettsider. Målet er å gjøre klubbene enklere å finne, og å gjøre terskelen lavere for å dra og spille et nytt sted.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Hvilken informasjon finnes om banene?</h2>
|
||||
<ul>
|
||||
<li>adresse og plassering på kart</li>
|
||||
<li>bilder og video når det finnes</li>
|
||||
<li>kontaktinformasjon, hjemmeside og sosiale medier</li>
|
||||
<li>banestatus, vær, flyfoto og turneringslenker</li>
|
||||
<li>banebeskrivelse og praktisk info om fasiliteter</li>
|
||||
<li>head pro, greenfee, medlemskap og Veien til Golf</li>
|
||||
<li>scorekort, slope og annen nyttig banedata</li>
|
||||
<li>redaksjonelt innhold som banebesøk og meninger når det finnes</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Hvor kommer informasjonen fra?</h2>
|
||||
<p>Innholdet hentes fra klubbenes egne nettsider, Golfbox, Shotzoom, sosiale medier, direkte kontakt med klubbene og i noen tilfeller egne besøk og manuell research.</p>
|
||||
<p>Ambisjonen er at informasjonen skal være korrekt, oppdatert og praktisk anvendelig. Når noe endrer seg, er TeeOff avhengig av gode kilder og raske tilbakemeldinger. Oppdager du feil, er det derfor bare å si fra.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Hvem holder TeeOff oppdatert?</h2>
|
||||
<p>TeeOff bygger videre på et mangeårig arbeid med å samle og rydde informasjon om norske golfanlegg. Den nye løsningen er laget for å gjøre det enklere å holde flere typer klubbdata oppdatert enn tidligere.</p>
|
||||
<p>Har du informasjon som bør endres, bilder som bør brukes, eller tips til innhold, kan du sende det inn via kontaktsiden.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Hvordan bytte toppbildet på en klubbside?</h2>
|
||||
<p>Hvis du representerer en klubb og vil bytte hovedbildet som presenterer banen, er det bare å ta kontakt og sende over et godt bilde i bredt format. Fotograf krediteres når informasjonen følger med.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Koster dette noe?</h2>
|
||||
<p>TeeOff er gratis å bruke for både golfspillere og klubber. Det ligger mye arbeid bak å samle, rydde og presentere informasjonen, men selve synligheten på TeeOff er ikke låst bak betaling.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Drukner klubbene i spam hvis e-postadressen vises?</h2>
|
||||
<p>Nei, det er ikke meningen. Kontaktinformasjon publiseres fordi den skal være nyttig for vanlige mennesker, samtidig som løsningene rundt kontaktsiden og systemet er laget for å redusere automatisert misbruk og spam.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Hva betyr TeeOff for klubbene egentlig?</h2>
|
||||
<p>Bruksmønsteret har lenge vært ganske tydelig: mange bruker TeeOff når de faktisk vurderer å dra og spille. De sammenligner anlegg, ser på praktisk informasjon, sjekker kart, turneringer og detaljer før de bestemmer seg.</p>
|
||||
<p>Det er den viktigste verdien TeeOff kan gi klubbene også i dag: gjøre det lettere for flere å oppdage nye golfanlegg, finne relevant informasjon og komme seg ut på banen.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Sporing og analyse</h2>
|
||||
<p>TeeOff bruker analyseverktøy for å forstå hvilke sider som brukes, hvordan besøkende navigerer og hva som bør forbedres. I dag skjer dette med Matomo.</p>
|
||||
<p>Hvis du vil vite mer om personvern, cookies og analyse, finnes det en egen side for dette.</p>
|
||||
<p><a href="/personvern-og-cookies">Les om personvern og cookies</a></p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Turneringer, kurs og andre tilbud</h2>
|
||||
<p>Klubber har ofte behov for å løfte frem turneringer, VTG-kurs og andre tilbud. TeeOff har derfor egne flater for dette, og slike ting kan også løftes frem sammen med den enkelte klubbprofilen.</p>
|
||||
<p><a href="/turneringer">Se turneringer</a><br /><a href="/vtg">Se Veien til Golf</a><br /><a href="/kontakt">Kontakt TeeOff</a></p>
|
||||
</section>
|
||||
""".strip(),
|
||||
"meta_title": "FAQ / Om TeeOff",
|
||||
"meta_description": (
|
||||
"Hvorfor TeeOff finnes, hvilken informasjon som samles om norske golfanlegg, "
|
||||
"og hvordan siden brukes av både golfspillere og klubber."
|
||||
),
|
||||
},
|
||||
"personvern-og-cookies": {
|
||||
"path": "/personvern-og-cookies",
|
||||
"eyebrow": "Personvern",
|
||||
"title": "Personvern og cookies",
|
||||
"hero_image_url": "",
|
||||
"intro_html": (
|
||||
"<p>Denne siden forklarer kort hvilke opplysninger TeeOff behandler, hvorfor vi "
|
||||
"gjør det, og hvordan cookies brukes på nettsiden.</p>"
|
||||
),
|
||||
"body_html": """
|
||||
<section>
|
||||
<h2>Hva vi lagrer</h2>
|
||||
<p>TeeOff lagrer i hovedsak opplysninger som er nødvendige for å vise klubbdata, publisere innhold og håndtere henvendelser fra brukere.</p>
|
||||
<p>Hvis du bruker kontaktskjemaet, lagres opplysningene du selv sender inn for å kunne besvare henvendelsen. Hvis du kommenterer artikler eller bruker innloggingsfunksjoner, behandles de opplysningene som er nødvendige for å autentisere deg og vise innholdet.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Cookies</h2>
|
||||
<p>TeeOff bruker cookies til noen få, konkrete formål:</p>
|
||||
<ul>
|
||||
<li>innlogging og sesjonshåndtering for administratorer</li>
|
||||
<li>innlogging for offentlige brukerfunksjoner som kommentarer</li>
|
||||
<li>måling av trafikk og bruksmønstre via Matomo</li>
|
||||
</ul>
|
||||
<p>Nødvendige cookies brukes for at nettsiden skal fungere. Analysecookies brukes for å forstå hvordan nettsiden brukes og forbedre innhold og funksjonalitet.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Analyse med Matomo</h2>
|
||||
<p>TeeOff bruker Matomo for å måle trafikk på nettsiden. Formålet er å forstå hvilke sider som brukes, hvordan besøkende navigerer og hvor innhold kan forbedres.</p>
|
||||
<p>Matomo-instansen kjøres på <strong>analyse.envide.no</strong>. Admin-områdene spores ikke på samme måte som vanlige publikumssider.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Kontakt om personvern</h2>
|
||||
<p>Hvis du har spørsmål om personvern, cookies eller ønsker innsyn knyttet til opplysninger du har sendt inn, kan du bruke kontaktsiden eller sende e-post til <a href="mailto:teeoff@teeoff.no">teeoff@teeoff.no</a>.</p>
|
||||
</section>
|
||||
""".strip(),
|
||||
"meta_title": "Personvern og cookies",
|
||||
"meta_description": (
|
||||
"Hvordan TeeOff behandler personopplysninger, bruker cookies og måleverktøy som Matomo."
|
||||
),
|
||||
},
|
||||
"kontakt": {
|
||||
"path": "/kontakt",
|
||||
"eyebrow": "Kontakt",
|
||||
"title": "Kontakt TeeOff",
|
||||
"hero_image_url": "",
|
||||
"intro_html": (
|
||||
"<p>Bruk skjemaet hvis du vil melde fra om feil i klubbdata, tips om artikler, "
|
||||
"spørsmål om administrasjonstilgang eller andre henvendelser.</p>"
|
||||
),
|
||||
"body_html": (
|
||||
"<p>Du kan bruke skjemaet under hvis du vil melde fra om feil i klubbdata, "
|
||||
"sende redaksjonelle tips eller spørre om klubbkontoer og administrasjonstilgang.</p>"
|
||||
),
|
||||
"meta_title": "Kontakt TeeOff",
|
||||
"meta_description": (
|
||||
"Kontakt TeeOff for feil i klubbdata, redaksjonelle tips, klubbkontoer og andre henvendelser."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
VALID_SITE_PAGE_KEYS = set(SITE_PAGE_CONFIGS.keys())
|
||||
|
||||
|
||||
def load_legacy_site_page_article(slug: str) -> dict[str, Any] | None:
|
||||
normalized_slug = str(slug or "").strip()
|
||||
if not normalized_slug:
|
||||
return None
|
||||
|
||||
candidate_paths = [
|
||||
Path(__file__).resolve().parent.parent / "frontend" / "src" / "content" / "importedMeninger.json",
|
||||
Path.cwd() / "frontend" / "src" / "content" / "importedMeninger.json",
|
||||
]
|
||||
|
||||
for candidate_path in candidate_paths:
|
||||
try:
|
||||
if not candidate_path.exists():
|
||||
continue
|
||||
payload = json.loads(candidate_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not isinstance(payload, list):
|
||||
continue
|
||||
|
||||
for entry in payload:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if str(entry.get("slug") or "").strip() == normalized_slug:
|
||||
return entry
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def build_default_site_page_row(page_key: str) -> dict[str, Any] | None:
|
||||
normalized_key = str(page_key or "").strip().lower()
|
||||
config = SITE_PAGE_CONFIGS.get(normalized_key)
|
||||
if not config:
|
||||
return None
|
||||
|
||||
payload = {
|
||||
"page_key": normalized_key,
|
||||
"eyebrow": str(config.get("eyebrow") or ""),
|
||||
"title": str(config.get("title") or ""),
|
||||
"hero_image_url": str(config.get("hero_image_url") or ""),
|
||||
"intro_html": str(config.get("intro_html") or ""),
|
||||
"body_html": str(config.get("body_html") or ""),
|
||||
"meta_title": str(config.get("meta_title") or ""),
|
||||
"meta_description": str(config.get("meta_description") or ""),
|
||||
"created_at": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
legacy_slug = LEGACY_SITE_PAGE_ARTICLE_SLUGS.get(normalized_key)
|
||||
if legacy_slug:
|
||||
legacy_article = load_legacy_site_page_article(legacy_slug)
|
||||
if legacy_article:
|
||||
payload["title"] = str(legacy_article.get("title") or payload["title"])
|
||||
payload["body_html"] = str(legacy_article.get("contentHtml") or payload["body_html"])
|
||||
featured_image = legacy_article.get("featuredImage") if isinstance(legacy_article, dict) else None
|
||||
if isinstance(featured_image, dict):
|
||||
payload["hero_image_url"] = str(
|
||||
featured_image.get("originalUrl")
|
||||
or featured_image.get("url")
|
||||
or payload["hero_image_url"]
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def format_site_page_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["eyebrow"] = str(d.get("eyebrow") or "")
|
||||
d["title"] = str(d.get("title") or "")
|
||||
d["hero_image_url"] = str(d.get("hero_image_url") or "")
|
||||
d["intro_html"] = str(d.get("intro_html") or "")
|
||||
d["body_html"] = str(d.get("body_html") or "")
|
||||
d["meta_title"] = str(d.get("meta_title") or "")
|
||||
d["meta_description"] = str(d.get("meta_description") or "")
|
||||
return d
|
||||
|
||||
|
||||
def format_simulator_operator_row(row):
|
||||
if row is None:
|
||||
return None
|
||||
|
|
@ -2487,6 +2829,59 @@ async def ensure_site_page_seo_table(conn):
|
|||
""")
|
||||
|
||||
|
||||
async def ensure_site_pages_table(conn):
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS site_pages (
|
||||
page_key VARCHAR(255) PRIMARY KEY,
|
||||
eyebrow TEXT,
|
||||
title TEXT,
|
||||
hero_image_url TEXT,
|
||||
intro_html TEXT,
|
||||
body_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 site_pages ADD COLUMN IF NOT EXISTS eyebrow TEXT")
|
||||
await conn.execute("ALTER TABLE site_pages ADD COLUMN IF NOT EXISTS title TEXT")
|
||||
await conn.execute("ALTER TABLE site_pages ADD COLUMN IF NOT EXISTS hero_image_url TEXT")
|
||||
await conn.execute("ALTER TABLE site_pages ADD COLUMN IF NOT EXISTS intro_html TEXT")
|
||||
await conn.execute("ALTER TABLE site_pages ADD COLUMN IF NOT EXISTS body_html TEXT")
|
||||
await conn.execute("ALTER TABLE site_pages ADD COLUMN IF NOT EXISTS meta_title TEXT")
|
||||
await conn.execute("ALTER TABLE site_pages ADD COLUMN IF NOT EXISTS meta_description TEXT")
|
||||
|
||||
for page_key in VALID_SITE_PAGE_KEYS:
|
||||
default_row = build_default_site_page_row(page_key)
|
||||
if not default_row:
|
||||
continue
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO site_pages (
|
||||
page_key,
|
||||
eyebrow,
|
||||
title,
|
||||
hero_image_url,
|
||||
intro_html,
|
||||
body_html,
|
||||
meta_title,
|
||||
meta_description
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (page_key) DO NOTHING
|
||||
""",
|
||||
default_row["page_key"],
|
||||
default_row["eyebrow"],
|
||||
default_row["title"],
|
||||
default_row["hero_image_url"],
|
||||
default_row["intro_html"],
|
||||
default_row["body_html"],
|
||||
default_row["meta_title"],
|
||||
default_row["meta_description"],
|
||||
)
|
||||
|
||||
|
||||
async def ensure_articles_table(conn):
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
|
|
@ -2749,6 +3144,7 @@ async def lifespan(app: FastAPI):
|
|||
await ensure_vtg_course_tables(conn)
|
||||
await ensure_place_pages_table(conn)
|
||||
await ensure_site_page_seo_table(conn)
|
||||
await ensure_site_pages_table(conn)
|
||||
await ensure_articles_table(conn)
|
||||
await ensure_public_user_tables(conn)
|
||||
await ensure_simulator_operator_tables(conn)
|
||||
|
|
@ -3393,12 +3789,25 @@ async def get_facility_ratings(request: Request, slug: str):
|
|||
@app.put("/api/facilities/{slug}/ratings")
|
||||
async def upsert_facility_rating(request: Request, payload: FacilityRatingUpsertRequest, slug: str):
|
||||
viewer = await require_authenticated_public_user(request)
|
||||
is_new_rating = False
|
||||
|
||||
async with app.state.pool.acquire() as conn:
|
||||
facility = await get_published_facility_by_slug(conn, slug)
|
||||
if not facility:
|
||||
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet.")
|
||||
|
||||
existing_rating = await conn.fetchrow(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM facility_ratings
|
||||
WHERE facility_id = $1 AND user_id = $2
|
||||
LIMIT 1
|
||||
""",
|
||||
int(facility["id"]),
|
||||
int(viewer["id"]),
|
||||
)
|
||||
is_new_rating = existing_rating is None
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO facility_ratings (
|
||||
|
|
@ -3426,6 +3835,33 @@ async def upsert_facility_rating(request: Request, payload: FacilityRatingUpsert
|
|||
int(viewer["id"]),
|
||||
)
|
||||
|
||||
try:
|
||||
facility_url = f"{build_public_base_url(request)}/golfbaner/{facility['slug']}"
|
||||
summary = response_payload["summary"]
|
||||
overall_rating = round(
|
||||
(payload.quality_rating + payload.conditions_rating + payload.hospitality_rating) / 3,
|
||||
1,
|
||||
)
|
||||
await send_facility_rating_notification_email(
|
||||
facility_name=str(facility["name"] or facility["slug"]),
|
||||
facility_url=facility_url,
|
||||
reviewer_name=str(viewer.get("display_name") or viewer.get("full_name") or "TeeOff-leser"),
|
||||
reviewer_email=(str(viewer.get("email")).strip() if viewer.get("email") else None),
|
||||
quality_rating=payload.quality_rating,
|
||||
conditions_rating=payload.conditions_rating,
|
||||
hospitality_rating=payload.hospitality_rating,
|
||||
overall_rating=overall_rating,
|
||||
rating_count=int(summary["rating_count"] or 0),
|
||||
quality_average=summary["quality_average"],
|
||||
conditions_average=summary["conditions_average"],
|
||||
hospitality_average=summary["hospitality_average"],
|
||||
overall_average=summary["overall_average"],
|
||||
is_new_rating=is_new_rating,
|
||||
ip_hash=hash_request_ip(request),
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"Kunne ikke sende vurderingsvarsel: {exc}")
|
||||
|
||||
return {
|
||||
"detail": "Vurderingen er lagret.",
|
||||
"viewer": viewer,
|
||||
|
|
@ -3901,6 +4337,38 @@ async def get_place_page(slug: str, response: Response):
|
|||
return payload
|
||||
|
||||
|
||||
@app.get("/api/site-pages/{page_key}")
|
||||
async def get_public_site_page(page_key: str, response: Response):
|
||||
normalized_key = str(page_key or "").strip().lower()
|
||||
if normalized_key not in VALID_SITE_PAGE_KEYS:
|
||||
raise HTTPException(status_code=404, detail="Side ikke funnet.")
|
||||
|
||||
site_page_cache: dict[str, tuple[float, Any]] = getattr(app.state, "public_site_page_cache", {})
|
||||
cached_payload = read_public_cache_entry(site_page_cache, normalized_key)
|
||||
if cached_payload is not None:
|
||||
apply_public_cache_headers(response, PUBLIC_SITE_PAGE_CACHE_TTL_SECONDS)
|
||||
return cached_payload
|
||||
|
||||
async with app.state.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM site_pages WHERE page_key = $1",
|
||||
normalized_key,
|
||||
)
|
||||
|
||||
payload = format_site_page_row(row) if row else build_default_site_page_row(normalized_key)
|
||||
if payload is None:
|
||||
raise HTTPException(status_code=404, detail="Side ikke funnet.")
|
||||
|
||||
write_public_cache_entry(
|
||||
site_page_cache,
|
||||
normalized_key,
|
||||
payload,
|
||||
PUBLIC_SITE_PAGE_CACHE_TTL_SECONDS,
|
||||
)
|
||||
apply_public_cache_headers(response, PUBLIC_SITE_PAGE_CACHE_TTL_SECONDS)
|
||||
return payload
|
||||
|
||||
|
||||
VALID_SITE_PAGE_SEO_KEYS = {"golfbaner", "vtg", "medlemskap", "banebesok", "meninger", "simulatorer"}
|
||||
|
||||
|
||||
|
|
@ -4100,6 +4568,76 @@ async def get_admin_place_page(slug: str):
|
|||
return format_place_page_row(row)
|
||||
|
||||
|
||||
@app.get("/api/admin/site-pages/{page_key}")
|
||||
async def get_admin_site_page(page_key: str):
|
||||
normalized_key = str(page_key or "").strip().lower()
|
||||
if normalized_key not in VALID_SITE_PAGE_KEYS:
|
||||
raise HTTPException(status_code=404, detail="Side ikke funnet.")
|
||||
|
||||
async with app.state.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM site_pages WHERE page_key = $1",
|
||||
normalized_key,
|
||||
)
|
||||
|
||||
payload = format_site_page_row(row) if row else build_default_site_page_row(normalized_key)
|
||||
if payload is None:
|
||||
raise HTTPException(status_code=404, detail="Side ikke funnet.")
|
||||
return payload
|
||||
|
||||
|
||||
@app.put("/api/admin/site-pages/{page_key}")
|
||||
async def update_admin_site_page(page_key: str, request: SitePageUpsertRequest):
|
||||
normalized_key = str(page_key or "").strip().lower()
|
||||
if normalized_key not in VALID_SITE_PAGE_KEYS:
|
||||
raise HTTPException(status_code=404, detail="Side ikke funnet.")
|
||||
|
||||
async with app.state.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO site_pages (
|
||||
page_key,
|
||||
eyebrow,
|
||||
title,
|
||||
hero_image_url,
|
||||
intro_html,
|
||||
body_html,
|
||||
meta_title,
|
||||
meta_description
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (page_key) DO UPDATE
|
||||
SET eyebrow = EXCLUDED.eyebrow,
|
||||
title = EXCLUDED.title,
|
||||
hero_image_url = EXCLUDED.hero_image_url,
|
||||
intro_html = EXCLUDED.intro_html,
|
||||
body_html = EXCLUDED.body_html,
|
||||
meta_title = EXCLUDED.meta_title,
|
||||
meta_description = EXCLUDED.meta_description,
|
||||
updated_at = NOW()
|
||||
RETURNING *
|
||||
""",
|
||||
normalized_key,
|
||||
normalize_optional_text(request.eyebrow),
|
||||
normalize_optional_text(request.title),
|
||||
normalize_optional_text(request.hero_image_url),
|
||||
request.intro_html or "",
|
||||
request.body_html or "",
|
||||
normalize_optional_text(request.meta_title),
|
||||
normalize_optional_text(request.meta_description),
|
||||
)
|
||||
|
||||
config = SITE_PAGE_CONFIGS.get(normalized_key) or {}
|
||||
path = str(config.get("path") or "").strip()
|
||||
invalidate_public_api_caches(include_site_pages=True)
|
||||
if path:
|
||||
schedule_indexnow_submission(
|
||||
collect_page_indexnow_urls([path]),
|
||||
reason="admin site page upsert",
|
||||
)
|
||||
return format_site_page_row(row)
|
||||
|
||||
|
||||
@app.put("/api/admin/place-pages/{slug}")
|
||||
async def update_admin_place_page(slug: str, request: PlacePageUpsertRequest):
|
||||
normalized_slug = str(slug or "").strip().lower()
|
||||
|
|
|
|||
|
|
@ -1503,6 +1503,9 @@ export default function AdminDashboard() {
|
|||
</div>
|
||||
<div className="space-y-2 mt-6">
|
||||
<div className="text-[8px] text-gray-500 font-bold uppercase tracking-widest pl-4 mb-2 opacity-50">Innhold</div>
|
||||
<Link href="/admin/sider" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Sider">
|
||||
{isSidebarCollapsed ? 'Si' : 'Sider'}
|
||||
</Link>
|
||||
<Link href="/admin/steder" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Steder">
|
||||
{isSidebarCollapsed ? 'S' : 'Steder'}
|
||||
</Link>
|
||||
|
|
@ -1555,6 +1558,12 @@ export default function AdminDashboard() {
|
|||
>
|
||||
Nytt anlegg
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/sider"
|
||||
className="btn btn-md btn-secondary"
|
||||
>
|
||||
Sider
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/steder"
|
||||
className="btn btn-md btn-secondary"
|
||||
|
|
|
|||
960
frontend/src/app/admin/sider/page.tsx
Normal file
960
frontend/src/app/admin/sider/page.tsx
Normal file
|
|
@ -0,0 +1,960 @@
|
|||
"use client";
|
||||
|
||||
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import AdminMobileMenu from "@/components/AdminMobileMenu";
|
||||
import TiptapHtmlEditor from "@/components/TiptapHtmlEditor";
|
||||
import { adminFetch } from "@/config/adminFetch";
|
||||
import { API_URL } from "@/config/constants";
|
||||
import SeoFieldset from "@/components/admin/SeoFieldset";
|
||||
|
||||
type SitePageSeoResponse = {
|
||||
page_key: string;
|
||||
meta_title?: string | null;
|
||||
meta_description?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
type SitePageResponse = {
|
||||
page_key: string;
|
||||
eyebrow?: string | null;
|
||||
title?: string | null;
|
||||
hero_image_url?: string | null;
|
||||
intro_html?: string | null;
|
||||
body_html?: string | null;
|
||||
meta_title?: string | null;
|
||||
meta_description?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
type UploadImageEntry = {
|
||||
url: string;
|
||||
folder: "articles" | "facilities";
|
||||
modified_at?: string | null;
|
||||
};
|
||||
|
||||
type FacilityImageSource = {
|
||||
slug?: string;
|
||||
name?: string;
|
||||
image_url?: string | null;
|
||||
front_image_url?: string | null;
|
||||
gallery?: unknown;
|
||||
};
|
||||
|
||||
type ArticleImageSource = {
|
||||
id: number;
|
||||
title?: string | null;
|
||||
section?: "banebesok" | "meninger";
|
||||
slug?: string;
|
||||
hero_images?: Array<{
|
||||
src?: string;
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
}>;
|
||||
media_gallery?: Array<{
|
||||
type?: "image" | "video";
|
||||
src?: string;
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
}>;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
type ImageLibraryEntry = {
|
||||
url: string;
|
||||
source: "uploads" | "facility" | "article";
|
||||
title: string;
|
||||
detail: string;
|
||||
modified_at?: string | null;
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_KEY = "golfbaner";
|
||||
const DEFAULT_SITE_PAGE_KEY = "turneringer";
|
||||
const DEFAULT_IMAGE_LIBRARY_SOURCE = "all";
|
||||
|
||||
const SITE_PAGE_OPTIONS = [
|
||||
{ key: "golfbaner", label: "/golfbaner" },
|
||||
{ key: "vtg", label: "/vtg" },
|
||||
{ key: "medlemskap", label: "/medlemskap" },
|
||||
{ key: "banebesok", label: "/banebesok" },
|
||||
{ key: "meninger", label: "/meninger" },
|
||||
{ key: "simulatorer", label: "/simulatorer (fremtidig)" },
|
||||
];
|
||||
|
||||
const SITE_PAGE_SEO_SUGGESTIONS: Record<string, { title: string; description: string }> = {
|
||||
golfbaner: {
|
||||
title: "Alle norske golfbaner: Finn din neste runde på gress | TeeOff.no",
|
||||
description:
|
||||
"Planlegg din neste golfrunde. Se komplett oversikt over alle norske golfbaner med oppdatert banestatus, greenfee-priser og kart på TeeOff.no.",
|
||||
},
|
||||
vtg: {
|
||||
title: "Veien til Golf: Finn golfkurs for nybegynnere (VTG) | TeeOff.no",
|
||||
description:
|
||||
"Vil du begynne med golf? Finn komplett oversikt over Veien til Golf-kurs (VTG) i hele Norge. Se kursdatoer, priser og finn din nærmeste klubb på TeeOff.no!",
|
||||
},
|
||||
medlemskap: {
|
||||
title: "Billig golfmedlemskap? Finn og sammenlign priser på alle klubber | TeeOff.no",
|
||||
description:
|
||||
"Hvor er det billigst å være medlem? Sammenlign priser på golfmedlemskap med full spillerett eller rimelige nasjonale alternativ (fjernmedlemskap) i Norge på TeeOff.no.",
|
||||
},
|
||||
banebesok: {
|
||||
title: "Golfreiser og banebesøk: Erfaringer fra norske golfbaner | TeeOff.no",
|
||||
description:
|
||||
"Bli med på tur! Vi besøker Norges vakreste golfbaner og deler ærlige reiseskildringer, spektakulære bilder og nyttige tips. Finn inspirasjon til din neste golfreise her.",
|
||||
},
|
||||
meninger: {
|
||||
title: "Golfblogg: Meninger, humor og skråblikk på golf-Norge | TeeOff.no",
|
||||
description:
|
||||
"Fra frustrasjon over saktespill til gleden over en perfekt drive. Les TeeOffs egne artikler, kommentarer og ærlige skråblikk på livet som golfer i Norge.",
|
||||
},
|
||||
simulatorer: {
|
||||
title: "Golfsimulatorer i Norge",
|
||||
description:
|
||||
"Finn golfsimulatorer, indoor golf og simulatorsteder i Norge når TeeOffs simulatoroversikt lanseres.",
|
||||
},
|
||||
};
|
||||
|
||||
const SITE_TEMPLATE_OPTIONS = [
|
||||
{ key: "turneringer", label: "/turneringer", path: "/turneringer" },
|
||||
{ key: "klubbnummer", label: "/klubbnummer", path: "/klubbnummer" },
|
||||
{ key: "om", label: "/om", path: "/om" },
|
||||
{ key: "personvern-og-cookies", label: "/personvern-og-cookies", path: "/personvern-og-cookies" },
|
||||
{ key: "kontakt", label: "/kontakt", path: "/kontakt" },
|
||||
];
|
||||
|
||||
const SITE_TEMPLATE_SEO_SUGGESTIONS: Record<string, { title: string; description: string }> = {
|
||||
turneringer: {
|
||||
title: "Golfturneringer i Norge: Oversikt og terminlister | TeeOff.no",
|
||||
description:
|
||||
"Vanskelig å finne frem i Golfbox? Vi samler terminlister for Narvesen Tour og regionale golfturneringer i hele Norge på ett sted. Finn din neste turnering her!",
|
||||
},
|
||||
klubbnummer: {
|
||||
title: "NGF Klubbnummer: Oversikt for Golfbox og Gimmie | TeeOff.no",
|
||||
description:
|
||||
"Hvilken klubb tilhører nummeret? Se komplett og sorterbar oversikt over alle norske golfklubber og deres NGF-klubbnummer for bruk i Golfbox og Gimmie.",
|
||||
},
|
||||
om: {
|
||||
title: "FAQ / Om TeeOff",
|
||||
description:
|
||||
"Hvorfor TeeOff finnes, hvilken informasjon som samles om norske golfanlegg, og hvordan siden brukes av både golfspillere og klubber.",
|
||||
},
|
||||
"personvern-og-cookies": {
|
||||
title: "Personvern og cookies",
|
||||
description:
|
||||
"Hvordan TeeOff behandler personopplysninger, bruker cookies og måleverktøy som Matomo.",
|
||||
},
|
||||
kontakt: {
|
||||
title: "Kontakt TeeOff",
|
||||
description:
|
||||
"Kontakt TeeOff for feil i klubbdata, redaksjonelle tips, klubbkontoer og andre henvendelser.",
|
||||
},
|
||||
};
|
||||
|
||||
const formatDateTime = (value: string | null | undefined) => {
|
||||
if (!value) return "Ikke lagret ennå";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "Ukjent";
|
||||
return date.toLocaleString("nb-NO", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeImageUrl = (value: unknown) => {
|
||||
if (typeof value !== "string") return "";
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
const extractGalleryImageUrls = (gallery: unknown): string[] => {
|
||||
if (!Array.isArray(gallery)) return [];
|
||||
|
||||
return gallery.flatMap((entry) => {
|
||||
if (typeof entry === "string") {
|
||||
const url = normalizeImageUrl(entry);
|
||||
return url ? [url] : [];
|
||||
}
|
||||
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidate = entry as Record<string, unknown>;
|
||||
const url =
|
||||
normalizeImageUrl(candidate.src) ||
|
||||
normalizeImageUrl(candidate.url) ||
|
||||
normalizeImageUrl(candidate.image);
|
||||
|
||||
return url ? [url] : [];
|
||||
});
|
||||
};
|
||||
|
||||
export default function AdminPagesPage() {
|
||||
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 [selectedSitePageKey, setSelectedSitePageKey] = useState(DEFAULT_SITE_PAGE_KEY);
|
||||
const [sitePageEyebrow, setSitePageEyebrow] = useState("");
|
||||
const [sitePageTitle, setSitePageTitle] = useState("");
|
||||
const [sitePageHeroImageUrl, setSitePageHeroImageUrl] = useState("");
|
||||
const [sitePageIntroHtml, setSitePageIntroHtml] = useState("");
|
||||
const [sitePageBodyHtml, setSitePageBodyHtml] = useState("");
|
||||
const [sitePageMetaTitle, setSitePageMetaTitle] = useState("");
|
||||
const [sitePageMetaDescription, setSitePageMetaDescription] = useState("");
|
||||
const [sitePageUpdatedAt, setSitePageUpdatedAt] = useState<string | null>(null);
|
||||
const [isLoadingSitePage, setIsLoadingSitePage] = useState(true);
|
||||
const [isSavingSitePage, setIsSavingSitePage] = useState(false);
|
||||
const [isUploadingSitePageHero, setIsUploadingSitePageHero] = useState(false);
|
||||
const [sitePageFeedback, setSitePageFeedback] = useState("");
|
||||
const [imageLibrarySource, setImageLibrarySource] = useState<
|
||||
"all" | "uploads" | "facility" | "article"
|
||||
>(
|
||||
DEFAULT_IMAGE_LIBRARY_SOURCE,
|
||||
);
|
||||
const [imageLibrarySearch, setImageLibrarySearch] = useState("");
|
||||
const [imageLibrary, setImageLibrary] = useState<ImageLibraryEntry[]>([]);
|
||||
const [isLoadingImageLibrary, setIsLoadingImageLibrary] = useState(true);
|
||||
const sitePageHeroInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const sitePageSuggestion = SITE_PAGE_SEO_SUGGESTIONS[selectedPageKey] || {
|
||||
title: "",
|
||||
description: "",
|
||||
};
|
||||
const selectedSiteTemplate = SITE_TEMPLATE_OPTIONS.find((option) => option.key === selectedSitePageKey);
|
||||
const siteTemplateSuggestion = SITE_TEMPLATE_SEO_SUGGESTIONS[selectedSitePageKey] || {
|
||||
title: "",
|
||||
description: "",
|
||||
};
|
||||
const effectiveSitePageMetaTitle = sitePageMetaTitle.trim() || siteTemplateSuggestion.title;
|
||||
const effectiveSitePageMetaDescription =
|
||||
sitePageMetaDescription.trim() || siteTemplateSuggestion.description;
|
||||
const visibleImageLibrary = useMemo(() => {
|
||||
const query = imageLibrarySearch.trim().toLowerCase();
|
||||
|
||||
return imageLibrary.filter((entry) => {
|
||||
if (imageLibrarySource !== "all" && entry.source !== imageLibrarySource) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!query) return true;
|
||||
|
||||
return `${entry.title} ${entry.detail} ${entry.url}`.toLowerCase().includes(query);
|
||||
});
|
||||
}, [imageLibrary, imageLibrarySearch, imageLibrarySource]);
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const loadSitePage = async () => {
|
||||
setIsLoadingSitePage(true);
|
||||
setSitePageFeedback("");
|
||||
|
||||
try {
|
||||
const response = await adminFetch(`${API_URL}/admin/site-pages/${selectedSitePageKey}`, {
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Kunne ikke hente sidemalen.");
|
||||
}
|
||||
|
||||
const data = (await response.json()) as SitePageResponse;
|
||||
setSitePageEyebrow(data.eyebrow || "");
|
||||
setSitePageTitle(data.title || "");
|
||||
setSitePageHeroImageUrl(data.hero_image_url || "");
|
||||
setSitePageIntroHtml(data.intro_html || "");
|
||||
setSitePageBodyHtml(data.body_html || "");
|
||||
setSitePageMetaTitle(data.meta_title || "");
|
||||
setSitePageMetaDescription(data.meta_description || "");
|
||||
setSitePageUpdatedAt(data.updated_at || null);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
setSitePageEyebrow("");
|
||||
setSitePageTitle("");
|
||||
setSitePageHeroImageUrl("");
|
||||
setSitePageIntroHtml("");
|
||||
setSitePageBodyHtml("");
|
||||
setSitePageMetaTitle("");
|
||||
setSitePageMetaDescription("");
|
||||
setSitePageUpdatedAt(null);
|
||||
setSitePageFeedback(error instanceof Error ? error.message : "Kunne ikke hente sidemalen.");
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setIsLoadingSitePage(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSitePage();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [selectedSitePageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const loadImageLibrary = async () => {
|
||||
setIsLoadingImageLibrary(true);
|
||||
|
||||
try {
|
||||
const [uploadsResponse, facilitiesResponse, articlesResponse] = await Promise.all([
|
||||
adminFetch("/api/admin/uploads/images?folder=all&limit=240", {
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
}),
|
||||
adminFetch(`${API_URL}/admin/facilities`, {
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
}),
|
||||
adminFetch(`${API_URL}/admin/articles`, {
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!uploadsResponse.ok || !facilitiesResponse.ok || !articlesResponse.ok) {
|
||||
throw new Error("Kunne ikke hente bildebiblioteket.");
|
||||
}
|
||||
|
||||
const uploadsPayload = (await uploadsResponse.json()) as { images?: UploadImageEntry[] };
|
||||
const facilitiesPayload = (await facilitiesResponse.json()) as FacilityImageSource[];
|
||||
const articlesPayload = (await articlesResponse.json()) as ArticleImageSource[];
|
||||
const byUrl = new Map<string, ImageLibraryEntry>();
|
||||
|
||||
for (const image of Array.isArray(uploadsPayload.images) ? uploadsPayload.images : []) {
|
||||
const url = normalizeImageUrl(image.url);
|
||||
if (!url || byUrl.has(url)) continue;
|
||||
|
||||
byUrl.set(url, {
|
||||
url,
|
||||
source: "uploads",
|
||||
title: url.split("/").pop() || url,
|
||||
detail: image.folder === "facilities" ? "Opplastet anleggsbilde" : "Opplastet artikkelbilde",
|
||||
modified_at: image.modified_at || null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const facility of Array.isArray(facilitiesPayload) ? facilitiesPayload : []) {
|
||||
const facilityName = String(facility?.name || "").trim() || "Uten navn";
|
||||
const candidates: Array<{ url: string; detail: string }> = [];
|
||||
const frontImageUrl = normalizeImageUrl(facility?.front_image_url);
|
||||
const imageUrl = normalizeImageUrl(facility?.image_url);
|
||||
|
||||
if (frontImageUrl) {
|
||||
candidates.push({ url: frontImageUrl, detail: "Anlegg: toppbilde" });
|
||||
}
|
||||
if (imageUrl) {
|
||||
candidates.push({ url: imageUrl, detail: "Anlegg: hovedbilde" });
|
||||
}
|
||||
|
||||
extractGalleryImageUrls(facility?.gallery).forEach((url, index) => {
|
||||
candidates.push({ url, detail: `Anlegg: galleri ${index + 1}` });
|
||||
});
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.url || byUrl.has(candidate.url)) continue;
|
||||
|
||||
byUrl.set(candidate.url, {
|
||||
url: candidate.url,
|
||||
source: "facility",
|
||||
title: facilityName,
|
||||
detail: candidate.detail,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const article of Array.isArray(articlesPayload) ? articlesPayload : []) {
|
||||
const articleTitle = String(article?.title || "").trim() || "Uten tittel";
|
||||
const sectionLabel = article?.section === "meninger" ? "Mening" : "Banebesøk";
|
||||
const candidates: Array<{ url: string; detail: string }> = [];
|
||||
|
||||
(Array.isArray(article?.hero_images) ? article.hero_images : []).forEach((image, index) => {
|
||||
const url = normalizeImageUrl(image?.src);
|
||||
if (!url) return;
|
||||
candidates.push({
|
||||
url,
|
||||
detail: `${sectionLabel}: hero-bilde${index > 0 ? ` ${index + 1}` : ""}`,
|
||||
});
|
||||
});
|
||||
|
||||
(Array.isArray(article?.media_gallery) ? article.media_gallery : []).forEach((media, index) => {
|
||||
if (media?.type !== "image") return;
|
||||
const url = normalizeImageUrl(media?.src);
|
||||
if (!url) return;
|
||||
candidates.push({
|
||||
url,
|
||||
detail: `${sectionLabel}: galleri ${index + 1}`,
|
||||
});
|
||||
});
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.url || byUrl.has(candidate.url)) continue;
|
||||
|
||||
byUrl.set(candidate.url, {
|
||||
url: candidate.url,
|
||||
source: "article",
|
||||
title: articleTitle,
|
||||
detail: candidate.detail,
|
||||
modified_at: article.updated_at || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const nextImageLibrary = Array.from(byUrl.values()).sort((left, right) => {
|
||||
const leftTime = left.modified_at ? Date.parse(left.modified_at) : 0;
|
||||
const rightTime = right.modified_at ? Date.parse(right.modified_at) : 0;
|
||||
if (leftTime !== rightTime) return rightTime - leftTime;
|
||||
return left.title.localeCompare(right.title, "nb");
|
||||
});
|
||||
|
||||
if (!controller.signal.aborted) {
|
||||
setImageLibrary(nextImageLibrary);
|
||||
}
|
||||
} catch {
|
||||
if (!controller.signal.aborted) {
|
||||
setImageLibrary([]);
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setIsLoadingImageLibrary(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadImageLibrary();
|
||||
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadSitePageImage = async (file: File) => {
|
||||
const payload = new FormData();
|
||||
payload.append("file", file);
|
||||
|
||||
const response = await adminFetch("/api/admin/uploads/images", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Kunne ikke laste opp bildet." }));
|
||||
throw new Error(error.detail || "Kunne ikke laste opp bildet.");
|
||||
}
|
||||
|
||||
const result = (await response.json()) as { url?: string };
|
||||
if (!result.url) {
|
||||
throw new Error("Uploaden returnerte ingen bildeadresse.");
|
||||
}
|
||||
|
||||
return result.url;
|
||||
};
|
||||
|
||||
const handleSitePageHeroUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
|
||||
setIsUploadingSitePageHero(true);
|
||||
setSitePageFeedback("");
|
||||
|
||||
try {
|
||||
const url = await uploadSitePageImage(file);
|
||||
setSitePageHeroImageUrl(url);
|
||||
setSitePageFeedback("Toppbildet ble lastet opp. Husk å lagre sidemalen.");
|
||||
} catch (error) {
|
||||
setSitePageFeedback(error instanceof Error ? error.message : "Kunne ikke laste opp bildet.");
|
||||
} finally {
|
||||
setIsUploadingSitePageHero(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSitePage = async () => {
|
||||
setIsSavingSitePage(true);
|
||||
setSitePageFeedback("");
|
||||
|
||||
try {
|
||||
const response = await adminFetch(`${API_URL}/admin/site-pages/${selectedSitePageKey}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
eyebrow: sitePageEyebrow,
|
||||
title: sitePageTitle,
|
||||
hero_image_url: sitePageHeroImageUrl,
|
||||
intro_html: sitePageIntroHtml,
|
||||
body_html: sitePageBodyHtml,
|
||||
meta_title: sitePageMetaTitle,
|
||||
meta_description: sitePageMetaDescription,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Kunne ikke lagre sidemalen.");
|
||||
}
|
||||
|
||||
const data = (await response.json()) as SitePageResponse;
|
||||
setSitePageUpdatedAt(data.updated_at || null);
|
||||
setSitePageFeedback("Lagret.");
|
||||
} catch (error) {
|
||||
setSitePageFeedback(error instanceof Error ? error.message : "Kunne ikke lagre sidemalen.");
|
||||
} finally {
|
||||
setIsSavingSitePage(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto min-h-screen max-w-[1100px] bg-white p-4 md:p-8">
|
||||
<AdminMobileMenu />
|
||||
|
||||
<div className="mb-10 border-b border-gray-200 pb-6">
|
||||
<Link href="/admin" className="mb-2 block text-sm font-bold text-gray-500 hover:text-[#8bc34a]">
|
||||
← Tilbake til oversikten
|
||||
</Link>
|
||||
<div className="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]">Sider</p>
|
||||
<h1 className="mt-2 text-4xl font-black tracking-tight text-[#11280f]">Samlesider og faste sidemaler</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-[#536256]">
|
||||
Her redigerer du SEO for samlesidene og innholdet på de faste sidene som <code>/turneringer</code>, <code>/klubbnummer</code>, <code>/om</code>, <code>/kontakt</code> og <code>/personvern-og-cookies</code>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Link href="/admin/steder" className="btn btn-md btn-secondary">
|
||||
Gå til sted-sider
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="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 samlesider</h2>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-[#536256]">
|
||||
Overstyr 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>
|
||||
</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]">Sidemaler</p>
|
||||
<h2 className="mt-2 text-3xl font-black tracking-tight text-[#11280f]">Faste sider med hero og HTML-innhold</h2>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-[#536256]">
|
||||
Disse sidene bruker samme mal med toppbilde, flytende overskrift og redigerbart HTML-innhold. Her vedlikeholder du blant annet <code>/turneringer</code>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
href={selectedSiteTemplate?.path || "/turneringer"}
|
||||
target="_blank"
|
||||
className="btn btn-md btn-secondary"
|
||||
>
|
||||
Åpne siden
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveSitePage}
|
||||
disabled={isLoadingSitePage || isSavingSitePage}
|
||||
className="btn btn-md btn-primary disabled:opacity-50"
|
||||
>
|
||||
{isSavingSitePage ? "Lagrer..." : "Lagre sidemal"}
|
||||
</button>
|
||||
</div>
|
||||
</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={selectedSitePageKey}
|
||||
onChange={(event) => setSelectedSitePageKey(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_TEMPLATE_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 sidemal</p>
|
||||
<p className="mt-2 text-xl font-black text-[#112015]">{selectedSiteTemplate?.label || selectedSitePageKey}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[#536256]">
|
||||
Offentlig URL: <span className="font-bold text-[#112015]">{selectedSiteTemplate?.path || "/"}</span>
|
||||
</p>
|
||||
<div className="mt-4 space-y-3 rounded-2xl border border-[#112015]/8 bg-[#f7faf4] p-4">
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Gjeldende meta title</p>
|
||||
<p className="mt-1 text-sm font-semibold leading-6 text-[#112015]">
|
||||
{effectiveSitePageMetaTitle || "Ikke tilgjengelig"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Gjeldende meta description</p>
|
||||
<p className="mt-1 text-sm leading-6 text-[#536256]">
|
||||
{effectiveSitePageMetaDescription || "Ikke tilgjengelig"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sitePageHeroImageUrl ? (
|
||||
<div className="mt-4 overflow-hidden rounded-[1.5rem] border border-[#112015]/8 bg-[#112015]">
|
||||
<img
|
||||
src={sitePageHeroImageUrl}
|
||||
alt={sitePageTitle || selectedSiteTemplate?.label || "Toppbilde"}
|
||||
className="aspect-[16/10] w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="mt-4 text-xs font-bold uppercase tracking-widest text-gray-500">
|
||||
Sist lagret: {formatDateTime(sitePageUpdatedAt)}
|
||||
</p>
|
||||
{sitePageFeedback ? (
|
||||
<p className="mt-3 text-sm font-bold text-[#11280f]">{sitePageFeedback}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isLoadingSitePage ? (
|
||||
<div className="rounded-[1.75rem] border border-[#112015]/8 bg-white px-5 py-12 text-sm font-bold text-[#536256]">
|
||||
Laster sidemal...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<input
|
||||
ref={sitePageHeroInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleSitePageHeroUpload}
|
||||
/>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Eyebrow</span>
|
||||
<input
|
||||
value={sitePageEyebrow}
|
||||
onChange={(event) => setSitePageEyebrow(event.target.value)}
|
||||
className="rounded-[1.1rem] border border-[#112015]/10 bg-white px-4 py-3 text-base font-bold text-[#112015] outline-none focus:border-[#8BC34A]"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Synlig tittel</span>
|
||||
<input
|
||||
value={sitePageTitle}
|
||||
onChange={(event) => setSitePageTitle(event.target.value)}
|
||||
className="rounded-[1.1rem] border border-[#112015]/10 bg-white px-4 py-3 text-base font-bold text-[#112015] outline-none focus:border-[#8BC34A]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.5rem] border border-[#112015]/8 bg-white p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Toppbilde</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[#536256]">
|
||||
Dette bildet fyller toppen av siden og får overskriften flytende over seg.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sitePageHeroInputRef.current?.click()}
|
||||
disabled={isUploadingSitePageHero}
|
||||
className="btn btn-sm btn-secondary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isUploadingSitePageHero ? "Laster opp..." : "Last opp bilde"}
|
||||
</button>
|
||||
</div>
|
||||
<label className="mt-4 flex flex-col gap-2">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Bilde-URL</span>
|
||||
<input
|
||||
value={sitePageHeroImageUrl}
|
||||
onChange={(event) => setSitePageHeroImageUrl(event.target.value)}
|
||||
className="rounded-[1.1rem] border border-[#112015]/10 bg-white px-4 py-3 text-base text-[#112015] outline-none focus:border-[#8BC34A]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Eksisterende bilder</p>
|
||||
<p className="mt-1 text-sm leading-6 text-[#536256]">
|
||||
Velg blant eksisterende uploads, anleggsbilder og artikkelbilder som allerede brukes på TeeOff.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<select
|
||||
value={imageLibrarySource}
|
||||
onChange={(event) =>
|
||||
setImageLibrarySource(
|
||||
event.target.value as "all" | "uploads" | "facility" | "article",
|
||||
)
|
||||
}
|
||||
className="rounded-[1rem] border border-[#112015]/10 bg-white px-4 py-3 text-sm font-bold text-[#112015] outline-none focus:border-[#8BC34A]"
|
||||
>
|
||||
<option value="all">Alle kilder</option>
|
||||
<option value="uploads">Uploads</option>
|
||||
<option value="facility">Anleggsbilder</option>
|
||||
<option value="article">Artikkelbilder</option>
|
||||
</select>
|
||||
<input
|
||||
value={imageLibrarySearch}
|
||||
onChange={(event) => setImageLibrarySearch(event.target.value)}
|
||||
placeholder="Søk på navn, kilde eller filnavn"
|
||||
className="min-w-[14rem] rounded-[1rem] border border-[#112015]/10 bg-white px-4 py-3 text-sm text-[#112015] outline-none focus:border-[#8BC34A]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoadingImageLibrary ? (
|
||||
<div className="rounded-[1.25rem] border border-[#112015]/8 bg-[#F7F9F2] px-4 py-5 text-sm font-bold text-[#536256]">
|
||||
Laster bilder...
|
||||
</div>
|
||||
) : visibleImageLibrary.length === 0 ? (
|
||||
<div className="rounded-[1.25rem] border border-[#112015]/8 bg-[#F7F9F2] px-4 py-5 text-sm font-bold text-[#536256]">
|
||||
Fant ingen bilder som matcher dette filteret.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
|
||||
{visibleImageLibrary.map((image) => {
|
||||
const isSelected = sitePageHeroImageUrl === image.url;
|
||||
return (
|
||||
<button
|
||||
key={`${image.source}:${image.url}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSitePageHeroImageUrl(image.url);
|
||||
setSitePageFeedback("Eksisterende bilde valgt. Husk å lagre sidemalen.");
|
||||
}}
|
||||
className={`overflow-hidden rounded-[1.25rem] border text-left transition ${
|
||||
isSelected
|
||||
? "border-[#8BC34A] ring-2 ring-[#8BC34A]/30"
|
||||
: "border-[#112015]/8 hover:border-[#8BC34A]/60"
|
||||
}`}
|
||||
>
|
||||
<div className="aspect-[4/3] bg-[#112015]">
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-white px-3 py-3">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">
|
||||
{image.source === "facility"
|
||||
? "Anlegg"
|
||||
: image.source === "article"
|
||||
? "Artikkel"
|
||||
: "Upload"}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs font-bold text-[#112015]">
|
||||
{image.title}
|
||||
</p>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-[#536256]">
|
||||
{image.detail}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SeoFieldset
|
||||
titleValue={sitePageMetaTitle}
|
||||
onTitleChange={setSitePageMetaTitle}
|
||||
descriptionValue={sitePageMetaDescription}
|
||||
onDescriptionChange={setSitePageMetaDescription}
|
||||
suggestedTitle={siteTemplateSuggestion.title}
|
||||
suggestedDescription={siteTemplateSuggestion.description}
|
||||
titlePlaceholder="Tomt felt bruker standardtittelen for siden."
|
||||
descriptionPlaceholder="Tomt felt bruker standardbeskrivelsen for siden."
|
||||
helperText="La felt stå tomme hvis du vil bruke standardverdiene. Fyll dem ut hvis denne siden trenger egen SEO-overstyring."
|
||||
/>
|
||||
|
||||
<div className="rounded-[1.5rem] border border-[#112015]/8 bg-[#FCFDF9] p-4 sm:p-5">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Intro over innholdet</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[#536256]">
|
||||
Dette vises over selve innholdskortet, oppå toppbildet sammen med overskriften.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<TiptapHtmlEditor
|
||||
value={sitePageIntroHtml}
|
||||
onChange={setSitePageIntroHtml}
|
||||
placeholder="Kort intro som vises oppå hero-bildet."
|
||||
onUploadImage={uploadSitePageImage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.5rem] border border-[#112015]/8 bg-[#FCFDF9] p-4 sm:p-5">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Hovedinnhold</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[#536256]">
|
||||
HTML-innholdet som vises under toppbildet. Dette er feltet du bruker for å vedlikeholde for eksempel <code>/turneringer</code>.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<TiptapHtmlEditor
|
||||
value={sitePageBodyHtml}
|
||||
onChange={setSitePageBodyHtml}
|
||||
placeholder="Skriv sideinnholdet her..."
|
||||
onUploadImage={uploadSitePageImage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -28,59 +28,7 @@ type PlacePageResponse = {
|
|||
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: "banebesok", label: "/banebesok" },
|
||||
{ key: "meninger", label: "/meninger" },
|
||||
{ key: "simulatorer", label: "/simulatorer (fremtidig)" },
|
||||
];
|
||||
|
||||
const SITE_PAGE_SEO_SUGGESTIONS: Record<
|
||||
string,
|
||||
{ title: string; description: string }
|
||||
> = {
|
||||
golfbaner: {
|
||||
title: "Alle norske golfbaner: Finn din neste runde på gress | TeeOff.no",
|
||||
description:
|
||||
"Planlegg din neste golfrunde. Se komplett oversikt over alle norske golfbaner med oppdatert banestatus, greenfee-priser og kart på TeeOff.no.",
|
||||
},
|
||||
vtg: {
|
||||
title: "Veien til Golf: Finn golfkurs for nybegynnere (VTG) | TeeOff.no",
|
||||
description:
|
||||
"Vil du begynne med golf? Finn komplett oversikt over Veien til Golf-kurs (VTG) i hele Norge. Se kursdatoer, priser og finn din nærmeste klubb på TeeOff.no!",
|
||||
},
|
||||
medlemskap: {
|
||||
title: "Billig golfmedlemskap? Finn og sammenlign priser på alle klubber | TeeOff.no",
|
||||
description:
|
||||
"Hvor er det billigst å være medlem? Sammenlign priser på golfmedlemskap med full spillerett eller rimelige nasjonale alternativ (fjernmedlemskap) i Norge på TeeOff.no.",
|
||||
},
|
||||
banebesok: {
|
||||
title: "Golfreiser og banebesøk: Erfaringer fra norske golfbaner | TeeOff.no",
|
||||
description:
|
||||
"Bli med på tur! Vi besøker Norges vakreste golfbaner og deler ærlige reiseskildringer, spektakulære bilder og nyttige tips. Finn inspirasjon til din neste golfreise her.",
|
||||
},
|
||||
meninger: {
|
||||
title: "Golfblogg: Meninger, humor og skråblikk på golf-Norge | TeeOff.no",
|
||||
description:
|
||||
"Fra frustrasjon over saktespill til gleden over en perfekt drive. Les TeeOffs egne artikler, kommentarer og ærlige skråblikk på livet som golfer i Norge.",
|
||||
},
|
||||
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å";
|
||||
|
|
@ -105,13 +53,6 @@ export default function AdminPlacePagesPage() {
|
|||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [placeFacilities, setPlaceFacilities] = useState<FacilityRecord[]>([]);
|
||||
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";
|
||||
|
|
@ -134,10 +75,6 @@ export default function AdminPlacePagesPage() {
|
|||
: "";
|
||||
const effectivePlaceMetaTitle = metaTitle.trim() || placeSuggestedTitle;
|
||||
const effectivePlaceMetaDescription = metaDescription.trim() || placeSuggestedDescription;
|
||||
const sitePageSuggestion = SITE_PAGE_SEO_SUGGESTIONS[selectedPageKey] || {
|
||||
title: "",
|
||||
description: "",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
|
@ -210,45 +147,6 @@ 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("");
|
||||
|
|
@ -279,35 +177,6 @@ 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 />
|
||||
|
|
@ -321,10 +190,13 @@ export default function AdminPlacePagesPage() {
|
|||
<p className="text-xs font-black uppercase tracking-[0.2em] text-[#7ca982]">Sted-sider</p>
|
||||
<h1 className="mt-2 text-4xl font-black tracking-tight text-[#11280f]">Rediger innhold over faktaboksen</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-[#536256]">
|
||||
Dette innholdet vises over nøkkeltall-/faktaboksen på `/sted/[slug]`.
|
||||
Dette innholdet vises over nøkkeltall-/faktaboksen på <code>/sted/[slug]</code>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Link href="/admin/sider" className="btn btn-md btn-secondary">
|
||||
Sider
|
||||
</Link>
|
||||
<Link href={`/sted/${selectedSlug}`} target="_blank" className="btn btn-md btn-secondary">
|
||||
Åpne sted-siden
|
||||
</Link>
|
||||
|
|
@ -418,76 +290,6 @@ export default function AdminPlacePagesPage() {
|
|||
</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 samlesider</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>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import sharp from "sharp";
|
||||
|
|
@ -17,11 +17,27 @@ const ALLOWED_MIME_TYPES = new Set([
|
|||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type UploadFolder = "articles" | "facilities";
|
||||
|
||||
type UploadImageEntry = {
|
||||
url: string;
|
||||
folder: UploadFolder;
|
||||
modified_at: string | null;
|
||||
};
|
||||
|
||||
function resolveUploadFolder(value: FormDataEntryValue | null) {
|
||||
const normalized = String(value || "articles").trim().toLowerCase();
|
||||
return normalized === "facilities" ? "facilities" : "articles";
|
||||
}
|
||||
|
||||
function resolveQueryUploadFolder(value: string | null) {
|
||||
const normalized = String(value || "all").trim().toLowerCase();
|
||||
if (normalized === "articles" || normalized === "facilities") {
|
||||
return normalized as UploadFolder;
|
||||
}
|
||||
return "all";
|
||||
}
|
||||
|
||||
function sanitizeFilenameStem(filename: string) {
|
||||
const stem = path.parse(filename).name;
|
||||
const normalized = stem
|
||||
|
|
@ -34,6 +50,80 @@ function sanitizeFilenameStem(filename: string) {
|
|||
return normalized || "image";
|
||||
}
|
||||
|
||||
async function collectUploadsFromFolder(folder: UploadFolder): Promise<UploadImageEntry[]> {
|
||||
const directory = path.join(process.cwd(), "public", "uploads", folder);
|
||||
|
||||
const walk = async (currentDirectory: string): Promise<UploadImageEntry[]> => {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(currentDirectory, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const collected = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const absolutePath = path.join(currentDirectory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return await walk(absolutePath);
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const extension = path.extname(entry.name).toLowerCase();
|
||||
if (![".avif", ".jpg", ".jpeg", ".png", ".webp", ".gif", ".tiff"].includes(extension)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const relativePath = path.relative(path.join(process.cwd(), "public"), absolutePath);
|
||||
const stats = await stat(absolutePath).catch(() => null);
|
||||
return [
|
||||
{
|
||||
url: `/${relativePath.replaceAll(path.sep, "/")}`,
|
||||
folder,
|
||||
modified_at: stats?.mtime ? stats.mtime.toISOString() : null,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
return collected.flat();
|
||||
};
|
||||
|
||||
return await walk(directory);
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const cookieStore = await cookies();
|
||||
if (!cookieStore.get("admin_session")) {
|
||||
return NextResponse.json({ detail: "Admin-innlogging kreves" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const folder = resolveQueryUploadFolder(searchParams.get("folder"));
|
||||
const limitRaw = Number(searchParams.get("limit") || "120");
|
||||
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(300, Math.trunc(limitRaw))) : 120;
|
||||
|
||||
const folders: UploadFolder[] =
|
||||
folder === "all" ? ["articles", "facilities"] : [folder];
|
||||
|
||||
const images = (
|
||||
await Promise.all(folders.map((currentFolder) => collectUploadsFromFolder(currentFolder)))
|
||||
)
|
||||
.flat()
|
||||
.sort((left, right) => {
|
||||
const leftTime = left.modified_at ? Date.parse(left.modified_at) : 0;
|
||||
const rightTime = right.modified_at ? Date.parse(right.modified_at) : 0;
|
||||
return rightTime - leftTime;
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
return NextResponse.json({
|
||||
images,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const cookieStore = await cookies();
|
||||
if (!cookieStore.get("admin_session")) {
|
||||
|
|
|
|||
|
|
@ -1,32 +1,47 @@
|
|||
import type { FacilityRecord } from "@/app/facilityData";
|
||||
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
||||
import InfoPageShell from "@/components/InfoPageShell";
|
||||
import ClubNumbersTable from "@/components/ClubNumbersTable";
|
||||
import SitePageTemplate from "@/components/SitePageTemplate";
|
||||
import { fetchSitePage } from "@/app/sitePages";
|
||||
import {
|
||||
createBreadcrumbJsonLd,
|
||||
createCollectionPageJsonLd,
|
||||
createPageMetadata,
|
||||
} from "@/app/seo";
|
||||
|
||||
const pageTitle = "NGF Klubbnummer: Oversikt for Golfbox og Gimmie | TeeOff.no";
|
||||
const pageDescription =
|
||||
const pageKey = "klubbnummer";
|
||||
const fallbackVisibleTitle = "Klubbnummer i Golfbox";
|
||||
const fallbackIntro =
|
||||
"<p>I booking-vinduet i Golfbox (eller Gimmie) er det ofte vanskelig å se hvilken klubb spillere er fra. Om du ønsker kan du da bruke denne tabellen som oppslagsverk.</p>";
|
||||
const fallbackMetaTitle = "NGF Klubbnummer: Oversikt for Golfbox og Gimmie | TeeOff.no";
|
||||
const fallbackMetaDescription =
|
||||
"Hvilken klubb tilhører nummeret? Se komplett og sorterbar oversikt over alle norske golfklubber og deres NGF-klubbnummer for bruk i Golfbox og Gimmie.";
|
||||
|
||||
export const metadata = createPageMetadata({
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
path: "/klubbnummer",
|
||||
});
|
||||
|
||||
export const revalidate = 3600;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const page = await fetchSitePage(pageKey);
|
||||
return createPageMetadata({
|
||||
title: page?.meta_title?.trim() || fallbackMetaTitle,
|
||||
description: page?.meta_description?.trim() || fallbackMetaDescription,
|
||||
path: "/klubbnummer",
|
||||
image: page?.hero_image_url,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function ClubNumbersPage() {
|
||||
const facilities = await fetchPublicFacilities<FacilityRecord>("clubnumbers", revalidate);
|
||||
const page = await fetchSitePage(pageKey);
|
||||
const title = page?.title?.trim() || fallbackVisibleTitle;
|
||||
const introHtml = page?.intro_html?.trim() || fallbackIntro;
|
||||
const bodyHtml = page?.body_html?.trim() || "";
|
||||
const metaTitle = page?.meta_title?.trim() || fallbackMetaTitle;
|
||||
const metaDescription = page?.meta_description?.trim() || fallbackMetaDescription;
|
||||
|
||||
const collectionJsonLd = createCollectionPageJsonLd({
|
||||
name: pageTitle,
|
||||
description: pageDescription,
|
||||
name: metaTitle,
|
||||
description: metaDescription,
|
||||
path: "/klubbnummer",
|
||||
});
|
||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||
|
|
@ -44,13 +59,15 @@ export default async function ClubNumbersPage() {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<InfoPageShell
|
||||
eyebrow="Klubbnummer"
|
||||
title="Klubbnummer i Golfbox"
|
||||
intro="I booking-vinduet i Golfbox (eller Gimmie) er det ofte vanskelig å se hvilken klubb spillere er fra. Om du ønsker kan du da bruke denne tabellen som oppslagsverk."
|
||||
<SitePageTemplate
|
||||
eyebrow={page?.eyebrow?.trim() || "Klubbnummer"}
|
||||
title={title}
|
||||
introHtml={introHtml}
|
||||
heroImageUrl={page?.hero_image_url}
|
||||
bodyHtml={bodyHtml}
|
||||
>
|
||||
<ClubNumbersTable facilities={facilities} />
|
||||
</InfoPageShell>
|
||||
</SitePageTemplate>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
import ContactForm from "@/components/ContactForm";
|
||||
import InfoPageShell from "@/components/InfoPageShell";
|
||||
import SitePageTemplate from "@/components/SitePageTemplate";
|
||||
import { fetchSitePage } from "@/app/sitePages";
|
||||
import {
|
||||
createBreadcrumbJsonLd,
|
||||
createCollectionPageJsonLd,
|
||||
createPageMetadata,
|
||||
} from "@/app/seo";
|
||||
|
||||
const pageTitle = "Kontakt TeeOff";
|
||||
const pageDescription =
|
||||
const pageKey = "kontakt";
|
||||
const fallbackVisibleTitle = "Kontakt TeeOff";
|
||||
const fallbackIntro =
|
||||
"<p>Bruk skjemaet hvis du vil melde fra om feil i klubbdata, tips om artikler, spørsmål om administrasjonstilgang eller andre henvendelser.</p>";
|
||||
const fallbackMetaTitle = "Kontakt TeeOff";
|
||||
const fallbackMetaDescription =
|
||||
"Kontakt TeeOff for feil i klubbdata, redaksjonelle tips, klubbkontoer og andre henvendelser.";
|
||||
|
||||
const contactCards = [
|
||||
|
|
@ -28,23 +33,35 @@ const contactCards = [
|
|||
},
|
||||
];
|
||||
|
||||
export const metadata = createPageMetadata({
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const page = await fetchSitePage(pageKey);
|
||||
return createPageMetadata({
|
||||
title: page?.meta_title?.trim() || fallbackMetaTitle,
|
||||
description: page?.meta_description?.trim() || fallbackMetaDescription,
|
||||
path: "/kontakt",
|
||||
image: page?.hero_image_url,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function ContactPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Promise<{ topic?: string; message?: string }>;
|
||||
}) {
|
||||
const page = await fetchSitePage(pageKey);
|
||||
const params = (await searchParams) || {};
|
||||
const initialTopic = typeof params.topic === "string" ? params.topic : undefined;
|
||||
const initialMessage = typeof params.message === "string" ? params.message : undefined;
|
||||
const title = page?.title?.trim() || fallbackVisibleTitle;
|
||||
const introHtml = page?.intro_html?.trim() || fallbackIntro;
|
||||
const bodyHtml = page?.body_html?.trim() || "";
|
||||
const metaTitle = page?.meta_title?.trim() || fallbackMetaTitle;
|
||||
const metaDescription = page?.meta_description?.trim() || fallbackMetaDescription;
|
||||
const collectionJsonLd = createCollectionPageJsonLd({
|
||||
name: pageTitle,
|
||||
description: pageDescription,
|
||||
name: metaTitle,
|
||||
description: metaDescription,
|
||||
path: "/kontakt",
|
||||
});
|
||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||
|
|
@ -62,10 +79,12 @@ export default async function ContactPage({
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<InfoPageShell
|
||||
eyebrow="Kontakt"
|
||||
title="Kontakt TeeOff"
|
||||
intro="Bruk skjemaet hvis du vil melde fra om feil i klubbdata, tips om artikler, spørsmål om administrasjonstilgang eller andre henvendelser."
|
||||
<SitePageTemplate
|
||||
eyebrow={page?.eyebrow?.trim() || "Kontakt"}
|
||||
title={title}
|
||||
introHtml={introHtml}
|
||||
heroImageUrl={page?.hero_image_url}
|
||||
bodyHtml={bodyHtml}
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-[0.9fr,1.1fr]">
|
||||
<div className="space-y-6">
|
||||
|
|
@ -111,7 +130,7 @@ export default async function ContactPage({
|
|||
|
||||
<ContactForm initialTopic={initialTopic} initialMessage={initialMessage} />
|
||||
</div>
|
||||
</InfoPageShell>
|
||||
</SitePageTemplate>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,107 +1,41 @@
|
|||
import Link from "next/link";
|
||||
import InfoPageShell from "@/components/InfoPageShell";
|
||||
import SitePageTemplate from "@/components/SitePageTemplate";
|
||||
import { fetchSitePage } from "@/app/sitePages";
|
||||
import {
|
||||
createBreadcrumbJsonLd,
|
||||
createCollectionPageJsonLd,
|
||||
createPageMetadata,
|
||||
} from "@/app/seo";
|
||||
|
||||
const pageTitle = "FAQ / Om TeeOff";
|
||||
const pageDescription =
|
||||
const pageKey = "om";
|
||||
const fallbackVisibleTitle = "Hva TeeOff er, og hvorfor siden finnes";
|
||||
const fallbackIntro =
|
||||
"<p>Kortversjonen er fortsatt den samme: TeeOff bruker mye tid på å samle, rydde og presentere norske golfanlegg på en ordentlig måte, slik at det blir lettere å finne ut hvor man faktisk har lyst til å spille.</p>";
|
||||
const fallbackMetaTitle = "FAQ / Om TeeOff";
|
||||
const fallbackMetaDescription =
|
||||
"Hvorfor TeeOff finnes, hvilken informasjon som samles om norske golfanlegg, og hvordan siden brukes av både golfspillere og klubber.";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: "Hvorfor dette nettstedet?",
|
||||
body: [
|
||||
"TeeOff startet i 2015 fordi det var unødvendig vanskelig å finne ut hvilke golfbaner som ligger hvor, hva de tilbyr, og hvilken bane som faktisk passer til turen du vurderer å ta. Ambisjonen var enkel: samle norske golfanlegg på ett sted og gjøre dem lettere å finne, forstå og sammenligne.",
|
||||
"Kjernen er den samme i dag: kjærlighet til golf, lysten til å vise hvor bra det er å spille i Norge, og behovet for en oversikt som faktisk er nyttig for greenfeespillere, klubbfolk og andre som vil oppdage nye baner.",
|
||||
"TeeOff er ikke laget for å erstatte klubbenes egne nettsider. Målet er å gjøre klubbene enklere å finne, og å gjøre terskelen lavere for å dra og spille et nytt sted.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Hvilken informasjon finnes om banene?",
|
||||
items: [
|
||||
"adresse og plassering på kart",
|
||||
"bilder og video når det finnes",
|
||||
"kontaktinformasjon, hjemmeside og sosiale medier",
|
||||
"banestatus, vær, flyfoto og turneringslenker",
|
||||
"banebeskrivelse og praktisk info om fasiliteter",
|
||||
"head pro, greenfee, medlemskap og Veien til Golf",
|
||||
"scorekort, slope og annen nyttig banedata",
|
||||
"redaksjonelt innhold som banebesøk og meninger når det finnes",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Hvor kommer informasjonen fra?",
|
||||
body: [
|
||||
"Innholdet hentes fra klubbenes egne nettsider, Golfbox, Shotzoom, sosiale medier, direkte kontakt med klubbene og i noen tilfeller egne besøk og manuell research.",
|
||||
"Ambisjonen er at informasjonen skal være korrekt, oppdatert og praktisk anvendelig. Når noe endrer seg, er TeeOff avhengig av gode kilder og raske tilbakemeldinger. Oppdager du feil, er det derfor bare å si fra.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Hvem holder TeeOff oppdatert?",
|
||||
body: [
|
||||
"TeeOff bygger videre på et mangeårig arbeid med å samle og rydde informasjon om norske golfanlegg. Den nye løsningen er laget for å gjøre det enklere å holde flere typer klubbdata oppdatert enn tidligere.",
|
||||
"Har du informasjon som bør endres, bilder som bør brukes, eller tips til innhold, kan du sende det inn via kontaktsiden.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Hvordan bytte toppbildet på en klubbside?",
|
||||
body: [
|
||||
"Hvis du representerer en klubb og vil bytte hovedbildet som presenterer banen, er det bare å ta kontakt og sende over et godt bilde i bredt format. Fotograf krediteres når informasjonen følger med.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Koster dette noe?",
|
||||
body: [
|
||||
"TeeOff er gratis å bruke for både golfspillere og klubber. Det ligger mye arbeid bak å samle, rydde og presentere informasjonen, men selve synligheten på TeeOff er ikke låst bak betaling.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Drukner klubbene i spam hvis e-postadressen vises?",
|
||||
body: [
|
||||
"Nei, det er ikke meningen. Kontaktinformasjon publiseres fordi den skal være nyttig for vanlige mennesker, samtidig som løsningene rundt kontaktsiden og systemet er laget for å redusere automatisert misbruk og spam.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Hva betyr TeeOff for klubbene egentlig?",
|
||||
body: [
|
||||
"Bruksmønsteret har lenge vært ganske tydelig: mange bruker TeeOff når de faktisk vurderer å dra og spille. De sammenligner anlegg, ser på praktisk informasjon, sjekker kart, turneringer og detaljer før de bestemmer seg.",
|
||||
"Det er den viktigste verdien TeeOff kan gi klubbene også i dag: gjøre det lettere for flere å oppdage nye golfanlegg, finne relevant informasjon og komme seg ut på banen.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Sporing og analyse",
|
||||
body: [
|
||||
"TeeOff bruker analyseverktøy for å forstå hvilke sider som brukes, hvordan besøkende navigerer og hva som bør forbedres. I dag skjer dette med Matomo.",
|
||||
"Hvis du vil vite mer om personvern, cookies og analyse, finnes det en egen side for dette.",
|
||||
],
|
||||
cta: { href: "/personvern-og-cookies", label: "Les om personvern og cookies" },
|
||||
},
|
||||
{
|
||||
title: "Turneringer, kurs og andre tilbud",
|
||||
body: [
|
||||
"Klubber har ofte behov for å løfte frem turneringer, VTG-kurs og andre tilbud. TeeOff har derfor egne flater for dette, og slike ting kan også løftes frem sammen med den enkelte klubbprofilen.",
|
||||
],
|
||||
links: [
|
||||
{ href: "/turneringer", label: "Se turneringer" },
|
||||
{ href: "/vtg", label: "Se Veien til Golf" },
|
||||
{ href: "/kontakt", label: "Kontakt TeeOff" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const metadata = createPageMetadata({
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
export async function generateMetadata() {
|
||||
const page = await fetchSitePage(pageKey);
|
||||
return createPageMetadata({
|
||||
title: page?.meta_title?.trim() || fallbackMetaTitle,
|
||||
description: page?.meta_description?.trim() || fallbackMetaDescription,
|
||||
path: "/om",
|
||||
image: page?.hero_image_url,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function AboutPage() {
|
||||
const page = await fetchSitePage(pageKey);
|
||||
const title = page?.title?.trim() || fallbackVisibleTitle;
|
||||
const introHtml = page?.intro_html?.trim() || fallbackIntro;
|
||||
const bodyHtml = page?.body_html?.trim() || "";
|
||||
const metaTitle = page?.meta_title?.trim() || fallbackMetaTitle;
|
||||
const metaDescription = page?.meta_description?.trim() || fallbackMetaDescription;
|
||||
|
||||
export default function AboutPage() {
|
||||
const collectionJsonLd = createCollectionPageJsonLd({
|
||||
name: pageTitle,
|
||||
description: pageDescription,
|
||||
name: metaTitle,
|
||||
description: metaDescription,
|
||||
path: "/om",
|
||||
});
|
||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||
|
|
@ -119,56 +53,13 @@ export default function AboutPage() {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<InfoPageShell
|
||||
eyebrow="FAQ / Om"
|
||||
title="Hva TeeOff er, og hvorfor siden finnes"
|
||||
intro="Kortversjonen er fortsatt den samme: TeeOff bruker mye tid på å samle, rydde og presentere norske golfanlegg på en ordentlig måte, slik at det blir lettere å finne ut hvor man faktisk har lyst til å spille."
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
{sections.map((section) => (
|
||||
<article key={section.title} className="surface-card rounded-[1.75rem] p-6 sm:p-8">
|
||||
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#8BC34A]">
|
||||
FAQ
|
||||
</p>
|
||||
<h2 className="mt-3 text-2xl font-black text-[#112015]">{section.title}</h2>
|
||||
|
||||
{section.body ? (
|
||||
<div className="mt-4 space-y-4 text-sm leading-7 text-[#4F5F50]">
|
||||
{section.body.map((paragraph) => (
|
||||
<p key={paragraph}>{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{section.items ? (
|
||||
<ul className="mt-4 list-disc space-y-2 pl-5 text-sm leading-7 text-[#4F5F50]">
|
||||
{section.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{section.cta ? (
|
||||
<div className="mt-6">
|
||||
<Link href={section.cta.href} className="btn btn-md btn-primary">
|
||||
{section.cta.label}
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{section.links ? (
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
{section.links.map((link) => (
|
||||
<Link key={link.href} href={link.href} className="btn btn-md btn-secondary">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</InfoPageShell>
|
||||
<SitePageTemplate
|
||||
eyebrow={page?.eyebrow?.trim() || "FAQ / Om"}
|
||||
title={title}
|
||||
introHtml={introHtml}
|
||||
heroImageUrl={page?.hero_image_url}
|
||||
bodyHtml={bodyHtml}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,42 @@
|
|||
import InfoPageShell from "@/components/InfoPageShell";
|
||||
import SitePageTemplate from "@/components/SitePageTemplate";
|
||||
import { fetchSitePage } from "@/app/sitePages";
|
||||
import {
|
||||
createBreadcrumbJsonLd,
|
||||
createCollectionPageJsonLd,
|
||||
createPageMetadata,
|
||||
} from "@/app/seo";
|
||||
|
||||
const pageTitle = "Personvern og cookies";
|
||||
const pageDescription =
|
||||
const pageKey = "personvern-og-cookies";
|
||||
const fallbackVisibleTitle = "Personvern og cookies";
|
||||
const fallbackIntro =
|
||||
"<p>Denne siden forklarer kort hvilke opplysninger TeeOff behandler, hvorfor vi gjør det, og hvordan cookies brukes på nettsiden.</p>";
|
||||
const fallbackMetaTitle = "Personvern og cookies";
|
||||
const fallbackMetaDescription =
|
||||
"Hvordan TeeOff behandler personopplysninger, bruker cookies og måleverktøy som Matomo.";
|
||||
|
||||
export const metadata = createPageMetadata({
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
path: "/personvern-og-cookies",
|
||||
});
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const page = await fetchSitePage(pageKey);
|
||||
return createPageMetadata({
|
||||
title: page?.meta_title?.trim() || fallbackMetaTitle,
|
||||
description: page?.meta_description?.trim() || fallbackMetaDescription,
|
||||
path: "/personvern-og-cookies",
|
||||
image: page?.hero_image_url,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function PrivacyAndCookiesPage() {
|
||||
const page = await fetchSitePage(pageKey);
|
||||
const title = page?.title?.trim() || fallbackVisibleTitle;
|
||||
const introHtml = page?.intro_html?.trim() || fallbackIntro;
|
||||
const bodyHtml = page?.body_html?.trim() || "";
|
||||
const metaTitle = page?.meta_title?.trim() || fallbackMetaTitle;
|
||||
const metaDescription = page?.meta_description?.trim() || fallbackMetaDescription;
|
||||
|
||||
export default function PrivacyAndCookiesPage() {
|
||||
const collectionJsonLd = createCollectionPageJsonLd({
|
||||
name: pageTitle,
|
||||
description: pageDescription,
|
||||
name: metaTitle,
|
||||
description: metaDescription,
|
||||
path: "/personvern-og-cookies",
|
||||
});
|
||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||
|
|
@ -36,70 +54,13 @@ export default function PrivacyAndCookiesPage() {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
|
||||
<InfoPageShell
|
||||
eyebrow="Personvern"
|
||||
title="Personvern og cookies"
|
||||
intro="Denne siden forklarer kort hvilke opplysninger TeeOff behandler, hvorfor vi gjør det, og hvordan cookies brukes på nettsiden."
|
||||
>
|
||||
<div className="grid gap-6">
|
||||
<section className="surface-card rounded-[2rem] p-6 sm:p-8">
|
||||
<h2 className="text-3xl font-black text-[#112015] sm:text-4xl">Hva vi lagrer</h2>
|
||||
<div className="course-visit-richtext mt-4 space-y-4 text-base leading-8 text-[#334238]">
|
||||
<p>
|
||||
TeeOff lagrer i hovedsak opplysninger som er nødvendige for å vise klubbdata,
|
||||
publisere innhold og håndtere henvendelser fra brukere.
|
||||
</p>
|
||||
<p>
|
||||
Hvis du bruker kontaktskjemaet, lagres opplysningene du selv sender inn for å kunne
|
||||
besvare henvendelsen. Hvis du kommenterer artikler eller bruker innloggingsfunksjoner,
|
||||
behandles de opplysningene som er nødvendige for å autentisere deg og vise innholdet.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="surface-card rounded-[2rem] p-6 sm:p-8">
|
||||
<h2 className="text-3xl font-black text-[#112015] sm:text-4xl">Cookies</h2>
|
||||
<div className="course-visit-richtext mt-4 space-y-4 text-base leading-8 text-[#334238]">
|
||||
<p>TeeOff bruker cookies til noen få, konkrete formål:</p>
|
||||
<ul className="list-disc pl-6">
|
||||
<li>innlogging og sesjonshåndtering for administratorer</li>
|
||||
<li>innlogging for offentlige brukerfunksjoner som kommentarer</li>
|
||||
<li>måling av trafikk og bruksmønstre via Matomo</li>
|
||||
</ul>
|
||||
<p>
|
||||
Nødvendige cookies brukes for at nettsiden skal fungere. Analysecookies brukes for å
|
||||
forstå hvordan nettsiden brukes og forbedre innhold og funksjonalitet.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="surface-card rounded-[2rem] p-6 sm:p-8">
|
||||
<h2 className="text-3xl font-black text-[#112015] sm:text-4xl">Analyse med Matomo</h2>
|
||||
<div className="course-visit-richtext mt-4 space-y-4 text-base leading-8 text-[#334238]">
|
||||
<p>
|
||||
TeeOff bruker Matomo for å måle trafikk på nettsiden. Formålet er å forstå hvilke
|
||||
sider som brukes, hvordan besøkende navigerer og hvor innhold kan forbedres.
|
||||
</p>
|
||||
<p>
|
||||
Matomo-instansen kjøres på <strong>analyse.envide.no</strong>. Admin-områdene spores
|
||||
ikke på samme måte som vanlige publikumssider.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="surface-card rounded-[2rem] p-6 sm:p-8">
|
||||
<h2 className="text-3xl font-black text-[#112015] sm:text-4xl">Kontakt om personvern</h2>
|
||||
<div className="course-visit-richtext mt-4 space-y-4 text-base leading-8 text-[#334238]">
|
||||
<p>
|
||||
Hvis du har spørsmål om personvern, cookies eller ønsker innsyn knyttet til opplysninger
|
||||
du har sendt inn, kan du bruke kontaktsiden eller sende e-post til{" "}
|
||||
<a href="mailto:teeoff@teeoff.no">teeoff@teeoff.no</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</InfoPageShell>
|
||||
<SitePageTemplate
|
||||
eyebrow={page?.eyebrow?.trim() || "Personvern"}
|
||||
title={title}
|
||||
introHtml={introHtml}
|
||||
heroImageUrl={page?.hero_image_url}
|
||||
bodyHtml={bodyHtml}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
25
frontend/src/app/sitePages.ts
Normal file
25
frontend/src/app/sitePages.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { cache } from "react";
|
||||
import { API_URL } from "@/config/constants";
|
||||
|
||||
export type SitePageRecord = {
|
||||
page_key: string;
|
||||
eyebrow?: string | null;
|
||||
title?: string | null;
|
||||
hero_image_url?: string | null;
|
||||
intro_html?: string | null;
|
||||
body_html?: string | null;
|
||||
meta_title?: string | null;
|
||||
meta_description?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export const fetchSitePage = cache(async (pageKey: string): Promise<SitePageRecord | null> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/site-pages/${pageKey}`, { cache: "no-store" });
|
||||
if (!response.ok) return null;
|
||||
return (await response.json()) as SitePageRecord;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
|
@ -20,12 +20,17 @@ type PlacePageRecord = {
|
|||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
type SitePageRecord = {
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
type StaticRouteConfig = {
|
||||
path: string;
|
||||
changeFrequency: "daily" | "weekly" | "monthly";
|
||||
priority: number;
|
||||
sourceFiles: string[];
|
||||
sitePageKey?: string;
|
||||
siteTemplateKey?: string;
|
||||
};
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
|
@ -78,30 +83,35 @@ const staticRouteConfigs: StaticRouteConfig[] = [
|
|||
changeFrequency: "daily",
|
||||
priority: 0.68,
|
||||
sourceFiles: ["src/app/turneringer/page.tsx"],
|
||||
siteTemplateKey: "turneringer",
|
||||
},
|
||||
{
|
||||
path: "/klubbnummer",
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.64,
|
||||
sourceFiles: ["src/app/klubbnummer/page.tsx"],
|
||||
siteTemplateKey: "klubbnummer",
|
||||
},
|
||||
{
|
||||
path: "/om",
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.45,
|
||||
sourceFiles: ["src/app/om/page.tsx"],
|
||||
siteTemplateKey: "om",
|
||||
},
|
||||
{
|
||||
path: "/kontakt",
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.42,
|
||||
sourceFiles: ["src/app/kontakt/page.tsx"],
|
||||
siteTemplateKey: "kontakt",
|
||||
},
|
||||
{
|
||||
path: "/personvern-og-cookies",
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.38,
|
||||
sourceFiles: ["src/app/personvern-og-cookies/page.tsx"],
|
||||
siteTemplateKey: "personvern-og-cookies",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -164,6 +174,19 @@ async function fetchPlacePageUpdatedAt(slug: string) {
|
|||
}
|
||||
}
|
||||
|
||||
async function fetchSiteTemplateUpdatedAt(pageKey: string) {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/site-pages/${pageKey}`, {
|
||||
next: { revalidate },
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = (await response.json()) as SitePageRecord;
|
||||
return parseDate(data.updated_at);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
let facilities: SitemapFacility[] = [];
|
||||
|
||||
|
|
@ -184,10 +207,17 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||
const sitePageUpdatedAt = route.sitePageKey
|
||||
? await fetchSitePageSeoUpdatedAt(route.sitePageKey)
|
||||
: null;
|
||||
const siteTemplateUpdatedAt = route.siteTemplateKey
|
||||
? await fetchSiteTemplateUpdatedAt(route.siteTemplateKey)
|
||||
: null;
|
||||
return {
|
||||
url: buildAbsoluteUrl(route.path),
|
||||
lastModified:
|
||||
maxDate([sitePageUpdatedAt, getSourceLastModified(route.sourceFiles)]) || new Date(),
|
||||
maxDate([
|
||||
sitePageUpdatedAt,
|
||||
siteTemplateUpdatedAt,
|
||||
getSourceLastModified(route.sourceFiles),
|
||||
]) || new Date(),
|
||||
changeFrequency: route.changeFrequency,
|
||||
priority: route.priority,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import CourseVisitGallery from "@/components/CourseVisitGallery";
|
||||
import InfoPageShell from "@/components/InfoPageShell";
|
||||
import { getOpinionArticleBySlug, type CourseVisitBodyBlock } from "@/content/courseVisits";
|
||||
import SitePageTemplate from "@/components/SitePageTemplate";
|
||||
import { fetchSitePage } from "@/app/sitePages";
|
||||
import {
|
||||
createBreadcrumbJsonLd,
|
||||
createCollectionPageJsonLd,
|
||||
|
|
@ -10,52 +8,38 @@ import {
|
|||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const articleSlug = "note-to-self-lenker-til-viktige-turneringer-i-golfbox";
|
||||
const pageTitle = "Golfturneringer i Norge: Oversikt og terminlister | TeeOff.no";
|
||||
const pageDescription =
|
||||
"Vanskelig å finne frem i Golfbox? Vi samler terminlister for Olyo Tour og regionale golfturneringer i hele Norge på ett sted. Finn din neste turnering her!";
|
||||
const pageIntro = "Her er alle turneringene vi ikke vet hvordan vi skal finne i Golfbox (og andre steder). God golfsesong!";
|
||||
function renderBlock(block: CourseVisitBodyBlock, index: number) {
|
||||
if (block.type !== "richText") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section key={index} className="surface-card rounded-[2rem] p-6 sm:p-8">
|
||||
{block.title ? (
|
||||
<h2 className="text-3xl font-black text-[#112015] sm:text-4xl">{block.title}</h2>
|
||||
) : null}
|
||||
<div
|
||||
className={`course-visit-richtext space-y-4 text-base leading-8 text-[#334238] [&_a]:font-black [&_a]:text-[#112015] [&_a]:underline [&_a]:underline-offset-4 [&_em]:italic [&_h2]:mt-12 [&_h2]:text-3xl [&_h2]:font-black [&_h2]:text-[#112015] [&_h3]:mt-10 [&_h3]:text-2xl [&_h3]:font-black [&_iframe]:mt-5 [&_iframe]:aspect-video [&_iframe]:w-full [&_iframe]:rounded-[1.5rem] [&_iframe]:border [&_iframe]:border-[#112015]/8 [&_iframe]:shadow-[0_12px_30px_rgba(17,32,21,0.08)] [&_img]:mt-5 [&_img]:w-full [&_img]:rounded-[1.5rem] [&_img]:border [&_img]:border-[#112015]/8 [&_img]:shadow-[0_12px_30px_rgba(17,32,21,0.08)] [&_li]:mt-2 [&_p]:mt-4 [&_table]:mt-6 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-[#112015]/10 [&_td]:px-3 [&_td]:py-2 [&_th]:border [&_th]:border-[#112015]/10 [&_th]:bg-[#F4F7EE] [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_ul]:mt-4 [&_ul]:list-disc [&_ul]:pl-6 ${
|
||||
block.title ? "mt-4" : ""
|
||||
}`}
|
||||
dangerouslySetInnerHTML={{ __html: block.html }}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
const pageKey = "turneringer";
|
||||
const fallbackTitle = "Lenker til viktige turneringer i Golfbox";
|
||||
const fallbackMetaTitle = "Golfturneringer i Norge: Oversikt og terminlister | TeeOff.no";
|
||||
const fallbackMetaDescription =
|
||||
"Vanskelig å finne frem i Golfbox? Vi samler terminlister for Narvesen Tour og regionale golfturneringer i hele Norge på ett sted. Finn din neste turnering her!";
|
||||
const fallbackIntro =
|
||||
"<p>Her er alle turneringene vi ikke vet hvordan vi skal finne i Golfbox (og andre steder). God golfsesong!</p>";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const article = await getOpinionArticleBySlug(articleSlug);
|
||||
const page = await fetchSitePage(pageKey);
|
||||
const title = page?.meta_title?.trim() || fallbackMetaTitle;
|
||||
const description = page?.meta_description?.trim() || fallbackMetaDescription;
|
||||
|
||||
return createPageMetadata({
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
title,
|
||||
description,
|
||||
path: "/turneringer",
|
||||
image: article?.heroImages[0]?.src,
|
||||
image: page?.hero_image_url,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function TournamentsPage() {
|
||||
const article = await getOpinionArticleBySlug(articleSlug);
|
||||
|
||||
if (!article) {
|
||||
notFound();
|
||||
}
|
||||
const page = await fetchSitePage(pageKey);
|
||||
const visibleTitle = page?.title?.trim() || fallbackTitle;
|
||||
const introHtml = page?.intro_html?.trim() || fallbackIntro;
|
||||
const bodyHtml = page?.body_html?.trim() || "";
|
||||
const metaTitle = page?.meta_title?.trim() || fallbackMetaTitle;
|
||||
const metaDescription = page?.meta_description?.trim() || fallbackMetaDescription;
|
||||
|
||||
const collectionJsonLd = createCollectionPageJsonLd({
|
||||
name: pageTitle,
|
||||
description: pageDescription,
|
||||
name: metaTitle,
|
||||
description: metaDescription,
|
||||
path: "/turneringer",
|
||||
});
|
||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||
|
|
@ -73,30 +57,13 @@ export default async function TournamentsPage() {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<InfoPageShell eyebrow="Turneringer" title={article.title} intro={pageIntro}>
|
||||
<div className="space-y-6">
|
||||
<CourseVisitGallery title={article.title} media={article.mediaGallery.slice(0, 1)} />
|
||||
{article.blocks.map((block, index) => renderBlock(block, index))}
|
||||
{article.mediaGallery.length > 1 ? (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#8BC34A]">
|
||||
Galleri
|
||||
</p>
|
||||
<h2 className="mt-3 text-3xl font-black text-[#112015] sm:text-4xl">
|
||||
Bilder og video
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-[#5A685C]">
|
||||
{article.mediaGallery.length} elementer
|
||||
</p>
|
||||
</div>
|
||||
<CourseVisitGallery title={article.title} media={article.mediaGallery} />
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</InfoPageShell>
|
||||
<SitePageTemplate
|
||||
eyebrow={page?.eyebrow?.trim() || "Turneringer"}
|
||||
title={visibleTitle}
|
||||
introHtml={introHtml}
|
||||
heroImageUrl={page?.hero_image_url}
|
||||
bodyHtml={bodyHtml}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const NAV_ITEMS = [
|
|||
{ href: '/admin/golfpakker', label: 'Golfpakker', match: (pathname: string) => pathname.startsWith('/admin/golfpakker') },
|
||||
{ href: '/admin/simulatorer', label: 'Simulatorer', match: (pathname: string) => pathname.startsWith('/admin/simulatorer') },
|
||||
{ href: '/admin/vtg', label: 'VTG', match: (pathname: string) => pathname.startsWith('/admin/vtg') },
|
||||
{ href: '/admin/sider', label: 'Sider', match: (pathname: string) => pathname.startsWith('/admin/sider') },
|
||||
{ href: '/admin/steder', label: 'Steder', match: (pathname: string) => pathname.startsWith('/admin/steder') },
|
||||
];
|
||||
|
||||
|
|
|
|||
79
frontend/src/components/SitePageTemplate.tsx
Normal file
79
frontend/src/components/SitePageTemplate.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { FALLBACK_IMAGE } from "@/config/constants";
|
||||
|
||||
type SitePageTemplateProps = {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
introHtml?: string | null;
|
||||
heroImageUrl?: string | null;
|
||||
bodyHtml?: string | null;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export default function SitePageTemplate({
|
||||
eyebrow: _eyebrow,
|
||||
title,
|
||||
introHtml,
|
||||
heroImageUrl,
|
||||
bodyHtml,
|
||||
children,
|
||||
}: SitePageTemplateProps) {
|
||||
const normalizedTitle = String(title || "").trim();
|
||||
const normalizedIntro = String(introHtml || "").trim();
|
||||
const normalizedBody = String(bodyHtml || "").trim();
|
||||
const heroSrc = String(heroImageUrl || "").trim() || FALLBACK_IMAGE;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[#EEF5E8] text-[#112015]">
|
||||
<section>
|
||||
<div className="relative isolate overflow-hidden bg-[#112015] shadow-[0_26px_70px_rgba(17,32,21,0.18)]">
|
||||
<img
|
||||
src={heroSrc}
|
||||
alt={normalizedTitle || "TeeOff"}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(17,32,21,0.06),rgba(17,32,21,0.28)_62%,rgba(17,32,21,0.54))]" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(214,240,173,0.22),transparent_28%)]" />
|
||||
|
||||
<div className="relative mx-auto flex min-h-[18rem] max-w-[1320px] items-end px-4 pb-4 pt-20 sm:min-h-[22rem] sm:px-6 sm:pb-6 lg:min-h-[31rem] lg:px-8 lg:pb-8 lg:pt-24">
|
||||
<div className="max-w-4xl">
|
||||
<div className="inline-block max-w-full rounded-[1.75rem] bg-[linear-gradient(180deg,rgba(12,20,15,0.42),rgba(12,20,15,0.74))] px-5 py-4 shadow-[0_26px_55px_rgba(0,0,0,0.24)] backdrop-blur-md sm:px-7 sm:py-5 lg:px-9 lg:py-7">
|
||||
<h1 className="text-4xl font-black tracking-[-0.05em] text-white sm:text-5xl lg:text-7xl">
|
||||
{normalizedTitle}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="pb-12 pt-5 sm:pb-16 sm:pt-7 lg:pb-20 lg:pt-9">
|
||||
<div className="mx-auto max-w-[1320px] px-4 sm:px-6 lg:px-8">
|
||||
{normalizedIntro ? (
|
||||
<div className="mb-8 sm:mb-10 lg:mb-12">
|
||||
<div
|
||||
className="site-page-richtext mx-auto max-w-4xl border-l-4 border-[#FF5722] pl-5 text-[1.12rem] leading-8 text-[#334238] sm:pl-7 sm:text-[1.18rem] sm:leading-9 lg:pl-8 lg:text-[1.28rem] lg:leading-10 [&_a]:font-black [&_a]:text-[#112015] [&_a]:underline [&_a]:underline-offset-4 [&_em]:italic [&_p]:mt-5 [&_p:first-child]:mt-0 [&_strong]:font-black"
|
||||
dangerouslySetInnerHTML={{ __html: normalizedIntro }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="overflow-hidden rounded-[2rem] border border-[#112015]/8 bg-[linear-gradient(180deg,#F8FBF3,#EFF5E7)] shadow-[0_20px_60px_rgba(17,32,21,0.08)] sm:rounded-[2.5rem]">
|
||||
<div className="px-5 py-7 sm:px-8 sm:py-9 lg:px-12 lg:py-12">
|
||||
{normalizedBody ? (
|
||||
<div
|
||||
className="site-page-richtext mx-auto max-w-4xl space-y-5 text-[1.02rem] leading-8 text-[#334238] lg:text-[1.06rem] lg:leading-9 [&_a]:font-black [&_a]:text-[#112015] [&_a]:underline [&_a]:underline-offset-4 [&_blockquote]:rounded-[1.5rem] [&_blockquote]:border-l-4 [&_blockquote]:border-[#8BC34A] [&_blockquote]:bg-white/70 [&_blockquote]:px-5 [&_blockquote]:py-4 [&_em]:italic [&_h2]:mt-14 [&_h2]:text-3xl [&_h2]:font-black [&_h2]:leading-tight [&_h2]:tracking-[-0.03em] [&_h2]:text-[#112015] [&_h3]:mt-10 [&_h3]:text-2xl [&_h3]:font-black [&_h3]:leading-tight [&_h3]:tracking-[-0.02em] [&_h3]:text-[#112015] [&_hr]:my-10 [&_hr]:border-0 [&_hr]:border-t [&_hr]:border-[#112015]/10 [&_iframe]:mt-6 [&_iframe]:aspect-video [&_iframe]:w-full [&_iframe]:rounded-[1.75rem] [&_iframe]:border [&_iframe]:border-[#112015]/8 [&_img]:mt-6 [&_img]:w-full [&_img]:rounded-[1.75rem] [&_img]:border [&_img]:border-[#112015]/8 [&_img]:shadow-[0_12px_30px_rgba(17,32,21,0.08)] [&_li]:mt-2 [&_ol]:pl-6 [&_p]:mt-5 [&_p:first-child]:mt-0 [&_strong]:font-black [&_table]:mt-8 [&_table]:w-full [&_table]:overflow-hidden [&_table]:rounded-[1.25rem] [&_table]:border-collapse [&_td]:border [&_td]:border-[#112015]/10 [&_td]:bg-white/68 [&_td]:px-3 [&_td]:py-2.5 [&_th]:border [&_th]:border-[#112015]/10 [&_th]:bg-white/95 [&_th]:px-3 [&_th]:py-2.5 [&_th]:text-left [&_ul]:pl-6"
|
||||
dangerouslySetInnerHTML={{ __html: normalizedBody }}
|
||||
/>
|
||||
) : null}
|
||||
{children ? (
|
||||
<div className={normalizedBody ? "mx-auto mt-8 max-w-5xl sm:mt-10" : "mx-auto max-w-5xl"}>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue