Før banebesøk
This commit is contained in:
parent
94afef6f33
commit
25ca19eba1
9 changed files with 715 additions and 54 deletions
102
frontend/src/app/banebesok/page.tsx
Normal file
102
frontend/src/app/banebesok/page.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import Link from "next/link";
|
||||
import InfoPageShell from "@/components/InfoPageShell";
|
||||
import {
|
||||
createBreadcrumbJsonLd,
|
||||
createCollectionPageJsonLd,
|
||||
createPageMetadata,
|
||||
} from "@/app/seo";
|
||||
|
||||
const pageTitle = "Banebesøk";
|
||||
const pageDescription =
|
||||
"Personlige artikler fra golfbaner TeeOff har spilt, med bilder, inntrykk og detaljer som er nyttige før ditt eget besøk.";
|
||||
|
||||
const articlePillars = [
|
||||
{
|
||||
title: "Baneguide med personlighet",
|
||||
text: "Hver artikkel skal være mer enn faktaark. Målet er å beskrive flyt, inntrykk og hva som faktisk gjør banen minneverdig.",
|
||||
},
|
||||
{
|
||||
title: "Foto og stemning",
|
||||
text: "Toppslider, utvalgte bilder og tydelige avsnitt gjør at banebesøk kan fungere både som inspirasjon og som planlegging.",
|
||||
},
|
||||
{
|
||||
title: "Norsk golfkontekst",
|
||||
text: "Banebesøk skal løfte fram særpreg ved norske anlegg i stedet for å bli en generisk reiseblogg.",
|
||||
},
|
||||
];
|
||||
|
||||
export const metadata = createPageMetadata({
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
path: "/banebesok",
|
||||
});
|
||||
|
||||
export default function CourseVisitsPage() {
|
||||
const collectionJsonLd = createCollectionPageJsonLd({
|
||||
name: pageTitle,
|
||||
description: pageDescription,
|
||||
path: "/banebesok",
|
||||
});
|
||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||
{ name: "Hjem", path: "/" },
|
||||
{ name: "Banebesøk", path: "/banebesok" },
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<InfoPageShell
|
||||
eyebrow="Banebesøk"
|
||||
title="Golfbaner fortalt som opplevelser"
|
||||
intro="Banebesøk blir TeeOffs redaksjonelle del: artikler om baner vi faktisk har spilt, med bilder, inntrykk og konkrete ting det er verdt å vite før man drar dit selv."
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-[1.3fr,0.9fr]">
|
||||
<section className="surface-card rounded-[2rem] p-6 sm:p-8">
|
||||
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#8BC34A]">
|
||||
Første versjon
|
||||
</p>
|
||||
<h2 className="mt-4 text-3xl font-black text-[#112015]">Seksjonen er klar til å fylles</h2>
|
||||
<p className="mt-4 max-w-3xl text-base leading-7 text-[#4F5F50]">
|
||||
Denne siden er satt opp som eget hjem for lange artikler, banebilder og personlige
|
||||
vurderinger. Neste steg er å koble på en faktisk artikkelmodell med egne URL-er per
|
||||
banebesøk.
|
||||
</p>
|
||||
<div className="mt-8 grid gap-4 md:grid-cols-3">
|
||||
{articlePillars.map((pillar) => (
|
||||
<article key={pillar.title} className="rounded-[1.5rem] border border-[#112015]/8 bg-[#F7F9F2] p-5">
|
||||
<h3 className="text-xl font-black text-[#112015]">{pillar.title}</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-[#5B675C]">{pillar.text}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="surface-card rounded-[2rem] p-6 sm:p-8">
|
||||
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#FF5722]">
|
||||
Neste anbefalte steg
|
||||
</p>
|
||||
<h2 className="mt-4 text-2xl font-black text-[#112015]">Bygg artikkelflyten før volumet</h2>
|
||||
<p className="mt-4 text-sm leading-6 text-[#4F5F50]">
|
||||
Start med et lite, redigerbart oppsett: ingress, slider, hovedtekst, faktaboks og
|
||||
bildegalleri. Da blir første publisering enkel å få ut uten å låse dere til et tungt
|
||||
CMS-spor med en gang.
|
||||
</p>
|
||||
<Link
|
||||
href="/kontakt"
|
||||
className="mt-6 inline-flex items-center rounded-full bg-[#112015] px-5 py-3 text-sm font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#25312A]"
|
||||
>
|
||||
Planlegg første artikkel
|
||||
</Link>
|
||||
</aside>
|
||||
</div>
|
||||
</InfoPageShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
83
frontend/src/app/klubbnummer/page.tsx
Normal file
83
frontend/src/app/klubbnummer/page.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import InfoPageShell from "@/components/InfoPageShell";
|
||||
import {
|
||||
createBreadcrumbJsonLd,
|
||||
createCollectionPageJsonLd,
|
||||
createPageMetadata,
|
||||
} from "@/app/seo";
|
||||
|
||||
const pageTitle = "Klubbnummer";
|
||||
const pageDescription =
|
||||
"Klubbnummer blir TeeOffs egen oversikt over NGF- og Golfbox-identifikatorer for norske golfklubber.";
|
||||
|
||||
const plannedColumns = ["Klubb", "Klubbnummer", "Sted", "Lenker"];
|
||||
|
||||
export const metadata = createPageMetadata({
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
path: "/klubbnummer",
|
||||
});
|
||||
|
||||
export default function ClubNumbersPage() {
|
||||
const collectionJsonLd = createCollectionPageJsonLd({
|
||||
name: pageTitle,
|
||||
description: pageDescription,
|
||||
path: "/klubbnummer",
|
||||
});
|
||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||
{ name: "Hjem", path: "/" },
|
||||
{ name: "Klubbnummer", path: "/klubbnummer" },
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<InfoPageShell
|
||||
eyebrow="Klubbnummer"
|
||||
title="Egen arbeidsflate for klubbnummer"
|
||||
intro="Denne siden er scaffoldet som hjem for en sorterbar og søkbar oversikt over klubb-ID-er. Den bør bygges som et verktøy, ikke som en vanlig artikkelside."
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr,0.8fr]">
|
||||
<article className="surface-card rounded-[2rem] p-6 sm:p-8">
|
||||
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#8BC34A]">
|
||||
Planlagt tabell
|
||||
</p>
|
||||
<div className="mt-6 grid gap-3">
|
||||
{plannedColumns.map((column) => (
|
||||
<div
|
||||
key={column}
|
||||
className="flex items-center justify-between rounded-[1.25rem] border border-[#112015]/8 bg-[#F7F9F2] px-4 py-3"
|
||||
>
|
||||
<span className="text-sm font-black uppercase tracking-[0.16em] text-[#112015]">
|
||||
{column}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-[#5B675C]">Sortering og filtrering</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="surface-card rounded-[2rem] p-6 sm:p-8">
|
||||
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#FF5722]">
|
||||
Anbefaling
|
||||
</p>
|
||||
<h2 className="mt-4 text-2xl font-black text-[#112015]">
|
||||
Vent med publisering til datakilden er verifisert
|
||||
</h2>
|
||||
<p className="mt-4 text-sm leading-6 text-[#4F5F50]">
|
||||
Dette er en nytteside der feil tall skaper mer frustrasjon enn verdi. Derfor er det
|
||||
bedre å ha en tydelig struktur klar nå og koble den mot korrekt datagrunnlag i neste
|
||||
steg.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</InfoPageShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
82
frontend/src/app/kontakt/page.tsx
Normal file
82
frontend/src/app/kontakt/page.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import InfoPageShell from "@/components/InfoPageShell";
|
||||
import {
|
||||
createBreadcrumbJsonLd,
|
||||
createCollectionPageJsonLd,
|
||||
createPageMetadata,
|
||||
} from "@/app/seo";
|
||||
|
||||
const pageTitle = "Kontakt TeeOff";
|
||||
const pageDescription =
|
||||
"Kontaktpunkter for TeeOff, med lenker til sosiale kanaler og en tydelig plass for fremtidig kontaktinformasjon.";
|
||||
|
||||
const contactLinks = [
|
||||
{
|
||||
label: "Facebook",
|
||||
href: "https://www.facebook.com/TeeOff.norge/",
|
||||
note: "Send melding eller følg oppdateringer.",
|
||||
},
|
||||
{
|
||||
label: "Instagram",
|
||||
href: "https://www.instagram.com/teeoffno/",
|
||||
note: "Visuelt innhold og korte oppdateringer.",
|
||||
},
|
||||
{
|
||||
label: "X / Twitter",
|
||||
href: "https://twitter.com/TeeOffno",
|
||||
note: "Kortform, delinger og lenker videre.",
|
||||
},
|
||||
];
|
||||
|
||||
export const metadata = createPageMetadata({
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
path: "/kontakt",
|
||||
});
|
||||
|
||||
export default function ContactPage() {
|
||||
const collectionJsonLd = createCollectionPageJsonLd({
|
||||
name: pageTitle,
|
||||
description: pageDescription,
|
||||
path: "/kontakt",
|
||||
});
|
||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||
{ name: "Hjem", path: "/" },
|
||||
{ name: "Kontakt", path: "/kontakt" },
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<InfoPageShell
|
||||
eyebrow="Kontakt"
|
||||
title="Kontaktpunktene er på plass"
|
||||
intro="Kontaktsiden er scaffoldet som et tydelig sted for dialog. Foreløpig peker den til TeeOffs sosiale kanaler, og den kan senere utvides med e-post, skjema eller pressehenvendelser."
|
||||
>
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{contactLinks.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="surface-card rounded-[1.75rem] p-6 transition hover:-translate-y-0.5"
|
||||
>
|
||||
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#8BC34A]">
|
||||
Sosial kanal
|
||||
</p>
|
||||
<h2 className="mt-3 text-2xl font-black text-[#112015]">{link.label}</h2>
|
||||
<p className="mt-4 text-sm leading-6 text-[#4F5F50]">{link.note}</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</InfoPageShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
76
frontend/src/app/om/page.tsx
Normal file
76
frontend/src/app/om/page.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import InfoPageShell from "@/components/InfoPageShell";
|
||||
import {
|
||||
createBreadcrumbJsonLd,
|
||||
createCollectionPageJsonLd,
|
||||
createPageMetadata,
|
||||
} from "@/app/seo";
|
||||
|
||||
const pageTitle = "FAQ / Om TeeOff";
|
||||
const pageDescription =
|
||||
"Hva TeeOff er, hva slags golfinformasjon siden samler, og hvorfor produktet er bygget som en praktisk arbeidsflate for norske golfspillere.";
|
||||
|
||||
const faqItems = [
|
||||
{
|
||||
question: "Hva er TeeOff?",
|
||||
answer:
|
||||
"TeeOff er en norsk oversiktstjeneste for golfbaner, medlemskap, banestatus, Veien til Golf og annen praktisk klubbinfo.",
|
||||
},
|
||||
{
|
||||
question: "Hva skiller TeeOff fra klubb- og Golfbox-sider?",
|
||||
answer:
|
||||
"TeeOff samler informasjon på tvers av klubbene, slik at du kan sammenligne og orientere deg raskere før du klikker deg videre.",
|
||||
},
|
||||
{
|
||||
question: "Skal TeeOff også ha redaksjonelt innhold?",
|
||||
answer:
|
||||
"Ja. Banebesøk og utvalgte ressursflater skal gi siden mer personlighet og mer verdi enn en ren katalog.",
|
||||
},
|
||||
];
|
||||
|
||||
export const metadata = createPageMetadata({
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
path: "/om",
|
||||
});
|
||||
|
||||
export default function AboutPage() {
|
||||
const collectionJsonLd = createCollectionPageJsonLd({
|
||||
name: pageTitle,
|
||||
description: pageDescription,
|
||||
path: "/om",
|
||||
});
|
||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||
{ name: "Hjem", path: "/" },
|
||||
{ name: "FAQ / Om", path: "/om" },
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<InfoPageShell
|
||||
eyebrow="FAQ / Om"
|
||||
title="TeeOff skal være nyttig før det skal være pyntet"
|
||||
intro="Kjernen i TeeOff er å gjøre golfinformasjon enklere å finne, sammenligne og bruke. Derfor er de viktigste flatene bygd som arbeidsverktøy først, og markedsføring etterpå."
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
{faqItems.map((item) => (
|
||||
<article key={item.question} 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]">{item.question}</h2>
|
||||
<p className="mt-4 max-w-4xl text-sm leading-7 text-[#4F5F50]">{item.answer}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</InfoPageShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import { createPageMetadata } from "@/app/seo";
|
|||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = createPageMetadata({
|
||||
title: "Din guide til norske golfbaner",
|
||||
title: "Komplett oversikt over ALLE norske golfbaner",
|
||||
description:
|
||||
"Utforsk norske golfanlegg med oppdatert banestatus, kart, priser, medlemskap og Veien til Golf samlet på TeeOff.",
|
||||
path: "/",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,36 @@ const staticRoutes: MetadataRoute.Sitemap = [
|
|||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: buildAbsoluteUrl("/banebesok"),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.72,
|
||||
},
|
||||
{
|
||||
url: buildAbsoluteUrl("/turneringer"),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily",
|
||||
priority: 0.68,
|
||||
},
|
||||
{
|
||||
url: buildAbsoluteUrl("/klubbnummer"),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.64,
|
||||
},
|
||||
{
|
||||
url: buildAbsoluteUrl("/om"),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.45,
|
||||
},
|
||||
{
|
||||
url: buildAbsoluteUrl("/kontakt"),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.42,
|
||||
},
|
||||
];
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
|
|
|
|||
144
frontend/src/app/turneringer/page.tsx
Normal file
144
frontend/src/app/turneringer/page.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import Link from "next/link";
|
||||
import { API_URL } from "@/config/constants";
|
||||
import type { FacilityRecord } from "@/app/facilityData";
|
||||
import InfoPageShell from "@/components/InfoPageShell";
|
||||
import {
|
||||
createBreadcrumbJsonLd,
|
||||
createCollectionPageJsonLd,
|
||||
createPageMetadata,
|
||||
} from "@/app/seo";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const pageTitle = "Turneringer";
|
||||
const pageDescription =
|
||||
"Oversikt over norske golfklubber som har publisert turneringslenker via Golfbox, samlet på TeeOff.";
|
||||
|
||||
export const metadata = createPageMetadata({
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
path: "/turneringer",
|
||||
});
|
||||
|
||||
export default async function TournamentsPage() {
|
||||
let facilities: FacilityRecord[] = [];
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/facilities`, {
|
||||
next: { revalidate: 0 },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`API returnerte status ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
facilities = Array.isArray(data) ? data : [];
|
||||
} catch (error) {
|
||||
console.error("Kunne ikke hente turneringsdata:", error);
|
||||
facilities = [];
|
||||
}
|
||||
|
||||
const listings = facilities
|
||||
.filter(
|
||||
(facility): facility is FacilityRecord & { golfbox_tournament_url: string } =>
|
||||
Boolean(facility.slug && facility.golfbox_tournament_url),
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "nb-NO"));
|
||||
|
||||
const collectionJsonLd = createCollectionPageJsonLd({
|
||||
name: pageTitle,
|
||||
description: pageDescription,
|
||||
path: "/turneringer",
|
||||
});
|
||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||
{ name: "Hjem", path: "/" },
|
||||
{ name: "Turneringer", path: "/turneringer" },
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<InfoPageShell
|
||||
eyebrow="Turneringer"
|
||||
title="Turneringslenker fra norske klubber"
|
||||
intro="Denne siden samler klubber som allerede har en turneringslenke registrert i TeeOff. Det gjør siden nyttig fra første versjon, samtidig som den kan utvides senere med større kalendere og kuraterte turneringsguider."
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-[0.85fr,1.15fr]">
|
||||
<article className="surface-card rounded-[2rem] p-6 sm:p-8">
|
||||
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#8BC34A]">
|
||||
Klubber med lenke
|
||||
</p>
|
||||
<p className="mt-4 text-5xl font-black text-[#112015]">{listings.length}</p>
|
||||
<p className="mt-4 text-sm leading-6 text-[#4F5F50]">
|
||||
Hver oppføring peker direkte til klubbens turneringsside i Golfbox og videre til
|
||||
TeeOffs egen baneside.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article className="surface-card rounded-[2rem] p-6 sm:p-8">
|
||||
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#FF5722]">
|
||||
Veien videre
|
||||
</p>
|
||||
<h2 className="mt-4 text-2xl font-black text-[#112015]">
|
||||
God nok nytteverdi nå, enkel å bygge videre på senere
|
||||
</h2>
|
||||
<p className="mt-4 text-sm leading-6 text-[#4F5F50]">
|
||||
Neste naturlige steg er filtrering på område, åpne eller kommende turneringer og
|
||||
egne redaksjonelle anbefalinger for turneringer det faktisk er verdt å følge med på.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{listings.length > 0 ? (
|
||||
<div className="mt-8 grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
||||
{listings.map((facility) => (
|
||||
<article key={facility.slug} className="surface-card rounded-[1.75rem] p-5">
|
||||
<p className="text-[11px] font-black uppercase tracking-[0.22em] text-[#8BC34A]">
|
||||
{[facility.county, facility.city].filter(Boolean).join(" • ") || "Norge"}
|
||||
</p>
|
||||
<h2 className="mt-3 text-2xl font-black text-[#112015]">{facility.name}</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-[#5B675C]">
|
||||
Gå rett til klubbens turneringer i Golfbox, eller åpne TeeOff-siden for resten av
|
||||
klubbinfoen.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<a
|
||||
href={facility.golfbox_tournament_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center rounded-full bg-[#112015] px-4 py-2 text-xs font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#25312A]"
|
||||
>
|
||||
Turneringer i Golfbox
|
||||
</a>
|
||||
<Link
|
||||
href={`/golfbaner/${facility.slug}`}
|
||||
className="inline-flex items-center rounded-full border border-[#112015]/10 px-4 py-2 text-xs font-black uppercase tracking-[0.16em] text-[#112015] transition hover:border-[#FF5722] hover:text-[#FF5722]"
|
||||
>
|
||||
Se baneside
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<article className="surface-card mt-8 rounded-[2rem] p-6 sm:p-8">
|
||||
<h2 className="text-2xl font-black text-[#112015]">Ingen turneringslenker registrert ennå</h2>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-6 text-[#4F5F50]">
|
||||
Når klubbene får lagt inn turneringslenker i TeeOff-datagrunnlaget, dukker de opp
|
||||
her automatisk.
|
||||
</p>
|
||||
</article>
|
||||
)}
|
||||
</InfoPageShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
const placeGroups = [
|
||||
{
|
||||
|
|
@ -59,21 +66,35 @@ const placeGroups = [
|
|||
{ href: "/sted/oslo-og-akershus", label: "Oslo og Akershus" },
|
||||
{ href: "/sted/akershus", label: "Akershus" },
|
||||
{ href: "/sted/oslo", label: "Oslo" },
|
||||
{ href: "/sted/innlandet", label: "Innlandet" },
|
||||
{ href: "/sted/viken", label: "Viken" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const primaryNavItems: NavItem[] = [
|
||||
{ href: "/medlemskap", label: "Medlemskap" },
|
||||
{ href: "/vtg", label: "VTG" },
|
||||
{ href: "/banebesok", label: "Banebesøk" },
|
||||
];
|
||||
|
||||
const resourceNavItems: NavItem[] = [
|
||||
{ href: "/turneringer", label: "Turneringer" },
|
||||
{ href: "/klubbnummer", label: "Klubbnummer" },
|
||||
{ href: "https://golfquiz.no", label: "Golfquiz.no", external: true },
|
||||
{ href: "https://golfbox.golf", label: "Golfbox", external: true },
|
||||
{ href: "/om", label: "FAQ / Om" },
|
||||
{ href: "/kontakt", label: "Kontakt" },
|
||||
];
|
||||
|
||||
export default function Header() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isPlacesOpen, setIsPlacesOpen] = useState(false);
|
||||
const navItems = [
|
||||
{ href: "/", label: "Hjem" },
|
||||
{ href: "/golfbaner", label: "Golfbaner" },
|
||||
{ href: "/medlemskap", label: "Medlemskap" },
|
||||
{ href: "/vtg", label: "VTG" },
|
||||
];
|
||||
const [isResourcesOpen, setIsResourcesOpen] = useState(false);
|
||||
|
||||
const closeAllMenus = () => {
|
||||
setIsOpen(false);
|
||||
setIsPlacesOpen(false);
|
||||
setIsResourcesOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-[2000] border-b border-white/10 bg-[#25312A]/95 text-white shadow-sm backdrop-blur-md">
|
||||
|
|
@ -90,11 +111,6 @@ export default function Header() {
|
|||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-8 text-[12px] font-extrabold uppercase tracking-[0.14em] text-white/90 md:flex">
|
||||
{navItems.map((item) => (
|
||||
<Link key={item.href} href={item.href} className="transition hover:text-[#8BC34A]">
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<div
|
||||
className="group relative"
|
||||
onMouseEnter={() => setIsPlacesOpen(true)}
|
||||
|
|
@ -103,7 +119,11 @@ export default function Header() {
|
|||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-[12px] font-extrabold uppercase tracking-[0.14em] transition hover:text-[#8BC34A]"
|
||||
onClick={() => setIsPlacesOpen((current) => !current)}
|
||||
onClick={() => {
|
||||
setIsPlacesOpen((current) => !current);
|
||||
setIsResourcesOpen(false);
|
||||
}}
|
||||
aria-expanded={isPlacesOpen}
|
||||
>
|
||||
<span>Steder</span>
|
||||
<span className={`text-[10px] transition ${isPlacesOpen ? "rotate-180" : ""}`}>▾</span>
|
||||
|
|
@ -137,6 +157,67 @@ export default function Header() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{primaryNavItems.map((item) => (
|
||||
<Link key={item.href} href={item.href} className="transition hover:text-[#8BC34A]">
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<div
|
||||
className="group relative"
|
||||
onMouseEnter={() => setIsResourcesOpen(true)}
|
||||
onMouseLeave={() => setIsResourcesOpen(false)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-[12px] font-extrabold uppercase tracking-[0.14em] transition hover:text-[#8BC34A]"
|
||||
onClick={() => {
|
||||
setIsResourcesOpen((current) => !current);
|
||||
setIsPlacesOpen(false);
|
||||
}}
|
||||
aria-expanded={isResourcesOpen}
|
||||
>
|
||||
<span>Ressurser</span>
|
||||
<span className={`text-[10px] transition ${isResourcesOpen ? "rotate-180" : ""}`}>▾</span>
|
||||
</button>
|
||||
|
||||
{isResourcesOpen && (
|
||||
<div className="absolute right-0 top-full z-[2100] w-[20rem] pt-4">
|
||||
<div className="rounded-[1.75rem] border border-white/10 bg-[#25312A] p-5 shadow-2xl">
|
||||
<p className="mb-3 text-[10px] font-extrabold uppercase tracking-[0.2em] text-[#8BC34A]">
|
||||
Ressurser
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{resourceNavItems.map((item) =>
|
||||
item.external ? (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center justify-between text-[13px] font-bold normal-case tracking-normal text-white/88 transition hover:text-[#FF5722]"
|
||||
onClick={() => setIsResourcesOpen(false)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="block text-[13px] font-bold normal-case tracking-normal text-white/88 transition hover:text-[#FF5722]"
|
||||
onClick={() => setIsResourcesOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<button onClick={() => setIsOpen(!isOpen)} className="p-2 text-white md:hidden" aria-label="Meny">
|
||||
|
|
@ -149,46 +230,79 @@ export default function Header() {
|
|||
{isOpen && (
|
||||
<div className="absolute left-0 top-20 max-h-[calc(100vh-5rem)] w-full overflow-y-auto border-b border-white/10 bg-[#25312A] px-6 py-6 shadow-2xl md:hidden">
|
||||
<div className="flex flex-col gap-5 pb-6">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
onClick={() => setIsOpen(false)}
|
||||
href={item.href}
|
||||
className="text-lg font-extrabold uppercase tracking-[0.08em] text-white"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="border-t border-white/10 pt-5">
|
||||
<Link
|
||||
onClick={() => setIsOpen(false)}
|
||||
href="/sted/norge"
|
||||
className="block text-lg font-extrabold uppercase tracking-[0.08em] text-white"
|
||||
>
|
||||
Steder
|
||||
</Link>
|
||||
<div className="mt-4 grid gap-4">
|
||||
{placeGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<p className="mb-2 text-[10px] font-extrabold uppercase tracking-[0.2em] text-[#8BC34A]">
|
||||
{group.label}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{group.items.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
onClick={() => setIsOpen(false)}
|
||||
href={item.href}
|
||||
className="block text-sm font-bold text-white/88"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="border-b border-white/10 pb-5">
|
||||
<Link
|
||||
onClick={closeAllMenus}
|
||||
href="/sted/norge"
|
||||
className="block text-lg font-extrabold uppercase tracking-[0.08em] text-white"
|
||||
>
|
||||
Steder
|
||||
</Link>
|
||||
<div className="mt-4 grid gap-4">
|
||||
{placeGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<p className="mb-2 text-[10px] font-extrabold uppercase tracking-[0.2em] text-[#8BC34A]">
|
||||
{group.label}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{group.items.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
onClick={closeAllMenus}
|
||||
href={item.href}
|
||||
className="block text-sm font-bold text-white/88"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{primaryNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
onClick={closeAllMenus}
|
||||
href={item.href}
|
||||
className="text-lg font-extrabold uppercase tracking-[0.08em] text-white"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<div className="border-t border-white/10 pt-5">
|
||||
<p className="text-[10px] font-extrabold uppercase tracking-[0.2em] text-[#8BC34A]">
|
||||
Ressurser
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
{resourceNavItems.map((item) =>
|
||||
item.external ? (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center justify-between text-sm font-bold text-white/88"
|
||||
onClick={closeAllMenus}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
key={item.href}
|
||||
onClick={closeAllMenus}
|
||||
href={item.href}
|
||||
className="text-sm font-bold text-white/88"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
30
frontend/src/components/InfoPageShell.tsx
Normal file
30
frontend/src/components/InfoPageShell.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
type InfoPageShellProps = {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
intro: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function InfoPageShell({ eyebrow, title, intro, children }: InfoPageShellProps) {
|
||||
return (
|
||||
<main className="site-shell min-h-screen">
|
||||
<section className="border-b border-[#112015]/8 bg-[linear-gradient(135deg,rgba(139,195,74,0.16),rgba(255,255,255,0.92))]">
|
||||
<div className="mx-auto max-w-[1400px] px-4 py-14 sm:px-6 lg:px-8 lg:py-20">
|
||||
<div className="max-w-4xl">
|
||||
<p className="mb-4 text-[11px] font-black uppercase tracking-[0.28em] text-[#8BC34A]">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h1 className="max-w-3xl text-5xl font-black text-[#112015] sm:text-6xl">{title}</h1>
|
||||
<p className="mt-6 max-w-3xl text-base leading-7 text-[#4F5F50] sm:text-lg">{intro}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-[1400px] px-4 py-8 sm:px-6 lg:px-8 lg:py-10">
|
||||
{children}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue