Før stor endring på admin

This commit is contained in:
Erol 2026-04-14 16:31:28 +02:00
parent 1b09e88fd3
commit 05ded6513e
30 changed files with 769 additions and 264 deletions

View file

@ -1823,6 +1823,14 @@ async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdat
async def update_facility_full(facility_id: int, request: Request):
"""Dynamisk endpoint som oppdaterer anlegg, baner og hull (den fulle editoren)."""
data = await request.json()
legacy_field_aliases = {
'vtg_presentasjon': 'vtg_beskrivelse',
'vtg_kursdatoer': 'vtg_datoer',
}
for legacy_field, canonical_field in legacy_field_aliases.items():
if legacy_field in data and canonical_field not in data:
data[canonical_field] = data[legacy_field]
# Felter som er trygge å oppdatere manuelt på anlegget
allowed_fields = [
@ -1834,7 +1842,7 @@ async def update_facility_full(facility_id: int, request: Request):
'nsg_url', 'nsg_data', 'golfamore', 'golfamore_data',
'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url',
'vtg_presentasjon', 'vtg_lenke', 'vtg_pris', 'vtg_kursdatoer',
'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris', 'vtg_datoer',
'guest_requirements', 'scrape_method', 'scrape_status_url',
'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at',
'greenfee_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector', 'vtg_lenke',
@ -1898,8 +1906,10 @@ async def update_facility_full(facility_id: int, request: Request):
await conn.execute(query, *values)
# 2. OPPDATER BANER (COURSES) OG HULL (HOLES)
courses = data.get('courses', [])
courses = data.get('courses') or []
for course in courses:
if not course:
continue
course_id = course.get('id')
if course_id:
# Rens datoformat for PostgreSQL (håndterer Next.js date input)
@ -1923,11 +1933,13 @@ async def update_facility_full(facility_id: int, request: Request):
""",
course.get('name'), course.get('par'), course.get('length_meters'),
course.get('architect'), course.get('status'), course.get('is_main_course'),
json.dumps(course.get('tee_boxes', {})), valid_until, course_id, facility_id)
json.dumps(course.get('tee_boxes') or {}), valid_until, course_id, facility_id)
# 3. OPPDATER HULL PÅ BANEN (HOLES)
holes = course.get('holes', [])
holes = course.get('holes') or []
for hole in holes:
if not hole:
continue
hole_id = hole.get('id')
if hole_id:
await conn.execute("""
@ -1936,7 +1948,7 @@ async def update_facility_full(facility_id: int, request: Request):
WHERE id=$4 AND course_id=$5
""",
hole.get('par'), hole.get('hcp_index'),
json.dumps(hole.get('lengths', {})), hole_id, course_id)
json.dumps(hole.get('lengths') or {}), hole_id, course_id)
return {"status": "success", "message": "Anlegg, baner og scorekort ble oppdatert."}

173
docs/design-system.md Normal file
View file

@ -0,0 +1,173 @@
# TeeOff designsystem
Dette dokumentet beskriver den visuelle profilen slik løsningen faktisk er bygget nå, og setter en tydelig standard for videre arbeid. Utgangspunktet er logoen og de etablerte fargetonene `#8BC24A` og `#FF5722`.
## 1. Designprofil
TeeOff skal oppleves som golfnært, redaksjonelt og praktisk samtidig. Profilen er ikke luksus-klubb eller generisk SaaS; den er bygget rundt natur, banestatus, kart, redaksjonelle historier og raske verktøy.
Kjennetegn:
- Jordnær og nordisk, ikke steril.
- Tydelig redaksjonell typografi med kraftige overskrifter.
- Klare signalfarger for status, handlinger og fremdrift.
- Store flater, avrundede kort og tydelig lagdeling.
- Bilder skal gi stemning, mens data og filtre skal gi kontroll.
## 2. Merkevaregrunnlag
Primære merkevarefarger:
- Brand green: `#8BC24A`
- Brand orange: `#FF5722`
Støttefarger i dagens kodebase:
- Pine 900: `#25312A`
- Pine/ink: `#112015`
- Pine 700: `#39443B`
- Mist 50: `#F3F6EE`
- Surface: `#FFFFFF`
- Ink muted: `#617063`
- Orange dark hover: `#C94F2D`
- Status closed: `#B6473D`
- Status winter: `#D2A63A`
Bruk:
- `#8BC24A` brukes til bekreftelse, aksenter, aktive valg og små signalflater.
- `#FF5722` brukes til primære CTA-er, lenker, høy oppmerksomhet og varme kontraster.
- `#25312A` og `#112015` er base for header, mørke knapper, overlay og typografisk tyngde.
- `#F3F6EE` og hvitt holder store flater lette og gir plass til bilder og data.
## 3. Typografi
Etablert fontbruk i appen:
- Display: `Oswald`
- UI/brødtekst: `Mulish`
Regler:
- Overskrifter skal bruke display-font og tåle høy vekt, store størrelser og tett linjehøyde.
- Brødtekst, skjemaer, kortmetadata og hjelpetekst skal bruke UI-font.
- Eyebrows, små etiketter og filterlabels skal ofte være uppercase med tydelig tracking.
- Lange forklaringer skal være lettleste, ikke komprimerte.
Tone i typografi:
- Overskrifter skal være tydelige og selvsikre.
- Brødtekst skal være enkel, presis og nytteorientert.
- UI-tekst skal prioritere klarhet foran kreativ formulering.
## 4. Layout og form
Gjeldende formspråk:
- Store avrundinger: typisk `1rem` til `2rem`
- Myke kort med tynn ramme og lett skygge
- Bred desktop-ramme: `max-w-[1400px]`
- Luftig spacing med tydelig seksjonsdeling
Regler:
- Kort, filtre og paneler skal føles som fysiske flater på toppen av en myk bakgrunn.
- Unngå harde svarte skiller og kompakte admin-tabeller i offentlig UI.
- Bruk få, tydelige lag i stedet for mange små bokser inni hverandre.
- Mobil skal prioriteres med vertikal rytme og tydelige trykkflater.
## 5. Komponentprinsipper
### Header og navigasjon
- Header skal ligge mørkt og stabilt over innholdet.
- Desktop-nav kan være kompakt og editorial.
- Mobilmeny skal være enkel å skanne, med viktigste innhold først og områdenavigasjon som egen blokk.
### Knapper og lenker
- Primær handling: orange fyll.
- Sekundær handling: mørk fyll eller lys outline, avhengig av bakgrunn.
- Vage CTA-er som `Se anlegg` bør unngås. Bruk heller presise handlinger som `Baneprofil`, `Les artikkelen` eller `Innmelding`.
- I frontend skal dette som utgangspunkt mappes til delte varianter i `globals.css`:
- `btn-primary` for viktigste handling i en seksjon.
- `btn-secondary` for sekundære handlinger på lyse flater.
- `btn-secondary-dark` for sekundære handlinger oppå bilder og mørke flater.
- `btn-ink` kun når en mørk handling bevisst skal være underordnet en orange primærknapp.
### Kort
- Kort skal bære både bilde, status og nytteinformasjon.
- Statuser skal vises som badges, ikke gjemmes i brødtekst.
- Kort skal ha tydelig hover-respons på desktop, men uten overdreven animasjon.
### Filtre
- Filtre er en kjernefunksjon og skal føles som arbeidsverktøy, ikke pynt.
- Felter skal være store nok til fingerbruk på mobil.
- Standardvalg må være tydelige og beskrive resultatet presist.
## 6. Bilder og visuell stemning
- Store banebilder er en sentral del av identiteten.
- Bilder skal fremheve landskap, hull, vær og opplevelse.
- Overlays skal mørkne nok til at tekst alltid er lesbar.
- Unngå generiske stock-bilder og sterile dekorflater.
## 7. Bevegelse
- Motion skal være diskret og funksjonell.
- Tillatte virkemidler:
- fade mellom hero-bilder
- lett hover-løft på kort
- tydelig overgang på knapper og lenker
Unngå:
- tung parallax
- unødvendige mikroanimasjoner på alt
- dekorativ motion som konkurrerer med innholdet
## 8. Språk og begreper
Anbefalt standard:
- Bruk `Golfbaner` i navigasjon, SEO, lister, overskrifter og publikumsvendt oppdagelsesinnhold.
- Bruk `Baneprofil` som CTA til detaljsiden.
- Bruk `golfanlegg` bare når man faktisk mener hele anlegget som fysisk eller organisatorisk enhet.
Begrunnelse:
- `Golfbaner` matcher brukerintensjon bedre i søk og i vanlig språkbruk.
- `golfanlegg` er mer presist, men mer teknisk og mindre naturlig i navigasjon og markedsføring.
## 9. Designregler for nye sider
- Start med én tydelig hovedhandling per seksjon.
- Bruk brand green til status og systembekreftelse, ikke som eneste CTA-farge.
- Bruk brand orange når noe skal klikkes, åpnes eller prioriteres.
- Hold bakgrunnene lyse, men aldri flate og døde hvis siden trenger karakter.
- Sørg for at kort, badges og filtre bruker samme radius- og skyggefamilie som resten av siden.
- Hvis en side føles som et adminverktøy, må den enten flyttes til admin eller gis en tydeligere offentlig redaksjonell behandling.
## 10. Ikke gjør dette
- Ikke innfør nye tilfeldige grønnfarger ved siden av merkevaregrønn.
- Ikke bruk lilla, blågrå SaaS-paletter eller standard dark mode som bryter med merkevaren.
- Ikke bruk generiske CTA-tekster når mer presis tekst er mulig.
- Ikke bygg nye offentlige sider med tett tabellfølelse når kort eller seksjoner fungerer bedre.
- Ikke bytt fontfamilier uten en bevisst oppdatering av hele profilen.
## 11. Kilde i kode
Dette dokumentet er forankret i nåværende implementasjon, spesielt:
- `frontend/src/app/globals.css`
- `frontend/src/app/layout.tsx`
- `frontend/src/components/Header.tsx`
- `frontend/src/app/HeroSlider.tsx`
- `frontend/src/app/FacilitySearch.tsx`
- `frontend/src/app/banebesok/page.tsx`
- `frontend/src/app/meninger/page.tsx`
Ved senere redesign skal dette dokumentet oppdateres samtidig som design-tokenene eller hovedmønstrene endres.

View file

@ -572,7 +572,7 @@ export default function FacilitySearch({
</FieldSelect>
<FieldSelect label="Antall hull" value={holeFilter} onChange={setHoleFilter} labelClassName={labelClassName}>
<option value="">Alle anlegg</option>
<option value="">Alle golfbaner</option>
<option value="18-plus">18 hull eller mer</option>
<option value="18">Nøyaktig 18 hull</option>
<option value="9">9 hull</option>
@ -620,8 +620,8 @@ export default function FacilitySearch({
setSpecialFilter("");
setSortMethod(userLocation ? "dist" : "updated");
}}
className={`mt-[1.72rem] h-[52px] rounded-2xl px-5 text-[11px] font-extrabold uppercase tracking-[0.2em] transition ${
variant === "home" ? "bg-[#FF5722] text-white hover:bg-[#C94F2D]" : "bg-[#25312A] text-white hover:bg-[#39443B]"
className={`btn btn-md mt-[1.72rem] h-[52px] ${
variant === "home" ? "btn-primary" : "btn-secondary"
}`}
>
Nullstill
@ -774,7 +774,7 @@ export default function FacilitySearch({
)}
</div>
<Link href={`/golfbaner/${facility.slug}`} className="justify-self-end shrink-0 text-right text-[#FF5722] transition hover:text-[#C94F2D]">
Se anlegg
Baneprofil
</Link>
</div>
</div>

View file

@ -139,7 +139,7 @@ export default function HeroSlider({ facilities }: { facilities: Facility[] }) {
<div className="flex flex-col items-center gap-4">
<Link
href={`/golfbaner/${sliderItems[currentIndex].slug}`}
className="rounded-full bg-white/72 px-5 py-3 text-center text-2xl font-bold uppercase tracking-tight text-[#FF5722] shadow-lg backdrop-blur-sm transition hover:bg-white sm:px-8 sm:text-4xl"
className="btn btn-lg btn-primary text-center text-xl tracking-tight shadow-lg sm:px-8 sm:text-3xl lg:text-4xl"
>
{sliderItems[currentIndex].name}
</Link>

View file

@ -395,14 +395,14 @@ export default function AdminArticlesPage() {
type="button"
onClick={handleSeedImported}
disabled={isSeeding}
className="rounded-full border border-[#11280f]/10 bg-white px-5 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-[#11280f] transition hover:border-[#8BC34A] disabled:opacity-50"
className="btn btn-md btn-secondary disabled:opacity-50"
>
{isSeeding ? "Importerer..." : "Seed fra import"}
</button>
<button
type="button"
onClick={handleCreateNew}
className="rounded-full bg-[#11280f] px-5 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#25312A]"
className="btn btn-md btn-primary"
>
Ny artikkel
</button>
@ -617,7 +617,7 @@ export default function AdminArticlesPage() {
type="button"
onClick={() => heroImageInputRef.current?.click()}
disabled={isUploadingHeroImages}
className="rounded-full border border-[#112015]/10 bg-white px-4 py-2 text-[10px] font-black uppercase tracking-[0.16em] text-[#112015] transition hover:border-[#8BC34A] disabled:cursor-not-allowed disabled:opacity-50"
className="btn btn-sm btn-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
{isUploadingHeroImages ? "Laster opp..." : "Last opp bilder"}
</button>
@ -687,7 +687,7 @@ export default function AdminArticlesPage() {
type="button"
onClick={handleSave}
disabled={isSaving}
className="rounded-full bg-[#112015] px-5 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#25312A] disabled:opacity-50"
className="btn btn-md btn-primary disabled:opacity-50"
>
{isSaving ? "Lagrer..." : "Lagre artikkel"}
</button>
@ -696,7 +696,7 @@ export default function AdminArticlesPage() {
type="button"
onClick={handleDelete}
disabled={isDeleting}
className="rounded-full border border-red-200 bg-red-50 px-5 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-red-700 transition hover:bg-red-100 disabled:opacity-50"
className="btn btn-md btn-danger disabled:opacity-50"
>
{isDeleting ? "Sletter..." : "Slett artikkel"}
</button>
@ -705,7 +705,7 @@ export default function AdminArticlesPage() {
<Link
href={`/${form.section}/${form.slug}`}
target="_blank"
className="rounded-full border border-[#112015]/10 bg-white px-5 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-[#112015] transition hover:border-[#8BC34A]"
className="btn btn-md btn-secondary"
>
Åpne offentlig side
</Link>
@ -714,7 +714,7 @@ export default function AdminArticlesPage() {
<Link
href={`/golfbaner/${form.facility_slug}`}
target="_blank"
className="rounded-full border border-[#112015]/10 bg-white px-5 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-[#112015] transition hover:border-[#FF5722]"
className="btn btn-md btn-secondary"
>
Åpne baneprofil
</Link>

View file

@ -119,7 +119,7 @@ export default function GreenfeeWasher() {
<h1 className="text-4xl font-black">Greenfee-Vaskeriet</h1>
<p className="text-sm text-gray-600 mt-2">Sjekk at prisene gir mening før publisering.</p>
</div>
<button onClick={handleApprove} disabled={saving || selectedIds.length === 0} className="w-full rounded-xl bg-[#8bc34a] px-8 py-4 font-black uppercase tracking-widest text-white shadow-lg transition-all hover:scale-105 disabled:opacity-50 md:w-auto">
<button onClick={handleApprove} disabled={saving || selectedIds.length === 0} className="btn btn-lg btn-primary w-full md:w-auto disabled:opacity-50">
{saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
</button>
</div>
@ -143,7 +143,7 @@ export default function GreenfeeWasher() {
<div className="flex-grow space-y-4">
<div className="flex flex-col gap-3 border-b pb-4 md:flex-row md:items-center md:justify-between">
<h3 className="text-2xl font-black">{draft.name} <span className="text-xs font-mono font-bold bg-gray-100 text-gray-400 px-2 py-1 rounded-md">ID: {draft.id}</span></h3>
<a href={draft.greenfee_url?.split(',')[0]} target="_blank" className="inline-flex w-full items-center justify-center rounded-lg bg-blue-50 px-4 py-3 text-xs font-bold text-blue-600 hover:underline md:w-auto">Sjekk Nettside </a>
<a href={draft.greenfee_url?.split(',')[0]} target="_blank" className="btn btn-md btn-secondary w-full md:w-auto">Sjekk Nettside </a>
</div>
{draft.greenfee_draft?.ai_begrunnelse && (
@ -180,7 +180,7 @@ export default function GreenfeeWasher() {
<input className="w-full rounded border border-gray-100 p-2 text-xs outline-none focus:border-[#8bc34a]" value={row.priskategori || ''} onChange={e => updateField(draft.id, idx, 'priskategori', e.target.value)} placeholder="Kategori" />
<input className="w-full rounded border border-gray-100 p-2 text-center text-xs outline-none focus:border-[#8bc34a]" type="number" value={row.pris_voksne || ''} onChange={e => updateField(draft.id, idx, 'pris_voksne', e.target.value)} placeholder="Voksen" />
<input className="w-full rounded border border-gray-100 p-2 text-center text-xs outline-none focus:border-[#8bc34a]" type="number" value={row.pris_junior || ''} onChange={e => updateField(draft.id, idx, 'pris_junior', e.target.value)} placeholder="Junior" />
<button onClick={() => removeRow(draft.id, idx)} className="px-2 text-left text-red-400 transition-colors hover:text-red-600 sm:text-center sm:opacity-0 sm:group-hover:opacity-100" title="Slett rad"></button>
<button onClick={() => removeRow(draft.id, idx)} className="btn btn-sm btn-danger sm:opacity-0 sm:group-hover:opacity-100" title="Slett rad"></button>
</div>
))}
<button onClick={() => {
@ -188,7 +188,7 @@ export default function GreenfeeWasher() {
const draftIndex = newDrafts.findIndex(d => d.id === draft.id);
newDrafts[draftIndex].edit_greenfee.push({ banenavn: '', priskategori: '', pris_voksne: '', pris_junior: '' });
setDrafts(newDrafts);
}} className="text-xs font-bold text-[#8bc34a] hover:underline mt-2 inline-block">
}} className="btn btn-sm btn-secondary mt-2">
+ Legg til manuell rad
</button>
</div>

View file

@ -96,7 +96,7 @@ export default function MembershipWasher() {
<button
onClick={handleApprove}
disabled={saving || selectedIds.length === 0}
className="w-full rounded-xl bg-[#8bc34a] px-8 py-4 font-black uppercase tracking-widest text-white shadow-lg transition-all hover:scale-105 disabled:scale-100 disabled:opacity-50 md:w-auto"
className="btn btn-lg btn-primary w-full md:w-auto disabled:opacity-50"
>
{saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
</button>

View file

@ -113,8 +113,8 @@ const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: n
<div className="flex flex-col gap-1 w-full max-w-[200px] animate-fade-in">
<textarea autoFocus rows={2} className="border-2 border-[#8bc34a] p-2 text-[10px] w-full rounded-lg outline-none resize-y shadow-sm font-mono text-black bg-white" value={value} onChange={e => setValue(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSave(); } }} placeholder="Lim inn URL(er)..." />
<div className="flex gap-1">
<button onClick={handleSave} className="bg-[#8bc34a] text-white px-3 py-1.5 rounded-md text-[10px] font-black uppercase flex-1 shadow-sm hover:bg-[#7ca982]">Lagre</button>
<button onClick={() => { setIsEditing(false); setValue(initialValue || ''); }} className="bg-gray-200 text-gray-600 px-3 py-1.5 rounded-md text-[10px] font-black uppercase hover:bg-gray-300">Avbryt</button>
<button onClick={handleSave} className="btn btn-sm btn-primary flex-1">Lagre</button>
<button onClick={() => { setIsEditing(false); setValue(initialValue || ''); }} className="btn btn-sm btn-secondary">Avbryt</button>
</div>
</div>
);
@ -151,6 +151,7 @@ export default function AdminDashboard() {
const [twoFactorSetup, setTwoFactorSetup] = useState<TwoFactorSetupResponse | null>(null);
const [copiedTwoFactorField, setCopiedTwoFactorField] = useState<'secret' | 'uri' | null>(null);
const [queueFeedback, setQueueFeedback] = useState<QueueFeedback | null>(null);
const [dismissedLatestJobKeys, setDismissedLatestJobKeys] = useState<Partial<Record<AdminTab, string>>>({});
const fetchFacilities = () => {
fetch(`${API_URL}/facilities`)
@ -177,6 +178,13 @@ export default function AdminDashboard() {
);
const latestJob = scrapeJobs[0] || null;
const isScraping = !!activeJob;
const latestJobCardKey = latestJob
? `${latestJob.job_type}:${latestJob.id}:${latestJob.status}:${latestJob.attempt_count ?? 0}:${latestJob.next_retry_at ?? ''}:${latestJob.finished_at ?? ''}`
: null;
const isLatestJobDismissed =
!!latestJobCardKey &&
latestJob.job_type === activeTab &&
dismissedLatestJobKeys[activeTab] === latestJobCardKey;
useEffect(() => {
fetchFacilities();
@ -251,6 +259,7 @@ export default function AdminDashboard() {
const filteredCourses = facility.course_statuses.filter((cs: any) => {
const s = cs.status || 'ukjent';
if (statusFilter === 'aapne') return s === 'aapen';
if (statusFilter === 'ikke_aapne') return s !== 'aapen';
if (statusFilter === 'ikke_stengt') return ['aapen', 'aapen_med_vintergreener', 'aapner_snart'].includes(s);
if (statusFilter === 'stengt') return s === 'stengt' || s === 'nedlagt';
if (statusFilter === 'ukjent_feil') return s === 'ukjent' || s === 'NOT_FOUND';
@ -291,6 +300,7 @@ export default function AdminDashboard() {
if (!latestJob) return scrapeJobs.slice(0, 4);
return scrapeJobs.filter(job => job.id !== latestJob.id).slice(0, 4);
}, [latestJob, scrapeJobs]);
const showJobHistory = recentJobs.length > 0 && !isLatestJobDismissed;
const latestJobProgress = useMemo(() => {
const total = latestJob?.progress_total ?? latestJob?.total_facilities ?? 0;
@ -537,8 +547,8 @@ export default function AdminDashboard() {
)}
</div>
<div className="bg-gray-50 p-6 flex justify-end gap-4 shrink-0">
<button onClick={() => setEditingFacility(null)} className="px-6 py-3 rounded-xl text-xs font-bold uppercase tracking-widest text-gray-500 hover:bg-gray-200 transition-colors">Avbryt</button>
<button onClick={handleSaveEdit} disabled={isSaving} className="bg-[#8bc34a] text-white px-6 py-3 rounded-xl text-xs font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50">
<button onClick={() => setEditingFacility(null)} className="btn btn-md btn-secondary">Avbryt</button>
<button onClick={handleSaveEdit} disabled={isSaving} className="btn btn-md btn-primary disabled:opacity-50">
{isSaving ? 'Lagrer...' : 'Lagre endringer'}
</button>
</div>
@ -558,7 +568,7 @@ export default function AdminDashboard() {
<div className="relative mx-auto my-4 flex w-full max-w-5xl flex-col overflow-hidden rounded-[2rem] bg-white shadow-2xl">
<button
onClick={closeTwoFactorModal}
className="absolute right-7 top-7 z-30 inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-white/12 text-lg font-black text-white backdrop-blur transition-colors hover:bg-white/20"
className="btn-icon absolute right-7 top-7 z-30 border-white/20 bg-white/12 text-lg text-white backdrop-blur hover:bg-white/20"
aria-label="Lukk 2FA-vindu"
title="Lukk"
>
@ -573,7 +583,7 @@ export default function AdminDashboard() {
Bekreft passordet ditt nytt for å vise QR-koden og den manuelle oppsettsnøkkelen som kan brukes i 1Password.
</p>
</div>
<button onClick={closeTwoFactorModal} className="hidden rounded-xl bg-white/10 px-4 py-2 text-xs font-black uppercase tracking-widest text-white hover:bg-white/20 md:block">
<button onClick={closeTwoFactorModal} className="btn btn-sm btn-secondary-dark hidden md:inline-flex">
Lukk
</button>
</div>
@ -617,7 +627,7 @@ export default function AdminDashboard() {
<button
type="submit"
disabled={isLoadingTwoFactor || twoFactorPassword.trim().length === 0}
className="w-full rounded-2xl bg-[#8bc34a] px-6 py-4 text-xs font-black uppercase tracking-[0.2em] text-white shadow-lg transition-all hover:scale-[1.01] disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-400"
className="btn btn-lg btn-primary w-full disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-400"
>
{isLoadingTwoFactor ? 'Henter oppsett...' : 'Vis oppsett for 1Password'}
</button>
@ -661,7 +671,7 @@ export default function AdminDashboard() {
<button
type="button"
onClick={() => copyTwoFactorValue('secret', twoFactorSetup.otp_secret)}
className="mt-4 w-full rounded-xl border border-gray-200 px-4 py-3 text-[10px] font-black uppercase tracking-[0.18em] text-gray-500 transition-colors hover:border-[#8bc34a] hover:text-[#11280f] sm:w-auto"
className="btn btn-md btn-secondary mt-4 w-full sm:w-auto"
>
{copiedTwoFactorField === 'secret' ? 'Kopiert' : 'Kopier nøkkel'}
</button>
@ -673,7 +683,7 @@ export default function AdminDashboard() {
<button
type="button"
onClick={() => copyTwoFactorValue('uri', twoFactorSetup.provisioning_uri)}
className="mt-4 w-full rounded-xl border border-gray-200 px-4 py-3 text-[10px] font-black uppercase tracking-[0.18em] text-gray-500 transition-colors hover:border-[#8bc34a] hover:text-[#11280f] sm:w-auto"
className="btn btn-md btn-secondary mt-4 w-full sm:w-auto"
>
{copiedTwoFactorField === 'uri' ? 'Kopiert' : 'Kopier URI'}
</button>
@ -817,7 +827,7 @@ export default function AdminDashboard() {
<div className="mb-6 flex md:hidden">
<button
onClick={() => setShowMobileAdminMenu(true)}
className="inline-flex items-center gap-3 rounded-2xl bg-[#11280f] px-5 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-white shadow-lg"
className="btn btn-md btn-primary"
>
<span className="text-lg leading-none"></span>
Adminmeny
@ -833,21 +843,22 @@ export default function AdminDashboard() {
<div className="flex flex-wrap items-center gap-3">
<Link
href="/admin/artikler"
className="rounded-2xl border border-gray-200 bg-white px-5 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500 shadow-sm transition-colors hover:border-[#ff5722] hover:text-[#11280f]"
className="btn btn-md btn-secondary"
>
Artikler / Banebesøk
</Link>
<button
onClick={openTwoFactorModal}
className="rounded-2xl border border-gray-200 bg-white px-5 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500 shadow-sm transition-colors hover:border-[#8bc34a] hover:text-[#11280f]"
className="btn btn-md btn-secondary"
>
2FA / 1Password
</button>
<button
onClick={handleRunScrapers}
disabled={selectedFacilities.length === 0 || isQueueing}
className={`text-white px-6 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl transition-all whitespace-nowrap
${isQueueing ? 'bg-yellow-500 animate-pulse' : 'bg-[#8bc34a] hover:scale-105 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed'}`}
className={`btn btn-lg whitespace-nowrap disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-400 ${
isQueueing ? 'bg-yellow-500 text-white animate-pulse' : 'btn-primary'
}`}
>
{isQueueing ? 'Legger i kø...' : isScraping ? `Legg ${activeTab}-skraping i kø (${selectedFacilities.length})` : `Kjør ${activeTab}-skrapere (${selectedFacilities.length})`}
</button>
@ -872,7 +883,7 @@ export default function AdminDashboard() {
</div>
<button
onClick={() => setQueueFeedback(null)}
className="self-start rounded-xl border border-white/70 bg-white/80 px-3 py-2 text-[10px] font-black uppercase tracking-widest text-gray-500 transition-colors hover:text-[#11280f]"
className="btn btn-sm btn-secondary self-start"
>
Lukk
</button>
@ -880,7 +891,7 @@ export default function AdminDashboard() {
</div>
)}
{latestJob && latestJob.job_type === activeTab && (
{latestJob && latestJob.job_type === activeTab && !isLatestJobDismissed && (
<div className={`mb-8 rounded-[1.75rem] border p-5 md:p-6 animate-fade-in ${
latestJob.status === 'failed'
? 'bg-red-50 border-red-100'
@ -1016,17 +1027,31 @@ export default function AdminDashboard() {
<p className="text-xs text-red-600 leading-relaxed">{latestJob.error_message}</p>
)}
</div>
<button
onClick={() => fetchScrapeJobs(activeTab)}
className="px-4 py-2 rounded-xl bg-white text-[10px] font-black uppercase tracking-widest text-gray-500 border border-gray-200 hover:border-[#8bc34a] hover:text-[#11280f] transition-colors"
>
Oppdater status
</button>
<div className="flex items-center gap-2 self-start">
<button
onClick={() => fetchScrapeJobs(activeTab)}
className="px-4 py-2 rounded-xl bg-white text-[10px] font-black uppercase tracking-widest text-gray-500 border border-gray-200 hover:border-[#8bc34a] hover:text-[#11280f] transition-colors"
>
Oppdater status
</button>
<button
type="button"
onClick={() => {
if (!latestJobCardKey) return;
setDismissedLatestJobKeys((prev) => ({ ...prev, [activeTab]: latestJobCardKey }));
}}
className="inline-flex h-10 w-10 items-center justify-center rounded-xl bg-white text-xl leading-none text-gray-500 border border-gray-200 hover:border-[#8bc34a] hover:text-[#11280f] transition-colors"
aria-label="Lukk jobbstatus"
title="Lukk"
>
×
</button>
</div>
</div>
</div>
)}
{recentJobs.length > 0 && (
{showJobHistory && (
<section className="mb-8 rounded-[1.75rem] border border-gray-100 bg-[#fbfcf8] p-5 md:p-6">
<div className="mb-4 flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
@ -1124,6 +1149,7 @@ export default function AdminDashboard() {
<select id="statusFilter" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} className="border-2 border-gray-200 rounded-xl p-2 text-sm font-bold text-[#11280f] focus:border-[#8bc34a] focus:outline-none transition-colors cursor-pointer">
<option value="alle">Vis alle anlegg</option>
<option value="aapne">🟢 Kun åpne baner</option>
<option value="ikke_aapne">🟠 Alle som ikke er åpne</option>
<option value="ikke_stengt">🟡 Ikke stengt (Åpne/Vintergreen/Snart)</option>
<option value="stengt">🔴 Kun stengte baner</option>
<option value="ukjent_feil"> Ukjent / Skrapefeil</option>
@ -1206,26 +1232,26 @@ export default function AdminDashboard() {
<div className="flex w-full flex-col gap-2 sm:w-auto sm:min-w-[180px]">
{activeTab === 'banestatus' && (
<button onClick={() => openEditModal(f)} className="rounded-2xl bg-white px-4 py-3 text-[10px] font-black uppercase tracking-widest text-[#11280f] shadow-sm transition-colors hover:bg-gray-100">
<button onClick={() => openEditModal(f)} className="btn btn-md btn-secondary">
Innstillinger
</button>
)}
{activeTab === 'medlemskap' && hasMemDraft && (
<Link href="/admin/medlemskap" className="rounded-2xl border border-yellow-200 bg-yellow-100 px-4 py-3 text-center text-[10px] font-black uppercase tracking-widest text-yellow-800 transition-colors hover:bg-yellow-200">
<Link href="/admin/medlemskap" className="btn btn-md btn-danger text-center">
Til vaskeri
</Link>
)}
{activeTab === 'greenfee' && hasGfDraft && (
<Link href="/admin/greenfee" className="rounded-2xl border border-yellow-200 bg-yellow-100 px-4 py-3 text-center text-[10px] font-black uppercase tracking-widest text-yellow-800 transition-colors hover:bg-yellow-200">
<Link href="/admin/greenfee" className="btn btn-md btn-danger text-center">
Til vaskeri
</Link>
)}
{activeTab === 'vtg' && hasVtgDraft && (
<Link href="/admin/vtg" className="rounded-2xl border border-yellow-200 bg-yellow-100 px-4 py-3 text-center text-[10px] font-black uppercase tracking-widest text-yellow-800 transition-colors hover:bg-yellow-200">
<Link href="/admin/vtg" className="btn btn-md btn-danger text-center">
Til vaskeri
</Link>
)}
<Link href={`/admin/rediger/${f.slug}`} className="rounded-2xl bg-[#11280f] px-4 py-3 text-center text-[10px] font-black uppercase tracking-widest text-white transition-colors hover:bg-[#8bc34a]">
<Link href={`/admin/rediger/${f.slug}`} className="btn btn-md btn-ink text-center">
Rediger alt
</Link>
</div>
@ -1519,12 +1545,12 @@ export default function AdminDashboard() {
<td className={`py-6 text-right pr-4 sticky right-0 z-10 min-w-[150px] ${isHighlighted ? 'bg-[#edf6e3]' : 'bg-white group-hover:bg-gray-50/50'}`}>
<div className="flex flex-col gap-2 items-end">
{activeTab === 'banestatus' && <button onClick={() => openEditModal(f)} className="bg-gray-100 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest text-[#11280f] hover:bg-gray-200 transition-all whitespace-nowrap">Innstillinger</button>}
{activeTab === 'medlemskap' && hasMemDraft && <Link href="/admin/medlemskap" className="bg-yellow-100 text-yellow-800 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-yellow-200 transition-all whitespace-nowrap shadow-sm border border-yellow-200"> til Vaskeri</Link>}
{activeTab === 'greenfee' && hasGfDraft && <Link href="/admin/greenfee" className="bg-yellow-100 text-yellow-800 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-yellow-200 transition-all whitespace-nowrap shadow-sm border border-yellow-200"> til Vaskeri</Link>}
{activeTab === 'vtg' && hasVtgDraft && <Link href="/admin/vtg" className="bg-yellow-100 text-yellow-800 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-yellow-200 transition-all whitespace-nowrap shadow-sm border border-yellow-200"> til Vaskeri</Link>}
{activeTab === 'banestatus' && <button onClick={() => openEditModal(f)} className="btn btn-sm btn-secondary whitespace-nowrap">Innstillinger</button>}
{activeTab === 'medlemskap' && hasMemDraft && <Link href="/admin/medlemskap" className="btn btn-sm btn-danger whitespace-nowrap"> til Vaskeri</Link>}
{activeTab === 'greenfee' && hasGfDraft && <Link href="/admin/greenfee" className="btn btn-sm btn-danger whitespace-nowrap"> til Vaskeri</Link>}
{activeTab === 'vtg' && hasVtgDraft && <Link href="/admin/vtg" className="btn btn-sm btn-danger whitespace-nowrap"> til Vaskeri</Link>}
<Link href={`/admin/rediger/${f.slug}`} className="bg-[#11280f] px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest text-white hover:bg-[#8bc34a] transition-all whitespace-nowrap text-center">Rediger alt</Link>
<Link href={`/admin/rediger/${f.slug}`} className="btn btn-sm btn-ink whitespace-nowrap text-center">Rediger alt</Link>
</div>
</td>
</tr>

View file

@ -75,11 +75,11 @@ const KeyValueEditor = ({ label, value, onChange }: { label: string, value: any,
value={String(v)}
onChange={e => updateVal(k, e.target.value)}
/>
<button onClick={() => removeKey(k)} className="w-full rounded-xl border border-red-200 bg-red-100 p-4 text-left text-lg font-black text-red-700 transition-colors hover:bg-red-200 hover:text-red-900 md:w-auto md:text-center"></button>
<button onClick={() => removeKey(k)} className="btn btn-md btn-danger w-full md:w-auto md:text-center"></button>
</div>
))}
</div>
<button onClick={addRow} className="mt-2 text-left text-sm font-black text-[#8bc34a] hover:text-[#11280f] transition-colors bg-white px-6 py-3 rounded-xl border-2 border-[#8bc34a] self-start">+ Legg til ny rad</button>
<button onClick={addRow} className="btn btn-md btn-secondary mt-2 self-start">+ Legg til ny rad</button>
</div>
);
};
@ -112,7 +112,7 @@ const ListObjectEditor = ({ label, value, templateKeys, onChange }: { label: str
<div className="space-y-6">
{items.map((item, idx) => (
<div key={idx} className="flex flex-col bg-white p-6 rounded-2xl border-2 border-gray-300 shadow-sm relative group hover:border-[#8bc34a] transition-colors">
<button onClick={() => removeRow(idx)} className="absolute top-4 right-4 w-8 h-8 flex items-center justify-center bg-red-100 text-red-700 hover:bg-red-200 hover:text-red-900 rounded-full text-sm font-black transition-colors border border-red-200 z-10"></button>
<button onClick={() => removeRow(idx)} className="btn btn-sm btn-danger absolute right-4 top-4 z-10"></button>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pr-10">
{templateKeys.map(key => (
<div key={key} className="flex flex-col gap-2">
@ -128,7 +128,7 @@ const ListObjectEditor = ({ label, value, templateKeys, onChange }: { label: str
</div>
))}
</div>
<button onClick={addRow} className="mt-2 text-left text-sm font-black text-[#8bc34a] hover:text-[#11280f] transition-colors bg-white px-6 py-3 rounded-xl border-2 border-[#8bc34a] self-start">+ Legg til nytt element</button>
<button onClick={addRow} className="btn btn-md btn-secondary mt-2 self-start">+ Legg til nytt element</button>
</div>
);
};
@ -370,8 +370,8 @@ const ScorecardBuilder = ({ course, onChange }: { course: any, onChange: (c: any
</div>
<div className="flex flex-col gap-4 px-2 sm:flex-row">
<button onClick={addHole} className="text-sm font-black text-[#8bc34a] hover:text-[#11280f] px-4 py-3 border-2 border-[#8bc34a] rounded-xl">+ Legg til hull</button>
<button onClick={removeLastHole} className="text-sm font-black text-red-500 hover:text-red-700 px-4 py-3 border-2 border-red-500 rounded-xl">- Slett siste hull</button>
<button onClick={addHole} className="btn btn-md btn-secondary">+ Legg til hull</button>
<button onClick={removeLastHole} className="btn btn-md btn-danger">- Slett siste hull</button>
</div>
</div>
);
@ -446,7 +446,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
<button
onClick={handleSave}
disabled={saving}
className="bg-[#11280f] text-white px-8 py-4 rounded-full font-black uppercase tracking-widest hover:bg-[#8bc34a] transition-colors shadow-xl disabled:opacity-50 w-full md:w-auto"
className="btn btn-lg btn-primary w-full md:w-auto disabled:opacity-50"
>
{saving ? "Lagrer..." : "Lagre endringer"}
</button>

View file

@ -126,7 +126,7 @@ export default function VtgWasher() {
<h1 className="text-4xl font-black">VTG-Vaskeriet</h1>
<p className="text-sm text-gray-600 mt-2"> gjennom og godkjenn kursinformasjon for Veien til Golf.</p>
</div>
<button onClick={handleApprove} disabled={saving || selectedIds.length === 0} className="w-full rounded-xl bg-[#8bc34a] px-8 py-4 font-black uppercase tracking-widest text-white shadow-lg transition-all hover:scale-105 disabled:opacity-50 md:w-auto">
<button onClick={handleApprove} disabled={saving || selectedIds.length === 0} className="btn btn-lg btn-primary w-full md:w-auto disabled:opacity-50">
{saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
</button>
</div>
@ -150,7 +150,7 @@ export default function VtgWasher() {
<div className="flex-grow space-y-4">
<div className="flex flex-col gap-3 border-b pb-4 md:flex-row md:items-center md:justify-between">
<h3 className="text-2xl font-black">{draft.name} <span className="text-xs font-mono font-bold bg-gray-100 text-gray-400 px-2 py-1 rounded-md">ID: {draft.id}</span></h3>
<a href={draft.vtg_lenke?.split(',')[0]} target="_blank" className="inline-flex w-full items-center justify-center rounded-lg bg-blue-50 px-4 py-3 text-xs font-bold text-blue-600 hover:underline md:w-auto">Sjekk Nettside </a>
<a href={draft.vtg_lenke?.split(',')[0]} target="_blank" className="btn btn-md btn-secondary w-full md:w-auto">Sjekk Nettside </a>
</div>
{draft.vtg_draft?.ai_begrunnelse && (
@ -189,11 +189,11 @@ export default function VtgWasher() {
<option value="Venteliste">Venteliste</option>
<option value="Få plasser"> plasser</option>
</select>
<button onClick={() => removeDateRow(draft.id, idx)} className="px-2 text-left text-red-400 transition-colors hover:text-red-600 sm:text-center sm:opacity-0 sm:group-hover:opacity-100" title="Slett dato"></button>
<button onClick={() => removeDateRow(draft.id, idx)} className="btn btn-sm btn-danger sm:opacity-0 sm:group-hover:opacity-100" title="Slett dato"></button>
</div>
))
)}
<button onClick={() => addDateRow(draft.id)} className="text-xs font-bold text-[#8bc34a] hover:underline mt-2 inline-block">
<button onClick={() => addDateRow(draft.id)} className="btn btn-sm btn-secondary mt-2">
+ Legg til ny dato
</button>
</div>

View file

@ -247,14 +247,14 @@ export default async function CourseVisitPage({ params }: CourseVisitPageProps)
{article.facilitySlug ? (
<Link
href={`/golfbaner/${article.facilitySlug}`}
className="inline-flex items-center justify-center rounded-full bg-[#FF5722] px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#C94F2D]"
className="btn btn-md btn-primary"
>
Åpne baneprofil
</Link>
) : null}
<Link
href="/banebesok"
className="inline-flex items-center justify-center rounded-full border border-white/12 bg-white/8 px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-white/14"
className="btn btn-md btn-secondary-dark"
>
Tilbake til banebesøk
</Link>
@ -263,7 +263,7 @@ export default async function CourseVisitPage({ params }: CourseVisitPageProps)
href={article.sourceUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-full border border-white/12 bg-white/8 px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-white/14"
className="btn btn-md btn-secondary-dark"
>
Original kilde
</a>

View file

@ -82,16 +82,16 @@ export default async function CourseVisitsPage() {
<div className="mt-6 flex flex-wrap gap-3">
<Link
href={`/banebesok/${featuredArticle.slug}`}
className="inline-flex items-center rounded-full bg-[#FF5722] px-5 py-3 text-sm font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#C94F2D]"
className="btn btn-md btn-primary"
>
Les artikkelen
</Link>
{featuredArticle.facilitySlug ? (
<Link
href={`/golfbaner/${featuredArticle.facilitySlug}`}
className="inline-flex items-center rounded-full border border-white/18 bg-white/10 px-5 py-3 text-sm font-black uppercase tracking-[0.16em] text-white transition hover:bg-white/18"
className="btn btn-md btn-secondary-dark"
>
til baneprofil
Baneprofil
</Link>
) : null}
</div>
@ -169,14 +169,14 @@ export default async function CourseVisitsPage() {
<div className="mt-6 flex flex-wrap gap-3">
<Link
href={`/banebesok/${article.slug}`}
className="inline-flex items-center rounded-full bg-[#112015] px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#25312A]"
className="btn btn-md btn-primary"
>
Åpne artikkel
Les artikkelen
</Link>
{article.facilitySlug ? (
<Link
href={`/golfbaner/${article.facilitySlug}`}
className="inline-flex items-center rounded-full border border-[#112015]/10 bg-white px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-[#112015] transition hover:border-[#8BC34A]"
className="btn btn-md btn-secondary"
>
Baneprofil
</Link>

View file

@ -281,8 +281,8 @@ export const getPlaceConfigFromSlug = (slug: string): PlaceConfig | null => {
slug,
areaFilter: "",
label: option.label,
title: "Alle golfanlegg i Norge",
intro: "Se alle norske golfanlegg på kartet, med statusikoner og listevisning under.",
title: "Alle golfbaner i Norge",
intro: "Se alle norske golfbaner på kartet, med statusikoner og listevisning under.",
};
}
@ -291,10 +291,10 @@ export const getPlaceConfigFromSlug = (slug: string): PlaceConfig | null => {
slug,
areaFilter: option.value,
label: option.label,
title: `Alle golfanlegg i ${option.label}`,
title: `Alle golfbaner i ${option.label}`,
intro: isRegion
? `Utforsk golfanlegg i ${option.label} på kartet og gå videre til hvert anlegg under.`
: `Utforsk golfanlegg i ${option.label} på kartet og sammenlign anleggene i listen under.`,
? `Utforsk golfbaner i ${option.label} på kartet og gå videre til hver bane under.`
: `Utforsk golfbaner i ${option.label} på kartet og sammenlign banene i listen under.`,
};
};

View file

@ -93,6 +93,157 @@ textarea {
0 10px 30px rgba(17, 32, 21, 0.05);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: 999px;
border: 1px solid transparent;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.16em;
transition:
background-color 160ms ease,
border-color 160ms ease,
color 160ms ease,
transform 160ms ease,
box-shadow 160ms ease;
}
.btn:hover {
transform: translateY(-1px);
}
.btn:focus-visible {
outline: 2px solid rgba(255, 87, 34, 0.35);
outline-offset: 2px;
}
.btn-sm {
padding: 0.625rem 1rem;
font-size: 0.6875rem;
}
.btn-md {
padding: 0.75rem 1.25rem;
font-size: 0.75rem;
}
.btn-lg {
padding: 0.875rem 1.5rem;
font-size: 0.875rem;
}
.btn-primary {
background: var(--color-brand-orange);
color: #ffffff;
box-shadow: 0 10px 24px rgba(255, 87, 34, 0.18);
}
.btn-primary:hover {
background: var(--color-brand-orange-dark);
color: #ffffff;
}
.btn-secondary {
border-color: rgba(17, 32, 21, 0.12);
background: #ffffff;
color: var(--color-ink);
}
.btn-secondary:hover {
border-color: var(--color-brand-orange);
color: var(--color-brand-orange);
}
.btn-secondary-dark {
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
backdrop-filter: blur(8px);
}
.btn-secondary-dark:hover {
border-color: rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.18);
color: #ffffff;
}
.btn-ink {
background: var(--color-ink);
color: #ffffff;
}
.btn-ink:hover {
background: var(--color-pine-900);
color: #ffffff;
}
.btn-danger {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.btn-danger:hover {
background: #fee2e2;
border-color: #fca5a5;
color: #991b1b;
}
.btn-icon {
display: inline-flex;
height: 2.5rem;
width: 2.5rem;
align-items: center;
justify-content: center;
border-radius: 1rem;
border: 1px solid rgba(17, 32, 21, 0.12);
background: #ffffff;
color: var(--color-ink);
transition:
background-color 160ms ease,
border-color 160ms ease,
color 160ms ease,
transform 160ms ease,
box-shadow 160ms ease;
}
.btn-icon:hover {
background: var(--color-brand-orange);
border-color: var(--color-brand-orange);
color: #ffffff;
transform: translateY(-1px);
}
.btn-panel {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border-radius: 1rem;
border: 1px solid rgba(17, 32, 21, 0.08);
background: #f7f9f2;
color: var(--color-ink);
font-size: 0.6875rem;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.16em;
transition:
background-color 160ms ease,
border-color 160ms ease,
color 160ms ease,
transform 160ms ease;
}
.btn-panel:hover {
background: var(--color-brand-orange);
border-color: var(--color-brand-orange);
color: #ffffff;
transform: translateY(-1px);
}
.teeoff-map .leaflet-container {
height: 100%;
width: 100%;

View file

@ -14,10 +14,28 @@
*/
import { useState, useEffect } from 'react';
import { Icon as LeafletIcon } from "leaflet";
import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet";
import { STATUS_MAP, FALLBACK_IMAGE } from "@/config/constants";
import { STATUS_ICON_PATHS, buildMapUrl, getPrimaryStatus, parseJson as parseSharedJson } from "@/app/facilityData";
import Link from 'next/link';
import CourseDisplay from './CourseDisplay';
const detailMarkerIconCache: Record<string, LeafletIcon> = {};
const getDetailMarkerIcon = (status: string) => {
const key = STATUS_ICON_PATHS[status] ? status : "ukjent";
if (!detailMarkerIconCache[key]) {
detailMarkerIconCache[key] = new LeafletIcon({
iconUrl: STATUS_ICON_PATHS[key],
iconSize: [34, 48],
iconAnchor: [17, 48],
popupAnchor: [0, -42],
});
}
return detailMarkerIconCache[key];
};
const formatPhoneForUrl = (phone: string) => {
if (!phone) return "";
return phone.replace('+', '00').replace(/\s/g, '');
@ -142,11 +160,8 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
const [showBackToTop, setShowBackToTop] = useState(false);
const [currentSlide, setCurrentSlide] = useState(0);
// Robust parser for å hente ut JSONB data fra Postgres trygt
const parseJson = (val: any, fallback: any) => {
if (!val) return fallback;
if (typeof val === 'object') return val;
try { return JSON.parse(val); } catch (e) { return fallback; }
return parseSharedJson(val, fallback);
};
const rawCourses = parseJson(facility.courses, []);
@ -170,9 +185,20 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
const hasGolfamore = facility.golfamore === true;
const hasNSG = facility.nsg_url || (nsgData && Object.keys(nsgData).length > 0);
const hasVtg = Boolean(
facility.vtg_pris ||
facility.vtg_beskrivelse ||
facility.vtg_lenke ||
(Array.isArray(vtgDatoer) && vtgDatoer.length > 0)
);
const mapUrl = buildMapUrl(facility.lat, facility.lng);
const primaryStatus = getPrimaryStatus(
activeCourses.map((course: any) => ({ status: course.status }))
);
const sidebarLinkClass = "flex items-center gap-4 text-[#11280f] hover:text-[#ff5722] transition-colors group";
const resourceBtnClass = "flex justify-between items-center p-5 bg-gray-50 rounded-2xl text-[11px] font-black uppercase text-[#11280f] hover:bg-[#ff5722] hover:text-white transition-all group";
const sidebarLinkClass = "group flex items-center gap-4 text-[#11280f] transition-colors hover:text-[#ff5722]";
const resourceBtnClass = "btn-panel p-5";
const sectionNavButtonClass = "btn btn-sm btn-secondary whitespace-nowrap";
useEffect(() => {
if (gallery.length <= 1) return;
@ -222,11 +248,11 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
{/* FLYTENDE HURTIGKNAPPER */}
<div className="absolute bottom-8 right-8 z-40 flex gap-2.5 bg-black/30 backdrop-blur-md p-2 rounded-2xl border border-white/10 shadow-2xl text-[#11280f]">
{facility.website_url && <a href={facility.website_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center hover:bg-[#ff5722] hover:text-white transition-all"><Icon children={ICONS.web} /></a>}
{facility.golfbox_booking_url && <a href={facility.golfbox_booking_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center hover:bg-[#ff5722] hover:text-white transition-all"><Icon children={ICONS.booking} /></a>}
{facility.golfbox_tournament_url && <a href={facility.golfbox_tournament_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center hover:bg-[#ff5722] hover:text-white transition-all"><Icon children={ICONS.trophy} /></a>}
<a href={`https://www.google.com/maps/search/?api=1&query=${facility.lat},${facility.lng}`} target="_blank" rel="noreferrer" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center hover:bg-[#ff5722] hover:text-white transition-all"><Icon children={ICONS.pin} /></a>
{facility.weather_url && <a href={facility.weather_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center hover:bg-[#ff5722] hover:text-white transition-all"><Icon children={ICONS.weather} /></a>}
{facility.website_url && <a href={facility.website_url} target="_blank" className="btn-icon"><Icon children={ICONS.web} /></a>}
{facility.golfbox_booking_url && <a href={facility.golfbox_booking_url} target="_blank" className="btn-icon"><Icon children={ICONS.booking} /></a>}
{facility.golfbox_tournament_url && <a href={facility.golfbox_tournament_url} target="_blank" className="btn-icon"><Icon children={ICONS.trophy} /></a>}
{mapUrl && <a href={mapUrl} target="_blank" rel="noreferrer" className="btn-icon"><Icon children={ICONS.pin} /></a>}
{facility.weather_url && <a href={facility.weather_url} target="_blank" className="btn-icon"><Icon children={ICONS.weather} /></a>}
</div>
{/* HERO TEXT */}
@ -240,15 +266,16 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
</div>
{/* 2. STICKY NAV */}
<nav className="sticky top-0 z-50 bg-white/95 backdrop-blur-md border-b border-gray-100 shadow-sm overflow-hidden">
<div className="max-w-[1200px] mx-auto px-6 flex justify-between md:justify-start gap-4 md:gap-10 h-16 items-center text-[10px] font-black uppercase tracking-widest text-gray-400">
<button onClick={() => scrollTo('intro')}>Info</button>
<button onClick={() => scrollTo('weather')}>Vær</button>
<button onClick={() => scrollTo('details')}>Detaljer</button>
<button onClick={() => scrollTo('map')}>Kart</button>
{facility.video_url && <button onClick={() => scrollTo('video')}>Video</button>}
<button onClick={() => scrollTo('prices')}>Priser</button>
<button onClick={() => scrollTo('scorecards')}>Scorekort</button>
<nav className="sticky top-0 z-50 bg-white/95 backdrop-blur-md border-b border-gray-100 shadow-sm overflow-x-auto">
<div className="max-w-[1200px] mx-auto px-4 md:px-6 flex min-w-max items-center gap-4 md:gap-6 lg:gap-8 xl:gap-10 h-16 lg:h-20 text-[10px] md:text-xs lg:text-sm xl:text-[15px] font-black uppercase tracking-[0.16em] text-gray-500">
<button className={sectionNavButtonClass} onClick={() => scrollTo('intro')}>Info</button>
<button className={sectionNavButtonClass} onClick={() => scrollTo('weather')}>Vær</button>
<button className={sectionNavButtonClass} onClick={() => scrollTo('details')}>Detaljer</button>
{mapUrl && <button className={sectionNavButtonClass} onClick={() => scrollTo('map')}>Kart</button>}
{facility.video_url && <button className={sectionNavButtonClass} onClick={() => scrollTo('video')}>Video</button>}
<button className={sectionNavButtonClass} onClick={() => scrollTo('prices')}>Priser</button>
{hasVtg && <button className={sectionNavButtonClass} onClick={() => scrollTo('vtg')}>VTG</button>}
<button className={sectionNavButtonClass} onClick={() => scrollTo('scorecards')}>Scorekort</button>
</div>
</nav>
@ -286,7 +313,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
<Icon children={ICONS.mail} /> <span className="truncate">{facility.email || 'Ikke oppgitt'}</span>
</a>
<div className="pt-2 border-t border-gray-50 mt-4">
<a href={`https://www.google.com/maps/search/?api=1&query=${facility.lat},${facility.lng}`} target="_blank" rel="noreferrer" className={sidebarLinkClass + " pt-4 leading-tight items-start"}>
<a href={mapUrl || "#"} target="_blank" rel="noreferrer" className={sidebarLinkClass + " pt-4 leading-tight items-start"}>
<Icon children={ICONS.pin} /> <span className="text-gray-400 group-hover:text-[#ff5722] transition-colors">{facility.address}<br/>{facility.city}</span>
</a>
</div>
@ -300,7 +327,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
const iconData = SOCIAL_ICONS[platform] || <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3" />;
return (
<a key={idx} href={social.url} target="_blank" rel="noreferrer" title={social.platform} className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center text-[#11280f] hover:bg-[#ff5722] hover:text-white transition-all shadow-sm">
<a key={idx} href={social.url} target="_blank" rel="noreferrer" title={social.platform} className="btn-icon h-10 w-10 rounded-full shadow-sm">
<Icon children={iconData} className="w-4 h-4 text-current" />
</a>
);
@ -398,7 +425,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
<span className="text-gray-400 block mb-2">Samarbeider med:</span>
<div className="flex flex-wrap gap-2">
{cooperatingClubs.map((slug: string) => (
<Link key={slug} href={`/golfbaner/${slug}`} className="px-3 py-1 bg-gray-100 rounded-lg text-[10px] uppercase font-black tracking-widest hover:bg-[#8bc34a] hover:text-white transition-colors">
<Link key={slug} href={`/golfbaner/${slug}`} className="btn btn-sm btn-secondary">
{slug.replace('-golfklubb', '').replace(/-/g, ' ')}
</Link>
))}
@ -418,12 +445,55 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
</section>
{/* 6. KART SEKSJON */}
{mapUrl && (
<section id="map" className="space-y-6">
<h2 className="text-3xl md:text-4xl font-black uppercase tracking-tighter flex items-center gap-5 ml-6 md:ml-0">Kart <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
<div className="w-full md:rounded-[3rem] overflow-hidden shadow-xl h-[450px] md:h-[650px] border-y-4 md:border-[12px] border-white bg-gray-100">
<iframe width="100%" height="100%" style={{ border: 0 }} src={`https://www.google.com/maps/search/?api=1&query=${facility.lat},${facility.lng}&t=k&z=15&ie=UTF8&iwloc=&output=embed`} allowFullScreen />
</div>
<div className="teeoff-map overflow-hidden md:rounded-[3rem] border-y-4 md:border-[12px] border-white bg-white shadow-xl">
<div className="h-[450px] md:h-[650px] w-full">
<MapContainer
center={[facility.lat as number, facility.lng as number]}
zoom={13}
scrollWheelZoom={false}
zoomControl
className="h-full w-full"
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker
position={[facility.lat as number, facility.lng as number]}
icon={getDetailMarkerIcon(primaryStatus)}
>
<Popup>
<div className="space-y-3">
<div>
<p className="text-lg font-extrabold text-[#112015]">{facility.name}</p>
<p className="mt-1 text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#617063]">
{facility.city} {facility.county}
</p>
</div>
<div className="inline-flex rounded-full bg-[#F3F6EE] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#112015]">
{STATUS_MAP[primaryStatus] || "Ukjent status"}
</div>
{mapUrl && (
<a
href={mapUrl}
target="_blank"
rel="noreferrer"
className="btn btn-sm btn-secondary"
>
Åpne kart
</a>
)}
</div>
</Popup>
</Marker>
</MapContainer>
</div>
</div>
</section>
)}
{/* 7. VIDEO SEKSJON */}
{facility.video_url && (
@ -435,7 +505,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
</section>
)}
{/* 8. PRISER (MEDLEMSKAP, GREENFEE & VTG) */}
{/* 8. PRISER (MEDLEMSKAP & GREENFEE) */}
<section id="prices" className="pt-10">
<h2 className="text-3xl md:text-4xl font-black uppercase tracking-tighter flex items-center gap-5 ml-6 md:ml-0 mb-8">
Priser <span className="h-1 flex-grow bg-gray-100 rounded-full" />
@ -535,78 +605,79 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
)}
</div>
{/* VEIEN TIL GOLF (VTG) - FULL BREDDE UNDER */}
{(facility.vtg_pris || facility.vtg_beskrivelse || (vtgDatoer && vtgDatoer.length > 0)) && (
<div className="mt-6 lg:mt-8 bg-[#8bc34a] text-white rounded-3xl p-6 lg:p-10 shadow-lg relative overflow-hidden group">
{/* Bakgrunnseffekt */}
<div className="absolute -right-20 -top-20 opacity-10 text-[200px] pointer-events-none transform group-hover:scale-110 transition-transform duration-700">🏌</div>
<div className="relative z-10">
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h3 className="text-2xl font-black uppercase tracking-tighter flex items-center gap-3">
Nybegynnerkurs (Veien til Golf)
</h3>
<Link
href="/vtg"
className="inline-flex items-center text-[10px] font-black uppercase tracking-[0.18em] text-white/80 transition hover:text-white"
>
Se alle VTG-kurs
</Link>
</div>
{facility.vtg_beskrivelse && (
<p className="text-sm md:text-base text-white/90 mb-8 leading-relaxed font-medium max-w-4xl">
{facility.vtg_beskrivelse}
</p>
)}
<div className="flex flex-col lg:flex-row gap-6 items-start lg:items-center justify-between bg-white/10 rounded-2xl p-6 backdrop-blur-sm border border-white/20">
{/* Pris */}
{facility.vtg_pris && (
<div className="flex-shrink-0">
<span className="block text-[10px] font-black uppercase tracking-widest text-white/70 mb-1">Standard voksenpris</span>
<span className="text-4xl font-black">{facility.vtg_pris},-</span>
</div>
)}
{/* Datoer */}
{vtgDatoer && vtgDatoer.length > 0 && (
<div className="flex-grow w-full lg:w-auto lg:px-6">
<h4 className="text-[10px] font-black uppercase tracking-widest text-white/70 mb-3">Kommende kurs:</h4>
<div className="flex flex-wrap gap-2">
{vtgDatoer.map((kurs: any, i: number) => {
const status = (kurs.status || '').toLowerCase();
const isFull = status.includes('full');
const isWaitlist = status.includes('vente') || status.includes('få');
let badgeColor = "bg-white/20 text-white";
if (isFull) badgeColor = "bg-red-500/80 text-white line-through opacity-75";
if (isWaitlist) badgeColor = "bg-yellow-400 text-[#11280f]";
return (
<div key={i} className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold border border-white/10 ${badgeColor}`}>
<span>{kurs.dato}</span>
<span className="text-[8px] uppercase tracking-widest opacity-80 border-l border-white/20 pl-2 ml-1">{kurs.status}</span>
</div>
);
})}
</div>
</div>
)}
{/* Påmeldingsknapp */}
{facility.vtg_lenke && (
<a href={facility.vtg_lenke.split(',')[0].trim()} target="_blank" rel="noopener noreferrer" className="mt-4 lg:mt-0 w-full lg:w-auto text-center inline-block bg-white text-[#8bc34a] px-8 py-4 rounded-xl text-xs font-black uppercase tracking-widest hover:bg-[#11280f] hover:text-white hover:scale-105 transition-all shadow-xl flex-shrink-0">
Påmelding
</a>
)}
</div>
</div>
</div>
)}
</section>
{hasVtg && (
<section id="vtg" className="pt-10 space-y-6">
<h2 className="text-3xl md:text-4xl font-black uppercase tracking-tighter flex items-center gap-5 ml-6 md:ml-0">
Veien til Golf <span className="h-1 flex-grow bg-gray-100 rounded-full" />
</h2>
<div className="bg-[#8bc34a] text-white rounded-3xl p-6 lg:p-10 shadow-lg relative overflow-hidden group">
<div className="absolute -right-20 -top-20 opacity-10 text-[200px] pointer-events-none transform group-hover:scale-110 transition-transform duration-700">🏌</div>
<div className="relative z-10">
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h3 className="text-2xl font-black uppercase tracking-tighter flex items-center gap-3">
Nybegynnerkurs hos {facility.name}
</h3>
<Link
href="/vtg"
className="inline-flex items-center text-[10px] font-black uppercase tracking-[0.18em] text-white/80 transition hover:text-white"
>
Se alle VTG-kurs
</Link>
</div>
{facility.vtg_beskrivelse && (
<p className="text-sm md:text-base text-white/90 mb-8 leading-relaxed font-medium max-w-4xl">
{facility.vtg_beskrivelse}
</p>
)}
<div className="flex flex-col lg:flex-row gap-6 items-start lg:items-center justify-between bg-white/10 rounded-2xl p-6 backdrop-blur-sm border border-white/20">
{facility.vtg_pris && (
<div className="flex-shrink-0">
<span className="block text-[10px] font-black uppercase tracking-widest text-white/70 mb-1">Standard voksenpris</span>
<span className="text-4xl font-black">{facility.vtg_pris},-</span>
</div>
)}
{vtgDatoer && vtgDatoer.length > 0 && (
<div className="flex-grow w-full lg:w-auto lg:px-6">
<h4 className="text-[10px] font-black uppercase tracking-widest text-white/70 mb-3">Kommende kurs:</h4>
<div className="flex flex-wrap gap-2">
{vtgDatoer.map((kurs: any, i: number) => {
const status = (kurs.status || '').toLowerCase();
const isFull = status.includes('full');
const isWaitlist = status.includes('vente') || status.includes('få');
let badgeColor = "bg-white/20 text-white";
if (isFull) badgeColor = "bg-red-500/80 text-white line-through opacity-75";
if (isWaitlist) badgeColor = "bg-yellow-400 text-[#11280f]";
return (
<div key={i} className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold border border-white/10 ${badgeColor}`}>
<span>{kurs.dato}</span>
<span className="text-[8px] uppercase tracking-widest opacity-80 border-l border-white/20 pl-2 ml-1">{kurs.status}</span>
</div>
);
})}
</div>
</div>
)}
{facility.vtg_lenke && (
<a href={facility.vtg_lenke.split(',')[0].trim()} target="_blank" rel="noopener noreferrer" className="btn btn-lg btn-primary mt-4 w-full flex-shrink-0 text-center lg:mt-0 lg:w-auto">
Påmelding
</a>
)}
</div>
</div>
</div>
</section>
)}
{/* 9. SCOREKORT SEKSJON */}
<section id="scorecards" className="pt-10 space-y-20 overflow-hidden">
<h3 className="text-center text-3xl md:text-5xl font-black uppercase tracking-tighter">Scorekort</h3>
@ -621,7 +692,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
</div>
{showBackToTop && (
<button onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className="fixed bottom-8 right-8 w-14 h-14 bg-[#11280f] text-white rounded-full shadow-2xl flex items-center justify-center text-2xl z-[100] border-4 border-white/20 hover:scale-110 transition-all"></button>
<button onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className="btn btn-ink fixed bottom-8 right-8 z-[100] flex h-14 w-14 items-center justify-center rounded-full border-4 border-white/20 p-0 text-2xl shadow-2xl"></button>
)}
</main>
);

View file

@ -23,7 +23,7 @@ export default async function OpenGraphImage({ params }: OpenGraphImageProps) {
const facility = await getFacility(slug);
const title = facility?.name || "TeeOff";
const location = [facility?.city, facility?.county].filter(Boolean).join(" · ") || "Norske golfanlegg";
const location = [facility?.city, facility?.county].filter(Boolean).join(" · ") || "Norske golfbaner";
const imageUrl = facility?.image_url ? buildAbsoluteUrl(facility.image_url) : null;
return new ImageResponse(

View file

@ -26,7 +26,7 @@ export async function generateMetadata({ params }: GolfCoursePageProps): Promise
if (!facility || facility.error) {
return createPageMetadata({
title: "Golfbane ikke funnet",
description: "Golfanlegget du prøvde å åpne finnes ikke på TeeOff.",
description: "Golfbanen du prøvde å åpne finnes ikke på TeeOff.",
path: `/golfbaner/${slug}`,
});
}
@ -43,7 +43,7 @@ export async function generateMetadata({ params }: GolfCoursePageProps): Promise
} catch {
return createPageMetadata({
title: "Golfbane",
description: "Golfanlegg på TeeOff med status, priser og klubbinfo.",
description: "Golfbane på TeeOff med status, priser og klubbinfo.",
path: `/golfbaner/${slug}`,
});
}

View file

@ -183,10 +183,8 @@ function SectionTable({
key={option.value}
type="button"
onClick={() => onSortChange(option.value)}
className={`rounded-full px-4 py-2 text-xs font-black uppercase tracking-[0.16em] transition ${
isActive
? "bg-[#112015] text-white"
: "border border-[#112015]/10 bg-white text-[#112015] hover:border-[#FF5722] hover:text-[#FF5722]"
className={`btn btn-sm ${
isActive ? "btn-ink" : "btn-secondary"
}`}
>
{option.label}
@ -252,7 +250,7 @@ function SectionTable({
<button
type="button"
onClick={() => onToggleRow(entry.key)}
className="inline-flex items-center gap-2 rounded-full border border-[#112015]/10 bg-[#F8FAF5] px-3 py-2 text-[11px] font-black uppercase tracking-[0.14em] text-[#112015] transition hover:border-[#FF5722] hover:text-[#FF5722]"
className="btn btn-sm btn-secondary"
aria-expanded={isOpen}
>
<span>{isOpen ? "Lukk" : "Detaljer"}</span>
@ -298,16 +296,16 @@ function SectionTable({
<div className="flex flex-col gap-3 lg:items-end">
<Link
href={`/golfbaner/${entry.slug}`}
className="inline-flex w-full items-center justify-center rounded-full bg-[#112015] px-5 py-3 text-sm font-black text-white transition hover:bg-[#25312A] lg:w-auto"
className="btn btn-md btn-ink w-full lg:w-auto"
>
Se anlegg
Baneprofil
</Link>
{hasUsableLink(entry.membershipUrl) ? (
<a
href={entry.membershipUrl || "#"}
target="_blank"
rel="noreferrer"
className="inline-flex w-full items-center justify-center rounded-full border border-[#112015]/10 bg-white px-5 py-3 text-sm font-black text-[#112015] transition hover:border-[#FF5722] hover:text-[#FF5722] lg:w-auto"
className="btn btn-md btn-primary w-full lg:w-auto"
>
Innmelding
</a>

View file

@ -247,14 +247,14 @@ export default async function OpinionPage({ params }: OpinionPageProps) {
{article.facilitySlug ? (
<Link
href={`/golfbaner/${article.facilitySlug}`}
className="inline-flex items-center justify-center rounded-full bg-[#FF5722] px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#C94F2D]"
className="btn btn-md btn-primary"
>
Åpne baneprofil
</Link>
) : null}
<Link
href="/meninger"
className="inline-flex items-center justify-center rounded-full border border-white/12 bg-white/8 px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-white/14"
className="btn btn-md btn-secondary-dark"
>
Tilbake til meninger
</Link>
@ -263,7 +263,7 @@ export default async function OpinionPage({ params }: OpinionPageProps) {
href={article.sourceUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-full border border-white/12 bg-white/8 px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-white/14"
className="btn btn-md btn-secondary-dark"
>
Original kilde
</a>

View file

@ -82,16 +82,16 @@ export default async function OpinionsPage() {
<div className="mt-6 flex flex-wrap gap-3">
<Link
href={`/meninger/${featuredArticle.slug}`}
className="inline-flex items-center rounded-full bg-[#FF5722] px-5 py-3 text-sm font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#C94F2D]"
className="btn btn-md btn-primary"
>
Les artikkelen
</Link>
{featuredArticle.facilitySlug ? (
<Link
href={`/golfbaner/${featuredArticle.facilitySlug}`}
className="inline-flex items-center rounded-full border border-white/18 bg-white/10 px-5 py-3 text-sm font-black uppercase tracking-[0.16em] text-white transition hover:bg-white/18"
className="btn btn-md btn-secondary-dark"
>
til baneprofil
Baneprofil
</Link>
) : null}
</div>
@ -167,14 +167,14 @@ export default async function OpinionsPage() {
<div className="mt-6 flex flex-wrap gap-3">
<Link
href={`/meninger/${article.slug}`}
className="inline-flex items-center rounded-full bg-[#112015] px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#25312A]"
className="btn btn-md btn-primary"
>
Åpne artikkel
Les artikkelen
</Link>
{article.facilitySlug ? (
<Link
href={`/golfbaner/${article.facilitySlug}`}
className="inline-flex items-center rounded-full border border-[#112015]/10 bg-white px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-[#112015] transition hover:border-[#8BC34A]"
className="btn btn-md btn-secondary"
>
Baneprofil
</Link>

View file

@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
export const metadata = createPageMetadata({
title: "Komplett oversikt over ALLE norske golfbaner",
description:
"Utforsk norske golfanlegg med oppdatert banestatus, kart, priser, medlemskap og Veien til Golf samlet på TeeOff.",
"Utforsk norske golfbaner med oppdatert banestatus, kart, priser, medlemskap og Veien til Golf samlet på TeeOff.",
path: "/",
});

View file

@ -34,7 +34,7 @@ type FacilitySeoRecord = FacilityRecord & {
export const SITE_NAME = "TeeOff";
export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL || "https://nye.teeoff.no").replace(/\/$/, "");
export const DEFAULT_DESCRIPTION =
"Oppdatert banestatus, priser, Veien til Golf og informasjon om norske golfanlegg samlet på ett sted.";
"Oppdatert banestatus, priser, Veien til Golf og informasjon om norske golfbaner samlet på ett sted.";
export const DEFAULT_OG_IMAGE = buildAbsoluteUrl(FALLBACK_IMAGE);
export const ORGANIZATION_ID = `${SITE_URL}#organization`;
export const WEBSITE_ID = `${SITE_URL}#website`;
@ -184,7 +184,7 @@ export function createFacilityJsonLd(facility: FacilitySeoRecord) {
name: facility.name,
description:
trimDescription(facility.description) ||
`${facility.name} er et golfanlegg på TeeOff med oppdatert banestatus og praktisk klubbinfo.`,
`${facility.name} er en golfbane på TeeOff med oppdatert banestatus og praktisk klubbinfo.`,
url: buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
image: resolveImageUrl(facility.image_url),
telephone: facility.phone || undefined,

View file

@ -103,7 +103,7 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
<p className="mt-4 max-w-3xl text-base leading-8 text-[#617063]">{place.intro}</p>
<div className="mt-5 flex flex-wrap gap-3">
<span className="rounded-full bg-white px-4 py-2 text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#112015] shadow-sm">
{facilitiesInPlace.length} anlegg
{facilitiesInPlace.length} golfbaner
</span>
<span className="rounded-full bg-[#25312A] px-4 py-2 text-[11px] font-extrabold uppercase tracking-[0.18em] text-white">
Kart og liste i samme visning
@ -120,7 +120,7 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
initialFacilities={safeData}
variant="catalog"
title={place.title}
intro={`Filtrer anleggene i ${place.label} videre etter banestatus, antall hull og andre egenskaper.`}
intro={`Filtrer golfbanene i ${place.label} videre etter banestatus, antall hull og andre egenskaper.`}
fixedAreaFilter={place.areaFilter}
hideTitleBlock
/>

View file

@ -115,15 +115,15 @@ export default async function TournamentsPage() {
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]"
className="btn btn-sm btn-primary"
>
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]"
className="btn btn-sm btn-secondary"
>
Se baneside
Baneprofil
</Link>
</div>
</article>

View file

@ -7,11 +7,11 @@ import {
filterFacilitiesByArea,
HIERARCHICAL_AREA_OPTIONS,
parseJson,
type EnrichedFacility,
type FacilityRecord,
type EnrichedFacility,
} from "@/app/facilityData";
type SortKey = "soonest" | "price" | "alpha";
type SortKey = "soonest" | "price" | "alpha" | "distance";
type TimeFilter = "all" | "upcoming" | "thisMonth" | "next30";
type PriceFilter = "all" | "under1500" | "1500to2500" | "2500plus";
@ -44,6 +44,7 @@ type VtgListing = {
upcomingCourseCount: number;
hasPublishedDates: boolean;
allDates: CourseDateSummary[];
distance: number;
enriched: EnrichedFacility;
};
@ -62,9 +63,10 @@ const priceOptions: Array<{ value: PriceFilter; label: string }> = [
];
const sortOptions: Array<{ value: SortKey; label: string }> = [
{ value: "soonest", label: "Snartest først" },
{ value: "soonest", label: "Neste kurs først" },
{ value: "price", label: "Billigst først" },
{ value: "alpha", label: "Alfabetisk" },
{ value: "distance", label: "Nærmest deg" },
];
const monthMap: Record<string, number> = {
@ -242,6 +244,12 @@ function statusTone(status: string) {
return "bg-[#EFF4E8] text-[#556555]";
}
function formatDistance(value: number) {
if (!Number.isFinite(value)) return null;
if (value < 10) return `${value.toFixed(1).replace(".", ",")} km unna`;
return `${Math.round(value)} km unna`;
}
export default function VtgExplorer({ facilities }: VtgExplorerProps) {
const [areaFilter, setAreaFilter] = useState("");
const [clubQuery, setClubQuery] = useState("");
@ -249,8 +257,49 @@ export default function VtgExplorer({ facilities }: VtgExplorerProps) {
const [priceFilter, setPriceFilter] = useState<PriceFilter>("all");
const [sortKey, setSortKey] = useState<SortKey>("soonest");
const [onlyWithDates, setOnlyWithDates] = useState(false);
const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(null);
const [isLocating, setIsLocating] = useState(false);
const [locationError, setLocationError] = useState<string | null>(null);
const enrichedFacilities = useMemo(() => enrichFacilities(facilities), [facilities]);
const requestUserLocation = (onSuccess?: () => void) => {
if (userLocation) {
onSuccess?.();
return;
}
if (!("geolocation" in navigator)) {
setLocationError("Nettleseren din støtter ikke posisjonering.");
return;
}
setIsLocating(true);
setLocationError(null);
navigator.geolocation.getCurrentPosition(
(position) => {
setUserLocation({
lat: position.coords.latitude,
lng: position.coords.longitude,
});
setIsLocating(false);
onSuccess?.();
},
() => {
setIsLocating(false);
setLocationError("Tillat posisjon i nettleseren for å sortere på nærhet.");
},
{
enableHighAccuracy: false,
timeout: 8000,
maximumAge: 1000 * 60 * 30,
},
);
};
const enrichedFacilities = useMemo(
() => enrichFacilities(facilities, userLocation),
[facilities, userLocation],
);
const listings = useMemo<VtgListing[]>(
() =>
@ -272,6 +321,7 @@ export default function VtgExplorer({ facilities }: VtgExplorerProps) {
upcomingCourseCount: summary.upcomingCourseCount,
hasPublishedDates: summary.hasPublishedDates,
allDates: summary.allDates,
distance: facility.distance,
enriched: facility,
};
}),
@ -312,6 +362,11 @@ export default function VtgExplorer({ facilities }: VtgExplorerProps) {
return a.name.localeCompare(b.name, "nb-NO");
}
if (sortKey === "distance") {
if (a.distance !== b.distance) return a.distance - b.distance;
return a.name.localeCompare(b.name, "nb-NO");
}
if (sortKey === "price") {
const priceA = typeof a.vtgPris === "number" ? a.vtgPris : Number.POSITIVE_INFINITY;
const priceB = typeof b.vtgPris === "number" ? b.vtgPris : Number.POSITIVE_INFINITY;
@ -474,11 +529,15 @@ export default function VtgExplorer({ facilities }: VtgExplorerProps) {
<button
key={option.value}
type="button"
onClick={() => setSortKey(option.value)}
className={`rounded-full px-4 py-2 text-xs font-black uppercase tracking-[0.16em] transition ${
isActive
? "bg-[#112015] text-white"
: "border border-[#112015]/10 bg-white text-[#112015] hover:border-[#FF5722] hover:text-[#FF5722]"
onClick={() => {
if (option.value === "distance") {
requestUserLocation(() => setSortKey("distance"));
return;
}
setSortKey(option.value);
}}
className={`btn btn-sm ${
isActive ? "btn-ink" : "btn-secondary"
}`}
>
{option.label}
@ -497,12 +556,22 @@ export default function VtgExplorer({ facilities }: VtgExplorerProps) {
setSortKey("soonest");
setOnlyWithDates(false);
}}
className="rounded-full border border-[#112015]/10 bg-white px-4 py-2 text-xs font-black uppercase tracking-[0.16em] text-[#112015] transition hover:border-[#FF5722] hover:text-[#FF5722]"
className="btn btn-sm btn-secondary"
>
Nullstill filtre
</button>
</div>
</div>
{(sortKey === "distance" || isLocating || locationError) && (
<p className="mt-3 text-sm text-[#5B675C]">
{isLocating
? "Henter posisjonen din for å sortere på nærhet…"
: sortKey === "distance" && userLocation
? "Sortert etter avstand fra deg."
: locationError || "Tillat posisjon i nettleseren for å sortere på nærhet."}
</p>
)}
</div>
</section>
@ -533,6 +602,11 @@ export default function VtgExplorer({ facilities }: VtgExplorerProps) {
<span className="rounded-full bg-[#EFF4E8] px-3 py-1 text-[11px] font-black uppercase tracking-[0.16em] text-[#556555]">
{listing.city ? `${listing.city} · ${listing.county}` : listing.county || "Norge"}
</span>
{formatDistance(listing.distance) ? (
<span className="rounded-full bg-[#F7F9F2] px-3 py-1 text-[11px] font-black uppercase tracking-[0.16em] text-[#556555]">
{formatDistance(listing.distance)}
</span>
) : null}
{listing.hasPublishedDates ? (
<span className="rounded-full bg-emerald-100 px-3 py-1 text-[11px] font-black uppercase tracking-[0.16em] text-emerald-700">
Har kursdato
@ -619,16 +693,16 @@ export default function VtgExplorer({ facilities }: VtgExplorerProps) {
<div className="flex w-full flex-col gap-3 xl:w-auto xl:min-w-[13rem]">
<Link
href={`/golfbaner/${listing.slug}`}
className="inline-flex items-center justify-center rounded-full bg-[#112015] px-5 py-3 text-sm font-black text-white transition hover:bg-[#25312A]"
className="btn btn-md btn-secondary w-full xl:w-auto"
>
Se klubbside
Baneprofil
</Link>
{listing.vtgLenke ? (
<a
href={listing.vtgLenke.split(",")[0].trim()}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-full border border-[#112015]/10 bg-white px-5 py-3 text-sm font-black text-[#112015] transition hover:border-[#FF5722] hover:text-[#FF5722]"
className="btn btn-md btn-primary w-full xl:w-auto"
>
Ta VTG her
</a>

View file

@ -49,7 +49,7 @@ export default function AdminMobileMenu({ onOpenTwoFactor }: AdminMobileMenuProp
<div className="mb-6 flex md:hidden">
<button
onClick={() => setIsOpen(true)}
className="inline-flex items-center gap-3 rounded-2xl bg-[#11280f] px-5 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-white shadow-lg"
className="btn btn-md btn-primary"
>
<span className="text-lg leading-none"></span>
Adminmeny
@ -73,7 +73,7 @@ export default function AdminMobileMenu({ onOpenTwoFactor }: AdminMobileMenuProp
</div>
<button
onClick={() => setIsOpen(false)}
className="inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-white/10 text-xl font-black text-white hover:bg-white/20"
className="btn-icon border-white/20 bg-white/10 text-xl text-white hover:bg-white/20"
aria-label="Lukk adminmeny"
>
×
@ -128,7 +128,7 @@ export default function AdminMobileMenu({ onOpenTwoFactor }: AdminMobileMenuProp
<div className="mt-8 border-t border-white/10 pt-6">
<button
onClick={handleLogout}
className="w-full rounded-2xl border border-red-400/20 bg-red-500/10 px-4 py-4 text-left text-[11px] font-black uppercase tracking-[0.2em] text-red-300 hover:bg-red-500/20"
className="btn btn-md btn-danger w-full justify-start text-left"
>
Logg ut
</button>

View file

@ -298,14 +298,14 @@ export default function ArticleComments({ slug, section }: ArticleCommentsProps)
type="button"
onClick={handleSubmit}
disabled={isSubmitting}
className="rounded-full bg-[#112015] px-5 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#25312A] disabled:opacity-50"
className="btn btn-md btn-primary disabled:opacity-50"
>
{isSubmitting ? "Publiserer..." : "Publiser kommentar"}
</button>
<button
type="button"
onClick={handleLogout}
className="rounded-full border border-[#112015]/10 bg-white px-5 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-[#112015] transition hover:border-[#8BC34A]"
className="btn btn-md btn-secondary"
>
Logg ut
</button>
@ -317,7 +317,7 @@ export default function ArticleComments({ slug, section }: ArticleCommentsProps)
{data.auth_providers?.google ? (
<Link
href={googleLoginHref}
className="rounded-full bg-[#112015] px-5 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#25312A]"
className="btn btn-md btn-primary"
>
Fortsett med Google
</Link>
@ -344,7 +344,7 @@ export default function ArticleComments({ slug, section }: ArticleCommentsProps)
type="button"
onClick={handleMagicLinkRequest}
disabled={isSendingMagicLink}
className="rounded-full bg-[#FF5722] px-5 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#C94F2D] disabled:opacity-50"
className="btn btn-md btn-primary disabled:opacity-50"
>
{isSendingMagicLink ? "Sender..." : "Send innloggingslenke"}
</button>

View file

@ -51,7 +51,7 @@ export default function CourseVisitGallery({ title, images }: CourseVisitGallery
<button
type="button"
onClick={showPrevious}
className="rounded-full border border-white/20 bg-white/10 px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-white/20"
className="btn btn-md btn-secondary-dark"
aria-label={`Forrige bilde i ${title}`}
>
Forrige
@ -59,7 +59,7 @@ export default function CourseVisitGallery({ title, images }: CourseVisitGallery
<button
type="button"
onClick={showNext}
className="rounded-full border border-white/20 bg-white/10 px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-white/20"
className="btn btn-md btn-secondary-dark"
aria-label={`Neste bilde i ${title}`}
>
Neste
@ -108,14 +108,14 @@ export default function CourseVisitGallery({ title, images }: CourseVisitGallery
<button
type="button"
onClick={showPrevious}
className="flex-1 rounded-full border border-[#112015]/10 bg-white px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-[#112015] transition hover:border-[#8BC34A]"
className="btn btn-md btn-secondary flex-1"
>
Forrige
</button>
<button
type="button"
onClick={showNext}
className="flex-1 rounded-full border border-[#112015]/10 bg-white px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-[#112015] transition hover:border-[#FF5722]"
className="btn btn-md btn-secondary flex-1"
>
Neste
</button>

View file

@ -231,37 +231,6 @@ 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">
<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>
{primaryNavItems.map((item) => (
<Link
key={item.href}
@ -304,6 +273,37 @@ export default function Header() {
)}
</div>
</div>
<div className="border-t border-white/10 pt-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>
</div>
)}

View file

@ -167,7 +167,7 @@ function MapLegend() {
["stengt", "Banen er stengt"],
["aapner_snart", "Banen åpner snart"],
["stenger_snart", "Banen stenger snart"],
["under_utvikling", "Golfanlegg under utvikling"],
["under_utvikling", "Golfbaner under utvikling"],
["nedlagt", "Banen er nedlagt"],
["ukjent", "Banestatus er ukjent"],
] as const;
@ -238,9 +238,9 @@ export default function PlaceMap({ facilities, placeLabel }: PlaceMapProps) {
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<p className="text-[11px] font-extrabold uppercase tracking-[0.28em] text-[#6FA786]">Kartoversikt</p>
<h2 className="mt-2 text-3xl text-[#112015] sm:text-4xl">Golfanlegg i {placeLabel}</h2>
<h2 className="mt-2 text-3xl text-[#112015] sm:text-4xl">Golfbaner i {placeLabel}</h2>
<p className="mt-3 text-base leading-7 text-[#617063]">
Klikk en markør for å åpne anlegget og bruke hurtiglenkene videre.
Klikk en markør for å åpne golfbanen og bruke hurtiglenkene videre.
</p>
<p className="mt-2 hidden text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#839184] lg:block">
Hold Shift inne for å zoome med musehjulet.