Før stor endring på admin
This commit is contained in:
parent
1b09e88fd3
commit
05ded6513e
30 changed files with 769 additions and 264 deletions
|
|
@ -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
173
docs/design-system.md
Normal 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.
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 på 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">Gå 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">Gå 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">Gå 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">Gå til Vaskeri</Link>}
|
||||
{activeTab === 'greenfee' && hasGfDraft && <Link href="/admin/greenfee" className="btn btn-sm btn-danger whitespace-nowrap">Gå til Vaskeri</Link>}
|
||||
{activeTab === 'vtg' && hasVtgDraft && <Link href="/admin/vtg" className="btn btn-sm btn-danger whitespace-nowrap">Gå 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">Gå 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">Få 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
Gå 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>
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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='© <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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
Gå 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>
|
||||
|
|
|
|||
|
|
@ -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: "/",
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 på en markør for å åpne anlegget og bruke hurtiglenkene videre.
|
||||
Klikk på 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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue