Remove export and dump artifacts from repository
This commit is contained in:
parent
1292fd0e20
commit
c0dee2f662
80 changed files with 2 additions and 14578 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -9,3 +9,5 @@ backend/.env
|
||||||
*.dump
|
*.dump
|
||||||
*_dump.txt
|
*_dump.txt
|
||||||
kode_eksport_*/
|
kode_eksport_*/
|
||||||
|
fil-tre*.txt
|
||||||
|
frontend/src/struktur_dump.txt
|
||||||
|
|
|
||||||
796
fil-tre-3.txt
796
fil-tre-3.txt
|
|
@ -1,796 +0,0 @@
|
||||||
📁 teeoff/
|
|
||||||
📄 fil-tre.txt
|
|
||||||
📄 struktur2_dump.txt
|
|
||||||
📄 seed.sql
|
|
||||||
📄 struktur3_dump.txt
|
|
||||||
📄 eksport_script.py
|
|
||||||
📄 update_golfbox.sql
|
|
||||||
📄 fil-tre-3.txt
|
|
||||||
📄 docker-compose.yml
|
|
||||||
📄 schema.sql
|
|
||||||
📄 init.sql
|
|
||||||
📁 kode_eksport_3/
|
|
||||||
📄 backend_scrape_membership_py.txt
|
|
||||||
📄 backend_scrape_greenfee_py.txt
|
|
||||||
📄 frontend_src_components_Header_tsx.txt
|
|
||||||
📄 backend_scrape_nsg_3_py.txt
|
|
||||||
📄 frontend_src_app_admin_medlemskap_page_tsx.txt
|
|
||||||
📄 frontend_next-env_d_ts.txt
|
|
||||||
📄 frontend_src_app_layout_tsx.txt
|
|
||||||
📄 frontend_src_app_admin_vtg_page_tsx.txt
|
|
||||||
📄 frontend_src_app_admin_greenfee_page_tsx.txt
|
|
||||||
📄 backend_import_urls_py.txt
|
|
||||||
📄 frontend_src_app_page_tsx.txt
|
|
||||||
📄 eksport_script_py.txt
|
|
||||||
📄 frontend_src_components_ScrapeMethodSelect_tsx.txt
|
|
||||||
📄 frontend_src_app_golfbaner_[slug]_page_tsx.txt
|
|
||||||
📄 backend_import_wp_py.txt
|
|
||||||
📄 frontend_src_middleware_ts.txt
|
|
||||||
📄 backend_test_gemini_py.txt
|
|
||||||
📄 frontend_src_app_golfbaner_[slug]_CourseDisplay_tsx.txt
|
|
||||||
📄 frontend_next_config_ts.txt
|
|
||||||
📄 backend_update_admin_py.txt
|
|
||||||
📄 backend_import_nye_felter_py.txt
|
|
||||||
📄 frontend_src_app_admin_login_page_tsx.txt
|
|
||||||
📄 frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt
|
|
||||||
📄 backend_main_py.txt
|
|
||||||
📄 frontend_src_app_admin_rediger_[slug]_page_tsx.txt
|
|
||||||
📄 frontend_src_app_admin_page_tsx.txt
|
|
||||||
📄 frontend_src_app_admin_rediger_[slug]_EditFacilityClient_tsx.txt
|
|
||||||
📄 frontend_src_app_HeroSlider_tsx.txt
|
|
||||||
📄 backend_test_login_py.txt
|
|
||||||
📄 backend_create_admin_py.txt
|
|
||||||
📄 backend_sync_greenfee_py.txt
|
|
||||||
📄 backend_scrape_status_py.txt
|
|
||||||
📄 backend_scrape_golfamore1_3_py.txt
|
|
||||||
📄 frontend_src_app_FacilitySearch_tsx.txt
|
|
||||||
📄 backend_scrape_vtg_py.txt
|
|
||||||
📄 frontend_src_config_constants_ts.txt
|
|
||||||
📄 backend_import_gallery_py.txt
|
|
||||||
📁 frontend/
|
|
||||||
📄 eslint.config.mjs
|
|
||||||
📄 next-env.d.ts
|
|
||||||
📄 tsconfig.json
|
|
||||||
📄 README.md
|
|
||||||
📄 next.config.ts
|
|
||||||
📄 postcss.config.mjs
|
|
||||||
📄 package-lock.json
|
|
||||||
📄 .gitignore
|
|
||||||
📄 package.json
|
|
||||||
📄 Dockerfile
|
|
||||||
📁 public/
|
|
||||||
📄 globe.svg
|
|
||||||
📄 vercel.svg
|
|
||||||
📄 Toppbilde-standard.jpg
|
|
||||||
📄 TeeOff-logo-Retina-1.png
|
|
||||||
📄 window.svg
|
|
||||||
📄 next.svg
|
|
||||||
📄 file.svg
|
|
||||||
📁 media/
|
|
||||||
📄 slide_naeroysund-golfklubb_5.jpg
|
|
||||||
📄 main_hakadal-golfklubb.jpg
|
|
||||||
📄 slide_baerum-golfklubb_1.jpg
|
|
||||||
📄 slide_egersund-golfklubb_7.jpg
|
|
||||||
📄 main_hammerfest-og-kvalsund-golfklubb.jpg
|
|
||||||
📄 main_odda-golfklubb.jpg
|
|
||||||
📄 slide_holtsmark-golfklubb_1.jpg
|
|
||||||
📄 main_voss-golfklubb.jpeg
|
|
||||||
📄 slide_soon-golfklubb_2.jpg
|
|
||||||
📄 logo_ogna-golfklubb.jpg
|
|
||||||
📄 slide_tyrifjord-golfklubb_4.jpg
|
|
||||||
📄 slide_tjome-golfklubb_2.jpg
|
|
||||||
📄 logo_garder-golfklubb.jpg
|
|
||||||
📄 slide_helgeland-golfklubb_1.jpg
|
|
||||||
📄 slide_kvinnherad-golfklubb_0.jpg
|
|
||||||
📄 main_rauma-golfklubb.jpg
|
|
||||||
📄 slide_naeroysund-golfklubb_0.jpg
|
|
||||||
📄 slide_hallingdal-golfklubb_1.jpeg
|
|
||||||
📄 slide_hakadal-golfklubb_13.jpg
|
|
||||||
📄 slide_sunndal-golfklubb_2.jpg
|
|
||||||
📄 logo_skjeberg-golfklubb.jpg
|
|
||||||
📄 slide_larvik-golfklubb_0.jpg
|
|
||||||
📄 logo_tingvoll-golfklubb.jpg
|
|
||||||
📄 slide_sandefjord-golfklubb_4.jpg
|
|
||||||
📄 logo_onsoy-golfklubb.jpg
|
|
||||||
📄 logo_oppdal-golfklubb.jpg
|
|
||||||
📄 main_nordhaug-golfklubb.jpg
|
|
||||||
📄 main_hof-golfklubb.jpg
|
|
||||||
📄 main_hauger-golfklubb.jpg
|
|
||||||
📄 slide_vradal-golfklubb_1.jpg
|
|
||||||
📄 slide_holtsmark-golfklubb_5.jpg
|
|
||||||
📄 main_baerum-golfklubb.jpg
|
|
||||||
📄 logo_huseby-hanko-golfklubb.png
|
|
||||||
📄 slide_solum-golfklubb_4.jpg
|
|
||||||
📄 logo_hardanger-golfklubb.png
|
|
||||||
📄 logo_kristiansund-og-omegn-golfklubb.jpg
|
|
||||||
📄 main_byneset-golf.jpg
|
|
||||||
📄 logo_laerdal-golfklubb.jpg
|
|
||||||
📄 slide_kjekstad-golfklubb_0.jpg
|
|
||||||
📄 main_namdal-golfklubb.jpg
|
|
||||||
📄 main_hallingdal-golfklubb.jpg
|
|
||||||
📄 logo_ibestad-golfklubb.jpg
|
|
||||||
📄 logo_alta-golfklubb.png
|
|
||||||
📄 slide_norsjo-golfklubb_4.jpg
|
|
||||||
📄 main_elverum-golfklubb.jpg
|
|
||||||
📄 logo_hammerfest-og-kvalsund-golfklubb.jpg
|
|
||||||
📄 main_garder-golfklubb.jpg
|
|
||||||
📄 logo_lommedalen-golfklubb.jpg
|
|
||||||
📄 slide_ski-golfklubb_1.jpg
|
|
||||||
📄 logo_gronmo-golfklubb.jpg
|
|
||||||
📄 logo_hvam-golfklubb.jpg
|
|
||||||
📄 main_nesbyen-golfklubb.jpg
|
|
||||||
📄 slide_naeroysund-golfklubb_2.jpg
|
|
||||||
📄 slide_tjome-golfklubb_0.jpg
|
|
||||||
📄 logo_miklagard-golfklubb.png
|
|
||||||
📄 main_nordfjord-golfklubb.jpg
|
|
||||||
📄 slide_bjaavann-golfklubb_1.jpg
|
|
||||||
📄 main_jaeren-golfklubb.jpg
|
|
||||||
📄 slide_salten-golfklubb-bodo-golfpark_1.jpg
|
|
||||||
📄 slide_larvik-golfklubb_1.jpg
|
|
||||||
📄 main_modum-golfklubb.jpg
|
|
||||||
📄 logo_volda-golfklubb.jpg
|
|
||||||
📄 main_grini-golfklubb.jpg
|
|
||||||
📄 slide_aurskog-golfpark_0.jpg
|
|
||||||
📄 main_miklagard-golfklubb.jpg
|
|
||||||
📄 main_bjaavann-golfklubb.jpg
|
|
||||||
📄 main_fet-golfklubb.jpg
|
|
||||||
📄 slide_atlungstad-golfklubb_2.jpg
|
|
||||||
📄 logo_kvinnherad-golfklubb.jpg
|
|
||||||
📄 slide_borregaard-golfklubb_1.jpg
|
|
||||||
📄 slide_tjome-golfklubb_4.jpg
|
|
||||||
📄 main_rjukan-og-tinn-golfklubb.jpg
|
|
||||||
📄 slide_kjekstad-golfklubb_1.jpg
|
|
||||||
📄 slide_norsjo-golfklubb_3.jpg
|
|
||||||
📄 logo_surnadal-golfklubb.png
|
|
||||||
📄 slide_kongsberg-golfklubb_0.jpg
|
|
||||||
📄 slide_hovden-golfklubb_1.jpg
|
|
||||||
📄 main_hemsedal-golfklubb.jpg
|
|
||||||
📄 slide_tyrifjord-golfklubb_5.jpg
|
|
||||||
📄 logo_alesund-golfklubb.jpg
|
|
||||||
📄 slide_tingvoll-golfklubb_0.jpg
|
|
||||||
📄 slide_tyrifjord-golfklubb_2.jpg
|
|
||||||
📄 slide_bleik-golfstrombane_0.jpg
|
|
||||||
📄 slide_nesbyen-golfklubb_0.jpg
|
|
||||||
📄 main_sunnfjord-golfklubb.jpg
|
|
||||||
📄 slide_hasvik-golfklubb_0.jpg
|
|
||||||
📄 logo_stavanger-golfklubb.png
|
|
||||||
📄 slide_ostmarka-golfklubb_3.jpg
|
|
||||||
📄 main_land-golfklubb.jpg
|
|
||||||
📄 slide_helgeland-golfklubb_5.jpg
|
|
||||||
📄 logo_bergen-golfklubb.png
|
|
||||||
📄 slide_lofoten-golfklubb_1.jpg
|
|
||||||
📄 logo_rjukan-og-tinn-golfklubb.jpg
|
|
||||||
📄 main_alta-golfklubb.jpg
|
|
||||||
📄 slide_asker-golfklubb_1.jpg
|
|
||||||
📄 slide_krokhol-golfklubb_5.jpg
|
|
||||||
📄 logo_nordvegen-golfklubb.png
|
|
||||||
📄 logo_kristiansand-golfklubb.jpg
|
|
||||||
📄 main_asker-golfklubb.jpg
|
|
||||||
📄 slide_sleneset-golfklubb_0.jpg
|
|
||||||
📄 logo_re-golfklubb.jpg
|
|
||||||
📄 slide_oustoen-country-club_0.jpg
|
|
||||||
📄 logo_hallingdal-golfklubb.png
|
|
||||||
📄 logo_fet-golfklubb.jpg
|
|
||||||
📄 slide_trondheim-golfklubb_3.jpg
|
|
||||||
📄 slide_borregaard-golfklubb_3.jpg
|
|
||||||
📄 main_hovden-golfklubb.jpg
|
|
||||||
📄 slide_ostmarka-golfklubb_2.jpg
|
|
||||||
📄 slide_sandane-golfklubb_2.jpg
|
|
||||||
📄 slide_trondheim-golfklubb_6.jpg
|
|
||||||
📄 slide_helgeland-golfklubb_9.jpg
|
|
||||||
📄 logo_borre-golfklubb.png
|
|
||||||
📄 main_karasjok-golfklubb.jpg
|
|
||||||
📄 slide_hallingdal-golfklubb_2.jpeg
|
|
||||||
📄 main_north-cape-golf-club.jpg
|
|
||||||
📄 logo_selbu-golfklubb.png
|
|
||||||
📄 logo_sola-golfklubb-forus.jpg
|
|
||||||
📄 slide_asker-golfklubb_0.jpg
|
|
||||||
📄 main_hafjell-golfklubb.jpg
|
|
||||||
📄 logo_mjosen-golfklubb.png
|
|
||||||
📄 slide_atlungstad-golfklubb_0.jpg
|
|
||||||
📄 main_moa-golfsenter.jpg
|
|
||||||
📄 slide_bleik-golfstrombane_7.jpg
|
|
||||||
📄 main_kvinnherad-golfklubb.jpg
|
|
||||||
📄 main_re-golfklubb.jpg
|
|
||||||
📄 logo_haga-golfklubb.jpg
|
|
||||||
📄 main_roros-golfklubb.jpg
|
|
||||||
📄 main_vestfold-golfklubb.jpg
|
|
||||||
📄 slide_egersund-golfklubb_2.jpg
|
|
||||||
📄 slide_volda-golfklubb_2.jpeg
|
|
||||||
📄 slide_egersund-golfklubb_5.jpg
|
|
||||||
📄 main_gjersjoen-golfklubb.jpg
|
|
||||||
📄 main_gamle-fredrikstad-golfklubb.jpg
|
|
||||||
📄 slide_naeroysund-golfklubb_3.jpg
|
|
||||||
📄 slide_oustoen-country-club_1.jpg
|
|
||||||
📄 logo_hitra-golfklubb.png
|
|
||||||
📄 logo_rygge-flystasjon-golf-klubb.jpg
|
|
||||||
📄 slide_tyrifjord-golfklubb_9.jpg
|
|
||||||
📄 slide_trondheim-golfklubb_5.jpg
|
|
||||||
📄 slide_aurskog-golfpark_1.jpg
|
|
||||||
📄 main_sola-golfklubb-solastranden.jpg
|
|
||||||
📄 logo_trysil-golfklubb.jpg
|
|
||||||
📄 logo_harstad-golfklubb.jpg
|
|
||||||
📄 slide_ogna-golfklubb_1.jpg
|
|
||||||
📄 logo_gamle-fredrikstad-golfklubb.jpg
|
|
||||||
📄 main_stranda-golfklubb.jpg
|
|
||||||
📄 logo_sauda-golfklubb.png
|
|
||||||
📄 slide_egersund-golfklubb_0.jpg
|
|
||||||
📄 logo_solum-golfklubb.jpg
|
|
||||||
📄 main_bjornefjorden-golfklubb.jpg
|
|
||||||
📄 main_vestlia-golf.jpg
|
|
||||||
📄 slide_sandefjord-golfklubb_1.jpg
|
|
||||||
📄 logo_borregaard-golfklubb.jpg
|
|
||||||
📄 main_grimstad-golfklubb.jpg
|
|
||||||
📄 logo_vesteralen-golfklubb.png
|
|
||||||
📄 main_kristiansund-og-omegn-golfklubb.jpg
|
|
||||||
📄 slide_hallingdal-golfklubb_3.jpeg
|
|
||||||
📄 logo_sandnes-golfklubb.jpg
|
|
||||||
📄 main_alesund-golfklubb.jpeg
|
|
||||||
📄 main_steinkjer-golfklubb.jpg
|
|
||||||
📄 main_skei-golfklubb.jpg
|
|
||||||
📄 slide_nesbyen-golfklubb_3.jpg
|
|
||||||
📄 slide_hardanger-golfklubb_0.jpg
|
|
||||||
📄 slide_gumoy-golf_1.jpg
|
|
||||||
📄 main_grenland-og-omegn-golfklubb.jpg
|
|
||||||
📄 main_karmoy-golfklubb.jpg
|
|
||||||
📄 logo_egersund-golfklubb.jpg
|
|
||||||
📄 logo_baerum-golfklubb.png
|
|
||||||
📄 main_kristiansand-golfklubb.jpg
|
|
||||||
📄 slide_kongsberg-golfklubb_2.jpg
|
|
||||||
📄 slide_gumoy-golf_3.jpg
|
|
||||||
📄 logo_smola-golfklubb.jpg
|
|
||||||
📄 logo_grimstad-golfklubb.png
|
|
||||||
📄 logo_stord-golfklubb.png
|
|
||||||
📄 slide_helgeland-golfklubb_6.jpg
|
|
||||||
📄 logo_rauma-golfklubb.png
|
|
||||||
📄 logo_hinnoy-golfklubb.jpg
|
|
||||||
📄 slide_holtsmark-golfklubb_0.jpg
|
|
||||||
📄 logo_varanger-golfklubb.jpg
|
|
||||||
📄 logo_mandal-golfklubb.jpg
|
|
||||||
📄 slide_trondheim-golfklubb_1.jpg
|
|
||||||
📄 logo_tjome-golfklubb.png
|
|
||||||
📄 slide_lofoten-golfklubb_2.jpg
|
|
||||||
📄 main_haga-golfklubb.jpg
|
|
||||||
📄 slide_bjaavann-golfklubb_2.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_8.jpg
|
|
||||||
📄 logo_herdla-golfklubb.jpg
|
|
||||||
📄 main_stavanger-golfklubb.jpg
|
|
||||||
📄 main_lommedalen-golfklubb.jpg
|
|
||||||
📄 main_sirdal-fjellgolf-klubb.jpg
|
|
||||||
📄 main_alsten-golfklubb.jpg
|
|
||||||
📄 logo_lillestrom-golfklubb.png
|
|
||||||
📄 main_ogna-golfklubb.jpg
|
|
||||||
📄 slide_helgeland-golfklubb_8.jpg
|
|
||||||
📄 slide_nesbyen-golfklubb_2.jpg
|
|
||||||
📄 main_tjome-golfklubb.jpg
|
|
||||||
📄 main_sandefjord-golfklubb.jpg
|
|
||||||
📄 main_namsos-golfklubb.jpg
|
|
||||||
📄 logo_notteroy-golfklubb.jpg
|
|
||||||
📄 main_trondheim-golfklubb.jpg
|
|
||||||
📄 slide_solum-golfklubb_1.jpg
|
|
||||||
📄 slide_kongsberg-golfklubb_4.jpg
|
|
||||||
📄 main_utsikten-golfklubb.jpg
|
|
||||||
📄 main_vesteralen-golfklubb.jpg
|
|
||||||
📄 slide_sandefjord-golfklubb_0.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_9.jpg
|
|
||||||
📄 logo_polarsirkelen-golfklubb.jpg
|
|
||||||
📄 logo_sandane-golfklubb.jpg
|
|
||||||
📄 slide_groruddalen-golfklubb_2.jpg
|
|
||||||
📄 main_ski-golfklubb.jpg
|
|
||||||
📄 logo_bjornefjorden-golfklubb.png
|
|
||||||
📄 main_polarsirkelen-golfklubb.jpg
|
|
||||||
📄 slide_krokhol-golfklubb_1.jpg
|
|
||||||
📄 main_groruddalen-golfklubb.jpg
|
|
||||||
📄 slide_krokhol-golfklubb_4.jpg
|
|
||||||
📄 main_vanylven-golfklubb.jpg
|
|
||||||
📄 main_borregaard-golfklubb.jpg
|
|
||||||
📄 slide_sandefjord-golfklubb_5.jpg
|
|
||||||
📄 slide_helgeland-golfklubb_2.jpg
|
|
||||||
📄 main_hinnoy-golfklubb.jpg
|
|
||||||
📄 slide_sunndal-golfklubb_0.jpg
|
|
||||||
📄 logo_groruddalen-golfklubb.png
|
|
||||||
📄 main_tingvoll-golfklubb.jpg
|
|
||||||
📄 slide_tjome-golfklubb_3.jpg
|
|
||||||
📄 main_lindesnes-golfklubb.jpg
|
|
||||||
📄 slide_kongsvingers-golfklubb_5.jpg
|
|
||||||
📄 slide_tromso-golfklubb_0.jpg
|
|
||||||
📄 main_randaberg-golfklubb.jpg
|
|
||||||
📄 logo_krokhol-golfklubb.png
|
|
||||||
📄 main_smola-golfklubb.jpg
|
|
||||||
📄 logo_sande-golfklubb.jpg
|
|
||||||
📄 logo_oslo-golfklubb.jpg
|
|
||||||
📄 logo_preikestolen.jpg
|
|
||||||
📄 logo_sunnfjord-golfklubb.jpg
|
|
||||||
📄 slide_lofoten-golfklubb_3.jpg
|
|
||||||
📄 main_bleik-golfstrombane.jpg
|
|
||||||
📄 slide_gumoy-golf_0.jpg
|
|
||||||
📄 logo_modum-golfklubb.png
|
|
||||||
📄 slide_tingvoll-golfklubb_1.jpg
|
|
||||||
📄 logo_bodo-golfklubb.jpg
|
|
||||||
📄 logo_nesbyen-golfklubb.png
|
|
||||||
📄 main_selbu-golfklubb.jpg
|
|
||||||
📄 slide_holtsmark-golfklubb_2.jpg
|
|
||||||
📄 logo_imjelt-pitch-putt.jpg
|
|
||||||
📄 logo_ullensaker-golfklubb.png
|
|
||||||
📄 slide_elverum-golfklubb_1.jpg
|
|
||||||
📄 logo_ringerike-golfklubb.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_6.jpg
|
|
||||||
📄 slide_volda-golfklubb_4.jpg
|
|
||||||
📄 slide_tyrifjord-golfklubb_6.jpg
|
|
||||||
📄 main_solum-golfklubb.jpg
|
|
||||||
📄 slide_helgeland-golfklubb_4.jpg
|
|
||||||
📄 main_stiklestad-golfklubb.jpg
|
|
||||||
📄 slide_stavanger-golfklubb_0.jpg
|
|
||||||
📄 logo_vestlia-golf.jpg
|
|
||||||
📄 slide_vestfold-golfklubb_0.jpg
|
|
||||||
📄 logo_floro-golfklubb.jpg
|
|
||||||
📄 logo_voss-golfklubb.jpg
|
|
||||||
📄 slide_tyrifjord-golfklubb_8.jpg
|
|
||||||
📄 slide_sotra-golfklubb_2.jpg
|
|
||||||
📄 logo_jaeren-golfklubb.png
|
|
||||||
📄 slide_moss-rygge-golfklubb_0.jpg
|
|
||||||
📄 logo_asker-golfklubb.png
|
|
||||||
📄 slide_bleik-golfstrombane_6.jpg
|
|
||||||
📄 slide_bjaavann-golfklubb_3.jpg
|
|
||||||
📄 main_hvam-golfklubb.jpg
|
|
||||||
📄 logo_eiker-golfklubb.jpg
|
|
||||||
📄 slide_bleik-golfstrombane_9.jpg
|
|
||||||
📄 slide_namdal-golfklubb_0.jpg
|
|
||||||
📄 logo_nordhaug-golfklubb.jpg
|
|
||||||
📄 slide_borregaard-golfklubb_0.jpg
|
|
||||||
📄 slide_kongsberg-golfklubb_5.jpg
|
|
||||||
📄 main_hardanger-golfklubb.jpg
|
|
||||||
📄 slide_ostmarka-golfklubb_5.jpg
|
|
||||||
📄 logo_oppegard-golfklubb.png
|
|
||||||
📄 slide_vradal-golfklubb_0.jpg
|
|
||||||
📄 main_narvik-golfklubb.jpg
|
|
||||||
📄 logo_midt-troms-golfklubb.jpg
|
|
||||||
📄 slide_solum-golfklubb_2.jpg
|
|
||||||
📄 slide_sunndal-golfklubb_1.jpg
|
|
||||||
📄 slide_gjersjoen-golfklubb_1.jpg
|
|
||||||
📄 slide_preikestolen_0.jpg
|
|
||||||
📄 main_huseby-hanko-golfklubb.jpg
|
|
||||||
📄 slide_holtsmark-golfklubb_4.jpg
|
|
||||||
📄 main_larvik-golfklubb.jpg
|
|
||||||
📄 slide_hasvik-golfklubb_1.jpg
|
|
||||||
📄 slide_herdla-golfklubb_1.jpg
|
|
||||||
📄 logo_kjekstad-golfklubb.jpg
|
|
||||||
📄 main_austratt-golfklubb.jpg
|
|
||||||
📄 slide_sandefjord-golfklubb_3.jpg
|
|
||||||
📄 logo_hakadal-golfklubb.png
|
|
||||||
📄 slide_bleik-golfstrombane_3.jpg
|
|
||||||
📄 logo_vanylven-golfklubb.jpg
|
|
||||||
📄 main_skjeberg-golfklubb.jpg
|
|
||||||
📄 slide_hovden-golfklubb_3.jpg
|
|
||||||
📄 slide_ostmarka-golfklubb_0.jpg
|
|
||||||
📄 logo_tromso-golfklubb.png
|
|
||||||
📄 main_stjordal-golfklubb.jpg
|
|
||||||
📄 slide_frosta-golfklubb_3.jpg
|
|
||||||
📄 main_nordvegen-golfklubb.jpg
|
|
||||||
📄 slide_norsjo-golfklubb_1.jpg
|
|
||||||
📄 logo_gumoy-golf.png
|
|
||||||
📄 slide_gronmo-golfklubb_2.jpg
|
|
||||||
📄 slide_hardanger-golfklubb_1.jpg
|
|
||||||
📄 main_oppdal-golfklubb.jpg
|
|
||||||
📄 slide_trondheim-golfklubb_0.jpg
|
|
||||||
📄 main_lillestrom-golfklubb.jpg
|
|
||||||
📄 slide_gjersjoen-golfklubb_0.jpg
|
|
||||||
📄 slide_soon-golfklubb_1.jpg
|
|
||||||
📄 logo_vestfold-golfklubb.jpg
|
|
||||||
📄 slide_stavanger-golfklubb_1.jpg
|
|
||||||
📄 main_norefjell-golfklubb.jpg
|
|
||||||
📄 main_kongsberg-golfklubb.jpg
|
|
||||||
📄 slide_gjovik-og-toten-golfklubb_2.jpg
|
|
||||||
📄 logo_veierland-golfklubb.jpg
|
|
||||||
📄 slide_soon-golfklubb_0.jpg
|
|
||||||
📄 slide_egersund-golfklubb_6.jpg
|
|
||||||
📄 slide_norsjo-golfklubb_0.jpg
|
|
||||||
📄 slide_kristiansand-golfklubb_0.jpg
|
|
||||||
📄 main_hurum-golfklubb.jpg
|
|
||||||
📄 main_sola-golfklubb-forus.jpg
|
|
||||||
📄 logo_mork-golfklubb.jpg
|
|
||||||
📄 logo_molde-golfklubb.png
|
|
||||||
📄 main_kongsvingers-golfklubb.jpg
|
|
||||||
📄 main_sunndal-golfklubb.jpg
|
|
||||||
📄 logo_utsikten-golfklubb.jpg
|
|
||||||
📄 logo_austratt-golfklubb.jpg
|
|
||||||
📄 logo_selje-golfklubb.jpg
|
|
||||||
📄 main_stord-golfklubb.jpg
|
|
||||||
📄 logo_sorknes-golfklubb.jpg
|
|
||||||
📄 slide_krokhol-golfklubb_3.jpg
|
|
||||||
📄 slide_hovden-golfklubb_4.jpg
|
|
||||||
📄 main_preikestolen.jpg
|
|
||||||
📄 logo_aurskog-golfpark.png
|
|
||||||
📄 main_aurskog-golfpark.jpg
|
|
||||||
📄 slide_mork-golfklubb_1.jpg
|
|
||||||
📄 logo_roros-golfklubb.jpg
|
|
||||||
📄 main_harstad-golfklubb.jpg
|
|
||||||
📄 main_helgeland-golfklubb.jpg
|
|
||||||
📄 slide_ogna-golfklubb_0.jpg
|
|
||||||
📄 slide_kvinnherad-golfklubb_1.jpg
|
|
||||||
📄 logo_haugesund-golfklubb.png
|
|
||||||
📄 slide_bjaavann-golfklubb_0.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_3.jpg
|
|
||||||
📄 slide_ostmarka-golfklubb_6.jpg
|
|
||||||
📄 slide_bleik-golfstrombane_11.jpg
|
|
||||||
📄 logo_moss-rygge-golfklubb.png
|
|
||||||
📄 slide_borregaard-golfklubb_2.jpg
|
|
||||||
📄 main_lofoten-golfklubb.jpg
|
|
||||||
📄 slide_bleik-golfstrombane_5.jpg
|
|
||||||
📄 logo_oustoen-country-club.jpg
|
|
||||||
📄 main_oslo-golfklubb.jpg
|
|
||||||
📄 slide_gjovik-og-toten-golfklubb_0.jpg
|
|
||||||
📄 slide_frosta-golfklubb_1.jpg
|
|
||||||
📄 slide_ostmarka-golfklubb_7.jpg
|
|
||||||
📄 slide_helgeland-golfklubb_3.jpg
|
|
||||||
📄 logo_vradal-golfklubb.png
|
|
||||||
📄 main_salten-golfklubb-bodo-golfpark.jpg
|
|
||||||
📄 main_rygge-flystasjon-golf-klubb.jpg
|
|
||||||
📄 slide_trondheim-golfklubb_4.jpg
|
|
||||||
📄 main_meland-golfklubb.jpg
|
|
||||||
📄 slide_sotra-golfklubb_1.jpg
|
|
||||||
📄 main_sandane-golfklubb.jpg
|
|
||||||
📄 slide_baerum-golfklubb_0.jpg
|
|
||||||
📄 slide_egersund-golfklubb_3.jpg
|
|
||||||
📄 slide_frosta-golfklubb_5.jpg
|
|
||||||
📄 logo_sandefjord-golfklubb.png
|
|
||||||
📄 slide_egersund-golfklubb_4.jpg
|
|
||||||
📄 slide_sandane-golfklubb_1.jpg
|
|
||||||
📄 slide_bleik-golfstrombane_2.jpg
|
|
||||||
📄 main_eiker-golfklubb.jpg
|
|
||||||
📄 main_tromso-golfklubb.jpg
|
|
||||||
📄 logo_tysnes-golfklubb.jpg
|
|
||||||
📄 slide_hallingdal-golfklubb_4.jpeg
|
|
||||||
📄 main_ringerike-golfklubb.jpg
|
|
||||||
📄 main_norsjo-golfklubb.jpg
|
|
||||||
📄 logo_byneset-golf.jpg
|
|
||||||
📄 logo_land-golfklubb.png
|
|
||||||
📄 slide_bleik-golfstrombane_8.jpg
|
|
||||||
📄 logo_losby-golfklubb.png
|
|
||||||
📄 logo_husoy-golfklubb.png
|
|
||||||
📄 main_nittedal-golfklubb.jpg
|
|
||||||
📄 logo_trondheim-par3golf-havstein.png
|
|
||||||
📄 main_mork-golfklubb.jpg
|
|
||||||
📄 slide_sleneset-golfklubb_1.jpg
|
|
||||||
📄 logo_ekholtbruket-golfklubb.jpg
|
|
||||||
📄 slide_hauger-golfklubb_1.jpg
|
|
||||||
📄 main_borre-golfklubb.jpg
|
|
||||||
📄 main_kragero-golfklubb.jpg
|
|
||||||
📄 main_lonne-golfklubb.jpg
|
|
||||||
📄 logo_namsos-golfklubb.jpg
|
|
||||||
📄 slide_hallingdal-golfklubb_0.jpeg
|
|
||||||
📄 slide_solum-golfklubb_0.jpg
|
|
||||||
📄 main_holtsmark-golfklubb.jpg
|
|
||||||
📄 main_oustoen-country-club.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_12.jpg
|
|
||||||
📄 slide_gronmo-golfklubb_1.jpg
|
|
||||||
📄 main_mjosen-golfklubb.jpg
|
|
||||||
📄 logo_sunndal-golfklubb.jpg
|
|
||||||
📄 logo_larvik-golfklubb.jpg
|
|
||||||
📄 slide_ekholtbruket-golfklubb_0.jpg
|
|
||||||
📄 main_husoy-golfklubb.jpg
|
|
||||||
📄 logo_drammen-golfklubb.png
|
|
||||||
📄 logo_helgeland-golfklubb.png
|
|
||||||
📄 main_gjerdrum-golfklubb.jpg
|
|
||||||
📄 slide_sandefjord-golfklubb_2.jpg
|
|
||||||
📄 main_hvaler-golfklubb.jpg
|
|
||||||
📄 logo_sunnmore-golfklubb.jpg
|
|
||||||
📄 slide_hauger-golfklubb_0.jpg
|
|
||||||
📄 main_naeroysund-golfklubb.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_5.jpg
|
|
||||||
📄 logo_bamble-golfklubb.png
|
|
||||||
📄 slide_sandane-golfklubb_0.jpg
|
|
||||||
📄 main_bodo-golfklubb.jpeg
|
|
||||||
📄 main_drammen-golfklubb.jpg
|
|
||||||
📄 slide_frosta-golfklubb_4.jpg
|
|
||||||
📄 slide_solum-golfklubb_3.jpg
|
|
||||||
📄 slide_bleik-golfstrombane_1.jpg
|
|
||||||
📄 main_trondheim-par3golf-havstein.jpg
|
|
||||||
📄 logo_kongsvingers-golfklubb.jpg
|
|
||||||
📄 slide_volda-golfklubb_3.jpeg
|
|
||||||
📄 slide_tyrifjord-golfklubb_3.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_2.jpg
|
|
||||||
📄 slide_hovden-golfklubb_2.jpg
|
|
||||||
📄 slide_kongsvingers-golfklubb_4.jpg
|
|
||||||
📄 main_arendal-omegn-golfklubb.jpg
|
|
||||||
📄 logo_bjaavann-golfklubb.png
|
|
||||||
📄 main_sande-golfklubb.jpg
|
|
||||||
📄 main_ballerud-golfklubb.jpg
|
|
||||||
📄 logo_norefjell-golfklubb.png
|
|
||||||
📄 slide_kristiansand-golfklubb_1.jpg
|
|
||||||
📄 main_veierland-golfklubb.jpg
|
|
||||||
📄 logo_karmoy-golfklubb.png
|
|
||||||
📄 logo_lofoten-golfklubb.png
|
|
||||||
📄 slide_lofoten-golfklubb_0.jpg
|
|
||||||
📄 logo_hurum-golfklubb.png
|
|
||||||
📄 slide_holtsmark-golfklubb_3.jpg
|
|
||||||
📄 logo_hemsedal-golfklubb.jpg
|
|
||||||
📄 slide_borregaard-golfklubb_5.jpg
|
|
||||||
📄 logo_haugaland-golfklubb.png
|
|
||||||
📄 slide_bjornefjorden-golfklubb_1.jpg
|
|
||||||
📄 main_notteroy-golfklubb.jpg
|
|
||||||
📄 slide_kongsvingers-golfklubb_3.jpg
|
|
||||||
📄 slide_kongsvingers-golfklubb_2.jpg
|
|
||||||
📄 main_fana-golfklubb.jpg
|
|
||||||
📄 logo_giske-golfklubb.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_11.jpg
|
|
||||||
📄 main_nes-golfklubb-09.jpg
|
|
||||||
📄 main_volda-golfklubb.jpeg
|
|
||||||
📄 logo_arendal-omegn-golfklubb.png
|
|
||||||
📄 slide_gjovik-og-toten-golfklubb_1.jpg
|
|
||||||
📄 main_soon-golfklubb.jpg
|
|
||||||
📄 logo_soon-golfklubb.png
|
|
||||||
📄 slide_gumoy-golf_2.jpg
|
|
||||||
📄 logo_alsten-golfklubb.png
|
|
||||||
📄 main_halden-golfklubb.jpg
|
|
||||||
📄 logo_narvik-golfklubb.jpg
|
|
||||||
📄 logo_randaberg-golfklubb.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_10.jpg
|
|
||||||
📄 main_ostmarka-golfklubb.jpg
|
|
||||||
📄 logo_bleik-golfstrombane.jpg
|
|
||||||
📄 logo_hasvik-golfklubb.png
|
|
||||||
📄 main_floro-golfklubb.jpg
|
|
||||||
📄 main_gronmo-golfklubb.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_7.jpg
|
|
||||||
📄 slide_egersund-golfklubb_8.jpg
|
|
||||||
📄 logo_atlungstad-golfklubb.png
|
|
||||||
📄 main_sunnmore-golfklubb.jpeg
|
|
||||||
📄 main_sleneset-golfklubb.jpg
|
|
||||||
📄 logo_lonne-golfklubb.png
|
|
||||||
📄 main_haugaland-golfklubb.jpg
|
|
||||||
📄 main_egersund-golfklubb.jpg
|
|
||||||
📄 slide_solum-golfklubb_5.jpg
|
|
||||||
📄 slide_gamle-fredrikstad-golfklubb_1.jpg
|
|
||||||
📄 main_sotra-golfklubb.jpg
|
|
||||||
📄 main_trysil-golfklubb.jpg
|
|
||||||
📄 logo_kongsberg-golfklubb.png
|
|
||||||
📄 slide_tromso-golfklubb_1.jpg
|
|
||||||
📄 logo_frosta-golfklubb.jpg
|
|
||||||
📄 slide_groruddalen-golfklubb_1.jpg
|
|
||||||
📄 logo_hafjell-golfklubb.png
|
|
||||||
📄 logo_sola-golfklubb-solastranden.png
|
|
||||||
📄 logo_holtsmark-golfklubb.png
|
|
||||||
📄 slide_naeroysund-golfklubb_1.jpg
|
|
||||||
📄 slide_groruddalen-golfklubb_0.jpg
|
|
||||||
📄 slide_naeroysund-golfklubb_4.jpg
|
|
||||||
📄 slide_kjekstad-golfklubb_2.jpg
|
|
||||||
📄 slide_gronmo-golfklubb_0.jpg
|
|
||||||
📄 slide_preikestolen_1.jpg
|
|
||||||
📄 logo_fana-golfklubb.jpg
|
|
||||||
📄 slide_sorknes-golfklubb_0.jpg
|
|
||||||
📄 slide_atlungstad-golfklubb_3.jpg
|
|
||||||
📄 main_losby-golfklubb.jpg
|
|
||||||
📄 slide_borregaard-golfklubb_4.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_1.jpg
|
|
||||||
📄 main_sandnes-golfklubb.jpg
|
|
||||||
📄 slide_norsjo-golfklubb_2.jpg
|
|
||||||
📄 main_frosta-golfklubb.jpg
|
|
||||||
📄 slide_krokhol-golfklubb_0.jpg
|
|
||||||
📄 logo_askim-golfklubb.png
|
|
||||||
📄 main_stryn-golfklubb.jpg
|
|
||||||
📄 slide_lofoten-golfklubb_5.jpg
|
|
||||||
📄 logo_karasjok-golfklubb.jpg
|
|
||||||
📄 slide_krokhol-golfklubb_2.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_14.jpg
|
|
||||||
📄 logo_gjovik-og-toten-golfklubb.png
|
|
||||||
📄 main_haugesund-golfklubb.jpg
|
|
||||||
📄 logo_stjordal-golfklubb.jpg
|
|
||||||
📄 logo_drobak-golfklubb.png
|
|
||||||
📄 main_atlungstad-golfklubb.jpg
|
|
||||||
📄 logo_trondheim-golfklubb.jpg
|
|
||||||
📄 logo_gjersjoen-golfklubb.png
|
|
||||||
📄 slide_lofoten-golfklubb_4.jpg
|
|
||||||
📄 slide_helgeland-golfklubb_7.jpg
|
|
||||||
📄 main_molde-golfklubb.jpg
|
|
||||||
📄 slide_tyrifjord-golfklubb_7.jpg
|
|
||||||
📄 logo_gjerdrum-golfklubb.png
|
|
||||||
📄 main_onsoy-golfklubb.jpg
|
|
||||||
📄 slide_trondheim-golfklubb_2.jpg
|
|
||||||
📄 slide_mork-golfklubb_0.jpg
|
|
||||||
📄 slide_frosta-golfklubb_2.jpg
|
|
||||||
📄 slide_giske-golfklubb_1.jpeg
|
|
||||||
📄 slide_moss-rygge-golfklubb_1.jpg
|
|
||||||
📄 slide_ostmarka-golfklubb_1.jpg
|
|
||||||
📄 slide_volda-golfklubb_0.jpeg
|
|
||||||
📄 logo_sotra-golfklubb.jpg
|
|
||||||
📄 main_mandal-golfklubb.jpg
|
|
||||||
📄 logo_grenland-og-omegn-golfklubb.jpg
|
|
||||||
📄 slide_volda-golfklubb_1.jpeg
|
|
||||||
📄 main_imjelt-pitch-putt.jpg
|
|
||||||
📄 logo_nittedal-golfklubb.png
|
|
||||||
📄 main_gumoy-golf.jpg
|
|
||||||
📄 logo_hauger-golfklubb.png
|
|
||||||
📄 slide_hakadal-golfklubb_4.jpg
|
|
||||||
📄 slide_ostmarka-golfklubb_4.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_0.jpg
|
|
||||||
📄 main_surnadal-golfklubb.jpg
|
|
||||||
📄 logo_hof-golfklubb.png
|
|
||||||
📄 slide_sleneset-golfklubb_2.jpg
|
|
||||||
📄 logo_halden-golfklubb.png
|
|
||||||
📄 logo_naeroysund-golfklubb.png
|
|
||||||
📄 logo_elverum-golfklubb.png
|
|
||||||
📄 main_bamble-golfklubb.jpg
|
|
||||||
📄 logo_kragero-golfklubb.png
|
|
||||||
📄 logo_valdres-golfklubb.png
|
|
||||||
📄 slide_tyrifjord-golfklubb_1.jpg
|
|
||||||
📄 logo_grini-golfklubb.png
|
|
||||||
📄 logo_lindesnes-golfklubb.jpg
|
|
||||||
📄 slide_lofoten-golfklubb_6.jpg
|
|
||||||
📄 main_sauda-golfklubb.jpg
|
|
||||||
📄 main_moss-rygge-golfklubb.jpg
|
|
||||||
📄 main_dalane-golfklubb.jpg
|
|
||||||
📄 logo_skei-golfklubb.jpg
|
|
||||||
📄 logo_ostmarka-golfklubb.png
|
|
||||||
📄 slide_nesbyen-golfklubb_1.jpg
|
|
||||||
📄 logo_stryn-golfklubb.png
|
|
||||||
📄 main_vradal-golfklubb.jpg
|
|
||||||
📄 slide_hovden-golfklubb_0.jpg
|
|
||||||
📄 main_gjovik-og-toten-golfklubb.jpg
|
|
||||||
📄 logo_hvaler-golfklubb.png
|
|
||||||
📄 main_giske-golfklubb.jpeg
|
|
||||||
📄 slide_herdla-golfklubb_0.jpg
|
|
||||||
📄 main_ibestad-golfklubb.jpg
|
|
||||||
📄 slide_kongsvingers-golfklubb_0.jpg
|
|
||||||
📄 slide_atlungstad-golfklubb_1.jpg
|
|
||||||
📄 logo_nordfjord-golfklubb.png
|
|
||||||
📄 logo_eidskog-golfklubb.png
|
|
||||||
📄 main_hitra-golfklubb.jpg
|
|
||||||
📄 logo_dalane-golfklubb.jpg
|
|
||||||
📄 main_kjekstad-golfklubb.jpg
|
|
||||||
📄 logo_odda-golfklubb.jpg
|
|
||||||
📄 logo_ballerud-golfklubb.jpg
|
|
||||||
📄 main_klaebu-golfklubb.jpg
|
|
||||||
📄 main_hasvik-golfklubb.jpg
|
|
||||||
📄 logo_klaebu-golfklubb.png
|
|
||||||
📄 slide_bleik-golfstrombane_4.jpg
|
|
||||||
📄 logo_north-cape-golf-club.jpg
|
|
||||||
📄 slide_tyrifjord-golfklubb_0.jpg
|
|
||||||
📄 logo_moa-golfsenter.png
|
|
||||||
📄 main_herdla-golfklubb.jpg
|
|
||||||
📄 slide_gjersjoen-golfklubb_2.jpg
|
|
||||||
📄 logo_stiklestad-golfklubb.png
|
|
||||||
📄 slide_bleik-golfstrombane_10.jpg
|
|
||||||
📄 logo_hovden-golfklubb.jpg
|
|
||||||
📄 slide_frosta-golfklubb_0.jpg
|
|
||||||
📄 logo_sirdal-fjellgolf-klubb.png
|
|
||||||
📄 main_ekholtbruket-golfklubb.jpg
|
|
||||||
📄 slide_salten-golfklubb-bodo-golfpark_0.jpg
|
|
||||||
📄 main_askim-golfklubb.jpg
|
|
||||||
📄 slide_elverum-golfklubb_0.jpg
|
|
||||||
📄 slide_gamle-fredrikstad-golfklubb_0.jpg
|
|
||||||
📄 main_oppegard-golfklubb.jpg
|
|
||||||
📄 slide_sorknes-golfklubb_1.jpg
|
|
||||||
📄 main_drobak-golfklubb.jpg
|
|
||||||
📄 slide_kongsvingers-golfklubb_1.jpg
|
|
||||||
📄 slide_helgeland-golfklubb_0.jpg
|
|
||||||
📄 slide_vestfold-golfklubb_1.jpg
|
|
||||||
📄 slide_kongsberg-golfklubb_1.jpg
|
|
||||||
📄 slide_tjome-golfklubb_1.jpg
|
|
||||||
📄 slide_ekholtbruket-golfklubb_1.jpg
|
|
||||||
📄 main_bergen-golfklubb.jpg
|
|
||||||
📄 logo_salten-golfklubb-bodo-golfpark.jpg
|
|
||||||
📄 slide_egersund-golfklubb_1.jpg
|
|
||||||
📄 slide_trondheim-golfklubb_7.jpg
|
|
||||||
📄 logo_stranda-golfklubb.jpg
|
|
||||||
📄 main_eidskog-golfklubb.jpg
|
|
||||||
📄 logo_nes-golfklubb-09.jpg
|
|
||||||
📄 logo_ski-golfklubb.png
|
|
||||||
📄 logo_norsjo-golfklubb.png
|
|
||||||
📄 slide_kongsberg-golfklubb_3.jpg
|
|
||||||
📄 slide_ski-golfklubb_0.jpg
|
|
||||||
📄 main_sorknes-golfklubb.jpg
|
|
||||||
📄 main_laerdal-golfklubb.jpg
|
|
||||||
📄 slide_giske-golfklubb_0.jpeg
|
|
||||||
📄 main_ullensaker-golfklubb.jpg
|
|
||||||
📄 main_midt-troms-golfklubb.jpg
|
|
||||||
📄 main_tysnes-golfklubb.jpg
|
|
||||||
📄 logo_namdal-golfklubb.jpg
|
|
||||||
📄 logo_tyrifjord-golfklubb.png
|
|
||||||
📄 slide_bjornefjorden-golfklubb_0.jpg
|
|
||||||
📄 slide_hovden-golfklubb_5.jpg
|
|
||||||
📄 main_tyrifjord-golfklubb.jpg
|
|
||||||
📄 slide_sotra-golfklubb_0.jpg
|
|
||||||
📄 main_valdres-golfklubb.jpg
|
|
||||||
📄 logo_steinkjer-golfklubb.jpg
|
|
||||||
📄 main_krokhol-golfklubb.jpg
|
|
||||||
📄 main_varanger-golfklubb.jpg
|
|
||||||
📄 logo_meland-golfklubb.png
|
|
||||||
📄 slide_namdal-golfklubb_1.jpg
|
|
||||||
📄 main_selje-golfklubb.jpg
|
|
||||||
📁 src/
|
|
||||||
📄 struktur_dump.txt
|
|
||||||
📄 middleware.ts
|
|
||||||
📁 components/
|
|
||||||
📄 ScrapeMethodSelect.tsx
|
|
||||||
📄 Header.tsx
|
|
||||||
📁 config/
|
|
||||||
📄 constants.ts
|
|
||||||
📁 app/
|
|
||||||
📄 FacilitySearch.tsx
|
|
||||||
📄 HeroSlider.tsx
|
|
||||||
📄 favicon.ico
|
|
||||||
📄 globals.css
|
|
||||||
📄 page.tsx
|
|
||||||
📄 layout.tsx
|
|
||||||
📁 golfbaner/
|
|
||||||
📁 [slug]/
|
|
||||||
📄 CourseDisplay.tsx
|
|
||||||
📄 page.tsx
|
|
||||||
📄 FacilityDetailView.tsx
|
|
||||||
📁 admin/
|
|
||||||
📄 page.tsx
|
|
||||||
📁 rediger/
|
|
||||||
📁 [slug]/
|
|
||||||
📄 EditFacilityClient.tsx
|
|
||||||
📄 page.tsx
|
|
||||||
📁 login/
|
|
||||||
📄 page.tsx
|
|
||||||
📁 vtg/
|
|
||||||
📄 page.tsx
|
|
||||||
📁 medlemskap/
|
|
||||||
📄 page.tsx
|
|
||||||
📁 greenfee/
|
|
||||||
📄 page.tsx
|
|
||||||
📁 kode_eksport_1/
|
|
||||||
📄 backend_scrape_membership_py.txt
|
|
||||||
📄 backend_scrape_greenfee_py.txt
|
|
||||||
📄 frontend_src_components_Header_tsx.txt
|
|
||||||
📄 backend_scrape_nsg_3_py.txt
|
|
||||||
📄 frontend_src_app_admin_medlemskap_page_tsx.txt
|
|
||||||
📄 frontend_next-env_d_ts.txt
|
|
||||||
📄 frontend_src_app_layout_tsx.txt
|
|
||||||
📄 frontend_src_app_admin_vtg_page_tsx.txt
|
|
||||||
📄 frontend_src_app_admin_greenfee_page_tsx.txt
|
|
||||||
📄 backend_import_urls_py.txt
|
|
||||||
📄 frontend_src_app_page_tsx.txt
|
|
||||||
📄 eksport_script_py.txt
|
|
||||||
📄 frontend_src_components_ScrapeMethodSelect_tsx.txt
|
|
||||||
📄 frontend_src_app_golfbaner_[slug]_page_tsx.txt
|
|
||||||
📄 backend_import_wp_py.txt
|
|
||||||
📄 frontend_src_middleware_ts.txt
|
|
||||||
📄 backend_test_gemini_py.txt
|
|
||||||
📄 frontend_src_app_golfbaner_[slug]_CourseDisplay_tsx.txt
|
|
||||||
📄 frontend_next_config_ts.txt
|
|
||||||
📄 backend_update_admin_py.txt
|
|
||||||
📄 backend_import_nye_felter_py.txt
|
|
||||||
📄 frontend_src_app_admin_login_page_tsx.txt
|
|
||||||
📄 frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt
|
|
||||||
📄 backend_main_py.txt
|
|
||||||
📄 frontend_src_app_admin_rediger_[slug]_page_tsx.txt
|
|
||||||
📄 frontend_src_app_admin_page_tsx.txt
|
|
||||||
📄 frontend_src_app_admin_rediger_[slug]_EditFacilityClient_tsx.txt
|
|
||||||
📄 frontend_src_app_HeroSlider_tsx.txt
|
|
||||||
📄 backend_test_login_py.txt
|
|
||||||
📄 backend_create_admin_py.txt
|
|
||||||
📄 backend_sync_greenfee_py.txt
|
|
||||||
📄 backend_scrape_status_py.txt
|
|
||||||
📄 backend_scrape_golfamore1_3_py.txt
|
|
||||||
📄 frontend_src_app_FacilitySearch_tsx.txt
|
|
||||||
📄 backend_scrape_vtg_py.txt
|
|
||||||
📄 frontend_src_config_constants_ts.txt
|
|
||||||
📄 backend_import_gallery_py.txt
|
|
||||||
📁 backend/
|
|
||||||
📄 scrape_nsg_3.py
|
|
||||||
📄 update_admin.py
|
|
||||||
📄 test_gemini.py
|
|
||||||
📄 import_gallery.py
|
|
||||||
📄 import_nye_felter.py
|
|
||||||
📄 .env
|
|
||||||
📄 VtG.txt
|
|
||||||
📄 scrape_membership.py
|
|
||||||
📄 test_login.py
|
|
||||||
📄 sync_greenfee.py
|
|
||||||
📄 scrape_greenfee.py
|
|
||||||
📄 scrape_status.py
|
|
||||||
📄 scrape_golfamore1.3.py
|
|
||||||
📄 requirements.txt
|
|
||||||
📄 import_wp.py
|
|
||||||
📄 create_admin.py
|
|
||||||
📄 scrape_vtg.py
|
|
||||||
📄 import_urls.py
|
|
||||||
📄 GreenFee.txt
|
|
||||||
📄 Medlemsskap.txt
|
|
||||||
📄 main.py
|
|
||||||
📄 Dockerfile
|
|
||||||
📁 public/
|
|
||||||
📁 media/
|
|
||||||
735
fil-tre.txt
735
fil-tre.txt
|
|
@ -1,735 +0,0 @@
|
||||||
📁 teeoff/
|
|
||||||
📄 seed.sql
|
|
||||||
📄 eksport_script.py
|
|
||||||
📄 update_golfbox.sql
|
|
||||||
📄 docker-compose.yml
|
|
||||||
📄 schema.sql
|
|
||||||
📄 init.sql
|
|
||||||
📁 frontend/
|
|
||||||
📄 eslint.config.mjs
|
|
||||||
📄 next-env.d.ts
|
|
||||||
📄 tsconfig.json
|
|
||||||
📄 README.md
|
|
||||||
📄 next.config.ts
|
|
||||||
📄 postcss.config.mjs
|
|
||||||
📄 package-lock.json
|
|
||||||
📄 .gitignore
|
|
||||||
📄 package.json
|
|
||||||
📄 Dockerfile
|
|
||||||
📁 public/
|
|
||||||
📄 globe.svg
|
|
||||||
📄 vercel.svg
|
|
||||||
📄 Toppbilde-standard.jpg
|
|
||||||
📄 TeeOff-logo-Retina-1.png
|
|
||||||
📄 window.svg
|
|
||||||
📄 next.svg
|
|
||||||
📄 file.svg
|
|
||||||
📁 media/
|
|
||||||
📄 slide_naeroysund-golfklubb_5.jpg
|
|
||||||
📄 main_hakadal-golfklubb.jpg
|
|
||||||
📄 slide_baerum-golfklubb_1.jpg
|
|
||||||
📄 slide_egersund-golfklubb_7.jpg
|
|
||||||
📄 main_hammerfest-og-kvalsund-golfklubb.jpg
|
|
||||||
📄 main_odda-golfklubb.jpg
|
|
||||||
📄 slide_holtsmark-golfklubb_1.jpg
|
|
||||||
📄 main_voss-golfklubb.jpeg
|
|
||||||
📄 slide_soon-golfklubb_2.jpg
|
|
||||||
📄 logo_ogna-golfklubb.jpg
|
|
||||||
📄 slide_tyrifjord-golfklubb_4.jpg
|
|
||||||
📄 slide_tjome-golfklubb_2.jpg
|
|
||||||
📄 logo_garder-golfklubb.jpg
|
|
||||||
📄 slide_helgeland-golfklubb_1.jpg
|
|
||||||
📄 slide_kvinnherad-golfklubb_0.jpg
|
|
||||||
📄 main_rauma-golfklubb.jpg
|
|
||||||
📄 slide_naeroysund-golfklubb_0.jpg
|
|
||||||
📄 slide_hallingdal-golfklubb_1.jpeg
|
|
||||||
📄 slide_hakadal-golfklubb_13.jpg
|
|
||||||
📄 slide_sunndal-golfklubb_2.jpg
|
|
||||||
📄 logo_skjeberg-golfklubb.jpg
|
|
||||||
📄 slide_larvik-golfklubb_0.jpg
|
|
||||||
📄 logo_tingvoll-golfklubb.jpg
|
|
||||||
📄 slide_sandefjord-golfklubb_4.jpg
|
|
||||||
📄 logo_onsoy-golfklubb.jpg
|
|
||||||
📄 logo_oppdal-golfklubb.jpg
|
|
||||||
📄 main_nordhaug-golfklubb.jpg
|
|
||||||
📄 main_hof-golfklubb.jpg
|
|
||||||
📄 main_hauger-golfklubb.jpg
|
|
||||||
📄 slide_vradal-golfklubb_1.jpg
|
|
||||||
📄 slide_holtsmark-golfklubb_5.jpg
|
|
||||||
📄 main_baerum-golfklubb.jpg
|
|
||||||
📄 logo_huseby-hanko-golfklubb.png
|
|
||||||
📄 slide_solum-golfklubb_4.jpg
|
|
||||||
📄 logo_hardanger-golfklubb.png
|
|
||||||
📄 logo_kristiansund-og-omegn-golfklubb.jpg
|
|
||||||
📄 main_byneset-golf.jpg
|
|
||||||
📄 logo_laerdal-golfklubb.jpg
|
|
||||||
📄 slide_kjekstad-golfklubb_0.jpg
|
|
||||||
📄 main_namdal-golfklubb.jpg
|
|
||||||
📄 main_hallingdal-golfklubb.jpg
|
|
||||||
📄 logo_ibestad-golfklubb.jpg
|
|
||||||
📄 logo_alta-golfklubb.png
|
|
||||||
📄 slide_norsjo-golfklubb_4.jpg
|
|
||||||
📄 main_elverum-golfklubb.jpg
|
|
||||||
📄 logo_hammerfest-og-kvalsund-golfklubb.jpg
|
|
||||||
📄 main_garder-golfklubb.jpg
|
|
||||||
📄 logo_lommedalen-golfklubb.jpg
|
|
||||||
📄 slide_ski-golfklubb_1.jpg
|
|
||||||
📄 logo_gronmo-golfklubb.jpg
|
|
||||||
📄 logo_hvam-golfklubb.jpg
|
|
||||||
📄 main_nesbyen-golfklubb.jpg
|
|
||||||
📄 slide_naeroysund-golfklubb_2.jpg
|
|
||||||
📄 slide_tjome-golfklubb_0.jpg
|
|
||||||
📄 logo_miklagard-golfklubb.png
|
|
||||||
📄 main_nordfjord-golfklubb.jpg
|
|
||||||
📄 slide_bjaavann-golfklubb_1.jpg
|
|
||||||
📄 main_jaeren-golfklubb.jpg
|
|
||||||
📄 slide_salten-golfklubb-bodo-golfpark_1.jpg
|
|
||||||
📄 slide_larvik-golfklubb_1.jpg
|
|
||||||
📄 main_modum-golfklubb.jpg
|
|
||||||
📄 logo_volda-golfklubb.jpg
|
|
||||||
📄 main_grini-golfklubb.jpg
|
|
||||||
📄 slide_aurskog-golfpark_0.jpg
|
|
||||||
📄 main_miklagard-golfklubb.jpg
|
|
||||||
📄 main_bjaavann-golfklubb.jpg
|
|
||||||
📄 main_fet-golfklubb.jpg
|
|
||||||
📄 slide_atlungstad-golfklubb_2.jpg
|
|
||||||
📄 logo_kvinnherad-golfklubb.jpg
|
|
||||||
📄 slide_borregaard-golfklubb_1.jpg
|
|
||||||
📄 slide_tjome-golfklubb_4.jpg
|
|
||||||
📄 main_rjukan-og-tinn-golfklubb.jpg
|
|
||||||
📄 slide_kjekstad-golfklubb_1.jpg
|
|
||||||
📄 slide_norsjo-golfklubb_3.jpg
|
|
||||||
📄 logo_surnadal-golfklubb.png
|
|
||||||
📄 slide_kongsberg-golfklubb_0.jpg
|
|
||||||
📄 slide_hovden-golfklubb_1.jpg
|
|
||||||
📄 main_hemsedal-golfklubb.jpg
|
|
||||||
📄 slide_tyrifjord-golfklubb_5.jpg
|
|
||||||
📄 logo_alesund-golfklubb.jpg
|
|
||||||
📄 slide_tingvoll-golfklubb_0.jpg
|
|
||||||
📄 slide_tyrifjord-golfklubb_2.jpg
|
|
||||||
📄 slide_bleik-golfstrombane_0.jpg
|
|
||||||
📄 slide_nesbyen-golfklubb_0.jpg
|
|
||||||
📄 main_sunnfjord-golfklubb.jpg
|
|
||||||
📄 slide_hasvik-golfklubb_0.jpg
|
|
||||||
📄 logo_stavanger-golfklubb.png
|
|
||||||
📄 slide_ostmarka-golfklubb_3.jpg
|
|
||||||
📄 main_land-golfklubb.jpg
|
|
||||||
📄 slide_helgeland-golfklubb_5.jpg
|
|
||||||
📄 logo_bergen-golfklubb.png
|
|
||||||
📄 slide_lofoten-golfklubb_1.jpg
|
|
||||||
📄 logo_rjukan-og-tinn-golfklubb.jpg
|
|
||||||
📄 main_alta-golfklubb.jpg
|
|
||||||
📄 slide_asker-golfklubb_1.jpg
|
|
||||||
📄 slide_krokhol-golfklubb_5.jpg
|
|
||||||
📄 logo_nordvegen-golfklubb.png
|
|
||||||
📄 logo_kristiansand-golfklubb.jpg
|
|
||||||
📄 main_asker-golfklubb.jpg
|
|
||||||
📄 slide_sleneset-golfklubb_0.jpg
|
|
||||||
📄 logo_re-golfklubb.jpg
|
|
||||||
📄 slide_oustoen-country-club_0.jpg
|
|
||||||
📄 logo_hallingdal-golfklubb.png
|
|
||||||
📄 logo_fet-golfklubb.jpg
|
|
||||||
📄 slide_trondheim-golfklubb_3.jpg
|
|
||||||
📄 slide_borregaard-golfklubb_3.jpg
|
|
||||||
📄 main_hovden-golfklubb.jpg
|
|
||||||
📄 slide_ostmarka-golfklubb_2.jpg
|
|
||||||
📄 slide_sandane-golfklubb_2.jpg
|
|
||||||
📄 slide_trondheim-golfklubb_6.jpg
|
|
||||||
📄 slide_helgeland-golfklubb_9.jpg
|
|
||||||
📄 logo_borre-golfklubb.png
|
|
||||||
📄 main_karasjok-golfklubb.jpg
|
|
||||||
📄 slide_hallingdal-golfklubb_2.jpeg
|
|
||||||
📄 main_north-cape-golf-club.jpg
|
|
||||||
📄 logo_selbu-golfklubb.png
|
|
||||||
📄 logo_sola-golfklubb-forus.jpg
|
|
||||||
📄 slide_asker-golfklubb_0.jpg
|
|
||||||
📄 main_hafjell-golfklubb.jpg
|
|
||||||
📄 logo_mjosen-golfklubb.png
|
|
||||||
📄 slide_atlungstad-golfklubb_0.jpg
|
|
||||||
📄 main_moa-golfsenter.jpg
|
|
||||||
📄 slide_bleik-golfstrombane_7.jpg
|
|
||||||
📄 main_kvinnherad-golfklubb.jpg
|
|
||||||
📄 main_re-golfklubb.jpg
|
|
||||||
📄 logo_haga-golfklubb.jpg
|
|
||||||
📄 main_roros-golfklubb.jpg
|
|
||||||
📄 main_vestfold-golfklubb.jpg
|
|
||||||
📄 slide_egersund-golfklubb_2.jpg
|
|
||||||
📄 slide_volda-golfklubb_2.jpeg
|
|
||||||
📄 slide_egersund-golfklubb_5.jpg
|
|
||||||
📄 main_gjersjoen-golfklubb.jpg
|
|
||||||
📄 main_gamle-fredrikstad-golfklubb.jpg
|
|
||||||
📄 slide_naeroysund-golfklubb_3.jpg
|
|
||||||
📄 slide_oustoen-country-club_1.jpg
|
|
||||||
📄 logo_hitra-golfklubb.png
|
|
||||||
📄 logo_rygge-flystasjon-golf-klubb.jpg
|
|
||||||
📄 slide_tyrifjord-golfklubb_9.jpg
|
|
||||||
📄 slide_trondheim-golfklubb_5.jpg
|
|
||||||
📄 slide_aurskog-golfpark_1.jpg
|
|
||||||
📄 main_sola-golfklubb-solastranden.jpg
|
|
||||||
📄 logo_trysil-golfklubb.jpg
|
|
||||||
📄 logo_harstad-golfklubb.jpg
|
|
||||||
📄 slide_ogna-golfklubb_1.jpg
|
|
||||||
📄 logo_gamle-fredrikstad-golfklubb.jpg
|
|
||||||
📄 main_stranda-golfklubb.jpg
|
|
||||||
📄 logo_sauda-golfklubb.png
|
|
||||||
📄 slide_egersund-golfklubb_0.jpg
|
|
||||||
📄 logo_solum-golfklubb.jpg
|
|
||||||
📄 main_bjornefjorden-golfklubb.jpg
|
|
||||||
📄 main_vestlia-golf.jpg
|
|
||||||
📄 slide_sandefjord-golfklubb_1.jpg
|
|
||||||
📄 logo_borregaard-golfklubb.jpg
|
|
||||||
📄 main_grimstad-golfklubb.jpg
|
|
||||||
📄 logo_vesteralen-golfklubb.png
|
|
||||||
📄 main_kristiansund-og-omegn-golfklubb.jpg
|
|
||||||
📄 slide_hallingdal-golfklubb_3.jpeg
|
|
||||||
📄 logo_sandnes-golfklubb.jpg
|
|
||||||
📄 main_alesund-golfklubb.jpeg
|
|
||||||
📄 main_steinkjer-golfklubb.jpg
|
|
||||||
📄 main_skei-golfklubb.jpg
|
|
||||||
📄 slide_nesbyen-golfklubb_3.jpg
|
|
||||||
📄 slide_hardanger-golfklubb_0.jpg
|
|
||||||
📄 slide_gumoy-golf_1.jpg
|
|
||||||
📄 main_grenland-og-omegn-golfklubb.jpg
|
|
||||||
📄 main_karmoy-golfklubb.jpg
|
|
||||||
📄 logo_egersund-golfklubb.jpg
|
|
||||||
📄 logo_baerum-golfklubb.png
|
|
||||||
📄 main_kristiansand-golfklubb.jpg
|
|
||||||
📄 slide_kongsberg-golfklubb_2.jpg
|
|
||||||
📄 slide_gumoy-golf_3.jpg
|
|
||||||
📄 logo_smola-golfklubb.jpg
|
|
||||||
📄 logo_grimstad-golfklubb.png
|
|
||||||
📄 logo_stord-golfklubb.png
|
|
||||||
📄 slide_helgeland-golfklubb_6.jpg
|
|
||||||
📄 logo_rauma-golfklubb.png
|
|
||||||
📄 logo_hinnoy-golfklubb.jpg
|
|
||||||
📄 slide_holtsmark-golfklubb_0.jpg
|
|
||||||
📄 logo_varanger-golfklubb.jpg
|
|
||||||
📄 logo_mandal-golfklubb.jpg
|
|
||||||
📄 slide_trondheim-golfklubb_1.jpg
|
|
||||||
📄 logo_tjome-golfklubb.png
|
|
||||||
📄 slide_lofoten-golfklubb_2.jpg
|
|
||||||
📄 main_haga-golfklubb.jpg
|
|
||||||
📄 slide_bjaavann-golfklubb_2.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_8.jpg
|
|
||||||
📄 logo_herdla-golfklubb.jpg
|
|
||||||
📄 main_stavanger-golfklubb.jpg
|
|
||||||
📄 main_lommedalen-golfklubb.jpg
|
|
||||||
📄 main_sirdal-fjellgolf-klubb.jpg
|
|
||||||
📄 main_alsten-golfklubb.jpg
|
|
||||||
📄 logo_lillestrom-golfklubb.png
|
|
||||||
📄 main_ogna-golfklubb.jpg
|
|
||||||
📄 slide_helgeland-golfklubb_8.jpg
|
|
||||||
📄 slide_nesbyen-golfklubb_2.jpg
|
|
||||||
📄 main_tjome-golfklubb.jpg
|
|
||||||
📄 main_sandefjord-golfklubb.jpg
|
|
||||||
📄 main_namsos-golfklubb.jpg
|
|
||||||
📄 logo_notteroy-golfklubb.jpg
|
|
||||||
📄 main_trondheim-golfklubb.jpg
|
|
||||||
📄 slide_solum-golfklubb_1.jpg
|
|
||||||
📄 slide_kongsberg-golfklubb_4.jpg
|
|
||||||
📄 main_utsikten-golfklubb.jpg
|
|
||||||
📄 main_vesteralen-golfklubb.jpg
|
|
||||||
📄 slide_sandefjord-golfklubb_0.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_9.jpg
|
|
||||||
📄 logo_polarsirkelen-golfklubb.jpg
|
|
||||||
📄 logo_sandane-golfklubb.jpg
|
|
||||||
📄 slide_groruddalen-golfklubb_2.jpg
|
|
||||||
📄 main_ski-golfklubb.jpg
|
|
||||||
📄 logo_bjornefjorden-golfklubb.png
|
|
||||||
📄 main_polarsirkelen-golfklubb.jpg
|
|
||||||
📄 slide_krokhol-golfklubb_1.jpg
|
|
||||||
📄 main_groruddalen-golfklubb.jpg
|
|
||||||
📄 slide_krokhol-golfklubb_4.jpg
|
|
||||||
📄 main_vanylven-golfklubb.jpg
|
|
||||||
📄 main_borregaard-golfklubb.jpg
|
|
||||||
📄 slide_sandefjord-golfklubb_5.jpg
|
|
||||||
📄 slide_helgeland-golfklubb_2.jpg
|
|
||||||
📄 main_hinnoy-golfklubb.jpg
|
|
||||||
📄 slide_sunndal-golfklubb_0.jpg
|
|
||||||
📄 logo_groruddalen-golfklubb.png
|
|
||||||
📄 main_tingvoll-golfklubb.jpg
|
|
||||||
📄 slide_tjome-golfklubb_3.jpg
|
|
||||||
📄 main_lindesnes-golfklubb.jpg
|
|
||||||
📄 slide_kongsvingers-golfklubb_5.jpg
|
|
||||||
📄 slide_tromso-golfklubb_0.jpg
|
|
||||||
📄 main_randaberg-golfklubb.jpg
|
|
||||||
📄 logo_krokhol-golfklubb.png
|
|
||||||
📄 main_smola-golfklubb.jpg
|
|
||||||
📄 logo_sande-golfklubb.jpg
|
|
||||||
📄 logo_oslo-golfklubb.jpg
|
|
||||||
📄 logo_preikestolen.jpg
|
|
||||||
📄 logo_sunnfjord-golfklubb.jpg
|
|
||||||
📄 slide_lofoten-golfklubb_3.jpg
|
|
||||||
📄 main_bleik-golfstrombane.jpg
|
|
||||||
📄 slide_gumoy-golf_0.jpg
|
|
||||||
📄 logo_modum-golfklubb.png
|
|
||||||
📄 slide_tingvoll-golfklubb_1.jpg
|
|
||||||
📄 logo_bodo-golfklubb.jpg
|
|
||||||
📄 logo_nesbyen-golfklubb.png
|
|
||||||
📄 main_selbu-golfklubb.jpg
|
|
||||||
📄 slide_holtsmark-golfklubb_2.jpg
|
|
||||||
📄 logo_imjelt-pitch-putt.jpg
|
|
||||||
📄 logo_ullensaker-golfklubb.png
|
|
||||||
📄 slide_elverum-golfklubb_1.jpg
|
|
||||||
📄 logo_ringerike-golfklubb.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_6.jpg
|
|
||||||
📄 slide_volda-golfklubb_4.jpg
|
|
||||||
📄 slide_tyrifjord-golfklubb_6.jpg
|
|
||||||
📄 main_solum-golfklubb.jpg
|
|
||||||
📄 slide_helgeland-golfklubb_4.jpg
|
|
||||||
📄 main_stiklestad-golfklubb.jpg
|
|
||||||
📄 slide_stavanger-golfklubb_0.jpg
|
|
||||||
📄 logo_vestlia-golf.jpg
|
|
||||||
📄 slide_vestfold-golfklubb_0.jpg
|
|
||||||
📄 logo_floro-golfklubb.jpg
|
|
||||||
📄 logo_voss-golfklubb.jpg
|
|
||||||
📄 slide_tyrifjord-golfklubb_8.jpg
|
|
||||||
📄 slide_sotra-golfklubb_2.jpg
|
|
||||||
📄 logo_jaeren-golfklubb.png
|
|
||||||
📄 slide_moss-rygge-golfklubb_0.jpg
|
|
||||||
📄 logo_asker-golfklubb.png
|
|
||||||
📄 slide_bleik-golfstrombane_6.jpg
|
|
||||||
📄 slide_bjaavann-golfklubb_3.jpg
|
|
||||||
📄 main_hvam-golfklubb.jpg
|
|
||||||
📄 logo_eiker-golfklubb.jpg
|
|
||||||
📄 slide_bleik-golfstrombane_9.jpg
|
|
||||||
📄 slide_namdal-golfklubb_0.jpg
|
|
||||||
📄 logo_nordhaug-golfklubb.jpg
|
|
||||||
📄 slide_borregaard-golfklubb_0.jpg
|
|
||||||
📄 slide_kongsberg-golfklubb_5.jpg
|
|
||||||
📄 main_hardanger-golfklubb.jpg
|
|
||||||
📄 slide_ostmarka-golfklubb_5.jpg
|
|
||||||
📄 logo_oppegard-golfklubb.png
|
|
||||||
📄 slide_vradal-golfklubb_0.jpg
|
|
||||||
📄 main_narvik-golfklubb.jpg
|
|
||||||
📄 logo_midt-troms-golfklubb.jpg
|
|
||||||
📄 slide_solum-golfklubb_2.jpg
|
|
||||||
📄 slide_sunndal-golfklubb_1.jpg
|
|
||||||
📄 slide_gjersjoen-golfklubb_1.jpg
|
|
||||||
📄 slide_preikestolen_0.jpg
|
|
||||||
📄 main_huseby-hanko-golfklubb.jpg
|
|
||||||
📄 slide_holtsmark-golfklubb_4.jpg
|
|
||||||
📄 main_larvik-golfklubb.jpg
|
|
||||||
📄 slide_hasvik-golfklubb_1.jpg
|
|
||||||
📄 slide_herdla-golfklubb_1.jpg
|
|
||||||
📄 logo_kjekstad-golfklubb.jpg
|
|
||||||
📄 main_austratt-golfklubb.jpg
|
|
||||||
📄 slide_sandefjord-golfklubb_3.jpg
|
|
||||||
📄 logo_hakadal-golfklubb.png
|
|
||||||
📄 slide_bleik-golfstrombane_3.jpg
|
|
||||||
📄 logo_vanylven-golfklubb.jpg
|
|
||||||
📄 main_skjeberg-golfklubb.jpg
|
|
||||||
📄 slide_hovden-golfklubb_3.jpg
|
|
||||||
📄 slide_ostmarka-golfklubb_0.jpg
|
|
||||||
📄 logo_tromso-golfklubb.png
|
|
||||||
📄 main_stjordal-golfklubb.jpg
|
|
||||||
📄 slide_frosta-golfklubb_3.jpg
|
|
||||||
📄 main_nordvegen-golfklubb.jpg
|
|
||||||
📄 slide_norsjo-golfklubb_1.jpg
|
|
||||||
📄 logo_gumoy-golf.png
|
|
||||||
📄 slide_gronmo-golfklubb_2.jpg
|
|
||||||
📄 slide_hardanger-golfklubb_1.jpg
|
|
||||||
📄 main_oppdal-golfklubb.jpg
|
|
||||||
📄 slide_trondheim-golfklubb_0.jpg
|
|
||||||
📄 main_lillestrom-golfklubb.jpg
|
|
||||||
📄 slide_gjersjoen-golfklubb_0.jpg
|
|
||||||
📄 slide_soon-golfklubb_1.jpg
|
|
||||||
📄 logo_vestfold-golfklubb.jpg
|
|
||||||
📄 slide_stavanger-golfklubb_1.jpg
|
|
||||||
📄 main_norefjell-golfklubb.jpg
|
|
||||||
📄 main_kongsberg-golfklubb.jpg
|
|
||||||
📄 slide_gjovik-og-toten-golfklubb_2.jpg
|
|
||||||
📄 logo_veierland-golfklubb.jpg
|
|
||||||
📄 slide_soon-golfklubb_0.jpg
|
|
||||||
📄 slide_egersund-golfklubb_6.jpg
|
|
||||||
📄 slide_norsjo-golfklubb_0.jpg
|
|
||||||
📄 slide_kristiansand-golfklubb_0.jpg
|
|
||||||
📄 main_hurum-golfklubb.jpg
|
|
||||||
📄 main_sola-golfklubb-forus.jpg
|
|
||||||
📄 logo_mork-golfklubb.jpg
|
|
||||||
📄 logo_molde-golfklubb.png
|
|
||||||
📄 main_kongsvingers-golfklubb.jpg
|
|
||||||
📄 main_sunndal-golfklubb.jpg
|
|
||||||
📄 logo_utsikten-golfklubb.jpg
|
|
||||||
📄 logo_austratt-golfklubb.jpg
|
|
||||||
📄 logo_selje-golfklubb.jpg
|
|
||||||
📄 main_stord-golfklubb.jpg
|
|
||||||
📄 logo_sorknes-golfklubb.jpg
|
|
||||||
📄 slide_krokhol-golfklubb_3.jpg
|
|
||||||
📄 slide_hovden-golfklubb_4.jpg
|
|
||||||
📄 main_preikestolen.jpg
|
|
||||||
📄 logo_aurskog-golfpark.png
|
|
||||||
📄 main_aurskog-golfpark.jpg
|
|
||||||
📄 slide_mork-golfklubb_1.jpg
|
|
||||||
📄 logo_roros-golfklubb.jpg
|
|
||||||
📄 main_harstad-golfklubb.jpg
|
|
||||||
📄 main_helgeland-golfklubb.jpg
|
|
||||||
📄 slide_ogna-golfklubb_0.jpg
|
|
||||||
📄 slide_kvinnherad-golfklubb_1.jpg
|
|
||||||
📄 logo_haugesund-golfklubb.png
|
|
||||||
📄 slide_bjaavann-golfklubb_0.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_3.jpg
|
|
||||||
📄 slide_ostmarka-golfklubb_6.jpg
|
|
||||||
📄 slide_bleik-golfstrombane_11.jpg
|
|
||||||
📄 logo_moss-rygge-golfklubb.png
|
|
||||||
📄 slide_borregaard-golfklubb_2.jpg
|
|
||||||
📄 main_lofoten-golfklubb.jpg
|
|
||||||
📄 slide_bleik-golfstrombane_5.jpg
|
|
||||||
📄 logo_oustoen-country-club.jpg
|
|
||||||
📄 main_oslo-golfklubb.jpg
|
|
||||||
📄 slide_gjovik-og-toten-golfklubb_0.jpg
|
|
||||||
📄 slide_frosta-golfklubb_1.jpg
|
|
||||||
📄 slide_ostmarka-golfklubb_7.jpg
|
|
||||||
📄 slide_helgeland-golfklubb_3.jpg
|
|
||||||
📄 logo_vradal-golfklubb.png
|
|
||||||
📄 main_salten-golfklubb-bodo-golfpark.jpg
|
|
||||||
📄 main_rygge-flystasjon-golf-klubb.jpg
|
|
||||||
📄 slide_trondheim-golfklubb_4.jpg
|
|
||||||
📄 main_meland-golfklubb.jpg
|
|
||||||
📄 slide_sotra-golfklubb_1.jpg
|
|
||||||
📄 main_sandane-golfklubb.jpg
|
|
||||||
📄 slide_baerum-golfklubb_0.jpg
|
|
||||||
📄 slide_egersund-golfklubb_3.jpg
|
|
||||||
📄 slide_frosta-golfklubb_5.jpg
|
|
||||||
📄 logo_sandefjord-golfklubb.png
|
|
||||||
📄 slide_egersund-golfklubb_4.jpg
|
|
||||||
📄 slide_sandane-golfklubb_1.jpg
|
|
||||||
📄 slide_bleik-golfstrombane_2.jpg
|
|
||||||
📄 main_eiker-golfklubb.jpg
|
|
||||||
📄 main_tromso-golfklubb.jpg
|
|
||||||
📄 logo_tysnes-golfklubb.jpg
|
|
||||||
📄 slide_hallingdal-golfklubb_4.jpeg
|
|
||||||
📄 main_ringerike-golfklubb.jpg
|
|
||||||
📄 main_norsjo-golfklubb.jpg
|
|
||||||
📄 logo_byneset-golf.jpg
|
|
||||||
📄 logo_land-golfklubb.png
|
|
||||||
📄 slide_bleik-golfstrombane_8.jpg
|
|
||||||
📄 logo_losby-golfklubb.png
|
|
||||||
📄 logo_husoy-golfklubb.png
|
|
||||||
📄 main_nittedal-golfklubb.jpg
|
|
||||||
📄 logo_trondheim-par3golf-havstein.png
|
|
||||||
📄 main_mork-golfklubb.jpg
|
|
||||||
📄 slide_sleneset-golfklubb_1.jpg
|
|
||||||
📄 logo_ekholtbruket-golfklubb.jpg
|
|
||||||
📄 slide_hauger-golfklubb_1.jpg
|
|
||||||
📄 main_borre-golfklubb.jpg
|
|
||||||
📄 main_kragero-golfklubb.jpg
|
|
||||||
📄 main_lonne-golfklubb.jpg
|
|
||||||
📄 logo_namsos-golfklubb.jpg
|
|
||||||
📄 slide_hallingdal-golfklubb_0.jpeg
|
|
||||||
📄 slide_solum-golfklubb_0.jpg
|
|
||||||
📄 main_holtsmark-golfklubb.jpg
|
|
||||||
📄 main_oustoen-country-club.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_12.jpg
|
|
||||||
📄 slide_gronmo-golfklubb_1.jpg
|
|
||||||
📄 main_mjosen-golfklubb.jpg
|
|
||||||
📄 logo_sunndal-golfklubb.jpg
|
|
||||||
📄 logo_larvik-golfklubb.jpg
|
|
||||||
📄 slide_ekholtbruket-golfklubb_0.jpg
|
|
||||||
📄 main_husoy-golfklubb.jpg
|
|
||||||
📄 logo_drammen-golfklubb.png
|
|
||||||
📄 logo_helgeland-golfklubb.png
|
|
||||||
📄 main_gjerdrum-golfklubb.jpg
|
|
||||||
📄 slide_sandefjord-golfklubb_2.jpg
|
|
||||||
📄 main_hvaler-golfklubb.jpg
|
|
||||||
📄 logo_sunnmore-golfklubb.jpg
|
|
||||||
📄 slide_hauger-golfklubb_0.jpg
|
|
||||||
📄 main_naeroysund-golfklubb.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_5.jpg
|
|
||||||
📄 logo_bamble-golfklubb.png
|
|
||||||
📄 slide_sandane-golfklubb_0.jpg
|
|
||||||
📄 main_bodo-golfklubb.jpeg
|
|
||||||
📄 main_drammen-golfklubb.jpg
|
|
||||||
📄 slide_frosta-golfklubb_4.jpg
|
|
||||||
📄 slide_solum-golfklubb_3.jpg
|
|
||||||
📄 slide_bleik-golfstrombane_1.jpg
|
|
||||||
📄 main_trondheim-par3golf-havstein.jpg
|
|
||||||
📄 logo_kongsvingers-golfklubb.jpg
|
|
||||||
📄 slide_volda-golfklubb_3.jpeg
|
|
||||||
📄 slide_tyrifjord-golfklubb_3.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_2.jpg
|
|
||||||
📄 slide_hovden-golfklubb_2.jpg
|
|
||||||
📄 slide_kongsvingers-golfklubb_4.jpg
|
|
||||||
📄 main_arendal-omegn-golfklubb.jpg
|
|
||||||
📄 logo_bjaavann-golfklubb.png
|
|
||||||
📄 main_sande-golfklubb.jpg
|
|
||||||
📄 main_ballerud-golfklubb.jpg
|
|
||||||
📄 logo_norefjell-golfklubb.png
|
|
||||||
📄 slide_kristiansand-golfklubb_1.jpg
|
|
||||||
📄 main_veierland-golfklubb.jpg
|
|
||||||
📄 logo_karmoy-golfklubb.png
|
|
||||||
📄 logo_lofoten-golfklubb.png
|
|
||||||
📄 slide_lofoten-golfklubb_0.jpg
|
|
||||||
📄 logo_hurum-golfklubb.png
|
|
||||||
📄 slide_holtsmark-golfklubb_3.jpg
|
|
||||||
📄 logo_hemsedal-golfklubb.jpg
|
|
||||||
📄 slide_borregaard-golfklubb_5.jpg
|
|
||||||
📄 logo_haugaland-golfklubb.png
|
|
||||||
📄 slide_bjornefjorden-golfklubb_1.jpg
|
|
||||||
📄 main_notteroy-golfklubb.jpg
|
|
||||||
📄 slide_kongsvingers-golfklubb_3.jpg
|
|
||||||
📄 slide_kongsvingers-golfklubb_2.jpg
|
|
||||||
📄 main_fana-golfklubb.jpg
|
|
||||||
📄 logo_giske-golfklubb.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_11.jpg
|
|
||||||
📄 main_nes-golfklubb-09.jpg
|
|
||||||
📄 main_volda-golfklubb.jpeg
|
|
||||||
📄 logo_arendal-omegn-golfklubb.png
|
|
||||||
📄 slide_gjovik-og-toten-golfklubb_1.jpg
|
|
||||||
📄 main_soon-golfklubb.jpg
|
|
||||||
📄 logo_soon-golfklubb.png
|
|
||||||
📄 slide_gumoy-golf_2.jpg
|
|
||||||
📄 logo_alsten-golfklubb.png
|
|
||||||
📄 main_halden-golfklubb.jpg
|
|
||||||
📄 logo_narvik-golfklubb.jpg
|
|
||||||
📄 logo_randaberg-golfklubb.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_10.jpg
|
|
||||||
📄 main_ostmarka-golfklubb.jpg
|
|
||||||
📄 logo_bleik-golfstrombane.jpg
|
|
||||||
📄 logo_hasvik-golfklubb.png
|
|
||||||
📄 main_floro-golfklubb.jpg
|
|
||||||
📄 main_gronmo-golfklubb.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_7.jpg
|
|
||||||
📄 slide_egersund-golfklubb_8.jpg
|
|
||||||
📄 logo_atlungstad-golfklubb.png
|
|
||||||
📄 main_sunnmore-golfklubb.jpeg
|
|
||||||
📄 main_sleneset-golfklubb.jpg
|
|
||||||
📄 logo_lonne-golfklubb.png
|
|
||||||
📄 main_haugaland-golfklubb.jpg
|
|
||||||
📄 main_egersund-golfklubb.jpg
|
|
||||||
📄 slide_solum-golfklubb_5.jpg
|
|
||||||
📄 slide_gamle-fredrikstad-golfklubb_1.jpg
|
|
||||||
📄 main_sotra-golfklubb.jpg
|
|
||||||
📄 main_trysil-golfklubb.jpg
|
|
||||||
📄 logo_kongsberg-golfklubb.png
|
|
||||||
📄 slide_tromso-golfklubb_1.jpg
|
|
||||||
📄 logo_frosta-golfklubb.jpg
|
|
||||||
📄 slide_groruddalen-golfklubb_1.jpg
|
|
||||||
📄 logo_hafjell-golfklubb.png
|
|
||||||
📄 logo_sola-golfklubb-solastranden.png
|
|
||||||
📄 logo_holtsmark-golfklubb.png
|
|
||||||
📄 slide_naeroysund-golfklubb_1.jpg
|
|
||||||
📄 slide_groruddalen-golfklubb_0.jpg
|
|
||||||
📄 slide_naeroysund-golfklubb_4.jpg
|
|
||||||
📄 slide_kjekstad-golfklubb_2.jpg
|
|
||||||
📄 slide_gronmo-golfklubb_0.jpg
|
|
||||||
📄 slide_preikestolen_1.jpg
|
|
||||||
📄 logo_fana-golfklubb.jpg
|
|
||||||
📄 slide_sorknes-golfklubb_0.jpg
|
|
||||||
📄 slide_atlungstad-golfklubb_3.jpg
|
|
||||||
📄 main_losby-golfklubb.jpg
|
|
||||||
📄 slide_borregaard-golfklubb_4.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_1.jpg
|
|
||||||
📄 main_sandnes-golfklubb.jpg
|
|
||||||
📄 slide_norsjo-golfklubb_2.jpg
|
|
||||||
📄 main_frosta-golfklubb.jpg
|
|
||||||
📄 slide_krokhol-golfklubb_0.jpg
|
|
||||||
📄 logo_askim-golfklubb.png
|
|
||||||
📄 main_stryn-golfklubb.jpg
|
|
||||||
📄 slide_lofoten-golfklubb_5.jpg
|
|
||||||
📄 logo_karasjok-golfklubb.jpg
|
|
||||||
📄 slide_krokhol-golfklubb_2.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_14.jpg
|
|
||||||
📄 logo_gjovik-og-toten-golfklubb.png
|
|
||||||
📄 main_haugesund-golfklubb.jpg
|
|
||||||
📄 logo_stjordal-golfklubb.jpg
|
|
||||||
📄 logo_drobak-golfklubb.png
|
|
||||||
📄 main_atlungstad-golfklubb.jpg
|
|
||||||
📄 logo_trondheim-golfklubb.jpg
|
|
||||||
📄 logo_gjersjoen-golfklubb.png
|
|
||||||
📄 slide_lofoten-golfklubb_4.jpg
|
|
||||||
📄 slide_helgeland-golfklubb_7.jpg
|
|
||||||
📄 main_molde-golfklubb.jpg
|
|
||||||
📄 slide_tyrifjord-golfklubb_7.jpg
|
|
||||||
📄 logo_gjerdrum-golfklubb.png
|
|
||||||
📄 main_onsoy-golfklubb.jpg
|
|
||||||
📄 slide_trondheim-golfklubb_2.jpg
|
|
||||||
📄 slide_mork-golfklubb_0.jpg
|
|
||||||
📄 slide_frosta-golfklubb_2.jpg
|
|
||||||
📄 slide_giske-golfklubb_1.jpeg
|
|
||||||
📄 slide_moss-rygge-golfklubb_1.jpg
|
|
||||||
📄 slide_ostmarka-golfklubb_1.jpg
|
|
||||||
📄 slide_volda-golfklubb_0.jpeg
|
|
||||||
📄 logo_sotra-golfklubb.jpg
|
|
||||||
📄 main_mandal-golfklubb.jpg
|
|
||||||
📄 logo_grenland-og-omegn-golfklubb.jpg
|
|
||||||
📄 slide_volda-golfklubb_1.jpeg
|
|
||||||
📄 main_imjelt-pitch-putt.jpg
|
|
||||||
📄 logo_nittedal-golfklubb.png
|
|
||||||
📄 main_gumoy-golf.jpg
|
|
||||||
📄 logo_hauger-golfklubb.png
|
|
||||||
📄 slide_hakadal-golfklubb_4.jpg
|
|
||||||
📄 slide_ostmarka-golfklubb_4.jpg
|
|
||||||
📄 slide_hakadal-golfklubb_0.jpg
|
|
||||||
📄 main_surnadal-golfklubb.jpg
|
|
||||||
📄 logo_hof-golfklubb.png
|
|
||||||
📄 slide_sleneset-golfklubb_2.jpg
|
|
||||||
📄 logo_halden-golfklubb.png
|
|
||||||
📄 logo_naeroysund-golfklubb.png
|
|
||||||
📄 logo_elverum-golfklubb.png
|
|
||||||
📄 main_bamble-golfklubb.jpg
|
|
||||||
📄 logo_kragero-golfklubb.png
|
|
||||||
📄 logo_valdres-golfklubb.png
|
|
||||||
📄 slide_tyrifjord-golfklubb_1.jpg
|
|
||||||
📄 logo_grini-golfklubb.png
|
|
||||||
📄 logo_lindesnes-golfklubb.jpg
|
|
||||||
📄 slide_lofoten-golfklubb_6.jpg
|
|
||||||
📄 main_sauda-golfklubb.jpg
|
|
||||||
📄 main_moss-rygge-golfklubb.jpg
|
|
||||||
📄 main_dalane-golfklubb.jpg
|
|
||||||
📄 logo_skei-golfklubb.jpg
|
|
||||||
📄 logo_ostmarka-golfklubb.png
|
|
||||||
📄 slide_nesbyen-golfklubb_1.jpg
|
|
||||||
📄 logo_stryn-golfklubb.png
|
|
||||||
📄 main_vradal-golfklubb.jpg
|
|
||||||
📄 slide_hovden-golfklubb_0.jpg
|
|
||||||
📄 main_gjovik-og-toten-golfklubb.jpg
|
|
||||||
📄 logo_hvaler-golfklubb.png
|
|
||||||
📄 main_giske-golfklubb.jpeg
|
|
||||||
📄 slide_herdla-golfklubb_0.jpg
|
|
||||||
📄 main_ibestad-golfklubb.jpg
|
|
||||||
📄 slide_kongsvingers-golfklubb_0.jpg
|
|
||||||
📄 slide_atlungstad-golfklubb_1.jpg
|
|
||||||
📄 logo_nordfjord-golfklubb.png
|
|
||||||
📄 logo_eidskog-golfklubb.png
|
|
||||||
📄 main_hitra-golfklubb.jpg
|
|
||||||
📄 logo_dalane-golfklubb.jpg
|
|
||||||
📄 main_kjekstad-golfklubb.jpg
|
|
||||||
📄 logo_odda-golfklubb.jpg
|
|
||||||
📄 logo_ballerud-golfklubb.jpg
|
|
||||||
📄 main_klaebu-golfklubb.jpg
|
|
||||||
📄 main_hasvik-golfklubb.jpg
|
|
||||||
📄 logo_klaebu-golfklubb.png
|
|
||||||
📄 slide_bleik-golfstrombane_4.jpg
|
|
||||||
📄 logo_north-cape-golf-club.jpg
|
|
||||||
📄 slide_tyrifjord-golfklubb_0.jpg
|
|
||||||
📄 logo_moa-golfsenter.png
|
|
||||||
📄 main_herdla-golfklubb.jpg
|
|
||||||
📄 slide_gjersjoen-golfklubb_2.jpg
|
|
||||||
📄 logo_stiklestad-golfklubb.png
|
|
||||||
📄 slide_bleik-golfstrombane_10.jpg
|
|
||||||
📄 logo_hovden-golfklubb.jpg
|
|
||||||
📄 slide_frosta-golfklubb_0.jpg
|
|
||||||
📄 logo_sirdal-fjellgolf-klubb.png
|
|
||||||
📄 main_ekholtbruket-golfklubb.jpg
|
|
||||||
📄 slide_salten-golfklubb-bodo-golfpark_0.jpg
|
|
||||||
📄 main_askim-golfklubb.jpg
|
|
||||||
📄 slide_elverum-golfklubb_0.jpg
|
|
||||||
📄 slide_gamle-fredrikstad-golfklubb_0.jpg
|
|
||||||
📄 main_oppegard-golfklubb.jpg
|
|
||||||
📄 slide_sorknes-golfklubb_1.jpg
|
|
||||||
📄 main_drobak-golfklubb.jpg
|
|
||||||
📄 slide_kongsvingers-golfklubb_1.jpg
|
|
||||||
📄 slide_helgeland-golfklubb_0.jpg
|
|
||||||
📄 slide_vestfold-golfklubb_1.jpg
|
|
||||||
📄 slide_kongsberg-golfklubb_1.jpg
|
|
||||||
📄 slide_tjome-golfklubb_1.jpg
|
|
||||||
📄 slide_ekholtbruket-golfklubb_1.jpg
|
|
||||||
📄 main_bergen-golfklubb.jpg
|
|
||||||
📄 logo_salten-golfklubb-bodo-golfpark.jpg
|
|
||||||
📄 slide_egersund-golfklubb_1.jpg
|
|
||||||
📄 slide_trondheim-golfklubb_7.jpg
|
|
||||||
📄 logo_stranda-golfklubb.jpg
|
|
||||||
📄 main_eidskog-golfklubb.jpg
|
|
||||||
📄 logo_nes-golfklubb-09.jpg
|
|
||||||
📄 logo_ski-golfklubb.png
|
|
||||||
📄 logo_norsjo-golfklubb.png
|
|
||||||
📄 slide_kongsberg-golfklubb_3.jpg
|
|
||||||
📄 slide_ski-golfklubb_0.jpg
|
|
||||||
📄 main_sorknes-golfklubb.jpg
|
|
||||||
📄 main_laerdal-golfklubb.jpg
|
|
||||||
📄 slide_giske-golfklubb_0.jpeg
|
|
||||||
📄 main_ullensaker-golfklubb.jpg
|
|
||||||
📄 main_midt-troms-golfklubb.jpg
|
|
||||||
📄 main_tysnes-golfklubb.jpg
|
|
||||||
📄 logo_namdal-golfklubb.jpg
|
|
||||||
📄 logo_tyrifjord-golfklubb.png
|
|
||||||
📄 slide_bjornefjorden-golfklubb_0.jpg
|
|
||||||
📄 slide_hovden-golfklubb_5.jpg
|
|
||||||
📄 main_tyrifjord-golfklubb.jpg
|
|
||||||
📄 slide_sotra-golfklubb_0.jpg
|
|
||||||
📄 main_valdres-golfklubb.jpg
|
|
||||||
📄 logo_steinkjer-golfklubb.jpg
|
|
||||||
📄 main_krokhol-golfklubb.jpg
|
|
||||||
📄 main_varanger-golfklubb.jpg
|
|
||||||
📄 logo_meland-golfklubb.png
|
|
||||||
📄 slide_namdal-golfklubb_1.jpg
|
|
||||||
📄 main_selje-golfklubb.jpg
|
|
||||||
📁 src/
|
|
||||||
📄 struktur_dump.txt
|
|
||||||
📄 middleware.ts
|
|
||||||
📁 components/
|
|
||||||
📄 ScrapeMethodSelect.tsx
|
|
||||||
📄 Header.tsx
|
|
||||||
📁 config/
|
|
||||||
📄 constants.ts
|
|
||||||
📁 app/
|
|
||||||
📄 FacilitySearch.tsx
|
|
||||||
📄 HeroSlider.tsx
|
|
||||||
📄 favicon.ico
|
|
||||||
📄 globals.css
|
|
||||||
📄 page.tsx
|
|
||||||
📄 layout.tsx
|
|
||||||
📁 golfbaner/
|
|
||||||
📁 [slug]/
|
|
||||||
📄 CourseDisplay.tsx
|
|
||||||
📄 page.tsx
|
|
||||||
📄 FacilityDetailView.tsx
|
|
||||||
📁 admin/
|
|
||||||
📄 page.tsx
|
|
||||||
📁 rediger/
|
|
||||||
📁 [slug]/
|
|
||||||
📄 EditFacilityClient.tsx
|
|
||||||
📄 page.tsx
|
|
||||||
📁 login/
|
|
||||||
📄 page.tsx
|
|
||||||
📁 vtg/
|
|
||||||
📄 page.tsx
|
|
||||||
📁 medlemskap/
|
|
||||||
📄 page.tsx
|
|
||||||
📁 greenfee/
|
|
||||||
📄 page.tsx
|
|
||||||
📁 kode_eksport_1/
|
|
||||||
📄 frontend_src_components_Header_tsx.txt
|
|
||||||
📄 frontend_src_app_admin_medlemskap_page_tsx.txt
|
|
||||||
📄 frontend_next-env_d_ts.txt
|
|
||||||
📄 frontend_src_app_layout_tsx.txt
|
|
||||||
📄 frontend_src_app_admin_vtg_page_tsx.txt
|
|
||||||
📄 frontend_src_app_admin_greenfee_page_tsx.txt
|
|
||||||
📄 frontend_src_app_page_tsx.txt
|
|
||||||
📄 eksport_script_py.txt
|
|
||||||
📄 frontend_src_components_ScrapeMethodSelect_tsx.txt
|
|
||||||
📄 frontend_src_app_golfbaner_[slug]_page_tsx.txt
|
|
||||||
📄 frontend_src_middleware_ts.txt
|
|
||||||
📄 frontend_src_app_golfbaner_[slug]_CourseDisplay_tsx.txt
|
|
||||||
📄 frontend_next_config_ts.txt
|
|
||||||
📄 frontend_src_app_admin_login_page_tsx.txt
|
|
||||||
📄 frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt
|
|
||||||
📄 frontend_src_app_admin_rediger_[slug]_page_tsx.txt
|
|
||||||
📄 frontend_src_app_admin_page_tsx.txt
|
|
||||||
📄 frontend_src_app_admin_rediger_[slug]_EditFacilityClient_tsx.txt
|
|
||||||
📄 frontend_src_app_HeroSlider_tsx.txt
|
|
||||||
📄 frontend_src_app_FacilitySearch_tsx.txt
|
|
||||||
📄 frontend_src_config_constants_ts.txt
|
|
||||||
📁 backend/
|
|
||||||
📄 scrape_nsg_3.py
|
|
||||||
📄 update_admin.py
|
|
||||||
📄 test_gemini.py
|
|
||||||
📄 import_gallery.py
|
|
||||||
📄 import_nye_felter.py
|
|
||||||
📄 .env
|
|
||||||
📄 scrape_membership.py
|
|
||||||
📄 test_login.py
|
|
||||||
📄 sync_greenfee.py
|
|
||||||
📄 scrape_greenfee.py
|
|
||||||
📄 scrape_status.py
|
|
||||||
📄 scrape_golfamore1.3.py
|
|
||||||
📄 requirements.txt
|
|
||||||
📄 import_wp.py
|
|
||||||
📄 create_admin.py
|
|
||||||
📄 scrape_vtg.py
|
|
||||||
📄 import_urls.py
|
|
||||||
📄 main.py
|
|
||||||
📄 Dockerfile
|
|
||||||
📁 public/
|
|
||||||
📁 media/
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
"""
|
|
||||||
TEE OFF ADMIN GENERATOR v1.9 (DEBUG & BULLETPROOF)
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
FUNKSJON: Genererer SQL-kommando for administrator.
|
|
||||||
STATUS: Beholder TRUNCATE for feilsøking, men sikrer SQL-innsendingen.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
"""
|
|
||||||
import pyotp
|
|
||||||
from passlib.hash import pbkdf2_sha256
|
|
||||||
import getpass
|
|
||||||
import sys
|
|
||||||
|
|
||||||
def generate_admin():
|
|
||||||
print("\n" + "="*50)
|
|
||||||
print(" TEE OFF ADMIN GENERATOR v1.9 (DEBUG MODE)")
|
|
||||||
print("="*50)
|
|
||||||
|
|
||||||
username = input("Brukernavn (f.eks Envide Webutvikling): ").strip()
|
|
||||||
email = input("E-post: ").strip()
|
|
||||||
|
|
||||||
# Sikre mot SQL-feil hvis navnet/eposten inneholder apostrof
|
|
||||||
safe_username = username.replace("'", "''")
|
|
||||||
safe_email = email.replace("'", "''")
|
|
||||||
|
|
||||||
# Passord-verifisering
|
|
||||||
while True:
|
|
||||||
password = getpass.getpass("Skriv inn passord: ")
|
|
||||||
password_confirm = getpass.getpass("Gjenta passord: ")
|
|
||||||
|
|
||||||
if password == password_confirm:
|
|
||||||
if len(password) < 8:
|
|
||||||
print("⚠️ Advarsel: Passordet bør være minst 8 tegn.")
|
|
||||||
print(f"\n[DEBUG] Passord akseptert. Lengde: {len(password)} tegn.")
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
print("❌ Passordene er ikke like. Prøv igjen.\n")
|
|
||||||
|
|
||||||
otp_secret = pyotp.random_base32()
|
|
||||||
|
|
||||||
print("⏳ Genererer PBKDF2-hash...")
|
|
||||||
password_hash = pbkdf2_sha256.hash(password)
|
|
||||||
print(f"[DEBUG] Hash generert. Lengde: {len(password_hash)} tegn.")
|
|
||||||
|
|
||||||
print("\n✅ GENERERING VELLYKKET!")
|
|
||||||
print("-" * 50)
|
|
||||||
print("SLIK LEGGER DU INN BRUKEREN TRYGT:")
|
|
||||||
print("-" * 50)
|
|
||||||
print("1. Gå inn i databasen:")
|
|
||||||
print(" docker exec -it teeoff_db psql -U teeoff_admin -d teeoff")
|
|
||||||
print("\n2. Lim inn disse to linjene nøyaktig slik de står:")
|
|
||||||
print("TRUNCATE admins;")
|
|
||||||
print(f"INSERT INTO admins (username, email, password_hash, otp_secret) VALUES ('{safe_username}', '{safe_email}', '{password_hash}', '{otp_secret}');")
|
|
||||||
print("\n3. Skriv 'exit' for å gå ut.")
|
|
||||||
print("-" * 50)
|
|
||||||
print("4. KONFIGURER 2FA I GOOGLE AUTHENTICATOR:")
|
|
||||||
print(f"Bruk denne nøkkelen: {otp_secret}")
|
|
||||||
print("-" * 50 + "\n")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
generate_admin()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\nAvbrutt.")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
import urllib.request
|
|
||||||
import json
|
|
||||||
|
|
||||||
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
|
||||||
|
|
||||||
async def fetch_json(url):
|
|
||||||
"""Hjelpefunksjon for å hente JSON fra en URL"""
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(url, headers={'User-Agent': 'TeeOff-Migrator/2.0'})
|
|
||||||
with urllib.request.urlopen(req) as response:
|
|
||||||
return json.loads(response.read().decode())
|
|
||||||
except Exception as e:
|
|
||||||
# print(f"⚠️ Kunne ikke hente {url}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def fetch_media_urls_by_ids(media_ids):
|
|
||||||
"""Henter URLer for en liste med media-IDer (ACF Slides)"""
|
|
||||||
if not media_ids or not isinstance(media_ids, list) or len(media_ids) == 0:
|
|
||||||
return []
|
|
||||||
|
|
||||||
valid_ids = [str(mid) for mid in media_ids if isinstance(mid, (int, str)) and str(mid).isdigit()]
|
|
||||||
if not valid_ids: return []
|
|
||||||
|
|
||||||
ids_str = ",".join(valid_ids)
|
|
||||||
url = f"https://teeoff.no/wp-json/wp/v2/media?include={ids_str}"
|
|
||||||
data = await fetch_json(url)
|
|
||||||
|
|
||||||
urls = []
|
|
||||||
if data:
|
|
||||||
for m in data:
|
|
||||||
if 'source_url' in m:
|
|
||||||
urls.append(m['source_url'])
|
|
||||||
return urls
|
|
||||||
|
|
||||||
async def run_robust_import():
|
|
||||||
print("🕵️♂️ Starter den store bildejakten (sjekker både Utvalgt bilde og Slides)...")
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
|
|
||||||
# VIKTIG: Vi tømmer tabellen for å starte med blanke ark og unngå duplikater
|
|
||||||
await conn.execute("TRUNCATE facility_images CASCADE;")
|
|
||||||
print("🗑️ Tømte gammel bilde-tabell. Starter import...")
|
|
||||||
|
|
||||||
# Hent alle anleggene fra vår egen database
|
|
||||||
facilities = await conn.fetch("SELECT id, slug, name FROM facilities ORDER BY name")
|
|
||||||
|
|
||||||
total_images_saved = 0
|
|
||||||
|
|
||||||
for i, fac in enumerate(facilities):
|
|
||||||
fac_id = fac['id']
|
|
||||||
slug = fac['slug']
|
|
||||||
name = fac['name']
|
|
||||||
print(f"[{i+1}/{len(facilities)}] Sjekker: {name} ({slug})...")
|
|
||||||
|
|
||||||
# Hent data fra WP med ?_embed for å få tak i Utvalgt bilde lett
|
|
||||||
wp_url = f"https://teeoff.no/wp-json/wp/v2/golfbaner?slug={slug}&_embed"
|
|
||||||
wp_data_list = await fetch_json(wp_url)
|
|
||||||
|
|
||||||
if not wp_data_list:
|
|
||||||
print(" ❌ Fant ikke anlegget i WordPress API.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
post = wp_data_list[0]
|
|
||||||
final_image_urls = []
|
|
||||||
|
|
||||||
# 1. SJEKK: "Utvalgt bilde" (Standard WordPress)
|
|
||||||
try:
|
|
||||||
embedded = post.get('_embedded', {})
|
|
||||||
if 'wp:featuredmedia' in embedded and len(embedded['wp:featuredmedia']) > 0:
|
|
||||||
feat_media = embedded['wp:featuredmedia'][0]
|
|
||||||
feat_url = feat_media.get('source_url')
|
|
||||||
if feat_url:
|
|
||||||
final_image_urls.append(feat_url)
|
|
||||||
# print(f" -> Fant utvalgt bilde.")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ⚠️ Feil ved sjekk av utvalgt bilde: {e}")
|
|
||||||
|
|
||||||
# 2. SJEKK: ACF Slides (Bildekarusell)
|
|
||||||
try:
|
|
||||||
acf = post.get('acf') or {}
|
|
||||||
slides_ids = acf.get('slides')
|
|
||||||
slide_urls = await fetch_media_urls_by_ids(slides_ids)
|
|
||||||
if slide_urls:
|
|
||||||
final_image_urls.extend(slide_urls)
|
|
||||||
# print(f" -> Fant {len(slide_urls)} bilder i slider.")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ⚠️ Feil ved sjekk av slides: {e}")
|
|
||||||
|
|
||||||
# Fjern duplikater (hvis samme bilde er brukt begge steder) og bevar rekkefølgen
|
|
||||||
unique_urls = list(dict.fromkeys(final_image_urls))
|
|
||||||
|
|
||||||
# LAGRE I DATABASEN
|
|
||||||
if unique_urls:
|
|
||||||
sort_order = 0
|
|
||||||
for url in unique_urls:
|
|
||||||
await conn.execute(
|
|
||||||
"INSERT INTO facility_images (facility_id, image_url, sort_order) VALUES ($1, $2, $3)",
|
|
||||||
fac_id, url, sort_order
|
|
||||||
)
|
|
||||||
sort_order += 1
|
|
||||||
print(f" ✅ Lagret {len(unique_urls)} unike bilder.")
|
|
||||||
total_images_saved += len(unique_urls)
|
|
||||||
else:
|
|
||||||
print(" ⚠️ Fant INGEN bilder for dette anlegget.")
|
|
||||||
|
|
||||||
print(f"\n🎉 FERDIG! Totalt {total_images_saved} bilder er nå trygt lagret i galleriet.")
|
|
||||||
await conn.close()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(run_robust_import())
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
import requests
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from datetime import datetime
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
# Laster miljøvariabler
|
|
||||||
load_dotenv()
|
|
||||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
|
||||||
|
|
||||||
# Grunn-URL uten page-parameter
|
|
||||||
WP_API_BASE_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100"
|
|
||||||
|
|
||||||
def extract_price(text):
|
|
||||||
"""Finner første hele tall i en tekst og returnerer det som integer."""
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
clean_text = str(text).replace(" ", "").replace(".", "")
|
|
||||||
match = re.search(r'\d+', clean_text)
|
|
||||||
if match:
|
|
||||||
return int(match.group())
|
|
||||||
return None
|
|
||||||
|
|
||||||
def parse_date(date_string):
|
|
||||||
"""Forsøker å konvertere ulike tekstformater for dato til et ekte Date-objekt."""
|
|
||||||
if not date_string:
|
|
||||||
return None
|
|
||||||
ds = str(date_string).strip().lower()
|
|
||||||
|
|
||||||
if ds in ["ukjent", "ikke oppgitt", "har ikke", ""]:
|
|
||||||
return None
|
|
||||||
|
|
||||||
formats = ['%Y-%m-%d', '%d.%m.%Y', '%d/%m/%Y', '%Y%m%d', '%d.%m.%y']
|
|
||||||
for fmt in formats:
|
|
||||||
try:
|
|
||||||
return datetime.strptime(ds, fmt).date()
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
|
|
||||||
def clean_jsonb(value):
|
|
||||||
"""Sørger for at vi ikke fyller databasen med 'Ikke oppgitt', men bruker tomme lister."""
|
|
||||||
if not value or str(value).lower() in ["ikke oppgitt", "har ikke / ikke oppgitt"]:
|
|
||||||
return []
|
|
||||||
|
|
||||||
if isinstance(value, str):
|
|
||||||
return [{"beskrivelse": value}]
|
|
||||||
|
|
||||||
if isinstance(value, list):
|
|
||||||
cleaned = [v for v in value if v and "ikke oppgitt" not in str(v).lower()]
|
|
||||||
return cleaned
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
async def run_import():
|
|
||||||
print("📡 Henter anleggsdata fra WordPress (inkluderer paginering)...")
|
|
||||||
|
|
||||||
all_data = []
|
|
||||||
page = 1
|
|
||||||
|
|
||||||
# --- LØKKE SOM HENTER ALLE SIDER FRA WORDPRESS ---
|
|
||||||
while True:
|
|
||||||
url = f"{WP_API_BASE_URL}&page={page}"
|
|
||||||
print(f" -> Henter side {page}...")
|
|
||||||
response = requests.get(url)
|
|
||||||
|
|
||||||
# Hvis vi får 400 Bad Request, betyr det at vi har nådd forbi siste side
|
|
||||||
if response.status_code != 200:
|
|
||||||
break
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
|
|
||||||
all_data.extend(data)
|
|
||||||
page += 1
|
|
||||||
|
|
||||||
print(f"✅ Fant totalt {len(all_data)} anlegg. Starter oppdatering av database...")
|
|
||||||
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
success_count = 0
|
|
||||||
|
|
||||||
for item in all_data:
|
|
||||||
slug = item.get('slug')
|
|
||||||
acf = item.get('acf', {})
|
|
||||||
|
|
||||||
# Ekstraher og vask verdiene
|
|
||||||
golfpakker = clean_jsonb(acf.get('golfpakke'))
|
|
||||||
rabattert_greenfee = clean_jsonb(acf.get('rabattert_greenfee'))
|
|
||||||
|
|
||||||
vtg_presentasjon = acf.get('vtg_presentasjon') or None
|
|
||||||
vtg_lenke = acf.get('lenke_til_kurssider') or None
|
|
||||||
vtg_pris = extract_price(acf.get('vtg_pris'))
|
|
||||||
vtg_kursdatoer = clean_jsonb(acf.get('kursdatoer'))
|
|
||||||
|
|
||||||
slope_hovedbane = parse_date(acf.get('gyldig_til_og_med'))
|
|
||||||
slope_bane_to = parse_date(acf.get('gyldig_til_og_med_bane_to'))
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 1. Oppdater fasilitets-tabellen
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE facilities
|
|
||||||
SET
|
|
||||||
golfpakker = $1::jsonb,
|
|
||||||
rabattert_greenfee = $2::jsonb,
|
|
||||||
vtg_presentasjon = $3,
|
|
||||||
vtg_lenke = $4,
|
|
||||||
vtg_pris = $5,
|
|
||||||
vtg_kursdatoer = $6::jsonb
|
|
||||||
WHERE slug = $7
|
|
||||||
""",
|
|
||||||
json.dumps(golfpakker),
|
|
||||||
json.dumps(rabattert_greenfee),
|
|
||||||
vtg_presentasjon,
|
|
||||||
vtg_lenke,
|
|
||||||
vtg_pris,
|
|
||||||
json.dumps(vtg_kursdatoer),
|
|
||||||
slug)
|
|
||||||
|
|
||||||
# 2. Oppdater utløpsdato på hovedbanen
|
|
||||||
if slope_hovedbane:
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE courses
|
|
||||||
SET slope_valid_until = $1
|
|
||||||
WHERE facility_id = (SELECT id FROM facilities WHERE slug = $2)
|
|
||||||
AND is_main_course = true
|
|
||||||
""", slope_hovedbane, slug)
|
|
||||||
|
|
||||||
# 3. Oppdater utløpsdato på bane 2
|
|
||||||
if slope_bane_to:
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE courses
|
|
||||||
SET slope_valid_until = $1
|
|
||||||
WHERE facility_id = (SELECT id FROM facilities WHERE slug = $2)
|
|
||||||
AND is_main_course = false
|
|
||||||
""", slope_bane_to, slug)
|
|
||||||
|
|
||||||
success_count += 1
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ Feil ved oppdatering av {slug}: {e}")
|
|
||||||
|
|
||||||
await conn.close()
|
|
||||||
print(f"\n🎉 Kjøring fullført! Målrettet import for {success_count} anlegg er lagret.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(run_import())
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
"""
|
|
||||||
TEE OFF - AUTOMATISK URL-IMPORTØR
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
Leser tekstfiler med lenker og forsøker å matche dem mot eksisterende
|
|
||||||
golfanlegg i databasen basert på domenenavn.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
import os
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
|
||||||
|
|
||||||
# Hvilke filer vi skal lese, og hvilke databasefelt de tilhører
|
|
||||||
FILES_TO_IMPORT = {
|
|
||||||
"Medlemsskap.txt": "medlemskap_url",
|
|
||||||
"GreenFee.txt": "greenfee_url",
|
|
||||||
"VtG.txt": "vtg_lenke"
|
|
||||||
}
|
|
||||||
|
|
||||||
def extract_domain(url: str) -> str:
|
|
||||||
"""Henter ut hoveddomenet (f.eks. 'tyrifjord-golfklubb.no') fra en URL."""
|
|
||||||
try:
|
|
||||||
domain = urlparse(url.strip()).netloc.lower()
|
|
||||||
if domain.startswith("www."):
|
|
||||||
domain = domain[4:]
|
|
||||||
return domain
|
|
||||||
except:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
async def run_import():
|
|
||||||
print("🚀 Starter URL-importør...")
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Hent alle eksisterende anlegg og deres domener
|
|
||||||
facilities = await conn.fetch("SELECT id, name, website_url, scrape_status_url FROM facilities")
|
|
||||||
|
|
||||||
# Bygg en ordbok: { 'domenenavn.no': facility_id } for superraskt oppslag
|
|
||||||
domain_map = {}
|
|
||||||
for f in facilities:
|
|
||||||
# Prøv å hente domene fra website_url
|
|
||||||
if f['website_url']:
|
|
||||||
domain = extract_domain(f['website_url'])
|
|
||||||
if domain: domain_map[domain] = f['id']
|
|
||||||
# Prøv også scrape_status_url for sikkerhets skyld
|
|
||||||
if f['scrape_status_url']:
|
|
||||||
domain = extract_domain(f['scrape_status_url'])
|
|
||||||
if domain: domain_map[domain] = f['id']
|
|
||||||
|
|
||||||
print(f"📋 Fant {len(domain_map)} unike domener i databasen.")
|
|
||||||
|
|
||||||
# Gå gjennom fil for fil
|
|
||||||
for filename, db_field in FILES_TO_IMPORT.items():
|
|
||||||
print(f"\n▶️ BEHANDLER: {filename} -> Setter felt: {db_field}")
|
|
||||||
|
|
||||||
if not os.path.exists(filename):
|
|
||||||
print(f" ⚠️ Filen '{filename}' ble ikke funnet. Hopper over.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
with open(filename, 'r', encoding='utf-8') as file:
|
|
||||||
lines = [line.strip() for line in file.readlines() if line.strip()]
|
|
||||||
|
|
||||||
matched_count = 0
|
|
||||||
unmatched = []
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
# Hvis det er flere URL-er på samme linje separert med komma,
|
|
||||||
# matcher vi basert på den FØRSTE URL-en.
|
|
||||||
first_url = line.split(',')[0].strip()
|
|
||||||
domain = extract_domain(first_url)
|
|
||||||
|
|
||||||
# Hvis vi fant en match i databasen!
|
|
||||||
if domain in domain_map:
|
|
||||||
fac_id = domain_map[domain]
|
|
||||||
|
|
||||||
# Oppdater databasen med HELE linjen (for å bevare ev. komma-lenker)
|
|
||||||
await conn.execute(f"""
|
|
||||||
UPDATE facilities
|
|
||||||
SET {db_field} = $1
|
|
||||||
WHERE id = $2
|
|
||||||
""", line, fac_id)
|
|
||||||
matched_count += 1
|
|
||||||
else:
|
|
||||||
unmatched.append(line)
|
|
||||||
|
|
||||||
print(f" ✅ Matchet og oppdatert {matched_count} anlegg.")
|
|
||||||
|
|
||||||
if unmatched:
|
|
||||||
print(f" ❌ Følgende {len(unmatched)} URL-er fant ingen match i databasen og må legges inn manuelt:")
|
|
||||||
for url in unmatched:
|
|
||||||
print(f" - {url}")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
await conn.close()
|
|
||||||
print("\n🏁 Import fullført!")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(run_import())
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
import asyncio, asyncpg, urllib.request, json, re, os, requests
|
|
||||||
|
|
||||||
# --- KONFIGURASJON ---
|
|
||||||
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
|
||||||
WP_API_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100&_embed"
|
|
||||||
MEDIA_ENDPOINT = "https://teeoff.no/wp-json/wp/v2/media"
|
|
||||||
MEDIA_DIR = "./public/media"
|
|
||||||
|
|
||||||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
|
||||||
media_cache = {}
|
|
||||||
|
|
||||||
def get_url_from_id(media_id):
|
|
||||||
if not media_id or not isinstance(media_id, int): return None
|
|
||||||
if media_id in media_cache: return media_cache[media_id]
|
|
||||||
try:
|
|
||||||
resp = requests.get(f"{MEDIA_ENDPOINT}/{media_id}", timeout=10)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
url = resp.json().get('source_url')
|
|
||||||
media_cache[media_id] = url
|
|
||||||
return url
|
|
||||||
except: return None
|
|
||||||
|
|
||||||
def download_media(url, slug, prefix):
|
|
||||||
if not isinstance(url, str) or not url: return None
|
|
||||||
clean_url = url.replace("https:///", "https://").replace("http:///", "http://")
|
|
||||||
if "teeoff.no" not in clean_url: return clean_url
|
|
||||||
try:
|
|
||||||
ext = clean_url.split('.')[-1].split('?')[0].lower()
|
|
||||||
if len(ext) > 4 or len(ext) < 3: ext = "jpg"
|
|
||||||
filename = f"{prefix}_{slug}.{ext}"
|
|
||||||
filepath = os.path.join(MEDIA_DIR, filename)
|
|
||||||
if os.path.exists(filepath): return f"/media/{filename}"
|
|
||||||
response = requests.get(clean_url, timeout=15)
|
|
||||||
if response.status_code == 200:
|
|
||||||
with open(filepath, 'wb') as f: f.write(response.content)
|
|
||||||
return f"/media/{filename}"
|
|
||||||
except: pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
def decode_html(text):
|
|
||||||
if not text: return ""
|
|
||||||
return str(text).replace('&', '&').replace('&', '&').replace(' ', ' ').strip()
|
|
||||||
|
|
||||||
def parse_int(val):
|
|
||||||
if val is None or val == '': return None
|
|
||||||
try:
|
|
||||||
nums = re.findall(r'\d+', str(val))
|
|
||||||
return int(nums[0]) if nums else None
|
|
||||||
except: return None
|
|
||||||
|
|
||||||
def extract_url(val):
|
|
||||||
if isinstance(val, dict): return val.get('url')
|
|
||||||
if isinstance(val, str): return val
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def run_master_import():
|
|
||||||
print("🚀 Starter MASTER IMPORT v9.2 (Robust datakonvertering & Banetype)...")
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
|
|
||||||
# Tømmer kun courses og holes (hjelpetabeller)
|
|
||||||
await conn.execute("TRUNCATE courses, holes RESTART IDENTITY CASCADE;")
|
|
||||||
|
|
||||||
page = 1
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(f"{WP_API_URL}&page={page}", headers={'User-Agent': 'TeeOff-V9.2'})
|
|
||||||
with urllib.request.urlopen(req) as response:
|
|
||||||
data = json.loads(response.read().decode())
|
|
||||||
except: break
|
|
||||||
if not data: break
|
|
||||||
|
|
||||||
for post in data:
|
|
||||||
acf = post.get('acf', {})
|
|
||||||
slug = post['slug']
|
|
||||||
name = decode_html(post.get('title', {}).get('rendered', ''))
|
|
||||||
print(f"📦 Mapper {name}...")
|
|
||||||
|
|
||||||
# Media & Identifiers
|
|
||||||
local_main_img = download_media(post.get('_embedded', {}).get('wp:featuredmedia', [{}])[0].get('source_url'), slug, "main")
|
|
||||||
local_logo = download_media(get_url_from_id(acf.get('logo')) if isinstance(acf.get('logo'), int) else extract_url(acf.get('logo')), slug, "logo")
|
|
||||||
|
|
||||||
# Galleri
|
|
||||||
slides = acf.get('slides') or []
|
|
||||||
local_gallery = [download_media(get_url_from_id(s) if isinstance(s, int) else extract_url(s), f"{slug}_{i}", "slide") for i, s in enumerate(slides)]
|
|
||||||
local_gallery = [url for url in local_gallery if url]
|
|
||||||
|
|
||||||
# Golfbox
|
|
||||||
booking_id = acf.get('golfbox_booking_id')
|
|
||||||
gb_booking_url = f"http://www.golfbox.no/site/system/redirect.asp?locale=nb_NO&rUrl=%2Fsite%2Fressources%2Fbooking%2Fgrid.asp%3FRessource_GUID%3D%{{{str(booking_id).strip().replace('{','').replace('}','')}}}" if booking_id else None
|
|
||||||
|
|
||||||
# --- UPSERT FACILITY ---
|
|
||||||
# Merk: $16 (status_updated_at) pakkes nå inn i TO_DATE for å unngå krasj
|
|
||||||
await conn.execute('''
|
|
||||||
INSERT INTO facilities (
|
|
||||||
name, slug, description, address, city, county, established_year, season,
|
|
||||||
email, phone, website_url, image_url, logo_url, video_url,
|
|
||||||
amenities, status_updated_at, gallery, banetype,
|
|
||||||
ngf_number, golfbox_club_id, golfbox_booking_url,
|
|
||||||
facebook_url, instagram_url, baneguide_url, flyfoto_url,
|
|
||||||
golfbox_tournament_url, footnote, social_links, webcam_url,
|
|
||||||
weather_url, architect,
|
|
||||||
navn_standard_medlemskap, standard_medlemskap, standard_medlemskap_kommentarer,
|
|
||||||
navn_rimeligste_alternativ, rimeligste_alternativ, rimeligste_alternativ_kommentarer,
|
|
||||||
medlemskap_url
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb,
|
|
||||||
TO_DATE(NULLIF($16, ''), 'YYYYMMDD'),
|
|
||||||
$17::jsonb, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28::jsonb,
|
|
||||||
$29, $30, $31, $32, $33, $34, $35, $36, $37, $38
|
|
||||||
)
|
|
||||||
ON CONFLICT (slug) DO UPDATE SET
|
|
||||||
name = EXCLUDED.name,
|
|
||||||
description = EXCLUDED.description,
|
|
||||||
address = EXCLUDED.address,
|
|
||||||
city = EXCLUDED.city,
|
|
||||||
phone = EXCLUDED.phone,
|
|
||||||
email = EXCLUDED.email,
|
|
||||||
website_url = EXCLUDED.website_url,
|
|
||||||
image_url = EXCLUDED.image_url,
|
|
||||||
logo_url = EXCLUDED.logo_url,
|
|
||||||
amenities = EXCLUDED.amenities,
|
|
||||||
gallery = EXCLUDED.gallery,
|
|
||||||
status_updated_at = EXCLUDED.status_updated_at,
|
|
||||||
banetype = EXCLUDED.banetype,
|
|
||||||
architect = EXCLUDED.architect
|
|
||||||
''', name, slug, decode_html(acf.get('beskrivelse')), acf.get('gateadresse'), acf.get('postnummer_og_poststed'), acf.get('fylke'), parse_int(acf.get('byggear')), acf.get('sesong'), acf.get('e-post'), acf.get('telefon'), extract_url(acf.get('hjemmeside')), local_main_img, local_logo, None, json.dumps({"drivingrange": decode_html(acf.get("drivingrange")), "treningsgreen": decode_html(acf.get("treningsgreen")), "proshop": decode_html(acf.get("proshop")), "kafe": decode_html(acf.get("kafe")), "bilutleie": decode_html(acf.get("bilutleie")), "kolleutleie": decode_html(acf.get("kolleutleie")), "pro": decode_html(acf.get("pro")), "simulator": decode_html(acf.get("golfsimulator")), "antall_hull": decode_html(acf.get("antall_hull"))}),
|
|
||||||
acf.get('dato_for_oppdatert_status'), # $16
|
|
||||||
json.dumps(local_gallery), decode_html(acf.get('banetype')),
|
|
||||||
parse_int(acf.get('klubbnummer_norges_golfforbund')), parse_int(acf.get('klubbnummer_golfbox')), gb_booking_url, extract_url(acf.get('facebook_url')), extract_url(acf.get('instagram_url')), extract_url(acf.get('baneguide')), extract_url(acf.get('flyfoto')), extract_url(acf.get('golfbox')), decode_html(acf.get('fotnote')), json.dumps(acf.get('sosiale_lenker') or []), decode_html(acf.get('webkamera')), extract_url(acf.get('varmelding_yr')), decode_html(acf.get('arkitekt')), decode_html(acf.get('navn_standard_medlemskap')), parse_int(acf.get('standard_medlemskap')), decode_html(acf.get('standard_medlemskap_kommentarer')), decode_html(acf.get('navn_rimeligste_alternativ')), parse_int(acf.get('rimeligste_alternativ')), decode_html(acf.get('rimeligste_alternativ_kommentarer')), extract_url(acf.get('medlemskap_url')))
|
|
||||||
|
|
||||||
fac_id = (await conn.fetchrow("SELECT id FROM facilities WHERE slug = $1", slug))['id']
|
|
||||||
|
|
||||||
# Baner og Hull
|
|
||||||
fac_main_len = 0
|
|
||||||
for suffix in ['', '_bane_to']:
|
|
||||||
c_name = acf.get('navn_pa_hovedbane' if suffix == '' else 'navn_pa_sekundar_bane') or ('Hovedbanen' if suffix == '' else 'Bane 2')
|
|
||||||
status = acf.get('banestatus' if suffix == '' else 'banestatus_sekundar_bane')
|
|
||||||
if suffix == '_bane_to' and (status == 'finnes_ingen_bane_to' or not parse_int(acf.get('hull_1_par_bane_to'))): continue
|
|
||||||
course_id = await conn.fetchval('INSERT INTO courses (facility_id, name, status, par, is_main_course, tee_boxes, architect) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id', fac_id, c_name, status, parse_int(acf.get('totalt_par' if suffix == '' else 'totalt_par_bane_to')), (suffix == ''), json.dumps({"herrer": acf.get(f"utslag_herrer{suffix}"), "damer": acf.get(f"utslag_damer{suffix}")}), decode_html(acf.get('arkitekt')))
|
|
||||||
curr_len = 0
|
|
||||||
for h_num in range(1, 19):
|
|
||||||
p = parse_int(acf.get(f'hull_{h_num}_par{suffix}'))
|
|
||||||
if p:
|
|
||||||
idx = parse_int(acf.get(f'hull_{h_num}_index{suffix}'))
|
|
||||||
lens = {k: parse_int(acf.get(f'{k}_hull_{h_num}{suffix}')) for k in ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest']}
|
|
||||||
curr_len += (lens['lengst'] or 0)
|
|
||||||
await conn.execute('INSERT INTO holes (course_id, hole_number, par, hcp_index, lengths) VALUES ($1, $2, $3, $4, $5::jsonb)', course_id, h_num, p, idx, json.dumps(lens))
|
|
||||||
await conn.execute("UPDATE courses SET length_meters = $1 WHERE id = $2", curr_len, course_id)
|
|
||||||
if suffix == '': fac_main_len = curr_len
|
|
||||||
await conn.execute("UPDATE facilities SET length_meters = $1 WHERE id = $2", fac_main_len, fac_id)
|
|
||||||
|
|
||||||
page += 1
|
|
||||||
await conn.close()
|
|
||||||
print("✅ IMPORT FERDIG!")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(run_master_import())
|
|
||||||
|
|
@ -1,638 +0,0 @@
|
||||||
"""
|
|
||||||
TEE OFF BACKEND API v3.7.0 - KOBLET PÅ FULL ADMIN REDIGERING
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
REGEL 1: Bruk str (ikke string) for type-hinting.
|
|
||||||
REGEL 2: Inkluder alle subqueries for banestatus og hull-data.
|
|
||||||
REGEL 3: Robust JSON-parsing (format_row) for å hindre Frontend-krasj.
|
|
||||||
REGEL 4: JWT-sesjoner lagres i HTTP-only cookies.
|
|
||||||
LOV: Aldri trunker eller slett logikk for "effektivitet".
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Response, Cookie, Depends, Request, BackgroundTasks
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
import asyncpg
|
|
||||||
import json
|
|
||||||
import pyotp
|
|
||||||
import os
|
|
||||||
from datetime import datetime, date, timedelta
|
|
||||||
from jose import jwt, JWTError
|
|
||||||
from passlib.context import CryptContext
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
# NYE IMPORTER FOR ADMIN PANELET OG BAKGRUNNSJOBBER
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import Optional, List, Any
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
# --- KONFIGURASJON ---
|
|
||||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
|
||||||
SECRET_KEY = os.getenv("JWT_SECRET", "super_secret_change_this_in_production")
|
|
||||||
ALGORITHM = "HS256"
|
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
|
||||||
|
|
||||||
# --- PYDANTIC MODELLER ---
|
|
||||||
class CourseStatusUpdate(BaseModel):
|
|
||||||
id: int
|
|
||||||
status: str
|
|
||||||
|
|
||||||
class ScrapeSettingsUpdate(BaseModel):
|
|
||||||
scrape_method: Optional[str] = None
|
|
||||||
scrape_status_url: Optional[str] = None
|
|
||||||
scrape_status_selector: Optional[str] = None
|
|
||||||
ai_instruction: Optional[str] = None
|
|
||||||
courses: Optional[List[CourseStatusUpdate]] = []
|
|
||||||
|
|
||||||
# NY MODELL FOR Å TA IMOT IDER FOR SCRAPING
|
|
||||||
class ScrapeRunRequest(BaseModel):
|
|
||||||
facility_ids: List[int]
|
|
||||||
|
|
||||||
class MembershipDraftApproval(BaseModel):
|
|
||||||
facility_id: int
|
|
||||||
navn_standard_medlemskap: Optional[str] = None
|
|
||||||
standard_medlemskap: Optional[int] = None
|
|
||||||
standard_medlemskap_kommentarer: Optional[str] = None
|
|
||||||
navn_rimeligste_alternativ: Optional[str] = None
|
|
||||||
rimeligste_alternativ: Optional[int] = None
|
|
||||||
|
|
||||||
class BulkApprovalRequest(BaseModel):
|
|
||||||
approvals: List[MembershipDraftApproval]
|
|
||||||
|
|
||||||
class QuickEditRequest(BaseModel):
|
|
||||||
field: str
|
|
||||||
value: str
|
|
||||||
|
|
||||||
class GreenfeeApproval(BaseModel):
|
|
||||||
facility_id: int
|
|
||||||
greenfee: List[dict]
|
|
||||||
|
|
||||||
|
|
||||||
class VtgApproval(BaseModel):
|
|
||||||
facility_id: int
|
|
||||||
vtg_pris: int | None
|
|
||||||
vtg_beskrivelse: str | None
|
|
||||||
vtg_datoer: List[dict] | None
|
|
||||||
|
|
||||||
class BulkVtgRequest(BaseModel):
|
|
||||||
approvals: List[VtgApproval]
|
|
||||||
# --- FUNKSJONER ---
|
|
||||||
def format_row(row):
|
|
||||||
"""
|
|
||||||
Vasker data fra databasen:
|
|
||||||
1. Konverterer datoer til ISO-format.
|
|
||||||
2. Tvinger tekst-JSON (stringified JSON) over til ekte Python objekter/lister.
|
|
||||||
3. Sikrer at lister og objekter aldri er None for å hindre Frontend-krasj.
|
|
||||||
"""
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
d = dict(row)
|
|
||||||
|
|
||||||
for key in ['status_updated_at', 'created_at', 'slope_valid_until', 'membership_updated_at']:
|
|
||||||
if isinstance(d.get(key), (date, datetime)):
|
|
||||||
d[key] = d[key].isoformat()
|
|
||||||
|
|
||||||
json_list_fields = [
|
|
||||||
'course_statuses', 'courses', 'gallery', 'greenfee',
|
|
||||||
'faqs', 'shotzoom', 'social_links', 'holes', 'golfpakker', 'cooperating_clubs', 'vtg_datoer'
|
|
||||||
]
|
|
||||||
json_dict_fields = [
|
|
||||||
'amenities', 'vtg', 'nsg_data', 'golfamore_data', 'membership_draft'
|
|
||||||
]
|
|
||||||
|
|
||||||
for field in json_list_fields:
|
|
||||||
if field in d:
|
|
||||||
val = d[field]
|
|
||||||
if val is None:
|
|
||||||
d[field] = []
|
|
||||||
elif isinstance(val, str):
|
|
||||||
try:
|
|
||||||
d[field] = json.loads(val)
|
|
||||||
except:
|
|
||||||
d[field] = []
|
|
||||||
elif not isinstance(val, list):
|
|
||||||
d[field] = []
|
|
||||||
|
|
||||||
for field in json_dict_fields:
|
|
||||||
if field in d:
|
|
||||||
val = d[field]
|
|
||||||
if val is None:
|
|
||||||
d[field] = {}
|
|
||||||
elif isinstance(val, str):
|
|
||||||
try:
|
|
||||||
d[field] = json.loads(val)
|
|
||||||
except:
|
|
||||||
d[field] = {}
|
|
||||||
elif not isinstance(val, dict):
|
|
||||||
d[field] = {}
|
|
||||||
|
|
||||||
return d
|
|
||||||
|
|
||||||
# --- BAKGRUNNSARBEIDER: FUNKSJON SOM KJØRER SKRAPEREN I BAKGRUNNEN ---
|
|
||||||
def run_scrape_worker(facility_ids: List[int]):
|
|
||||||
"""
|
|
||||||
Kjører selve skraping-scriptet i bakgrunnen.
|
|
||||||
Slik kan frontenden få et umiddelbart svar, mens skraperen jobber.
|
|
||||||
"""
|
|
||||||
print(f"🔄 STARTER BAKGRUNNSSKRAPING FOR FØLGENDE IDER: {facility_ids}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
ids_arg = ",".join(map(str, facility_ids))
|
|
||||||
|
|
||||||
# NYTT: Bruker "python -u" for LIVE logging, og fjerner "> /dev/null 2>&1"
|
|
||||||
command = f"python -u scrape_status.py --ids {ids_arg}"
|
|
||||||
|
|
||||||
subprocess.run(command, shell=True, check=True)
|
|
||||||
|
|
||||||
print(f"✅ BAKGRUNNSSKRAPING FULLFØRT FOR IDER: {facility_ids}")
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"❌ FEIL UNDER BAKGRUNNSSKRAPING: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"🔥 UFORUTSETT FEIL UNDER BAKGRUNNSSKRAPING: {e}")
|
|
||||||
|
|
||||||
def run_membership_worker(facility_ids: List[int]):
|
|
||||||
"""Kjører medlemskap-skraperen i bakgrunnen."""
|
|
||||||
print(f"🔄 STARTER MEDLEMSKAP-SKRAPING FOR IDER: {facility_ids}")
|
|
||||||
try:
|
|
||||||
ids_arg = ",".join(map(str, facility_ids))
|
|
||||||
command = f"python -u scrape_membership.py --ids {ids_arg}"
|
|
||||||
subprocess.run(command, shell=True, check=True)
|
|
||||||
print(f"✅ MEDLEMSKAP-SKRAPING FULLFØRT FOR IDER: {facility_ids}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"🔥 FEIL UNDER MEDLEMSKAP-SKRAPING: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
# Opprett database-pool ved start
|
|
||||||
try:
|
|
||||||
print(f"📡 Forsøker å koble til database på: {DB_URL}")
|
|
||||||
app.state.pool = await asyncpg.create_pool(
|
|
||||||
DB_URL,
|
|
||||||
min_size=5,
|
|
||||||
max_size=20,
|
|
||||||
command_timeout=60
|
|
||||||
)
|
|
||||||
print("✅ Database tilkoblet og pool opprettet")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Databasefeil under oppstart: {e}")
|
|
||||||
raise e
|
|
||||||
yield
|
|
||||||
# Lukk pool ved avslutning
|
|
||||||
await app.state.pool.close()
|
|
||||||
|
|
||||||
app = FastAPI(title="TeeOff API v3.7.0", lifespan=lifespan)
|
|
||||||
|
|
||||||
# CORS - Tillater både lokal utvikling og produksjonsdomene
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=[
|
|
||||||
"https://nye.teeoff.no",
|
|
||||||
"http://nye.teeoff.no",
|
|
||||||
"http://localhost:3000"
|
|
||||||
],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- AUTH ENDPOINTS ---
|
|
||||||
|
|
||||||
@app.post("/api/auth/login")
|
|
||||||
async def login(data: dict):
|
|
||||||
"""Steg 1: Sjekk passord og returner temp_token for 2FA."""
|
|
||||||
print(f"🔐 Loggin-forsøk for: {data.get('username')}")
|
|
||||||
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
admin = await conn.fetchrow(
|
|
||||||
"SELECT * FROM admins WHERE username = $1 OR email = $1",
|
|
||||||
data.get('username')
|
|
||||||
)
|
|
||||||
|
|
||||||
if not admin:
|
|
||||||
print(" - ❌ Bruker ikke funnet i databasen")
|
|
||||||
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
|
|
||||||
|
|
||||||
h = admin['password_hash']
|
|
||||||
print(f" - Verifiserer hash i DB (starter med: {h[:20]}...)")
|
|
||||||
|
|
||||||
try:
|
|
||||||
is_valid = pwd_context.verify(data.get('password'), h)
|
|
||||||
except Exception as e:
|
|
||||||
print(f" - 🔥 FEIL VED LESING AV HASH: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internt problem med passord-format")
|
|
||||||
|
|
||||||
if not is_valid:
|
|
||||||
print(" - ❌ Passordet samsvarer ikke med hashen")
|
|
||||||
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
|
|
||||||
|
|
||||||
temp_token = jwt.encode(
|
|
||||||
{"sub": admin['username'], "partial": True, "exp": datetime.utcnow() + timedelta(minutes=5)},
|
|
||||||
SECRET_KEY, algorithm=ALGORITHM
|
|
||||||
)
|
|
||||||
print(" - ✅ Steg 1 fullført. Temp-token generert.")
|
|
||||||
return {"step": "2fa", "temp_token": temp_token}
|
|
||||||
|
|
||||||
@app.post("/api/auth/verify-2fa")
|
|
||||||
async def verify_2fa(data: dict, response: Response):
|
|
||||||
"""Steg 2: Verifiser TOTP-kode og sett session cookie."""
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(data.get('temp_token'), SECRET_KEY, algorithms=[ALGORITHM])
|
|
||||||
if not payload.get("partial"):
|
|
||||||
raise JWTError()
|
|
||||||
username = payload.get("sub")
|
|
||||||
except JWTError:
|
|
||||||
raise HTTPException(status_code=401, detail="Sesjonen har utløpt eller er ugyldig")
|
|
||||||
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
admin = await conn.fetchrow("SELECT otp_secret FROM admins WHERE username = $1", username)
|
|
||||||
|
|
||||||
totp = pyotp.TOTP(admin['otp_secret'])
|
|
||||||
if not totp.verify(data.get('code')):
|
|
||||||
print(f" - ❌ Feil 2FA-kode oppgitt for {username}")
|
|
||||||
raise HTTPException(status_code=401, detail="Feil 2FA-kode")
|
|
||||||
|
|
||||||
final_token = jwt.encode(
|
|
||||||
{"sub": username, "exp": datetime.utcnow() + timedelta(hours=12)},
|
|
||||||
SECRET_KEY, algorithm=ALGORITHM
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sett som HTTP-only cookie
|
|
||||||
response.set_cookie(
|
|
||||||
key="admin_session",
|
|
||||||
value=final_token,
|
|
||||||
httponly=True,
|
|
||||||
samesite="lax",
|
|
||||||
secure=False # Sett til True i produksjon (HTTPS)
|
|
||||||
)
|
|
||||||
return {"status": "success"}
|
|
||||||
|
|
||||||
# --- DATA ENDPOINTS ---
|
|
||||||
|
|
||||||
@app.get("/api/facilities")
|
|
||||||
async def get_facilities():
|
|
||||||
"""Henter alle golfanlegg med aggregert banestatus for forsiden."""
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
rows = await conn.fetch("""
|
|
||||||
SELECT f.*, (
|
|
||||||
SELECT jsonb_agg(cs) FROM (
|
|
||||||
SELECT id, name, status FROM courses
|
|
||||||
WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to'
|
|
||||||
ORDER BY is_main_course DESC, id ASC
|
|
||||||
) cs
|
|
||||||
) as course_statuses
|
|
||||||
FROM facilities f
|
|
||||||
ORDER BY f.name ASC
|
|
||||||
""")
|
|
||||||
return [format_row(row) for row in rows]
|
|
||||||
|
|
||||||
@app.get("/api/facilities/{slug}")
|
|
||||||
async def get_facility(slug: str):
|
|
||||||
"""Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull."""
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
row = await conn.fetchrow("""
|
|
||||||
SELECT f.*, (
|
|
||||||
SELECT jsonb_agg(c_data) FROM (
|
|
||||||
SELECT c.*, (
|
|
||||||
SELECT jsonb_agg(h_data ORDER BY h_data.hole_number ASC)
|
|
||||||
FROM (SELECT * FROM holes WHERE course_id = c.id) h_data
|
|
||||||
) as holes
|
|
||||||
FROM courses c
|
|
||||||
WHERE c.facility_id = f.id
|
|
||||||
AND (c.is_main_course = true OR (c.status NOT IN ('finnes_ingen_bane_to', 'ukjent')))
|
|
||||||
ORDER BY c.is_main_course DESC, c.id ASC
|
|
||||||
) c_data
|
|
||||||
) as courses
|
|
||||||
FROM facilities f WHERE f.slug = $1
|
|
||||||
""", slug)
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
|
|
||||||
|
|
||||||
return format_row(row)
|
|
||||||
|
|
||||||
# --- ADMIN ENDPOINTS ---
|
|
||||||
|
|
||||||
@app.patch("/api/admin/facilities/{facility_id}/scrape-settings")
|
|
||||||
async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate):
|
|
||||||
"""Oppdaterer hvordan et anlegg skal skrapes (f.eks. slå på Gemini AI eller bytte URL)."""
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
try:
|
|
||||||
# Sjekk først at anlegget eksisterer
|
|
||||||
facility = await conn.fetchrow("SELECT id FROM facilities WHERE id = $1", facility_id)
|
|
||||||
if not facility:
|
|
||||||
raise HTTPException(status_code=404, detail="Anlegget finnes ikke.")
|
|
||||||
|
|
||||||
# Oppdater verdiene i databasen inkludert AI instruks
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE facilities
|
|
||||||
SET scrape_method = $1,
|
|
||||||
scrape_status_url = $2,
|
|
||||||
scrape_status_selector = $3,
|
|
||||||
ai_instruction = $4
|
|
||||||
WHERE id = $5
|
|
||||||
""",
|
|
||||||
settings.scrape_method,
|
|
||||||
settings.scrape_status_url,
|
|
||||||
settings.scrape_status_selector,
|
|
||||||
settings.ai_instruction,
|
|
||||||
facility_id)
|
|
||||||
|
|
||||||
# Hvis metoden er manuell, tvinger vi gjennom de nye banestatusene direkte
|
|
||||||
if settings.scrape_method == 'manual' and settings.courses:
|
|
||||||
for c in settings.courses:
|
|
||||||
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", c.status, c.id)
|
|
||||||
|
|
||||||
return {"status": "success", "message": f"Skrapeinnstillinger for anlegg ID {facility_id} ble oppdatert."}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
if isinstance(e, HTTPException):
|
|
||||||
raise e
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
# --- NYTT ADMIN ENDPOINT FOR FULL OPPDATERING (JSON-EDITOR) ---
|
|
||||||
@app.put("/api/admin/facilities/{facility_id}/full")
|
|
||||||
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()
|
|
||||||
|
|
||||||
# Felter som er trygge å oppdatere manuelt på anlegget
|
|
||||||
allowed_fields = [
|
|
||||||
'name', 'description', 'established_year', 'season', 'banetype', 'architect', 'length_meters',
|
|
||||||
'address', 'zipcode', 'city', 'county', 'lat', 'lng',
|
|
||||||
'email', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url',
|
|
||||||
'weather_url', 'webcam_url', 'video_url', 'baneguide_url', 'flyfoto_url',
|
|
||||||
'amenities', 'greenfee', 'golfpakker', 'rabattert_greenfee',
|
|
||||||
'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',
|
|
||||||
'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'
|
|
||||||
]
|
|
||||||
|
|
||||||
update_data = {k: v for k, v in data.items() if k in allowed_fields}
|
|
||||||
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
async with conn.transaction(): # Sikrer at alt lagres samlet
|
|
||||||
|
|
||||||
# 1. OPPDATER ANLEGG (FACILITIES)
|
|
||||||
if update_data:
|
|
||||||
set_clauses = []
|
|
||||||
values = []
|
|
||||||
for i, (k, v) in enumerate(update_data.items(), 1):
|
|
||||||
if isinstance(v, (dict, list)):
|
|
||||||
set_clauses.append(f"{k} = ${i}::jsonb")
|
|
||||||
values.append(json.dumps(v))
|
|
||||||
else:
|
|
||||||
set_clauses.append(f"{k} = ${i}")
|
|
||||||
values.append(v)
|
|
||||||
|
|
||||||
values.append(facility_id)
|
|
||||||
query = f"UPDATE facilities SET {', '.join(set_clauses)} WHERE id = ${len(values)}"
|
|
||||||
await conn.execute(query, *values)
|
|
||||||
|
|
||||||
# 2. OPPDATER BANER (COURSES) OG HULL (HOLES)
|
|
||||||
courses = data.get('courses', [])
|
|
||||||
for course in courses:
|
|
||||||
course_id = course.get('id')
|
|
||||||
if course_id:
|
|
||||||
# Rens datoformat for PostgreSQL (håndterer Next.js date input)
|
|
||||||
valid_until = course.get('slope_valid_until')
|
|
||||||
if valid_until == "" or valid_until is None:
|
|
||||||
valid_until = None
|
|
||||||
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE courses
|
|
||||||
SET name=$1, par=$2, length_meters=$3, architect=$4,
|
|
||||||
status=$5, is_main_course=$6, tee_boxes=$7::jsonb,
|
|
||||||
slope_valid_until=$8
|
|
||||||
WHERE id=$9 AND facility_id=$10
|
|
||||||
""",
|
|
||||||
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)
|
|
||||||
|
|
||||||
# 3. OPPDATER HULL PÅ BANEN (HOLES)
|
|
||||||
holes = course.get('holes', [])
|
|
||||||
for hole in holes:
|
|
||||||
hole_id = hole.get('id')
|
|
||||||
if hole_id:
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE holes
|
|
||||||
SET par=$1, hcp_index=$2, lengths=$3::jsonb
|
|
||||||
WHERE id=$4 AND course_id=$5
|
|
||||||
""",
|
|
||||||
hole.get('par'), hole.get('hcp_index'),
|
|
||||||
json.dumps(hole.get('lengths', {})), hole_id, course_id)
|
|
||||||
|
|
||||||
return {"status": "success", "message": "Anlegg, baner og scorekort ble oppdatert."}
|
|
||||||
|
|
||||||
# --- NYTT ADMIN ENDPOINT: KJØRER SKRAPEREN FOR VALGTE IDER ---
|
|
||||||
@app.post("/api/admin/run-scraper")
|
|
||||||
async def run_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
|
|
||||||
"""
|
|
||||||
Tar imot IDer for skraping, og starter en bakgrunnsjobb.
|
|
||||||
Gir et umiddelbart svar tilbake til frontenden slik at den slipper å vente.
|
|
||||||
"""
|
|
||||||
if not request.facility_ids:
|
|
||||||
raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
|
|
||||||
|
|
||||||
print(f"📡 API mottok forespørsel om å kjøre skraping for IDer: {request.facility_ids}")
|
|
||||||
|
|
||||||
background_tasks.add_task(run_scrape_worker, request.facility_ids)
|
|
||||||
|
|
||||||
return {"status": "queued", "message": f"Skraping for {len(request.facility_ids)} anlegg ble lagt i kø."}
|
|
||||||
|
|
||||||
@app.post("/api/admin/run-membership-scraper")
|
|
||||||
async def run_membership_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
|
|
||||||
"""Tar imot IDer for medlemskapsskraping og legger jobben i kø."""
|
|
||||||
if not request.facility_ids:
|
|
||||||
raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
|
|
||||||
|
|
||||||
print(f"📡 API mottok forespørsel om medlemskapsskraping for IDer: {request.facility_ids}")
|
|
||||||
background_tasks.add_task(run_membership_worker, request.facility_ids)
|
|
||||||
|
|
||||||
return {"status": "queued", "message": f"Medlemskapsskraping for {len(request.facility_ids)} anlegg ble lagt i kø."}
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
|
||||||
async def health_check():
|
|
||||||
"""Enkel sjekk for å se at API og DB lever."""
|
|
||||||
try:
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
await conn.execute("SELECT 1")
|
|
||||||
return {"status": "healthy", "database": "connected"}
|
|
||||||
except Exception as e:
|
|
||||||
return {"status": "unhealthy", "error": str(e)}
|
|
||||||
|
|
||||||
# --- MEDLEMSKAP "VASKERI" ENDEPUNKTER ---
|
|
||||||
|
|
||||||
@app.get("/api/admin/membership/drafts")
|
|
||||||
async def get_membership_drafts():
|
|
||||||
"""Henter alle anlegg som har et ventende forslag fra AI-skraperen."""
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
rows = await conn.fetch("""
|
|
||||||
SELECT id, name, slug, medlemskap_url,
|
|
||||||
navn_standard_medlemskap, standard_medlemskap,
|
|
||||||
navn_rimeligste_alternativ, rimeligste_alternativ,
|
|
||||||
membership_draft
|
|
||||||
FROM facilities
|
|
||||||
WHERE membership_draft IS NOT NULL
|
|
||||||
AND membership_draft::text != '{}'
|
|
||||||
ORDER BY name ASC
|
|
||||||
""")
|
|
||||||
return [format_row(row) for row in rows]
|
|
||||||
|
|
||||||
@app.post("/api/admin/membership/approve-bulk")
|
|
||||||
async def approve_membership_bulk(request: BulkApprovalRequest):
|
|
||||||
"""Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet."""
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
async with conn.transaction():
|
|
||||||
for approval in request.approvals:
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE facilities
|
|
||||||
SET navn_standard_medlemskap = $1,
|
|
||||||
standard_medlemskap = $2,
|
|
||||||
standard_medlemskap_kommentarer = $3,
|
|
||||||
navn_rimeligste_alternativ = $4,
|
|
||||||
rimeligste_alternativ = $5,
|
|
||||||
membership_updated_at = NOW(),
|
|
||||||
membership_draft = NULL
|
|
||||||
WHERE id = $6
|
|
||||||
""",
|
|
||||||
approval.navn_standard_medlemskap,
|
|
||||||
approval.standard_medlemskap,
|
|
||||||
approval.standard_medlemskap_kommentarer,
|
|
||||||
approval.navn_rimeligste_alternativ,
|
|
||||||
approval.rimeligste_alternativ,
|
|
||||||
approval.facility_id)
|
|
||||||
return {"status": "success", "message": f"{len(request.approvals)} anlegg ble oppdatert med nye priser!"}
|
|
||||||
|
|
||||||
@app.patch("/api/admin/facilities/{facility_id}/quick-edit")
|
|
||||||
async def quick_edit_facility(facility_id: int, request: QuickEditRequest):
|
|
||||||
"""Lyn-redigering av enkle URL-felter fra admin-dashbordet."""
|
|
||||||
# Sikkerhet: Tillat KUN disse tre feltene for hurtigredigering
|
|
||||||
allowed_fields = ['scrape_status_url', 'medlemskap_url', 'scrape_status_selector']
|
|
||||||
if request.field not in allowed_fields:
|
|
||||||
raise HTTPException(status_code=400, detail="Ugyldig felt for hurtigredigering.")
|
|
||||||
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
# F-string her er trygt fordi request.field er sjekket mot allowed_fields-listen
|
|
||||||
await conn.execute(f"UPDATE facilities SET {request.field} = $1 WHERE id = $2",
|
|
||||||
request.value, facility_id)
|
|
||||||
return {"status": "success"}
|
|
||||||
|
|
||||||
# --- GREENFEE "VASKERI" ENDEPUNKTER ---
|
|
||||||
|
|
||||||
@app.get("/api/admin/greenfee/drafts")
|
|
||||||
async def get_greenfee_drafts():
|
|
||||||
"""Henter alle anlegg som har et ventende greenfee-forslag fra AI-skraperen."""
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
rows = await conn.fetch("""
|
|
||||||
SELECT id, name, slug, greenfee_url, greenfee, greenfee_draft
|
|
||||||
FROM facilities
|
|
||||||
WHERE greenfee_draft IS NOT NULL
|
|
||||||
AND greenfee_draft::text != '{}'
|
|
||||||
ORDER BY name ASC
|
|
||||||
""")
|
|
||||||
return [format_row(row) for row in rows]
|
|
||||||
|
|
||||||
class BulkGreenfeeRequest(BaseModel):
|
|
||||||
approvals: List[GreenfeeApproval]
|
|
||||||
|
|
||||||
@app.post("/api/admin/greenfee/approve-bulk")
|
|
||||||
async def approve_greenfee_bulk(request: BulkGreenfeeRequest):
|
|
||||||
"""Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet."""
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
async with conn.transaction():
|
|
||||||
for approval in request.approvals:
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE facilities
|
|
||||||
SET greenfee = $1::jsonb,
|
|
||||||
greenfee_updated_at = NOW(),
|
|
||||||
greenfee_draft = NULL
|
|
||||||
WHERE id = $2
|
|
||||||
""", json.dumps(approval.greenfee), approval.facility_id)
|
|
||||||
return {"status": "success"}
|
|
||||||
|
|
||||||
def run_greenfee_worker(facility_ids: List[int]):
|
|
||||||
"""Kjører greenfee-skraperen i bakgrunnen."""
|
|
||||||
print(f"🔄 STARTER GREENFEE-SKRAPING FOR IDER: {facility_ids}")
|
|
||||||
try:
|
|
||||||
import subprocess
|
|
||||||
ids_arg = ",".join(map(str, facility_ids))
|
|
||||||
command = f"python -u scrape_greenfee.py --ids {ids_arg}"
|
|
||||||
subprocess.run(command, shell=True, check=True)
|
|
||||||
print(f"✅ GREENFEE-SKRAPING FULLFØRT FOR IDER: {facility_ids}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"🔥 FEIL UNDER GREENFEE-SKRAPING: {e}")
|
|
||||||
|
|
||||||
@app.post("/api/admin/run-greenfee-scraper")
|
|
||||||
async def run_greenfee_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
|
|
||||||
"""Tar imot IDer for greenfeeskraping og legger jobben i kø."""
|
|
||||||
if not request.facility_ids:
|
|
||||||
raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
|
|
||||||
background_tasks.add_task(run_greenfee_worker, request.facility_ids)
|
|
||||||
return {"status": "queued", "message": "Skraping startet"}
|
|
||||||
|
|
||||||
# --- VEIEN TIL GOLF (VTG) "VASKERI" ENDEPUNKTER ---
|
|
||||||
|
|
||||||
@app.get("/api/admin/vtg/drafts")
|
|
||||||
async def get_vtg_drafts():
|
|
||||||
"""Henter alle anlegg som har et ventende VTG-forslag."""
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
rows = await conn.fetch("""
|
|
||||||
SELECT id, name, slug, vtg_lenke, vtg_pris, vtg_beskrivelse, vtg_datoer, vtg_draft
|
|
||||||
FROM facilities
|
|
||||||
WHERE vtg_draft IS NOT NULL
|
|
||||||
AND vtg_draft::text != '{}'
|
|
||||||
ORDER BY name ASC
|
|
||||||
""")
|
|
||||||
return [format_row(row) for row in rows]
|
|
||||||
|
|
||||||
@app.post("/api/admin/vtg/approve-bulk")
|
|
||||||
async def approve_vtg_bulk(request: BulkVtgRequest):
|
|
||||||
"""Godkjenner AI-forslag for VTG, setter oppdatert-dato og sletter utkastet."""
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
async with conn.transaction():
|
|
||||||
for approval in request.approvals:
|
|
||||||
datoer_json = json.dumps(approval.vtg_datoer) if approval.vtg_datoer is not None else '[]'
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE facilities
|
|
||||||
SET vtg_pris = $1,
|
|
||||||
vtg_beskrivelse = $2,
|
|
||||||
vtg_datoer = $3::jsonb,
|
|
||||||
vtg_updated_at = NOW(),
|
|
||||||
vtg_draft = NULL
|
|
||||||
WHERE id = $4
|
|
||||||
""", approval.vtg_pris, approval.vtg_beskrivelse, datoer_json, approval.facility_id)
|
|
||||||
return {"status": "success"}
|
|
||||||
|
|
||||||
def run_vtg_worker(facility_ids: List[int]):
|
|
||||||
"""Kjører VTG-skraperen i bakgrunnen."""
|
|
||||||
print(f"🔄 STARTER VTG-SKRAPING FOR IDER: {facility_ids}")
|
|
||||||
try:
|
|
||||||
import subprocess
|
|
||||||
ids_arg = ",".join(map(str, facility_ids))
|
|
||||||
command = f"python -u scrape_vtg.py --ids {ids_arg}"
|
|
||||||
subprocess.run(command, shell=True, check=True)
|
|
||||||
print(f"✅ VTG-SKRAPING FULLFØRT FOR IDER: {facility_ids}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"🔥 FEIL UNDER VTG-SKRAPING: {e}")
|
|
||||||
|
|
||||||
@app.post("/api/admin/run-vtg-scraper")
|
|
||||||
async def run_vtg_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
|
|
||||||
"""Tar imot IDer for VTG-skraping og legger jobben i kø."""
|
|
||||||
if not request.facility_ids:
|
|
||||||
raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
|
|
||||||
background_tasks.add_task(run_vtg_worker, request.facility_ids)
|
|
||||||
return {"status": "queued", "message": "Skraping startet"}
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import uvicorn
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
|
|
||||||
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
|
||||||
|
|
||||||
# Data hentet direkte fra bildet du sendte
|
|
||||||
GOLFAMORE_DATA = {
|
|
||||||
"borre": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 14, 19, 20, 21",
|
|
||||||
"nesfjellet": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 27, 28, 29, 30",
|
|
||||||
"vradal": "Kortet er gyldig alle dager, ikke uke 28, 29, 30, 31",
|
|
||||||
"alta": "Kortet er gyldig alle dager",
|
|
||||||
"elverum": "Kortet er gyldig hverdager (ikke helligdager)",
|
|
||||||
"gronmo": "Kortet er gyldig alle dager",
|
|
||||||
"notteroy": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 27, 28, 29, 30",
|
|
||||||
"roros": "Kortet er gyldig alle dager",
|
|
||||||
"stiklestad": "Kortet er gyldig alle dager",
|
|
||||||
"arendalomegn": "Kortet er gyldig alle dager, ikke uke 27, 28, 29, 30",
|
|
||||||
"northcape": "Kortet er gyldig alle dager",
|
|
||||||
"trysil": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 28, 29, 30, 31",
|
|
||||||
"mork": "Kortet er gyldig hverdager (ikke helligdager)",
|
|
||||||
"norsjo": "Kortet er gyldig alle dager",
|
|
||||||
"ringerike": "Kortet er gyldig alle dager",
|
|
||||||
"stord": "Kortet er gyldig alle dager",
|
|
||||||
"sunnmore": "Kortet er gyldig alle dager",
|
|
||||||
"bodogolfparksalten": "Kortet er gyldig alle dager",
|
|
||||||
"drammen": "Kortet er gyldig alle dager",
|
|
||||||
"gjoviktoten": "Kortet er gyldig alle dager",
|
|
||||||
"grenlandomegn": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 27, 28, 29, 30",
|
|
||||||
"nes09": "Kortet er gyldig alle dager, ikke uke 15, 16, 17, 18",
|
|
||||||
"romerike": "Kortet er gyldig alle dager",
|
|
||||||
"bamble": "Kortet er gyldig alle dager",
|
|
||||||
"bleik": "Kortet er gyldig alle dager",
|
|
||||||
"krokhol": "Kortet er gyldig alle dager",
|
|
||||||
"skjeberg": "Kortet er gyldig hverdager (ikke helligdager)",
|
|
||||||
"utsikten": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 27, 28, 29, 30",
|
|
||||||
"eiker": "Kortet er gyldig alle dager",
|
|
||||||
"hafjell": "Kortet er gyldig alle dager",
|
|
||||||
"mandal": "Kortet er gyldig alle dager, ikke uke 27, 28, 29, 30",
|
|
||||||
"mjosen": "Kortet er gyldig alle dager",
|
|
||||||
"randsfjorden": "Kortet er gyldig alle dager",
|
|
||||||
"ski": "Kortet er gyldig alle dager",
|
|
||||||
"bjornefjorden": "Kortet er gyldig alle dager",
|
|
||||||
"sande": "Kortet er gyldig alle dager",
|
|
||||||
"haugesund": "Kortet er gyldig alle dager",
|
|
||||||
"midttroms": "Kortet er gyldig alle dager",
|
|
||||||
"skei": "Kortet er gyldig hverdager (ikke helligdager)",
|
|
||||||
"sorknes": "Kortet er gyldig alle dager",
|
|
||||||
"gjerdrum": "Kortet er gyldig alle dager",
|
|
||||||
"herdla": "Kortet er gyldig alle dager",
|
|
||||||
"hovden": "Kortet er gyldig alle dager",
|
|
||||||
"oppdal": "Kortet er gyldig alle dager",
|
|
||||||
"gjersjoen": "Kortet er gyldig alle dager",
|
|
||||||
"ogna": "Kortet er gyldig alle dager",
|
|
||||||
"tonsberg": "Kortet er gyldig alle dager",
|
|
||||||
"ullensaker": "Kortet er gyldig alle dager",
|
|
||||||
"hof": "Kortet er gyldig hverdager (ikke helligdager)",
|
|
||||||
"klabu": "Kortet er gyldig alle dager",
|
|
||||||
"hemsedal": "Kortet er gyldig alle dager",
|
|
||||||
"narvik": "Kortet er gyldig alle dager",
|
|
||||||
"norefjell": "Kortet er gyldig hverdager (ikke helligdager)",
|
|
||||||
"austratt": "Kortet er gyldig alle dager",
|
|
||||||
"hammerfest": "Kortet er gyldig alle dager",
|
|
||||||
"helgeland": "Kortet er gyldig alle dager",
|
|
||||||
"jaren": "Kortet er gyldig alle dager",
|
|
||||||
"namdal": "Kortet er gyldig alle dager",
|
|
||||||
"namsos": "Kortet er gyldig alle dager",
|
|
||||||
"nordfjord": "Kortet er gyldig alle dager",
|
|
||||||
"polarsirkelen": "Kortet er gyldig alle dager",
|
|
||||||
"sandnesbarheim": "Kortet er gyldig alle dager",
|
|
||||||
"steinkjer": "Kortet er gyldig alle dager",
|
|
||||||
"varanger": "Kortet er gyldig alle dager"
|
|
||||||
}
|
|
||||||
|
|
||||||
def clean(text):
|
|
||||||
if not text: return ""
|
|
||||||
# Fjerner alt som ikke er bokstaver/tall for matching
|
|
||||||
s = text.lower().replace("golfklubb", "").replace("gk", "").replace(" og ", "").replace("&", "").strip()
|
|
||||||
return re.sub(r'[^a-z0-9]', '', s)
|
|
||||||
|
|
||||||
async def update_golfamore():
|
|
||||||
print("\n🚀 OPPDATERER GOLFAMORE FRA BILDE-DATA...")
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
facilities = await conn.fetch("SELECT id, name FROM facilities")
|
|
||||||
|
|
||||||
# Lag et vasket map av bilde-dataen
|
|
||||||
image_data_clean = {clean(name): val for name, val in GOLFAMORE_DATA.items()}
|
|
||||||
|
|
||||||
matches = 0
|
|
||||||
for fac in facilities:
|
|
||||||
fac_id = fac['id']
|
|
||||||
fac_name = fac['name']
|
|
||||||
fac_clean = clean(fac_name)
|
|
||||||
|
|
||||||
validity = None
|
|
||||||
# Prøv eksakt match først
|
|
||||||
if fac_clean in image_data_clean:
|
|
||||||
validity = image_data_clean[fac_clean]
|
|
||||||
else:
|
|
||||||
# Prøv delvis match (f.eks "Arendal" i "Arendal & Omegn")
|
|
||||||
for key, val in image_data_clean.items():
|
|
||||||
if len(fac_clean) > 4 and (fac_clean in key or key in fac_clean):
|
|
||||||
validity = val
|
|
||||||
break
|
|
||||||
|
|
||||||
if validity:
|
|
||||||
print(f"✅ Match funnet: {fac_name}")
|
|
||||||
ga_data = {"validity": validity}
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE facilities
|
|
||||||
SET golfamore = true, golfamore_data = $1
|
|
||||||
WHERE id = $2
|
|
||||||
""", json.dumps(ga_data), fac_id)
|
|
||||||
matches += 1
|
|
||||||
else:
|
|
||||||
# Hvis den ikke er i listen fra bildet, sett til false
|
|
||||||
await conn.execute("UPDATE facilities SET golfamore = false, golfamore_data = '{}' WHERE id = $1", fac_id)
|
|
||||||
|
|
||||||
await conn.close()
|
|
||||||
print(f"\n🎉 Ferdig! {matches} baner ble oppdatert med Golfamore-info.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(update_golfamore())
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
"""
|
|
||||||
TEE OFF - GREENFEE-SKRAPER MED GEMINI AI
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
Henter alle greenfee-varianter fra en (eller flere) URL-er og strukturerer
|
|
||||||
dem i en JSON-liste. Finner også avtaleklubber/vennskapsklubber.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import argparse
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from playwright.async_api import async_playwright
|
|
||||||
import google.generativeai as genai
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
|
||||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
|
||||||
|
|
||||||
if not GEMINI_API_KEY:
|
|
||||||
raise ValueError("🚨 GEMINI_API_KEY mangler i .env filen!")
|
|
||||||
|
|
||||||
genai.configure(api_key=GEMINI_API_KEY)
|
|
||||||
model = genai.GenerativeModel('gemini-2.5-flash')
|
|
||||||
|
|
||||||
async def fetch_page_text(url: str, browser) -> str:
|
|
||||||
url = url.strip()
|
|
||||||
if not url.startswith("http"):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
print(f" 🌐 Laster inn: {url}")
|
|
||||||
try:
|
|
||||||
page = await browser.new_page()
|
|
||||||
await page.goto(url, wait_until="domcontentloaded", timeout=15000)
|
|
||||||
html_content = await page.content()
|
|
||||||
await page.close()
|
|
||||||
|
|
||||||
soup = BeautifulSoup(html_content, 'html.parser')
|
|
||||||
for script in soup(["script", "style", "nav", "footer", "header"]):
|
|
||||||
script.extract()
|
|
||||||
|
|
||||||
return soup.get_text(separator=' ', strip=True)
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ Feil ved lasting av {url}: {e}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def analyze_greenfee_with_gemini(text: str, club_name: str) -> dict:
|
|
||||||
print(f" 🧠 Sender {len(text)} tegn til Gemini for greenfee-analyse...")
|
|
||||||
|
|
||||||
prompt = f"""
|
|
||||||
Du er en ekspert på norske golfklubber og prissetting.
|
|
||||||
Din oppgave er å lese teksten hentet fra nettsidene til "{club_name}" og hente ut TO ting:
|
|
||||||
1. ALLE varianter av greenfee-priser.
|
|
||||||
2. Navn på eventuelle vennskapsklubber/avtaleklubber (hvis nevnt).
|
|
||||||
|
|
||||||
REGLER FOR GREENFEE:
|
|
||||||
- Trekk ut absolutt alle priskategorier du finner (f.eks. "Hverdag høysesong", "Helg før kl 14", "Gjest av medlem", "9 hull kveld", osv.).
|
|
||||||
- Finn både voksenpris og juniorpris for hver kategori.
|
|
||||||
- HVIS juniorpris er oppgitt som en regel (f.eks. "Juniorer betaler halv pris" eller "50% rabatt for junior"), MÅ du selv regne ut prisen og skrive inn heltallet.
|
|
||||||
- "banenavn": Bruk navnet på banen hvis det er spesifisert (f.eks. "18-hullsbanen", "Korthullsbanen"). Hvis ikke spesifisert, bruk "{club_name}".
|
|
||||||
- Priser SKAL være tall (integer). Sett pris til null (null) hvis den ikke finnes.
|
|
||||||
|
|
||||||
REGLER FOR AVTALEKLUBBER:
|
|
||||||
- Let etter overskrifter som "Vennskapsklubber", "Avtaleklubber", "Gjestespill", "Samarbeidsklubber".
|
|
||||||
- Trekk ut kun navnene på klubbene i en liste (f.eks. ["Haga GK", "Oslo GK"]). La listen være tom hvis du ikke finner noen.
|
|
||||||
|
|
||||||
TEKST FRA NETTSIDEN:
|
|
||||||
{text}
|
|
||||||
|
|
||||||
OPPGAVE:
|
|
||||||
Returner KUN et gyldig JSON-objekt med nøyaktig følgende struktur:
|
|
||||||
{{
|
|
||||||
"foreslatt_greenfee": [
|
|
||||||
{{
|
|
||||||
"banenavn": "Navn på banen",
|
|
||||||
"priskategori": "F.eks: Hverdag Gjest av Medlem",
|
|
||||||
"pris_voksne": 600,
|
|
||||||
"pris_junior": 300
|
|
||||||
}}
|
|
||||||
],
|
|
||||||
"foreslatt_avtaleklubber": [
|
|
||||||
"Klubb 1 GK",
|
|
||||||
"Klubb 2 GK"
|
|
||||||
],
|
|
||||||
"ai_begrunnelse": "Kort forklaring, f.eks: 'Fant et komplekst prissystem for høy/lavsesong. Regnet ut juniorpriser til 50% som angitt i teksten. Fant 3 samarbeidsklubber nederst.'"
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = model.generate_content(prompt)
|
|
||||||
raw_response = response.text.strip()
|
|
||||||
|
|
||||||
if raw_response.startswith("```json"):
|
|
||||||
raw_response = raw_response[7:]
|
|
||||||
if raw_response.endswith("```"):
|
|
||||||
raw_response = raw_response[:-3]
|
|
||||||
|
|
||||||
return json.loads(raw_response.strip())
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ AI-analyse feilet: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def run_greenfee_scraper(facility_ids=None):
|
|
||||||
print("🚀 Starter Greenfee-skraperen...")
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
|
|
||||||
try:
|
|
||||||
query = "SELECT id, name, greenfee_url FROM facilities WHERE greenfee_url IS NOT NULL AND greenfee_url != ''"
|
|
||||||
if facility_ids:
|
|
||||||
query += f" AND id IN ({','.join(map(str, facility_ids))})"
|
|
||||||
|
|
||||||
facilities = await conn.fetch(query)
|
|
||||||
print(f"📋 Fant {len(facilities)} anlegg å skrape.")
|
|
||||||
|
|
||||||
async with async_playwright() as p:
|
|
||||||
browser = await p.chromium.launch(headless=True)
|
|
||||||
|
|
||||||
for facility in facilities:
|
|
||||||
fac_id = facility['id']
|
|
||||||
name = facility['name']
|
|
||||||
urls_raw = facility['greenfee_url']
|
|
||||||
|
|
||||||
print(f"\n▶️ Behandler Greenfee for: {name} (ID: {fac_id})")
|
|
||||||
|
|
||||||
urls = [u.strip() for u in urls_raw.split(',')]
|
|
||||||
combined_text = ""
|
|
||||||
|
|
||||||
for idx, url in enumerate(urls, 1):
|
|
||||||
page_text = await fetch_page_text(url, browser)
|
|
||||||
if page_text:
|
|
||||||
combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}"
|
|
||||||
|
|
||||||
if len(combined_text) < 50:
|
|
||||||
print(" ⚠️ Fant for lite tekst, hopper over.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
draft_data = analyze_greenfee_with_gemini(combined_text[:25000], name)
|
|
||||||
|
|
||||||
if not draft_data:
|
|
||||||
continue
|
|
||||||
|
|
||||||
funnet_priser = len(draft_data.get('foreslatt_greenfee', []))
|
|
||||||
funnet_klubber = len(draft_data.get('foreslatt_avtaleklubber', []))
|
|
||||||
print(f" ✅ AI fant {funnet_priser} greenfee-varianter og {funnet_klubber} avtaleklubber.")
|
|
||||||
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE facilities
|
|
||||||
SET greenfee_draft = $1::jsonb
|
|
||||||
WHERE id = $2
|
|
||||||
""", json.dumps(draft_data), fac_id)
|
|
||||||
|
|
||||||
print(" 💾 Greenfee-utkast lagret i databasen!")
|
|
||||||
|
|
||||||
await browser.close()
|
|
||||||
|
|
||||||
finally:
|
|
||||||
await conn.close()
|
|
||||||
print("\n🏁 Skraping fullført.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(description="Skrap greenfeepriser via AI.")
|
|
||||||
parser.add_argument("--ids", type=str, help="Kommaseparert liste med facility IDs (eks: 1,5,12)")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
ids_to_scrape = None
|
|
||||||
if args.ids:
|
|
||||||
ids_to_scrape = [int(x.strip()) for x in args.ids.split(",")]
|
|
||||||
|
|
||||||
asyncio.run(run_greenfee_scraper(ids_to_scrape))
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
"""
|
|
||||||
TEE OFF - MEDLEMSKAPSSKRAPER MED GEMINI AI (MULTI-URL VERSJON)
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
Går til oppgitte medlemskaps-URLer (støtter flere URLer adskilt med komma),
|
|
||||||
henter ut tekst, og bruker Gemini til å summere og finne 'Standard' og
|
|
||||||
'Rimeligste' medlemskap.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import argparse
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from playwright.async_api import async_playwright
|
|
||||||
import google.generativeai as genai
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
|
||||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
|
||||||
|
|
||||||
if not GEMINI_API_KEY:
|
|
||||||
raise ValueError("🚨 GEMINI_API_KEY mangler i .env filen!")
|
|
||||||
|
|
||||||
genai.configure(api_key=GEMINI_API_KEY)
|
|
||||||
model = genai.GenerativeModel('gemini-2.5-flash')
|
|
||||||
|
|
||||||
async def fetch_page_text(url: str, browser) -> str:
|
|
||||||
"""Bruker Playwright for å hente all synlig tekst fra EN nettside."""
|
|
||||||
url = url.strip()
|
|
||||||
if not url.startswith("http"):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
print(f" 🌐 Laster inn: {url}")
|
|
||||||
try:
|
|
||||||
page = await browser.new_page()
|
|
||||||
await page.goto(url, wait_until="domcontentloaded", timeout=15000)
|
|
||||||
html_content = await page.content()
|
|
||||||
await page.close()
|
|
||||||
|
|
||||||
soup = BeautifulSoup(html_content, 'html.parser')
|
|
||||||
for script in soup(["script", "style", "nav", "footer", "header"]):
|
|
||||||
script.extract()
|
|
||||||
|
|
||||||
text = soup.get_text(separator=' ', strip=True)
|
|
||||||
return text
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ Feil ved lasting av {url}: {e}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def analyze_with_gemini(text: str, club_name: str) -> dict:
|
|
||||||
"""Sender den kombinerte teksten til Gemini for å trekke ut og evt. summere priser."""
|
|
||||||
print(f" 🧠 Sender {len(text)} tegn til Gemini for analyse...")
|
|
||||||
|
|
||||||
prompt = f"""
|
|
||||||
Du er en ekspert på norske golfklubber. Din oppgave er å lese teksten hentet fra nettsidene til "{club_name}" og finne to spesifikke priser.
|
|
||||||
|
|
||||||
VIKTIG REGEL OM NORSK GOLF:
|
|
||||||
Mange steder er "Klubbkontingent/Medlemskap" og "Spillerett/Årskort" to forskjellige ting.
|
|
||||||
For å få spille ubegrenset (Fritt spill) MÅ man betale BEGGE DELER. Hvis du ser at prisene for kontingent og spillerett er oppgitt hver for seg, SKAL DU SUMMERE disse to summene og bruke totalen som "Standard pris".
|
|
||||||
|
|
||||||
ALDERSPREMISS FOR BEGGE PRISER:
|
|
||||||
Vi forutsetter at personen som skal ha medlemskap er en VOKSEN GOLFER PÅ MINST 35 ÅR. Du må ALDRI velge priser som gjelder for barn, junior, ung voksen (f.eks. 20-29 år), student eller senior/pensjonist.
|
|
||||||
|
|
||||||
DEFINISJONER DU MÅ FØLGE STRENGT:
|
|
||||||
1. "Standard medlemskap": Hva er TOTALPRISEN (inkludert evt. spillerett/årskort) for en voksen person (35+ år) for å spille SÅ MYE VEDKOMMENDE ØNSKER (Fritt spill) i år?
|
|
||||||
2. "Rimeligste alternativ": Det absolutt billigste alternativet FOR EN VOKSEN PERSON (35+ år) som gir medlemskap i klubben (golfkortet), forutsatt at man betaler greenfee for hver runde. (Ofte kalt Greenfeemedlem, Postkassemedlem, Fjernmedlem, eller kun "Klubbkontingent for voksne" uten spillerett).
|
|
||||||
|
|
||||||
TEKST FRA NETTSIDEN(E):
|
|
||||||
{text}
|
|
||||||
|
|
||||||
OPPGAVE:
|
|
||||||
Returner KUN et gyldig JSON-objekt med følgende struktur:
|
|
||||||
{{
|
|
||||||
"foreslatt_standard_navn": "Navn (eks: Hovedmedlem Voksen inkl. spillerett)",
|
|
||||||
"foreslatt_standard_pris": 1234,
|
|
||||||
"foreslatt_standard_kommentar": "Kort kommentar (eks: Måtte summere kontingent på 900 og årskort på 5000)",
|
|
||||||
"foreslatt_rimeligste_navn": "Navn (eks: Greenfeemedlemskap Voksen)",
|
|
||||||
"foreslatt_rimeligste_pris": 500,
|
|
||||||
"ai_begrunnelse": "Kort forklaring på utregningen din."
|
|
||||||
}}
|
|
||||||
Merk: Prisene SKAL være tall (integer), ikke tekst. Sett til null hvis du ikke finner det.
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = model.generate_content(prompt)
|
|
||||||
raw_response = response.text.strip()
|
|
||||||
|
|
||||||
if raw_response.startswith("```json"):
|
|
||||||
raw_response = raw_response[7:]
|
|
||||||
if raw_response.endswith("```"):
|
|
||||||
raw_response = raw_response[:-3]
|
|
||||||
|
|
||||||
return json.loads(raw_response.strip())
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ AI-analyse feilet: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def run_scraper(facility_ids=None):
|
|
||||||
print("🚀 Starter Medlemskaps-skraperen (Støtter multi-URL)...")
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
|
|
||||||
try:
|
|
||||||
query = "SELECT id, name, medlemskap_url FROM facilities WHERE medlemskap_url IS NOT NULL AND medlemskap_url != ''"
|
|
||||||
if facility_ids:
|
|
||||||
query += f" AND id IN ({','.join(map(str, facility_ids))})"
|
|
||||||
|
|
||||||
facilities = await conn.fetch(query)
|
|
||||||
print(f"📋 Fant {len(facilities)} anlegg å skrape.")
|
|
||||||
|
|
||||||
async with async_playwright() as p:
|
|
||||||
browser = await p.chromium.launch(headless=True)
|
|
||||||
|
|
||||||
for facility in facilities:
|
|
||||||
fac_id = facility['id']
|
|
||||||
name = facility['name']
|
|
||||||
urls_raw = facility['medlemskap_url']
|
|
||||||
|
|
||||||
print(f"\n▶️ Behandler: {name} (ID: {fac_id})")
|
|
||||||
|
|
||||||
# Sjekker om det er flere URL-er adskilt med komma
|
|
||||||
urls = [u.strip() for u in urls_raw.split(',')]
|
|
||||||
combined_text = ""
|
|
||||||
|
|
||||||
for idx, url in enumerate(urls, 1):
|
|
||||||
page_text = await fetch_page_text(url, browser)
|
|
||||||
if page_text:
|
|
||||||
combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}"
|
|
||||||
|
|
||||||
if len(combined_text) < 50:
|
|
||||||
print(" ⚠️ Fant for lite tekst, hopper over.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Kutter teksten for å ikke overbelaste Gemini (ca 25000 tegn maks)
|
|
||||||
draft_data = analyze_with_gemini(combined_text[:25000], name)
|
|
||||||
|
|
||||||
if not draft_data:
|
|
||||||
continue
|
|
||||||
|
|
||||||
print(f" ✅ AI foreslår: Standard: {draft_data.get('foreslatt_standard_pris')} | Rimeligste: {draft_data.get('foreslatt_rimeligste_pris')}")
|
|
||||||
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE facilities
|
|
||||||
SET membership_draft = $1::jsonb
|
|
||||||
WHERE id = $2
|
|
||||||
""", json.dumps(draft_data), fac_id)
|
|
||||||
|
|
||||||
print(" 💾 Utkast lagret i databasen!")
|
|
||||||
|
|
||||||
await browser.close()
|
|
||||||
|
|
||||||
finally:
|
|
||||||
await conn.close()
|
|
||||||
print("\n🏁 Skraping fullført.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(description="Skrap medlemskapspriser via AI.")
|
|
||||||
parser.add_argument("--ids", type=str, help="Kommaseparert liste med facility IDs (eks: 1,5,12)")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
ids_to_scrape = None
|
|
||||||
if args.ids:
|
|
||||||
ids_to_scrape = [int(x.strip()) for x in args.ids.split(",")]
|
|
||||||
|
|
||||||
asyncio.run(run_scraper(ids_to_scrape))
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
import httpx
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
import re
|
|
||||||
import json
|
|
||||||
|
|
||||||
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
|
||||||
|
|
||||||
def clean_name(text):
|
|
||||||
if not text: return ""
|
|
||||||
s = text.lower().replace("golfklubb", "").replace("gk", "").replace("par3golf", "").replace(" & ", "").strip()
|
|
||||||
return re.sub(r'[^a-z]', '', s)
|
|
||||||
|
|
||||||
def clean_nsg_content(text):
|
|
||||||
"""Fjerner doble linjeskift og kutter teksten før websidemenyen starter"""
|
|
||||||
if not text: return ""
|
|
||||||
# Fjern alt som ligner på bunn-menyen til NSG
|
|
||||||
garbage_phrases = [
|
|
||||||
"Klubbens hjemmeside", "Resultatlister i Golfbox", "Livescoring",
|
|
||||||
"Scoreinntasting", "Lagserie", "Turneringer", "Innmelding"
|
|
||||||
]
|
|
||||||
for phrase in garbage_phrases:
|
|
||||||
text = text.split(phrase)[0]
|
|
||||||
|
|
||||||
# Rydd opp i linjeskift og doble mellomrom
|
|
||||||
text = text.replace('\r', '').replace('\n', ' ')
|
|
||||||
text = re.sub(r'\s+', ' ', text).strip()
|
|
||||||
return text
|
|
||||||
|
|
||||||
async def get_nsg_links(client):
|
|
||||||
links = []
|
|
||||||
urls = ["https://seniorgolf.no/lojalitetskort-sitemap.xml", "https://seniorgolf.no/fordelskortet/"]
|
|
||||||
for url in urls:
|
|
||||||
try:
|
|
||||||
resp = await client.get(url)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
if ".xml" in url:
|
|
||||||
found = re.findall(r'<loc>(https://seniorgolf.no/lojalitetskort/.*?/)</loc>', resp.text)
|
|
||||||
if found: return list(set(found))
|
|
||||||
else:
|
|
||||||
soup = BeautifulSoup(resp.text, 'html.parser')
|
|
||||||
links.extend([l['href'] for l in soup.select('a[href*="/lojalitetskort/"]')])
|
|
||||||
except: continue
|
|
||||||
return list(set(links))
|
|
||||||
|
|
||||||
async def scrape_nsg():
|
|
||||||
print("🚀 Starter NSG VASKEMASKIN v3.8...")
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
facilities = await conn.fetch("SELECT id, name FROM facilities")
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=20.0, headers={'User-Agent': 'Mozilla/5.0'}) as client:
|
|
||||||
all_nsg_links = await get_nsg_links(client)
|
|
||||||
link_map = {clean_name(l.split('/')[-2].replace('-', ' ')): l for l in all_nsg_links}
|
|
||||||
|
|
||||||
matches_found = 0
|
|
||||||
for fac in facilities:
|
|
||||||
fac_name_clean = clean_name(fac['name'])
|
|
||||||
match_url = link_map.get(fac_name_clean)
|
|
||||||
|
|
||||||
if not match_url:
|
|
||||||
for slug, url in link_map.items():
|
|
||||||
if fac_name_clean in slug or slug in fac_name_clean:
|
|
||||||
match_url = url
|
|
||||||
break
|
|
||||||
|
|
||||||
if match_url:
|
|
||||||
try:
|
|
||||||
f_resp = await client.get(match_url)
|
|
||||||
f_soup = BeautifulSoup(f_resp.text, 'html.parser')
|
|
||||||
|
|
||||||
# Finn hovedinnholdet i stedet for hele siden for å unngå menyer
|
|
||||||
main_content = f_soup.find('div', {'class': 'entry-content'}) or f_soup
|
|
||||||
text = main_content.get_text()
|
|
||||||
|
|
||||||
st = re.search(r"Starttider:?\s*(.*?)(?=Greenfee|Booking|Adresse|Kontakt|$)", text, re.S | re.I)
|
|
||||||
gf = re.search(r"Greenfee:?\s*(.*?)(?=Booking|Adresse|Kontakt|$)", text, re.S | re.I)
|
|
||||||
bk = re.search(r"Booking:?\s*(.*?)(?=Adresse|Kontakt|$)", text, re.S | re.I)
|
|
||||||
|
|
||||||
nsg_data = {
|
|
||||||
"url": match_url,
|
|
||||||
"starttider": clean_nsg_content(st.group(1)) if st else "Se nettside",
|
|
||||||
"greenfee": clean_nsg_content(gf.group(1)) if gf else "Se nettside",
|
|
||||||
"booking": clean_nsg_content(bk.group(1)) if bk else "Se nettside"
|
|
||||||
}
|
|
||||||
|
|
||||||
await conn.execute("UPDATE facilities SET nsg_data = $1 WHERE id = $2", json.dumps(nsg_data), fac['id'])
|
|
||||||
print(f"✅ Vasket & Lagret: {fac['name']}")
|
|
||||||
matches_found += 1
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
await conn.close()
|
|
||||||
print(f"\n🎉 Vask ferdig! {matches_found} baner er nå 100% klare.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(scrape_nsg())
|
|
||||||
|
|
@ -1,332 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import os
|
|
||||||
import asyncpg
|
|
||||||
import smtplib
|
|
||||||
import re
|
|
||||||
import argparse
|
|
||||||
from datetime import datetime
|
|
||||||
from email.mime.text import MIMEText
|
|
||||||
from email.mime.multipart import MIMEMultipart
|
|
||||||
from playwright.async_api import async_playwright
|
|
||||||
try:
|
|
||||||
from playwright_stealth import stealth_async as apply_stealth
|
|
||||||
except ImportError:
|
|
||||||
from playwright_stealth import stealth as apply_stealth
|
|
||||||
|
|
||||||
from google import genai
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# KONFIGURERER GEMINI AI (NY SDK)
|
|
||||||
# ==========================================
|
|
||||||
client = genai.Client()
|
|
||||||
|
|
||||||
async def ask_llm_status(text, course_name, is_single_course, ai_instruction=None):
|
|
||||||
if is_single_course:
|
|
||||||
bane_instruks = "Finn den generelle banestatusen for dette golfanlegget. Se bort fra spesifikke banenavn, da anlegget kun har én bane."
|
|
||||||
else:
|
|
||||||
bane_instruks = f'Finn banestatusen SPESIFIKT for banen som heter/omtales som: "{course_name}".'
|
|
||||||
|
|
||||||
ekstra_tekst = f"\n!!! VIKTIG EKSTRA-INSTRUKS FRA ADMIN (DENNE OVERSTYRER ALLE ANDRE REGLER) !!!:\n{ai_instruction}\n" if ai_instruction else ""
|
|
||||||
|
|
||||||
prompt = f"""
|
|
||||||
Du er en ekspert på å lese norske golfklubbers nettsider for å finne banestatus.
|
|
||||||
{bane_instruks}
|
|
||||||
{ekstra_tekst}
|
|
||||||
Svar KUN med nøyaktig ETT av disse ordene:
|
|
||||||
- aapen (hvis banen er åpen/sommergreener)
|
|
||||||
- stengt (hvis banen er lukket/stengt/frost/snø)
|
|
||||||
- aapen_med_vintergreener (hvis det spilles på vintergreener)
|
|
||||||
- aapner_snart (hvis den åpner om kort tid)
|
|
||||||
- stenger_snart (hvis den stenger for sesongen om kort tid)
|
|
||||||
- under_utvikling (hvis den er under utvikling)
|
|
||||||
- nedlagt (hvis den er nedlagt)
|
|
||||||
- ukjent (hvis du ikke finner noe info om banen i teksten)
|
|
||||||
|
|
||||||
Tekst fra nettsiden:
|
|
||||||
{text[:15000]}
|
|
||||||
"""
|
|
||||||
|
|
||||||
print("\n" + "="*60)
|
|
||||||
print(f"🤖 SENDER PROMPT TIL GEMINI FOR: '{course_name}'")
|
|
||||||
print(f"👉 STANDARD-INSTRUKS: {bane_instruks}")
|
|
||||||
if ai_instruction:
|
|
||||||
print(f"👉 ADMIN-HVISKER: {ai_instruction}")
|
|
||||||
clean_text_sample = " ".join(text.split())[:250]
|
|
||||||
print(f"👉 TEKST FRA NETTSIDEN (utdrag): '{clean_text_sample}...'")
|
|
||||||
print("="*60 + "\n")
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await client.aio.models.generate_content(
|
|
||||||
model='gemini-2.5-flash',
|
|
||||||
contents=prompt
|
|
||||||
)
|
|
||||||
svar = response.text.strip().lower()
|
|
||||||
|
|
||||||
print(f" 🧠 GEMINI RÅ-SVAR: '{svar}'")
|
|
||||||
|
|
||||||
# --- NYTT: SORTERT SIKKERHETSFILTER ---
|
|
||||||
gyldige_svar = [
|
|
||||||
"aapen_med_vintergreener",
|
|
||||||
"aapner_snart",
|
|
||||||
"stenger_snart",
|
|
||||||
"under_utvikling",
|
|
||||||
"nedlagt",
|
|
||||||
"stengt",
|
|
||||||
"aapen",
|
|
||||||
"ukjent"
|
|
||||||
]
|
|
||||||
|
|
||||||
for gyldig in gyldige_svar:
|
|
||||||
if gyldig in svar:
|
|
||||||
return gyldig
|
|
||||||
return "ukjent"
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Gemini Feil: {e}")
|
|
||||||
return "ukjent"
|
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# EKSISTERENDE LOGIKK FOR MANUELL SCRAPING
|
|
||||||
# ==========================================
|
|
||||||
def clean_text(text):
|
|
||||||
return re.sub(r'[^a-zA-Z0-9æøåÆØÅ]', '', text).lower()
|
|
||||||
|
|
||||||
def interpret_status(text, keyword=None):
|
|
||||||
t_raw = text.lower()
|
|
||||||
|
|
||||||
if keyword:
|
|
||||||
k_clean = clean_text(keyword)
|
|
||||||
if k_clean not in clean_text(t_raw):
|
|
||||||
return "NOT_FOUND"
|
|
||||||
|
|
||||||
parts = re.split(re.escape(keyword), t_raw, flags=re.IGNORECASE)
|
|
||||||
if len(parts) > 1:
|
|
||||||
t_raw = parts[1][:150]
|
|
||||||
else:
|
|
||||||
t_raw = t_raw[-200:]
|
|
||||||
|
|
||||||
if any(word in t_raw for word in ["stengt", "lukket", "frost", "snø", "is", "closed", "stenger"]):
|
|
||||||
return "stengt"
|
|
||||||
if any(word in t_raw for word in ["vintergreen", "vintergrønn", "vinter"]):
|
|
||||||
return "aapen_med_vintergreener"
|
|
||||||
if any(word in t_raw for word in ["snart", "åpner kl"]):
|
|
||||||
return "aapner_snart"
|
|
||||||
if any(word in t_raw for word in ["åpen", "åpent", "aapen", "open"]):
|
|
||||||
return "aapen"
|
|
||||||
return "ukjent"
|
|
||||||
|
|
||||||
def send_report(changes, warnings, successes):
|
|
||||||
if not changes and not warnings and not successes: return
|
|
||||||
subject = f"TeeOff Banestatus Rapport - {datetime.now().strftime('%d.%m.%Y')}"
|
|
||||||
|
|
||||||
body = "BANESTATUS RAPPORT\n" + "="*30 + "\n\n"
|
|
||||||
|
|
||||||
if changes: body += "✅ OPPDATERINGER:\n" + "\n".join(changes) + "\n\n"
|
|
||||||
if warnings: body += "⚠️ MERKNADER / ADVARSLER:\n" + "\n".join(warnings) + "\n\n"
|
|
||||||
if successes: body += "🆗 VELLYKKEDE SJEKKER (INGEN ENDRING):\n" + "\n".join(successes) + "\n"
|
|
||||||
|
|
||||||
msg = MIMEMultipart()
|
|
||||||
msg['From'] = os.getenv("SMTP_USER")
|
|
||||||
msg['To'] = os.getenv("EMAIL_TO")
|
|
||||||
msg['Subject'] = subject
|
|
||||||
msg.attach(MIMEText(body, 'plain'))
|
|
||||||
try:
|
|
||||||
with smtplib.SMTP_SSL(os.getenv("SMTP_SERVER"), int(os.getenv("SMTP_PORT"))) as server:
|
|
||||||
server.login(os.getenv("SMTP_USER"), os.getenv("SMTP_PASS"))
|
|
||||||
server.send_message(msg)
|
|
||||||
print("✅ Rapport sendt på e-post.")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ E-post feil: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# HOVEDMOTOR
|
|
||||||
# ==========================================
|
|
||||||
async def run_daily_scraping(facility_ids=None):
|
|
||||||
print(f"🚀 Starter sjekk {datetime.now().strftime('%H:%M:%S')}...")
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
|
|
||||||
if facility_ids:
|
|
||||||
print(f"📌 Kjører skraping KUN for anlegg-ID(er): {facility_ids}")
|
|
||||||
facilities = await conn.fetch(
|
|
||||||
"SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method, ai_instruction FROM facilities WHERE scrape_status_url IS NOT NULL AND id = ANY($1::int[])",
|
|
||||||
facility_ids
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print("🌍 Kjører skraping for ALLE anlegg med scrape_status_url...")
|
|
||||||
facilities = await conn.fetch(
|
|
||||||
"SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method, ai_instruction FROM facilities WHERE scrape_status_url IS NOT NULL"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not facilities:
|
|
||||||
print("⚠️ Fant ingen anlegg å skrape.")
|
|
||||||
await conn.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
changes, warnings, successes = [], [], []
|
|
||||||
|
|
||||||
async with async_playwright() as p:
|
|
||||||
browser = await p.chromium.launch(headless=True)
|
|
||||||
context = await browser.new_context()
|
|
||||||
|
|
||||||
for f in facilities:
|
|
||||||
method = f.get('scrape_method') or 'css_selector'
|
|
||||||
|
|
||||||
if method == 'manual':
|
|
||||||
successes.append(f"⏸️ {f['name']}: Hoppet over (Manuell overstyring)")
|
|
||||||
print(f" ⏸️ Hopper over skraping av {f['name']} (Satt til Manuell)")
|
|
||||||
continue
|
|
||||||
|
|
||||||
page = await context.new_page()
|
|
||||||
try: await apply_stealth(page)
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
print(f"🔍 Besøker {f['name']} (Metode: {method})...")
|
|
||||||
await page.goto(f['scrape_status_url'], timeout=60000, wait_until="domcontentloaded")
|
|
||||||
await page.wait_for_timeout(3000)
|
|
||||||
|
|
||||||
full_text = ""
|
|
||||||
|
|
||||||
if method == 'css_selector':
|
|
||||||
element = page.locator(f['scrape_status_selector']).first
|
|
||||||
if await element.count() == 0:
|
|
||||||
warnings.append(f"❌ {f['name']}: Fant ikke CSS-elementet '{f['scrape_status_selector']}'")
|
|
||||||
continue
|
|
||||||
full_text = await element.inner_text()
|
|
||||||
|
|
||||||
elif method == 'iframe_golfbox':
|
|
||||||
frame = page.frame_locator('iframe[src*="golfbox"]')
|
|
||||||
element = frame.locator(f['scrape_status_selector']).first
|
|
||||||
if await element.count() == 0:
|
|
||||||
warnings.append(f"❌ {f['name']}: Fant ikke elementet '{f['scrape_status_selector']}' i iframen")
|
|
||||||
continue
|
|
||||||
full_text = await element.inner_text()
|
|
||||||
|
|
||||||
elif method == 'click_then_css':
|
|
||||||
parts = f['scrape_status_selector'].split('||')
|
|
||||||
if len(parts) != 2:
|
|
||||||
warnings.append(f"❌ {f['name']}: Ugyldig selector for click_then_css (mangler ||)")
|
|
||||||
continue
|
|
||||||
|
|
||||||
btn_selector, text_selector = parts
|
|
||||||
btn = page.locator(btn_selector).first
|
|
||||||
if await btn.count() == 0:
|
|
||||||
warnings.append(f"❌ {f['name']}: Fant ikke knappen å klikke på: '{btn_selector}'")
|
|
||||||
continue
|
|
||||||
|
|
||||||
await btn.click(force=True)
|
|
||||||
await page.wait_for_timeout(2000)
|
|
||||||
|
|
||||||
element = page.locator(text_selector).first
|
|
||||||
if await element.count() == 0:
|
|
||||||
warnings.append(f"❌ {f['name']}: Fant ikke tekstboksen '{text_selector}' etter klikk")
|
|
||||||
continue
|
|
||||||
|
|
||||||
full_text = await element.inner_text()
|
|
||||||
|
|
||||||
elif method == 'llm_parse':
|
|
||||||
print(" 🖱️ Leter etter knapper å klikke på for å avdekke skjult tekst...")
|
|
||||||
knapper = await page.get_by_text(re.compile(r"banestatus|dagens status|se status|se dagens status|baneinfo|\bstatus\b", re.IGNORECASE)).all()
|
|
||||||
|
|
||||||
klikk_count = 0
|
|
||||||
for knapp in knapper:
|
|
||||||
try:
|
|
||||||
if await knapp.is_visible():
|
|
||||||
await knapp.click(timeout=2000, force=True)
|
|
||||||
klikk_count += 1
|
|
||||||
await page.wait_for_timeout(2000)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if klikk_count > 0:
|
|
||||||
print(f" 🎯 Tvangsklikket på {klikk_count} status-knapp(er)! Venter ekstra på at innholdet laster...")
|
|
||||||
await page.wait_for_timeout(2000)
|
|
||||||
else:
|
|
||||||
print(" ⚠️ Fant ingen knapper å klikke på.")
|
|
||||||
|
|
||||||
# --- NYTT: HENTER OGSÅ SKJULT TEKST (For Scangolf megamenyer) ---
|
|
||||||
element = page.locator("body").first
|
|
||||||
if await element.count() == 0:
|
|
||||||
warnings.append(f"❌ {f['name']}: Klarte ikke å lese siden for AI-tolkning")
|
|
||||||
continue
|
|
||||||
|
|
||||||
synlig_tekst = await element.inner_text() or ""
|
|
||||||
skjult_tekst = await element.text_content() or ""
|
|
||||||
|
|
||||||
# Slår sammen all tekst slik at Gemini får med seg menyer som er gjemt med CSS
|
|
||||||
råtekst = synlig_tekst + " " + skjult_tekst
|
|
||||||
full_text = " ".join(råtekst.split())
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
|
|
||||||
else:
|
|
||||||
warnings.append(f"⚠️ {f['name']}: Ukjent skrapemetode i databasen: '{method}'")
|
|
||||||
continue
|
|
||||||
|
|
||||||
await conn.execute("UPDATE facilities SET status_updated_at = CURRENT_DATE WHERE id = $1", f['id'])
|
|
||||||
|
|
||||||
courses = await conn.fetch("SELECT id, name, status, scrape_keyword FROM courses WHERE facility_id = $1", f['id'])
|
|
||||||
|
|
||||||
is_single_course = len(courses) == 1
|
|
||||||
|
|
||||||
for c in courses:
|
|
||||||
old_status = c['status'] or "ukjent"
|
|
||||||
|
|
||||||
if method == 'llm_parse':
|
|
||||||
print(f" 🤖 Spør Gemini om status for '{c['name']}' (Singelbane: {is_single_course})...")
|
|
||||||
new_status = await ask_llm_status(full_text, c['name'], is_single_course, f.get('ai_instruction'))
|
|
||||||
|
|
||||||
print(" ⏳ Tar 5 sekunders pause for å spare Gemini-kvoten...")
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
else:
|
|
||||||
new_status = interpret_status(full_text, c['scrape_keyword'])
|
|
||||||
|
|
||||||
if new_status == "NOT_FOUND":
|
|
||||||
warnings.append(f"❓ {f['name']} ({c['name']}): Fant ikke søkeordet '{c['scrape_keyword']}' i teksten.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# --- OPPDATERT LOGIKK (Fikser logg-buggen) ---
|
|
||||||
if new_status == "ukjent":
|
|
||||||
# Sikkerhetsnettet slår inn: Vi beholder gammel status!
|
|
||||||
warnings.append(f"⚠️ {f['name']} ({c['name']}): Fant ikke status. Beholder '{old_status.upper()}'.")
|
|
||||||
print(f" 🟡 KONKLUSJON: Fant ikke status i teksten (Sikkerhetsnett). Beholder gammel status ({old_status.upper()}).")
|
|
||||||
elif new_status != old_status:
|
|
||||||
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", new_status, c['id'])
|
|
||||||
changes.append(f"🔹 {f['name']} ({c['name']}): {old_status.upper()} ➔ {new_status.upper()}")
|
|
||||||
print(f" 🟢 KONKLUSJON: Status endret fra {old_status.upper()} til {new_status.upper()}")
|
|
||||||
else:
|
|
||||||
successes.append(f"✅ {f['name']} ({c['name']}): {new_status.upper()}")
|
|
||||||
print(f" ⚪ KONKLUSJON: Ingen endring. Banen er fortsatt {old_status.upper()}")
|
|
||||||
# ---------------------------------------------
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
err_msg = str(e).split('\n')[0]
|
|
||||||
warnings.append(f"🔥 {f['name']}: Feil under skraping: {err_msg}")
|
|
||||||
finally:
|
|
||||||
await page.close()
|
|
||||||
|
|
||||||
await browser.close()
|
|
||||||
|
|
||||||
await conn.close()
|
|
||||||
send_report(changes, warnings, successes)
|
|
||||||
print("🏁 Ferdig.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(description="TeeOff Status Scraper")
|
|
||||||
parser.add_argument("--ids", type=str, help="Kommaseparert liste med anleggs-IDer", default=None)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
facility_ids_list = None
|
|
||||||
if args.ids:
|
|
||||||
try:
|
|
||||||
facility_ids_list = [int(id_str.strip()) for id_str in args.ids.split(",") if id_str.strip()]
|
|
||||||
except ValueError:
|
|
||||||
print("❌ Feil format på --ids. Må være kommaseparerte tall, f.eks: 1,4,12")
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
asyncio.run(run_daily_scraping(facility_ids_list))
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
"""
|
|
||||||
TEE OFF - VEIEN TIL GOLF (VTG) SKRAPER MED GEMINI AI
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
Henter pris, beskrivelse (inkl. lånekøller/medlemskap) og kursdatoer fra VTG-sider.
|
|
||||||
Støtter kommaseparerte URL-er.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import argparse
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from playwright.async_api import async_playwright
|
|
||||||
import google.generativeai as genai
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
|
||||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
|
||||||
|
|
||||||
if not GEMINI_API_KEY:
|
|
||||||
raise ValueError("🚨 GEMINI_API_KEY mangler i .env filen!")
|
|
||||||
|
|
||||||
genai.configure(api_key=GEMINI_API_KEY)
|
|
||||||
model = genai.GenerativeModel('gemini-2.5-flash')
|
|
||||||
|
|
||||||
async def fetch_page_text(url: str, browser) -> str:
|
|
||||||
url = url.strip()
|
|
||||||
if not url.startswith("http"):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
print(f" 🌐 Laster inn: {url}")
|
|
||||||
try:
|
|
||||||
page = await browser.new_page()
|
|
||||||
await page.goto(url, wait_until="domcontentloaded", timeout=15000)
|
|
||||||
html_content = await page.content()
|
|
||||||
await page.close()
|
|
||||||
|
|
||||||
soup = BeautifulSoup(html_content, 'html.parser')
|
|
||||||
for script in soup(["script", "style", "nav", "footer", "header"]):
|
|
||||||
script.extract()
|
|
||||||
|
|
||||||
return soup.get_text(separator=' ', strip=True)
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ Feil ved lasting av {url}: {e}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def analyze_vtg_with_gemini(text: str, club_name: str) -> dict:
|
|
||||||
print(f" 🧠 Sender {len(text)} tegn til Gemini for VTG-analyse...")
|
|
||||||
|
|
||||||
prompt = f"""
|
|
||||||
Du er en ekspert på norske golfklubber. Din oppgave er å lese en lang tekst fra nettsidene til "{club_name}" og koke dette ned til essensen om deres "Veien til Golf" (VTG) nybegynnerkurs.
|
|
||||||
|
|
||||||
OPPGAVER:
|
|
||||||
1. Finn standardprisen for VTG-kurset for en vanlig voksen person. (Returner KUN tallet).
|
|
||||||
2. Skriv en KOMPRIMERT, selgende beskrivelse (maks 3-4 setninger). Du MÅ inkludere informasjon om:
|
|
||||||
- Er lån av køller/utstyr inkludert i kurset?
|
|
||||||
- Inkluderer prisen et medlemskap/spillerett i klubben (og ev. for hvor lenge)?
|
|
||||||
- Hva er omfanget? (F.eks. "12 timer praksis pluss e-læring").
|
|
||||||
Ignorer uvesentlig støy og lange historiske utgreiinger.
|
|
||||||
3. Finn alle kommende kursdatoer. Finn startdato/sluttdato for hvert kurs, og noter status ("Ledig", "Fulltegnet", "Venteliste").
|
|
||||||
|
|
||||||
TEKST FRA NETTSIDEN:
|
|
||||||
{text}
|
|
||||||
|
|
||||||
OPPGAVE:
|
|
||||||
Returner KUN et gyldig JSON-objekt med nøyaktig følgende struktur:
|
|
||||||
{{
|
|
||||||
"foreslatt_vtg_pris": 1990,
|
|
||||||
"foreslatt_vtg_beskrivelse": "Kurset går over 12 timer inkludert obligatorisk e-læring. Lån av golfkøller er inkludert under hele kurset, og prisen gir deg også fritt spill og medlemskap ut året.",
|
|
||||||
"foreslatt_vtg_datoer": [
|
|
||||||
{{"dato": "12.-14. mai", "status": "Fulltegnet"}},
|
|
||||||
{{"dato": "5.-7. juni", "status": "Ledig"}}
|
|
||||||
],
|
|
||||||
"ai_begrunnelse": "Fant voksenpris på 1990,-. Teksten nevnte eksplisitt at medlemskap ut året er med i prisen, og at man får låne utstyr."
|
|
||||||
}}
|
|
||||||
Merk: Sett foreslatt_vtg_pris til null (null) hvis du ikke finner den. Hvis du ikke finner datoer, la listen være tom [].
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = model.generate_content(prompt)
|
|
||||||
raw_response = response.text.strip()
|
|
||||||
|
|
||||||
if raw_response.startswith("```json"):
|
|
||||||
raw_response = raw_response[7:]
|
|
||||||
if raw_response.endswith("```"):
|
|
||||||
raw_response = raw_response[:-3]
|
|
||||||
|
|
||||||
return json.loads(raw_response.strip())
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ AI-analyse feilet: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def run_vtg_scraper(facility_ids=None):
|
|
||||||
print("🚀 Starter Veien til Golf (VTG) skraperen...")
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
|
|
||||||
try:
|
|
||||||
query = "SELECT id, name, vtg_lenke FROM facilities WHERE vtg_lenke IS NOT NULL AND vtg_lenke != ''"
|
|
||||||
if facility_ids:
|
|
||||||
query += f" AND id IN ({','.join(map(str, facility_ids))})"
|
|
||||||
|
|
||||||
facilities = await conn.fetch(query)
|
|
||||||
print(f"📋 Fant {len(facilities)} anlegg å skrape.")
|
|
||||||
|
|
||||||
async with async_playwright() as p:
|
|
||||||
browser = await p.chromium.launch(headless=True)
|
|
||||||
|
|
||||||
for facility in facilities:
|
|
||||||
fac_id = facility['id']
|
|
||||||
name = facility['name']
|
|
||||||
urls_raw = facility['vtg_lenke']
|
|
||||||
|
|
||||||
print(f"\n▶️ Behandler VTG for: {name} (ID: {fac_id})")
|
|
||||||
|
|
||||||
urls = [u.strip() for u in urls_raw.split(',')]
|
|
||||||
combined_text = ""
|
|
||||||
|
|
||||||
for idx, url in enumerate(urls, 1):
|
|
||||||
page_text = await fetch_page_text(url, browser)
|
|
||||||
if page_text:
|
|
||||||
combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}"
|
|
||||||
|
|
||||||
if len(combined_text) < 50:
|
|
||||||
print(" ⚠️ Fant for lite tekst, hopper over.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
draft_data = analyze_vtg_with_gemini(combined_text[:25000], name)
|
|
||||||
|
|
||||||
if not draft_data:
|
|
||||||
continue
|
|
||||||
|
|
||||||
print(f" ✅ AI fant pris: {draft_data.get('foreslatt_vtg_pris')}, og {len(draft_data.get('foreslatt_vtg_datoer', []))} datoer.")
|
|
||||||
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE facilities
|
|
||||||
SET vtg_draft = $1::jsonb
|
|
||||||
WHERE id = $2
|
|
||||||
""", json.dumps(draft_data), fac_id)
|
|
||||||
|
|
||||||
print(" 💾 VTG-utkast lagret i databasen!")
|
|
||||||
|
|
||||||
await browser.close()
|
|
||||||
|
|
||||||
finally:
|
|
||||||
await conn.close()
|
|
||||||
print("\n🏁 Skraping fullført.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(description="Skrap VTG via AI.")
|
|
||||||
parser.add_argument("--ids", type=str, help="Kommaseparert liste med facility IDs (eks: 1,5,12)")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
ids_to_scrape = None
|
|
||||||
if args.ids:
|
|
||||||
ids_to_scrape = [int(x.strip()) for x in args.ids.split(",")]
|
|
||||||
|
|
||||||
asyncio.run(run_vtg_scraper(ids_to_scrape))
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
import asyncio, asyncpg, urllib.request, json
|
|
||||||
|
|
||||||
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
|
||||||
# Vi fjerner acf_format=standard da rå-feltnavnene er tryggere her
|
|
||||||
WP_API_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100"
|
|
||||||
|
|
||||||
def decode_html(text):
|
|
||||||
if not text: return ""
|
|
||||||
return str(text).replace('&', '&').replace('&', '&').replace(' ', ' ').strip()
|
|
||||||
|
|
||||||
async def run_greenfee_sync():
|
|
||||||
print("🎯 Starter GREENFEE-SYNC v1.2 (Basert på rå-API mapping)...")
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
page = 1
|
|
||||||
total_updated = 0
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(f"{WP_API_URL}&page={page}", headers={'User-Agent': 'TeeOff-Sync'})
|
|
||||||
with urllib.request.urlopen(req) as response:
|
|
||||||
data = json.loads(response.read().decode())
|
|
||||||
except: break
|
|
||||||
if not data: break
|
|
||||||
|
|
||||||
for post in data:
|
|
||||||
slug = post['slug']
|
|
||||||
acf = post.get('acf', {})
|
|
||||||
|
|
||||||
# Henter banenavn for å gruppere riktig
|
|
||||||
bane_1_navn = acf.get('navn_pa_hovedbane') or "Hovedbanen"
|
|
||||||
bane_2_navn = acf.get('navn_pa_sekundar_bane') or "Bane 2"
|
|
||||||
|
|
||||||
final_greenfee = []
|
|
||||||
|
|
||||||
# --- MAPPER BANE 1 (Voksne + Junior) ---
|
|
||||||
voksne_1 = acf.get('greenfee_-_voksne') or []
|
|
||||||
junior_1 = acf.get('greenfee_-_junior') or []
|
|
||||||
|
|
||||||
for i, item in enumerate(voksne_1):
|
|
||||||
row = {
|
|
||||||
"banenavn": bane_1_navn,
|
|
||||||
"priskategori": item.get('priskategori'),
|
|
||||||
"pris_voksne": item.get('pris_voksne')
|
|
||||||
}
|
|
||||||
# Legger til juniorpris hvis den finnes på samme index
|
|
||||||
if i < len(junior_1):
|
|
||||||
row["pris_junior"] = junior_1[i].get('pris_junior')
|
|
||||||
final_greenfee.append(row)
|
|
||||||
|
|
||||||
# --- MAPPER BANE 2 (Voksne + Junior) ---
|
|
||||||
voksne_2 = acf.get('greenfee_-_voksne_bane_to') or []
|
|
||||||
junior_2 = acf.get('greenfee_-_junior_bane_to') or []
|
|
||||||
|
|
||||||
for i, item in enumerate(voksne_2):
|
|
||||||
row = {
|
|
||||||
"banenavn": bane_2_navn,
|
|
||||||
"priskategori": item.get('priskategori_bane_to'),
|
|
||||||
"pris_voksne": item.get('pris_voksne_bane_to')
|
|
||||||
}
|
|
||||||
if i < len(junior_2):
|
|
||||||
row["pris_junior"] = junior_2[i].get('pris_junior_bane_to')
|
|
||||||
final_greenfee.append(row)
|
|
||||||
|
|
||||||
# Henter krav (Gjeste_krav)
|
|
||||||
reqs = decode_html(acf.get('krav_til_gjestespillere'))
|
|
||||||
|
|
||||||
if final_greenfee:
|
|
||||||
await conn.execute('''
|
|
||||||
UPDATE facilities SET greenfee = $1::jsonb, guest_requirements = $2 WHERE slug = $3
|
|
||||||
''', json.dumps(final_greenfee), reqs, slug)
|
|
||||||
print(f"✅ {slug}: Importerte {len(final_greenfee)} prisrader for {bane_1_navn}/{bane_2_navn}")
|
|
||||||
total_updated += 1
|
|
||||||
|
|
||||||
page += 1
|
|
||||||
await conn.close()
|
|
||||||
print(f"\n✨ Ferdig! Oppdaterte priser for {total_updated} anlegg.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(run_greenfee_sync())
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from playwright.async_api import async_playwright
|
|
||||||
from google import genai
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
# Den nye pakken henter automatisk GEMINI_API_KEY fra .env-filen din
|
|
||||||
client = genai.Client()
|
|
||||||
|
|
||||||
async def ask_llm_status(text, course_name, is_single_course):
|
|
||||||
if is_single_course:
|
|
||||||
bane_instruks = "Finn den generelle banestatusen for dette golfanlegget. Se bort fra spesifikke banenavn, da anlegget kun har én bane."
|
|
||||||
else:
|
|
||||||
bane_instruks = f'Finn banestatusen SPESIFIKT for banen som heter/omtales som: "{course_name}".'
|
|
||||||
|
|
||||||
prompt = f"""
|
|
||||||
Du er en ekspert på å lese norske golfklubbers nettsider for å finne banestatus.
|
|
||||||
{bane_instruks}
|
|
||||||
Svar KUN med nøyaktig ETT av disse ordene:
|
|
||||||
- aapen (hvis banen er åpen/sommergreener)
|
|
||||||
- stengt (hvis banen er lukket/stengt/frost/snø)
|
|
||||||
- aapen_med_vintergreener (hvis det spilles på vintergreener)
|
|
||||||
- aapner_snart (hvis den åpner om kort tid)
|
|
||||||
- stenger_snart (hvis den stenger for sesongen om kort tid)
|
|
||||||
- under_utvikling (hvis den er under utvikling)
|
|
||||||
- nedlagt (hvis den er nedlagt)
|
|
||||||
- ukjent (hvis du ikke finner noe info om banen i teksten)
|
|
||||||
|
|
||||||
Tekst fra nettsiden:
|
|
||||||
{text[:15000]}
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Ny måte å kalle modellen asynkront på med google-genai
|
|
||||||
response = await client.aio.models.generate_content(
|
|
||||||
model='gemini-2.5-flash',
|
|
||||||
contents=prompt
|
|
||||||
)
|
|
||||||
svar = response.text.strip().lower()
|
|
||||||
|
|
||||||
gyldige_svar = [
|
|
||||||
"aapen", "stengt", "aapen_med_vintergreener",
|
|
||||||
"aapner_snart", "stenger_snart", "under_utvikling",
|
|
||||||
"nedlagt", "ukjent"
|
|
||||||
]
|
|
||||||
|
|
||||||
for gyldig in gyldige_svar:
|
|
||||||
if gyldig in svar:
|
|
||||||
return gyldig
|
|
||||||
return "ukjent"
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Gemini Feil: {e}")
|
|
||||||
return "ukjent"
|
|
||||||
|
|
||||||
async def run_test():
|
|
||||||
print("\n" + "="*50)
|
|
||||||
print(" 🧪 TEE OFF: GEMINI TEST-VERKTØY (MED AUTO-KLIKKER)")
|
|
||||||
print("="*50)
|
|
||||||
|
|
||||||
url = input("🌐 Skriv inn URL til golfklubben (f.eks. https://oslogk.no): ").strip()
|
|
||||||
if not url.startswith("http"):
|
|
||||||
url = "https://" + url
|
|
||||||
|
|
||||||
course_name = input("⛳ Skriv inn banenavn (eller trykk ENTER hvis anlegget kun har 1 bane): ").strip()
|
|
||||||
is_single = len(course_name) == 0
|
|
||||||
|
|
||||||
print("\n⏳ 1. Starter nettleser og besøker siden...")
|
|
||||||
|
|
||||||
full_text = ""
|
|
||||||
async with async_playwright() as p:
|
|
||||||
browser = await p.chromium.launch(headless=True)
|
|
||||||
page = await browser.new_page()
|
|
||||||
try:
|
|
||||||
await page.goto(url, timeout=30000, wait_until="domcontentloaded")
|
|
||||||
await asyncio.sleep(3) # Vent på animasjoner og iframes
|
|
||||||
|
|
||||||
# --- NY LOGIKK: AUTO-KLIKKER ---
|
|
||||||
print("🖱️ Leter etter 'banestatus'-knapper å klikke på...")
|
|
||||||
# Vi leter etter tekst som inneholder "banestatus" (ignorerer store/små bokstaver)
|
|
||||||
knapper = await page.get_by_text(re.compile(r"banestatus", re.IGNORECASE)).all()
|
|
||||||
|
|
||||||
for knapp in knapper:
|
|
||||||
try:
|
|
||||||
if await knapp.is_visible():
|
|
||||||
await knapp.click(timeout=3000)
|
|
||||||
print(" 🎯 Klikket på en banestatus-knapp! Venter 2 sekunder...")
|
|
||||||
await asyncio.sleep(2) # Venter på at modalen/pop-upen åpner seg
|
|
||||||
break # Vi trenger bare å klikke på den første vi finner
|
|
||||||
except Exception as e:
|
|
||||||
# Ignorerer hvis knappen ikke er klikkbar, prøver neste
|
|
||||||
pass
|
|
||||||
# --------------------------------
|
|
||||||
|
|
||||||
element = page.locator("body").first
|
|
||||||
råtekst = await element.inner_text()
|
|
||||||
full_text = " ".join(råtekst.split())
|
|
||||||
print(f"✅ Hentet {len(full_text)} tegn med tekst fra nettsiden.")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Feil ved innlasting av side: {e}")
|
|
||||||
await browser.close()
|
|
||||||
return
|
|
||||||
await browser.close()
|
|
||||||
|
|
||||||
print("🧠 2. Sender teksten til Gemini for analyse...")
|
|
||||||
status = await ask_llm_status(full_text, course_name, is_single)
|
|
||||||
|
|
||||||
print("\n" + "="*50)
|
|
||||||
print(f"🎯 GEMINI SITT SVAR: {status.upper()}")
|
|
||||||
print("="*50 + "\n")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(run_test())
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
import os
|
|
||||||
from passlib.context import CryptContext
|
|
||||||
|
|
||||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
|
||||||
|
|
||||||
# Vi setter opp passord-sjekkeren AKKURAT slik main.py gjør det
|
|
||||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
|
||||||
|
|
||||||
async def test_sannheten():
|
|
||||||
print("\n" + "="*50)
|
|
||||||
print(" 🔍 TEE OFF SANNHETSSERUM")
|
|
||||||
print("="*50)
|
|
||||||
|
|
||||||
username = "Envide Webutvikling"
|
|
||||||
test_password = "Solveig Vilde Ingvild Gina" # Sørg for at dette er det du satte sist!
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
row = await conn.fetchrow("SELECT password_hash FROM admins WHERE username = $1", username)
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
print("❌ FEIL: Fant ikke brukeren i det hele tatt!")
|
|
||||||
return
|
|
||||||
|
|
||||||
db_hash = row['password_hash']
|
|
||||||
print(f"1. Hash funnet i databasen: {db_hash[:30]}...")
|
|
||||||
|
|
||||||
print(f"2. Tester mot passordet: '{test_password}'")
|
|
||||||
|
|
||||||
# Den magiske testen
|
|
||||||
is_valid = pwd_context.verify(test_password, db_hash)
|
|
||||||
|
|
||||||
print("-" * 50)
|
|
||||||
if is_valid:
|
|
||||||
print("✅ SUKSESS! Passordet og hashen stemmer 100% overens.")
|
|
||||||
print("➡️ KONKLUSJON: Hashingen fungerer perfekt. Problemet MÅ være at FastAPI (main.py) ikke klarer å lese JSON-dataene fra curl/frontend riktig.")
|
|
||||||
else:
|
|
||||||
print("❌ FEIL! Passordet stemmer IKKE med hashen i databasen.")
|
|
||||||
print("➡️ KONKLUSJON: Scriptet som oppdaterer passordet gjør en feil (f.eks. legger til usynlige tegn), eller lagringen i databasen blir korrupt.")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
await conn.close()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(test_sannheten())
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
"""
|
|
||||||
TEE OFF ADMIN PASSWORD UPDATER (API CONTAINER VERSION)
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
FUNKSJON: Kobler direkte til databasen inni API-containeren, sjekker at
|
|
||||||
brukeren finnes, og utfører passordoppdateringen automatisk.
|
|
||||||
STATUS: Påvirker IKKE tofaktor (2FA). Gjør jobben fra start til slutt.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import getpass
|
|
||||||
from passlib.hash import pbkdf2_sha256
|
|
||||||
|
|
||||||
# Henter database-URL fra miljøvariabler (samme metode som backenden din bruker)
|
|
||||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
|
||||||
|
|
||||||
async def update_admin_password():
|
|
||||||
print("\n" + "="*50)
|
|
||||||
print(" TEE OFF ADMIN PASSORD-OPPDATERER (DIREKTE TILKOBLING)")
|
|
||||||
print("="*50)
|
|
||||||
|
|
||||||
# Kobler til databasen på ekte backend-vis
|
|
||||||
try:
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Kunne ikke koble til databasen: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Brukernavn-verifisering
|
|
||||||
while True:
|
|
||||||
username = input("Brukernavn på admin som skal oppdateres: ").strip()
|
|
||||||
|
|
||||||
print("⏳ Sjekker databasen...")
|
|
||||||
# Spør databasen direkte hvor mange som har dette navnet
|
|
||||||
count = await conn.fetchval("SELECT COUNT(*) FROM admins WHERE username = $1", username)
|
|
||||||
|
|
||||||
if count == 0:
|
|
||||||
print(f"❌ Fant ingen bruker med navnet '{username}'. Prøv igjen.\n")
|
|
||||||
elif count > 1:
|
|
||||||
print(f"⚠️ KRITISK FEIL: Fant {count} brukere med navnet '{username}'. Avbryter.")
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
print(f"✅ Bruker '{username}' funnet i databasen!\n")
|
|
||||||
break
|
|
||||||
|
|
||||||
# Passord-verifisering
|
|
||||||
while True:
|
|
||||||
password = getpass.getpass("Skriv inn NYTT passord: ")
|
|
||||||
password_confirm = getpass.getpass("Gjenta NYTT passord: ")
|
|
||||||
|
|
||||||
if password == password_confirm:
|
|
||||||
if len(password) < 8:
|
|
||||||
print("⚠️ Advarsel: Passordet bør være minst 8 tegn.")
|
|
||||||
print(f"\n[DEBUG] Passord akseptert.")
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
print("❌ Passordene er ikke like. Prøv igjen.\n")
|
|
||||||
|
|
||||||
print("⏳ Genererer PBKDF2-hash...")
|
|
||||||
password_hash = pbkdf2_sha256.hash(password)
|
|
||||||
|
|
||||||
print("⏳ Oppdaterer databasen automatisk...")
|
|
||||||
# Utfører selve oppdateringen (sikret mot SQL-injeksjoner)
|
|
||||||
await conn.execute("UPDATE admins SET password_hash = $1 WHERE username = $2", password_hash, username)
|
|
||||||
|
|
||||||
print("\n✅ PASSORD OPPDATERT VELLYKKET!")
|
|
||||||
print("-" * 50)
|
|
||||||
print(f"Passordet for '{username}' er nå endret i databasen.")
|
|
||||||
print("Tofaktor (2FA) og alt annet er beholdt urørt.")
|
|
||||||
print("-" * 50 + "\n")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Lukk tilkoblingen pent
|
|
||||||
await conn.close()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
# Siden vi bruker asyncpg, må scriptet kjøres i en asyncio-loop
|
|
||||||
asyncio.run(update_admin_password())
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\nAvbrutt.")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# --- KONFIGURASJON ---
|
|
||||||
KILDE_MAPPE = "/opt/teeoff/"
|
|
||||||
EKSPORT_MAPPE = "/opt/teeoff/kode_eksport_1/"
|
|
||||||
TRE_FIL = "/opt/teeoff/fil-tre.txt"
|
|
||||||
|
|
||||||
# Filtyper vi vil kopiere
|
|
||||||
FILTYPER = ['.py', '.ts', '.tsx']
|
|
||||||
|
|
||||||
# Mapper vi IKKE vil ha med i treet eller skanne (sparer tid og rot)
|
|
||||||
IGNORER_MAPPER = ['.git', 'node_modules', '__pycache__', 'kode_eksport', '.next']
|
|
||||||
|
|
||||||
def generer_tre_og_kopier():
|
|
||||||
kilde_sti = Path(KILDE_MAPPE)
|
|
||||||
eksport_sti = Path(EKSPORT_MAPPE)
|
|
||||||
|
|
||||||
# 1. Opprett eksportmappen hvis den ikke finnes
|
|
||||||
eksport_sti.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
tre_linjer = []
|
|
||||||
kopierte_filer = 0
|
|
||||||
|
|
||||||
print("Skanner filer og genererer tre...")
|
|
||||||
|
|
||||||
# 2. Gå gjennom alle mapper og filer
|
|
||||||
for root, dirs, files in os.walk(kilde_sti):
|
|
||||||
# Fjern ignorerte mapper så vi ikke går inn i dem
|
|
||||||
dirs[:] = [d for d in dirs if d not in IGNORER_MAPPER]
|
|
||||||
|
|
||||||
# Regn ut innrykk basert på hvor dypt vi er i mappestrukturen
|
|
||||||
nivaa = root.replace(KILDE_MAPPE, '').count(os.sep)
|
|
||||||
innrykk = ' ' * 4 * nivaa
|
|
||||||
mappe_navn = os.path.basename(root)
|
|
||||||
|
|
||||||
# Legg til mappen i treet
|
|
||||||
if mappe_navn:
|
|
||||||
tre_linjer.append(f"{innrykk}📁 {mappe_navn}/")
|
|
||||||
else:
|
|
||||||
tre_linjer.append(f"📁 {kilde_sti.name}/")
|
|
||||||
|
|
||||||
sub_innrykk = ' ' * 4 * (nivaa + 1)
|
|
||||||
|
|
||||||
# 3. Gå gjennom filene i mappen
|
|
||||||
for fil in files:
|
|
||||||
tre_linjer.append(f"{sub_innrykk}📄 {fil}")
|
|
||||||
|
|
||||||
fil_sti = Path(root) / fil
|
|
||||||
|
|
||||||
# 4. Sjekk om filen har riktig endelse og skal kopieres
|
|
||||||
if fil_sti.suffix in FILTYPER:
|
|
||||||
# Lag et unikt filnavn for å unngå overskriving
|
|
||||||
relativ_sti = fil_sti.relative_to(kilde_sti)
|
|
||||||
nytt_navn = str(relativ_sti).replace(os.sep, '_').replace('.', '_') + '.txt'
|
|
||||||
ny_sti = eksport_sti / nytt_navn
|
|
||||||
|
|
||||||
# Kopier filen
|
|
||||||
shutil.copy2(fil_sti, ny_sti)
|
|
||||||
kopierte_filer += 1
|
|
||||||
|
|
||||||
# 5. Lagre filteret til tekstfilen
|
|
||||||
with open(TRE_FIL, 'w', encoding='utf-8') as f:
|
|
||||||
f.write('\n'.join(tre_linjer))
|
|
||||||
|
|
||||||
print(f"\n✅ Ferdig!")
|
|
||||||
print(f"📁 Filtre er lagret i: {TRE_FIL}")
|
|
||||||
print(f"📝 Kopierte {kopierte_filer} kodefiler til: {EKSPORT_MAPPE}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
generer_tre_og_kopier()
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
import "./.next/dev/types/routes.d.ts";
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import type { NextConfig } from "next";
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
/* config options here */
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
|
|
@ -1,217 +0,0 @@
|
||||||
"use client";
|
|
||||||
/**
|
|
||||||
* TEE OFF SYSTEM INSTRUCTIONS - FACILITY CARDS v3.8 (BLOB SEARCH)
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
* REGEL 1: Status-badge SKAL vises øverst til venstre FOR ALLE BANER.
|
|
||||||
* Bruk STATUS_MAP for tekst.
|
|
||||||
* REGEL 2: DATA-PARSING: Bruk parseJson() for 'course_statuses', 'amenities' og 'nsg_data'.
|
|
||||||
* REGEL 3: Avstand-pillen skal ha fargen #2d3319 (Mørk oliven) med hvit tekst.
|
|
||||||
* REGEL 4: NSG (Blå 'N') og Golfamore (Oransje 'G') sirkler skal ha hvit kant (border-2).
|
|
||||||
* REGEL 5: Bunnen: Antall Hull (grønn pill), Banetype (grå pill), og Ikon-sirkler.
|
|
||||||
* REGEL 6: Viser dato (f.eks "05. mars 2026") rett til høyre for øverste status-pille.
|
|
||||||
* REGEL 7: Natural Language Search bruker en "Search Blob" for å støtte delvise
|
|
||||||
* ord og skrivefeil slik at listen ikke tømmes mens brukeren skriver.
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { STATUS_MAP, REGIONS } from "@/config/constants";
|
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
function getDistance(lat1: number, lon1: number, lat2: number, lon2: number) {
|
|
||||||
try {
|
|
||||||
const R = 6371;
|
|
||||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
||||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
|
||||||
const a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon/2) * Math.sin(dLon/2);
|
|
||||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
||||||
} catch (e) { return Infinity; }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FacilitySearch({ initialFacilities }: { initialFacilities: any[] }) {
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [userLocation, setUserLocation] = useState<{ lat: number, lng: number } | null>(null);
|
|
||||||
const [sortMethod, setSortMethod] = useState<'dist' | 'alpha'>('alpha');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if ("geolocation" in navigator) {
|
|
||||||
navigator.geolocation.getCurrentPosition(p => {
|
|
||||||
setUserLocation({ lat: p.coords.latitude, lng: p.coords.longitude });
|
|
||||||
setSortMethod('dist');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const processed = useMemo(() => {
|
|
||||||
if (!Array.isArray(initialFacilities)) return [];
|
|
||||||
|
|
||||||
// Fyllord som fjernes slik at "Åpne baner i Oslo" blir til søkeordene ["åpne", "oslo"]
|
|
||||||
const stopWords = new Set(["i", "på", "for", "med", "av", "og"]);
|
|
||||||
|
|
||||||
return initialFacilities.map(f => {
|
|
||||||
// --- ROBUST DATA-PARSING ---
|
|
||||||
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; }
|
|
||||||
};
|
|
||||||
|
|
||||||
const rawStatuses = parseJson(f.course_statuses, []);
|
|
||||||
const sArr = Array.isArray(rawStatuses) && rawStatuses.length > 0
|
|
||||||
? rawStatuses
|
|
||||||
: [{ status: 'ukjent', name: 'Hovedbane' }];
|
|
||||||
|
|
||||||
const amenities = parseJson(f.amenities, {});
|
|
||||||
const nsgData = parseJson(f.nsg_data, {});
|
|
||||||
|
|
||||||
const dist = userLocation && f.lat && f.lng ? getDistance(userLocation.lat, userLocation.lng, f.lat, f.lng) : Infinity;
|
|
||||||
const hasNSG = nsgData && Object.keys(nsgData).length > 0;
|
|
||||||
const hasGolfamore = f.golfamore === true;
|
|
||||||
|
|
||||||
// --- THE SEARCH BLOB ---
|
|
||||||
// Vi starter med å legge navn, by og fylke i en stor, usynlig tekststreng
|
|
||||||
let searchableText = `${f.name} ${f.city} ${f.county}`.toLowerCase();
|
|
||||||
|
|
||||||
// 1. Injiser statuser i tekststrengen
|
|
||||||
const hasOpen = sArr.some((c: any) => (c.status || "") === 'aapen');
|
|
||||||
const hasClosed = sArr.some((c: any) => (c.status || "") === 'stengt');
|
|
||||||
const hasWinter = sArr.some((c: any) => (c.status || "") === 'aapen_med_vintergreener');
|
|
||||||
const hasNedlagt = sArr.some((c: any) => (c.status || "") === 'nedlagt');
|
|
||||||
|
|
||||||
if (hasOpen) searchableText += " åpen åpne aapen";
|
|
||||||
if (hasClosed) searchableText += " stengt stengte";
|
|
||||||
if (hasWinter) searchableText += " vinter vintergreener vinterbane";
|
|
||||||
if (hasNedlagt) searchableText += " nedlagt nedlagte";
|
|
||||||
|
|
||||||
// 2. Injiser spesial-tags
|
|
||||||
if (hasNSG) searchableText += " nsg norsk seniorgolf";
|
|
||||||
if (hasGolfamore) searchableText += " golfamore amore";
|
|
||||||
|
|
||||||
// 3. Injiser landsdel (f.eks. hvis fylket er Akershus, legger vi til "østlandet")
|
|
||||||
const fylke = (f.county || "").toLowerCase();
|
|
||||||
Object.entries(REGIONS).forEach(([regionName, counties]) => {
|
|
||||||
if (counties.includes(fylke)) {
|
|
||||||
searchableText += ` ${regionName}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Splitter brukerens søk inn i enkeltord og fjerner stopWords + ordene "bane"/"baner"
|
|
||||||
const words = searchQuery
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter(w => w.length > 0 && !stopWords.has(w) && w !== "bane" && w !== "baner");
|
|
||||||
|
|
||||||
// Sjekker at ALLE ordene brukeren har skrevet, finnes et sted i "Search Blob"-en
|
|
||||||
const matches = words.every(w => searchableText.includes(w));
|
|
||||||
|
|
||||||
return { ...f, statuses: sArr, amenities, dist, hasNSG, hasGolfamore, matches };
|
|
||||||
})
|
|
||||||
.filter(f => f.matches)
|
|
||||||
.sort((a, b) => {
|
|
||||||
if (sortMethod === 'dist' && a.dist !== b.dist) return a.dist - b.dist;
|
|
||||||
return a.name.localeCompare(b.name, 'nb');
|
|
||||||
});
|
|
||||||
}, [searchQuery, initialFacilities, userLocation, sortMethod]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-[1400px] mx-auto px-6 py-12 relative z-40">
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<button onClick={() => setSortMethod(sortMethod === 'dist' ? 'alpha' : 'dist')} className="bg-white px-6 py-3 rounded-full shadow-md text-[10px] font-black text-[#8bc34a] uppercase tracking-widest border border-gray-100 transition-colors">
|
|
||||||
{sortMethod === 'dist' ? "📍 Nærmeste baner først" : "🔠 Alfabetisk visning"} • {processed.length} baner
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input className="w-full p-8 rounded-[2.5rem] shadow-2xl mb-16 text-gray-900 border-none ring-1 ring-black/5 text-2xl outline-none focus:ring-4 focus:ring-[#8bc34a]/20 transition-all bg-white" placeholder='Søk baner, fylke, status eller spesial (f.eks "Åpne baner i Akershus" eller "NSG")...' value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">
|
|
||||||
{processed.map((f: any) => {
|
|
||||||
const sArr = f.statuses; // Sikret via pre-prosesseringen over
|
|
||||||
|
|
||||||
// Formater datoen pent: "05. mars 2026"
|
|
||||||
const lastUpdated = f.status_updated_at
|
|
||||||
? new Date(f.status_updated_at).toLocaleDateString('nb-NO', { day: '2-digit', month: 'long', year: 'numeric' })
|
|
||||||
: 'Ukjent';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link href={`/golfbaner/${f.slug}`} key={f.id} className="bg-white rounded-[2.5rem] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-gray-100 flex flex-col group relative">
|
|
||||||
<div className="h-64 relative overflow-hidden bg-gray-100">
|
|
||||||
<img src={f.image_url || "/Toppbilde-standard.jpg"} className="w-full h-full object-cover transition duration-1000 group-hover:scale-105" alt={f.name} />
|
|
||||||
|
|
||||||
{/* Status Badges for ALLE baner på anlegget */}
|
|
||||||
<div className="absolute top-5 left-5 flex flex-col gap-2 z-20">
|
|
||||||
{sArr.map((course: any, idx: number) => {
|
|
||||||
const rawStatus = (course.status || "ukjent").toLowerCase();
|
|
||||||
|
|
||||||
let statusColor = "bg-gray-400";
|
|
||||||
if (rawStatus === 'aapen') statusColor = "bg-[#8bc34a]";
|
|
||||||
else if (rawStatus.includes('vinter') || rawStatus === 'stenger_snart') statusColor = "bg-[#ff5722]";
|
|
||||||
else if (rawStatus === 'aapner_snart') statusColor = "bg-amber-500";
|
|
||||||
else if (rawStatus === 'stengt') statusColor = "bg-red-600";
|
|
||||||
else if (rawStatus === 'nedlagt') statusColor = "bg-black";
|
|
||||||
else if (rawStatus === 'under_utvikling') statusColor = "bg-blue-500";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={idx} className="flex items-center gap-3">
|
|
||||||
<div className={`${statusColor} text-white px-3 py-1.5 rounded-xl text-[9px] font-black uppercase shadow-lg backdrop-blur-sm bg-opacity-90 flex items-center gap-2 max-w-[200px]`}>
|
|
||||||
{sArr.length > 1 && (
|
|
||||||
<span className="opacity-80 border-r border-white/30 pr-2 truncate max-w-[90px]" title={course.name}>
|
|
||||||
{course.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>{STATUS_MAP[rawStatus] || rawStatus}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dato-pille ved siden av den øverste status-pillen */}
|
|
||||||
{idx === 0 && (
|
|
||||||
<div className="bg-white/30 backdrop-blur-sm text-[#11280f]/90 px-3 py-1.5 rounded-xl text-[11px] font-bold shadow-lg">
|
|
||||||
{lastUpdated}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Avstandspille */}
|
|
||||||
{f.dist !== Infinity && (
|
|
||||||
<div className="absolute bottom-5 right-5 bg-[#2d3319] text-white px-4 py-2 rounded-2xl text-[10px] font-black shadow-lg z-20">
|
|
||||||
{Math.round(f.dist)} km unna
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-8 flex flex-col flex-grow">
|
|
||||||
<h3 className="font-black text-3xl text-[#11280f] mb-1 group-hover:text-[#8bc34a] transition-colors leading-tight">{f.name}</h3>
|
|
||||||
<p className="text-gray-400 text-[11px] font-bold uppercase tracking-widest mb-8">{f.city} • {f.county}</p>
|
|
||||||
|
|
||||||
<div className="mt-auto flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* Hull-pille */}
|
|
||||||
<span className="bg-[#f1f7ed] text-[#8bc34a] px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest">
|
|
||||||
{f.amenities?.antall_hull || '--'} HULL
|
|
||||||
</span>
|
|
||||||
{/* Banetype-pille */}
|
|
||||||
<span className="bg-gray-50 text-gray-400 px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border border-gray-100">
|
|
||||||
{f.banetype || 'SKOGSBANE'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sirkel-ikoner (NSG / Golfamore) */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{f.hasNSG && (
|
|
||||||
<div className="w-9 h-9 bg-blue-600 text-white rounded-full flex items-center justify-center font-black text-sm shadow-lg border-2 border-white translate-y-1">N</div>
|
|
||||||
)}
|
|
||||||
{f.hasGolfamore && (
|
|
||||||
<div className="w-9 h-9 bg-[#ff5722] text-white rounded-full flex items-center justify-center font-black text-sm shadow-lg border-2 border-white translate-y-1">G</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
"use client";
|
|
||||||
/**
|
|
||||||
* TEE OFF SYSTEM INSTRUCTIONS - HERO SLIDER v2.4
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
* REGEL 1: Kun baner med status 'aapen', 'aapner_snart', 'stenger_snart'
|
|
||||||
* eller 'aapen_med_vintergreener' skal prioriteres.
|
|
||||||
* REGEL 2: Baner med status 'nedlagt' eller 'under_utvikling' skal ALDRI vises.
|
|
||||||
* REGEL 3: Baner med generiske bilder (inneholder 'standard') skal ALDRI vises.
|
|
||||||
* REGEL 4: MANUELL EKSKLUDERING: Slugs i MANUAL_EXCLUSION_LIST skal aldri vises.
|
|
||||||
* REGEL 5: Slideren skal vise nøyaktig 5 baner.
|
|
||||||
* REGEL 6: Maks høyde er låst til 624px. Ingen badges.
|
|
||||||
* REGEL 7: Typografi: Nedjustert fontstørrelse (4xl mobil / 7xl desktop) for eleganse.
|
|
||||||
* REGEL 8: Utvalget skal være stabilt i én time (Hourly Seed) før det refreshes.
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
const MANUAL_EXCLUSION_LIST = [
|
|
||||||
'alsten-golfklubb', 'askim-golfklubb', 'bergen-golfklubb', 'eidskog-golfklubb',
|
|
||||||
'eiker-golfklubb', 'floro-golfklubb', 'garder-golfklubb', 'hafjell-golfklubb',
|
|
||||||
'halden-golfklubb', 'haugesund-golfklubb', 'hinnoy-golfklubb', 'hitra-golfklubb',
|
|
||||||
'hurum-golfklubb', 'imjelt-pitch-putt', 'karmoy-golfklubb', 'kristiansund-og-omegn-golfklubb',
|
|
||||||
'lommedalen-golfklubb', 'laerdal-golfklubb', 'moa-golfsenter', 'modum-golfklubb',
|
|
||||||
'nes-golfklubb-09', 'nittedal-golfklubb', 'selbu-golfklubb', 'stryn-golfklubb',
|
|
||||||
'sunnfjord-golfklubb', 'tysnes-golfklubb', 'vanylven-golfklubb', 'vesteralen-golfklubb',
|
|
||||||
'vestlia-golf'
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function HeroSlider({ facilities }: { facilities: any[] }) {
|
|
||||||
const [currentIndex, setCurrentSlide] = useState(0);
|
|
||||||
|
|
||||||
const sliderItems = useMemo(() => {
|
|
||||||
if (!Array.isArray(facilities) || facilities.length === 0) return [];
|
|
||||||
|
|
||||||
const preferredStatuses = ['aapen', 'aapner_snart', 'stenger_snart', 'aapen_med_vintergreener'];
|
|
||||||
const forbiddenStatuses = ['nedlagt', 'under_utvikling'];
|
|
||||||
|
|
||||||
const validCandidates = facilities.filter(f => {
|
|
||||||
if (MANUAL_EXCLUSION_LIST.includes(f.slug)) return false;
|
|
||||||
const img = f.image_url || "";
|
|
||||||
if (!img || img.toLowerCase().includes('standard') || img.length < 5) return false;
|
|
||||||
|
|
||||||
const statuses = Array.isArray(f.course_statuses) ? f.course_statuses : [];
|
|
||||||
const isForbidden = statuses.some((s: any) =>
|
|
||||||
forbiddenStatuses.includes((s.status || "").toLowerCase())
|
|
||||||
);
|
|
||||||
return !isForbidden;
|
|
||||||
});
|
|
||||||
|
|
||||||
const highPriority = validCandidates.filter(f => {
|
|
||||||
const statuses = Array.isArray(f.course_statuses) ? f.course_statuses : [];
|
|
||||||
return statuses.some((s: any) => preferredStatuses.includes((s.status || "").toLowerCase()));
|
|
||||||
});
|
|
||||||
|
|
||||||
const fallbackPool = validCandidates.filter(f => !highPriority.includes(f));
|
|
||||||
const now = new Date();
|
|
||||||
const hourlySeed = parseInt(`${now.getFullYear()}${now.getMonth()}${now.getDate()}${now.getHours()}`);
|
|
||||||
|
|
||||||
const seededShuffle = (arr: any[]) => {
|
|
||||||
return [...arr].sort((a, b) => ((a.id * hourlySeed) % 100) - ((b.id * hourlySeed) % 100));
|
|
||||||
};
|
|
||||||
|
|
||||||
let selection = seededShuffle(highPriority);
|
|
||||||
if (selection.length < 5) {
|
|
||||||
selection = [...selection, ...seededShuffle(fallbackPool)].slice(0, 5);
|
|
||||||
} else {
|
|
||||||
selection = selection.slice(0, 5);
|
|
||||||
}
|
|
||||||
return selection;
|
|
||||||
}, [facilities]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (sliderItems.length <= 1) return;
|
|
||||||
const interval = setInterval(() => setCurrentSlide((p) => (p + 1) % sliderItems.length), 8000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [sliderItems.length]);
|
|
||||||
|
|
||||||
if (sliderItems.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="relative h-[65vh] max-h-[624px] w-full overflow-hidden bg-[#11280f]">
|
|
||||||
{sliderItems.map((f, i) => (
|
|
||||||
<div
|
|
||||||
key={f.id}
|
|
||||||
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
|
|
||||||
i === currentIndex ? 'opacity-100 z-10' : 'opacity-0 z-0'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Link href={`/golfbaner/${f.slug}`} className="block h-full relative group">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-[#11280f] via-[#11280f]/40 to-black/10 z-10" />
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={f.image_url}
|
|
||||||
alt={f.name}
|
|
||||||
className="w-full h-full object-cover transition-transform duration-[10s] scale-100 group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="absolute inset-0 z-20 flex items-center">
|
|
||||||
<div className="max-w-[1400px] mx-auto px-6 w-full">
|
|
||||||
<div className="max-w-4xl animate-in fade-in slide-in-from-bottom-8 duration-1000">
|
|
||||||
{/* FONT NEDJUSTERT FRA text-6xl md:text-9xl TIL text-4xl md:text-7xl */}
|
|
||||||
<h2 className="text-4xl md:text-7xl font-black text-white tracking-tighter drop-shadow-2xl leading-[0.9] mb-4">
|
|
||||||
{f.name}
|
|
||||||
</h2>
|
|
||||||
<p className="text-white/90 text-sm md:text-xl font-bold uppercase tracking-[0.4em] drop-shadow-md">
|
|
||||||
{f.county} <span className="text-[#8bc34a] mx-2">•</span> {f.city}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 z-30 flex gap-4">
|
|
||||||
{sliderItems.map((_, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
onClick={() => setCurrentSlide(i)}
|
|
||||||
className={`h-1 transition-all duration-500 rounded-full ${
|
|
||||||
i === currentIndex ? 'w-16 bg-[#8bc34a]' : 'w-4 bg-white/20'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,203 +0,0 @@
|
||||||
"use client";
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { API_URL } from "@/config/constants";
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
export default function GreenfeeWasher() {
|
|
||||||
const [drafts, setDrafts] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const fetchDrafts = () => {
|
|
||||||
setLoading(true);
|
|
||||||
fetch(`${API_URL}/admin/greenfee/drafts`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
const editableDrafts = data.map((f: any) => {
|
|
||||||
// JSONB fra Postgres kan noen ganger komme som en streng,
|
|
||||||
// vi må sikre at vi parser det hvis det trengs
|
|
||||||
let parsedDraft = f.greenfee_draft;
|
|
||||||
if (typeof parsedDraft === 'string') {
|
|
||||||
try { parsedDraft = JSON.parse(parsedDraft); }
|
|
||||||
catch (e) { console.error("Kunne ikke parse JSON", e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hent ut selve listen (fallback til tom liste hvis noe er feil)
|
|
||||||
const greenfeeList = parsedDraft?.foreslatt_greenfee || [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
...f,
|
|
||||||
greenfee_draft: parsedDraft, // Lagre den parsede versjonen for visning
|
|
||||||
edit_greenfee: greenfeeList // Dette er arrayet som binder seg til input-feltene
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setDrafts(editableDrafts);
|
|
||||||
setLoading(false);
|
|
||||||
})
|
|
||||||
.catch(() => setLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => { fetchDrafts(); }, []);
|
|
||||||
|
|
||||||
const toggleSelectAll = (checked: boolean) => {
|
|
||||||
if (checked) setSelectedIds(drafts.map(d => d.id));
|
|
||||||
else setSelectedIds([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleOne = (id: number) => {
|
|
||||||
if (selectedIds.includes(id)) setSelectedIds(selectedIds.filter(i => i !== id));
|
|
||||||
else setSelectedIds([...selectedIds, id]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeRow = (facilityId: number, rowIndex: number) => {
|
|
||||||
setDrafts(drafts.map(d => {
|
|
||||||
if (d.id === facilityId) {
|
|
||||||
const newRows = [...d.edit_greenfee];
|
|
||||||
newRows.splice(rowIndex, 1);
|
|
||||||
return { ...d, edit_greenfee: newRows };
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateField = (facilityId: number, rowIndex: number, field: string, value: string | number) => {
|
|
||||||
setDrafts(drafts.map(d => {
|
|
||||||
if (d.id === facilityId) {
|
|
||||||
const newRows = [...d.edit_greenfee];
|
|
||||||
newRows[rowIndex] = { ...newRows[rowIndex], [field]: value };
|
|
||||||
return { ...d, edit_greenfee: newRows };
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApprove = async () => {
|
|
||||||
const toApprove = drafts.filter(d => selectedIds.includes(d.id)).map(d => ({
|
|
||||||
facility_id: d.id,
|
|
||||||
greenfee: d.edit_greenfee.map((row: any) => ({
|
|
||||||
banenavn: row.banenavn || "",
|
|
||||||
priskategori: row.priskategori || "",
|
|
||||||
pris_voksne: Number(row.pris_voksne) || null,
|
|
||||||
pris_junior: Number(row.pris_junior) || null
|
|
||||||
}))
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (toApprove.length === 0) return alert("Velg minst ett anlegg å godkjenne.");
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/admin/greenfee/approve-bulk`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ approvals: toApprove })
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
alert(`${toApprove.length} anlegg oppdatert!`);
|
|
||||||
setSelectedIds([]);
|
|
||||||
fetchDrafts();
|
|
||||||
} else {
|
|
||||||
alert("Noe gikk galt under lagring.");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert("Nettverksfeil");
|
|
||||||
}
|
|
||||||
setSaving(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <div className="p-20 text-center font-black animate-pulse">Laster utkast...</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#f1f7ed] p-8 text-[#11280f]">
|
|
||||||
<div className="max-w-[1400px] mx-auto">
|
|
||||||
<div className="flex justify-between items-end mb-10 border-b border-gray-200 pb-6">
|
|
||||||
<div>
|
|
||||||
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block">← Tilbake til oversikten</Link>
|
|
||||||
<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="bg-[#8bc34a] text-white px-8 py-4 rounded-xl font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50">
|
|
||||||
{saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{drafts.length === 0 ? (
|
|
||||||
<div className="bg-white p-20 rounded-[2rem] text-center shadow-sm">
|
|
||||||
<span className="text-6xl mb-4 block">🧹</span>
|
|
||||||
<h2 className="text-2xl font-black text-gray-400">Alt er rent og pent!</h2>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-white p-4 rounded-2xl shadow-sm flex items-center gap-4">
|
|
||||||
<input type="checkbox" className="w-5 h-5 accent-[#8bc34a] ml-2" checked={selectedIds.length === drafts.length && drafts.length > 0} onChange={(e) => toggleSelectAll(e.target.checked)} />
|
|
||||||
<span className="font-black uppercase tracking-widest text-xs text-gray-500">Velg Alle</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{drafts.map(draft => (
|
|
||||||
<div key={draft.id} className={`bg-white p-6 rounded-3xl shadow-sm border-2 transition-all ${selectedIds.includes(draft.id) ? 'border-[#8bc34a] bg-[#8bc34a]/5' : 'border-transparent'}`}>
|
|
||||||
<div className="flex gap-6 items-start">
|
|
||||||
<div className="pt-2"><input type="checkbox" className="w-6 h-6 accent-[#8bc34a] cursor-pointer" checked={selectedIds.includes(draft.id)} onChange={() => toggleOne(draft.id)} /></div>
|
|
||||||
<div className="flex-grow space-y-4">
|
|
||||||
<div className="flex justify-between items-center border-b pb-4">
|
|
||||||
<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="text-xs font-bold text-blue-600 hover:underline bg-blue-50 px-4 py-2 rounded-lg">Sjekk Nettside ↗</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{draft.greenfee_draft?.ai_begrunnelse && (
|
|
||||||
<div className="bg-blue-50/50 p-4 rounded-xl text-sm italic text-blue-900 border border-blue-100">
|
|
||||||
<strong>🤖 AI Begrunnelse:</strong> {draft.greenfee_draft.ai_begrunnelse}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{draft.greenfee_draft?.foreslatt_avtaleklubber?.length > 0 && (
|
|
||||||
<div className="bg-green-50/50 p-4 rounded-xl text-sm text-green-900 border border-green-100">
|
|
||||||
<strong>🤝 AI fant disse avtaleklubbene i teksten:</strong> {draft.greenfee_draft.foreslatt_avtaleklubber.join(', ')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-black uppercase tracking-widest text-gray-400 mb-2">Slik ser det ut i databasen nå:</h4>
|
|
||||||
<div className="bg-gray-50 p-4 rounded-xl text-xs space-y-2 opacity-75">
|
|
||||||
{draft.greenfee && draft.greenfee.length > 0 ? draft.greenfee.map((g: any, i: number) => (
|
|
||||||
<div key={i} className="flex justify-between border-b pb-1">
|
|
||||||
<span>{g.banenavn} - {g.priskategori}</span>
|
|
||||||
<span className="font-bold">V: {g.pris_voksne || '-'} | J: {g.pris_junior || '-'}</span>
|
|
||||||
</div>
|
|
||||||
)) : "Ingen priser registrert."}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-black uppercase tracking-widest text-green-600 mb-2">Nytt forslag å godkjenne:</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{draft.edit_greenfee && draft.edit_greenfee.map((row: any, idx: number) => (
|
|
||||||
<div key={idx} className="flex gap-2 items-center bg-white border border-gray-200 p-2 rounded-lg relative group">
|
|
||||||
<input className="w-1/3 p-2 rounded border border-gray-100 text-xs font-bold focus:border-[#8bc34a] outline-none" value={row.banenavn || ''} onChange={e => updateField(draft.id, idx, 'banenavn', e.target.value)} placeholder="Bane" />
|
|
||||||
<input className="w-1/3 p-2 rounded border border-gray-100 text-xs focus:border-[#8bc34a] outline-none" value={row.priskategori || ''} onChange={e => updateField(draft.id, idx, 'priskategori', e.target.value)} placeholder="Kategori" />
|
|
||||||
<input className="w-16 p-2 rounded border border-gray-100 text-xs text-center focus:border-[#8bc34a] outline-none" type="number" value={row.pris_voksne || ''} onChange={e => updateField(draft.id, idx, 'pris_voksne', e.target.value)} placeholder="Voksen" />
|
|
||||||
<input className="w-16 p-2 rounded border border-gray-100 text-xs text-center focus:border-[#8bc34a] outline-none" 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="text-red-400 hover:text-red-600 px-2 opacity-0 group-hover:opacity-100 transition-opacity" title="Slett rad">✕</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button onClick={() => {
|
|
||||||
const newDrafts = [...drafts];
|
|
||||||
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">
|
|
||||||
+ Legg til manuell rad
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
"use client";
|
|
||||||
/**
|
|
||||||
* TEE OFF ADMIN LOGIN v1.2
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
* PLASSERING: frontend/src/app/admin/login/page.tsx
|
|
||||||
* FUNKSJON: Offentlig tilgjengelig innlogging for administratorer.
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { API_URL } from "@/config/constants";
|
|
||||||
|
|
||||||
export default function AdminLogin() {
|
|
||||||
const [step, setStep] = useState(1);
|
|
||||||
const [formData, setFormData] = useState({ username: '', password: '', code: '' });
|
|
||||||
const [tempToken, setTempToken] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsLoading(true);
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ username: formData.username, password: formData.password })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok) {
|
|
||||||
setTempToken(data.temp_token);
|
|
||||||
setStep(2);
|
|
||||||
} else {
|
|
||||||
setError(data.detail || 'Ugyldig pålogging');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("🔥 DEN EKTE FEILEN ER:", err);
|
|
||||||
setError('Systemfeil: Kunne ikke koble til API-et');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVerify2FA = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/auth/verify-2fa`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ temp_token: tempToken, code: formData.code })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
// VIKTIG: Etter suksess sender vi brukeren til selve dashbordet
|
|
||||||
router.push('/admin');
|
|
||||||
router.refresh();
|
|
||||||
} else {
|
|
||||||
setError('Ugyldig 2FA-kode');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError('Tilkoblingsfeil ved 2FA-verifisering');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-[#f1f7ed] p-6 font-sans">
|
|
||||||
<div className="max-w-md w-full bg-white rounded-[3rem] shadow-2xl p-12 border border-white">
|
|
||||||
<div className="flex justify-center mb-10">
|
|
||||||
<img src="/TeeOff-logo-Retina-1.png" className="h-10 w-auto" alt="TeeOff" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-black text-center uppercase tracking-tighter mb-8 text-[#11280f]">
|
|
||||||
{step === 1 ? "Admin Portalen" : "Tofaktor Sjekk"}
|
|
||||||
</h2>
|
|
||||||
<form onSubmit={step === 1 ? handleLogin : handleVerify2FA} className="space-y-4">
|
|
||||||
{step === 1 ? (
|
|
||||||
<>
|
|
||||||
<input type="text" placeholder="Brukernavn eller E-post" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData(prevState => ({...prevState, username: e.target.value}))} required />
|
|
||||||
<input type="password" placeholder="Passord" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData(prevState => ({...prevState, password: e.target.value}))} required />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-[10px] text-gray-400 font-black uppercase text-center tracking-widest">Tast inn 6 siffer fra appen din</p>
|
|
||||||
<input type="text" placeholder="000 000" className="w-full p-6 text-center text-4xl tracking-[0.3em] font-black bg-gray-50 rounded-3xl border-none ring-2 ring-[#ff5722]/20 outline-none focus:ring-[#ff5722] transition-all text-[#ff5722]" onChange={e => setFormData({...formData, code: e.target.value})} autoFocus required />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && <div className="bg-red-50 p-4 rounded-xl text-red-600 text-[10px] font-black uppercase tracking-widest text-center border border-red-100">⚠️ {error}</div>}
|
|
||||||
<button type="submit" disabled={isLoading} className={`w-full p-6 rounded-2xl font-black uppercase text-xs tracking-widest text-white transition-all shadow-xl ${step === 1 ? 'bg-[#11280f]' : 'bg-[#ff5722]'}`}>
|
|
||||||
{isLoading ? "Venter..." : (step === 1 ? "Fortsett" : "Logg inn")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
"use client";
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { API_URL } from "@/config/constants";
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
export default function MembershipWasher() {
|
|
||||||
const [drafts, setDrafts] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const fetchDrafts = () => {
|
|
||||||
setLoading(true);
|
|
||||||
fetch(`${API_URL}/admin/membership/drafts`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
// Konverter innkommende drafts til editerbare felter lokalt
|
|
||||||
const editableDrafts = data.map((f: any) => ({
|
|
||||||
...f,
|
|
||||||
edit_standard_navn: f.membership_draft?.foreslatt_standard_navn || f.navn_standard_medlemskap || "",
|
|
||||||
edit_standard_pris: f.membership_draft?.foreslatt_standard_pris || f.standard_medlemskap || "",
|
|
||||||
edit_standard_kommentar: f.membership_draft?.foreslatt_standard_kommentar || "",
|
|
||||||
edit_rimeligste_navn: f.membership_draft?.foreslatt_rimeligste_navn || f.navn_rimeligste_alternativ || "",
|
|
||||||
edit_rimeligste_pris: f.membership_draft?.foreslatt_rimeligste_pris || f.rimeligste_alternativ || "",
|
|
||||||
}));
|
|
||||||
setDrafts(editableDrafts);
|
|
||||||
setLoading(false);
|
|
||||||
})
|
|
||||||
.catch(() => setLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchDrafts();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleSelectAll = (checked: boolean) => {
|
|
||||||
if (checked) setSelectedIds(drafts.map(d => d.id));
|
|
||||||
else setSelectedIds([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleOne = (id: number) => {
|
|
||||||
if (selectedIds.includes(id)) setSelectedIds(selectedIds.filter(i => i !== id));
|
|
||||||
else setSelectedIds([...selectedIds, id]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateDraftField = (id: number, field: string, value: any) => {
|
|
||||||
setDrafts(drafts.map(d => d.id === id ? { ...d, [field]: value } : d));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApprove = async () => {
|
|
||||||
const toApprove = drafts.filter(d => selectedIds.includes(d.id)).map(d => ({
|
|
||||||
facility_id: d.id,
|
|
||||||
navn_standard_medlemskap: d.edit_standard_navn,
|
|
||||||
standard_medlemskap: Number(d.edit_standard_pris) || null,
|
|
||||||
standard_medlemskap_kommentarer: d.edit_standard_kommentar,
|
|
||||||
navn_rimeligste_alternativ: d.edit_rimeligste_navn,
|
|
||||||
rimeligste_alternativ: Number(d.edit_rimeligste_pris) || null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (toApprove.length === 0) return alert("Velg minst ett anlegg å godkjenne.");
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/admin/membership/approve-bulk`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ approvals: toApprove })
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
alert(`${toApprove.length} anlegg ble oppdatert og lagret til live!`);
|
|
||||||
setSelectedIds([]);
|
|
||||||
fetchDrafts(); // Oppdaterer listen (fjerner de godkjente)
|
|
||||||
} else {
|
|
||||||
alert("Noe gikk galt under lagring.");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert("Nettverksfeil");
|
|
||||||
}
|
|
||||||
setSaving(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <div className="p-20 text-center font-black animate-pulse">Laster utkast...</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#f1f7ed] p-8 text-[#11280f]">
|
|
||||||
<div className="max-w-[1400px] mx-auto">
|
|
||||||
<div className="flex justify-between items-end mb-10 border-b border-gray-200 pb-6">
|
|
||||||
<div>
|
|
||||||
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block">← Tilbake til oversikten</Link>
|
|
||||||
<h1 className="text-4xl font-black">Medlemskaps-Vaskeriet</h1>
|
|
||||||
<p className="text-sm text-gray-600 mt-2">Gå gjennom AI-ens forslag, juster hvis nødvendig, og godkjenn for å publisere. Oppdatert-dato settes automatisk i dag.</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleApprove}
|
|
||||||
disabled={saving || selectedIds.length === 0}
|
|
||||||
className="bg-[#8bc34a] text-white px-8 py-4 rounded-xl font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50 disabled:scale-100"
|
|
||||||
>
|
|
||||||
{saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{drafts.length === 0 ? (
|
|
||||||
<div className="bg-white p-20 rounded-[2rem] text-center shadow-sm">
|
|
||||||
<span className="text-6xl mb-4 block">🧹</span>
|
|
||||||
<h2 className="text-2xl font-black text-gray-400">Alt er rent og pent!</h2>
|
|
||||||
<p className="text-gray-500">Ingen ventende forslag fra AI-skraperen akkurat nå.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-white p-4 rounded-2xl shadow-sm flex items-center gap-4">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="w-5 h-5 accent-[#8bc34a] ml-2"
|
|
||||||
checked={selectedIds.length === drafts.length}
|
|
||||||
onChange={(e) => toggleSelectAll(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span className="font-black uppercase tracking-widest text-xs text-gray-500">Velg Alle</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{drafts.map(draft => (
|
|
||||||
<div key={draft.id} className={`bg-white p-6 rounded-3xl shadow-sm border-2 transition-all ${selectedIds.includes(draft.id) ? 'border-[#8bc34a] bg-[#8bc34a]/5' : 'border-transparent'}`}>
|
|
||||||
<div className="flex gap-6 items-start">
|
|
||||||
<div className="pt-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="w-6 h-6 accent-[#8bc34a] cursor-pointer"
|
|
||||||
checked={selectedIds.includes(draft.id)}
|
|
||||||
onChange={() => toggleOne(draft.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-grow space-y-4">
|
|
||||||
|
|
||||||
{/* OPPDATERT: Navn + ID Badge */}
|
|
||||||
<div className="flex justify-between items-center border-b pb-4">
|
|
||||||
<h3 className="text-2xl font-black flex items-center gap-3">
|
|
||||||
{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.medlemskap_url} target="_blank" className="text-xs font-bold text-blue-600 hover:underline bg-blue-50 px-4 py-2 rounded-lg">Sjekk Klubbens Nettside ↗</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{draft.membership_draft?.ai_begrunnelse && (
|
|
||||||
<div className="bg-blue-50/50 p-4 rounded-xl text-sm italic text-blue-900 border border-blue-100">
|
|
||||||
<strong>🤖 AI Begrunnelse:</strong> {draft.membership_draft.ai_begrunnelse}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-2">
|
|
||||||
{/* Standard */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="text-xs font-black uppercase tracking-widest text-gray-400">Standard Medlemskap (Ubegrenset)</h4>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input className="w-2/3 p-3 rounded-xl border border-gray-200 font-bold focus:border-[#8bc34a] outline-none" value={draft.edit_standard_navn} onChange={e => updateDraftField(draft.id, 'edit_standard_navn', e.target.value)} placeholder="Navn (eks. Hovedmedlem)" />
|
|
||||||
<input className="w-1/3 p-3 rounded-xl border border-gray-200 font-bold text-right focus:border-[#8bc34a] outline-none" type="number" value={draft.edit_standard_pris} onChange={e => updateDraftField(draft.id, 'edit_standard_pris', e.target.value)} placeholder="Pris" />
|
|
||||||
</div>
|
|
||||||
<input className="w-full p-3 rounded-xl border border-gray-200 text-sm focus:border-[#8bc34a] outline-none" value={draft.edit_standard_kommentar} onChange={e => updateDraftField(draft.id, 'edit_standard_kommentar', e.target.value)} placeholder="Kommentar (F.eks: Inkluderer ikke treningsavgift)" />
|
|
||||||
<p className="text-[10px] text-gray-400">Gammel pris var: {draft.standard_medlemskap ? `kr ${draft.standard_medlemskap} (${draft.navn_standard_medlemskap})` : 'Ikke registrert'}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rimeligste */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="text-xs font-black uppercase tracking-widest text-gray-400">Rimeligste (Betaler Greenfee)</h4>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input className="w-2/3 p-3 rounded-xl border border-gray-200 font-bold focus:border-[#8bc34a] outline-none" value={draft.edit_rimeligste_navn} onChange={e => updateDraftField(draft.id, 'edit_rimeligste_navn', e.target.value)} placeholder="Navn (eks. Greenfeemedlem)" />
|
|
||||||
<input className="w-1/3 p-3 rounded-xl border border-gray-200 font-bold text-right focus:border-[#8bc34a] outline-none" type="number" value={draft.edit_rimeligste_pris} onChange={e => updateDraftField(draft.id, 'edit_rimeligste_pris', e.target.value)} placeholder="Pris" />
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-gray-400 mt-2">Gammel pris var: {draft.rimeligste_alternativ ? `kr ${draft.rimeligste_alternativ} (${draft.navn_rimeligste_alternativ})` : 'Ikke registrert'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,468 +0,0 @@
|
||||||
"use client";
|
|
||||||
/**
|
|
||||||
* TEE OFF ADMIN DASHBOARD v4.0 - KONTROLLPANEL
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
|
||||||
import { API_URL } from "@/config/constants";
|
|
||||||
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: number, field: string, initialValue: string, onSave: (id: number, field: string, val: string) => void }) => {
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [value, setValue] = useState(initialValue || '');
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
setIsEditing(false);
|
|
||||||
if (value !== initialValue) {
|
|
||||||
onSave(facilityId, field, value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEditing) {
|
|
||||||
return (
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="group flex items-start gap-2 cursor-pointer p-1.5 -ml-1.5 rounded-lg hover:bg-white border border-transparent hover:border-gray-200 hover:shadow-sm transition-all" onClick={() => setIsEditing(true)} title="Klikk for å redigere URL">
|
|
||||||
<div className="text-[10px] text-blue-600 break-all max-w-[150px] leading-tight line-clamp-2">
|
|
||||||
{initialValue ? initialValue : <span className="text-red-400 italic">Mangler URL</span>}
|
|
||||||
</div>
|
|
||||||
<span className="opacity-0 group-hover:opacity-100 text-[10px] bg-gray-100 p-1 rounded transition-opacity">✏️</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AdminDashboard() {
|
|
||||||
const [facilities, setFacilities] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [selectedFacilities, setSelectedFacilities] = useState<number[]>([]);
|
|
||||||
const [isScraping, setIsScraping] = useState(false);
|
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
|
||||||
const [editingFacility, setEditingFacility] = useState<any | null>(null);
|
|
||||||
const [activeTab, setActiveTab] = useState<'banestatus' | 'medlemskap' | 'greenfee' | 'vtg'>('banestatus');
|
|
||||||
const [statusFilter, setStatusFilter] = useState('alle');
|
|
||||||
const [editForm, setEditForm] = useState({ scrape_status_url: '', scrape_status_selector: '', scrape_method: '', ai_instruction: '', courses: [] as any[] });
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
|
|
||||||
const fetchFacilities = () => {
|
|
||||||
fetch(`${API_URL}/facilities`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
setFacilities(Array.isArray(data) ? data : []);
|
|
||||||
setLoading(false);
|
|
||||||
})
|
|
||||||
.catch(() => setLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => { fetchFacilities(); }, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let interval: NodeJS.Timeout;
|
|
||||||
if (isScraping) interval = setInterval(() => fetchFacilities(), 10000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [isScraping]);
|
|
||||||
|
|
||||||
useEffect(() => { setSelectedFacilities([]); }, [activeTab]);
|
|
||||||
|
|
||||||
const filteredFacilities = useMemo(() => {
|
|
||||||
if (statusFilter === 'alle') return facilities;
|
|
||||||
return facilities.map(facility => {
|
|
||||||
if (!facility.course_statuses) return facility;
|
|
||||||
const filteredCourses = facility.course_statuses.filter((cs: any) => {
|
|
||||||
const s = cs.status || 'ukjent';
|
|
||||||
if (statusFilter === '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';
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
return { ...facility, course_statuses: filteredCourses };
|
|
||||||
}).filter(facility => facility.course_statuses && facility.course_statuses.length > 0);
|
|
||||||
}, [facilities, statusFilter]);
|
|
||||||
|
|
||||||
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (e.target.checked) setSelectedFacilities(filteredFacilities.map(f => f.id));
|
|
||||||
else setSelectedFacilities([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectOne = (id: number, checked: boolean) => {
|
|
||||||
if (checked) setSelectedFacilities([...selectedFacilities, id]);
|
|
||||||
else setSelectedFacilities(selectedFacilities.filter(facilityId => facilityId !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleQuickEdit = async (id: number, field: string, value: string) => {
|
|
||||||
setFacilities(facilities.map(f => f.id === id ? { ...f, [field]: value } : f));
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/admin/facilities/${id}/quick-edit`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ field, value })
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Feil ved lagring");
|
|
||||||
} catch (e) {
|
|
||||||
alert("Kunne ikke lagre endringen i databasen.");
|
|
||||||
fetchFacilities();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRunScrapers = async () => {
|
|
||||||
if (isScraping) { setIsScraping(false); return; }
|
|
||||||
setIsScraping(true);
|
|
||||||
const endpoint = activeTab === 'banestatus' ? '/admin/run-scraper' :
|
|
||||||
activeTab === 'medlemskap' ? '/admin/run-membership-scraper' :
|
|
||||||
activeTab === 'greenfee' ? '/admin/run-greenfee-scraper' :
|
|
||||||
'/admin/run-vtg-scraper';
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ facility_ids: selectedFacilities })
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error("Kunne ikke starte skraping");
|
|
||||||
const timeoutMs = Math.max(selectedFacilities.length * 40 * 1000, 60000);
|
|
||||||
setSelectedFacilities([]);
|
|
||||||
setTimeout(() => setIsScraping(false), timeoutMs);
|
|
||||||
} catch (error) {
|
|
||||||
alert(`Feil ved start av ${activeTab}-skraperen.`);
|
|
||||||
setIsScraping(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditModal = (facility: any) => {
|
|
||||||
setEditingFacility(facility);
|
|
||||||
setEditForm({
|
|
||||||
scrape_status_url: facility.scrape_status_url || '',
|
|
||||||
scrape_status_selector: facility.scrape_status_selector || '',
|
|
||||||
scrape_method: facility.scrape_method || 'css_selector',
|
|
||||||
ai_instruction: facility.ai_instruction || '',
|
|
||||||
courses: facility.course_statuses ? facility.course_statuses.map((c: any) => ({id: c.id, name: c.name, status: c.status})) : []
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveEdit = async () => {
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_URL}/admin/facilities/${editingFacility.id}/scrape-settings`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(editForm)
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error("Feil ved lagring");
|
|
||||||
setEditingFacility(null);
|
|
||||||
fetchFacilities();
|
|
||||||
} catch (error) {
|
|
||||||
alert("Kunne ikke lagre endringene.");
|
|
||||||
} finally { setIsSaving(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <div className="p-20 text-center font-black animate-pulse">LASTER KONTROLLPANEL...</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen bg-[#f1f7ed] font-sans relative overflow-hidden">
|
|
||||||
|
|
||||||
{/* REDIGER-MODAL FOR BANESTATUS */}
|
|
||||||
{editingFacility && (
|
|
||||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
|
||||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]">
|
|
||||||
<div className="bg-[#11280f] text-white p-6 shrink-0">
|
|
||||||
<h3 className="text-xl font-black uppercase tracking-widest">Skrape-innstillinger</h3>
|
|
||||||
<p className="text-sm text-[#7ca982]">{editingFacility.name}</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-8 space-y-6 overflow-y-auto flex-grow">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Scrape URL (Banestatus)</label>
|
|
||||||
<input type="text" value={editForm.scrape_status_url} onChange={(e) => setEditForm({...editForm, scrape_status_url: e.target.value})} className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors" placeholder="f.eks. https://golfklubb.no/banestatus" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Skrapemetode</label>
|
|
||||||
<select value={editForm.scrape_method} onChange={(e) => setEditForm({...editForm, scrape_method: e.target.value})} className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors">
|
|
||||||
<option value="css_selector">Standard (CSS)</option>
|
|
||||||
<option value="llm_parse">✨ Gemini AI (LLM)</option>
|
|
||||||
<option value="iframe_golfbox">Golfbox iframe</option>
|
|
||||||
<option value="click_then_css">Auto-klikk + CSS</option>
|
|
||||||
<option value="manual">🚨 Manuell (Ikke skrap)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{editForm.scrape_method === 'llm_parse' && (
|
|
||||||
<div className="animate-fade-in">
|
|
||||||
<label className="block text-xs font-bold text-[#8bc34a] uppercase tracking-widest mb-2">✨ AI-Hviskeren (Instruks til Gemini)</label>
|
|
||||||
<textarea value={editForm.ai_instruction || ''} onChange={(e) => setEditForm({...editForm, ai_instruction: e.target.value})} className="w-full border-2 border-[#8bc34a]/30 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors" placeholder="F.eks: Ignorer info om korthullsbanen. Banen er åpen." rows={3} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{editForm.scrape_method === 'manual' && (
|
|
||||||
<div className="bg-red-50 border border-red-100 rounded-xl p-4 animate-fade-in">
|
|
||||||
<label className="block text-xs font-black text-red-500 uppercase tracking-widest mb-4">🚨 Sett Status Manuelt</label>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{editForm.courses.map((course: any, idx: number) => (
|
|
||||||
<div key={course.id} className="flex justify-between items-center bg-white p-3 rounded-lg shadow-sm">
|
|
||||||
<span className="text-xs font-bold text-gray-700 uppercase tracking-widest truncate mr-2" title={course.name}>{course.name}</span>
|
|
||||||
<select value={course.status || 'ukjent'} onChange={(e) => { const newCourses = [...editForm.courses]; newCourses[idx].status = e.target.value; setEditForm({...editForm, courses: newCourses}); }} className="border border-gray-200 rounded-lg p-2 text-xs font-bold focus:outline-none focus:border-red-400 shrink-0">
|
|
||||||
<option value="aapen">🟢 Åpen</option>
|
|
||||||
<option value="aapen_med_vintergreener">🟡 Vintergreener</option>
|
|
||||||
<option value="aapner_snart">🟡 Åpner Snart</option>
|
|
||||||
<option value="stengt">🔴 Stengt</option>
|
|
||||||
<option value="stenger_snart">🔴 Stenger Snart</option>
|
|
||||||
<option value="under_utvikling">🔨 Under Utvikling</option>
|
|
||||||
<option value="nedlagt">⚫ Nedlagt</option>
|
|
||||||
<option value="ukjent">⚪ Ukjent</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(editForm.scrape_method === 'css_selector' || editForm.scrape_method === 'click_then_css' || editForm.scrape_method === 'iframe_golfbox') && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">CSS Selector</label>
|
|
||||||
<input type="text" value={editForm.scrape_status_selector} onChange={(e) => setEditForm({...editForm, scrape_status_selector: e.target.value})} className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors font-mono" placeholder="f.eks. .status-text" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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">
|
|
||||||
{isSaving ? 'Lagrer...' : 'Lagre endringer'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* SIDEBAR */}
|
|
||||||
<aside className={`bg-[#11280f] text-white flex flex-col transition-all duration-300 shrink-0 ${isSidebarCollapsed ? 'w-16 p-4' : 'w-64 p-8'} hidden md:flex`}>
|
|
||||||
<div className={`flex items-center mb-10 ${isSidebarCollapsed ? 'justify-center' : 'justify-between'}`}>
|
|
||||||
{!isSidebarCollapsed && <h1 className="text-2xl font-black uppercase tracking-tighter">TeeOff</h1>}
|
|
||||||
<button onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)} className="text-2xl hover:text-[#8bc34a] transition-colors" title="Skjul/Vis meny">☰</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="space-y-6 text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982] flex-grow">
|
|
||||||
<Link href="/admin" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-[#8bc34a] text-white'}`} title="Kontrollpanel">
|
|
||||||
{isSidebarCollapsed ? 'KP' : 'Kontrollpanel'}
|
|
||||||
</Link>
|
|
||||||
<div className="space-y-2 mt-4">
|
|
||||||
<div className="text-[8px] text-gray-500 font-bold uppercase tracking-widest pl-4 mb-2 opacity-50">Datavask</div>
|
|
||||||
<Link href="/admin/medlemskap" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Medlemskap">
|
|
||||||
{isSidebarCollapsed ? 'M' : 'Medlemskap'}
|
|
||||||
</Link>
|
|
||||||
<Link href="/admin/greenfee" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Greenfee">
|
|
||||||
{isSidebarCollapsed ? 'G' : 'Greenfee'}
|
|
||||||
</Link>
|
|
||||||
<Link href="/admin/vtg" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Veien til Golf (VTG)">
|
|
||||||
{isSidebarCollapsed ? 'V' : 'VTG'}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className={`mt-auto pt-8 border-t border-white/10 ${isSidebarCollapsed ? 'text-center' : ''}`}>
|
|
||||||
<button onClick={() => window.location.href='/'} className={`text-[10px] font-black uppercase tracking-widest text-red-400 hover:text-red-300 ${isSidebarCollapsed ? 'writing-vertical' : ''}`} title="Logg ut">
|
|
||||||
{isSidebarCollapsed ? 'UT' : 'Logg ut'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* HOVEDINNHOLD */}
|
|
||||||
<main className="flex-1 min-w-0 p-4 md:p-8 lg:p-10 h-screen overflow-y-auto">
|
|
||||||
<div className="bg-white rounded-[2rem] shadow-2xl p-6 lg:p-10 border border-white">
|
|
||||||
<header className="flex flex-col xl:flex-row justify-between items-start xl:items-center gap-6 mb-8">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-3xl md:text-4xl font-black tracking-tighter text-[#11280f] mb-2">Kontrollpanel</h2>
|
|
||||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Oversikt over {filteredFacilities.length} anlegg</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleRunScrapers}
|
|
||||||
disabled={selectedFacilities.length === 0 && !isScraping}
|
|
||||||
className={`text-white px-6 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl transition-all whitespace-nowrap
|
|
||||||
${isScraping ? 'bg-yellow-500 animate-pulse cursor-pointer hover:bg-yellow-600' : 'bg-[#8bc34a] hover:scale-105 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed'}`}
|
|
||||||
>
|
|
||||||
{isScraping ? '🤖 Skraper... Klikk for å avslutte' : `Kjør ${activeTab}-skrapere (${selectedFacilities.length})`}
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* VELDIG SYNLIGE FANER */}
|
|
||||||
<div className="flex gap-2 mb-8 border-b-2 border-gray-100 pb-0 overflow-x-auto hide-scrollbar">
|
|
||||||
<button onClick={() => setActiveTab('banestatus')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'banestatus' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Banestatus</button>
|
|
||||||
<button onClick={() => setActiveTab('medlemskap')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'medlemskap' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Medlemskap</button>
|
|
||||||
<button onClick={() => setActiveTab('greenfee')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'greenfee' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Greenfee</button>
|
|
||||||
<button onClick={() => setActiveTab('vtg')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'vtg' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>VTG-Kurs</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === 'banestatus' && (
|
|
||||||
<div className="flex flex-wrap items-center gap-4 bg-gray-50 p-4 rounded-2xl border border-gray-100 mb-8 animate-fade-in">
|
|
||||||
<label htmlFor="statusFilter" className="text-xs font-bold text-gray-500 uppercase tracking-widest">Filtrer på status:</label>
|
|
||||||
<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_stengt">🟡 Ikke stengt (Åpne/Vintergreen/Snart)</option>
|
|
||||||
<option value="stengt">🔴 Kun stengte baner</option>
|
|
||||||
<option value="ukjent_feil">⚪ Ukjent / Skrapefeil</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="overflow-x-auto pb-4">
|
|
||||||
<table className="w-full text-left border-collapse min-w-[900px]">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-[10px] font-black uppercase tracking-widest text-gray-400 border-b border-gray-100">
|
|
||||||
<th className="pb-4 pl-4 w-10"><input type="checkbox" className="w-4 h-4 cursor-pointer accent-[#8bc34a]" checked={selectedFacilities.length === filteredFacilities.length && filteredFacilities.length > 0} onChange={handleSelectAll} /></th>
|
|
||||||
<th className="pb-4 w-12 text-center">ID</th>
|
|
||||||
<th className="pb-4 pr-6">Anlegg</th>
|
|
||||||
|
|
||||||
{activeTab === 'banestatus' && (
|
|
||||||
<>
|
|
||||||
<th className="pb-4">Konfigurasjon (URL & Selektor)</th>
|
|
||||||
<th className="pb-4">Metode</th>
|
|
||||||
<th className="pb-4">Siste Sjekk</th>
|
|
||||||
<th className="pb-4">Banestatus</th>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{activeTab === 'medlemskap' && (
|
|
||||||
<>
|
|
||||||
<th className="pb-4">Medlemskap-side (Klikk for å redigere)</th>
|
|
||||||
<th className="pb-4">Nåværende Priser</th>
|
|
||||||
<th className="pb-4 text-center">Nytt Utkast?</th>
|
|
||||||
<th className="pb-4">Sist Vasket</th>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{activeTab === 'greenfee' && (
|
|
||||||
<>
|
|
||||||
<th className="pb-4">Greenfee-side (Klikk for å redigere)</th>
|
|
||||||
<th className="pb-4">Aktive priser</th>
|
|
||||||
<th className="pb-4 text-center">Nytt Utkast?</th>
|
|
||||||
<th className="pb-4">Sist Vasket</th>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{activeTab === 'vtg' && (
|
|
||||||
<>
|
|
||||||
<th className="pb-4">VTG-side (Klikk for å redigere)</th>
|
|
||||||
<th className="pb-4 w-64">Registrert Informasjon</th>
|
|
||||||
<th className="pb-4 text-center">Nytt Utkast?</th>
|
|
||||||
<th className="pb-4">Sist Vasket</th>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<th className="pb-4 text-right pr-4">Handling</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody className="text-sm font-bold text-[#11280f]">
|
|
||||||
{filteredFacilities.map((f: any) => {
|
|
||||||
const hasMemDraft = f.membership_draft && Object.keys(f.membership_draft).length > 0;
|
|
||||||
const hasGfDraft = f.greenfee_draft && Object.keys(f.greenfee_draft).length > 0;
|
|
||||||
const hasVtgDraft = f.vtg_draft && Object.keys(f.vtg_draft).length > 0;
|
|
||||||
const isHighlighted = (activeTab === 'medlemskap' && hasMemDraft) || (activeTab === 'greenfee' && hasGfDraft) || (activeTab === 'vtg' && hasVtgDraft);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={f.id} className={`border-b border-gray-50 group transition-colors ${isHighlighted ? 'bg-[#8bc34a]/10' : 'hover:bg-gray-50/50'}`}>
|
|
||||||
<td className="py-6 pl-4 w-10"><input type="checkbox" className="w-4 h-4 cursor-pointer accent-[#8bc34a]" checked={selectedFacilities.includes(f.id)} onChange={(e) => handleSelectOne(f.id, e.target.checked)} /></td>
|
|
||||||
<td className="py-6 text-center text-xs font-mono text-gray-400">#{f.id}</td>
|
|
||||||
<td className="py-6 pr-6">
|
|
||||||
<div className="font-black text-base md:text-lg whitespace-nowrap">{f.name}</div>
|
|
||||||
<div className="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{activeTab === 'banestatus' && (
|
|
||||||
<>
|
|
||||||
<td className="py-6 pr-4">
|
|
||||||
<InlineEdit facilityId={f.id} field="scrape_status_url" initialValue={f.scrape_status_url} onSave={handleQuickEdit} />
|
|
||||||
<div className="text-[9px] font-mono text-gray-300 truncate max-w-[150px] mt-1" title={f.scrape_status_selector}>{f.scrape_status_selector}</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-6 pr-4"><ScrapeMethodSelect facility={f} /></td>
|
|
||||||
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
|
|
||||||
<td className="py-6 pr-4">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{f.course_statuses && f.course_statuses.map((cs: any, idx: number) => {
|
|
||||||
let badgeColor = "bg-gray-100 text-gray-500";
|
|
||||||
if (cs.status === "aapen") badgeColor = "bg-green-100 text-green-700";
|
|
||||||
if (cs.status === "stengt" || cs.status === "nedlagt") badgeColor = "bg-red-100 text-red-700";
|
|
||||||
if (cs.status === "aapen_med_vintergreener" || cs.status === "aapner_snart") badgeColor = "bg-yellow-100 text-yellow-700";
|
|
||||||
return (
|
|
||||||
<div key={idx} className="flex items-center gap-2">
|
|
||||||
<span className="text-[9px] uppercase tracking-widest text-gray-400 truncate max-w-[80px]" title={cs.name}>{cs.name}</span>
|
|
||||||
<span className={`px-2 py-0.5 rounded-md text-[9px] font-black uppercase tracking-widest whitespace-nowrap ${badgeColor}`}>{cs.status || 'UKJENT'}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'medlemskap' && (
|
|
||||||
<>
|
|
||||||
<td className="py-6 pr-4"><InlineEdit facilityId={f.id} field="medlemskap_url" initialValue={f.medlemskap_url} onSave={handleQuickEdit} /></td>
|
|
||||||
<td className="py-6 pr-4">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-xs">Standard: <strong>{f.standard_medlemskap ? `${f.standard_medlemskap},-` : '---'}</strong></span>
|
|
||||||
<span className="text-xs text-gray-500">Rimeligste: <strong>{f.rimeligste_alternativ ? `${f.rimeligste_alternativ},-` : '---'}</strong></span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-6 pr-4 text-center">{hasMemDraft ? <span className="px-3 py-1 bg-yellow-100 text-yellow-700 text-xs font-black uppercase tracking-widest rounded-xl animate-pulse">Ja, vask!</span> : <span className="text-gray-300">-</span>}</td>
|
|
||||||
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.membership_updated_at ? new Date(f.membership_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'greenfee' && (
|
|
||||||
<>
|
|
||||||
<td className="py-6 pr-4"><InlineEdit facilityId={f.id} field="greenfee_url" initialValue={f.greenfee_url} onSave={handleQuickEdit} /></td>
|
|
||||||
<td className="py-6 pr-4">
|
|
||||||
<div className="flex flex-col gap-1 text-[10px] text-gray-500 max-h-16 overflow-y-auto pr-2">
|
|
||||||
{f.greenfee && f.greenfee.length > 0 ? f.greenfee.map((g: any, i: number) => (
|
|
||||||
<div key={i} className="flex justify-between border-b border-gray-50 pb-1">
|
|
||||||
<span className="truncate max-w-[120px]">{g.banenavn}</span>
|
|
||||||
<span className="font-bold text-[#11280f]">V: {g.pris_voksne} J: {g.pris_junior}</span>
|
|
||||||
</div>
|
|
||||||
)) : 'Ingen priser'}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-6 pr-4 text-center">{hasGfDraft ? <span className="px-3 py-1 bg-yellow-100 text-yellow-700 text-xs font-black uppercase tracking-widest rounded-xl animate-pulse">Ja, vask!</span> : <span className="text-gray-300">-</span>}</td>
|
|
||||||
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.greenfee_updated_at ? new Date(f.greenfee_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'vtg' && (
|
|
||||||
<>
|
|
||||||
<td className="py-6 pr-4"><InlineEdit facilityId={f.id} field="vtg_lenke" initialValue={f.vtg_lenke} onSave={handleQuickEdit} /></td>
|
|
||||||
<td className="py-6 pr-4 max-w-[250px]">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-xs">Pris: <strong className="text-[#8bc34a]">{f.vtg_pris ? `${f.vtg_pris},-` : '---'}</strong></span>
|
|
||||||
<span className="text-[10px] text-gray-500 line-clamp-2" title={f.vtg_beskrivelse}>{f.vtg_beskrivelse || 'Ingen beskrivelse registrert.'}</span>
|
|
||||||
<span className="text-[10px] font-bold text-[#11280f] mt-1 bg-gray-50 px-2 py-1 rounded-md inline-block w-max">
|
|
||||||
{f.vtg_datoer && f.vtg_datoer.length > 0 ? `📅 ${f.vtg_datoer.length} kursdato(er)` : '📅 Ingen datoer registrert'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-6 pr-4 text-center">{hasVtgDraft ? <span className="px-3 py-1 bg-yellow-100 text-yellow-700 text-xs font-black uppercase tracking-widest rounded-xl animate-pulse">Ja, vask!</span> : <span className="text-gray-300">-</span>}</td>
|
|
||||||
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.vtg_updated_at ? new Date(f.vtg_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<td className="py-6 text-right pr-4">
|
|
||||||
<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>}
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,637 +0,0 @@
|
||||||
"use client";
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
// KOMPONENT 1: MultiSelect for samarbeidende klubber
|
|
||||||
const MultiSelect = ({ label, options, selected, onChange }: { label: string, options: any[], selected: string[], onChange: (s: string[]) => void }) => {
|
|
||||||
const toggle = (val: string) => {
|
|
||||||
if (selected.includes(val)) onChange(selected.filter(x => x !== val));
|
|
||||||
else onChange([...selected, val]);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 mb-8 col-span-1 md:col-span-2">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">{label}</label>
|
|
||||||
<div className="p-4 rounded-2xl border-2 border-gray-300 bg-white shadow-sm max-h-64 overflow-y-auto grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
||||||
{options.map(opt => (
|
|
||||||
<label key={opt.slug} className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded-lg cursor-pointer border border-transparent hover:border-gray-200 transition-all">
|
|
||||||
<input type="checkbox" checked={selected.includes(opt.slug)} onChange={() => toggle(opt.slug)} className="w-5 h-5 accent-[#8bc34a]" />
|
|
||||||
<span className="text-sm font-bold text-gray-700">{opt.name}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// KOMPONENT 2: Viser flate JSON-objekter (som fasiliteter) som rader med Nøkkel og Verdi
|
|
||||||
const KeyValueEditor = ({ label, value, onChange }: { label: string, value: any, onChange: (v: any) => void }) => {
|
|
||||||
const entries = Object.entries(value || {});
|
|
||||||
|
|
||||||
const updateKey = (oldKey: string, newKey: string, val: any) => {
|
|
||||||
const newObj: any = {};
|
|
||||||
for (const [k, v] of entries) {
|
|
||||||
if (k === oldKey) {
|
|
||||||
if (newKey.trim()) newObj[newKey] = val;
|
|
||||||
} else {
|
|
||||||
newObj[k] = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onChange(newObj);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateVal = (key: string, val: string) => {
|
|
||||||
onChange({ ...value, [key]: val });
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeKey = (key: string) => {
|
|
||||||
const newObj = { ...value };
|
|
||||||
delete newObj[key];
|
|
||||||
onChange(newObj);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addRow = () => {
|
|
||||||
const tempKey = `ny_rad_${Date.now()}`;
|
|
||||||
onChange({ ...value, [tempKey]: "" });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4 mb-8 bg-gray-100 p-6 md:p-8 rounded-[2rem] border border-gray-200 shadow-sm">
|
|
||||||
<label className="text-sm font-black uppercase tracking-widest text-[#11280f]">{label}</label>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{entries.map(([k, v]) => (
|
|
||||||
<div key={k} className="flex gap-3 items-center">
|
|
||||||
<input
|
|
||||||
className="w-1/3 p-4 rounded-xl border-2 border-gray-300 text-sm font-bold text-black bg-white focus:border-[#8bc34a] outline-none shadow-sm"
|
|
||||||
placeholder="Nøkkel (f.eks proshop)"
|
|
||||||
defaultValue={k.startsWith('ny_rad_') ? '' : k}
|
|
||||||
onBlur={e => updateKey(k, e.target.value, v)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="w-full p-4 rounded-xl border-2 border-gray-300 text-base font-medium text-black bg-white focus:border-[#8bc34a] outline-none shadow-sm"
|
|
||||||
placeholder="Verdi (f.eks Ja, eller et navn)"
|
|
||||||
value={String(v)}
|
|
||||||
onChange={e => updateVal(k, e.target.value)}
|
|
||||||
/>
|
|
||||||
<button onClick={() => removeKey(k)} className="p-4 bg-red-100 text-red-700 hover:bg-red-200 hover:text-red-900 rounded-xl font-black text-lg transition-colors border border-red-200">✕</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>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// KOMPONENT 3: Viser Arrays med objekter (som Greenfee-lister) som små pene kort
|
|
||||||
const ListObjectEditor = ({ label, value, templateKeys, onChange }: { label: string, value: any[], templateKeys: string[], onChange: (v: any[]) => void }) => {
|
|
||||||
const items = Array.isArray(value) ? value : [];
|
|
||||||
|
|
||||||
const updateField = (index: number, key: string, val: string | number) => {
|
|
||||||
const newItems = [...items];
|
|
||||||
const parsedVal = (!isNaN(Number(val)) && val !== "") ? Number(val) : val;
|
|
||||||
newItems[index] = { ...newItems[index], [key]: parsedVal };
|
|
||||||
onChange(newItems);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addRow = () => {
|
|
||||||
const newItem: any = {};
|
|
||||||
templateKeys.forEach(k => newItem[k] = "");
|
|
||||||
onChange([...items, newItem]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeRow = (index: number) => {
|
|
||||||
const newItems = items.filter((_, i) => i !== index);
|
|
||||||
onChange(newItems);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4 mb-8 bg-gray-100 p-6 md:p-8 rounded-[2rem] border border-gray-200 shadow-sm">
|
|
||||||
<label className="text-sm font-black uppercase tracking-widest text-[#11280f]">{label}</label>
|
|
||||||
<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>
|
|
||||||
<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">
|
|
||||||
<label className="text-xs uppercase font-black text-gray-600 tracking-wider">{key.replace(/_/g, ' ')}</label>
|
|
||||||
<input
|
|
||||||
className="p-3 rounded-lg border-2 border-gray-300 text-base font-bold text-black bg-gray-50 focus:bg-white focus:border-[#8bc34a] outline-none transition-colors"
|
|
||||||
value={item[key] || ""}
|
|
||||||
onChange={e => updateField(idx, key, e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// KOMPONENT 4: DEN NYE SCOREKORT-BYGGEREN
|
|
||||||
const ScorecardBuilder = ({ course, onChange }: { course: any, onChange: (c: any) => void }) => {
|
|
||||||
const ALL_KEYS = ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest'];
|
|
||||||
|
|
||||||
const [holes, setHoles] = useState<any[]>(() => {
|
|
||||||
const h = course.holes || [];
|
|
||||||
if (h.length === 0) {
|
|
||||||
return Array.from({length: 18}, (_, i) => ({ hole_number: i+1, par: '', hcp_index: '', lengths: {} }));
|
|
||||||
}
|
|
||||||
return h.sort((a: any, b: any) => a.hole_number - b.hole_number);
|
|
||||||
});
|
|
||||||
|
|
||||||
const [activeKeys, setActiveKeys] = useState<string[]>(() => {
|
|
||||||
const keys = new Set<string>();
|
|
||||||
holes.forEach(h => {
|
|
||||||
if (h.lengths) Object.keys(h.lengths).forEach(k => keys.add(k));
|
|
||||||
});
|
|
||||||
return ALL_KEYS.filter(k => keys.has(k));
|
|
||||||
});
|
|
||||||
|
|
||||||
const [tees, setTees] = useState<any>(() => {
|
|
||||||
const herrer = course.tee_boxes?.herrer || [];
|
|
||||||
const damer = course.tee_boxes?.damer || [];
|
|
||||||
const initialTees = { herrer: {} as any, damer: {} as any };
|
|
||||||
activeKeys.forEach((key, idx) => {
|
|
||||||
initialTees.herrer[key] = herrer[idx] || { navn_utslag: '', baneverdi: '', slopeverdi: '' };
|
|
||||||
initialTees.damer[key] = damer[idx] || { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' };
|
|
||||||
});
|
|
||||||
return initialTees;
|
|
||||||
});
|
|
||||||
|
|
||||||
const syncToParent = (newHoles: any[], newKeys: string[], newTees: any) => {
|
|
||||||
const updatedTeeBoxes = {
|
|
||||||
herrer: newKeys.map(k => newTees.herrer[k] || {}),
|
|
||||||
damer: newKeys.map(k => newTees.damer[k] || {})
|
|
||||||
};
|
|
||||||
onChange({
|
|
||||||
...course,
|
|
||||||
holes: newHoles,
|
|
||||||
tee_boxes: updatedTeeBoxes
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleKey = (key: string) => {
|
|
||||||
const newKeys = activeKeys.includes(key)
|
|
||||||
? activeKeys.filter(k => k !== key)
|
|
||||||
: ALL_KEYS.filter(k => activeKeys.includes(k) || k === key);
|
|
||||||
setActiveKeys(newKeys);
|
|
||||||
|
|
||||||
const newTees = { ...tees };
|
|
||||||
if (!newTees.herrer[key]) newTees.herrer[key] = { navn_utslag: '', baneverdi: '', slopeverdi: '' };
|
|
||||||
if (!newTees.damer[key]) newTees.damer[key] = { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' };
|
|
||||||
setTees(newTees);
|
|
||||||
syncToParent(holes, newKeys, newTees);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateTee = (gender: 'herrer'|'damer', key: string, field: string, value: string) => {
|
|
||||||
const newTees = { ...tees };
|
|
||||||
newTees[gender][key] = { ...newTees[gender][key], [field]: value };
|
|
||||||
setTees(newTees);
|
|
||||||
syncToParent(holes, activeKeys, newTees);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateHole = (index: number, field: string, value: string, lengthKey: string | null = null) => {
|
|
||||||
const newHoles = [...holes];
|
|
||||||
if (lengthKey) {
|
|
||||||
newHoles[index].lengths = { ...newHoles[index].lengths, [lengthKey]: value === '' ? '' : Number(value) };
|
|
||||||
} else {
|
|
||||||
newHoles[index][field] = value === '' ? '' : Number(value);
|
|
||||||
}
|
|
||||||
setHoles(newHoles);
|
|
||||||
syncToParent(newHoles, activeKeys, tees);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addHole = () => {
|
|
||||||
const newHoles = [...holes, { hole_number: holes.length + 1, par: '', hcp_index: '', lengths: {} }];
|
|
||||||
setHoles(newHoles);
|
|
||||||
syncToParent(newHoles, activeKeys, tees);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeLastHole = () => {
|
|
||||||
const newHoles = holes.slice(0, -1);
|
|
||||||
setHoles(newHoles);
|
|
||||||
syncToParent(newHoles, activeKeys, tees);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4 mt-6">
|
|
||||||
<div className="flex flex-wrap gap-4 items-center bg-gray-100 p-4 rounded-xl border-2 border-gray-200">
|
|
||||||
<span className="text-xs font-black uppercase tracking-widest text-gray-600">Aktive Utslagskolonner:</span>
|
|
||||||
{ALL_KEYS.map(k => (
|
|
||||||
<label key={k} className="flex items-center gap-2 text-sm font-bold cursor-pointer text-black">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={activeKeys.includes(k)}
|
|
||||||
onChange={() => toggleKey(k)}
|
|
||||||
className="w-5 h-5 accent-[#8bc34a]"
|
|
||||||
/>
|
|
||||||
{k.toUpperCase()}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto rounded-2xl border-2 border-gray-300 shadow-sm bg-white pb-2">
|
|
||||||
<table className="w-full text-center text-sm min-w-[800px] border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-gray-100 text-gray-700 text-xs font-black uppercase tracking-widest border-b-2 border-gray-300">
|
|
||||||
<th className="p-3 border-r border-gray-200">Hull</th>
|
|
||||||
<th className="p-3 border-r border-gray-200">Par</th>
|
|
||||||
<th className="p-3 border-r border-gray-300">HCP</th>
|
|
||||||
{activeKeys.map(k => <th key={k} className="p-3 border-r border-gray-300 w-32">{k}</th>)}
|
|
||||||
</tr>
|
|
||||||
{/* Herrer */}
|
|
||||||
<tr className="bg-blue-50 border-b border-gray-300">
|
|
||||||
<th colSpan={3} className="p-3 text-right text-[10px] font-black text-blue-900 uppercase tracking-widest border-r border-gray-300">
|
|
||||||
Herrer (Navn / CR / Slope)
|
|
||||||
</th>
|
|
||||||
{activeKeys.map(k => (
|
|
||||||
<td key={k} className="p-2 border-r border-gray-300 align-top">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<input placeholder="Eks: Gul" className="w-full p-2 text-xs font-bold text-center border border-blue-200 rounded outline-none focus:border-blue-500 bg-white text-black" value={tees.herrer[k]?.navn_utslag || ''} onChange={e => updateTee('herrer', k, 'navn_utslag', e.target.value)} />
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<input placeholder="CR" className="w-1/2 p-2 text-xs text-center border border-blue-200 rounded outline-none focus:border-blue-500 bg-white text-black" value={tees.herrer[k]?.baneverdi || ''} onChange={e => updateTee('herrer', k, 'baneverdi', e.target.value)} />
|
|
||||||
<input placeholder="Slope" className="w-1/2 p-2 text-xs text-center border border-blue-200 rounded outline-none focus:border-blue-500 bg-white text-black" value={tees.herrer[k]?.slopeverdi || ''} onChange={e => updateTee('herrer', k, 'slopeverdi', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
{/* Damer */}
|
|
||||||
<tr className="bg-red-50 border-b-4 border-gray-400">
|
|
||||||
<th colSpan={3} className="p-3 text-right text-[10px] font-black text-red-900 uppercase tracking-widest border-r border-gray-300">
|
|
||||||
Damer (Navn / CR / Slope)
|
|
||||||
</th>
|
|
||||||
{activeKeys.map(k => (
|
|
||||||
<td key={k} className="p-2 border-r border-gray-300 align-top">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<input placeholder="Eks: Rød" className="w-full p-2 text-xs font-bold text-center border border-red-200 rounded outline-none focus:border-red-500 bg-white text-black" value={tees.damer[k]?.navn_utslag_damer || ''} onChange={e => updateTee('damer', k, 'navn_utslag_damer', e.target.value)} />
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<input placeholder="CR" className="w-1/2 p-2 text-xs text-center border border-red-200 rounded outline-none focus:border-red-500 bg-white text-black" value={tees.damer[k]?.baneverdi_damer || ''} onChange={e => updateTee('damer', k, 'baneverdi_damer', e.target.value)} />
|
|
||||||
<input placeholder="Slope" className="w-1/2 p-2 text-xs text-center border border-red-200 rounded outline-none focus:border-red-500 bg-white text-black" value={tees.damer[k]?.slopeverdi_damer || ''} onChange={e => updateTee('damer', k, 'slopeverdi_damer', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{holes.map((h, idx) => (
|
|
||||||
<tr key={idx} className="border-b border-gray-200 hover:bg-gray-50">
|
|
||||||
<td className="p-2 font-black text-lg text-gray-800 border-r border-gray-200">{h.hole_number}</td>
|
|
||||||
<td className="p-2 border-r border-gray-200"><input type="number" className="w-full p-3 text-center border-2 border-gray-200 rounded-xl font-bold text-black outline-none focus:border-[#8bc34a] bg-white" value={h.par || ''} onChange={e => updateHole(idx, 'par', e.target.value)} /></td>
|
|
||||||
<td className="p-2 border-r border-gray-300"><input type="number" className="w-full p-3 text-center border-2 border-gray-200 rounded-xl font-bold text-black outline-none focus:border-[#8bc34a] bg-white" value={h.hcp_index || ''} onChange={e => updateHole(idx, 'hcp_index', e.target.value)} /></td>
|
|
||||||
{activeKeys.map(k => (
|
|
||||||
<td key={k} className="p-2 border-r border-gray-300 bg-gray-50/50">
|
|
||||||
<input type="number" placeholder="Lengde" className="w-full p-3 text-center border-2 border-gray-200 rounded-xl font-mono font-bold text-black outline-none focus:border-[#8bc34a] bg-white" value={h.lengths?.[k] || ''} onChange={e => updateHole(idx, 'lengths', e.target.value, k)} />
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4 px-2">
|
|
||||||
<button onClick={addHole} className="text-sm font-black text-[#8bc34a] hover:text-[#11280f] px-4 py-2 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-2 border-2 border-red-500 rounded-xl">- Slett siste hull</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export default function EditFacilityClient({ initialData, allFacilities }: { initialData: any, allFacilities: any[] }) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [formData, setFormData] = useState(initialData);
|
|
||||||
const [activeTab, setActiveTab] = useState('generelt');
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
// Trekk ut unike arkitekter fra alle anlegg
|
|
||||||
const uniqueArchitects = Array.from(new Set(allFacilities.map(f => f.architect).filter(Boolean))).sort();
|
|
||||||
|
|
||||||
// Sørg for at cooperating_clubs er et array
|
|
||||||
const [coopClubs, setCoopClubs] = useState<string[]>(
|
|
||||||
Array.isArray(initialData.cooperating_clubs) ? initialData.cooperating_clubs :
|
|
||||||
(typeof initialData.cooperating_clubs === 'string' ? JSON.parse(initialData.cooperating_clubs) : [])
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChange = (field: string, value: any) => {
|
|
||||||
setFormData((prev: any) => ({ ...prev, [field]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/admin/facilities/${initialData.id}/full`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(formData)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
alert("Lagret suksessfullt!");
|
|
||||||
router.refresh();
|
|
||||||
} else {
|
|
||||||
alert("Noe gikk galt under lagring.");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert("Nettverksfeil.");
|
|
||||||
}
|
|
||||||
setSaving(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'generelt', label: 'Generelt' },
|
|
||||||
{ id: 'lokasjon', label: 'Lokasjon & Kontakt' },
|
|
||||||
{ id: 'linker', label: 'Lenker & Media' },
|
|
||||||
{ id: 'okonomi', label: 'Økonomi & Medlemskap' },
|
|
||||||
{ id: 'baner', label: 'Baner & Scorekort' }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Hjelpefunksjon for å hente ut verdi (spesielt formatert for dato)
|
|
||||||
const getValue = (field: string, type: string) => {
|
|
||||||
let val = formData[field] || "";
|
|
||||||
if (type === 'date' && val) {
|
|
||||||
val = val.split('T')[0];
|
|
||||||
}
|
|
||||||
return val;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-[1400px] mx-auto p-4 md:p-8 relative z-40 bg-white min-h-screen">
|
|
||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-10 pb-6 border-b border-gray-200 gap-6">
|
|
||||||
<div>
|
|
||||||
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block">← Tilbake til oversikten</Link>
|
|
||||||
<h1 className="text-4xl font-black text-[#11280f]">Rediger: <span className="text-[#8bc34a]">{initialData.name}</span></h1>
|
|
||||||
</div>
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{saving ? "Lagrer..." : "Lagre endringer"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-10">
|
|
||||||
{/* SIDEBAR MENY */}
|
|
||||||
<div className="w-full md:w-1/4 flex flex-col gap-3">
|
|
||||||
{tabs.map(tab => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
className={`p-4 rounded-2xl text-left font-black uppercase text-sm tracking-widest transition-all ${activeTab === tab.id ? 'bg-[#8bc34a] text-white shadow-lg translate-x-2' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SKJEMA OMRÅDE */}
|
|
||||||
<div className="w-full md:w-3/4">
|
|
||||||
{activeTab === 'generelt' && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
|
||||||
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Anleggsnavn</label>
|
|
||||||
<input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('name', 'text')} onChange={e => handleChange('name', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Viktig beskjed (Kursiv intro-tekst)</label>
|
|
||||||
<textarea className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base shadow-sm focus:border-[#8bc34a] outline-none" rows={4} value={getValue('footnote', 'textarea')} onChange={e => handleChange('footnote', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Hovedbeskrivelse</label>
|
|
||||||
<textarea className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base shadow-sm focus:border-[#8bc34a] outline-none" rows={4} value={getValue('description', 'textarea')} onChange={e => handleChange('description', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-8">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Banetype (f.eks Park/Skog)</label>
|
|
||||||
<input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('banetype', 'text')} onChange={e => handleChange('banetype', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-8">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Sesong (f.eks April-Oktober)</label>
|
|
||||||
<input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('season', 'text')} onChange={e => handleChange('season', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-8">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Byggeår</label>
|
|
||||||
<input type="number" className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('established_year', 'number')} onChange={e => handleChange('established_year', Number(e.target.value))} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-8">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Arkitekt</label>
|
|
||||||
<input
|
|
||||||
list="architect-list"
|
|
||||||
className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none transition-all"
|
|
||||||
value={getValue('architect', 'text')}
|
|
||||||
onChange={e => handleChange('architect', e.target.value)}
|
|
||||||
placeholder="Velg eller skriv inn ny..."
|
|
||||||
/>
|
|
||||||
<datalist id="architect-list">
|
|
||||||
<option value="Ukjent" />
|
|
||||||
{uniqueArchitects.map((arch: any) => <option key={arch} value={arch} />)}
|
|
||||||
</datalist>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-8">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Totallengde (meter)</label>
|
|
||||||
<input type="number" className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('length_meters', 'number')} onChange={e => handleChange('length_meters', Number(e.target.value))} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MultiSelect
|
|
||||||
label="Samarbeidende Klubber (Gjestespill etc.)"
|
|
||||||
options={allFacilities.filter(f => f.id !== initialData.id)}
|
|
||||||
selected={coopClubs}
|
|
||||||
onChange={(val) => {
|
|
||||||
setCoopClubs(val);
|
|
||||||
handleChange('cooperating_clubs', val);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'lokasjon' && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
|
||||||
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Gateadresse</label>
|
|
||||||
<input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('address', 'text')} onChange={e => handleChange('address', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Postnummer</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('zipcode', 'text')} onChange={e => handleChange('zipcode', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Poststed / By</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('city', 'text')} onChange={e => handleChange('city', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Fylke</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('county', 'text')} onChange={e => handleChange('county', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Telefon</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('phone', 'text')} onChange={e => handleChange('phone', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">E-post</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('email', 'text')} onChange={e => handleChange('email', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Breddegrad (Latitude)</label><input type="number" className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('lat', 'number')} onChange={e => handleChange('lat', Number(e.target.value))} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Lengdegrad (Longitude)</label><input type="number" className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('lng', 'number')} onChange={e => handleChange('lng', Number(e.target.value))} /></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'linker' && (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Nettside URL</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('website_url', 'text')} onChange={e => handleChange('website_url', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Golfbox Booking URL</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('golfbox_booking_url', 'text')} onChange={e => handleChange('golfbox_booking_url', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Golfbox Turnering URL</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('golfbox_tournament_url', 'text')} onChange={e => handleChange('golfbox_tournament_url', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Baneguide URL</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('baneguide_url', 'text')} onChange={e => handleChange('baneguide_url', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Flyfoto URL</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('flyfoto_url', 'text')} onChange={e => handleChange('flyfoto_url', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Vær URL (YR)</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('weather_url', 'text')} onChange={e => handleChange('weather_url', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Webkamera URL</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('webcam_url', 'text')} onChange={e => handleChange('webcam_url', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Video URL (YouTube/Vimeo)</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('video_url', 'text')} onChange={e => handleChange('video_url', e.target.value)} /></div>
|
|
||||||
|
|
||||||
<ListObjectEditor
|
|
||||||
label="Sosiale Medier (Legg inn f.eks facebook, instagram, linkedin)"
|
|
||||||
value={formData.social_links}
|
|
||||||
templateKeys={['platform', 'url']}
|
|
||||||
onChange={(v) => handleChange('social_links', v)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'okonomi' && (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{/* MEDLEMSKAP */}
|
|
||||||
<div className="bg-gray-100 p-6 rounded-2xl mb-8 border border-gray-200">
|
|
||||||
<h3 className="font-black uppercase tracking-widest text-gray-800 mb-6 pb-2 border-b-2 border-gray-200">Medlemskap</h3>
|
|
||||||
<div className="flex flex-col gap-2 mb-8">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Sist Oppdatert (Dato)</label>
|
|
||||||
<input type="date" className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none w-max" value={getValue('membership_updated_at', 'date')} onChange={e => handleChange('membership_updated_at', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Navn på standard medlemskap</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('navn_standard_medlemskap', 'text')} onChange={e => handleChange('navn_standard_medlemskap', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Pris standard (kun tall)</label><input type="number" className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('standard_medlemskap', 'number')} onChange={e => handleChange('standard_medlemskap', Number(e.target.value))} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8 col-span-1 md:col-span-2"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Kommentar standard</label><textarea className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base shadow-sm focus:border-[#8bc34a] outline-none" rows={2} value={getValue('standard_medlemskap_kommentarer', 'textarea')} onChange={e => handleChange('standard_medlemskap_kommentarer', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Navn på rimeligste</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('navn_rimeligste_alternativ', 'text')} onChange={e => handleChange('navn_rimeligste_alternativ', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Pris rimeligste (kun tall)</label><input type="number" className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('rimeligste_alternativ', 'number')} onChange={e => handleChange('rimeligste_alternativ', Number(e.target.value))} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8 col-span-1 md:col-span-2"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Lenke til medlemskapsside</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('medlemskap_url', 'text')} onChange={e => handleChange('medlemskap_url', e.target.value)} /></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* GREENFEE */}
|
|
||||||
<div className="bg-gray-100 p-6 rounded-2xl border border-gray-200 mb-8">
|
|
||||||
<h3 className="font-black uppercase tracking-widest text-gray-800 mb-6 pb-2 border-b-2 border-gray-200">Greenfee / Gjestespill</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Lenke til Greenfee-side</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('greenfee_url', 'text')} onChange={e => handleChange('greenfee_url', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Krav til Gjestespill (f.eks Klubbhandicap)</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('guest_requirements', 'text')} onChange={e => handleChange('guest_requirements', e.target.value)} /></div>
|
|
||||||
</div>
|
|
||||||
<ListObjectEditor
|
|
||||||
label="Greenfee Priser (Legg til rader for Voksen/Junior etc)"
|
|
||||||
value={formData.greenfee}
|
|
||||||
templateKeys={['banenavn', 'priskategori', 'pris_voksne', 'pris_junior']}
|
|
||||||
onChange={(v) => handleChange('greenfee', v)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* VEIEN TIL GOLF (VTG) */}
|
|
||||||
<div className="bg-[#8bc34a]/10 p-6 rounded-2xl border border-[#8bc34a]/30 mb-8">
|
|
||||||
<h3 className="font-black uppercase tracking-widest text-[#11280f] mb-6 pb-2 border-b-2 border-[#8bc34a]/20">Veien til Golf (VTG)</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Pris VTG kurs (kun tall)</label><input type="number" className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('vtg_pris', 'number')} onChange={e => handleChange('vtg_pris', Number(e.target.value))} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Lenke til VTG påmelding</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('vtg_lenke', 'text')} onChange={e => handleChange('vtg_lenke', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8 col-span-1 md:col-span-2"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Beskrivelse / Hva er inkludert</label><textarea className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base shadow-sm focus:border-[#8bc34a] outline-none" rows={3} value={getValue('vtg_beskrivelse', 'textarea')} onChange={e => handleChange('vtg_beskrivelse', e.target.value)} /></div>
|
|
||||||
</div>
|
|
||||||
<ListObjectEditor
|
|
||||||
label="Kursdatoer"
|
|
||||||
value={formData.vtg_datoer}
|
|
||||||
templateKeys={['dato', 'status']}
|
|
||||||
onChange={(v) => handleChange('vtg_datoer', v)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 border-t-2 border-gray-200 pt-8">
|
|
||||||
<KeyValueEditor label="Fasiliteter (Proshop, Kafé etc.)" value={formData.amenities} onChange={(v) => handleChange('amenities', v)} />
|
|
||||||
<KeyValueEditor label="Norsk Seniorgolf (NSG)" value={formData.nsg_data} onChange={(v) => handleChange('nsg_data', v)} />
|
|
||||||
<KeyValueEditor label="Golfamore Info" value={formData.golfamore_data} onChange={(v) => handleChange('golfamore_data', v)} />
|
|
||||||
|
|
||||||
{/* HER ER GOLFPAKKENE SOM JEG MISTET I FORRIGE RUNDE */}
|
|
||||||
<ListObjectEditor
|
|
||||||
label="Golfpakker"
|
|
||||||
value={formData.golfpakker}
|
|
||||||
templateKeys={['navn', 'pris', 'beskrivelse']}
|
|
||||||
onChange={(v) => handleChange('golfpakker', v)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'baner' && (
|
|
||||||
<div className="flex flex-col gap-8">
|
|
||||||
<div className="bg-[#f1f7ed] p-6 rounded-2xl border-2 border-[#7ca982] mb-4">
|
|
||||||
<h3 className="font-black text-[#11280f] text-lg uppercase tracking-widest mb-2">Baner og Scorekort</h3>
|
|
||||||
<p className="text-sm text-gray-800 font-medium">Bruk det interaktive skjemaet under for å redigere lengder, par og utslag.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formData.courses?.map((course: any, cIdx: number) => (
|
|
||||||
<div key={course.id || cIdx} className="bg-gray-100 p-8 rounded-[2rem] border-2 border-gray-200 shadow-sm mb-8">
|
|
||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4 border-b-2 border-gray-200 pb-4">
|
|
||||||
<h4 className="text-2xl font-black text-black">{course.name}</h4>
|
|
||||||
<span className={`px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest ${course.is_main_course ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-300 text-gray-700'}`}>
|
|
||||||
{course.is_main_course ? 'Hovedbane' : 'Sekundærbane'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Banenavn</label>
|
|
||||||
<input className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.name || ""} onChange={e => {
|
|
||||||
const newCourses = [...formData.courses];
|
|
||||||
newCourses[cIdx] = {...course, name: e.target.value};
|
|
||||||
handleChange('courses', newCourses);
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Status</label>
|
|
||||||
<select className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.status || "ukjent"} onChange={e => {
|
|
||||||
const newCourses = [...formData.courses];
|
|
||||||
newCourses[cIdx] = {...course, status: e.target.value};
|
|
||||||
handleChange('courses', newCourses);
|
|
||||||
}}>
|
|
||||||
<option value="aapen">🟢 Åpen</option>
|
|
||||||
<option value="aapen_med_vintergreener">🟡 Vintergreener</option>
|
|
||||||
<option value="aapner_snart">🟡 Åpner Snart</option>
|
|
||||||
<option value="stengt">🔴 Stengt</option>
|
|
||||||
<option value="nedlagt">⚫ Nedlagt</option>
|
|
||||||
<option value="ukjent">⚪ Ukjent</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Total Par (Bane)</label>
|
|
||||||
<input type="number" className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.par || ""} onChange={e => {
|
|
||||||
const newCourses = [...formData.courses];
|
|
||||||
newCourses[cIdx] = {...course, par: Number(e.target.value)};
|
|
||||||
handleChange('courses', newCourses);
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Utløpsdato Slope</label>
|
|
||||||
<input type="date" className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.slope_valid_until ? course.slope_valid_until.split('T')[0] : ""} onChange={e => {
|
|
||||||
const newCourses = [...formData.courses];
|
|
||||||
newCourses[cIdx] = {...course, slope_valid_until: e.target.value};
|
|
||||||
handleChange('courses', newCourses);
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* DET NYE SCOREKORTET INKLUDERES HER */}
|
|
||||||
<ScorecardBuilder
|
|
||||||
course={course}
|
|
||||||
onChange={(updatedCourse) => {
|
|
||||||
const newCourses = [...formData.courses];
|
|
||||||
newCourses[cIdx] = updatedCourse;
|
|
||||||
handleChange('courses', newCourses);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { API_URL } from "@/config/constants";
|
|
||||||
import EditFacilityClient from "./EditFacilityClient";
|
|
||||||
|
|
||||||
export default async function EditFacilityPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
||||||
const { slug } = await params;
|
|
||||||
|
|
||||||
// Henter anlegget vi skal redigere
|
|
||||||
const res = await fetch(`${API_URL}/facilities/${slug}`, { cache: 'no-store' });
|
|
||||||
const facility = await res.json();
|
|
||||||
|
|
||||||
// Henter ALLE anlegg slik at vi kan bygge lister for samarbeid og arkitekter
|
|
||||||
const allRes = await fetch(`${API_URL}/facilities`, { cache: 'no-store' });
|
|
||||||
const allFacilities = await allRes.json();
|
|
||||||
|
|
||||||
if (!facility || facility.error) {
|
|
||||||
return <div className="p-20 text-center font-bold text-2xl">Fant ikke anlegget...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <EditFacilityClient initialData={facility} allFacilities={allFacilities} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,208 +0,0 @@
|
||||||
"use client";
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { API_URL } from "@/config/constants";
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
export default function VtgWasher() {
|
|
||||||
const [drafts, setDrafts] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const fetchDrafts = () => {
|
|
||||||
setLoading(true);
|
|
||||||
fetch(`${API_URL}/admin/vtg/drafts`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
const editableDrafts = data.map((f: any) => {
|
|
||||||
let parsedDraft = f.vtg_draft;
|
|
||||||
if (typeof parsedDraft === 'string') {
|
|
||||||
try { parsedDraft = JSON.parse(parsedDraft); }
|
|
||||||
catch (e) { console.error("Kunne ikke parse JSON", e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...f,
|
|
||||||
vtg_draft: parsedDraft,
|
|
||||||
edit_pris: parsedDraft?.foreslatt_vtg_pris || f.vtg_pris || '',
|
|
||||||
edit_beskrivelse: parsedDraft?.foreslatt_vtg_beskrivelse || f.vtg_beskrivelse || '',
|
|
||||||
edit_datoer: parsedDraft?.foreslatt_vtg_datoer || []
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setDrafts(editableDrafts);
|
|
||||||
setLoading(false);
|
|
||||||
})
|
|
||||||
.catch(() => setLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => { fetchDrafts(); }, []);
|
|
||||||
|
|
||||||
const toggleSelectAll = (checked: boolean) => {
|
|
||||||
if (checked) setSelectedIds(drafts.map(d => d.id));
|
|
||||||
else setSelectedIds([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleOne = (id: number) => {
|
|
||||||
if (selectedIds.includes(id)) setSelectedIds(selectedIds.filter(i => i !== id));
|
|
||||||
else setSelectedIds([...selectedIds, id]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateField = (facilityId: number, field: string, value: any) => {
|
|
||||||
setDrafts(drafts.map(d => d.id === facilityId ? { ...d, [field]: value } : d));
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateDateRow = (facilityId: number, rowIndex: number, field: string, value: string) => {
|
|
||||||
setDrafts(drafts.map(d => {
|
|
||||||
if (d.id === facilityId) {
|
|
||||||
const newDates = [...d.edit_datoer];
|
|
||||||
newDates[rowIndex] = { ...newDates[rowIndex], [field]: value };
|
|
||||||
return { ...d, edit_datoer: newDates };
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addDateRow = (facilityId: number) => {
|
|
||||||
setDrafts(drafts.map(d => {
|
|
||||||
if (d.id === facilityId) {
|
|
||||||
return { ...d, edit_datoer: [...d.edit_datoer, { dato: '', status: 'Ledig' }] };
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeDateRow = (facilityId: number, rowIndex: number) => {
|
|
||||||
setDrafts(drafts.map(d => {
|
|
||||||
if (d.id === facilityId) {
|
|
||||||
const newDates = [...d.edit_datoer];
|
|
||||||
newDates.splice(rowIndex, 1);
|
|
||||||
return { ...d, edit_datoer: newDates };
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApprove = async () => {
|
|
||||||
const toApprove = drafts.filter(d => selectedIds.includes(d.id)).map(d => ({
|
|
||||||
facility_id: d.id,
|
|
||||||
vtg_pris: Number(d.edit_pris) || null,
|
|
||||||
vtg_beskrivelse: d.edit_beskrivelse,
|
|
||||||
vtg_datoer: d.edit_datoer
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (toApprove.length === 0) return alert("Velg minst ett anlegg å godkjenne.");
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/admin/vtg/approve-bulk`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ approvals: toApprove })
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
alert(`${toApprove.length} anlegg oppdatert!`);
|
|
||||||
setSelectedIds([]);
|
|
||||||
fetchDrafts();
|
|
||||||
} else {
|
|
||||||
alert("Noe gikk galt under lagring.");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert("Nettverksfeil");
|
|
||||||
}
|
|
||||||
setSaving(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <div className="p-20 text-center font-black animate-pulse">Laster VTG-utkast...</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#f1f7ed] p-8 text-[#11280f]">
|
|
||||||
<div className="max-w-[1400px] mx-auto">
|
|
||||||
<div className="flex justify-between items-end mb-10 border-b border-gray-200 pb-6">
|
|
||||||
<div>
|
|
||||||
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block">← Tilbake til oversikten</Link>
|
|
||||||
<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="bg-[#8bc34a] text-white px-8 py-4 rounded-xl font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50">
|
|
||||||
{saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{drafts.length === 0 ? (
|
|
||||||
<div className="bg-white p-20 rounded-[2rem] text-center shadow-sm">
|
|
||||||
<span className="text-6xl mb-4 block">🧹</span>
|
|
||||||
<h2 className="text-2xl font-black text-gray-400">Ingen ventende VTG-utkast!</h2>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-white p-4 rounded-2xl shadow-sm flex items-center gap-4">
|
|
||||||
<input type="checkbox" className="w-5 h-5 accent-[#8bc34a] ml-2" checked={selectedIds.length === drafts.length && drafts.length > 0} onChange={(e) => toggleSelectAll(e.target.checked)} />
|
|
||||||
<span className="font-black uppercase tracking-widest text-xs text-gray-500">Velg Alle</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{drafts.map(draft => (
|
|
||||||
<div key={draft.id} className={`bg-white p-6 rounded-3xl shadow-sm border-2 transition-all ${selectedIds.includes(draft.id) ? 'border-[#8bc34a] bg-[#8bc34a]/5' : 'border-transparent'}`}>
|
|
||||||
<div className="flex gap-6 items-start">
|
|
||||||
<div className="pt-2"><input type="checkbox" className="w-6 h-6 accent-[#8bc34a] cursor-pointer" checked={selectedIds.includes(draft.id)} onChange={() => toggleOne(draft.id)} /></div>
|
|
||||||
<div className="flex-grow space-y-4">
|
|
||||||
<div className="flex justify-between items-center border-b pb-4">
|
|
||||||
<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="text-xs font-bold text-blue-600 hover:underline bg-blue-50 px-4 py-2 rounded-lg">Sjekk Nettside ↗</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{draft.vtg_draft?.ai_begrunnelse && (
|
|
||||||
<div className="bg-blue-50/50 p-4 rounded-xl text-sm italic text-blue-900 border border-blue-100">
|
|
||||||
<strong>🤖 AI Begrunnelse:</strong> {draft.vtg_draft.ai_begrunnelse}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
||||||
{/* Pris & Beskrivelse */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h4 className="text-xs font-black uppercase tracking-widest text-green-600">Pris & Beskrivelse</h4>
|
|
||||||
<div>
|
|
||||||
<label className="text-[10px] font-bold text-gray-500 uppercase">Standardpris for Voksen (kr)</label>
|
|
||||||
<input className="w-full mt-1 p-3 rounded-xl border border-gray-200 text-sm focus:border-[#8bc34a] outline-none" type="number" value={draft.edit_pris} onChange={e => updateField(draft.id, 'edit_pris', e.target.value)} placeholder="Eks: 1990" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-[10px] font-bold text-gray-500 uppercase">Selgende tekst / Inkludert i kurset</label>
|
|
||||||
<textarea className="w-full mt-1 p-3 rounded-xl border border-gray-200 text-sm focus:border-[#8bc34a] outline-none resize-y" rows={5} value={draft.edit_beskrivelse} onChange={e => updateField(draft.id, 'edit_beskrivelse', e.target.value)} placeholder="Beskriv kurset..." />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Kursdatoer */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-black uppercase tracking-widest text-green-600 mb-4">Kursdatoer</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{draft.edit_datoer.length === 0 ? (
|
|
||||||
<div className="p-4 bg-gray-50 rounded-xl text-sm text-gray-500 italic">Fant ingen spesifikke kursdatoer.</div>
|
|
||||||
) : (
|
|
||||||
draft.edit_datoer.map((row: any, idx: number) => (
|
|
||||||
<div key={idx} className="flex gap-2 items-center bg-white border border-gray-200 p-2 rounded-lg relative group">
|
|
||||||
<input className="flex-grow p-2 rounded border border-gray-100 text-xs font-bold focus:border-[#8bc34a] outline-none" value={row.dato} onChange={e => updateDateRow(draft.id, idx, 'dato', e.target.value)} placeholder="F.eks: 12.-14. mai" />
|
|
||||||
<select className="w-32 p-2 rounded border border-gray-100 text-xs focus:border-[#8bc34a] outline-none bg-white" value={row.status} onChange={e => updateDateRow(draft.id, idx, 'status', e.target.value)}>
|
|
||||||
<option value="Ledig">Ledig</option>
|
|
||||||
<option value="Fulltegnet">Fulltegnet</option>
|
|
||||||
<option value="Venteliste">Venteliste</option>
|
|
||||||
<option value="Få plasser">Få plasser</option>
|
|
||||||
</select>
|
|
||||||
<button onClick={() => removeDateRow(draft.id, idx)} className="text-red-400 hover:text-red-600 px-2 opacity-0 group-hover:opacity-100 transition-opacity" title="Slett dato">✕</button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
<button onClick={() => addDateRow(draft.id)} className="text-xs font-bold text-[#8bc34a] hover:underline mt-2 inline-block">
|
|
||||||
+ Legg til ny dato
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
"use client";
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { STATUS_MAP } from "@/config/constants";
|
|
||||||
|
|
||||||
// Designerens definisjon av fargetemaer - Nå med kraftigere tints for kolonnene
|
|
||||||
const getTeeTheme = (label: string) => {
|
|
||||||
const name = label.toLowerCase();
|
|
||||||
if (name.includes("svart") || name.includes("black")) {
|
|
||||||
return { header: "bg-gray-900 text-white", col: "bg-gray-100", text: "text-gray-900" };
|
|
||||||
}
|
|
||||||
if (name.includes("hvit") || name.includes("white")) {
|
|
||||||
return { header: "bg-white text-gray-800 border border-gray-300", col: "bg-gray-50", text: "text-gray-700" };
|
|
||||||
}
|
|
||||||
if (name.includes("gul") || name.includes("yellow")) {
|
|
||||||
return { header: "bg-yellow-400 text-yellow-950", col: "bg-yellow-50", text: "text-yellow-900" };
|
|
||||||
}
|
|
||||||
if (name.includes("blå") || name.includes("bla") || name.includes("blue")) {
|
|
||||||
return { header: "bg-blue-600 text-white", col: "bg-blue-50", text: "text-blue-900" };
|
|
||||||
}
|
|
||||||
if (name.includes("rød") || name.includes("rod") || name.includes("red")) {
|
|
||||||
return { header: "bg-red-500 text-white", col: "bg-red-50", text: "text-red-900" };
|
|
||||||
}
|
|
||||||
if (name.includes("grønn") || name.includes("gronn") || name.includes("green")) {
|
|
||||||
return { header: "bg-emerald-500 text-white", col: "bg-emerald-50", text: "text-emerald-900" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// DEFAULT: Nøytral grå for utslag med tall (f.eks "52", "45")
|
|
||||||
return { header: "bg-gray-200 text-gray-700", col: "bg-gray-100/60", text: "text-gray-600" };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CourseDisplay({ course }: { course: any }) {
|
|
||||||
const [hcp, setHcp] = useState("15.0");
|
|
||||||
const [gender, setGender] = useState<'herrer' | 'damer'>('herrer');
|
|
||||||
const [selectedTeeIndex, setSelectedTeeIndex] = useState(0);
|
|
||||||
|
|
||||||
const allHoles = course.holes || [];
|
|
||||||
const holesOut = allHoles.filter((h: any) => h.hole_number <= 9);
|
|
||||||
const holesIn = allHoles.filter((h: any) => h.hole_number > 9);
|
|
||||||
const hasInHoles = holesIn.length > 0;
|
|
||||||
|
|
||||||
const lengthKeys = ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest'];
|
|
||||||
const availableTees = course.tee_boxes?.[gender] || [];
|
|
||||||
|
|
||||||
const activeColumns = lengthKeys
|
|
||||||
.filter(k => allHoles.some((h: any) => h.lengths?.[k]))
|
|
||||||
.map((key, idx) => {
|
|
||||||
const info = availableTees[idx];
|
|
||||||
const label = info?.navn_utslag || info?.navn_utslag_damer || key.toUpperCase();
|
|
||||||
return { key, label, theme: getTeeTheme(label) };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Kalkulering av SpH
|
|
||||||
const activeTee = availableTees[selectedTeeIndex];
|
|
||||||
let playingHandicap = 0;
|
|
||||||
|
|
||||||
if (activeTee && hcp) {
|
|
||||||
const exactHcp = Number(hcp.replace(',', '.'));
|
|
||||||
const slope = Number(activeTee.slopeverdi || activeTee.slopeverdi_damer || 113);
|
|
||||||
const cr = Number(String(activeTee.baneverdi || activeTee.baneverdi_damer || course.par).replace(',', '.'));
|
|
||||||
playingHandicap = Math.round((exactHcp * (slope / 113)) + (cr - course.par));
|
|
||||||
}
|
|
||||||
|
|
||||||
const getExtraStrokes = (hcpIndex: number) => {
|
|
||||||
if (!hcpIndex || isNaN(playingHandicap)) return 0;
|
|
||||||
const base = Math.floor(playingHandicap / 18);
|
|
||||||
const rem = playingHandicap % 18;
|
|
||||||
return base + (hcpIndex <= rem ? 1 : 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sumPar = (holes: any[]) => holes.reduce((acc, h) => acc + (h.par || 0), 0);
|
|
||||||
const sumLen = (holes: any[], key: string) => holes.reduce((acc, h) => acc + (h.lengths?.[key] || 0), 0);
|
|
||||||
|
|
||||||
// Formater utløpsdato
|
|
||||||
const slopeExpiry = course.slope_valid_until
|
|
||||||
? new Date(course.slope_valid_until).toLocaleDateString('nb-NO', { year: 'numeric', month: 'short', day: 'numeric' })
|
|
||||||
: 'Ukjent';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-[3rem] shadow-sm border border-gray-200 overflow-hidden mb-12">
|
|
||||||
|
|
||||||
{/* HEADER / KALKULATOR */}
|
|
||||||
<div className="p-8 md:p-12 flex flex-col md:flex-row justify-between items-center gap-8 border-b border-gray-100 bg-white">
|
|
||||||
<div className="text-center md:text-left">
|
|
||||||
<h2 className="text-5xl font-black text-[#11280f] tracking-tighter">{course.name}</h2>
|
|
||||||
<p className="text-[#7ca982] font-black uppercase text-xs tracking-[0.2em] mt-2 mb-1">
|
|
||||||
Par {course.par} • {course.length_meters || '--'} meter
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-400 text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Rating utløper: {slopeExpiry}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-6 bg-gray-50 p-6 rounded-[2.5rem] border border-gray-100">
|
|
||||||
<div className="flex flex-col"><span className="text-[9px] font-black text-[#7ca982] uppercase ml-1">Kjønn</span>
|
|
||||||
<select value={gender} onChange={e => { setGender(e.target.value as any); setSelectedTeeIndex(0); }} className="bg-transparent text-[#11280f] font-black outline-none border-b-2 border-[#7ca982]/30 pb-1 cursor-pointer">
|
|
||||||
<option value="herrer">HERRER</option><option value="damer">DAMER</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col"><span className="text-[9px] font-black text-[#7ca982] uppercase ml-1">Utslag</span>
|
|
||||||
<select value={selectedTeeIndex} onChange={e => setSelectedTeeIndex(Number(e.target.value))} className="bg-transparent text-[#11280f] font-black outline-none border-b-2 border-[#7ca982]/30 pb-1 cursor-pointer">
|
|
||||||
{availableTees.map((t: any, i: number) => (<option key={i} value={i}>{t.navn_utslag || t.navn_utslag_damer}</option>))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col"><span className="text-[9px] font-black text-[#7ca982] uppercase ml-1">Ditt HCP</span>
|
|
||||||
<input type="text" value={hcp} onChange={e => setHcp(e.target.value)} className="w-12 bg-transparent text-[#11280f] font-black text-center border-b-2 border-[#7ca982]/30" />
|
|
||||||
</div>
|
|
||||||
<div className="pl-6 border-l border-gray-200 text-center">
|
|
||||||
<p className="text-[9px] uppercase font-black text-[#7ca982] mb-1">SpH</p>
|
|
||||||
<p className="text-4xl font-black text-[#11280f] leading-none">{playingHandicap || 0}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SCOREKORT TABELL */}
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-center border-collapse table-fixed min-w-[850px]">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-white text-[10px] text-gray-400 font-black uppercase">
|
|
||||||
<th className="w-20 p-5 text-left pl-10 border-b border-gray-100">Hull</th>
|
|
||||||
<th className="w-16 p-5 border-l border-gray-100 border-b border-gray-100">Par</th>
|
|
||||||
<th className="w-16 p-5 border-l border-gray-100 border-b border-gray-100">HCP</th>
|
|
||||||
<th className="w-24 p-5 border-l border-gray-100 border-b border-gray-100 bg-[#7ca982]/10 text-[#7ca982]">Mottatt</th>
|
|
||||||
<th className="w-24 p-5 border-l border-gray-100 border-b border-gray-100 bg-[#7ca982]/20 text-[#11280f]">Din Par</th>
|
|
||||||
{activeColumns.map((col, i) => (
|
|
||||||
<th key={i} className={`p-5 border-l border-white font-black ${col.theme.header}`}>{col.label}</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="font-bold text-[#11280f]">
|
|
||||||
{/* UT-RUNDE */}
|
|
||||||
{holesOut.map((h: any) => {
|
|
||||||
const extra = getExtraStrokes(h.hcp_index);
|
|
||||||
return (
|
|
||||||
<tr key={h.id} className="border-t border-gray-100 group hover:bg-white transition-colors">
|
|
||||||
<td className="p-4 text-left pl-10 font-black text-lg text-gray-800">{h.hole_number}</td>
|
|
||||||
<td className="p-4 border-l border-gray-100 bg-white">{h.par}</td>
|
|
||||||
<td className="p-4 border-l border-gray-100 text-gray-300 text-xs font-mono">{h.hcp_index}</td>
|
|
||||||
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/5 text-[#7ca982] font-mono">{extra > 0 ? `+${extra}` : '-'}</td>
|
|
||||||
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/10 text-lg font-mono">{h.par + extra}</td>
|
|
||||||
{activeColumns.map((col, i) => (
|
|
||||||
<td key={i} className={`p-4 border-l border-white font-mono transition-all ${col.theme.col} ${col.theme.text}`}>
|
|
||||||
{h.lengths?.[col.key] || '--'}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* UT RAD */}
|
|
||||||
<tr className="bg-[#f1f7ed]/50 text-[#11280f] font-black border-y border-gray-200">
|
|
||||||
<td className="p-4 text-left pl-10 uppercase tracking-widest text-[10px] text-gray-400">Ut</td>
|
|
||||||
<td className="p-4 border-l border-gray-100">{sumPar(holesOut)}</td>
|
|
||||||
<td colSpan={3} className="border-l border-gray-100 bg-white"></td>
|
|
||||||
{activeColumns.map((col, i) => (
|
|
||||||
<td key={i} className={`p-4 border-l border-white font-mono ${col.theme.col} text-gray-900`}>{sumLen(holesOut, col.key)}</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{/* INN-RUNDE */}
|
|
||||||
{hasInHoles && holesIn.map((h: any) => {
|
|
||||||
const extra = getExtraStrokes(h.hcp_index);
|
|
||||||
return (
|
|
||||||
<tr key={h.id} className="border-t border-gray-100 group hover:bg-white transition-colors">
|
|
||||||
<td className="p-4 text-left pl-10 font-black text-lg text-gray-800">{h.hole_number}</td>
|
|
||||||
<td className="p-4 border-l border-gray-100 bg-white">{h.par}</td>
|
|
||||||
<td className="p-4 border-l border-gray-100 text-gray-300 text-xs font-mono">{h.hcp_index}</td>
|
|
||||||
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/5 text-[#7ca982] font-mono">{extra > 0 ? `+${extra}` : '-'}</td>
|
|
||||||
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/10 text-lg font-mono">{h.par + extra}</td>
|
|
||||||
{activeColumns.map((col, i) => (
|
|
||||||
<td key={i} className={`p-4 border-l border-white font-mono transition-all ${col.theme.col} ${col.theme.text}`}>
|
|
||||||
{h.lengths?.[col.key] || '--'}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* INN RAD */}
|
|
||||||
{hasInHoles && (
|
|
||||||
<tr className="bg-[#f1f7ed]/50 text-[#11280f] font-black border-y border-gray-200">
|
|
||||||
<td className="p-4 text-left pl-10 uppercase tracking-widest text-[10px] text-gray-400">Inn</td>
|
|
||||||
<td className="p-4 border-l border-gray-100">{sumPar(holesIn)}</td>
|
|
||||||
<td colSpan={3} className="border-l border-gray-100 bg-white"></td>
|
|
||||||
{activeColumns.map((col, i) => (
|
|
||||||
<td key={i} className={`p-4 border-l border-white font-mono ${col.theme.col} text-gray-900`}>{sumLen(holesIn, col.key)}</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* TOTAL RAD */}
|
|
||||||
<tr className="bg-[#11280f] text-white text-xl font-black">
|
|
||||||
<td className="p-8 text-left pl-10 uppercase tracking-tighter">Totalt</td>
|
|
||||||
<td className="p-8 border-l border-white/10">{sumPar(allHoles)}</td>
|
|
||||||
<td colSpan={3} className="border-l border-white/10 bg-[#1a3a17]"></td>
|
|
||||||
{activeColumns.map((col, i) => (
|
|
||||||
<td key={i} className={`p-8 border-l border-white/10 font-mono ${col.theme.header.split(' ')[0]}`}>
|
|
||||||
{sumLen(allHoles, col.key)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,543 +0,0 @@
|
||||||
"use client";
|
|
||||||
/**
|
|
||||||
* TEE OFF DETAIL VIEW - COMPLETE v3.4 (FINAL LAYOUT FIX)
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
* FIX: Gjenopprettet "Turneringer" i den flytende knapperaden over bildet.
|
|
||||||
* FIX: Byttet plass på tekst og sidebar (Tekst øverst på mobil).
|
|
||||||
* FIX: Økt padding (pb-32) i Hero-teksten på mobil for å unngå krasj med knapper.
|
|
||||||
* FIX: Alle 4 kontaktpunkter i sidebar er klikkbare (tel:0047 fix inkludert).
|
|
||||||
* NEW: Sosiale Medier, Footnote og Samarbeidende klubber integrert.
|
|
||||||
* NEW: Priser (Medlemskap + Greenfee) i 2-kolonne Grid (xl:grid-cols-2).
|
|
||||||
* NEW: Veien til Golf (VTG) i full bredde under prisene, med robust array-parsing.
|
|
||||||
* REGEL: Beholder monokrome ikoner, 22/78 layout og robust JSON-parsing.
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { STATUS_MAP, FALLBACK_IMAGE } from "@/config/constants";
|
|
||||||
import Link from 'next/link';
|
|
||||||
import CourseDisplay from './CourseDisplay';
|
|
||||||
|
|
||||||
const formatPhoneForUrl = (phone: string) => {
|
|
||||||
if (!phone) return "";
|
|
||||||
return phone.replace('+', '00').replace(/\s/g, '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderValue = (val: string) => {
|
|
||||||
if (!val) return "Nei";
|
|
||||||
const hasLink = val.includes('<a');
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={hasLink ? "text-[#ff5722] font-bold hover:underline" : "text-[#11280f]"}
|
|
||||||
dangerouslySetInnerHTML={{ __html: val }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Icon = ({ children, className = "w-5 h-5" }: { children: React.ReactNode, className?: string }) => (
|
|
||||||
<svg
|
|
||||||
className={`${className} flex-shrink-0 text-current`}
|
|
||||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
|
||||||
style={{ width: '20px', height: '20px' }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ICONS = {
|
|
||||||
web: <><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></>,
|
|
||||||
phone: <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />,
|
|
||||||
mail: <><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" /><polyline points="22,6 12,13 2,6" /></>,
|
|
||||||
pin: <><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" /><circle cx="12" cy="10" r="3" /></>,
|
|
||||||
booking: <><path d="M3 10h18M7 15h.01M11 15h.01M15 15h.01M7 19h.01M11 19h.01M15 19h.01M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2zM16 3v4M8 3v4"/></>,
|
|
||||||
trophy: <><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22M18 2H6v7a6 6 0 0 0 12 0V2z"/></>,
|
|
||||||
guide: <><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 4.5A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1-2.5-2.5V4.5z"/></>,
|
|
||||||
camera: <><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></>,
|
|
||||||
webcam: <><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></>,
|
|
||||||
chart: <><path d="M18 20V10M12 20V4M6 20v-6"/></>,
|
|
||||||
weather: <><path d="M12 2v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="M20 12h2"/><path d="m19.07 4.93-1.41 1.41"/><path d="M15.947 12.65a4 4 0 0 0-5.925-4.128"/><path d="M13 22H7a5 5 0 1 1 4.9-6H13a3 3 0 0 1 0 6Z"/></>
|
|
||||||
};
|
|
||||||
|
|
||||||
const SOCIAL_ICONS: Record<string, React.ReactNode> = {
|
|
||||||
facebook: <path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />,
|
|
||||||
instagram: <><rect x="2" y="2" width="20" height="20" rx="5" ry="5" /><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" /><line x1="17.5" y1="6.5" x2="17.51" y2="6.5" /></>,
|
|
||||||
twitter: <path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z" />,
|
|
||||||
x: <path d="M4 4l16 16M4 20L20 4" />,
|
|
||||||
linkedin: <><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" /><rect x="2" y="9" width="4" height="12" /><circle cx="4" cy="4" r="2" /></>,
|
|
||||||
youtube: <><path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33 2.78 2.78 0 0 0 1.94 2c1.72.46 8.6.46 8.6.46s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.33 29 29 0 0 0-.46-5.33z" /><polygon points="9.75 15.02 15.5 11.75 9.75 8.48 9.75 15.02" /></>,
|
|
||||||
tiktok: <path d="M9 12a4 4 0 1 0 4 4V2a5 5 0 0 0 5 5h-2a3 3 0 0 1-3-3V16a2 2 0 1 1-2-2v-2z" />,
|
|
||||||
snapchat: <path d="M12 2C8.5 2 6 5 6 8.5c0 1.5.5 3 1.5 4-1 .5-2.5 1-3.5 1-.5 0-1 .5-1 1s.5 1 1.5 1h15c1 0 1.5-.5 1.5-1s-.5-1-1-1c-1 0-2.5-.5-3.5-1 1-1 1.5-2.5 1.5-4C18 5 15.5 2 12 2zm0 15c-3 0-5-1-5-1s.5 1.5 1.5 2h7C16.5 17.5 17 16 17 16s-2 1-5 1z" />
|
|
||||||
};
|
|
||||||
|
|
||||||
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; }
|
|
||||||
};
|
|
||||||
|
|
||||||
const rawCourses = parseJson(facility.courses, []);
|
|
||||||
const activeCourses = Array.isArray(rawCourses) ? rawCourses.filter((c: any) => c.holes && (typeof c.holes === 'string' || c.holes.length > 0)) : [];
|
|
||||||
const amenities = parseJson(facility.amenities, {});
|
|
||||||
const galleryRaw = parseJson(facility.gallery, []);
|
|
||||||
const gallery = galleryRaw.length > 0 ? galleryRaw : [facility.image_url || FALLBACK_IMAGE];
|
|
||||||
const shotzoom = parseJson(facility.shotzoom, []);
|
|
||||||
|
|
||||||
// Pris og kurs-arrays
|
|
||||||
const greenfeeRaw = parseJson(facility.greenfee, []);
|
|
||||||
const vtgDatoer = parseJson(facility.vtg_datoer, []);
|
|
||||||
|
|
||||||
const golfamoreData = parseJson(facility.golfamore_data, {});
|
|
||||||
const nsgData = parseJson(facility.nsg_data, {});
|
|
||||||
const socialLinksRaw = parseJson(facility.social_links, []);
|
|
||||||
const socialLinks = Array.isArray(socialLinksRaw) ? socialLinksRaw : [];
|
|
||||||
|
|
||||||
const coopClubsRaw = parseJson(facility.cooperating_clubs, []);
|
|
||||||
const cooperatingClubs = Array.isArray(coopClubsRaw) ? coopClubsRaw : [];
|
|
||||||
|
|
||||||
const hasGolfamore = facility.golfamore === true;
|
|
||||||
const hasNSG = facility.nsg_url || (nsgData && Object.keys(nsgData).length > 0);
|
|
||||||
|
|
||||||
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";
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (gallery.length <= 1) return;
|
|
||||||
const timer = setInterval(() => setCurrentSlide((p) => (p + 1) % gallery.length), 5000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [gallery.length]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleScroll = () => setShowBackToTop(window.scrollY > 500);
|
|
||||||
window.addEventListener('scroll', handleScroll);
|
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const scrollTo = (id: string) => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el) window.scrollTo({ top: el.getBoundingClientRect().top + window.pageYOffset - 80, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString('nb-NO', { day: 'numeric', month: 'long', year: 'numeric' }) : null;
|
|
||||||
const weatherImg = facility.weather_url?.replace("/graf/dag/", "/innhold/").replace(/\/$/, "") + "/meteogram.svg";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-[#f1f7ed] pb-20 relative font-sans text-[#11280f]">
|
|
||||||
|
|
||||||
{/* 1. HERO SLIDER */}
|
|
||||||
<div className="h-[55vh] min-h-[450px] relative overflow-hidden bg-[#11280f]">
|
|
||||||
{gallery.map((img: string, i: number) => (
|
|
||||||
<img key={i} src={img} className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-1000 ${i === currentSlide ? 'opacity-100 z-10' : 'opacity-0 z-0'}`} alt="" />
|
|
||||||
))}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-[#11280f]/90 via-transparent to-black/10 z-20" />
|
|
||||||
|
|
||||||
{/* BANESTATUS BADGES */}
|
|
||||||
<div className="absolute top-8 right-8 z-40 flex flex-col items-end gap-2">
|
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
|
||||||
{activeCourses.map((c: any) => (
|
|
||||||
<span key={c.id} className="px-3 py-1.5 rounded-lg text-[10px] font-black uppercase bg-[#7ca982] text-white shadow-xl">
|
|
||||||
{c.name.toUpperCase()}: {STATUS_MAP[c.status] || c.status}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{facility.status_updated_at && (
|
|
||||||
<span className="text-white/60 text-[10px] uppercase font-black tracking-widest bg-black/20 px-2 py-1 rounded">
|
|
||||||
Sist oppdatert: {formatDate(facility.status_updated_at)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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=$?q=${facility.lat},${facility.lng}`} 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.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>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* HERO TEXT */}
|
|
||||||
<div className="relative z-30 max-w-[1200px] mx-auto px-6 w-full h-full flex flex-col justify-end pb-32 md:pb-12">
|
|
||||||
{facility.logo_url && (
|
|
||||||
<div className="hidden md:block mb-8 w-24 h-24 bg-white p-2 rounded-2xl shadow-2xl border-4 border-white/20 overflow-hidden"><img src={facility.logo_url} className="w-full h-full object-contain" alt="Logo" /></div>
|
|
||||||
)}
|
|
||||||
<h1 className="text-5xl md:text-8xl font-black text-white mb-3 tracking-tighter drop-shadow-2xl">{facility.name}</h1>
|
|
||||||
<p className="text-[#7ca982] uppercase tracking-[0.4em] font-black text-xs md:text-sm pl-1">{facility.county} • {facility.city}</p>
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="max-w-[1200px] mx-auto px-0 md:px-6 space-y-4 md:space-y-12 mt-0 md:mt-12">
|
|
||||||
|
|
||||||
{/* 3. INTRO & SIDEBAR */}
|
|
||||||
<section id="intro" className="flex flex-col lg:flex-row gap-0 md:gap-8 items-stretch">
|
|
||||||
{/* HOVEDINNHOLD (78%) */}
|
|
||||||
<div className="lg:w-[78%] bg-white p-10 md:p-16 md:rounded-[3rem] shadow-sm border-b md:border-none">
|
|
||||||
{facility.footnote && (
|
|
||||||
<div className="mb-8 pb-8 border-b border-gray-50 italic text-[#ff5722] text-lg font-serif">
|
|
||||||
{facility.footnote}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="leading-relaxed text-lg md:text-xl text-gray-600" dangerouslySetInnerHTML={{ __html: facility.description || 'Ingen beskrivelse tilgjengelig.' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SIDEBAR (22%) */}
|
|
||||||
<div className="lg:w-[22%] bg-white p-10 md:rounded-[3rem] shadow-sm flex flex-col order-last lg:order-none">
|
|
||||||
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-widest mb-10">Kontakt & Adresse</h3>
|
|
||||||
<div className="flex-grow space-y-7 text-sm font-bold">
|
|
||||||
<a href={facility.website_url} target="_blank" className={sidebarLinkClass}><Icon children={ICONS.web} /> Besøk nettsiden</a>
|
|
||||||
<a href={`tel:${formatPhoneForUrl(facility.phone)}`} className={sidebarLinkClass}>
|
|
||||||
<Icon children={ICONS.phone} /> {facility.phone || 'Ikke oppgitt'}
|
|
||||||
</a>
|
|
||||||
<a href={`mailto:${facility.email}`} className={sidebarLinkClass}>
|
|
||||||
<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=$?q=${facility.lat},${facility.lng}`} target="_blank" 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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SOSIALE MEDIER IKONER */}
|
|
||||||
{socialLinks.length > 0 && (
|
|
||||||
<div className="pt-6 border-t border-gray-50 mt-6 flex flex-wrap gap-3">
|
|
||||||
{socialLinks.map((social: any, idx: number) => {
|
|
||||||
const platform = (social.platform || '').toLowerCase().trim();
|
|
||||||
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">
|
|
||||||
<Icon children={iconData} className="w-4 h-4 text-current" />
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-10 pt-6 border-t border-gray-50">
|
|
||||||
<Link href={`/`} className="text-[10px] font-black uppercase tracking-widest text-[#7ca982] hover:text-[#11280f] transition-all flex items-center gap-1">
|
|
||||||
Se alle baner i {facility.county} →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 4. 3-KOLONNE INFO */}
|
|
||||||
<section id="details" className="grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-8">
|
|
||||||
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm">
|
|
||||||
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter">Andre Ressurser</h3>
|
|
||||||
<div className="space-y-2.5">
|
|
||||||
{facility.golfbox_booking_url && (
|
|
||||||
<a href={facility.golfbox_booking_url} target="_blank" className={resourceBtnClass}>
|
|
||||||
<span className="flex items-center gap-3"><Icon children={ICONS.booking} className="group-hover:text-white" /> Book Starttid</span><span>→</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{facility.golfbox_tournament_url && (
|
|
||||||
<a href={facility.golfbox_tournament_url} target="_blank" className={resourceBtnClass}>
|
|
||||||
<span className="flex items-center gap-3"><Icon children={ICONS.trophy} className="group-hover:text-white" /> Turneringer</span><span>→</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{facility.baneguide_url && (
|
|
||||||
<a href={facility.baneguide_url} target="_blank" className={resourceBtnClass}>
|
|
||||||
<span className="flex items-center gap-3"><Icon children={ICONS.guide} className="group-hover:text-white" /> Baneguide</span><span>→</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{facility.flyfoto_url && (
|
|
||||||
<a href={facility.flyfoto_url} target="_blank" className={resourceBtnClass}>
|
|
||||||
<span className="flex items-center gap-3"><Icon children={ICONS.camera} className="group-hover:text-white" /> Flyfoto</span><span>→</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{facility.webcam_url && (
|
|
||||||
<a href={facility.webcam_url} target="_blank" className={resourceBtnClass}>
|
|
||||||
<span className="flex items-center gap-3"><Icon children={ICONS.webcam} className="group-hover:text-white" /> Webkamera</span><span>→</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{shotzoom.map((sz: any, i: number) => (
|
|
||||||
<a key={i} href={sz.shotzoom_url} target="_blank" className={resourceBtnClass}>
|
|
||||||
<span className="flex items-center gap-3"><Icon children={ICONS.chart} className="group-hover:text-white" /> Statistikk: {sz.shotzoom_beskrivelse?.replace(/ ?/g, ' ').trim().toUpperCase()}</span><span>→</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm text-sm font-bold text-gray-700 flex flex-col">
|
|
||||||
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter text-[#11280f]">Banen</h3>
|
|
||||||
<div className="space-y-5 flex-grow">
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Hull:</span><span>{amenities.antall_hull || '--'}</span></div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Lengde:</span><span>{facility.length_meters ? `${facility.length_meters}m` : '--'}</span></div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Sesong:</span><span>{facility.season || '--'}</span></div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Byggeår:</span><span>{facility.established_year || '--'}</span></div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Banetype:</span><span>{facility.banetype || 'Park/Skog'}</span></div>
|
|
||||||
<div className="flex justify-between"><span className="text-gray-400">Arkitekt:</span><span className="text-right truncate ml-4">{facility.architect || '--'}</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm text-sm font-bold text-gray-700">
|
|
||||||
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter text-[#11280f]">Andre Tilbud</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Drivingrange:</span><span>{amenities.drivingrange || 'Nei'}</span></div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Nærspill:</span><span>{amenities.treningsgreen || 'Ja'}</span></div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Proshop:</span><span className="text-right ml-4">{renderValue(amenities.proshop)}</span></div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Kølleutleie:</span><span>{amenities.kolleutleie || 'Ja'}</span></div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Bilutleie:</span><span>{amenities.bilutleie || 'Nei'}</span></div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Simulator:</span><span className="text-right ml-4">{renderValue(amenities.simulator)}</span></div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Kafé:</span><span className="text-right ml-4">{renderValue(amenities.kafe)}</span></div>
|
|
||||||
|
|
||||||
{/* Golfamore og NSG */}
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-2">
|
|
||||||
<span className="text-gray-400">Golfamore:</span>
|
|
||||||
<span className="text-right ml-4">
|
|
||||||
{hasGolfamore ? <span className="text-[#ff5722] font-black">{golfamoreData.gyldighet || "Ja"}</span> : "Nei"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-2">
|
|
||||||
<span className="text-gray-400">Seniorgolf (NSG):</span>
|
|
||||||
<span className="text-right ml-4">
|
|
||||||
{hasNSG && facility.nsg_url
|
|
||||||
? <a href={facility.nsg_url} target="_blank" className="text-blue-600 font-black hover:underline">Ja (Vis Avtale)</a>
|
|
||||||
: (hasNSG ? <span className="text-blue-600 font-black">Ja</span> : "Nei")
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SAMARBEIDENDE KLUBBER */}
|
|
||||||
{cooperatingClubs.length > 0 && (
|
|
||||||
<div className="pt-2">
|
|
||||||
<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">
|
|
||||||
{slug.replace('-golfklubb', '').replace(/-/g, ' ')}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 5. VÆR SEKSJON */}
|
|
||||||
<section id="weather" className="bg-white p-0 md:p-12 md:rounded-[3rem] shadow-sm border-b md:border-none overflow-hidden text-center">
|
|
||||||
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-[0.2em] py-8 md:py-0 md:mb-10 flex items-center justify-center gap-3"><Icon children={ICONS.weather} /> Vær for {facility.name}</h3>
|
|
||||||
<div className="w-full flex justify-center px-4 md:px-0">
|
|
||||||
{facility.weather_url ? ( <img src={weatherImg} className="w-full h-auto block max-w-5xl" alt="Vær" /> ) : <p className="text-center py-24 text-gray-300 italic text-sm">Værvarsel ikke tilgjengelig</p>}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 6. KART SEKSJON */}
|
|
||||||
<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=$?q=${facility.lat},${facility.lng}&t=k&z=15&ie=UTF8&iwloc=&output=embed`} allowFullScreen />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 7. VIDEO SEKSJON */}
|
|
||||||
{facility.video_url && (
|
|
||||||
<section id="video" 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">Video <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
|
|
||||||
<div className="w-full md:rounded-[3rem] overflow-hidden shadow-2xl aspect-video bg-black border-y-4 md:border-[12px] border-white">
|
|
||||||
<iframe src={facility.video_url} className="w-full h-full" allowFullScreen />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 8. PRISER (MEDLEMSKAP, GREENFEE & VTG) */}
|
|
||||||
<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" />
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6 lg:gap-8 items-start">
|
|
||||||
|
|
||||||
{/* VENSTRE KOLONNE: MEDLEMSKAP */}
|
|
||||||
{(facility.standard_medlemskap || facility.rimeligste_alternativ) && (
|
|
||||||
<div className="bg-white border-2 border-[#11280f]/5 rounded-3xl p-6 lg:p-8 shadow-sm h-full flex flex-col">
|
|
||||||
<div className="flex justify-between items-center mb-6">
|
|
||||||
<h3 className="text-xl font-black text-[#11280f] uppercase tracking-tighter flex items-center gap-2">
|
|
||||||
<span>⛳</span> Medlemskap
|
|
||||||
</h3>
|
|
||||||
{facility.medlemskap_url && (
|
|
||||||
<a href={facility.medlemskap_url.split(',')[0].trim()} target="_blank" className="text-[10px] font-black uppercase tracking-widest text-[#7ca982] hover:text-[#11280f] transition-colors">
|
|
||||||
Se alle →
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6 flex-grow">
|
|
||||||
{facility.standard_medlemskap && (
|
|
||||||
<div className="bg-gray-50 rounded-2xl p-5 border border-gray-100 relative overflow-hidden group hover:border-[#8bc34a]/30 transition-colors">
|
|
||||||
<div className="absolute top-0 right-0 bg-[#8bc34a] text-white text-[9px] font-black uppercase tracking-widest px-3 py-1 rounded-bl-xl">Mest valgte</div>
|
|
||||||
<span className="block text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Standard</span>
|
|
||||||
<span className="text-3xl font-black text-[#11280f]">{facility.standard_medlemskap},-</span>
|
|
||||||
{facility.standard_medlemskap_navn && <p className="text-xs font-bold mt-1 text-[#8bc34a]">{facility.standard_medlemskap_navn}</p>}
|
|
||||||
{facility.standard_medlemskap_kommentarer && (
|
|
||||||
<p className="text-[10px] text-gray-500 mt-2 uppercase font-bold italic leading-tight border-t border-gray-200 pt-2">
|
|
||||||
{facility.standard_medlemskap_kommentarer.split('\n').map((line: string, i: number) => (
|
|
||||||
<span key={i}>{line}<br /></span>
|
|
||||||
))}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{facility.rimeligste_alternativ && (
|
|
||||||
<div className="bg-white rounded-2xl p-5 border border-gray-100 hover:border-[#8bc34a]/30 transition-colors">
|
|
||||||
<span className="block text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Rimeligste golfkort</span>
|
|
||||||
<span className="text-xl font-black text-gray-600">{facility.rimeligste_alternativ},-</span>
|
|
||||||
{facility.rimeligste_navn && <p className="text-[10px] font-bold mt-1 text-gray-500">{facility.rimeligste_navn}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* HØYRE KOLONNE: GREENFEE */}
|
|
||||||
{greenfeeRaw && greenfeeRaw.length > 0 && (
|
|
||||||
<div className="bg-white border-2 border-[#11280f]/5 rounded-3xl p-6 lg:p-8 shadow-sm h-full flex flex-col">
|
|
||||||
<div className="flex justify-between items-center mb-6">
|
|
||||||
<h3 className="text-xl font-black text-[#11280f] uppercase tracking-tighter flex items-center gap-2">
|
|
||||||
<span>🎫</span> Greenfee
|
|
||||||
</h3>
|
|
||||||
{facility.greenfee_url && (
|
|
||||||
<a href={facility.greenfee_url.split(',')[0].trim()} target="_blank" className="text-[10px] font-black uppercase tracking-widest text-[#7ca982] hover:text-[#11280f] transition-colors">
|
|
||||||
Alle priser →
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto hide-scrollbar flex-grow">
|
|
||||||
<table className="w-full text-left min-w-[300px]">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b-2 border-gray-100 text-[10px] font-black uppercase tracking-widest text-gray-400">
|
|
||||||
<th className="pb-3 pr-2">Bane/Kat.</th>
|
|
||||||
<th className="pb-3 text-right pr-2">Voksen</th>
|
|
||||||
<th className="pb-3 text-right">Junior</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="text-xs font-bold text-[#11280f]">
|
|
||||||
{greenfeeRaw.map((gf: any, idx: number) => (
|
|
||||||
<tr key={idx} className="border-b border-gray-50 hover:bg-gray-50 transition-colors">
|
|
||||||
<td className="py-3 pr-2 leading-tight">
|
|
||||||
<span className="block truncate max-w-[150px] sm:max-w-[200px]" title={gf.banenavn}>{gf.banenavn}</span>
|
|
||||||
<span className="text-[9px] text-gray-400 uppercase tracking-widest block truncate max-w-[150px] sm:max-w-[200px]" title={gf.priskategori}>{gf.priskategori}</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 text-right pr-2 text-[#8bc34a] font-black text-sm whitespace-nowrap">
|
|
||||||
{gf.pris_voksne ? `${gf.pris_voksne},-` : '-'}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 text-right text-blue-400 whitespace-nowrap">
|
|
||||||
{gf.pris_junior ? `${gf.pris_junior},-` : '-'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{facility.guest_requirements && (
|
|
||||||
<p className="mt-4 text-[10px] text-gray-400 font-black uppercase tracking-widest italic pt-4 border-t border-gray-50">
|
|
||||||
Krav: {facility.guest_requirements}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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">
|
|
||||||
<h3 className="text-2xl font-black uppercase tracking-tighter mb-4 flex items-center gap-3">
|
|
||||||
Nybegynnerkurs (Veien til Golf)
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
<div className="w-full flex flex-col items-center gap-20">
|
|
||||||
{activeCourses.map((c: any) => (
|
|
||||||
<div key={c.id} className="w-full overflow-x-auto md:overflow-visible no-scrollbar">
|
|
||||||
<div className="min-w-[800px] md:min-w-0"><CourseDisplay course={c} /></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
// page.tsx
|
|
||||||
import { API_URL } from "@/config/constants";
|
|
||||||
import FacilityDetailView from "./FacilityDetailView";
|
|
||||||
|
|
||||||
export default async function GolfCoursePage({ params }: { params: Promise<{ slug: string }> }) {
|
|
||||||
const { slug } = await params;
|
|
||||||
|
|
||||||
const res = await fetch(`${API_URL}/facilities/${slug}`, { cache: 'no-store' });
|
|
||||||
const facility = await res.json();
|
|
||||||
|
|
||||||
if (!facility || facility.error) {
|
|
||||||
return <div className="p-20 text-center font-bold text-2xl">Fant ikke golfbanen...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vi sender dataene til den navngitte komponenten
|
|
||||||
return <FacilityDetailView facility={facility} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import type { Metadata } from "next";
|
|
||||||
import "./globals.css";
|
|
||||||
import Header from "@/components/Header";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "TeeOff.no - Din guide til norske golfbaner",
|
|
||||||
description: "Oppdatert banestatus, priser og informasjon om alle norske golfanlegg.",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<html lang="nb">
|
|
||||||
<body className="antialiased bg-[#f1f7ed]">
|
|
||||||
<Header />
|
|
||||||
{children}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import HeroSlider from './HeroSlider';
|
|
||||||
import FacilitySearch from './FacilitySearch';
|
|
||||||
import { API_URL } from '@/config/constants';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
export default async function Home() {
|
|
||||||
let facilities = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/facilities`, {
|
|
||||||
next: { revalidate: 0 },
|
|
||||||
cache: 'no-store'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const errorData = await res.json();
|
|
||||||
console.error("API Error Body:", errorData);
|
|
||||||
throw new Error(`API returnerte status ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
facilities = await res.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Kritisk feil ved henting av data:", error);
|
|
||||||
facilities = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sikrer at vi alltid sender en array til komponentene
|
|
||||||
const safeData = Array.isArray(facilities) ? facilities : [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-[#f1f7ed]">
|
|
||||||
{/* Wrapper slideren i en div som skjuler den på mobil (hidden) og viser den på PC (md:block) */}
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<HeroSlider facilities={safeData} />
|
|
||||||
</div>
|
|
||||||
<FacilitySearch initialFacilities={safeData} />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
"use client";
|
|
||||||
import { useState } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
export default function Header() {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="sticky top-0 z-[100] bg-white/95 backdrop-blur-md border-b border-gray-100 shadow-sm">
|
|
||||||
<div className="max-w-[1400px] mx-auto px-6 h-20 flex items-center justify-between">
|
|
||||||
|
|
||||||
{/* LOGO */}
|
|
||||||
<Link href="/" className="h-10 md:h-12 transition-transform hover:scale-105 active:scale-95">
|
|
||||||
<img src="/TeeOff-logo-Retina-1.png" alt="TeeOff.no" className="h-full w-auto object-contain" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* DESKTOP NAV */}
|
|
||||||
<nav className="hidden md:flex items-center gap-8 text-[11px] font-black uppercase tracking-widest text-gray-500">
|
|
||||||
<Link href="/" className="hover:text-[#8bc34a]">Hjem</Link>
|
|
||||||
<Link href="/golfbaner" className="hover:text-[#8bc34a]">Finn Bane</Link>
|
|
||||||
<Link href="/medlemskap" className="hover:text-[#8bc34a]">Medlemskap</Link>
|
|
||||||
<Link href="/om-oss" className="hover:text-[#8bc34a]">Om oss</Link>
|
|
||||||
<Link href="/admin/login" className="px-5 py-2 bg-[#ff5722] text-white rounded-xl hover:bg-black transition-all font-black uppercase text-[10px] tracking-widest">Admin</Link>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* HAMBURGER (Mobil) */}
|
|
||||||
<button onClick={() => setIsOpen(!isOpen)} className="md:hidden p-2 text-[#11280f]">
|
|
||||||
<div className="w-6 h-0.5 bg-current mb-1.5 transition-all"></div>
|
|
||||||
<div className="w-6 h-0.5 bg-current mb-1.5"></div>
|
|
||||||
<div className="w-6 h-0.5 bg-current"></div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* MOBIL MENY OVERLAY */}
|
|
||||||
{isOpen && (
|
|
||||||
<div className="md:hidden absolute top-20 left-0 w-full bg-white border-b border-gray-100 p-6 flex flex-col gap-6 shadow-2xl animate-in slide-in-from-top duration-300">
|
|
||||||
<Link onClick={() => setIsOpen(false)} href="/" className="text-lg font-black uppercase text-[#11280f]">Hjem</Link>
|
|
||||||
<Link onClick={() => setIsOpen(false)} href="/golfbaner" className="text-lg font-black uppercase text-[#11280f]">Finn Bane</Link>
|
|
||||||
<Link onClick={() => setIsOpen(false)} href="/medlemskap" className="text-lg font-black uppercase text-[#11280f]">Medlemskap</Link>
|
|
||||||
<Link onClick={() => setIsOpen(false)} href="/logg-inn" className="text-[#ff5722] font-black uppercase">Admin Logg inn</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
// Tilpass interface til de dataene du allerede har i frontend
|
|
||||||
interface Facility {
|
|
||||||
id: number;
|
|
||||||
scrape_method?: string;
|
|
||||||
scrape_status_url?: string;
|
|
||||||
scrape_status_selector?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ScrapeMethodSelect({ facility }: { facility: Facility }) {
|
|
||||||
// Setter standardverdi til 'css_selector' hvis den er tom i databasen
|
|
||||||
const [method, setMethod] = useState(facility.scrape_method || 'css_selector');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [statusColor, setStatusColor] = useState('bg-transparent'); // For å gi visuell feedback
|
|
||||||
|
|
||||||
const handleMethodChange = async (newMethod: string) => {
|
|
||||||
setMethod(newMethod);
|
|
||||||
setIsLoading(true);
|
|
||||||
setStatusColor('bg-yellow-200'); // Lyser gult mens den lagrer
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Husk å endre URL-en hvis API-et ditt ligger på et annet domene
|
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/api/admin/facilities/${facility.id}/scrape-settings`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
// Hvis du bruker JWT i headers i stedet for cookies, legg det til her:
|
|
||||||
// 'Authorization': `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
scrape_method: newMethod,
|
|
||||||
scrape_status_url: facility.scrape_status_url, // Beholder eksisterende
|
|
||||||
scrape_status_selector: facility.scrape_status_selector // Beholder eksisterende
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Feil ved lagring');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Suksess! Lyser grønt et kort sekund
|
|
||||||
setStatusColor('bg-green-300');
|
|
||||||
setTimeout(() => setStatusColor('bg-transparent'), 2000);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
setStatusColor('bg-red-300'); // Lyser rødt ved feil
|
|
||||||
alert("Kunne ikke oppdatere skrapemetode.");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
value={method}
|
|
||||||
onChange={(e) => handleMethodChange(e.target.value)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className={`border rounded p-1 text-sm transition-colors duration-300 ${statusColor} ${isLoading ? 'opacity-50' : ''}`}
|
|
||||||
>
|
|
||||||
<option value="css_selector">Standard (CSS)</option>
|
|
||||||
<option value="llm_parse">✨ Gemini AI (LLM)</option>
|
|
||||||
<option value="iframe_golfbox">Golfbox iframe</option>
|
|
||||||
<option value="click_then_css">Auto-klikk + CSS</option>
|
|
||||||
<option value="">Ingen (Avslått)</option>
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
/**
|
|
||||||
* TEE OFF CONFIG CONSTANTS v1.3
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
* REGEL 1: ALDRI trunker eller fjern data fra denne filen.
|
|
||||||
* REGEL 2: Håndterer både intern Docker-kommunikasjon og ekstern browser-kommunikasjon.
|
|
||||||
* REGEL 3: Inneholder alle regionale mappinger for Norge.
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
const isBrowser = typeof window !== 'undefined';
|
|
||||||
|
|
||||||
// Intern URL for server-to-server (Docker-internt)
|
|
||||||
const INTERNAL_API = process.env.API_URL || "http://api:8000/api";
|
|
||||||
|
|
||||||
// Relativ sti for browseren.
|
|
||||||
// Ved å bruke '/api' sørger vi for at nettleseren bruker samme protokoll (https)
|
|
||||||
// og domene (nye.teeoff.no) som resten av siden.
|
|
||||||
const EXTERNAL_API = "/api";
|
|
||||||
|
|
||||||
export const API_URL = isBrowser ? EXTERNAL_API : INTERNAL_API;
|
|
||||||
|
|
||||||
export const FALLBACK_IMAGE = "/Toppbilde-standard.jpg";
|
|
||||||
export const TEEOFF_LOGO = "/TeeOff-logo-Retina-1.png";
|
|
||||||
|
|
||||||
export const STATUS_MAP: Record<string, string> = {
|
|
||||||
"ukjent": "Ukjent status",
|
|
||||||
"aapen": "Åpen",
|
|
||||||
"aapen_med_vintergreener": "Vintergreener",
|
|
||||||
"stengt": "Stengt",
|
|
||||||
"nedlagt": "Nedlagt",
|
|
||||||
"under_utvikling": "Under utvikling",
|
|
||||||
"aapner_snart": "Åpner snart",
|
|
||||||
"stenger_snart": "Stenger snart"
|
|
||||||
};
|
|
||||||
|
|
||||||
export const REGIONS: Record<string, string[]> = {
|
|
||||||
"nord-norge": ["finnmark", "troms", "nordland"],
|
|
||||||
"midt-norge": ["nord-trøndelag", "sør-trøndelag", "trøndelag"],
|
|
||||||
"vestlandet": ["møre og romsdal", "sogn og fjordane", "hordaland", "rogaland", "vestland"],
|
|
||||||
"sørlandet": ["vest-agder", "aust-agder", "agder"],
|
|
||||||
"østlandet": ["telemark", "vestfold", "østfold", "buskerud", "hedmark", "oppland", "oslo", "akershus", "innlandet", "viken"]
|
|
||||||
};
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
/**
|
|
||||||
* TEE OFF SECURITY MIDDLEWARE v1.1
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
* REGEL: Beskytter alle ruter under /admin (unntatt /admin/login).
|
|
||||||
* FUNKSJON: Sjekker for admin_session cookie og omdirigerer hvis den mangler.
|
|
||||||
* RETTING: Flyttet NextRequest til next/server for å fikse build-error.
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextResponse, type NextRequest } from 'next/server';
|
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
|
||||||
const { pathname } = request.nextUrl;
|
|
||||||
const session = request.cookies.get('admin_session');
|
|
||||||
|
|
||||||
// 1. Tillat alltid tilgang til innloggingssiden
|
|
||||||
if (pathname.startsWith('/admin/login')) {
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Beskytt alle andre ruter under /admin
|
|
||||||
if (pathname.startsWith('/admin')) {
|
|
||||||
if (!session) {
|
|
||||||
// Ingen sesjon funnet -> Send til innlogging
|
|
||||||
const loginUrl = new URL('/admin/login', request.url);
|
|
||||||
return NextResponse.redirect(loginUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Definer hvilke ruter middleware skal kjøre på
|
|
||||||
export const config = {
|
|
||||||
matcher: ['/admin/:path*'],
|
|
||||||
};
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
"""
|
|
||||||
TEE OFF ADMIN GENERATOR v1.9 (DEBUG & BULLETPROOF)
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
FUNKSJON: Genererer SQL-kommando for administrator.
|
|
||||||
STATUS: Beholder TRUNCATE for feilsøking, men sikrer SQL-innsendingen.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
"""
|
|
||||||
import pyotp
|
|
||||||
from passlib.hash import pbkdf2_sha256
|
|
||||||
import getpass
|
|
||||||
import sys
|
|
||||||
|
|
||||||
def generate_admin():
|
|
||||||
print("\n" + "="*50)
|
|
||||||
print(" TEE OFF ADMIN GENERATOR v1.9 (DEBUG MODE)")
|
|
||||||
print("="*50)
|
|
||||||
|
|
||||||
username = input("Brukernavn (f.eks Envide Webutvikling): ").strip()
|
|
||||||
email = input("E-post: ").strip()
|
|
||||||
|
|
||||||
# Sikre mot SQL-feil hvis navnet/eposten inneholder apostrof
|
|
||||||
safe_username = username.replace("'", "''")
|
|
||||||
safe_email = email.replace("'", "''")
|
|
||||||
|
|
||||||
# Passord-verifisering
|
|
||||||
while True:
|
|
||||||
password = getpass.getpass("Skriv inn passord: ")
|
|
||||||
password_confirm = getpass.getpass("Gjenta passord: ")
|
|
||||||
|
|
||||||
if password == password_confirm:
|
|
||||||
if len(password) < 8:
|
|
||||||
print("⚠️ Advarsel: Passordet bør være minst 8 tegn.")
|
|
||||||
print(f"\n[DEBUG] Passord akseptert. Lengde: {len(password)} tegn.")
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
print("❌ Passordene er ikke like. Prøv igjen.\n")
|
|
||||||
|
|
||||||
otp_secret = pyotp.random_base32()
|
|
||||||
|
|
||||||
print("⏳ Genererer PBKDF2-hash...")
|
|
||||||
password_hash = pbkdf2_sha256.hash(password)
|
|
||||||
print(f"[DEBUG] Hash generert. Lengde: {len(password_hash)} tegn.")
|
|
||||||
|
|
||||||
print("\n✅ GENERERING VELLYKKET!")
|
|
||||||
print("-" * 50)
|
|
||||||
print("SLIK LEGGER DU INN BRUKEREN TRYGT:")
|
|
||||||
print("-" * 50)
|
|
||||||
print("1. Gå inn i databasen:")
|
|
||||||
print(" docker exec -it teeoff_db psql -U teeoff_admin -d teeoff")
|
|
||||||
print("\n2. Lim inn disse to linjene nøyaktig slik de står:")
|
|
||||||
print("TRUNCATE admins;")
|
|
||||||
print(f"INSERT INTO admins (username, email, password_hash, otp_secret) VALUES ('{safe_username}', '{safe_email}', '{password_hash}', '{otp_secret}');")
|
|
||||||
print("\n3. Skriv 'exit' for å gå ut.")
|
|
||||||
print("-" * 50)
|
|
||||||
print("4. KONFIGURER 2FA I GOOGLE AUTHENTICATOR:")
|
|
||||||
print(f"Bruk denne nøkkelen: {otp_secret}")
|
|
||||||
print("-" * 50 + "\n")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
generate_admin()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\nAvbrutt.")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
import urllib.request
|
|
||||||
import json
|
|
||||||
|
|
||||||
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
|
||||||
|
|
||||||
async def fetch_json(url):
|
|
||||||
"""Hjelpefunksjon for å hente JSON fra en URL"""
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(url, headers={'User-Agent': 'TeeOff-Migrator/2.0'})
|
|
||||||
with urllib.request.urlopen(req) as response:
|
|
||||||
return json.loads(response.read().decode())
|
|
||||||
except Exception as e:
|
|
||||||
# print(f"⚠️ Kunne ikke hente {url}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def fetch_media_urls_by_ids(media_ids):
|
|
||||||
"""Henter URLer for en liste med media-IDer (ACF Slides)"""
|
|
||||||
if not media_ids or not isinstance(media_ids, list) or len(media_ids) == 0:
|
|
||||||
return []
|
|
||||||
|
|
||||||
valid_ids = [str(mid) for mid in media_ids if isinstance(mid, (int, str)) and str(mid).isdigit()]
|
|
||||||
if not valid_ids: return []
|
|
||||||
|
|
||||||
ids_str = ",".join(valid_ids)
|
|
||||||
url = f"https://teeoff.no/wp-json/wp/v2/media?include={ids_str}"
|
|
||||||
data = await fetch_json(url)
|
|
||||||
|
|
||||||
urls = []
|
|
||||||
if data:
|
|
||||||
for m in data:
|
|
||||||
if 'source_url' in m:
|
|
||||||
urls.append(m['source_url'])
|
|
||||||
return urls
|
|
||||||
|
|
||||||
async def run_robust_import():
|
|
||||||
print("🕵️♂️ Starter den store bildejakten (sjekker både Utvalgt bilde og Slides)...")
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
|
|
||||||
# VIKTIG: Vi tømmer tabellen for å starte med blanke ark og unngå duplikater
|
|
||||||
await conn.execute("TRUNCATE facility_images CASCADE;")
|
|
||||||
print("🗑️ Tømte gammel bilde-tabell. Starter import...")
|
|
||||||
|
|
||||||
# Hent alle anleggene fra vår egen database
|
|
||||||
facilities = await conn.fetch("SELECT id, slug, name FROM facilities ORDER BY name")
|
|
||||||
|
|
||||||
total_images_saved = 0
|
|
||||||
|
|
||||||
for i, fac in enumerate(facilities):
|
|
||||||
fac_id = fac['id']
|
|
||||||
slug = fac['slug']
|
|
||||||
name = fac['name']
|
|
||||||
print(f"[{i+1}/{len(facilities)}] Sjekker: {name} ({slug})...")
|
|
||||||
|
|
||||||
# Hent data fra WP med ?_embed for å få tak i Utvalgt bilde lett
|
|
||||||
wp_url = f"https://teeoff.no/wp-json/wp/v2/golfbaner?slug={slug}&_embed"
|
|
||||||
wp_data_list = await fetch_json(wp_url)
|
|
||||||
|
|
||||||
if not wp_data_list:
|
|
||||||
print(" ❌ Fant ikke anlegget i WordPress API.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
post = wp_data_list[0]
|
|
||||||
final_image_urls = []
|
|
||||||
|
|
||||||
# 1. SJEKK: "Utvalgt bilde" (Standard WordPress)
|
|
||||||
try:
|
|
||||||
embedded = post.get('_embedded', {})
|
|
||||||
if 'wp:featuredmedia' in embedded and len(embedded['wp:featuredmedia']) > 0:
|
|
||||||
feat_media = embedded['wp:featuredmedia'][0]
|
|
||||||
feat_url = feat_media.get('source_url')
|
|
||||||
if feat_url:
|
|
||||||
final_image_urls.append(feat_url)
|
|
||||||
# print(f" -> Fant utvalgt bilde.")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ⚠️ Feil ved sjekk av utvalgt bilde: {e}")
|
|
||||||
|
|
||||||
# 2. SJEKK: ACF Slides (Bildekarusell)
|
|
||||||
try:
|
|
||||||
acf = post.get('acf') or {}
|
|
||||||
slides_ids = acf.get('slides')
|
|
||||||
slide_urls = await fetch_media_urls_by_ids(slides_ids)
|
|
||||||
if slide_urls:
|
|
||||||
final_image_urls.extend(slide_urls)
|
|
||||||
# print(f" -> Fant {len(slide_urls)} bilder i slider.")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ⚠️ Feil ved sjekk av slides: {e}")
|
|
||||||
|
|
||||||
# Fjern duplikater (hvis samme bilde er brukt begge steder) og bevar rekkefølgen
|
|
||||||
unique_urls = list(dict.fromkeys(final_image_urls))
|
|
||||||
|
|
||||||
# LAGRE I DATABASEN
|
|
||||||
if unique_urls:
|
|
||||||
sort_order = 0
|
|
||||||
for url in unique_urls:
|
|
||||||
await conn.execute(
|
|
||||||
"INSERT INTO facility_images (facility_id, image_url, sort_order) VALUES ($1, $2, $3)",
|
|
||||||
fac_id, url, sort_order
|
|
||||||
)
|
|
||||||
sort_order += 1
|
|
||||||
print(f" ✅ Lagret {len(unique_urls)} unike bilder.")
|
|
||||||
total_images_saved += len(unique_urls)
|
|
||||||
else:
|
|
||||||
print(" ⚠️ Fant INGEN bilder for dette anlegget.")
|
|
||||||
|
|
||||||
print(f"\n🎉 FERDIG! Totalt {total_images_saved} bilder er nå trygt lagret i galleriet.")
|
|
||||||
await conn.close()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(run_robust_import())
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
import requests
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from datetime import datetime
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
# Laster miljøvariabler
|
|
||||||
load_dotenv()
|
|
||||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
|
||||||
|
|
||||||
# Grunn-URL uten page-parameter
|
|
||||||
WP_API_BASE_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100"
|
|
||||||
|
|
||||||
def extract_price(text):
|
|
||||||
"""Finner første hele tall i en tekst og returnerer det som integer."""
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
clean_text = str(text).replace(" ", "").replace(".", "")
|
|
||||||
match = re.search(r'\d+', clean_text)
|
|
||||||
if match:
|
|
||||||
return int(match.group())
|
|
||||||
return None
|
|
||||||
|
|
||||||
def parse_date(date_string):
|
|
||||||
"""Forsøker å konvertere ulike tekstformater for dato til et ekte Date-objekt."""
|
|
||||||
if not date_string:
|
|
||||||
return None
|
|
||||||
ds = str(date_string).strip().lower()
|
|
||||||
|
|
||||||
if ds in ["ukjent", "ikke oppgitt", "har ikke", ""]:
|
|
||||||
return None
|
|
||||||
|
|
||||||
formats = ['%Y-%m-%d', '%d.%m.%Y', '%d/%m/%Y', '%Y%m%d', '%d.%m.%y']
|
|
||||||
for fmt in formats:
|
|
||||||
try:
|
|
||||||
return datetime.strptime(ds, fmt).date()
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
|
|
||||||
def clean_jsonb(value):
|
|
||||||
"""Sørger for at vi ikke fyller databasen med 'Ikke oppgitt', men bruker tomme lister."""
|
|
||||||
if not value or str(value).lower() in ["ikke oppgitt", "har ikke / ikke oppgitt"]:
|
|
||||||
return []
|
|
||||||
|
|
||||||
if isinstance(value, str):
|
|
||||||
return [{"beskrivelse": value}]
|
|
||||||
|
|
||||||
if isinstance(value, list):
|
|
||||||
cleaned = [v for v in value if v and "ikke oppgitt" not in str(v).lower()]
|
|
||||||
return cleaned
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
async def run_import():
|
|
||||||
print("📡 Henter anleggsdata fra WordPress (inkluderer paginering)...")
|
|
||||||
|
|
||||||
all_data = []
|
|
||||||
page = 1
|
|
||||||
|
|
||||||
# --- LØKKE SOM HENTER ALLE SIDER FRA WORDPRESS ---
|
|
||||||
while True:
|
|
||||||
url = f"{WP_API_BASE_URL}&page={page}"
|
|
||||||
print(f" -> Henter side {page}...")
|
|
||||||
response = requests.get(url)
|
|
||||||
|
|
||||||
# Hvis vi får 400 Bad Request, betyr det at vi har nådd forbi siste side
|
|
||||||
if response.status_code != 200:
|
|
||||||
break
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
|
|
||||||
all_data.extend(data)
|
|
||||||
page += 1
|
|
||||||
|
|
||||||
print(f"✅ Fant totalt {len(all_data)} anlegg. Starter oppdatering av database...")
|
|
||||||
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
success_count = 0
|
|
||||||
|
|
||||||
for item in all_data:
|
|
||||||
slug = item.get('slug')
|
|
||||||
acf = item.get('acf', {})
|
|
||||||
|
|
||||||
# Ekstraher og vask verdiene
|
|
||||||
golfpakker = clean_jsonb(acf.get('golfpakke'))
|
|
||||||
rabattert_greenfee = clean_jsonb(acf.get('rabattert_greenfee'))
|
|
||||||
|
|
||||||
vtg_presentasjon = acf.get('vtg_presentasjon') or None
|
|
||||||
vtg_lenke = acf.get('lenke_til_kurssider') or None
|
|
||||||
vtg_pris = extract_price(acf.get('vtg_pris'))
|
|
||||||
vtg_kursdatoer = clean_jsonb(acf.get('kursdatoer'))
|
|
||||||
|
|
||||||
slope_hovedbane = parse_date(acf.get('gyldig_til_og_med'))
|
|
||||||
slope_bane_to = parse_date(acf.get('gyldig_til_og_med_bane_to'))
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 1. Oppdater fasilitets-tabellen
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE facilities
|
|
||||||
SET
|
|
||||||
golfpakker = $1::jsonb,
|
|
||||||
rabattert_greenfee = $2::jsonb,
|
|
||||||
vtg_presentasjon = $3,
|
|
||||||
vtg_lenke = $4,
|
|
||||||
vtg_pris = $5,
|
|
||||||
vtg_kursdatoer = $6::jsonb
|
|
||||||
WHERE slug = $7
|
|
||||||
""",
|
|
||||||
json.dumps(golfpakker),
|
|
||||||
json.dumps(rabattert_greenfee),
|
|
||||||
vtg_presentasjon,
|
|
||||||
vtg_lenke,
|
|
||||||
vtg_pris,
|
|
||||||
json.dumps(vtg_kursdatoer),
|
|
||||||
slug)
|
|
||||||
|
|
||||||
# 2. Oppdater utløpsdato på hovedbanen
|
|
||||||
if slope_hovedbane:
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE courses
|
|
||||||
SET slope_valid_until = $1
|
|
||||||
WHERE facility_id = (SELECT id FROM facilities WHERE slug = $2)
|
|
||||||
AND is_main_course = true
|
|
||||||
""", slope_hovedbane, slug)
|
|
||||||
|
|
||||||
# 3. Oppdater utløpsdato på bane 2
|
|
||||||
if slope_bane_to:
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE courses
|
|
||||||
SET slope_valid_until = $1
|
|
||||||
WHERE facility_id = (SELECT id FROM facilities WHERE slug = $2)
|
|
||||||
AND is_main_course = false
|
|
||||||
""", slope_bane_to, slug)
|
|
||||||
|
|
||||||
success_count += 1
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ Feil ved oppdatering av {slug}: {e}")
|
|
||||||
|
|
||||||
await conn.close()
|
|
||||||
print(f"\n🎉 Kjøring fullført! Målrettet import for {success_count} anlegg er lagret.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(run_import())
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
"""
|
|
||||||
TEE OFF - AUTOMATISK URL-IMPORTØR
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
Leser tekstfiler med lenker og forsøker å matche dem mot eksisterende
|
|
||||||
golfanlegg i databasen basert på domenenavn.
|
|
||||||
|
|
||||||
docker compose exec api python import_urls.py
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
import os
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
|
||||||
|
|
||||||
# Hvilke filer vi skal lese, og hvilke databasefelt de tilhører
|
|
||||||
FILES_TO_IMPORT = {
|
|
||||||
"Medlemsskap.txt": "medlemskap_url",
|
|
||||||
"GreenFee.txt": "greenfee_url",
|
|
||||||
"VtG.txt": "vtg_lenke"
|
|
||||||
}
|
|
||||||
|
|
||||||
def extract_domain(url: str) -> str:
|
|
||||||
"""Henter ut hoveddomenet (f.eks. 'tyrifjord-golfklubb.no') fra en URL."""
|
|
||||||
try:
|
|
||||||
domain = urlparse(url.strip()).netloc.lower()
|
|
||||||
if domain.startswith("www."):
|
|
||||||
domain = domain[4:]
|
|
||||||
return domain
|
|
||||||
except:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
async def run_import():
|
|
||||||
print("🚀 Starter URL-importør...")
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Hent alle eksisterende anlegg og deres domener
|
|
||||||
facilities = await conn.fetch("SELECT id, name, website_url, scrape_status_url FROM facilities")
|
|
||||||
|
|
||||||
# Bygg en ordbok: { 'domenenavn.no': facility_id } for superraskt oppslag
|
|
||||||
domain_map = {}
|
|
||||||
for f in facilities:
|
|
||||||
# Prøv å hente domene fra website_url
|
|
||||||
if f['website_url']:
|
|
||||||
domain = extract_domain(f['website_url'])
|
|
||||||
if domain: domain_map[domain] = f['id']
|
|
||||||
# Prøv også scrape_status_url for sikkerhets skyld
|
|
||||||
if f['scrape_status_url']:
|
|
||||||
domain = extract_domain(f['scrape_status_url'])
|
|
||||||
if domain: domain_map[domain] = f['id']
|
|
||||||
|
|
||||||
print(f"📋 Fant {len(domain_map)} unike domener i databasen.")
|
|
||||||
|
|
||||||
# Gå gjennom fil for fil
|
|
||||||
for filename, db_field in FILES_TO_IMPORT.items():
|
|
||||||
print(f"\n▶️ BEHANDLER: {filename} -> Setter felt: {db_field}")
|
|
||||||
|
|
||||||
if not os.path.exists(filename):
|
|
||||||
print(f" ⚠️ Filen '{filename}' ble ikke funnet. Hopper over.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
with open(filename, 'r', encoding='utf-8') as file:
|
|
||||||
lines = [line.strip() for line in file.readlines() if line.strip()]
|
|
||||||
|
|
||||||
matched_count = 0
|
|
||||||
unmatched = []
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
# Hvis det er flere URL-er på samme linje separert med komma,
|
|
||||||
# matcher vi basert på den FØRSTE URL-en.
|
|
||||||
first_url = line.split(',')[0].strip()
|
|
||||||
domain = extract_domain(first_url)
|
|
||||||
|
|
||||||
# Hvis vi fant en match i databasen!
|
|
||||||
if domain in domain_map:
|
|
||||||
fac_id = domain_map[domain]
|
|
||||||
|
|
||||||
# Oppdater databasen med HELE linjen (for å bevare ev. komma-lenker)
|
|
||||||
await conn.execute(f"""
|
|
||||||
UPDATE facilities
|
|
||||||
SET {db_field} = $1
|
|
||||||
WHERE id = $2
|
|
||||||
""", line, fac_id)
|
|
||||||
matched_count += 1
|
|
||||||
else:
|
|
||||||
unmatched.append(line)
|
|
||||||
|
|
||||||
print(f" ✅ Matchet og oppdatert {matched_count} anlegg.")
|
|
||||||
|
|
||||||
if unmatched:
|
|
||||||
print(f" ❌ Følgende {len(unmatched)} URL-er fant ingen match i databasen og må legges inn manuelt:")
|
|
||||||
for url in unmatched:
|
|
||||||
print(f" - {url}")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
await conn.close()
|
|
||||||
print("\n🏁 Import fullført!")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(run_import())
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
import asyncio, asyncpg, urllib.request, json, re, os, requests
|
|
||||||
|
|
||||||
# --- KONFIGURASJON ---
|
|
||||||
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
|
||||||
WP_API_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100&_embed"
|
|
||||||
MEDIA_ENDPOINT = "https://teeoff.no/wp-json/wp/v2/media"
|
|
||||||
MEDIA_DIR = "./public/media"
|
|
||||||
|
|
||||||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
|
||||||
media_cache = {}
|
|
||||||
|
|
||||||
def get_url_from_id(media_id):
|
|
||||||
if not media_id or not isinstance(media_id, int): return None
|
|
||||||
if media_id in media_cache: return media_cache[media_id]
|
|
||||||
try:
|
|
||||||
resp = requests.get(f"{MEDIA_ENDPOINT}/{media_id}", timeout=10)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
url = resp.json().get('source_url')
|
|
||||||
media_cache[media_id] = url
|
|
||||||
return url
|
|
||||||
except: return None
|
|
||||||
|
|
||||||
def download_media(url, slug, prefix):
|
|
||||||
if not isinstance(url, str) or not url: return None
|
|
||||||
clean_url = url.replace("https:///", "https://").replace("http:///", "http://")
|
|
||||||
if "teeoff.no" not in clean_url: return clean_url
|
|
||||||
try:
|
|
||||||
ext = clean_url.split('.')[-1].split('?')[0].lower()
|
|
||||||
if len(ext) > 4 or len(ext) < 3: ext = "jpg"
|
|
||||||
filename = f"{prefix}_{slug}.{ext}"
|
|
||||||
filepath = os.path.join(MEDIA_DIR, filename)
|
|
||||||
if os.path.exists(filepath): return f"/media/{filename}"
|
|
||||||
response = requests.get(clean_url, timeout=15)
|
|
||||||
if response.status_code == 200:
|
|
||||||
with open(filepath, 'wb') as f: f.write(response.content)
|
|
||||||
return f"/media/{filename}"
|
|
||||||
except: pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
def decode_html(text):
|
|
||||||
if not text: return ""
|
|
||||||
return str(text).replace('&', '&').replace('&', '&').replace(' ', ' ').strip()
|
|
||||||
|
|
||||||
def parse_int(val):
|
|
||||||
if val is None or val == '': return None
|
|
||||||
try:
|
|
||||||
nums = re.findall(r'\d+', str(val))
|
|
||||||
return int(nums[0]) if nums else None
|
|
||||||
except: return None
|
|
||||||
|
|
||||||
def extract_url(val):
|
|
||||||
if isinstance(val, dict): return val.get('url')
|
|
||||||
if isinstance(val, str): return val
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def run_master_import():
|
|
||||||
print("🚀 Starter MASTER IMPORT v9.2 (Robust datakonvertering & Banetype)...")
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
|
|
||||||
# Tømmer kun courses og holes (hjelpetabeller)
|
|
||||||
await conn.execute("TRUNCATE courses, holes RESTART IDENTITY CASCADE;")
|
|
||||||
|
|
||||||
page = 1
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(f"{WP_API_URL}&page={page}", headers={'User-Agent': 'TeeOff-V9.2'})
|
|
||||||
with urllib.request.urlopen(req) as response:
|
|
||||||
data = json.loads(response.read().decode())
|
|
||||||
except: break
|
|
||||||
if not data: break
|
|
||||||
|
|
||||||
for post in data:
|
|
||||||
acf = post.get('acf', {})
|
|
||||||
slug = post['slug']
|
|
||||||
name = decode_html(post.get('title', {}).get('rendered', ''))
|
|
||||||
print(f"📦 Mapper {name}...")
|
|
||||||
|
|
||||||
# Media & Identifiers
|
|
||||||
local_main_img = download_media(post.get('_embedded', {}).get('wp:featuredmedia', [{}])[0].get('source_url'), slug, "main")
|
|
||||||
local_logo = download_media(get_url_from_id(acf.get('logo')) if isinstance(acf.get('logo'), int) else extract_url(acf.get('logo')), slug, "logo")
|
|
||||||
|
|
||||||
# Galleri
|
|
||||||
slides = acf.get('slides') or []
|
|
||||||
local_gallery = [download_media(get_url_from_id(s) if isinstance(s, int) else extract_url(s), f"{slug}_{i}", "slide") for i, s in enumerate(slides)]
|
|
||||||
local_gallery = [url for url in local_gallery if url]
|
|
||||||
|
|
||||||
# Golfbox
|
|
||||||
booking_id = acf.get('golfbox_booking_id')
|
|
||||||
gb_booking_url = f"http://www.golfbox.no/site/system/redirect.asp?locale=nb_NO&rUrl=%2Fsite%2Fressources%2Fbooking%2Fgrid.asp%3FRessource_GUID%3D%{{{str(booking_id).strip().replace('{','').replace('}','')}}}" if booking_id else None
|
|
||||||
|
|
||||||
# --- UPSERT FACILITY ---
|
|
||||||
# Merk: $16 (status_updated_at) pakkes nå inn i TO_DATE for å unngå krasj
|
|
||||||
await conn.execute('''
|
|
||||||
INSERT INTO facilities (
|
|
||||||
name, slug, description, address, city, county, established_year, season,
|
|
||||||
email, phone, website_url, image_url, logo_url, video_url,
|
|
||||||
amenities, status_updated_at, gallery, banetype,
|
|
||||||
ngf_number, golfbox_club_id, golfbox_booking_url,
|
|
||||||
facebook_url, instagram_url, baneguide_url, flyfoto_url,
|
|
||||||
golfbox_tournament_url, footnote, social_links, webcam_url,
|
|
||||||
weather_url, architect,
|
|
||||||
navn_standard_medlemskap, standard_medlemskap, standard_medlemskap_kommentarer,
|
|
||||||
navn_rimeligste_alternativ, rimeligste_alternativ, rimeligste_alternativ_kommentarer,
|
|
||||||
medlemskap_url
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb,
|
|
||||||
TO_DATE(NULLIF($16, ''), 'YYYYMMDD'),
|
|
||||||
$17::jsonb, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28::jsonb,
|
|
||||||
$29, $30, $31, $32, $33, $34, $35, $36, $37, $38
|
|
||||||
)
|
|
||||||
ON CONFLICT (slug) DO UPDATE SET
|
|
||||||
name = EXCLUDED.name,
|
|
||||||
description = EXCLUDED.description,
|
|
||||||
address = EXCLUDED.address,
|
|
||||||
city = EXCLUDED.city,
|
|
||||||
phone = EXCLUDED.phone,
|
|
||||||
email = EXCLUDED.email,
|
|
||||||
website_url = EXCLUDED.website_url,
|
|
||||||
image_url = EXCLUDED.image_url,
|
|
||||||
logo_url = EXCLUDED.logo_url,
|
|
||||||
amenities = EXCLUDED.amenities,
|
|
||||||
gallery = EXCLUDED.gallery,
|
|
||||||
status_updated_at = EXCLUDED.status_updated_at,
|
|
||||||
banetype = EXCLUDED.banetype,
|
|
||||||
architect = EXCLUDED.architect
|
|
||||||
''', name, slug, decode_html(acf.get('beskrivelse')), acf.get('gateadresse'), acf.get('postnummer_og_poststed'), acf.get('fylke'), parse_int(acf.get('byggear')), acf.get('sesong'), acf.get('e-post'), acf.get('telefon'), extract_url(acf.get('hjemmeside')), local_main_img, local_logo, None, json.dumps({"drivingrange": decode_html(acf.get("drivingrange")), "treningsgreen": decode_html(acf.get("treningsgreen")), "proshop": decode_html(acf.get("proshop")), "kafe": decode_html(acf.get("kafe")), "bilutleie": decode_html(acf.get("bilutleie")), "kolleutleie": decode_html(acf.get("kolleutleie")), "pro": decode_html(acf.get("pro")), "simulator": decode_html(acf.get("golfsimulator")), "antall_hull": decode_html(acf.get("antall_hull"))}),
|
|
||||||
acf.get('dato_for_oppdatert_status'), # $16
|
|
||||||
json.dumps(local_gallery), decode_html(acf.get('banetype')),
|
|
||||||
parse_int(acf.get('klubbnummer_norges_golfforbund')), parse_int(acf.get('klubbnummer_golfbox')), gb_booking_url, extract_url(acf.get('facebook_url')), extract_url(acf.get('instagram_url')), extract_url(acf.get('baneguide')), extract_url(acf.get('flyfoto')), extract_url(acf.get('golfbox')), decode_html(acf.get('fotnote')), json.dumps(acf.get('sosiale_lenker') or []), decode_html(acf.get('webkamera')), extract_url(acf.get('varmelding_yr')), decode_html(acf.get('arkitekt')), decode_html(acf.get('navn_standard_medlemskap')), parse_int(acf.get('standard_medlemskap')), decode_html(acf.get('standard_medlemskap_kommentarer')), decode_html(acf.get('navn_rimeligste_alternativ')), parse_int(acf.get('rimeligste_alternativ')), decode_html(acf.get('rimeligste_alternativ_kommentarer')), extract_url(acf.get('medlemskap_url')))
|
|
||||||
|
|
||||||
fac_id = (await conn.fetchrow("SELECT id FROM facilities WHERE slug = $1", slug))['id']
|
|
||||||
|
|
||||||
# Baner og Hull
|
|
||||||
fac_main_len = 0
|
|
||||||
for suffix in ['', '_bane_to']:
|
|
||||||
c_name = acf.get('navn_pa_hovedbane' if suffix == '' else 'navn_pa_sekundar_bane') or ('Hovedbanen' if suffix == '' else 'Bane 2')
|
|
||||||
status = acf.get('banestatus' if suffix == '' else 'banestatus_sekundar_bane')
|
|
||||||
if suffix == '_bane_to' and (status == 'finnes_ingen_bane_to' or not parse_int(acf.get('hull_1_par_bane_to'))): continue
|
|
||||||
course_id = await conn.fetchval('INSERT INTO courses (facility_id, name, status, par, is_main_course, tee_boxes, architect) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id', fac_id, c_name, status, parse_int(acf.get('totalt_par' if suffix == '' else 'totalt_par_bane_to')), (suffix == ''), json.dumps({"herrer": acf.get(f"utslag_herrer{suffix}"), "damer": acf.get(f"utslag_damer{suffix}")}), decode_html(acf.get('arkitekt')))
|
|
||||||
curr_len = 0
|
|
||||||
for h_num in range(1, 19):
|
|
||||||
p = parse_int(acf.get(f'hull_{h_num}_par{suffix}'))
|
|
||||||
if p:
|
|
||||||
idx = parse_int(acf.get(f'hull_{h_num}_index{suffix}'))
|
|
||||||
lens = {k: parse_int(acf.get(f'{k}_hull_{h_num}{suffix}')) for k in ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest']}
|
|
||||||
curr_len += (lens['lengst'] or 0)
|
|
||||||
await conn.execute('INSERT INTO holes (course_id, hole_number, par, hcp_index, lengths) VALUES ($1, $2, $3, $4, $5::jsonb)', course_id, h_num, p, idx, json.dumps(lens))
|
|
||||||
await conn.execute("UPDATE courses SET length_meters = $1 WHERE id = $2", curr_len, course_id)
|
|
||||||
if suffix == '': fac_main_len = curr_len
|
|
||||||
await conn.execute("UPDATE facilities SET length_meters = $1 WHERE id = $2", fac_main_len, fac_id)
|
|
||||||
|
|
||||||
page += 1
|
|
||||||
await conn.close()
|
|
||||||
print("✅ IMPORT FERDIG!")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(run_master_import())
|
|
||||||
|
|
@ -1,663 +0,0 @@
|
||||||
"""
|
|
||||||
TEE OFF BACKEND API v3.8.0 - KOBLET PÅ FULL ADMIN REDIGERING
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
REGEL 1: Bruk str (ikke string) for type-hinting.
|
|
||||||
REGEL 2: Inkluder alle subqueries for banestatus og hull-data.
|
|
||||||
REGEL 3: Robust JSON-parsing (format_row) for å hindre Frontend-krasj.
|
|
||||||
REGEL 4: JWT-sesjoner lagres i HTTP-only cookies.
|
|
||||||
LOV: Aldri trunker eller slett logikk for "effektivitet".
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Response, Cookie, Depends, Request, BackgroundTasks
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
import asyncpg
|
|
||||||
import json
|
|
||||||
import pyotp
|
|
||||||
import os
|
|
||||||
from datetime import datetime, date, timedelta
|
|
||||||
from jose import jwt, JWTError
|
|
||||||
from passlib.context import CryptContext
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
# NYE IMPORTER FOR ADMIN PANELET OG BAKGRUNNSJOBBER
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import Optional, List, Any
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
# --- KONFIGURASJON ---
|
|
||||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
|
||||||
SECRET_KEY = os.getenv("JWT_SECRET", "super_secret_change_this_in_production")
|
|
||||||
ALGORITHM = "HS256"
|
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
|
||||||
|
|
||||||
# --- PYDANTIC MODELLER ---
|
|
||||||
class CourseStatusUpdate(BaseModel):
|
|
||||||
id: int
|
|
||||||
status: str
|
|
||||||
|
|
||||||
class ScrapeSettingsUpdate(BaseModel):
|
|
||||||
scrape_method: Optional[str] = None
|
|
||||||
scrape_status_url: Optional[str] = None
|
|
||||||
scrape_status_selector: Optional[str] = None
|
|
||||||
ai_instruction: Optional[str] = None
|
|
||||||
courses: Optional[List[CourseStatusUpdate]] = []
|
|
||||||
|
|
||||||
# NY MODELL FOR Å TA IMOT IDER FOR SCRAPING
|
|
||||||
class ScrapeRunRequest(BaseModel):
|
|
||||||
facility_ids: List[int]
|
|
||||||
|
|
||||||
class MembershipDraftApproval(BaseModel):
|
|
||||||
facility_id: int
|
|
||||||
navn_standard_medlemskap: Optional[str] = None
|
|
||||||
standard_medlemskap: Optional[int] = None
|
|
||||||
standard_medlemskap_kommentarer: Optional[str] = None
|
|
||||||
navn_rimeligste_alternativ: Optional[str] = None
|
|
||||||
rimeligste_alternativ: Optional[int] = None
|
|
||||||
|
|
||||||
class BulkApprovalRequest(BaseModel):
|
|
||||||
approvals: List[MembershipDraftApproval]
|
|
||||||
|
|
||||||
class QuickEditRequest(BaseModel):
|
|
||||||
field: str
|
|
||||||
value: str
|
|
||||||
|
|
||||||
class GreenfeeApproval(BaseModel):
|
|
||||||
facility_id: int
|
|
||||||
greenfee: List[dict]
|
|
||||||
|
|
||||||
|
|
||||||
class VtgApproval(BaseModel):
|
|
||||||
facility_id: int
|
|
||||||
vtg_pris: int | None
|
|
||||||
vtg_beskrivelse: str | None
|
|
||||||
vtg_datoer: List[dict] | None
|
|
||||||
|
|
||||||
class BulkVtgRequest(BaseModel):
|
|
||||||
approvals: List[VtgApproval]
|
|
||||||
# --- FUNKSJONER ---
|
|
||||||
def format_row(row):
|
|
||||||
"""
|
|
||||||
Vasker data fra databasen:
|
|
||||||
1. Konverterer datoer til ISO-format.
|
|
||||||
2. Tvinger tekst-JSON (stringified JSON) over til ekte Python objekter/lister.
|
|
||||||
3. Sikrer at lister og objekter aldri er None for å hindre Frontend-krasj.
|
|
||||||
"""
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
d = dict(row)
|
|
||||||
|
|
||||||
for key in ['status_updated_at', 'created_at', 'slope_valid_until', 'membership_updated_at']:
|
|
||||||
if isinstance(d.get(key), (date, datetime)):
|
|
||||||
d[key] = d[key].isoformat()
|
|
||||||
|
|
||||||
json_list_fields = [
|
|
||||||
'course_statuses', 'courses', 'gallery', 'greenfee',
|
|
||||||
'faqs', 'shotzoom', 'social_links', 'holes', 'golfpakker', 'cooperating_clubs', 'vtg_datoer'
|
|
||||||
]
|
|
||||||
json_dict_fields = [
|
|
||||||
'amenities', 'vtg', 'nsg_data', 'golfamore_data', 'membership_draft'
|
|
||||||
]
|
|
||||||
|
|
||||||
for field in json_list_fields:
|
|
||||||
if field in d:
|
|
||||||
val = d[field]
|
|
||||||
if val is None:
|
|
||||||
d[field] = []
|
|
||||||
elif isinstance(val, str):
|
|
||||||
try:
|
|
||||||
d[field] = json.loads(val)
|
|
||||||
except:
|
|
||||||
d[field] = []
|
|
||||||
elif not isinstance(val, list):
|
|
||||||
d[field] = []
|
|
||||||
|
|
||||||
for field in json_dict_fields:
|
|
||||||
if field in d:
|
|
||||||
val = d[field]
|
|
||||||
if val is None:
|
|
||||||
d[field] = {}
|
|
||||||
elif isinstance(val, str):
|
|
||||||
try:
|
|
||||||
d[field] = json.loads(val)
|
|
||||||
except:
|
|
||||||
d[field] = {}
|
|
||||||
elif not isinstance(val, dict):
|
|
||||||
d[field] = {}
|
|
||||||
|
|
||||||
return d
|
|
||||||
|
|
||||||
# --- BAKGRUNNSARBEIDER: FUNKSJON SOM KJØRER SKRAPEREN I BAKGRUNNEN ---
|
|
||||||
def run_scrape_worker(facility_ids: List[int]):
|
|
||||||
"""
|
|
||||||
Kjører selve skraping-scriptet i bakgrunnen.
|
|
||||||
Slik kan frontenden få et umiddelbart svar, mens skraperen jobber.
|
|
||||||
"""
|
|
||||||
print(f"🔄 STARTER BAKGRUNNSSKRAPING FOR FØLGENDE IDER: {facility_ids}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
ids_arg = ",".join(map(str, facility_ids))
|
|
||||||
|
|
||||||
# NYTT: Bruker "python -u" for LIVE logging, og fjerner "> /dev/null 2>&1"
|
|
||||||
command = f"python -u scrape_status.py --ids {ids_arg}"
|
|
||||||
|
|
||||||
subprocess.run(command, shell=True, check=True)
|
|
||||||
|
|
||||||
print(f"✅ BAKGRUNNSSKRAPING FULLFØRT FOR IDER: {facility_ids}")
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"❌ FEIL UNDER BAKGRUNNSSKRAPING: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"🔥 UFORUTSETT FEIL UNDER BAKGRUNNSSKRAPING: {e}")
|
|
||||||
|
|
||||||
def run_membership_worker(facility_ids: List[int]):
|
|
||||||
"""Kjører medlemskap-skraperen i bakgrunnen."""
|
|
||||||
print(f"🔄 STARTER MEDLEMSKAP-SKRAPING FOR IDER: {facility_ids}")
|
|
||||||
try:
|
|
||||||
ids_arg = ",".join(map(str, facility_ids))
|
|
||||||
command = f"python -u scrape_membership.py --ids {ids_arg}"
|
|
||||||
subprocess.run(command, shell=True, check=True)
|
|
||||||
print(f"✅ MEDLEMSKAP-SKRAPING FULLFØRT FOR IDER: {facility_ids}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"🔥 FEIL UNDER MEDLEMSKAP-SKRAPING: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
# Opprett database-pool ved start
|
|
||||||
try:
|
|
||||||
print(f"📡 Forsøker å koble til database på: {DB_URL}")
|
|
||||||
app.state.pool = await asyncpg.create_pool(
|
|
||||||
DB_URL,
|
|
||||||
min_size=5,
|
|
||||||
max_size=20,
|
|
||||||
command_timeout=60
|
|
||||||
)
|
|
||||||
print("✅ Database tilkoblet og pool opprettet")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Databasefeil under oppstart: {e}")
|
|
||||||
raise e
|
|
||||||
yield
|
|
||||||
# Lukk pool ved avslutning
|
|
||||||
await app.state.pool.close()
|
|
||||||
|
|
||||||
app = FastAPI(title="TeeOff API v3.8.0", lifespan=lifespan)
|
|
||||||
|
|
||||||
# CORS - Tillater både lokal utvikling og produksjonsdomene
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=[
|
|
||||||
"https://nye.teeoff.no",
|
|
||||||
"http://nye.teeoff.no",
|
|
||||||
"http://localhost:3000"
|
|
||||||
],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- AUTH ENDPOINTS ---
|
|
||||||
|
|
||||||
@app.post("/api/auth/login")
|
|
||||||
async def login(data: dict):
|
|
||||||
"""Steg 1: Sjekk passord og returner temp_token for 2FA."""
|
|
||||||
print(f"🔐 Loggin-forsøk for: {data.get('username')}")
|
|
||||||
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
admin = await conn.fetchrow(
|
|
||||||
"SELECT * FROM admins WHERE username = $1 OR email = $1",
|
|
||||||
data.get('username')
|
|
||||||
)
|
|
||||||
|
|
||||||
if not admin:
|
|
||||||
print(" - ❌ Bruker ikke funnet i databasen")
|
|
||||||
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
|
|
||||||
|
|
||||||
h = admin['password_hash']
|
|
||||||
print(f" - Verifiserer hash i DB (starter med: {h[:20]}...)")
|
|
||||||
|
|
||||||
try:
|
|
||||||
is_valid = pwd_context.verify(data.get('password'), h)
|
|
||||||
except Exception as e:
|
|
||||||
print(f" - 🔥 FEIL VED LESING AV HASH: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internt problem med passord-format")
|
|
||||||
|
|
||||||
if not is_valid:
|
|
||||||
print(" - ❌ Passordet samsvarer ikke med hashen")
|
|
||||||
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
|
|
||||||
|
|
||||||
temp_token = jwt.encode(
|
|
||||||
{"sub": admin['username'], "partial": True, "exp": datetime.utcnow() + timedelta(minutes=5)},
|
|
||||||
SECRET_KEY, algorithm=ALGORITHM
|
|
||||||
)
|
|
||||||
print(" - ✅ Steg 1 fullført. Temp-token generert.")
|
|
||||||
return {"step": "2fa", "temp_token": temp_token}
|
|
||||||
|
|
||||||
@app.post("/api/auth/verify-2fa")
|
|
||||||
async def verify_2fa(data: dict, response: Response):
|
|
||||||
"""Steg 2: Verifiser TOTP-kode og sett session cookie."""
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(data.get('temp_token'), SECRET_KEY, algorithms=[ALGORITHM])
|
|
||||||
if not payload.get("partial"):
|
|
||||||
raise JWTError()
|
|
||||||
username = payload.get("sub")
|
|
||||||
except JWTError:
|
|
||||||
raise HTTPException(status_code=401, detail="Sesjonen har utløpt eller er ugyldig")
|
|
||||||
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
admin = await conn.fetchrow("SELECT otp_secret FROM admins WHERE username = $1", username)
|
|
||||||
|
|
||||||
totp = pyotp.TOTP(admin['otp_secret'])
|
|
||||||
if not totp.verify(data.get('code')):
|
|
||||||
print(f" - ❌ Feil 2FA-kode oppgitt for {username}")
|
|
||||||
raise HTTPException(status_code=401, detail="Feil 2FA-kode")
|
|
||||||
|
|
||||||
final_token = jwt.encode(
|
|
||||||
{"sub": username, "exp": datetime.utcnow() + timedelta(hours=12)},
|
|
||||||
SECRET_KEY, algorithm=ALGORITHM
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sett som HTTP-only cookie
|
|
||||||
response.set_cookie(
|
|
||||||
key="admin_session",
|
|
||||||
value=final_token,
|
|
||||||
httponly=True,
|
|
||||||
samesite="lax",
|
|
||||||
secure=False # Sett til True i produksjon (HTTPS)
|
|
||||||
)
|
|
||||||
return {"status": "success"}
|
|
||||||
|
|
||||||
# --- DATA ENDPOINTS ---
|
|
||||||
|
|
||||||
@app.get("/api/facilities")
|
|
||||||
async def get_facilities():
|
|
||||||
"""Henter alle golfanlegg med aggregert banestatus for forsiden."""
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
rows = await conn.fetch("""
|
|
||||||
SELECT f.*, (
|
|
||||||
SELECT jsonb_agg(cs) FROM (
|
|
||||||
SELECT id, name, status FROM courses
|
|
||||||
WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to'
|
|
||||||
ORDER BY is_main_course DESC, id ASC
|
|
||||||
) cs
|
|
||||||
) as course_statuses
|
|
||||||
FROM facilities f
|
|
||||||
ORDER BY f.name ASC
|
|
||||||
""")
|
|
||||||
return [format_row(row) for row in rows]
|
|
||||||
|
|
||||||
@app.get("/api/facilities/{slug}")
|
|
||||||
async def get_facility(slug: str):
|
|
||||||
"""Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull."""
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
row = await conn.fetchrow("""
|
|
||||||
SELECT f.*, (
|
|
||||||
SELECT jsonb_agg(c_data) FROM (
|
|
||||||
SELECT c.*, (
|
|
||||||
SELECT jsonb_agg(h_data ORDER BY h_data.hole_number ASC)
|
|
||||||
FROM (SELECT * FROM holes WHERE course_id = c.id) h_data
|
|
||||||
) as holes
|
|
||||||
FROM courses c
|
|
||||||
WHERE c.facility_id = f.id
|
|
||||||
AND (c.is_main_course = true OR (c.status NOT IN ('finnes_ingen_bane_to', 'ukjent')))
|
|
||||||
ORDER BY c.is_main_course DESC, c.id ASC
|
|
||||||
) c_data
|
|
||||||
) as courses
|
|
||||||
FROM facilities f WHERE f.slug = $1
|
|
||||||
""", slug)
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
|
|
||||||
|
|
||||||
return format_row(row)
|
|
||||||
|
|
||||||
# --- ADMIN ENDPOINTS ---
|
|
||||||
|
|
||||||
@app.patch("/api/admin/facilities/{facility_id}/scrape-settings")
|
|
||||||
async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate):
|
|
||||||
"""Oppdaterer hvordan et anlegg skal skrapes (f.eks. slå på Gemini AI eller bytte URL)."""
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
try:
|
|
||||||
# Sjekk først at anlegget eksisterer
|
|
||||||
facility = await conn.fetchrow("SELECT id FROM facilities WHERE id = $1", facility_id)
|
|
||||||
if not facility:
|
|
||||||
raise HTTPException(status_code=404, detail="Anlegget finnes ikke.")
|
|
||||||
|
|
||||||
# Oppdater verdiene i databasen inkludert AI instruks
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE facilities
|
|
||||||
SET scrape_method = $1,
|
|
||||||
scrape_status_url = $2,
|
|
||||||
scrape_status_selector = $3,
|
|
||||||
ai_instruction = $4
|
|
||||||
WHERE id = $5
|
|
||||||
""",
|
|
||||||
settings.scrape_method,
|
|
||||||
settings.scrape_status_url,
|
|
||||||
settings.scrape_status_selector,
|
|
||||||
settings.ai_instruction,
|
|
||||||
facility_id)
|
|
||||||
|
|
||||||
# Hvis metoden er manuell, tvinger vi gjennom de nye banestatusene direkte
|
|
||||||
if settings.scrape_method == 'manual' and settings.courses:
|
|
||||||
for c in settings.courses:
|
|
||||||
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", c.status, c.id)
|
|
||||||
|
|
||||||
return {"status": "success", "message": f"Skrapeinnstillinger for anlegg ID {facility_id} ble oppdatert."}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
if isinstance(e, HTTPException):
|
|
||||||
raise e
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
# --- NYTT ADMIN ENDPOINT FOR FULL OPPDATERING (JSON-EDITOR) ---
|
|
||||||
@app.put("/api/admin/facilities/{facility_id}/full")
|
|
||||||
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()
|
|
||||||
|
|
||||||
# Felter som er trygge å oppdatere manuelt på anlegget
|
|
||||||
allowed_fields = [
|
|
||||||
'name', 'description', 'established_year', 'season', 'banetype', 'architect', 'length_meters',
|
|
||||||
'address', 'zipcode', 'city', 'county', 'lat', 'lng',
|
|
||||||
'email', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url',
|
|
||||||
'weather_url', 'webcam_url', 'video_url', 'baneguide_url', 'flyfoto_url',
|
|
||||||
'amenities', 'greenfee', 'golfpakker', 'rabattert_greenfee',
|
|
||||||
'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',
|
|
||||||
'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'
|
|
||||||
]
|
|
||||||
|
|
||||||
update_data = {k: v for k, v in data.items() if k in allowed_fields}
|
|
||||||
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
async with conn.transaction(): # Sikrer at alt lagres samlet
|
|
||||||
|
|
||||||
# 1. OPPDATER ANLEGG (FACILITIES)
|
|
||||||
if update_data:
|
|
||||||
set_clauses = []
|
|
||||||
values = []
|
|
||||||
|
|
||||||
# Definer hvilke felt som er datoer i databasen
|
|
||||||
date_fields = ['membership_updated_at', 'greenfee_updated_at', 'vtg_updated_at', 'status_updated_at']
|
|
||||||
|
|
||||||
for i, (k, v) in enumerate(update_data.items(), 1):
|
|
||||||
if isinstance(v, (dict, list)):
|
|
||||||
set_clauses.append(f"{k} = ${i}::jsonb")
|
|
||||||
values.append(json.dumps(v))
|
|
||||||
elif k in date_fields:
|
|
||||||
set_clauses.append(f"{k} = ${i}")
|
|
||||||
# Håndter tomme datoer og konverter til Python datetime
|
|
||||||
if v == "" or v is None:
|
|
||||||
values.append(None)
|
|
||||||
else:
|
|
||||||
# Tving strengen over til et ekte datetime-objekt.
|
|
||||||
# .replace() håndterer Next.js' "Z"-format.
|
|
||||||
dt_str = str(v).replace("Z", "+00:00")
|
|
||||||
try:
|
|
||||||
dt_obj = datetime.fromisoformat(dt_str)
|
|
||||||
values.append(dt_obj)
|
|
||||||
except ValueError:
|
|
||||||
values.append(None)
|
|
||||||
else:
|
|
||||||
set_clauses.append(f"{k} = ${i}")
|
|
||||||
values.append(v)
|
|
||||||
|
|
||||||
values.append(facility_id)
|
|
||||||
query = f"UPDATE facilities SET {', '.join(set_clauses)} WHERE id = ${len(values)}"
|
|
||||||
await conn.execute(query, *values)
|
|
||||||
|
|
||||||
# 2. OPPDATER BANER (COURSES) OG HULL (HOLES)
|
|
||||||
courses = data.get('courses', [])
|
|
||||||
for course in courses:
|
|
||||||
course_id = course.get('id')
|
|
||||||
if course_id:
|
|
||||||
# Rens datoformat for PostgreSQL (håndterer Next.js date input)
|
|
||||||
valid_until_str = course.get('slope_valid_until')
|
|
||||||
if valid_until_str == "" or valid_until_str is None:
|
|
||||||
valid_until = None
|
|
||||||
else:
|
|
||||||
# Gjør om strengen til et ekte date-objekt for asyncpg
|
|
||||||
try:
|
|
||||||
date_part = valid_until_str.split('T')[0]
|
|
||||||
valid_until = datetime.strptime(date_part, "%Y-%m-%d").date()
|
|
||||||
except ValueError:
|
|
||||||
valid_until = None
|
|
||||||
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE courses
|
|
||||||
SET name=$1, par=$2, length_meters=$3, architect=$4,
|
|
||||||
status=$5, is_main_course=$6, tee_boxes=$7::jsonb,
|
|
||||||
slope_valid_until=$8
|
|
||||||
WHERE id=$9 AND facility_id=$10
|
|
||||||
""",
|
|
||||||
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)
|
|
||||||
|
|
||||||
# 3. OPPDATER HULL PÅ BANEN (HOLES)
|
|
||||||
holes = course.get('holes', [])
|
|
||||||
for hole in holes:
|
|
||||||
hole_id = hole.get('id')
|
|
||||||
if hole_id:
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE holes
|
|
||||||
SET par=$1, hcp_index=$2, lengths=$3::jsonb
|
|
||||||
WHERE id=$4 AND course_id=$5
|
|
||||||
""",
|
|
||||||
hole.get('par'), hole.get('hcp_index'),
|
|
||||||
json.dumps(hole.get('lengths', {})), hole_id, course_id)
|
|
||||||
|
|
||||||
return {"status": "success", "message": "Anlegg, baner og scorekort ble oppdatert."}
|
|
||||||
|
|
||||||
# --- NYTT ADMIN ENDPOINT: KJØRER SKRAPEREN FOR VALGTE IDER ---
|
|
||||||
@app.post("/api/admin/run-scraper")
|
|
||||||
async def run_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
|
|
||||||
"""
|
|
||||||
Tar imot IDer for skraping, og starter en bakgrunnsjobb.
|
|
||||||
Gir et umiddelbart svar tilbake til frontenden slik at den slipper å vente.
|
|
||||||
"""
|
|
||||||
if not request.facility_ids:
|
|
||||||
raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
|
|
||||||
|
|
||||||
print(f"📡 API mottok forespørsel om å kjøre skraping for IDer: {request.facility_ids}")
|
|
||||||
|
|
||||||
background_tasks.add_task(run_scrape_worker, request.facility_ids)
|
|
||||||
|
|
||||||
return {"status": "queued", "message": f"Skraping for {len(request.facility_ids)} anlegg ble lagt i kø."}
|
|
||||||
|
|
||||||
@app.post("/api/admin/run-membership-scraper")
|
|
||||||
async def run_membership_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
|
|
||||||
"""Tar imot IDer for medlemskapsskraping og legger jobben i kø."""
|
|
||||||
if not request.facility_ids:
|
|
||||||
raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
|
|
||||||
|
|
||||||
print(f"📡 API mottok forespørsel om medlemskapsskraping for IDer: {request.facility_ids}")
|
|
||||||
background_tasks.add_task(run_membership_worker, request.facility_ids)
|
|
||||||
|
|
||||||
return {"status": "queued", "message": f"Medlemskapsskraping for {len(request.facility_ids)} anlegg ble lagt i kø."}
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
|
||||||
async def health_check():
|
|
||||||
"""Enkel sjekk for å se at API og DB lever."""
|
|
||||||
try:
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
await conn.execute("SELECT 1")
|
|
||||||
return {"status": "healthy", "database": "connected"}
|
|
||||||
except Exception as e:
|
|
||||||
return {"status": "unhealthy", "error": str(e)}
|
|
||||||
|
|
||||||
# --- MEDLEMSKAP "VASKERI" ENDEPUNKTER ---
|
|
||||||
|
|
||||||
@app.get("/api/admin/membership/drafts")
|
|
||||||
async def get_membership_drafts():
|
|
||||||
"""Henter alle anlegg som har et ventende forslag fra AI-skraperen."""
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
rows = await conn.fetch("""
|
|
||||||
SELECT id, name, slug, medlemskap_url,
|
|
||||||
navn_standard_medlemskap, standard_medlemskap,
|
|
||||||
navn_rimeligste_alternativ, rimeligste_alternativ,
|
|
||||||
membership_draft
|
|
||||||
FROM facilities
|
|
||||||
WHERE membership_draft IS NOT NULL
|
|
||||||
AND membership_draft::text != '{}'
|
|
||||||
ORDER BY name ASC
|
|
||||||
""")
|
|
||||||
return [format_row(row) for row in rows]
|
|
||||||
|
|
||||||
@app.post("/api/admin/membership/approve-bulk")
|
|
||||||
async def approve_membership_bulk(request: BulkApprovalRequest):
|
|
||||||
"""Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet."""
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
async with conn.transaction():
|
|
||||||
for approval in request.approvals:
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE facilities
|
|
||||||
SET navn_standard_medlemskap = $1,
|
|
||||||
standard_medlemskap = $2,
|
|
||||||
standard_medlemskap_kommentarer = $3,
|
|
||||||
navn_rimeligste_alternativ = $4,
|
|
||||||
rimeligste_alternativ = $5,
|
|
||||||
membership_updated_at = NOW(),
|
|
||||||
membership_draft = NULL
|
|
||||||
WHERE id = $6
|
|
||||||
""",
|
|
||||||
approval.navn_standard_medlemskap,
|
|
||||||
approval.standard_medlemskap,
|
|
||||||
approval.standard_medlemskap_kommentarer,
|
|
||||||
approval.navn_rimeligste_alternativ,
|
|
||||||
approval.rimeligste_alternativ,
|
|
||||||
approval.facility_id)
|
|
||||||
return {"status": "success", "message": f"{len(request.approvals)} anlegg ble oppdatert med nye priser!"}
|
|
||||||
|
|
||||||
@app.patch("/api/admin/facilities/{facility_id}/quick-edit")
|
|
||||||
async def quick_edit_facility(facility_id: int, request: QuickEditRequest):
|
|
||||||
"""Lyn-redigering av enkle URL-felter fra admin-dashbordet."""
|
|
||||||
# Sikkerhet: Tillat KUN disse tre feltene for hurtigredigering
|
|
||||||
allowed_fields = ['scrape_status_url', 'medlemskap_url', 'scrape_status_selector']
|
|
||||||
if request.field not in allowed_fields:
|
|
||||||
raise HTTPException(status_code=400, detail="Ugyldig felt for hurtigredigering.")
|
|
||||||
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
# F-string her er trygt fordi request.field er sjekket mot allowed_fields-listen
|
|
||||||
await conn.execute(f"UPDATE facilities SET {request.field} = $1 WHERE id = $2",
|
|
||||||
request.value, facility_id)
|
|
||||||
return {"status": "success"}
|
|
||||||
|
|
||||||
# --- GREENFEE "VASKERI" ENDEPUNKTER ---
|
|
||||||
|
|
||||||
@app.get("/api/admin/greenfee/drafts")
|
|
||||||
async def get_greenfee_drafts():
|
|
||||||
"""Henter alle anlegg som har et ventende greenfee-forslag fra AI-skraperen."""
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
rows = await conn.fetch("""
|
|
||||||
SELECT id, name, slug, greenfee_url, greenfee, greenfee_draft
|
|
||||||
FROM facilities
|
|
||||||
WHERE greenfee_draft IS NOT NULL
|
|
||||||
AND greenfee_draft::text != '{}'
|
|
||||||
ORDER BY name ASC
|
|
||||||
""")
|
|
||||||
return [format_row(row) for row in rows]
|
|
||||||
|
|
||||||
class BulkGreenfeeRequest(BaseModel):
|
|
||||||
approvals: List[GreenfeeApproval]
|
|
||||||
|
|
||||||
@app.post("/api/admin/greenfee/approve-bulk")
|
|
||||||
async def approve_greenfee_bulk(request: BulkGreenfeeRequest):
|
|
||||||
"""Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet."""
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
async with conn.transaction():
|
|
||||||
for approval in request.approvals:
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE facilities
|
|
||||||
SET greenfee = $1::jsonb,
|
|
||||||
greenfee_updated_at = NOW(),
|
|
||||||
greenfee_draft = NULL
|
|
||||||
WHERE id = $2
|
|
||||||
""", json.dumps(approval.greenfee), approval.facility_id)
|
|
||||||
return {"status": "success"}
|
|
||||||
|
|
||||||
def run_greenfee_worker(facility_ids: List[int]):
|
|
||||||
"""Kjører greenfee-skraperen i bakgrunnen."""
|
|
||||||
print(f"🔄 STARTER GREENFEE-SKRAPING FOR IDER: {facility_ids}")
|
|
||||||
try:
|
|
||||||
import subprocess
|
|
||||||
ids_arg = ",".join(map(str, facility_ids))
|
|
||||||
command = f"python -u scrape_greenfee.py --ids {ids_arg}"
|
|
||||||
subprocess.run(command, shell=True, check=True)
|
|
||||||
print(f"✅ GREENFEE-SKRAPING FULLFØRT FOR IDER: {facility_ids}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"🔥 FEIL UNDER GREENFEE-SKRAPING: {e}")
|
|
||||||
|
|
||||||
@app.post("/api/admin/run-greenfee-scraper")
|
|
||||||
async def run_greenfee_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
|
|
||||||
"""Tar imot IDer for greenfeeskraping og legger jobben i kø."""
|
|
||||||
if not request.facility_ids:
|
|
||||||
raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
|
|
||||||
background_tasks.add_task(run_greenfee_worker, request.facility_ids)
|
|
||||||
return {"status": "queued", "message": "Skraping startet"}
|
|
||||||
|
|
||||||
# --- VEIEN TIL GOLF (VTG) "VASKERI" ENDEPUNKTER ---
|
|
||||||
|
|
||||||
@app.get("/api/admin/vtg/drafts")
|
|
||||||
async def get_vtg_drafts():
|
|
||||||
"""Henter alle anlegg som har et ventende VTG-forslag."""
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
rows = await conn.fetch("""
|
|
||||||
SELECT id, name, slug, vtg_lenke, vtg_pris, vtg_beskrivelse, vtg_datoer, vtg_draft
|
|
||||||
FROM facilities
|
|
||||||
WHERE vtg_draft IS NOT NULL
|
|
||||||
AND vtg_draft::text != '{}'
|
|
||||||
ORDER BY name ASC
|
|
||||||
""")
|
|
||||||
return [format_row(row) for row in rows]
|
|
||||||
|
|
||||||
@app.post("/api/admin/vtg/approve-bulk")
|
|
||||||
async def approve_vtg_bulk(request: BulkVtgRequest):
|
|
||||||
"""Godkjenner AI-forslag for VTG, setter oppdatert-dato og sletter utkastet."""
|
|
||||||
async with app.state.pool.acquire() as conn:
|
|
||||||
async with conn.transaction():
|
|
||||||
for approval in request.approvals:
|
|
||||||
datoer_json = json.dumps(approval.vtg_datoer) if approval.vtg_datoer is not None else '[]'
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE facilities
|
|
||||||
SET vtg_pris = $1,
|
|
||||||
vtg_beskrivelse = $2,
|
|
||||||
vtg_datoer = $3::jsonb,
|
|
||||||
vtg_updated_at = NOW(),
|
|
||||||
vtg_draft = NULL
|
|
||||||
WHERE id = $4
|
|
||||||
""", approval.vtg_pris, approval.vtg_beskrivelse, datoer_json, approval.facility_id)
|
|
||||||
return {"status": "success"}
|
|
||||||
|
|
||||||
def run_vtg_worker(facility_ids: List[int]):
|
|
||||||
"""Kjører VTG-skraperen i bakgrunnen."""
|
|
||||||
print(f"🔄 STARTER VTG-SKRAPING FOR IDER: {facility_ids}")
|
|
||||||
try:
|
|
||||||
import subprocess
|
|
||||||
ids_arg = ",".join(map(str, facility_ids))
|
|
||||||
command = f"python -u scrape_vtg.py --ids {ids_arg}"
|
|
||||||
subprocess.run(command, shell=True, check=True)
|
|
||||||
print(f"✅ VTG-SKRAPING FULLFØRT FOR IDER: {facility_ids}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"🔥 FEIL UNDER VTG-SKRAPING: {e}")
|
|
||||||
|
|
||||||
@app.post("/api/admin/run-vtg-scraper")
|
|
||||||
async def run_vtg_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
|
|
||||||
"""Tar imot IDer for VTG-skraping og legger jobben i kø."""
|
|
||||||
if not request.facility_ids:
|
|
||||||
raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
|
|
||||||
background_tasks.add_task(run_vtg_worker, request.facility_ids)
|
|
||||||
return {"status": "queued", "message": "Skraping startet"}
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import uvicorn
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
|
|
||||||
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
|
||||||
|
|
||||||
# Data hentet direkte fra bildet du sendte
|
|
||||||
GOLFAMORE_DATA = {
|
|
||||||
"borre": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 14, 19, 20, 21",
|
|
||||||
"nesfjellet": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 27, 28, 29, 30",
|
|
||||||
"vradal": "Kortet er gyldig alle dager, ikke uke 28, 29, 30, 31",
|
|
||||||
"alta": "Kortet er gyldig alle dager",
|
|
||||||
"elverum": "Kortet er gyldig hverdager (ikke helligdager)",
|
|
||||||
"gronmo": "Kortet er gyldig alle dager",
|
|
||||||
"notteroy": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 27, 28, 29, 30",
|
|
||||||
"roros": "Kortet er gyldig alle dager",
|
|
||||||
"stiklestad": "Kortet er gyldig alle dager",
|
|
||||||
"arendalomegn": "Kortet er gyldig alle dager, ikke uke 27, 28, 29, 30",
|
|
||||||
"northcape": "Kortet er gyldig alle dager",
|
|
||||||
"trysil": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 28, 29, 30, 31",
|
|
||||||
"mork": "Kortet er gyldig hverdager (ikke helligdager)",
|
|
||||||
"norsjo": "Kortet er gyldig alle dager",
|
|
||||||
"ringerike": "Kortet er gyldig alle dager",
|
|
||||||
"stord": "Kortet er gyldig alle dager",
|
|
||||||
"sunnmore": "Kortet er gyldig alle dager",
|
|
||||||
"bodogolfparksalten": "Kortet er gyldig alle dager",
|
|
||||||
"drammen": "Kortet er gyldig alle dager",
|
|
||||||
"gjoviktoten": "Kortet er gyldig alle dager",
|
|
||||||
"grenlandomegn": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 27, 28, 29, 30",
|
|
||||||
"nes09": "Kortet er gyldig alle dager, ikke uke 15, 16, 17, 18",
|
|
||||||
"romerike": "Kortet er gyldig alle dager",
|
|
||||||
"bamble": "Kortet er gyldig alle dager",
|
|
||||||
"bleik": "Kortet er gyldig alle dager",
|
|
||||||
"krokhol": "Kortet er gyldig alle dager",
|
|
||||||
"skjeberg": "Kortet er gyldig hverdager (ikke helligdager)",
|
|
||||||
"utsikten": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 27, 28, 29, 30",
|
|
||||||
"eiker": "Kortet er gyldig alle dager",
|
|
||||||
"hafjell": "Kortet er gyldig alle dager",
|
|
||||||
"mandal": "Kortet er gyldig alle dager, ikke uke 27, 28, 29, 30",
|
|
||||||
"mjosen": "Kortet er gyldig alle dager",
|
|
||||||
"randsfjorden": "Kortet er gyldig alle dager",
|
|
||||||
"ski": "Kortet er gyldig alle dager",
|
|
||||||
"bjornefjorden": "Kortet er gyldig alle dager",
|
|
||||||
"sande": "Kortet er gyldig alle dager",
|
|
||||||
"haugesund": "Kortet er gyldig alle dager",
|
|
||||||
"midttroms": "Kortet er gyldig alle dager",
|
|
||||||
"skei": "Kortet er gyldig hverdager (ikke helligdager)",
|
|
||||||
"sorknes": "Kortet er gyldig alle dager",
|
|
||||||
"gjerdrum": "Kortet er gyldig alle dager",
|
|
||||||
"herdla": "Kortet er gyldig alle dager",
|
|
||||||
"hovden": "Kortet er gyldig alle dager",
|
|
||||||
"oppdal": "Kortet er gyldig alle dager",
|
|
||||||
"gjersjoen": "Kortet er gyldig alle dager",
|
|
||||||
"ogna": "Kortet er gyldig alle dager",
|
|
||||||
"tonsberg": "Kortet er gyldig alle dager",
|
|
||||||
"ullensaker": "Kortet er gyldig alle dager",
|
|
||||||
"hof": "Kortet er gyldig hverdager (ikke helligdager)",
|
|
||||||
"klabu": "Kortet er gyldig alle dager",
|
|
||||||
"hemsedal": "Kortet er gyldig alle dager",
|
|
||||||
"narvik": "Kortet er gyldig alle dager",
|
|
||||||
"norefjell": "Kortet er gyldig hverdager (ikke helligdager)",
|
|
||||||
"austratt": "Kortet er gyldig alle dager",
|
|
||||||
"hammerfest": "Kortet er gyldig alle dager",
|
|
||||||
"helgeland": "Kortet er gyldig alle dager",
|
|
||||||
"jaren": "Kortet er gyldig alle dager",
|
|
||||||
"namdal": "Kortet er gyldig alle dager",
|
|
||||||
"namsos": "Kortet er gyldig alle dager",
|
|
||||||
"nordfjord": "Kortet er gyldig alle dager",
|
|
||||||
"polarsirkelen": "Kortet er gyldig alle dager",
|
|
||||||
"sandnesbarheim": "Kortet er gyldig alle dager",
|
|
||||||
"steinkjer": "Kortet er gyldig alle dager",
|
|
||||||
"varanger": "Kortet er gyldig alle dager"
|
|
||||||
}
|
|
||||||
|
|
||||||
def clean(text):
|
|
||||||
if not text: return ""
|
|
||||||
# Fjerner alt som ikke er bokstaver/tall for matching
|
|
||||||
s = text.lower().replace("golfklubb", "").replace("gk", "").replace(" og ", "").replace("&", "").strip()
|
|
||||||
return re.sub(r'[^a-z0-9]', '', s)
|
|
||||||
|
|
||||||
async def update_golfamore():
|
|
||||||
print("\n🚀 OPPDATERER GOLFAMORE FRA BILDE-DATA...")
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
facilities = await conn.fetch("SELECT id, name FROM facilities")
|
|
||||||
|
|
||||||
# Lag et vasket map av bilde-dataen
|
|
||||||
image_data_clean = {clean(name): val for name, val in GOLFAMORE_DATA.items()}
|
|
||||||
|
|
||||||
matches = 0
|
|
||||||
for fac in facilities:
|
|
||||||
fac_id = fac['id']
|
|
||||||
fac_name = fac['name']
|
|
||||||
fac_clean = clean(fac_name)
|
|
||||||
|
|
||||||
validity = None
|
|
||||||
# Prøv eksakt match først
|
|
||||||
if fac_clean in image_data_clean:
|
|
||||||
validity = image_data_clean[fac_clean]
|
|
||||||
else:
|
|
||||||
# Prøv delvis match (f.eks "Arendal" i "Arendal & Omegn")
|
|
||||||
for key, val in image_data_clean.items():
|
|
||||||
if len(fac_clean) > 4 and (fac_clean in key or key in fac_clean):
|
|
||||||
validity = val
|
|
||||||
break
|
|
||||||
|
|
||||||
if validity:
|
|
||||||
print(f"✅ Match funnet: {fac_name}")
|
|
||||||
ga_data = {"validity": validity}
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE facilities
|
|
||||||
SET golfamore = true, golfamore_data = $1
|
|
||||||
WHERE id = $2
|
|
||||||
""", json.dumps(ga_data), fac_id)
|
|
||||||
matches += 1
|
|
||||||
else:
|
|
||||||
# Hvis den ikke er i listen fra bildet, sett til false
|
|
||||||
await conn.execute("UPDATE facilities SET golfamore = false, golfamore_data = '{}' WHERE id = $1", fac_id)
|
|
||||||
|
|
||||||
await conn.close()
|
|
||||||
print(f"\n🎉 Ferdig! {matches} baner ble oppdatert med Golfamore-info.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(update_golfamore())
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
"""
|
|
||||||
TEE OFF - GREENFEE-SKRAPER MED GEMINI AI
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
Henter alle greenfee-varianter fra en (eller flere) URL-er og strukturerer
|
|
||||||
dem i en JSON-liste. Finner også avtaleklubber/vennskapsklubber.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import argparse
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from playwright.async_api import async_playwright
|
|
||||||
import google.generativeai as genai
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
|
||||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
|
||||||
|
|
||||||
if not GEMINI_API_KEY:
|
|
||||||
raise ValueError("🚨 GEMINI_API_KEY mangler i .env filen!")
|
|
||||||
|
|
||||||
genai.configure(api_key=GEMINI_API_KEY)
|
|
||||||
model = genai.GenerativeModel('gemini-2.5-flash')
|
|
||||||
|
|
||||||
async def fetch_page_text(url: str, browser) -> str:
|
|
||||||
url = url.strip()
|
|
||||||
if not url.startswith("http"):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
print(f" 🌐 Laster inn: {url}")
|
|
||||||
try:
|
|
||||||
page = await browser.new_page()
|
|
||||||
await page.goto(url, wait_until="domcontentloaded", timeout=15000)
|
|
||||||
html_content = await page.content()
|
|
||||||
await page.close()
|
|
||||||
|
|
||||||
soup = BeautifulSoup(html_content, 'html.parser')
|
|
||||||
for script in soup(["script", "style", "nav", "footer", "header"]):
|
|
||||||
script.extract()
|
|
||||||
|
|
||||||
return soup.get_text(separator=' ', strip=True)
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ Feil ved lasting av {url}: {e}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def analyze_greenfee_with_gemini(text: str, club_name: str) -> dict:
|
|
||||||
print(f" 🧠 Sender {len(text)} tegn til Gemini for greenfee-analyse...")
|
|
||||||
|
|
||||||
prompt = f"""
|
|
||||||
Du er en ekspert på norske golfklubber og prissetting.
|
|
||||||
Din oppgave er å lese teksten hentet fra nettsidene til "{club_name}" og hente ut TO ting:
|
|
||||||
1. ALLE varianter av greenfee-priser.
|
|
||||||
2. Navn på eventuelle vennskapsklubber/avtaleklubber (hvis nevnt).
|
|
||||||
|
|
||||||
REGLER FOR GREENFEE:
|
|
||||||
- Trekk ut absolutt alle priskategorier du finner (f.eks. "Hverdag høysesong", "Helg før kl 14", "Gjest av medlem", "9 hull kveld", osv.).
|
|
||||||
- Finn både voksenpris og juniorpris for hver kategori.
|
|
||||||
- HVIS juniorpris er oppgitt som en regel (f.eks. "Juniorer betaler halv pris" eller "50% rabatt for junior"), MÅ du selv regne ut prisen og skrive inn heltallet.
|
|
||||||
- "banenavn": Bruk navnet på banen hvis det er spesifisert (f.eks. "18-hullsbanen", "Korthullsbanen"). Hvis ikke spesifisert, bruk "{club_name}".
|
|
||||||
- Priser SKAL være tall (integer). Sett pris til null (null) hvis den ikke finnes.
|
|
||||||
|
|
||||||
REGLER FOR AVTALEKLUBBER:
|
|
||||||
- Let etter overskrifter som "Vennskapsklubber", "Avtaleklubber", "Gjestespill", "Samarbeidsklubber".
|
|
||||||
- Trekk ut kun navnene på klubbene i en liste (f.eks. ["Haga GK", "Oslo GK"]). La listen være tom hvis du ikke finner noen.
|
|
||||||
|
|
||||||
TEKST FRA NETTSIDEN:
|
|
||||||
{text}
|
|
||||||
|
|
||||||
OPPGAVE:
|
|
||||||
Returner KUN et gyldig JSON-objekt med nøyaktig følgende struktur:
|
|
||||||
{{
|
|
||||||
"foreslatt_greenfee": [
|
|
||||||
{{
|
|
||||||
"banenavn": "Navn på banen",
|
|
||||||
"priskategori": "F.eks: Hverdag Gjest av Medlem",
|
|
||||||
"pris_voksne": 600,
|
|
||||||
"pris_junior": 300
|
|
||||||
}}
|
|
||||||
],
|
|
||||||
"foreslatt_avtaleklubber": [
|
|
||||||
"Klubb 1 GK",
|
|
||||||
"Klubb 2 GK"
|
|
||||||
],
|
|
||||||
"ai_begrunnelse": "Kort forklaring, f.eks: 'Fant et komplekst prissystem for høy/lavsesong. Regnet ut juniorpriser til 50% som angitt i teksten. Fant 3 samarbeidsklubber nederst.'"
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = model.generate_content(prompt)
|
|
||||||
raw_response = response.text.strip()
|
|
||||||
|
|
||||||
if raw_response.startswith("```json"):
|
|
||||||
raw_response = raw_response[7:]
|
|
||||||
if raw_response.endswith("```"):
|
|
||||||
raw_response = raw_response[:-3]
|
|
||||||
|
|
||||||
return json.loads(raw_response.strip())
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ AI-analyse feilet: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def run_greenfee_scraper(facility_ids=None):
|
|
||||||
print("🚀 Starter Greenfee-skraperen...")
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
|
|
||||||
try:
|
|
||||||
query = "SELECT id, name, greenfee_url FROM facilities WHERE greenfee_url IS NOT NULL AND greenfee_url != ''"
|
|
||||||
if facility_ids:
|
|
||||||
query += f" AND id IN ({','.join(map(str, facility_ids))})"
|
|
||||||
|
|
||||||
facilities = await conn.fetch(query)
|
|
||||||
print(f"📋 Fant {len(facilities)} anlegg å skrape.")
|
|
||||||
|
|
||||||
async with async_playwright() as p:
|
|
||||||
browser = await p.chromium.launch(headless=True)
|
|
||||||
|
|
||||||
for facility in facilities:
|
|
||||||
fac_id = facility['id']
|
|
||||||
name = facility['name']
|
|
||||||
urls_raw = facility['greenfee_url']
|
|
||||||
|
|
||||||
print(f"\n▶️ Behandler Greenfee for: {name} (ID: {fac_id})")
|
|
||||||
|
|
||||||
urls = [u.strip() for u in urls_raw.split(',')]
|
|
||||||
combined_text = ""
|
|
||||||
|
|
||||||
for idx, url in enumerate(urls, 1):
|
|
||||||
page_text = await fetch_page_text(url, browser)
|
|
||||||
if page_text:
|
|
||||||
combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}"
|
|
||||||
|
|
||||||
if len(combined_text) < 50:
|
|
||||||
print(" ⚠️ Fant for lite tekst, hopper over.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
draft_data = analyze_greenfee_with_gemini(combined_text[:25000], name)
|
|
||||||
|
|
||||||
if not draft_data:
|
|
||||||
continue
|
|
||||||
|
|
||||||
funnet_priser = len(draft_data.get('foreslatt_greenfee', []))
|
|
||||||
funnet_klubber = len(draft_data.get('foreslatt_avtaleklubber', []))
|
|
||||||
print(f" ✅ AI fant {funnet_priser} greenfee-varianter og {funnet_klubber} avtaleklubber.")
|
|
||||||
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE facilities
|
|
||||||
SET greenfee_draft = $1::jsonb
|
|
||||||
WHERE id = $2
|
|
||||||
""", json.dumps(draft_data), fac_id)
|
|
||||||
|
|
||||||
print(" 💾 Greenfee-utkast lagret i databasen!")
|
|
||||||
|
|
||||||
await browser.close()
|
|
||||||
|
|
||||||
finally:
|
|
||||||
await conn.close()
|
|
||||||
print("\n🏁 Skraping fullført.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(description="Skrap greenfeepriser via AI.")
|
|
||||||
parser.add_argument("--ids", type=str, help="Kommaseparert liste med facility IDs (eks: 1,5,12)")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
ids_to_scrape = None
|
|
||||||
if args.ids:
|
|
||||||
ids_to_scrape = [int(x.strip()) for x in args.ids.split(",")]
|
|
||||||
|
|
||||||
asyncio.run(run_greenfee_scraper(ids_to_scrape))
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
"""
|
|
||||||
TEE OFF - MEDLEMSKAPSSKRAPER MED GEMINI AI (MULTI-URL VERSJON)
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
Går til oppgitte medlemskaps-URLer (støtter flere URLer adskilt med komma),
|
|
||||||
henter ut tekst, og bruker Gemini til å summere og finne 'Standard' og
|
|
||||||
'Rimeligste' medlemskap.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import argparse
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from playwright.async_api import async_playwright
|
|
||||||
import google.generativeai as genai
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
|
||||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
|
||||||
|
|
||||||
if not GEMINI_API_KEY:
|
|
||||||
raise ValueError("🚨 GEMINI_API_KEY mangler i .env filen!")
|
|
||||||
|
|
||||||
genai.configure(api_key=GEMINI_API_KEY)
|
|
||||||
model = genai.GenerativeModel('gemini-2.5-flash')
|
|
||||||
|
|
||||||
async def fetch_page_text(url: str, browser) -> str:
|
|
||||||
"""Bruker Playwright for å hente all synlig tekst fra EN nettside."""
|
|
||||||
url = url.strip()
|
|
||||||
if not url.startswith("http"):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
print(f" 🌐 Laster inn: {url}")
|
|
||||||
try:
|
|
||||||
page = await browser.new_page()
|
|
||||||
await page.goto(url, wait_until="domcontentloaded", timeout=15000)
|
|
||||||
html_content = await page.content()
|
|
||||||
await page.close()
|
|
||||||
|
|
||||||
soup = BeautifulSoup(html_content, 'html.parser')
|
|
||||||
for script in soup(["script", "style", "nav", "footer", "header"]):
|
|
||||||
script.extract()
|
|
||||||
|
|
||||||
text = soup.get_text(separator=' ', strip=True)
|
|
||||||
return text
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ Feil ved lasting av {url}: {e}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def analyze_with_gemini(text: str, club_name: str) -> dict:
|
|
||||||
"""Sender den kombinerte teksten til Gemini for å trekke ut og evt. summere priser."""
|
|
||||||
print(f" 🧠 Sender {len(text)} tegn til Gemini for analyse...")
|
|
||||||
|
|
||||||
prompt = f"""
|
|
||||||
Du er en ekspert på norske golfklubber. Din oppgave er å lese teksten hentet fra nettsidene til "{club_name}" og finne to spesifikke priser.
|
|
||||||
|
|
||||||
VIKTIG REGEL OM NORSK GOLF:
|
|
||||||
Mange steder er "Klubbkontingent/Medlemskap" og "Spillerett/Årskort" to forskjellige ting.
|
|
||||||
For å få spille ubegrenset (Fritt spill) MÅ man betale BEGGE DELER. Hvis du ser at prisene for kontingent og spillerett er oppgitt hver for seg, SKAL DU SUMMERE disse to summene og bruke totalen som "Standard pris".
|
|
||||||
|
|
||||||
ALDERSPREMISS FOR BEGGE PRISER:
|
|
||||||
Vi forutsetter at personen som skal ha medlemskap er en VOKSEN GOLFER PÅ MINST 35 ÅR. Du må ALDRI velge priser som gjelder for barn, junior, ung voksen (f.eks. 20-29 år), student eller senior/pensjonist.
|
|
||||||
|
|
||||||
DEFINISJONER DU MÅ FØLGE STRENGT:
|
|
||||||
1. "Standard medlemskap": Hva er TOTALPRISEN (inkludert evt. spillerett/årskort) for en voksen person (35+ år) for å spille SÅ MYE VEDKOMMENDE ØNSKER (Fritt spill) i år?
|
|
||||||
2. "Rimeligste alternativ": Det absolutt billigste alternativet FOR EN VOKSEN PERSON (35+ år) som gir medlemskap i klubben (golfkortet), forutsatt at man betaler greenfee for hver runde. (Ofte kalt Greenfeemedlem, Postkassemedlem, Fjernmedlem, eller kun "Klubbkontingent for voksne" uten spillerett).
|
|
||||||
|
|
||||||
TEKST FRA NETTSIDEN(E):
|
|
||||||
{text}
|
|
||||||
|
|
||||||
OPPGAVE:
|
|
||||||
Returner KUN et gyldig JSON-objekt med følgende struktur:
|
|
||||||
{{
|
|
||||||
"foreslatt_standard_navn": "Navn (eks: Hovedmedlem Voksen inkl. spillerett)",
|
|
||||||
"foreslatt_standard_pris": 1234,
|
|
||||||
"foreslatt_standard_kommentar": "Kort kommentar (eks: Måtte summere kontingent på 900 og årskort på 5000)",
|
|
||||||
"foreslatt_rimeligste_navn": "Navn (eks: Greenfeemedlemskap Voksen)",
|
|
||||||
"foreslatt_rimeligste_pris": 500,
|
|
||||||
"ai_begrunnelse": "Kort forklaring på utregningen din."
|
|
||||||
}}
|
|
||||||
Merk: Prisene SKAL være tall (integer), ikke tekst. Sett til null hvis du ikke finner det.
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = model.generate_content(prompt)
|
|
||||||
raw_response = response.text.strip()
|
|
||||||
|
|
||||||
if raw_response.startswith("```json"):
|
|
||||||
raw_response = raw_response[7:]
|
|
||||||
if raw_response.endswith("```"):
|
|
||||||
raw_response = raw_response[:-3]
|
|
||||||
|
|
||||||
return json.loads(raw_response.strip())
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ AI-analyse feilet: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def run_scraper(facility_ids=None):
|
|
||||||
print("🚀 Starter Medlemskaps-skraperen (Støtter multi-URL)...")
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
|
|
||||||
try:
|
|
||||||
query = "SELECT id, name, medlemskap_url FROM facilities WHERE medlemskap_url IS NOT NULL AND medlemskap_url != ''"
|
|
||||||
if facility_ids:
|
|
||||||
query += f" AND id IN ({','.join(map(str, facility_ids))})"
|
|
||||||
|
|
||||||
facilities = await conn.fetch(query)
|
|
||||||
print(f"📋 Fant {len(facilities)} anlegg å skrape.")
|
|
||||||
|
|
||||||
async with async_playwright() as p:
|
|
||||||
browser = await p.chromium.launch(headless=True)
|
|
||||||
|
|
||||||
for facility in facilities:
|
|
||||||
fac_id = facility['id']
|
|
||||||
name = facility['name']
|
|
||||||
urls_raw = facility['medlemskap_url']
|
|
||||||
|
|
||||||
print(f"\n▶️ Behandler: {name} (ID: {fac_id})")
|
|
||||||
|
|
||||||
# Sjekker om det er flere URL-er adskilt med komma
|
|
||||||
urls = [u.strip() for u in urls_raw.split(',')]
|
|
||||||
combined_text = ""
|
|
||||||
|
|
||||||
for idx, url in enumerate(urls, 1):
|
|
||||||
page_text = await fetch_page_text(url, browser)
|
|
||||||
if page_text:
|
|
||||||
combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}"
|
|
||||||
|
|
||||||
if len(combined_text) < 50:
|
|
||||||
print(" ⚠️ Fant for lite tekst, hopper over.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Kutter teksten for å ikke overbelaste Gemini (ca 25000 tegn maks)
|
|
||||||
draft_data = analyze_with_gemini(combined_text[:25000], name)
|
|
||||||
|
|
||||||
if not draft_data:
|
|
||||||
continue
|
|
||||||
|
|
||||||
print(f" ✅ AI foreslår: Standard: {draft_data.get('foreslatt_standard_pris')} | Rimeligste: {draft_data.get('foreslatt_rimeligste_pris')}")
|
|
||||||
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE facilities
|
|
||||||
SET membership_draft = $1::jsonb
|
|
||||||
WHERE id = $2
|
|
||||||
""", json.dumps(draft_data), fac_id)
|
|
||||||
|
|
||||||
print(" 💾 Utkast lagret i databasen!")
|
|
||||||
|
|
||||||
await browser.close()
|
|
||||||
|
|
||||||
finally:
|
|
||||||
await conn.close()
|
|
||||||
print("\n🏁 Skraping fullført.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(description="Skrap medlemskapspriser via AI.")
|
|
||||||
parser.add_argument("--ids", type=str, help="Kommaseparert liste med facility IDs (eks: 1,5,12)")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
ids_to_scrape = None
|
|
||||||
if args.ids:
|
|
||||||
ids_to_scrape = [int(x.strip()) for x in args.ids.split(",")]
|
|
||||||
|
|
||||||
asyncio.run(run_scraper(ids_to_scrape))
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
import httpx
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
import re
|
|
||||||
import json
|
|
||||||
|
|
||||||
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
|
||||||
|
|
||||||
def clean_name(text):
|
|
||||||
if not text: return ""
|
|
||||||
s = text.lower().replace("golfklubb", "").replace("gk", "").replace("par3golf", "").replace(" & ", "").strip()
|
|
||||||
return re.sub(r'[^a-z]', '', s)
|
|
||||||
|
|
||||||
def clean_nsg_content(text):
|
|
||||||
"""Fjerner doble linjeskift og kutter teksten før websidemenyen starter"""
|
|
||||||
if not text: return ""
|
|
||||||
# Fjern alt som ligner på bunn-menyen til NSG
|
|
||||||
garbage_phrases = [
|
|
||||||
"Klubbens hjemmeside", "Resultatlister i Golfbox", "Livescoring",
|
|
||||||
"Scoreinntasting", "Lagserie", "Turneringer", "Innmelding"
|
|
||||||
]
|
|
||||||
for phrase in garbage_phrases:
|
|
||||||
text = text.split(phrase)[0]
|
|
||||||
|
|
||||||
# Rydd opp i linjeskift og doble mellomrom
|
|
||||||
text = text.replace('\r', '').replace('\n', ' ')
|
|
||||||
text = re.sub(r'\s+', ' ', text).strip()
|
|
||||||
return text
|
|
||||||
|
|
||||||
async def get_nsg_links(client):
|
|
||||||
links = []
|
|
||||||
urls = ["https://seniorgolf.no/lojalitetskort-sitemap.xml", "https://seniorgolf.no/fordelskortet/"]
|
|
||||||
for url in urls:
|
|
||||||
try:
|
|
||||||
resp = await client.get(url)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
if ".xml" in url:
|
|
||||||
found = re.findall(r'<loc>(https://seniorgolf.no/lojalitetskort/.*?/)</loc>', resp.text)
|
|
||||||
if found: return list(set(found))
|
|
||||||
else:
|
|
||||||
soup = BeautifulSoup(resp.text, 'html.parser')
|
|
||||||
links.extend([l['href'] for l in soup.select('a[href*="/lojalitetskort/"]')])
|
|
||||||
except: continue
|
|
||||||
return list(set(links))
|
|
||||||
|
|
||||||
async def scrape_nsg():
|
|
||||||
print("🚀 Starter NSG VASKEMASKIN v3.8...")
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
facilities = await conn.fetch("SELECT id, name FROM facilities")
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=20.0, headers={'User-Agent': 'Mozilla/5.0'}) as client:
|
|
||||||
all_nsg_links = await get_nsg_links(client)
|
|
||||||
link_map = {clean_name(l.split('/')[-2].replace('-', ' ')): l for l in all_nsg_links}
|
|
||||||
|
|
||||||
matches_found = 0
|
|
||||||
for fac in facilities:
|
|
||||||
fac_name_clean = clean_name(fac['name'])
|
|
||||||
match_url = link_map.get(fac_name_clean)
|
|
||||||
|
|
||||||
if not match_url:
|
|
||||||
for slug, url in link_map.items():
|
|
||||||
if fac_name_clean in slug or slug in fac_name_clean:
|
|
||||||
match_url = url
|
|
||||||
break
|
|
||||||
|
|
||||||
if match_url:
|
|
||||||
try:
|
|
||||||
f_resp = await client.get(match_url)
|
|
||||||
f_soup = BeautifulSoup(f_resp.text, 'html.parser')
|
|
||||||
|
|
||||||
# Finn hovedinnholdet i stedet for hele siden for å unngå menyer
|
|
||||||
main_content = f_soup.find('div', {'class': 'entry-content'}) or f_soup
|
|
||||||
text = main_content.get_text()
|
|
||||||
|
|
||||||
st = re.search(r"Starttider:?\s*(.*?)(?=Greenfee|Booking|Adresse|Kontakt|$)", text, re.S | re.I)
|
|
||||||
gf = re.search(r"Greenfee:?\s*(.*?)(?=Booking|Adresse|Kontakt|$)", text, re.S | re.I)
|
|
||||||
bk = re.search(r"Booking:?\s*(.*?)(?=Adresse|Kontakt|$)", text, re.S | re.I)
|
|
||||||
|
|
||||||
nsg_data = {
|
|
||||||
"url": match_url,
|
|
||||||
"starttider": clean_nsg_content(st.group(1)) if st else "Se nettside",
|
|
||||||
"greenfee": clean_nsg_content(gf.group(1)) if gf else "Se nettside",
|
|
||||||
"booking": clean_nsg_content(bk.group(1)) if bk else "Se nettside"
|
|
||||||
}
|
|
||||||
|
|
||||||
await conn.execute("UPDATE facilities SET nsg_data = $1 WHERE id = $2", json.dumps(nsg_data), fac['id'])
|
|
||||||
print(f"✅ Vasket & Lagret: {fac['name']}")
|
|
||||||
matches_found += 1
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
await conn.close()
|
|
||||||
print(f"\n🎉 Vask ferdig! {matches_found} baner er nå 100% klare.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(scrape_nsg())
|
|
||||||
|
|
@ -1,332 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import os
|
|
||||||
import asyncpg
|
|
||||||
import smtplib
|
|
||||||
import re
|
|
||||||
import argparse
|
|
||||||
from datetime import datetime
|
|
||||||
from email.mime.text import MIMEText
|
|
||||||
from email.mime.multipart import MIMEMultipart
|
|
||||||
from playwright.async_api import async_playwright
|
|
||||||
try:
|
|
||||||
from playwright_stealth import stealth_async as apply_stealth
|
|
||||||
except ImportError:
|
|
||||||
from playwright_stealth import stealth as apply_stealth
|
|
||||||
|
|
||||||
from google import genai
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# KONFIGURERER GEMINI AI (NY SDK)
|
|
||||||
# ==========================================
|
|
||||||
client = genai.Client()
|
|
||||||
|
|
||||||
async def ask_llm_status(text, course_name, is_single_course, ai_instruction=None):
|
|
||||||
if is_single_course:
|
|
||||||
bane_instruks = "Finn den generelle banestatusen for dette golfanlegget. Se bort fra spesifikke banenavn, da anlegget kun har én bane."
|
|
||||||
else:
|
|
||||||
bane_instruks = f'Finn banestatusen SPESIFIKT for banen som heter/omtales som: "{course_name}".'
|
|
||||||
|
|
||||||
ekstra_tekst = f"\n!!! VIKTIG EKSTRA-INSTRUKS FRA ADMIN (DENNE OVERSTYRER ALLE ANDRE REGLER) !!!:\n{ai_instruction}\n" if ai_instruction else ""
|
|
||||||
|
|
||||||
prompt = f"""
|
|
||||||
Du er en ekspert på å lese norske golfklubbers nettsider for å finne banestatus.
|
|
||||||
{bane_instruks}
|
|
||||||
{ekstra_tekst}
|
|
||||||
Svar KUN med nøyaktig ETT av disse ordene:
|
|
||||||
- aapen (hvis banen er åpen/sommergreener)
|
|
||||||
- stengt (hvis banen er lukket/stengt/frost/snø)
|
|
||||||
- aapen_med_vintergreener (hvis det spilles på vintergreener)
|
|
||||||
- aapner_snart (hvis den åpner om kort tid)
|
|
||||||
- stenger_snart (hvis den stenger for sesongen om kort tid)
|
|
||||||
- under_utvikling (hvis den er under utvikling)
|
|
||||||
- nedlagt (hvis den er nedlagt)
|
|
||||||
- ukjent (hvis du ikke finner noe info om banen i teksten)
|
|
||||||
|
|
||||||
Tekst fra nettsiden:
|
|
||||||
{text[:15000]}
|
|
||||||
"""
|
|
||||||
|
|
||||||
print("\n" + "="*60)
|
|
||||||
print(f"🤖 SENDER PROMPT TIL GEMINI FOR: '{course_name}'")
|
|
||||||
print(f"👉 STANDARD-INSTRUKS: {bane_instruks}")
|
|
||||||
if ai_instruction:
|
|
||||||
print(f"👉 ADMIN-HVISKER: {ai_instruction}")
|
|
||||||
clean_text_sample = " ".join(text.split())[:250]
|
|
||||||
print(f"👉 TEKST FRA NETTSIDEN (utdrag): '{clean_text_sample}...'")
|
|
||||||
print("="*60 + "\n")
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await client.aio.models.generate_content(
|
|
||||||
model='gemini-2.5-flash',
|
|
||||||
contents=prompt
|
|
||||||
)
|
|
||||||
svar = response.text.strip().lower()
|
|
||||||
|
|
||||||
print(f" 🧠 GEMINI RÅ-SVAR: '{svar}'")
|
|
||||||
|
|
||||||
# --- NYTT: SORTERT SIKKERHETSFILTER ---
|
|
||||||
gyldige_svar = [
|
|
||||||
"aapen_med_vintergreener",
|
|
||||||
"aapner_snart",
|
|
||||||
"stenger_snart",
|
|
||||||
"under_utvikling",
|
|
||||||
"nedlagt",
|
|
||||||
"stengt",
|
|
||||||
"aapen",
|
|
||||||
"ukjent"
|
|
||||||
]
|
|
||||||
|
|
||||||
for gyldig in gyldige_svar:
|
|
||||||
if gyldig in svar:
|
|
||||||
return gyldig
|
|
||||||
return "ukjent"
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Gemini Feil: {e}")
|
|
||||||
return "ukjent"
|
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# EKSISTERENDE LOGIKK FOR MANUELL SCRAPING
|
|
||||||
# ==========================================
|
|
||||||
def clean_text(text):
|
|
||||||
return re.sub(r'[^a-zA-Z0-9æøåÆØÅ]', '', text).lower()
|
|
||||||
|
|
||||||
def interpret_status(text, keyword=None):
|
|
||||||
t_raw = text.lower()
|
|
||||||
|
|
||||||
if keyword:
|
|
||||||
k_clean = clean_text(keyword)
|
|
||||||
if k_clean not in clean_text(t_raw):
|
|
||||||
return "NOT_FOUND"
|
|
||||||
|
|
||||||
parts = re.split(re.escape(keyword), t_raw, flags=re.IGNORECASE)
|
|
||||||
if len(parts) > 1:
|
|
||||||
t_raw = parts[1][:150]
|
|
||||||
else:
|
|
||||||
t_raw = t_raw[-200:]
|
|
||||||
|
|
||||||
if any(word in t_raw for word in ["stengt", "lukket", "frost", "snø", "is", "closed", "stenger"]):
|
|
||||||
return "stengt"
|
|
||||||
if any(word in t_raw for word in ["vintergreen", "vintergrønn", "vinter"]):
|
|
||||||
return "aapen_med_vintergreener"
|
|
||||||
if any(word in t_raw for word in ["snart", "åpner kl"]):
|
|
||||||
return "aapner_snart"
|
|
||||||
if any(word in t_raw for word in ["åpen", "åpent", "aapen", "open"]):
|
|
||||||
return "aapen"
|
|
||||||
return "ukjent"
|
|
||||||
|
|
||||||
def send_report(changes, warnings, successes):
|
|
||||||
if not changes and not warnings and not successes: return
|
|
||||||
subject = f"TeeOff Banestatus Rapport - {datetime.now().strftime('%d.%m.%Y')}"
|
|
||||||
|
|
||||||
body = "BANESTATUS RAPPORT\n" + "="*30 + "\n\n"
|
|
||||||
|
|
||||||
if changes: body += "✅ OPPDATERINGER:\n" + "\n".join(changes) + "\n\n"
|
|
||||||
if warnings: body += "⚠️ MERKNADER / ADVARSLER:\n" + "\n".join(warnings) + "\n\n"
|
|
||||||
if successes: body += "🆗 VELLYKKEDE SJEKKER (INGEN ENDRING):\n" + "\n".join(successes) + "\n"
|
|
||||||
|
|
||||||
msg = MIMEMultipart()
|
|
||||||
msg['From'] = os.getenv("SMTP_USER")
|
|
||||||
msg['To'] = os.getenv("EMAIL_TO")
|
|
||||||
msg['Subject'] = subject
|
|
||||||
msg.attach(MIMEText(body, 'plain'))
|
|
||||||
try:
|
|
||||||
with smtplib.SMTP_SSL(os.getenv("SMTP_SERVER"), int(os.getenv("SMTP_PORT"))) as server:
|
|
||||||
server.login(os.getenv("SMTP_USER"), os.getenv("SMTP_PASS"))
|
|
||||||
server.send_message(msg)
|
|
||||||
print("✅ Rapport sendt på e-post.")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ E-post feil: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# HOVEDMOTOR
|
|
||||||
# ==========================================
|
|
||||||
async def run_daily_scraping(facility_ids=None):
|
|
||||||
print(f"🚀 Starter sjekk {datetime.now().strftime('%H:%M:%S')}...")
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
|
|
||||||
if facility_ids:
|
|
||||||
print(f"📌 Kjører skraping KUN for anlegg-ID(er): {facility_ids}")
|
|
||||||
facilities = await conn.fetch(
|
|
||||||
"SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method, ai_instruction FROM facilities WHERE scrape_status_url IS NOT NULL AND id = ANY($1::int[])",
|
|
||||||
facility_ids
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print("🌍 Kjører skraping for ALLE anlegg med scrape_status_url...")
|
|
||||||
facilities = await conn.fetch(
|
|
||||||
"SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method, ai_instruction FROM facilities WHERE scrape_status_url IS NOT NULL"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not facilities:
|
|
||||||
print("⚠️ Fant ingen anlegg å skrape.")
|
|
||||||
await conn.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
changes, warnings, successes = [], [], []
|
|
||||||
|
|
||||||
async with async_playwright() as p:
|
|
||||||
browser = await p.chromium.launch(headless=True)
|
|
||||||
context = await browser.new_context()
|
|
||||||
|
|
||||||
for f in facilities:
|
|
||||||
method = f.get('scrape_method') or 'css_selector'
|
|
||||||
|
|
||||||
if method == 'manual':
|
|
||||||
successes.append(f"⏸️ {f['name']}: Hoppet over (Manuell overstyring)")
|
|
||||||
print(f" ⏸️ Hopper over skraping av {f['name']} (Satt til Manuell)")
|
|
||||||
continue
|
|
||||||
|
|
||||||
page = await context.new_page()
|
|
||||||
try: await apply_stealth(page)
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
print(f"🔍 Besøker {f['name']} (Metode: {method})...")
|
|
||||||
await page.goto(f['scrape_status_url'], timeout=60000, wait_until="domcontentloaded")
|
|
||||||
await page.wait_for_timeout(3000)
|
|
||||||
|
|
||||||
full_text = ""
|
|
||||||
|
|
||||||
if method == 'css_selector':
|
|
||||||
element = page.locator(f['scrape_status_selector']).first
|
|
||||||
if await element.count() == 0:
|
|
||||||
warnings.append(f"❌ {f['name']}: Fant ikke CSS-elementet '{f['scrape_status_selector']}'")
|
|
||||||
continue
|
|
||||||
full_text = await element.inner_text()
|
|
||||||
|
|
||||||
elif method == 'iframe_golfbox':
|
|
||||||
frame = page.frame_locator('iframe[src*="golfbox"]')
|
|
||||||
element = frame.locator(f['scrape_status_selector']).first
|
|
||||||
if await element.count() == 0:
|
|
||||||
warnings.append(f"❌ {f['name']}: Fant ikke elementet '{f['scrape_status_selector']}' i iframen")
|
|
||||||
continue
|
|
||||||
full_text = await element.inner_text()
|
|
||||||
|
|
||||||
elif method == 'click_then_css':
|
|
||||||
parts = f['scrape_status_selector'].split('||')
|
|
||||||
if len(parts) != 2:
|
|
||||||
warnings.append(f"❌ {f['name']}: Ugyldig selector for click_then_css (mangler ||)")
|
|
||||||
continue
|
|
||||||
|
|
||||||
btn_selector, text_selector = parts
|
|
||||||
btn = page.locator(btn_selector).first
|
|
||||||
if await btn.count() == 0:
|
|
||||||
warnings.append(f"❌ {f['name']}: Fant ikke knappen å klikke på: '{btn_selector}'")
|
|
||||||
continue
|
|
||||||
|
|
||||||
await btn.click(force=True)
|
|
||||||
await page.wait_for_timeout(2000)
|
|
||||||
|
|
||||||
element = page.locator(text_selector).first
|
|
||||||
if await element.count() == 0:
|
|
||||||
warnings.append(f"❌ {f['name']}: Fant ikke tekstboksen '{text_selector}' etter klikk")
|
|
||||||
continue
|
|
||||||
|
|
||||||
full_text = await element.inner_text()
|
|
||||||
|
|
||||||
elif method == 'llm_parse':
|
|
||||||
print(" 🖱️ Leter etter knapper å klikke på for å avdekke skjult tekst...")
|
|
||||||
knapper = await page.get_by_text(re.compile(r"banestatus|dagens status|se status|se dagens status|baneinfo|\bstatus\b", re.IGNORECASE)).all()
|
|
||||||
|
|
||||||
klikk_count = 0
|
|
||||||
for knapp in knapper:
|
|
||||||
try:
|
|
||||||
if await knapp.is_visible():
|
|
||||||
await knapp.click(timeout=2000, force=True)
|
|
||||||
klikk_count += 1
|
|
||||||
await page.wait_for_timeout(2000)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if klikk_count > 0:
|
|
||||||
print(f" 🎯 Tvangsklikket på {klikk_count} status-knapp(er)! Venter ekstra på at innholdet laster...")
|
|
||||||
await page.wait_for_timeout(2000)
|
|
||||||
else:
|
|
||||||
print(" ⚠️ Fant ingen knapper å klikke på.")
|
|
||||||
|
|
||||||
# --- NYTT: HENTER OGSÅ SKJULT TEKST (For Scangolf megamenyer) ---
|
|
||||||
element = page.locator("body").first
|
|
||||||
if await element.count() == 0:
|
|
||||||
warnings.append(f"❌ {f['name']}: Klarte ikke å lese siden for AI-tolkning")
|
|
||||||
continue
|
|
||||||
|
|
||||||
synlig_tekst = await element.inner_text() or ""
|
|
||||||
skjult_tekst = await element.text_content() or ""
|
|
||||||
|
|
||||||
# Slår sammen all tekst slik at Gemini får med seg menyer som er gjemt med CSS
|
|
||||||
råtekst = synlig_tekst + " " + skjult_tekst
|
|
||||||
full_text = " ".join(råtekst.split())
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
|
|
||||||
else:
|
|
||||||
warnings.append(f"⚠️ {f['name']}: Ukjent skrapemetode i databasen: '{method}'")
|
|
||||||
continue
|
|
||||||
|
|
||||||
await conn.execute("UPDATE facilities SET status_updated_at = CURRENT_DATE WHERE id = $1", f['id'])
|
|
||||||
|
|
||||||
courses = await conn.fetch("SELECT id, name, status, scrape_keyword FROM courses WHERE facility_id = $1", f['id'])
|
|
||||||
|
|
||||||
is_single_course = len(courses) == 1
|
|
||||||
|
|
||||||
for c in courses:
|
|
||||||
old_status = c['status'] or "ukjent"
|
|
||||||
|
|
||||||
if method == 'llm_parse':
|
|
||||||
print(f" 🤖 Spør Gemini om status for '{c['name']}' (Singelbane: {is_single_course})...")
|
|
||||||
new_status = await ask_llm_status(full_text, c['name'], is_single_course, f.get('ai_instruction'))
|
|
||||||
|
|
||||||
print(" ⏳ Tar 5 sekunders pause for å spare Gemini-kvoten...")
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
else:
|
|
||||||
new_status = interpret_status(full_text, c['scrape_keyword'])
|
|
||||||
|
|
||||||
if new_status == "NOT_FOUND":
|
|
||||||
warnings.append(f"❓ {f['name']} ({c['name']}): Fant ikke søkeordet '{c['scrape_keyword']}' i teksten.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# --- OPPDATERT LOGIKK (Fikser logg-buggen) ---
|
|
||||||
if new_status == "ukjent":
|
|
||||||
# Sikkerhetsnettet slår inn: Vi beholder gammel status!
|
|
||||||
warnings.append(f"⚠️ {f['name']} ({c['name']}): Fant ikke status. Beholder '{old_status.upper()}'.")
|
|
||||||
print(f" 🟡 KONKLUSJON: Fant ikke status i teksten (Sikkerhetsnett). Beholder gammel status ({old_status.upper()}).")
|
|
||||||
elif new_status != old_status:
|
|
||||||
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", new_status, c['id'])
|
|
||||||
changes.append(f"🔹 {f['name']} ({c['name']}): {old_status.upper()} ➔ {new_status.upper()}")
|
|
||||||
print(f" 🟢 KONKLUSJON: Status endret fra {old_status.upper()} til {new_status.upper()}")
|
|
||||||
else:
|
|
||||||
successes.append(f"✅ {f['name']} ({c['name']}): {new_status.upper()}")
|
|
||||||
print(f" ⚪ KONKLUSJON: Ingen endring. Banen er fortsatt {old_status.upper()}")
|
|
||||||
# ---------------------------------------------
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
err_msg = str(e).split('\n')[0]
|
|
||||||
warnings.append(f"🔥 {f['name']}: Feil under skraping: {err_msg}")
|
|
||||||
finally:
|
|
||||||
await page.close()
|
|
||||||
|
|
||||||
await browser.close()
|
|
||||||
|
|
||||||
await conn.close()
|
|
||||||
send_report(changes, warnings, successes)
|
|
||||||
print("🏁 Ferdig.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(description="TeeOff Status Scraper")
|
|
||||||
parser.add_argument("--ids", type=str, help="Kommaseparert liste med anleggs-IDer", default=None)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
facility_ids_list = None
|
|
||||||
if args.ids:
|
|
||||||
try:
|
|
||||||
facility_ids_list = [int(id_str.strip()) for id_str in args.ids.split(",") if id_str.strip()]
|
|
||||||
except ValueError:
|
|
||||||
print("❌ Feil format på --ids. Må være kommaseparerte tall, f.eks: 1,4,12")
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
asyncio.run(run_daily_scraping(facility_ids_list))
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
"""
|
|
||||||
TEE OFF - VEIEN TIL GOLF (VTG) SKRAPER MED GEMINI AI
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
Henter pris, beskrivelse (inkl. lånekøller/medlemskap) og kursdatoer fra VTG-sider.
|
|
||||||
Støtter kommaseparerte URL-er.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import argparse
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from playwright.async_api import async_playwright
|
|
||||||
import google.generativeai as genai
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
|
||||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
|
||||||
|
|
||||||
if not GEMINI_API_KEY:
|
|
||||||
raise ValueError("🚨 GEMINI_API_KEY mangler i .env filen!")
|
|
||||||
|
|
||||||
genai.configure(api_key=GEMINI_API_KEY)
|
|
||||||
model = genai.GenerativeModel('gemini-2.5-flash')
|
|
||||||
|
|
||||||
async def fetch_page_text(url: str, browser) -> str:
|
|
||||||
url = url.strip()
|
|
||||||
if not url.startswith("http"):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
print(f" 🌐 Laster inn: {url}")
|
|
||||||
try:
|
|
||||||
page = await browser.new_page()
|
|
||||||
await page.goto(url, wait_until="domcontentloaded", timeout=15000)
|
|
||||||
html_content = await page.content()
|
|
||||||
await page.close()
|
|
||||||
|
|
||||||
soup = BeautifulSoup(html_content, 'html.parser')
|
|
||||||
for script in soup(["script", "style", "nav", "footer", "header"]):
|
|
||||||
script.extract()
|
|
||||||
|
|
||||||
return soup.get_text(separator=' ', strip=True)
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ Feil ved lasting av {url}: {e}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def analyze_vtg_with_gemini(text: str, club_name: str) -> dict:
|
|
||||||
print(f" 🧠 Sender {len(text)} tegn til Gemini for VTG-analyse...")
|
|
||||||
|
|
||||||
prompt = f"""
|
|
||||||
Du er en ekspert på norske golfklubber. Din oppgave er å lese en lang tekst fra nettsidene til "{club_name}" og koke dette ned til essensen om deres "Veien til Golf" (VTG) nybegynnerkurs.
|
|
||||||
|
|
||||||
OPPGAVER:
|
|
||||||
1. Finn standardprisen for VTG-kurset for en vanlig voksen person. (Returner KUN tallet).
|
|
||||||
2. Skriv en KOMPRIMERT, selgende beskrivelse (maks 3-4 setninger). Du MÅ inkludere informasjon om:
|
|
||||||
- Er lån av køller/utstyr inkludert i kurset?
|
|
||||||
- Inkluderer prisen et medlemskap/spillerett i klubben (og ev. for hvor lenge)?
|
|
||||||
- Hva er omfanget? (F.eks. "12 timer praksis pluss e-læring").
|
|
||||||
Ignorer uvesentlig støy og lange historiske utgreiinger.
|
|
||||||
3. Finn alle kommende kursdatoer. Finn startdato/sluttdato for hvert kurs, og noter status ("Ledig", "Fulltegnet", "Venteliste").
|
|
||||||
|
|
||||||
TEKST FRA NETTSIDEN:
|
|
||||||
{text}
|
|
||||||
|
|
||||||
OPPGAVE:
|
|
||||||
Returner KUN et gyldig JSON-objekt med nøyaktig følgende struktur:
|
|
||||||
{{
|
|
||||||
"foreslatt_vtg_pris": 1990,
|
|
||||||
"foreslatt_vtg_beskrivelse": "Kurset går over 12 timer inkludert obligatorisk e-læring. Lån av golfkøller er inkludert under hele kurset, og prisen gir deg også fritt spill og medlemskap ut året.",
|
|
||||||
"foreslatt_vtg_datoer": [
|
|
||||||
{{"dato": "12.-14. mai", "status": "Fulltegnet"}},
|
|
||||||
{{"dato": "5.-7. juni", "status": "Ledig"}}
|
|
||||||
],
|
|
||||||
"ai_begrunnelse": "Fant voksenpris på 1990,-. Teksten nevnte eksplisitt at medlemskap ut året er med i prisen, og at man får låne utstyr."
|
|
||||||
}}
|
|
||||||
Merk: Sett foreslatt_vtg_pris til null (null) hvis du ikke finner den. Hvis du ikke finner datoer, la listen være tom [].
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = model.generate_content(prompt)
|
|
||||||
raw_response = response.text.strip()
|
|
||||||
|
|
||||||
if raw_response.startswith("```json"):
|
|
||||||
raw_response = raw_response[7:]
|
|
||||||
if raw_response.endswith("```"):
|
|
||||||
raw_response = raw_response[:-3]
|
|
||||||
|
|
||||||
return json.loads(raw_response.strip())
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ AI-analyse feilet: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def run_vtg_scraper(facility_ids=None):
|
|
||||||
print("🚀 Starter Veien til Golf (VTG) skraperen...")
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
|
|
||||||
try:
|
|
||||||
query = "SELECT id, name, vtg_lenke FROM facilities WHERE vtg_lenke IS NOT NULL AND vtg_lenke != ''"
|
|
||||||
if facility_ids:
|
|
||||||
query += f" AND id IN ({','.join(map(str, facility_ids))})"
|
|
||||||
|
|
||||||
facilities = await conn.fetch(query)
|
|
||||||
print(f"📋 Fant {len(facilities)} anlegg å skrape.")
|
|
||||||
|
|
||||||
async with async_playwright() as p:
|
|
||||||
browser = await p.chromium.launch(headless=True)
|
|
||||||
|
|
||||||
for facility in facilities:
|
|
||||||
fac_id = facility['id']
|
|
||||||
name = facility['name']
|
|
||||||
urls_raw = facility['vtg_lenke']
|
|
||||||
|
|
||||||
print(f"\n▶️ Behandler VTG for: {name} (ID: {fac_id})")
|
|
||||||
|
|
||||||
urls = [u.strip() for u in urls_raw.split(',')]
|
|
||||||
combined_text = ""
|
|
||||||
|
|
||||||
for idx, url in enumerate(urls, 1):
|
|
||||||
page_text = await fetch_page_text(url, browser)
|
|
||||||
if page_text:
|
|
||||||
combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}"
|
|
||||||
|
|
||||||
if len(combined_text) < 50:
|
|
||||||
print(" ⚠️ Fant for lite tekst, hopper over.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
draft_data = analyze_vtg_with_gemini(combined_text[:25000], name)
|
|
||||||
|
|
||||||
if not draft_data:
|
|
||||||
continue
|
|
||||||
|
|
||||||
print(f" ✅ AI fant pris: {draft_data.get('foreslatt_vtg_pris')}, og {len(draft_data.get('foreslatt_vtg_datoer', []))} datoer.")
|
|
||||||
|
|
||||||
await conn.execute("""
|
|
||||||
UPDATE facilities
|
|
||||||
SET vtg_draft = $1::jsonb
|
|
||||||
WHERE id = $2
|
|
||||||
""", json.dumps(draft_data), fac_id)
|
|
||||||
|
|
||||||
print(" 💾 VTG-utkast lagret i databasen!")
|
|
||||||
|
|
||||||
await browser.close()
|
|
||||||
|
|
||||||
finally:
|
|
||||||
await conn.close()
|
|
||||||
print("\n🏁 Skraping fullført.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(description="Skrap VTG via AI.")
|
|
||||||
parser.add_argument("--ids", type=str, help="Kommaseparert liste med facility IDs (eks: 1,5,12)")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
ids_to_scrape = None
|
|
||||||
if args.ids:
|
|
||||||
ids_to_scrape = [int(x.strip()) for x in args.ids.split(",")]
|
|
||||||
|
|
||||||
asyncio.run(run_vtg_scraper(ids_to_scrape))
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
import asyncio, asyncpg, urllib.request, json
|
|
||||||
|
|
||||||
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
|
||||||
# Vi fjerner acf_format=standard da rå-feltnavnene er tryggere her
|
|
||||||
WP_API_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100"
|
|
||||||
|
|
||||||
def decode_html(text):
|
|
||||||
if not text: return ""
|
|
||||||
return str(text).replace('&', '&').replace('&', '&').replace(' ', ' ').strip()
|
|
||||||
|
|
||||||
async def run_greenfee_sync():
|
|
||||||
print("🎯 Starter GREENFEE-SYNC v1.2 (Basert på rå-API mapping)...")
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
page = 1
|
|
||||||
total_updated = 0
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(f"{WP_API_URL}&page={page}", headers={'User-Agent': 'TeeOff-Sync'})
|
|
||||||
with urllib.request.urlopen(req) as response:
|
|
||||||
data = json.loads(response.read().decode())
|
|
||||||
except: break
|
|
||||||
if not data: break
|
|
||||||
|
|
||||||
for post in data:
|
|
||||||
slug = post['slug']
|
|
||||||
acf = post.get('acf', {})
|
|
||||||
|
|
||||||
# Henter banenavn for å gruppere riktig
|
|
||||||
bane_1_navn = acf.get('navn_pa_hovedbane') or "Hovedbanen"
|
|
||||||
bane_2_navn = acf.get('navn_pa_sekundar_bane') or "Bane 2"
|
|
||||||
|
|
||||||
final_greenfee = []
|
|
||||||
|
|
||||||
# --- MAPPER BANE 1 (Voksne + Junior) ---
|
|
||||||
voksne_1 = acf.get('greenfee_-_voksne') or []
|
|
||||||
junior_1 = acf.get('greenfee_-_junior') or []
|
|
||||||
|
|
||||||
for i, item in enumerate(voksne_1):
|
|
||||||
row = {
|
|
||||||
"banenavn": bane_1_navn,
|
|
||||||
"priskategori": item.get('priskategori'),
|
|
||||||
"pris_voksne": item.get('pris_voksne')
|
|
||||||
}
|
|
||||||
# Legger til juniorpris hvis den finnes på samme index
|
|
||||||
if i < len(junior_1):
|
|
||||||
row["pris_junior"] = junior_1[i].get('pris_junior')
|
|
||||||
final_greenfee.append(row)
|
|
||||||
|
|
||||||
# --- MAPPER BANE 2 (Voksne + Junior) ---
|
|
||||||
voksne_2 = acf.get('greenfee_-_voksne_bane_to') or []
|
|
||||||
junior_2 = acf.get('greenfee_-_junior_bane_to') or []
|
|
||||||
|
|
||||||
for i, item in enumerate(voksne_2):
|
|
||||||
row = {
|
|
||||||
"banenavn": bane_2_navn,
|
|
||||||
"priskategori": item.get('priskategori_bane_to'),
|
|
||||||
"pris_voksne": item.get('pris_voksne_bane_to')
|
|
||||||
}
|
|
||||||
if i < len(junior_2):
|
|
||||||
row["pris_junior"] = junior_2[i].get('pris_junior_bane_to')
|
|
||||||
final_greenfee.append(row)
|
|
||||||
|
|
||||||
# Henter krav (Gjeste_krav)
|
|
||||||
reqs = decode_html(acf.get('krav_til_gjestespillere'))
|
|
||||||
|
|
||||||
if final_greenfee:
|
|
||||||
await conn.execute('''
|
|
||||||
UPDATE facilities SET greenfee = $1::jsonb, guest_requirements = $2 WHERE slug = $3
|
|
||||||
''', json.dumps(final_greenfee), reqs, slug)
|
|
||||||
print(f"✅ {slug}: Importerte {len(final_greenfee)} prisrader for {bane_1_navn}/{bane_2_navn}")
|
|
||||||
total_updated += 1
|
|
||||||
|
|
||||||
page += 1
|
|
||||||
await conn.close()
|
|
||||||
print(f"\n✨ Ferdig! Oppdaterte priser for {total_updated} anlegg.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(run_greenfee_sync())
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from playwright.async_api import async_playwright
|
|
||||||
from google import genai
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
# Den nye pakken henter automatisk GEMINI_API_KEY fra .env-filen din
|
|
||||||
client = genai.Client()
|
|
||||||
|
|
||||||
async def ask_llm_status(text, course_name, is_single_course):
|
|
||||||
if is_single_course:
|
|
||||||
bane_instruks = "Finn den generelle banestatusen for dette golfanlegget. Se bort fra spesifikke banenavn, da anlegget kun har én bane."
|
|
||||||
else:
|
|
||||||
bane_instruks = f'Finn banestatusen SPESIFIKT for banen som heter/omtales som: "{course_name}".'
|
|
||||||
|
|
||||||
prompt = f"""
|
|
||||||
Du er en ekspert på å lese norske golfklubbers nettsider for å finne banestatus.
|
|
||||||
{bane_instruks}
|
|
||||||
Svar KUN med nøyaktig ETT av disse ordene:
|
|
||||||
- aapen (hvis banen er åpen/sommergreener)
|
|
||||||
- stengt (hvis banen er lukket/stengt/frost/snø)
|
|
||||||
- aapen_med_vintergreener (hvis det spilles på vintergreener)
|
|
||||||
- aapner_snart (hvis den åpner om kort tid)
|
|
||||||
- stenger_snart (hvis den stenger for sesongen om kort tid)
|
|
||||||
- under_utvikling (hvis den er under utvikling)
|
|
||||||
- nedlagt (hvis den er nedlagt)
|
|
||||||
- ukjent (hvis du ikke finner noe info om banen i teksten)
|
|
||||||
|
|
||||||
Tekst fra nettsiden:
|
|
||||||
{text[:15000]}
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Ny måte å kalle modellen asynkront på med google-genai
|
|
||||||
response = await client.aio.models.generate_content(
|
|
||||||
model='gemini-2.5-flash',
|
|
||||||
contents=prompt
|
|
||||||
)
|
|
||||||
svar = response.text.strip().lower()
|
|
||||||
|
|
||||||
gyldige_svar = [
|
|
||||||
"aapen", "stengt", "aapen_med_vintergreener",
|
|
||||||
"aapner_snart", "stenger_snart", "under_utvikling",
|
|
||||||
"nedlagt", "ukjent"
|
|
||||||
]
|
|
||||||
|
|
||||||
for gyldig in gyldige_svar:
|
|
||||||
if gyldig in svar:
|
|
||||||
return gyldig
|
|
||||||
return "ukjent"
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Gemini Feil: {e}")
|
|
||||||
return "ukjent"
|
|
||||||
|
|
||||||
async def run_test():
|
|
||||||
print("\n" + "="*50)
|
|
||||||
print(" 🧪 TEE OFF: GEMINI TEST-VERKTØY (MED AUTO-KLIKKER)")
|
|
||||||
print("="*50)
|
|
||||||
|
|
||||||
url = input("🌐 Skriv inn URL til golfklubben (f.eks. https://oslogk.no): ").strip()
|
|
||||||
if not url.startswith("http"):
|
|
||||||
url = "https://" + url
|
|
||||||
|
|
||||||
course_name = input("⛳ Skriv inn banenavn (eller trykk ENTER hvis anlegget kun har 1 bane): ").strip()
|
|
||||||
is_single = len(course_name) == 0
|
|
||||||
|
|
||||||
print("\n⏳ 1. Starter nettleser og besøker siden...")
|
|
||||||
|
|
||||||
full_text = ""
|
|
||||||
async with async_playwright() as p:
|
|
||||||
browser = await p.chromium.launch(headless=True)
|
|
||||||
page = await browser.new_page()
|
|
||||||
try:
|
|
||||||
await page.goto(url, timeout=30000, wait_until="domcontentloaded")
|
|
||||||
await asyncio.sleep(3) # Vent på animasjoner og iframes
|
|
||||||
|
|
||||||
# --- NY LOGIKK: AUTO-KLIKKER ---
|
|
||||||
print("🖱️ Leter etter 'banestatus'-knapper å klikke på...")
|
|
||||||
# Vi leter etter tekst som inneholder "banestatus" (ignorerer store/små bokstaver)
|
|
||||||
knapper = await page.get_by_text(re.compile(r"banestatus", re.IGNORECASE)).all()
|
|
||||||
|
|
||||||
for knapp in knapper:
|
|
||||||
try:
|
|
||||||
if await knapp.is_visible():
|
|
||||||
await knapp.click(timeout=3000)
|
|
||||||
print(" 🎯 Klikket på en banestatus-knapp! Venter 2 sekunder...")
|
|
||||||
await asyncio.sleep(2) # Venter på at modalen/pop-upen åpner seg
|
|
||||||
break # Vi trenger bare å klikke på den første vi finner
|
|
||||||
except Exception as e:
|
|
||||||
# Ignorerer hvis knappen ikke er klikkbar, prøver neste
|
|
||||||
pass
|
|
||||||
# --------------------------------
|
|
||||||
|
|
||||||
element = page.locator("body").first
|
|
||||||
råtekst = await element.inner_text()
|
|
||||||
full_text = " ".join(råtekst.split())
|
|
||||||
print(f"✅ Hentet {len(full_text)} tegn med tekst fra nettsiden.")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Feil ved innlasting av side: {e}")
|
|
||||||
await browser.close()
|
|
||||||
return
|
|
||||||
await browser.close()
|
|
||||||
|
|
||||||
print("🧠 2. Sender teksten til Gemini for analyse...")
|
|
||||||
status = await ask_llm_status(full_text, course_name, is_single)
|
|
||||||
|
|
||||||
print("\n" + "="*50)
|
|
||||||
print(f"🎯 GEMINI SITT SVAR: {status.upper()}")
|
|
||||||
print("="*50 + "\n")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(run_test())
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
import os
|
|
||||||
from passlib.context import CryptContext
|
|
||||||
|
|
||||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
|
||||||
|
|
||||||
# Vi setter opp passord-sjekkeren AKKURAT slik main.py gjør det
|
|
||||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
|
||||||
|
|
||||||
async def test_sannheten():
|
|
||||||
print("\n" + "="*50)
|
|
||||||
print(" 🔍 TEE OFF SANNHETSSERUM")
|
|
||||||
print("="*50)
|
|
||||||
|
|
||||||
username = "Envide Webutvikling"
|
|
||||||
test_password = "Solveig Vilde Ingvild Gina" # Sørg for at dette er det du satte sist!
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
row = await conn.fetchrow("SELECT password_hash FROM admins WHERE username = $1", username)
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
print("❌ FEIL: Fant ikke brukeren i det hele tatt!")
|
|
||||||
return
|
|
||||||
|
|
||||||
db_hash = row['password_hash']
|
|
||||||
print(f"1. Hash funnet i databasen: {db_hash[:30]}...")
|
|
||||||
|
|
||||||
print(f"2. Tester mot passordet: '{test_password}'")
|
|
||||||
|
|
||||||
# Den magiske testen
|
|
||||||
is_valid = pwd_context.verify(test_password, db_hash)
|
|
||||||
|
|
||||||
print("-" * 50)
|
|
||||||
if is_valid:
|
|
||||||
print("✅ SUKSESS! Passordet og hashen stemmer 100% overens.")
|
|
||||||
print("➡️ KONKLUSJON: Hashingen fungerer perfekt. Problemet MÅ være at FastAPI (main.py) ikke klarer å lese JSON-dataene fra curl/frontend riktig.")
|
|
||||||
else:
|
|
||||||
print("❌ FEIL! Passordet stemmer IKKE med hashen i databasen.")
|
|
||||||
print("➡️ KONKLUSJON: Scriptet som oppdaterer passordet gjør en feil (f.eks. legger til usynlige tegn), eller lagringen i databasen blir korrupt.")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
await conn.close()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(test_sannheten())
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
"""
|
|
||||||
TEE OFF ADMIN PASSWORD UPDATER (API CONTAINER VERSION)
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
FUNKSJON: Kobler direkte til databasen inni API-containeren, sjekker at
|
|
||||||
brukeren finnes, og utfører passordoppdateringen automatisk.
|
|
||||||
STATUS: Påvirker IKKE tofaktor (2FA). Gjør jobben fra start til slutt.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import getpass
|
|
||||||
from passlib.hash import pbkdf2_sha256
|
|
||||||
|
|
||||||
# Henter database-URL fra miljøvariabler (samme metode som backenden din bruker)
|
|
||||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
|
||||||
|
|
||||||
async def update_admin_password():
|
|
||||||
print("\n" + "="*50)
|
|
||||||
print(" TEE OFF ADMIN PASSORD-OPPDATERER (DIREKTE TILKOBLING)")
|
|
||||||
print("="*50)
|
|
||||||
|
|
||||||
# Kobler til databasen på ekte backend-vis
|
|
||||||
try:
|
|
||||||
conn = await asyncpg.connect(DB_URL)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Kunne ikke koble til databasen: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Brukernavn-verifisering
|
|
||||||
while True:
|
|
||||||
username = input("Brukernavn på admin som skal oppdateres: ").strip()
|
|
||||||
|
|
||||||
print("⏳ Sjekker databasen...")
|
|
||||||
# Spør databasen direkte hvor mange som har dette navnet
|
|
||||||
count = await conn.fetchval("SELECT COUNT(*) FROM admins WHERE username = $1", username)
|
|
||||||
|
|
||||||
if count == 0:
|
|
||||||
print(f"❌ Fant ingen bruker med navnet '{username}'. Prøv igjen.\n")
|
|
||||||
elif count > 1:
|
|
||||||
print(f"⚠️ KRITISK FEIL: Fant {count} brukere med navnet '{username}'. Avbryter.")
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
print(f"✅ Bruker '{username}' funnet i databasen!\n")
|
|
||||||
break
|
|
||||||
|
|
||||||
# Passord-verifisering
|
|
||||||
while True:
|
|
||||||
password = getpass.getpass("Skriv inn NYTT passord: ")
|
|
||||||
password_confirm = getpass.getpass("Gjenta NYTT passord: ")
|
|
||||||
|
|
||||||
if password == password_confirm:
|
|
||||||
if len(password) < 8:
|
|
||||||
print("⚠️ Advarsel: Passordet bør være minst 8 tegn.")
|
|
||||||
print(f"\n[DEBUG] Passord akseptert.")
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
print("❌ Passordene er ikke like. Prøv igjen.\n")
|
|
||||||
|
|
||||||
print("⏳ Genererer PBKDF2-hash...")
|
|
||||||
password_hash = pbkdf2_sha256.hash(password)
|
|
||||||
|
|
||||||
print("⏳ Oppdaterer databasen automatisk...")
|
|
||||||
# Utfører selve oppdateringen (sikret mot SQL-injeksjoner)
|
|
||||||
await conn.execute("UPDATE admins SET password_hash = $1 WHERE username = $2", password_hash, username)
|
|
||||||
|
|
||||||
print("\n✅ PASSORD OPPDATERT VELLYKKET!")
|
|
||||||
print("-" * 50)
|
|
||||||
print(f"Passordet for '{username}' er nå endret i databasen.")
|
|
||||||
print("Tofaktor (2FA) og alt annet er beholdt urørt.")
|
|
||||||
print("-" * 50 + "\n")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Lukk tilkoblingen pent
|
|
||||||
await conn.close()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
# Siden vi bruker asyncpg, må scriptet kjøres i en asyncio-loop
|
|
||||||
asyncio.run(update_admin_password())
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\nAvbrutt.")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# --- KONFIGURASJON ---
|
|
||||||
KILDE_MAPPE = "/opt/teeoff/"
|
|
||||||
EKSPORT_MAPPE = "/opt/teeoff/kode_eksport_3/"
|
|
||||||
TRE_FIL = "/opt/teeoff/fil-tre-3.txt"
|
|
||||||
|
|
||||||
# Filtyper vi vil kopiere
|
|
||||||
FILTYPER = ['.py', '.ts', '.tsx']
|
|
||||||
|
|
||||||
# Mapper vi IKKE vil ha med i treet eller skanne (sparer tid og rot)
|
|
||||||
IGNORER_MAPPER = ['.git', 'node_modules', '__pycache__', 'kode_eksport', '.next']
|
|
||||||
|
|
||||||
def generer_tre_og_kopier():
|
|
||||||
kilde_sti = Path(KILDE_MAPPE)
|
|
||||||
eksport_sti = Path(EKSPORT_MAPPE)
|
|
||||||
|
|
||||||
# 1. Opprett eksportmappen hvis den ikke finnes
|
|
||||||
eksport_sti.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
tre_linjer = []
|
|
||||||
kopierte_filer = 0
|
|
||||||
|
|
||||||
print("Skanner filer og genererer tre...")
|
|
||||||
|
|
||||||
# 2. Gå gjennom alle mapper og filer
|
|
||||||
for root, dirs, files in os.walk(kilde_sti):
|
|
||||||
# Fjern ignorerte mapper så vi ikke går inn i dem
|
|
||||||
dirs[:] = [d for d in dirs if d not in IGNORER_MAPPER]
|
|
||||||
|
|
||||||
# Regn ut innrykk basert på hvor dypt vi er i mappestrukturen
|
|
||||||
nivaa = root.replace(KILDE_MAPPE, '').count(os.sep)
|
|
||||||
innrykk = ' ' * 4 * nivaa
|
|
||||||
mappe_navn = os.path.basename(root)
|
|
||||||
|
|
||||||
# Legg til mappen i treet
|
|
||||||
if mappe_navn:
|
|
||||||
tre_linjer.append(f"{innrykk}📁 {mappe_navn}/")
|
|
||||||
else:
|
|
||||||
tre_linjer.append(f"📁 {kilde_sti.name}/")
|
|
||||||
|
|
||||||
sub_innrykk = ' ' * 4 * (nivaa + 1)
|
|
||||||
|
|
||||||
# 3. Gå gjennom filene i mappen
|
|
||||||
for fil in files:
|
|
||||||
tre_linjer.append(f"{sub_innrykk}📄 {fil}")
|
|
||||||
|
|
||||||
fil_sti = Path(root) / fil
|
|
||||||
|
|
||||||
# 4. Sjekk om filen har riktig endelse og skal kopieres
|
|
||||||
if fil_sti.suffix in FILTYPER:
|
|
||||||
# Lag et unikt filnavn for å unngå overskriving
|
|
||||||
relativ_sti = fil_sti.relative_to(kilde_sti)
|
|
||||||
nytt_navn = str(relativ_sti).replace(os.sep, '_').replace('.', '_') + '.txt'
|
|
||||||
ny_sti = eksport_sti / nytt_navn
|
|
||||||
|
|
||||||
# Kopier filen
|
|
||||||
shutil.copy2(fil_sti, ny_sti)
|
|
||||||
kopierte_filer += 1
|
|
||||||
|
|
||||||
# 5. Lagre filteret til tekstfilen
|
|
||||||
with open(TRE_FIL, 'w', encoding='utf-8') as f:
|
|
||||||
f.write('\n'.join(tre_linjer))
|
|
||||||
|
|
||||||
print(f"\n✅ Ferdig!")
|
|
||||||
print(f"📁 Filtre er lagret i: {TRE_FIL}")
|
|
||||||
print(f"📝 Kopierte {kopierte_filer} kodefiler til: {EKSPORT_MAPPE}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
generer_tre_og_kopier()
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
import "./.next/dev/types/routes.d.ts";
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import type { NextConfig } from "next";
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
/* config options here */
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
|
|
@ -1,217 +0,0 @@
|
||||||
"use client";
|
|
||||||
/**
|
|
||||||
* TEE OFF SYSTEM INSTRUCTIONS - FACILITY CARDS v3.8 (BLOB SEARCH)
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
* REGEL 1: Status-badge SKAL vises øverst til venstre FOR ALLE BANER.
|
|
||||||
* Bruk STATUS_MAP for tekst.
|
|
||||||
* REGEL 2: DATA-PARSING: Bruk parseJson() for 'course_statuses', 'amenities' og 'nsg_data'.
|
|
||||||
* REGEL 3: Avstand-pillen skal ha fargen #2d3319 (Mørk oliven) med hvit tekst.
|
|
||||||
* REGEL 4: NSG (Blå 'N') og Golfamore (Oransje 'G') sirkler skal ha hvit kant (border-2).
|
|
||||||
* REGEL 5: Bunnen: Antall Hull (grønn pill), Banetype (grå pill), og Ikon-sirkler.
|
|
||||||
* REGEL 6: Viser dato (f.eks "05. mars 2026") rett til høyre for øverste status-pille.
|
|
||||||
* REGEL 7: Natural Language Search bruker en "Search Blob" for å støtte delvise
|
|
||||||
* ord og skrivefeil slik at listen ikke tømmes mens brukeren skriver.
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { STATUS_MAP, REGIONS } from "@/config/constants";
|
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
function getDistance(lat1: number, lon1: number, lat2: number, lon2: number) {
|
|
||||||
try {
|
|
||||||
const R = 6371;
|
|
||||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
||||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
|
||||||
const a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon/2) * Math.sin(dLon/2);
|
|
||||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
||||||
} catch (e) { return Infinity; }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FacilitySearch({ initialFacilities }: { initialFacilities: any[] }) {
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [userLocation, setUserLocation] = useState<{ lat: number, lng: number } | null>(null);
|
|
||||||
const [sortMethod, setSortMethod] = useState<'dist' | 'alpha'>('alpha');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if ("geolocation" in navigator) {
|
|
||||||
navigator.geolocation.getCurrentPosition(p => {
|
|
||||||
setUserLocation({ lat: p.coords.latitude, lng: p.coords.longitude });
|
|
||||||
setSortMethod('dist');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const processed = useMemo(() => {
|
|
||||||
if (!Array.isArray(initialFacilities)) return [];
|
|
||||||
|
|
||||||
// Fyllord som fjernes slik at "Åpne baner i Oslo" blir til søkeordene ["åpne", "oslo"]
|
|
||||||
const stopWords = new Set(["i", "på", "for", "med", "av", "og"]);
|
|
||||||
|
|
||||||
return initialFacilities.map(f => {
|
|
||||||
// --- ROBUST DATA-PARSING ---
|
|
||||||
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; }
|
|
||||||
};
|
|
||||||
|
|
||||||
const rawStatuses = parseJson(f.course_statuses, []);
|
|
||||||
const sArr = Array.isArray(rawStatuses) && rawStatuses.length > 0
|
|
||||||
? rawStatuses
|
|
||||||
: [{ status: 'ukjent', name: 'Hovedbane' }];
|
|
||||||
|
|
||||||
const amenities = parseJson(f.amenities, {});
|
|
||||||
const nsgData = parseJson(f.nsg_data, {});
|
|
||||||
|
|
||||||
const dist = userLocation && f.lat && f.lng ? getDistance(userLocation.lat, userLocation.lng, f.lat, f.lng) : Infinity;
|
|
||||||
const hasNSG = nsgData && Object.keys(nsgData).length > 0;
|
|
||||||
const hasGolfamore = f.golfamore === true;
|
|
||||||
|
|
||||||
// --- THE SEARCH BLOB ---
|
|
||||||
// Vi starter med å legge navn, by og fylke i en stor, usynlig tekststreng
|
|
||||||
let searchableText = `${f.name} ${f.city} ${f.county}`.toLowerCase();
|
|
||||||
|
|
||||||
// 1. Injiser statuser i tekststrengen
|
|
||||||
const hasOpen = sArr.some((c: any) => (c.status || "") === 'aapen');
|
|
||||||
const hasClosed = sArr.some((c: any) => (c.status || "") === 'stengt');
|
|
||||||
const hasWinter = sArr.some((c: any) => (c.status || "") === 'aapen_med_vintergreener');
|
|
||||||
const hasNedlagt = sArr.some((c: any) => (c.status || "") === 'nedlagt');
|
|
||||||
|
|
||||||
if (hasOpen) searchableText += " åpen åpne aapen";
|
|
||||||
if (hasClosed) searchableText += " stengt stengte";
|
|
||||||
if (hasWinter) searchableText += " vinter vintergreener vinterbane";
|
|
||||||
if (hasNedlagt) searchableText += " nedlagt nedlagte";
|
|
||||||
|
|
||||||
// 2. Injiser spesial-tags
|
|
||||||
if (hasNSG) searchableText += " nsg norsk seniorgolf";
|
|
||||||
if (hasGolfamore) searchableText += " golfamore amore";
|
|
||||||
|
|
||||||
// 3. Injiser landsdel (f.eks. hvis fylket er Akershus, legger vi til "østlandet")
|
|
||||||
const fylke = (f.county || "").toLowerCase();
|
|
||||||
Object.entries(REGIONS).forEach(([regionName, counties]) => {
|
|
||||||
if (counties.includes(fylke)) {
|
|
||||||
searchableText += ` ${regionName}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Splitter brukerens søk inn i enkeltord og fjerner stopWords + ordene "bane"/"baner"
|
|
||||||
const words = searchQuery
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter(w => w.length > 0 && !stopWords.has(w) && w !== "bane" && w !== "baner");
|
|
||||||
|
|
||||||
// Sjekker at ALLE ordene brukeren har skrevet, finnes et sted i "Search Blob"-en
|
|
||||||
const matches = words.every(w => searchableText.includes(w));
|
|
||||||
|
|
||||||
return { ...f, statuses: sArr, amenities, dist, hasNSG, hasGolfamore, matches };
|
|
||||||
})
|
|
||||||
.filter(f => f.matches)
|
|
||||||
.sort((a, b) => {
|
|
||||||
if (sortMethod === 'dist' && a.dist !== b.dist) return a.dist - b.dist;
|
|
||||||
return a.name.localeCompare(b.name, 'nb');
|
|
||||||
});
|
|
||||||
}, [searchQuery, initialFacilities, userLocation, sortMethod]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-[1400px] mx-auto px-6 py-12 relative z-40">
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<button onClick={() => setSortMethod(sortMethod === 'dist' ? 'alpha' : 'dist')} className="bg-white px-6 py-3 rounded-full shadow-md text-[10px] font-black text-[#8bc34a] uppercase tracking-widest border border-gray-100 transition-colors">
|
|
||||||
{sortMethod === 'dist' ? "📍 Nærmeste baner først" : "🔠 Alfabetisk visning"} • {processed.length} baner
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input className="w-full p-8 rounded-[2.5rem] shadow-2xl mb-16 text-gray-900 border-none ring-1 ring-black/5 text-2xl outline-none focus:ring-4 focus:ring-[#8bc34a]/20 transition-all bg-white" placeholder='Søk baner, fylke, status eller spesial (f.eks "Åpne baner i Akershus" eller "NSG")...' value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">
|
|
||||||
{processed.map((f: any) => {
|
|
||||||
const sArr = f.statuses; // Sikret via pre-prosesseringen over
|
|
||||||
|
|
||||||
// Formater datoen pent: "05. mars 2026"
|
|
||||||
const lastUpdated = f.status_updated_at
|
|
||||||
? new Date(f.status_updated_at).toLocaleDateString('nb-NO', { day: '2-digit', month: 'long', year: 'numeric' })
|
|
||||||
: 'Ukjent';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link href={`/golfbaner/${f.slug}`} key={f.id} className="bg-white rounded-[2.5rem] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-gray-100 flex flex-col group relative">
|
|
||||||
<div className="h-64 relative overflow-hidden bg-gray-100">
|
|
||||||
<img src={f.image_url || "/Toppbilde-standard.jpg"} className="w-full h-full object-cover transition duration-1000 group-hover:scale-105" alt={f.name} />
|
|
||||||
|
|
||||||
{/* Status Badges for ALLE baner på anlegget */}
|
|
||||||
<div className="absolute top-5 left-5 flex flex-col gap-2 z-20">
|
|
||||||
{sArr.map((course: any, idx: number) => {
|
|
||||||
const rawStatus = (course.status || "ukjent").toLowerCase();
|
|
||||||
|
|
||||||
let statusColor = "bg-gray-400";
|
|
||||||
if (rawStatus === 'aapen') statusColor = "bg-[#8bc34a]";
|
|
||||||
else if (rawStatus.includes('vinter') || rawStatus === 'stenger_snart') statusColor = "bg-[#ff5722]";
|
|
||||||
else if (rawStatus === 'aapner_snart') statusColor = "bg-amber-500";
|
|
||||||
else if (rawStatus === 'stengt') statusColor = "bg-red-600";
|
|
||||||
else if (rawStatus === 'nedlagt') statusColor = "bg-black";
|
|
||||||
else if (rawStatus === 'under_utvikling') statusColor = "bg-blue-500";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={idx} className="flex items-center gap-3">
|
|
||||||
<div className={`${statusColor} text-white px-3 py-1.5 rounded-xl text-[9px] font-black uppercase shadow-lg backdrop-blur-sm bg-opacity-90 flex items-center gap-2 max-w-[200px]`}>
|
|
||||||
{sArr.length > 1 && (
|
|
||||||
<span className="opacity-80 border-r border-white/30 pr-2 truncate max-w-[90px]" title={course.name}>
|
|
||||||
{course.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>{STATUS_MAP[rawStatus] || rawStatus}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dato-pille ved siden av den øverste status-pillen */}
|
|
||||||
{idx === 0 && (
|
|
||||||
<div className="bg-white/30 backdrop-blur-sm text-[#11280f]/90 px-3 py-1.5 rounded-xl text-[11px] font-bold shadow-lg">
|
|
||||||
{lastUpdated}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Avstandspille */}
|
|
||||||
{f.dist !== Infinity && (
|
|
||||||
<div className="absolute bottom-5 right-5 bg-[#2d3319] text-white px-4 py-2 rounded-2xl text-[10px] font-black shadow-lg z-20">
|
|
||||||
{Math.round(f.dist)} km unna
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-8 flex flex-col flex-grow">
|
|
||||||
<h3 className="font-black text-3xl text-[#11280f] mb-1 group-hover:text-[#8bc34a] transition-colors leading-tight">{f.name}</h3>
|
|
||||||
<p className="text-gray-400 text-[11px] font-bold uppercase tracking-widest mb-8">{f.city} • {f.county}</p>
|
|
||||||
|
|
||||||
<div className="mt-auto flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* Hull-pille */}
|
|
||||||
<span className="bg-[#f1f7ed] text-[#8bc34a] px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest">
|
|
||||||
{f.amenities?.antall_hull || '--'} HULL
|
|
||||||
</span>
|
|
||||||
{/* Banetype-pille */}
|
|
||||||
<span className="bg-gray-50 text-gray-400 px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border border-gray-100">
|
|
||||||
{f.banetype || 'SKOGSBANE'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sirkel-ikoner (NSG / Golfamore) */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{f.hasNSG && (
|
|
||||||
<div className="w-9 h-9 bg-blue-600 text-white rounded-full flex items-center justify-center font-black text-sm shadow-lg border-2 border-white translate-y-1">N</div>
|
|
||||||
)}
|
|
||||||
{f.hasGolfamore && (
|
|
||||||
<div className="w-9 h-9 bg-[#ff5722] text-white rounded-full flex items-center justify-center font-black text-sm shadow-lg border-2 border-white translate-y-1">G</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
"use client";
|
|
||||||
/**
|
|
||||||
* TEE OFF SYSTEM INSTRUCTIONS - HERO SLIDER v2.4
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
* REGEL 1: Kun baner med status 'aapen', 'aapner_snart', 'stenger_snart'
|
|
||||||
* eller 'aapen_med_vintergreener' skal prioriteres.
|
|
||||||
* REGEL 2: Baner med status 'nedlagt' eller 'under_utvikling' skal ALDRI vises.
|
|
||||||
* REGEL 3: Baner med generiske bilder (inneholder 'standard') skal ALDRI vises.
|
|
||||||
* REGEL 4: MANUELL EKSKLUDERING: Slugs i MANUAL_EXCLUSION_LIST skal aldri vises.
|
|
||||||
* REGEL 5: Slideren skal vise nøyaktig 5 baner.
|
|
||||||
* REGEL 6: Maks høyde er låst til 624px. Ingen badges.
|
|
||||||
* REGEL 7: Typografi: Nedjustert fontstørrelse (4xl mobil / 7xl desktop) for eleganse.
|
|
||||||
* REGEL 8: Utvalget skal være stabilt i én time (Hourly Seed) før det refreshes.
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
const MANUAL_EXCLUSION_LIST = [
|
|
||||||
'alsten-golfklubb', 'askim-golfklubb', 'bergen-golfklubb', 'eidskog-golfklubb',
|
|
||||||
'eiker-golfklubb', 'floro-golfklubb', 'garder-golfklubb', 'hafjell-golfklubb',
|
|
||||||
'halden-golfklubb', 'haugesund-golfklubb', 'hinnoy-golfklubb', 'hitra-golfklubb',
|
|
||||||
'hurum-golfklubb', 'imjelt-pitch-putt', 'karmoy-golfklubb', 'kristiansund-og-omegn-golfklubb',
|
|
||||||
'lommedalen-golfklubb', 'laerdal-golfklubb', 'moa-golfsenter', 'modum-golfklubb',
|
|
||||||
'nes-golfklubb-09', 'nittedal-golfklubb', 'selbu-golfklubb', 'stryn-golfklubb',
|
|
||||||
'sunnfjord-golfklubb', 'tysnes-golfklubb', 'vanylven-golfklubb', 'vesteralen-golfklubb',
|
|
||||||
'vestlia-golf'
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function HeroSlider({ facilities }: { facilities: any[] }) {
|
|
||||||
const [currentIndex, setCurrentSlide] = useState(0);
|
|
||||||
|
|
||||||
const sliderItems = useMemo(() => {
|
|
||||||
if (!Array.isArray(facilities) || facilities.length === 0) return [];
|
|
||||||
|
|
||||||
const preferredStatuses = ['aapen', 'aapner_snart', 'stenger_snart', 'aapen_med_vintergreener'];
|
|
||||||
const forbiddenStatuses = ['nedlagt', 'under_utvikling'];
|
|
||||||
|
|
||||||
const validCandidates = facilities.filter(f => {
|
|
||||||
if (MANUAL_EXCLUSION_LIST.includes(f.slug)) return false;
|
|
||||||
const img = f.image_url || "";
|
|
||||||
if (!img || img.toLowerCase().includes('standard') || img.length < 5) return false;
|
|
||||||
|
|
||||||
const statuses = Array.isArray(f.course_statuses) ? f.course_statuses : [];
|
|
||||||
const isForbidden = statuses.some((s: any) =>
|
|
||||||
forbiddenStatuses.includes((s.status || "").toLowerCase())
|
|
||||||
);
|
|
||||||
return !isForbidden;
|
|
||||||
});
|
|
||||||
|
|
||||||
const highPriority = validCandidates.filter(f => {
|
|
||||||
const statuses = Array.isArray(f.course_statuses) ? f.course_statuses : [];
|
|
||||||
return statuses.some((s: any) => preferredStatuses.includes((s.status || "").toLowerCase()));
|
|
||||||
});
|
|
||||||
|
|
||||||
const fallbackPool = validCandidates.filter(f => !highPriority.includes(f));
|
|
||||||
const now = new Date();
|
|
||||||
const hourlySeed = parseInt(`${now.getFullYear()}${now.getMonth()}${now.getDate()}${now.getHours()}`);
|
|
||||||
|
|
||||||
const seededShuffle = (arr: any[]) => {
|
|
||||||
return [...arr].sort((a, b) => ((a.id * hourlySeed) % 100) - ((b.id * hourlySeed) % 100));
|
|
||||||
};
|
|
||||||
|
|
||||||
let selection = seededShuffle(highPriority);
|
|
||||||
if (selection.length < 5) {
|
|
||||||
selection = [...selection, ...seededShuffle(fallbackPool)].slice(0, 5);
|
|
||||||
} else {
|
|
||||||
selection = selection.slice(0, 5);
|
|
||||||
}
|
|
||||||
return selection;
|
|
||||||
}, [facilities]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (sliderItems.length <= 1) return;
|
|
||||||
const interval = setInterval(() => setCurrentSlide((p) => (p + 1) % sliderItems.length), 8000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [sliderItems.length]);
|
|
||||||
|
|
||||||
if (sliderItems.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="relative h-[65vh] max-h-[624px] w-full overflow-hidden bg-[#11280f]">
|
|
||||||
{sliderItems.map((f, i) => (
|
|
||||||
<div
|
|
||||||
key={f.id}
|
|
||||||
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
|
|
||||||
i === currentIndex ? 'opacity-100 z-10' : 'opacity-0 z-0'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Link href={`/golfbaner/${f.slug}`} className="block h-full relative group">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-[#11280f] via-[#11280f]/40 to-black/10 z-10" />
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={f.image_url}
|
|
||||||
alt={f.name}
|
|
||||||
className="w-full h-full object-cover transition-transform duration-[10s] scale-100 group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="absolute inset-0 z-20 flex items-center">
|
|
||||||
<div className="max-w-[1400px] mx-auto px-6 w-full">
|
|
||||||
<div className="max-w-4xl animate-in fade-in slide-in-from-bottom-8 duration-1000">
|
|
||||||
{/* FONT NEDJUSTERT FRA text-6xl md:text-9xl TIL text-4xl md:text-7xl */}
|
|
||||||
<h2 className="text-4xl md:text-7xl font-black text-white tracking-tighter drop-shadow-2xl leading-[0.9] mb-4">
|
|
||||||
{f.name}
|
|
||||||
</h2>
|
|
||||||
<p className="text-white/90 text-sm md:text-xl font-bold uppercase tracking-[0.4em] drop-shadow-md">
|
|
||||||
{f.county} <span className="text-[#8bc34a] mx-2">•</span> {f.city}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 z-30 flex gap-4">
|
|
||||||
{sliderItems.map((_, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
onClick={() => setCurrentSlide(i)}
|
|
||||||
className={`h-1 transition-all duration-500 rounded-full ${
|
|
||||||
i === currentIndex ? 'w-16 bg-[#8bc34a]' : 'w-4 bg-white/20'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,203 +0,0 @@
|
||||||
"use client";
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { API_URL } from "@/config/constants";
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
export default function GreenfeeWasher() {
|
|
||||||
const [drafts, setDrafts] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const fetchDrafts = () => {
|
|
||||||
setLoading(true);
|
|
||||||
fetch(`${API_URL}/admin/greenfee/drafts`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
const editableDrafts = data.map((f: any) => {
|
|
||||||
// JSONB fra Postgres kan noen ganger komme som en streng,
|
|
||||||
// vi må sikre at vi parser det hvis det trengs
|
|
||||||
let parsedDraft = f.greenfee_draft;
|
|
||||||
if (typeof parsedDraft === 'string') {
|
|
||||||
try { parsedDraft = JSON.parse(parsedDraft); }
|
|
||||||
catch (e) { console.error("Kunne ikke parse JSON", e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hent ut selve listen (fallback til tom liste hvis noe er feil)
|
|
||||||
const greenfeeList = parsedDraft?.foreslatt_greenfee || [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
...f,
|
|
||||||
greenfee_draft: parsedDraft, // Lagre den parsede versjonen for visning
|
|
||||||
edit_greenfee: greenfeeList // Dette er arrayet som binder seg til input-feltene
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setDrafts(editableDrafts);
|
|
||||||
setLoading(false);
|
|
||||||
})
|
|
||||||
.catch(() => setLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => { fetchDrafts(); }, []);
|
|
||||||
|
|
||||||
const toggleSelectAll = (checked: boolean) => {
|
|
||||||
if (checked) setSelectedIds(drafts.map(d => d.id));
|
|
||||||
else setSelectedIds([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleOne = (id: number) => {
|
|
||||||
if (selectedIds.includes(id)) setSelectedIds(selectedIds.filter(i => i !== id));
|
|
||||||
else setSelectedIds([...selectedIds, id]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeRow = (facilityId: number, rowIndex: number) => {
|
|
||||||
setDrafts(drafts.map(d => {
|
|
||||||
if (d.id === facilityId) {
|
|
||||||
const newRows = [...d.edit_greenfee];
|
|
||||||
newRows.splice(rowIndex, 1);
|
|
||||||
return { ...d, edit_greenfee: newRows };
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateField = (facilityId: number, rowIndex: number, field: string, value: string | number) => {
|
|
||||||
setDrafts(drafts.map(d => {
|
|
||||||
if (d.id === facilityId) {
|
|
||||||
const newRows = [...d.edit_greenfee];
|
|
||||||
newRows[rowIndex] = { ...newRows[rowIndex], [field]: value };
|
|
||||||
return { ...d, edit_greenfee: newRows };
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApprove = async () => {
|
|
||||||
const toApprove = drafts.filter(d => selectedIds.includes(d.id)).map(d => ({
|
|
||||||
facility_id: d.id,
|
|
||||||
greenfee: d.edit_greenfee.map((row: any) => ({
|
|
||||||
banenavn: row.banenavn || "",
|
|
||||||
priskategori: row.priskategori || "",
|
|
||||||
pris_voksne: Number(row.pris_voksne) || null,
|
|
||||||
pris_junior: Number(row.pris_junior) || null
|
|
||||||
}))
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (toApprove.length === 0) return alert("Velg minst ett anlegg å godkjenne.");
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/admin/greenfee/approve-bulk`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ approvals: toApprove })
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
alert(`${toApprove.length} anlegg oppdatert!`);
|
|
||||||
setSelectedIds([]);
|
|
||||||
fetchDrafts();
|
|
||||||
} else {
|
|
||||||
alert("Noe gikk galt under lagring.");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert("Nettverksfeil");
|
|
||||||
}
|
|
||||||
setSaving(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <div className="p-20 text-center font-black animate-pulse">Laster utkast...</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#f1f7ed] p-8 text-[#11280f]">
|
|
||||||
<div className="max-w-[1400px] mx-auto">
|
|
||||||
<div className="flex justify-between items-end mb-10 border-b border-gray-200 pb-6">
|
|
||||||
<div>
|
|
||||||
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block">← Tilbake til oversikten</Link>
|
|
||||||
<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="bg-[#8bc34a] text-white px-8 py-4 rounded-xl font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50">
|
|
||||||
{saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{drafts.length === 0 ? (
|
|
||||||
<div className="bg-white p-20 rounded-[2rem] text-center shadow-sm">
|
|
||||||
<span className="text-6xl mb-4 block">🧹</span>
|
|
||||||
<h2 className="text-2xl font-black text-gray-400">Alt er rent og pent!</h2>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-white p-4 rounded-2xl shadow-sm flex items-center gap-4">
|
|
||||||
<input type="checkbox" className="w-5 h-5 accent-[#8bc34a] ml-2" checked={selectedIds.length === drafts.length && drafts.length > 0} onChange={(e) => toggleSelectAll(e.target.checked)} />
|
|
||||||
<span className="font-black uppercase tracking-widest text-xs text-gray-500">Velg Alle</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{drafts.map(draft => (
|
|
||||||
<div key={draft.id} className={`bg-white p-6 rounded-3xl shadow-sm border-2 transition-all ${selectedIds.includes(draft.id) ? 'border-[#8bc34a] bg-[#8bc34a]/5' : 'border-transparent'}`}>
|
|
||||||
<div className="flex gap-6 items-start">
|
|
||||||
<div className="pt-2"><input type="checkbox" className="w-6 h-6 accent-[#8bc34a] cursor-pointer" checked={selectedIds.includes(draft.id)} onChange={() => toggleOne(draft.id)} /></div>
|
|
||||||
<div className="flex-grow space-y-4">
|
|
||||||
<div className="flex justify-between items-center border-b pb-4">
|
|
||||||
<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="text-xs font-bold text-blue-600 hover:underline bg-blue-50 px-4 py-2 rounded-lg">Sjekk Nettside ↗</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{draft.greenfee_draft?.ai_begrunnelse && (
|
|
||||||
<div className="bg-blue-50/50 p-4 rounded-xl text-sm italic text-blue-900 border border-blue-100">
|
|
||||||
<strong>🤖 AI Begrunnelse:</strong> {draft.greenfee_draft.ai_begrunnelse}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{draft.greenfee_draft?.foreslatt_avtaleklubber?.length > 0 && (
|
|
||||||
<div className="bg-green-50/50 p-4 rounded-xl text-sm text-green-900 border border-green-100">
|
|
||||||
<strong>🤝 AI fant disse avtaleklubbene i teksten:</strong> {draft.greenfee_draft.foreslatt_avtaleklubber.join(', ')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-black uppercase tracking-widest text-gray-400 mb-2">Slik ser det ut i databasen nå:</h4>
|
|
||||||
<div className="bg-gray-50 p-4 rounded-xl text-xs space-y-2 opacity-75">
|
|
||||||
{draft.greenfee && draft.greenfee.length > 0 ? draft.greenfee.map((g: any, i: number) => (
|
|
||||||
<div key={i} className="flex justify-between border-b pb-1">
|
|
||||||
<span>{g.banenavn} - {g.priskategori}</span>
|
|
||||||
<span className="font-bold">V: {g.pris_voksne || '-'} | J: {g.pris_junior || '-'}</span>
|
|
||||||
</div>
|
|
||||||
)) : "Ingen priser registrert."}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-black uppercase tracking-widest text-green-600 mb-2">Nytt forslag å godkjenne:</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{draft.edit_greenfee && draft.edit_greenfee.map((row: any, idx: number) => (
|
|
||||||
<div key={idx} className="flex gap-2 items-center bg-white border border-gray-200 p-2 rounded-lg relative group">
|
|
||||||
<input className="w-1/3 p-2 rounded border border-gray-100 text-xs font-bold focus:border-[#8bc34a] outline-none" value={row.banenavn || ''} onChange={e => updateField(draft.id, idx, 'banenavn', e.target.value)} placeholder="Bane" />
|
|
||||||
<input className="w-1/3 p-2 rounded border border-gray-100 text-xs focus:border-[#8bc34a] outline-none" value={row.priskategori || ''} onChange={e => updateField(draft.id, idx, 'priskategori', e.target.value)} placeholder="Kategori" />
|
|
||||||
<input className="w-16 p-2 rounded border border-gray-100 text-xs text-center focus:border-[#8bc34a] outline-none" type="number" value={row.pris_voksne || ''} onChange={e => updateField(draft.id, idx, 'pris_voksne', e.target.value)} placeholder="Voksen" />
|
|
||||||
<input className="w-16 p-2 rounded border border-gray-100 text-xs text-center focus:border-[#8bc34a] outline-none" 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="text-red-400 hover:text-red-600 px-2 opacity-0 group-hover:opacity-100 transition-opacity" title="Slett rad">✕</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button onClick={() => {
|
|
||||||
const newDrafts = [...drafts];
|
|
||||||
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">
|
|
||||||
+ Legg til manuell rad
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
"use client";
|
|
||||||
/**
|
|
||||||
* TEE OFF ADMIN LOGIN v1.2
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
* PLASSERING: frontend/src/app/admin/login/page.tsx
|
|
||||||
* FUNKSJON: Offentlig tilgjengelig innlogging for administratorer.
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { API_URL } from "@/config/constants";
|
|
||||||
|
|
||||||
export default function AdminLogin() {
|
|
||||||
const [step, setStep] = useState(1);
|
|
||||||
const [formData, setFormData] = useState({ username: '', password: '', code: '' });
|
|
||||||
const [tempToken, setTempToken] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsLoading(true);
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ username: formData.username, password: formData.password })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok) {
|
|
||||||
setTempToken(data.temp_token);
|
|
||||||
setStep(2);
|
|
||||||
} else {
|
|
||||||
setError(data.detail || 'Ugyldig pålogging');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("🔥 DEN EKTE FEILEN ER:", err);
|
|
||||||
setError('Systemfeil: Kunne ikke koble til API-et');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVerify2FA = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/auth/verify-2fa`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ temp_token: tempToken, code: formData.code })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
// VIKTIG: Etter suksess sender vi brukeren til selve dashbordet
|
|
||||||
router.push('/admin');
|
|
||||||
router.refresh();
|
|
||||||
} else {
|
|
||||||
setError('Ugyldig 2FA-kode');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError('Tilkoblingsfeil ved 2FA-verifisering');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-[#f1f7ed] p-6 font-sans">
|
|
||||||
<div className="max-w-md w-full bg-white rounded-[3rem] shadow-2xl p-12 border border-white">
|
|
||||||
<div className="flex justify-center mb-10">
|
|
||||||
<img src="/TeeOff-logo-Retina-1.png" className="h-10 w-auto" alt="TeeOff" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-black text-center uppercase tracking-tighter mb-8 text-[#11280f]">
|
|
||||||
{step === 1 ? "Admin Portalen" : "Tofaktor Sjekk"}
|
|
||||||
</h2>
|
|
||||||
<form onSubmit={step === 1 ? handleLogin : handleVerify2FA} className="space-y-4">
|
|
||||||
{step === 1 ? (
|
|
||||||
<>
|
|
||||||
<input type="text" placeholder="Brukernavn eller E-post" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData(prevState => ({...prevState, username: e.target.value}))} required />
|
|
||||||
<input type="password" placeholder="Passord" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData(prevState => ({...prevState, password: e.target.value}))} required />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-[10px] text-gray-400 font-black uppercase text-center tracking-widest">Tast inn 6 siffer fra appen din</p>
|
|
||||||
<input type="text" placeholder="000 000" className="w-full p-6 text-center text-4xl tracking-[0.3em] font-black bg-gray-50 rounded-3xl border-none ring-2 ring-[#ff5722]/20 outline-none focus:ring-[#ff5722] transition-all text-[#ff5722]" onChange={e => setFormData({...formData, code: e.target.value})} autoFocus required />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && <div className="bg-red-50 p-4 rounded-xl text-red-600 text-[10px] font-black uppercase tracking-widest text-center border border-red-100">⚠️ {error}</div>}
|
|
||||||
<button type="submit" disabled={isLoading} className={`w-full p-6 rounded-2xl font-black uppercase text-xs tracking-widest text-white transition-all shadow-xl ${step === 1 ? 'bg-[#11280f]' : 'bg-[#ff5722]'}`}>
|
|
||||||
{isLoading ? "Venter..." : (step === 1 ? "Fortsett" : "Logg inn")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
"use client";
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { API_URL } from "@/config/constants";
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
export default function MembershipWasher() {
|
|
||||||
const [drafts, setDrafts] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const fetchDrafts = () => {
|
|
||||||
setLoading(true);
|
|
||||||
fetch(`${API_URL}/admin/membership/drafts`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
// Konverter innkommende drafts til editerbare felter lokalt
|
|
||||||
const editableDrafts = data.map((f: any) => ({
|
|
||||||
...f,
|
|
||||||
edit_standard_navn: f.membership_draft?.foreslatt_standard_navn || f.navn_standard_medlemskap || "",
|
|
||||||
edit_standard_pris: f.membership_draft?.foreslatt_standard_pris || f.standard_medlemskap || "",
|
|
||||||
edit_standard_kommentar: f.membership_draft?.foreslatt_standard_kommentar || "",
|
|
||||||
edit_rimeligste_navn: f.membership_draft?.foreslatt_rimeligste_navn || f.navn_rimeligste_alternativ || "",
|
|
||||||
edit_rimeligste_pris: f.membership_draft?.foreslatt_rimeligste_pris || f.rimeligste_alternativ || "",
|
|
||||||
}));
|
|
||||||
setDrafts(editableDrafts);
|
|
||||||
setLoading(false);
|
|
||||||
})
|
|
||||||
.catch(() => setLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchDrafts();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleSelectAll = (checked: boolean) => {
|
|
||||||
if (checked) setSelectedIds(drafts.map(d => d.id));
|
|
||||||
else setSelectedIds([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleOne = (id: number) => {
|
|
||||||
if (selectedIds.includes(id)) setSelectedIds(selectedIds.filter(i => i !== id));
|
|
||||||
else setSelectedIds([...selectedIds, id]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateDraftField = (id: number, field: string, value: any) => {
|
|
||||||
setDrafts(drafts.map(d => d.id === id ? { ...d, [field]: value } : d));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApprove = async () => {
|
|
||||||
const toApprove = drafts.filter(d => selectedIds.includes(d.id)).map(d => ({
|
|
||||||
facility_id: d.id,
|
|
||||||
navn_standard_medlemskap: d.edit_standard_navn,
|
|
||||||
standard_medlemskap: Number(d.edit_standard_pris) || null,
|
|
||||||
standard_medlemskap_kommentarer: d.edit_standard_kommentar,
|
|
||||||
navn_rimeligste_alternativ: d.edit_rimeligste_navn,
|
|
||||||
rimeligste_alternativ: Number(d.edit_rimeligste_pris) || null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (toApprove.length === 0) return alert("Velg minst ett anlegg å godkjenne.");
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/admin/membership/approve-bulk`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ approvals: toApprove })
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
alert(`${toApprove.length} anlegg ble oppdatert og lagret til live!`);
|
|
||||||
setSelectedIds([]);
|
|
||||||
fetchDrafts(); // Oppdaterer listen (fjerner de godkjente)
|
|
||||||
} else {
|
|
||||||
alert("Noe gikk galt under lagring.");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert("Nettverksfeil");
|
|
||||||
}
|
|
||||||
setSaving(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <div className="p-20 text-center font-black animate-pulse">Laster utkast...</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#f1f7ed] p-8 text-[#11280f]">
|
|
||||||
<div className="max-w-[1400px] mx-auto">
|
|
||||||
<div className="flex justify-between items-end mb-10 border-b border-gray-200 pb-6">
|
|
||||||
<div>
|
|
||||||
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block">← Tilbake til oversikten</Link>
|
|
||||||
<h1 className="text-4xl font-black">Medlemskaps-Vaskeriet</h1>
|
|
||||||
<p className="text-sm text-gray-600 mt-2">Gå gjennom AI-ens forslag, juster hvis nødvendig, og godkjenn for å publisere. Oppdatert-dato settes automatisk i dag.</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleApprove}
|
|
||||||
disabled={saving || selectedIds.length === 0}
|
|
||||||
className="bg-[#8bc34a] text-white px-8 py-4 rounded-xl font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50 disabled:scale-100"
|
|
||||||
>
|
|
||||||
{saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{drafts.length === 0 ? (
|
|
||||||
<div className="bg-white p-20 rounded-[2rem] text-center shadow-sm">
|
|
||||||
<span className="text-6xl mb-4 block">🧹</span>
|
|
||||||
<h2 className="text-2xl font-black text-gray-400">Alt er rent og pent!</h2>
|
|
||||||
<p className="text-gray-500">Ingen ventende forslag fra AI-skraperen akkurat nå.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-white p-4 rounded-2xl shadow-sm flex items-center gap-4">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="w-5 h-5 accent-[#8bc34a] ml-2"
|
|
||||||
checked={selectedIds.length === drafts.length}
|
|
||||||
onChange={(e) => toggleSelectAll(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span className="font-black uppercase tracking-widest text-xs text-gray-500">Velg Alle</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{drafts.map(draft => (
|
|
||||||
<div key={draft.id} className={`bg-white p-6 rounded-3xl shadow-sm border-2 transition-all ${selectedIds.includes(draft.id) ? 'border-[#8bc34a] bg-[#8bc34a]/5' : 'border-transparent'}`}>
|
|
||||||
<div className="flex gap-6 items-start">
|
|
||||||
<div className="pt-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="w-6 h-6 accent-[#8bc34a] cursor-pointer"
|
|
||||||
checked={selectedIds.includes(draft.id)}
|
|
||||||
onChange={() => toggleOne(draft.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-grow space-y-4">
|
|
||||||
|
|
||||||
{/* OPPDATERT: Navn + ID Badge */}
|
|
||||||
<div className="flex justify-between items-center border-b pb-4">
|
|
||||||
<h3 className="text-2xl font-black flex items-center gap-3">
|
|
||||||
{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.medlemskap_url} target="_blank" className="text-xs font-bold text-blue-600 hover:underline bg-blue-50 px-4 py-2 rounded-lg">Sjekk Klubbens Nettside ↗</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{draft.membership_draft?.ai_begrunnelse && (
|
|
||||||
<div className="bg-blue-50/50 p-4 rounded-xl text-sm italic text-blue-900 border border-blue-100">
|
|
||||||
<strong>🤖 AI Begrunnelse:</strong> {draft.membership_draft.ai_begrunnelse}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-2">
|
|
||||||
{/* Standard */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="text-xs font-black uppercase tracking-widest text-gray-400">Standard Medlemskap (Ubegrenset)</h4>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input className="w-2/3 p-3 rounded-xl border border-gray-200 font-bold focus:border-[#8bc34a] outline-none" value={draft.edit_standard_navn} onChange={e => updateDraftField(draft.id, 'edit_standard_navn', e.target.value)} placeholder="Navn (eks. Hovedmedlem)" />
|
|
||||||
<input className="w-1/3 p-3 rounded-xl border border-gray-200 font-bold text-right focus:border-[#8bc34a] outline-none" type="number" value={draft.edit_standard_pris} onChange={e => updateDraftField(draft.id, 'edit_standard_pris', e.target.value)} placeholder="Pris" />
|
|
||||||
</div>
|
|
||||||
<input className="w-full p-3 rounded-xl border border-gray-200 text-sm focus:border-[#8bc34a] outline-none" value={draft.edit_standard_kommentar} onChange={e => updateDraftField(draft.id, 'edit_standard_kommentar', e.target.value)} placeholder="Kommentar (F.eks: Inkluderer ikke treningsavgift)" />
|
|
||||||
<p className="text-[10px] text-gray-400">Gammel pris var: {draft.standard_medlemskap ? `kr ${draft.standard_medlemskap} (${draft.navn_standard_medlemskap})` : 'Ikke registrert'}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rimeligste */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="text-xs font-black uppercase tracking-widest text-gray-400">Rimeligste (Betaler Greenfee)</h4>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input className="w-2/3 p-3 rounded-xl border border-gray-200 font-bold focus:border-[#8bc34a] outline-none" value={draft.edit_rimeligste_navn} onChange={e => updateDraftField(draft.id, 'edit_rimeligste_navn', e.target.value)} placeholder="Navn (eks. Greenfeemedlem)" />
|
|
||||||
<input className="w-1/3 p-3 rounded-xl border border-gray-200 font-bold text-right focus:border-[#8bc34a] outline-none" type="number" value={draft.edit_rimeligste_pris} onChange={e => updateDraftField(draft.id, 'edit_rimeligste_pris', e.target.value)} placeholder="Pris" />
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-gray-400 mt-2">Gammel pris var: {draft.rimeligste_alternativ ? `kr ${draft.rimeligste_alternativ} (${draft.navn_rimeligste_alternativ})` : 'Ikke registrert'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,468 +0,0 @@
|
||||||
"use client";
|
|
||||||
/**
|
|
||||||
* TEE OFF ADMIN DASHBOARD v4.0 - KONTROLLPANEL
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
|
||||||
import { API_URL } from "@/config/constants";
|
|
||||||
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: number, field: string, initialValue: string, onSave: (id: number, field: string, val: string) => void }) => {
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [value, setValue] = useState(initialValue || '');
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
setIsEditing(false);
|
|
||||||
if (value !== initialValue) {
|
|
||||||
onSave(facilityId, field, value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEditing) {
|
|
||||||
return (
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="group flex items-start gap-2 cursor-pointer p-1.5 -ml-1.5 rounded-lg hover:bg-white border border-transparent hover:border-gray-200 hover:shadow-sm transition-all" onClick={() => setIsEditing(true)} title="Klikk for å redigere URL">
|
|
||||||
<div className="text-[10px] text-blue-600 break-all max-w-[150px] leading-tight line-clamp-2">
|
|
||||||
{initialValue ? initialValue : <span className="text-red-400 italic">Mangler URL</span>}
|
|
||||||
</div>
|
|
||||||
<span className="opacity-0 group-hover:opacity-100 text-[10px] bg-gray-100 p-1 rounded transition-opacity">✏️</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AdminDashboard() {
|
|
||||||
const [facilities, setFacilities] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [selectedFacilities, setSelectedFacilities] = useState<number[]>([]);
|
|
||||||
const [isScraping, setIsScraping] = useState(false);
|
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
|
||||||
const [editingFacility, setEditingFacility] = useState<any | null>(null);
|
|
||||||
const [activeTab, setActiveTab] = useState<'banestatus' | 'medlemskap' | 'greenfee' | 'vtg'>('banestatus');
|
|
||||||
const [statusFilter, setStatusFilter] = useState('alle');
|
|
||||||
const [editForm, setEditForm] = useState({ scrape_status_url: '', scrape_status_selector: '', scrape_method: '', ai_instruction: '', courses: [] as any[] });
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
|
|
||||||
const fetchFacilities = () => {
|
|
||||||
fetch(`${API_URL}/facilities`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
setFacilities(Array.isArray(data) ? data : []);
|
|
||||||
setLoading(false);
|
|
||||||
})
|
|
||||||
.catch(() => setLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => { fetchFacilities(); }, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let interval: NodeJS.Timeout;
|
|
||||||
if (isScraping) interval = setInterval(() => fetchFacilities(), 10000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [isScraping]);
|
|
||||||
|
|
||||||
useEffect(() => { setSelectedFacilities([]); }, [activeTab]);
|
|
||||||
|
|
||||||
const filteredFacilities = useMemo(() => {
|
|
||||||
if (statusFilter === 'alle') return facilities;
|
|
||||||
return facilities.map(facility => {
|
|
||||||
if (!facility.course_statuses) return facility;
|
|
||||||
const filteredCourses = facility.course_statuses.filter((cs: any) => {
|
|
||||||
const s = cs.status || 'ukjent';
|
|
||||||
if (statusFilter === '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';
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
return { ...facility, course_statuses: filteredCourses };
|
|
||||||
}).filter(facility => facility.course_statuses && facility.course_statuses.length > 0);
|
|
||||||
}, [facilities, statusFilter]);
|
|
||||||
|
|
||||||
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (e.target.checked) setSelectedFacilities(filteredFacilities.map(f => f.id));
|
|
||||||
else setSelectedFacilities([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectOne = (id: number, checked: boolean) => {
|
|
||||||
if (checked) setSelectedFacilities([...selectedFacilities, id]);
|
|
||||||
else setSelectedFacilities(selectedFacilities.filter(facilityId => facilityId !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleQuickEdit = async (id: number, field: string, value: string) => {
|
|
||||||
setFacilities(facilities.map(f => f.id === id ? { ...f, [field]: value } : f));
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/admin/facilities/${id}/quick-edit`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ field, value })
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Feil ved lagring");
|
|
||||||
} catch (e) {
|
|
||||||
alert("Kunne ikke lagre endringen i databasen.");
|
|
||||||
fetchFacilities();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRunScrapers = async () => {
|
|
||||||
if (isScraping) { setIsScraping(false); return; }
|
|
||||||
setIsScraping(true);
|
|
||||||
const endpoint = activeTab === 'banestatus' ? '/admin/run-scraper' :
|
|
||||||
activeTab === 'medlemskap' ? '/admin/run-membership-scraper' :
|
|
||||||
activeTab === 'greenfee' ? '/admin/run-greenfee-scraper' :
|
|
||||||
'/admin/run-vtg-scraper';
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ facility_ids: selectedFacilities })
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error("Kunne ikke starte skraping");
|
|
||||||
const timeoutMs = Math.max(selectedFacilities.length * 40 * 1000, 60000);
|
|
||||||
setSelectedFacilities([]);
|
|
||||||
setTimeout(() => setIsScraping(false), timeoutMs);
|
|
||||||
} catch (error) {
|
|
||||||
alert(`Feil ved start av ${activeTab}-skraperen.`);
|
|
||||||
setIsScraping(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditModal = (facility: any) => {
|
|
||||||
setEditingFacility(facility);
|
|
||||||
setEditForm({
|
|
||||||
scrape_status_url: facility.scrape_status_url || '',
|
|
||||||
scrape_status_selector: facility.scrape_status_selector || '',
|
|
||||||
scrape_method: facility.scrape_method || 'css_selector',
|
|
||||||
ai_instruction: facility.ai_instruction || '',
|
|
||||||
courses: facility.course_statuses ? facility.course_statuses.map((c: any) => ({id: c.id, name: c.name, status: c.status})) : []
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveEdit = async () => {
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_URL}/admin/facilities/${editingFacility.id}/scrape-settings`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(editForm)
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error("Feil ved lagring");
|
|
||||||
setEditingFacility(null);
|
|
||||||
fetchFacilities();
|
|
||||||
} catch (error) {
|
|
||||||
alert("Kunne ikke lagre endringene.");
|
|
||||||
} finally { setIsSaving(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <div className="p-20 text-center font-black animate-pulse">LASTER KONTROLLPANEL...</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen bg-[#f1f7ed] font-sans relative overflow-hidden">
|
|
||||||
|
|
||||||
{/* REDIGER-MODAL FOR BANESTATUS */}
|
|
||||||
{editingFacility && (
|
|
||||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
|
||||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]">
|
|
||||||
<div className="bg-[#11280f] text-white p-6 shrink-0">
|
|
||||||
<h3 className="text-xl font-black uppercase tracking-widest">Skrape-innstillinger</h3>
|
|
||||||
<p className="text-sm text-[#7ca982]">{editingFacility.name}</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-8 space-y-6 overflow-y-auto flex-grow">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Scrape URL (Banestatus)</label>
|
|
||||||
<input type="text" value={editForm.scrape_status_url} onChange={(e) => setEditForm({...editForm, scrape_status_url: e.target.value})} className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors" placeholder="f.eks. https://golfklubb.no/banestatus" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Skrapemetode</label>
|
|
||||||
<select value={editForm.scrape_method} onChange={(e) => setEditForm({...editForm, scrape_method: e.target.value})} className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors">
|
|
||||||
<option value="css_selector">Standard (CSS)</option>
|
|
||||||
<option value="llm_parse">✨ Gemini AI (LLM)</option>
|
|
||||||
<option value="iframe_golfbox">Golfbox iframe</option>
|
|
||||||
<option value="click_then_css">Auto-klikk + CSS</option>
|
|
||||||
<option value="manual">🚨 Manuell (Ikke skrap)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{editForm.scrape_method === 'llm_parse' && (
|
|
||||||
<div className="animate-fade-in">
|
|
||||||
<label className="block text-xs font-bold text-[#8bc34a] uppercase tracking-widest mb-2">✨ AI-Hviskeren (Instruks til Gemini)</label>
|
|
||||||
<textarea value={editForm.ai_instruction || ''} onChange={(e) => setEditForm({...editForm, ai_instruction: e.target.value})} className="w-full border-2 border-[#8bc34a]/30 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors" placeholder="F.eks: Ignorer info om korthullsbanen. Banen er åpen." rows={3} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{editForm.scrape_method === 'manual' && (
|
|
||||||
<div className="bg-red-50 border border-red-100 rounded-xl p-4 animate-fade-in">
|
|
||||||
<label className="block text-xs font-black text-red-500 uppercase tracking-widest mb-4">🚨 Sett Status Manuelt</label>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{editForm.courses.map((course: any, idx: number) => (
|
|
||||||
<div key={course.id} className="flex justify-between items-center bg-white p-3 rounded-lg shadow-sm">
|
|
||||||
<span className="text-xs font-bold text-gray-700 uppercase tracking-widest truncate mr-2" title={course.name}>{course.name}</span>
|
|
||||||
<select value={course.status || 'ukjent'} onChange={(e) => { const newCourses = [...editForm.courses]; newCourses[idx].status = e.target.value; setEditForm({...editForm, courses: newCourses}); }} className="border border-gray-200 rounded-lg p-2 text-xs font-bold focus:outline-none focus:border-red-400 shrink-0">
|
|
||||||
<option value="aapen">🟢 Åpen</option>
|
|
||||||
<option value="aapen_med_vintergreener">🟡 Vintergreener</option>
|
|
||||||
<option value="aapner_snart">🟡 Åpner Snart</option>
|
|
||||||
<option value="stengt">🔴 Stengt</option>
|
|
||||||
<option value="stenger_snart">🔴 Stenger Snart</option>
|
|
||||||
<option value="under_utvikling">🔨 Under Utvikling</option>
|
|
||||||
<option value="nedlagt">⚫ Nedlagt</option>
|
|
||||||
<option value="ukjent">⚪ Ukjent</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(editForm.scrape_method === 'css_selector' || editForm.scrape_method === 'click_then_css' || editForm.scrape_method === 'iframe_golfbox') && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">CSS Selector</label>
|
|
||||||
<input type="text" value={editForm.scrape_status_selector} onChange={(e) => setEditForm({...editForm, scrape_status_selector: e.target.value})} className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors font-mono" placeholder="f.eks. .status-text" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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">
|
|
||||||
{isSaving ? 'Lagrer...' : 'Lagre endringer'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* SIDEBAR */}
|
|
||||||
<aside className={`bg-[#11280f] text-white flex flex-col transition-all duration-300 shrink-0 ${isSidebarCollapsed ? 'w-16 p-4' : 'w-64 p-8'} hidden md:flex`}>
|
|
||||||
<div className={`flex items-center mb-10 ${isSidebarCollapsed ? 'justify-center' : 'justify-between'}`}>
|
|
||||||
{!isSidebarCollapsed && <h1 className="text-2xl font-black uppercase tracking-tighter">TeeOff</h1>}
|
|
||||||
<button onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)} className="text-2xl hover:text-[#8bc34a] transition-colors" title="Skjul/Vis meny">☰</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="space-y-6 text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982] flex-grow">
|
|
||||||
<Link href="/admin" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-[#8bc34a] text-white'}`} title="Kontrollpanel">
|
|
||||||
{isSidebarCollapsed ? 'KP' : 'Kontrollpanel'}
|
|
||||||
</Link>
|
|
||||||
<div className="space-y-2 mt-4">
|
|
||||||
<div className="text-[8px] text-gray-500 font-bold uppercase tracking-widest pl-4 mb-2 opacity-50">Datavask</div>
|
|
||||||
<Link href="/admin/medlemskap" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Medlemskap">
|
|
||||||
{isSidebarCollapsed ? 'M' : 'Medlemskap'}
|
|
||||||
</Link>
|
|
||||||
<Link href="/admin/greenfee" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Greenfee">
|
|
||||||
{isSidebarCollapsed ? 'G' : 'Greenfee'}
|
|
||||||
</Link>
|
|
||||||
<Link href="/admin/vtg" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Veien til Golf (VTG)">
|
|
||||||
{isSidebarCollapsed ? 'V' : 'VTG'}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className={`mt-auto pt-8 border-t border-white/10 ${isSidebarCollapsed ? 'text-center' : ''}`}>
|
|
||||||
<button onClick={() => window.location.href='/'} className={`text-[10px] font-black uppercase tracking-widest text-red-400 hover:text-red-300 ${isSidebarCollapsed ? 'writing-vertical' : ''}`} title="Logg ut">
|
|
||||||
{isSidebarCollapsed ? 'UT' : 'Logg ut'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* HOVEDINNHOLD */}
|
|
||||||
<main className="flex-1 min-w-0 p-4 md:p-8 lg:p-10 h-screen overflow-y-auto">
|
|
||||||
<div className="bg-white rounded-[2rem] shadow-2xl p-6 lg:p-10 border border-white">
|
|
||||||
<header className="flex flex-col xl:flex-row justify-between items-start xl:items-center gap-6 mb-8">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-3xl md:text-4xl font-black tracking-tighter text-[#11280f] mb-2">Kontrollpanel</h2>
|
|
||||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Oversikt over {filteredFacilities.length} anlegg</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleRunScrapers}
|
|
||||||
disabled={selectedFacilities.length === 0 && !isScraping}
|
|
||||||
className={`text-white px-6 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl transition-all whitespace-nowrap
|
|
||||||
${isScraping ? 'bg-yellow-500 animate-pulse cursor-pointer hover:bg-yellow-600' : 'bg-[#8bc34a] hover:scale-105 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed'}`}
|
|
||||||
>
|
|
||||||
{isScraping ? '🤖 Skraper... Klikk for å avslutte' : `Kjør ${activeTab}-skrapere (${selectedFacilities.length})`}
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* VELDIG SYNLIGE FANER */}
|
|
||||||
<div className="flex gap-2 mb-8 border-b-2 border-gray-100 pb-0 overflow-x-auto hide-scrollbar">
|
|
||||||
<button onClick={() => setActiveTab('banestatus')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'banestatus' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Banestatus</button>
|
|
||||||
<button onClick={() => setActiveTab('medlemskap')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'medlemskap' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Medlemskap</button>
|
|
||||||
<button onClick={() => setActiveTab('greenfee')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'greenfee' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Greenfee</button>
|
|
||||||
<button onClick={() => setActiveTab('vtg')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'vtg' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>VTG-Kurs</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === 'banestatus' && (
|
|
||||||
<div className="flex flex-wrap items-center gap-4 bg-gray-50 p-4 rounded-2xl border border-gray-100 mb-8 animate-fade-in">
|
|
||||||
<label htmlFor="statusFilter" className="text-xs font-bold text-gray-500 uppercase tracking-widest">Filtrer på status:</label>
|
|
||||||
<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_stengt">🟡 Ikke stengt (Åpne/Vintergreen/Snart)</option>
|
|
||||||
<option value="stengt">🔴 Kun stengte baner</option>
|
|
||||||
<option value="ukjent_feil">⚪ Ukjent / Skrapefeil</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="overflow-x-auto pb-4">
|
|
||||||
<table className="w-full text-left border-collapse min-w-[900px]">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-[10px] font-black uppercase tracking-widest text-gray-400 border-b border-gray-100">
|
|
||||||
<th className="pb-4 pl-4 w-10"><input type="checkbox" className="w-4 h-4 cursor-pointer accent-[#8bc34a]" checked={selectedFacilities.length === filteredFacilities.length && filteredFacilities.length > 0} onChange={handleSelectAll} /></th>
|
|
||||||
<th className="pb-4 w-12 text-center">ID</th>
|
|
||||||
<th className="pb-4 pr-6">Anlegg</th>
|
|
||||||
|
|
||||||
{activeTab === 'banestatus' && (
|
|
||||||
<>
|
|
||||||
<th className="pb-4">Konfigurasjon (URL & Selektor)</th>
|
|
||||||
<th className="pb-4">Metode</th>
|
|
||||||
<th className="pb-4">Siste Sjekk</th>
|
|
||||||
<th className="pb-4">Banestatus</th>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{activeTab === 'medlemskap' && (
|
|
||||||
<>
|
|
||||||
<th className="pb-4">Medlemskap-side (Klikk for å redigere)</th>
|
|
||||||
<th className="pb-4">Nåværende Priser</th>
|
|
||||||
<th className="pb-4 text-center">Nytt Utkast?</th>
|
|
||||||
<th className="pb-4">Sist Vasket</th>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{activeTab === 'greenfee' && (
|
|
||||||
<>
|
|
||||||
<th className="pb-4">Greenfee-side (Klikk for å redigere)</th>
|
|
||||||
<th className="pb-4">Aktive priser</th>
|
|
||||||
<th className="pb-4 text-center">Nytt Utkast?</th>
|
|
||||||
<th className="pb-4">Sist Vasket</th>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{activeTab === 'vtg' && (
|
|
||||||
<>
|
|
||||||
<th className="pb-4">VTG-side (Klikk for å redigere)</th>
|
|
||||||
<th className="pb-4 w-64">Registrert Informasjon</th>
|
|
||||||
<th className="pb-4 text-center">Nytt Utkast?</th>
|
|
||||||
<th className="pb-4">Sist Vasket</th>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<th className="pb-4 text-right pr-4">Handling</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody className="text-sm font-bold text-[#11280f]">
|
|
||||||
{filteredFacilities.map((f: any) => {
|
|
||||||
const hasMemDraft = f.membership_draft && Object.keys(f.membership_draft).length > 0;
|
|
||||||
const hasGfDraft = f.greenfee_draft && Object.keys(f.greenfee_draft).length > 0;
|
|
||||||
const hasVtgDraft = f.vtg_draft && Object.keys(f.vtg_draft).length > 0;
|
|
||||||
const isHighlighted = (activeTab === 'medlemskap' && hasMemDraft) || (activeTab === 'greenfee' && hasGfDraft) || (activeTab === 'vtg' && hasVtgDraft);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={f.id} className={`border-b border-gray-50 group transition-colors ${isHighlighted ? 'bg-[#8bc34a]/10' : 'hover:bg-gray-50/50'}`}>
|
|
||||||
<td className="py-6 pl-4 w-10"><input type="checkbox" className="w-4 h-4 cursor-pointer accent-[#8bc34a]" checked={selectedFacilities.includes(f.id)} onChange={(e) => handleSelectOne(f.id, e.target.checked)} /></td>
|
|
||||||
<td className="py-6 text-center text-xs font-mono text-gray-400">#{f.id}</td>
|
|
||||||
<td className="py-6 pr-6">
|
|
||||||
<div className="font-black text-base md:text-lg whitespace-nowrap">{f.name}</div>
|
|
||||||
<div className="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{activeTab === 'banestatus' && (
|
|
||||||
<>
|
|
||||||
<td className="py-6 pr-4">
|
|
||||||
<InlineEdit facilityId={f.id} field="scrape_status_url" initialValue={f.scrape_status_url} onSave={handleQuickEdit} />
|
|
||||||
<div className="text-[9px] font-mono text-gray-300 truncate max-w-[150px] mt-1" title={f.scrape_status_selector}>{f.scrape_status_selector}</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-6 pr-4"><ScrapeMethodSelect facility={f} /></td>
|
|
||||||
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
|
|
||||||
<td className="py-6 pr-4">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{f.course_statuses && f.course_statuses.map((cs: any, idx: number) => {
|
|
||||||
let badgeColor = "bg-gray-100 text-gray-500";
|
|
||||||
if (cs.status === "aapen") badgeColor = "bg-green-100 text-green-700";
|
|
||||||
if (cs.status === "stengt" || cs.status === "nedlagt") badgeColor = "bg-red-100 text-red-700";
|
|
||||||
if (cs.status === "aapen_med_vintergreener" || cs.status === "aapner_snart") badgeColor = "bg-yellow-100 text-yellow-700";
|
|
||||||
return (
|
|
||||||
<div key={idx} className="flex items-center gap-2">
|
|
||||||
<span className="text-[9px] uppercase tracking-widest text-gray-400 truncate max-w-[80px]" title={cs.name}>{cs.name}</span>
|
|
||||||
<span className={`px-2 py-0.5 rounded-md text-[9px] font-black uppercase tracking-widest whitespace-nowrap ${badgeColor}`}>{cs.status || 'UKJENT'}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'medlemskap' && (
|
|
||||||
<>
|
|
||||||
<td className="py-6 pr-4"><InlineEdit facilityId={f.id} field="medlemskap_url" initialValue={f.medlemskap_url} onSave={handleQuickEdit} /></td>
|
|
||||||
<td className="py-6 pr-4">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-xs">Standard: <strong>{f.standard_medlemskap ? `${f.standard_medlemskap},-` : '---'}</strong></span>
|
|
||||||
<span className="text-xs text-gray-500">Rimeligste: <strong>{f.rimeligste_alternativ ? `${f.rimeligste_alternativ},-` : '---'}</strong></span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-6 pr-4 text-center">{hasMemDraft ? <span className="px-3 py-1 bg-yellow-100 text-yellow-700 text-xs font-black uppercase tracking-widest rounded-xl animate-pulse">Ja, vask!</span> : <span className="text-gray-300">-</span>}</td>
|
|
||||||
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.membership_updated_at ? new Date(f.membership_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'greenfee' && (
|
|
||||||
<>
|
|
||||||
<td className="py-6 pr-4"><InlineEdit facilityId={f.id} field="greenfee_url" initialValue={f.greenfee_url} onSave={handleQuickEdit} /></td>
|
|
||||||
<td className="py-6 pr-4">
|
|
||||||
<div className="flex flex-col gap-1 text-[10px] text-gray-500 max-h-16 overflow-y-auto pr-2">
|
|
||||||
{f.greenfee && f.greenfee.length > 0 ? f.greenfee.map((g: any, i: number) => (
|
|
||||||
<div key={i} className="flex justify-between border-b border-gray-50 pb-1">
|
|
||||||
<span className="truncate max-w-[120px]">{g.banenavn}</span>
|
|
||||||
<span className="font-bold text-[#11280f]">V: {g.pris_voksne} J: {g.pris_junior}</span>
|
|
||||||
</div>
|
|
||||||
)) : 'Ingen priser'}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-6 pr-4 text-center">{hasGfDraft ? <span className="px-3 py-1 bg-yellow-100 text-yellow-700 text-xs font-black uppercase tracking-widest rounded-xl animate-pulse">Ja, vask!</span> : <span className="text-gray-300">-</span>}</td>
|
|
||||||
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.greenfee_updated_at ? new Date(f.greenfee_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'vtg' && (
|
|
||||||
<>
|
|
||||||
<td className="py-6 pr-4"><InlineEdit facilityId={f.id} field="vtg_lenke" initialValue={f.vtg_lenke} onSave={handleQuickEdit} /></td>
|
|
||||||
<td className="py-6 pr-4 max-w-[250px]">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-xs">Pris: <strong className="text-[#8bc34a]">{f.vtg_pris ? `${f.vtg_pris},-` : '---'}</strong></span>
|
|
||||||
<span className="text-[10px] text-gray-500 line-clamp-2" title={f.vtg_beskrivelse}>{f.vtg_beskrivelse || 'Ingen beskrivelse registrert.'}</span>
|
|
||||||
<span className="text-[10px] font-bold text-[#11280f] mt-1 bg-gray-50 px-2 py-1 rounded-md inline-block w-max">
|
|
||||||
{f.vtg_datoer && f.vtg_datoer.length > 0 ? `📅 ${f.vtg_datoer.length} kursdato(er)` : '📅 Ingen datoer registrert'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-6 pr-4 text-center">{hasVtgDraft ? <span className="px-3 py-1 bg-yellow-100 text-yellow-700 text-xs font-black uppercase tracking-widest rounded-xl animate-pulse">Ja, vask!</span> : <span className="text-gray-300">-</span>}</td>
|
|
||||||
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.vtg_updated_at ? new Date(f.vtg_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<td className="py-6 text-right pr-4">
|
|
||||||
<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>}
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,637 +0,0 @@
|
||||||
"use client";
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
// KOMPONENT 1: MultiSelect for samarbeidende klubber
|
|
||||||
const MultiSelect = ({ label, options, selected, onChange }: { label: string, options: any[], selected: string[], onChange: (s: string[]) => void }) => {
|
|
||||||
const toggle = (val: string) => {
|
|
||||||
if (selected.includes(val)) onChange(selected.filter(x => x !== val));
|
|
||||||
else onChange([...selected, val]);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 mb-8 col-span-1 md:col-span-2">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">{label}</label>
|
|
||||||
<div className="p-4 rounded-2xl border-2 border-gray-300 bg-white shadow-sm max-h-64 overflow-y-auto grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
||||||
{options.map(opt => (
|
|
||||||
<label key={opt.slug} className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded-lg cursor-pointer border border-transparent hover:border-gray-200 transition-all">
|
|
||||||
<input type="checkbox" checked={selected.includes(opt.slug)} onChange={() => toggle(opt.slug)} className="w-5 h-5 accent-[#8bc34a]" />
|
|
||||||
<span className="text-sm font-bold text-gray-700">{opt.name}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// KOMPONENT 2: Viser flate JSON-objekter (som fasiliteter) som rader med Nøkkel og Verdi
|
|
||||||
const KeyValueEditor = ({ label, value, onChange }: { label: string, value: any, onChange: (v: any) => void }) => {
|
|
||||||
const entries = Object.entries(value || {});
|
|
||||||
|
|
||||||
const updateKey = (oldKey: string, newKey: string, val: any) => {
|
|
||||||
const newObj: any = {};
|
|
||||||
for (const [k, v] of entries) {
|
|
||||||
if (k === oldKey) {
|
|
||||||
if (newKey.trim()) newObj[newKey] = val;
|
|
||||||
} else {
|
|
||||||
newObj[k] = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onChange(newObj);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateVal = (key: string, val: string) => {
|
|
||||||
onChange({ ...value, [key]: val });
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeKey = (key: string) => {
|
|
||||||
const newObj = { ...value };
|
|
||||||
delete newObj[key];
|
|
||||||
onChange(newObj);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addRow = () => {
|
|
||||||
const tempKey = `ny_rad_${Date.now()}`;
|
|
||||||
onChange({ ...value, [tempKey]: "" });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4 mb-8 bg-gray-100 p-6 md:p-8 rounded-[2rem] border border-gray-200 shadow-sm">
|
|
||||||
<label className="text-sm font-black uppercase tracking-widest text-[#11280f]">{label}</label>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{entries.map(([k, v]) => (
|
|
||||||
<div key={k} className="flex gap-3 items-center">
|
|
||||||
<input
|
|
||||||
className="w-1/3 p-4 rounded-xl border-2 border-gray-300 text-sm font-bold text-black bg-white focus:border-[#8bc34a] outline-none shadow-sm"
|
|
||||||
placeholder="Nøkkel (f.eks proshop)"
|
|
||||||
defaultValue={k.startsWith('ny_rad_') ? '' : k}
|
|
||||||
onBlur={e => updateKey(k, e.target.value, v)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="w-full p-4 rounded-xl border-2 border-gray-300 text-base font-medium text-black bg-white focus:border-[#8bc34a] outline-none shadow-sm"
|
|
||||||
placeholder="Verdi (f.eks Ja, eller et navn)"
|
|
||||||
value={String(v)}
|
|
||||||
onChange={e => updateVal(k, e.target.value)}
|
|
||||||
/>
|
|
||||||
<button onClick={() => removeKey(k)} className="p-4 bg-red-100 text-red-700 hover:bg-red-200 hover:text-red-900 rounded-xl font-black text-lg transition-colors border border-red-200">✕</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>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// KOMPONENT 3: Viser Arrays med objekter (som Greenfee-lister) som små pene kort
|
|
||||||
const ListObjectEditor = ({ label, value, templateKeys, onChange }: { label: string, value: any[], templateKeys: string[], onChange: (v: any[]) => void }) => {
|
|
||||||
const items = Array.isArray(value) ? value : [];
|
|
||||||
|
|
||||||
const updateField = (index: number, key: string, val: string | number) => {
|
|
||||||
const newItems = [...items];
|
|
||||||
const parsedVal = (!isNaN(Number(val)) && val !== "") ? Number(val) : val;
|
|
||||||
newItems[index] = { ...newItems[index], [key]: parsedVal };
|
|
||||||
onChange(newItems);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addRow = () => {
|
|
||||||
const newItem: any = {};
|
|
||||||
templateKeys.forEach(k => newItem[k] = "");
|
|
||||||
onChange([...items, newItem]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeRow = (index: number) => {
|
|
||||||
const newItems = items.filter((_, i) => i !== index);
|
|
||||||
onChange(newItems);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4 mb-8 bg-gray-100 p-6 md:p-8 rounded-[2rem] border border-gray-200 shadow-sm">
|
|
||||||
<label className="text-sm font-black uppercase tracking-widest text-[#11280f]">{label}</label>
|
|
||||||
<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>
|
|
||||||
<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">
|
|
||||||
<label className="text-xs uppercase font-black text-gray-600 tracking-wider">{key.replace(/_/g, ' ')}</label>
|
|
||||||
<input
|
|
||||||
className="p-3 rounded-lg border-2 border-gray-300 text-base font-bold text-black bg-gray-50 focus:bg-white focus:border-[#8bc34a] outline-none transition-colors"
|
|
||||||
value={item[key] || ""}
|
|
||||||
onChange={e => updateField(idx, key, e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// KOMPONENT 4: DEN NYE SCOREKORT-BYGGEREN
|
|
||||||
const ScorecardBuilder = ({ course, onChange }: { course: any, onChange: (c: any) => void }) => {
|
|
||||||
const ALL_KEYS = ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest'];
|
|
||||||
|
|
||||||
const [holes, setHoles] = useState<any[]>(() => {
|
|
||||||
const h = course.holes || [];
|
|
||||||
if (h.length === 0) {
|
|
||||||
return Array.from({length: 18}, (_, i) => ({ hole_number: i+1, par: '', hcp_index: '', lengths: {} }));
|
|
||||||
}
|
|
||||||
return h.sort((a: any, b: any) => a.hole_number - b.hole_number);
|
|
||||||
});
|
|
||||||
|
|
||||||
const [activeKeys, setActiveKeys] = useState<string[]>(() => {
|
|
||||||
const keys = new Set<string>();
|
|
||||||
holes.forEach(h => {
|
|
||||||
if (h.lengths) Object.keys(h.lengths).forEach(k => keys.add(k));
|
|
||||||
});
|
|
||||||
return ALL_KEYS.filter(k => keys.has(k));
|
|
||||||
});
|
|
||||||
|
|
||||||
const [tees, setTees] = useState<any>(() => {
|
|
||||||
const herrer = course.tee_boxes?.herrer || [];
|
|
||||||
const damer = course.tee_boxes?.damer || [];
|
|
||||||
const initialTees = { herrer: {} as any, damer: {} as any };
|
|
||||||
activeKeys.forEach((key, idx) => {
|
|
||||||
initialTees.herrer[key] = herrer[idx] || { navn_utslag: '', baneverdi: '', slopeverdi: '' };
|
|
||||||
initialTees.damer[key] = damer[idx] || { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' };
|
|
||||||
});
|
|
||||||
return initialTees;
|
|
||||||
});
|
|
||||||
|
|
||||||
const syncToParent = (newHoles: any[], newKeys: string[], newTees: any) => {
|
|
||||||
const updatedTeeBoxes = {
|
|
||||||
herrer: newKeys.map(k => newTees.herrer[k] || {}),
|
|
||||||
damer: newKeys.map(k => newTees.damer[k] || {})
|
|
||||||
};
|
|
||||||
onChange({
|
|
||||||
...course,
|
|
||||||
holes: newHoles,
|
|
||||||
tee_boxes: updatedTeeBoxes
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleKey = (key: string) => {
|
|
||||||
const newKeys = activeKeys.includes(key)
|
|
||||||
? activeKeys.filter(k => k !== key)
|
|
||||||
: ALL_KEYS.filter(k => activeKeys.includes(k) || k === key);
|
|
||||||
setActiveKeys(newKeys);
|
|
||||||
|
|
||||||
const newTees = { ...tees };
|
|
||||||
if (!newTees.herrer[key]) newTees.herrer[key] = { navn_utslag: '', baneverdi: '', slopeverdi: '' };
|
|
||||||
if (!newTees.damer[key]) newTees.damer[key] = { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' };
|
|
||||||
setTees(newTees);
|
|
||||||
syncToParent(holes, newKeys, newTees);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateTee = (gender: 'herrer'|'damer', key: string, field: string, value: string) => {
|
|
||||||
const newTees = { ...tees };
|
|
||||||
newTees[gender][key] = { ...newTees[gender][key], [field]: value };
|
|
||||||
setTees(newTees);
|
|
||||||
syncToParent(holes, activeKeys, newTees);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateHole = (index: number, field: string, value: string, lengthKey: string | null = null) => {
|
|
||||||
const newHoles = [...holes];
|
|
||||||
if (lengthKey) {
|
|
||||||
newHoles[index].lengths = { ...newHoles[index].lengths, [lengthKey]: value === '' ? '' : Number(value) };
|
|
||||||
} else {
|
|
||||||
newHoles[index][field] = value === '' ? '' : Number(value);
|
|
||||||
}
|
|
||||||
setHoles(newHoles);
|
|
||||||
syncToParent(newHoles, activeKeys, tees);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addHole = () => {
|
|
||||||
const newHoles = [...holes, { hole_number: holes.length + 1, par: '', hcp_index: '', lengths: {} }];
|
|
||||||
setHoles(newHoles);
|
|
||||||
syncToParent(newHoles, activeKeys, tees);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeLastHole = () => {
|
|
||||||
const newHoles = holes.slice(0, -1);
|
|
||||||
setHoles(newHoles);
|
|
||||||
syncToParent(newHoles, activeKeys, tees);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4 mt-6">
|
|
||||||
<div className="flex flex-wrap gap-4 items-center bg-gray-100 p-4 rounded-xl border-2 border-gray-200">
|
|
||||||
<span className="text-xs font-black uppercase tracking-widest text-gray-600">Aktive Utslagskolonner:</span>
|
|
||||||
{ALL_KEYS.map(k => (
|
|
||||||
<label key={k} className="flex items-center gap-2 text-sm font-bold cursor-pointer text-black">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={activeKeys.includes(k)}
|
|
||||||
onChange={() => toggleKey(k)}
|
|
||||||
className="w-5 h-5 accent-[#8bc34a]"
|
|
||||||
/>
|
|
||||||
{k.toUpperCase()}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto rounded-2xl border-2 border-gray-300 shadow-sm bg-white pb-2">
|
|
||||||
<table className="w-full text-center text-sm min-w-[800px] border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-gray-100 text-gray-700 text-xs font-black uppercase tracking-widest border-b-2 border-gray-300">
|
|
||||||
<th className="p-3 border-r border-gray-200">Hull</th>
|
|
||||||
<th className="p-3 border-r border-gray-200">Par</th>
|
|
||||||
<th className="p-3 border-r border-gray-300">HCP</th>
|
|
||||||
{activeKeys.map(k => <th key={k} className="p-3 border-r border-gray-300 w-32">{k}</th>)}
|
|
||||||
</tr>
|
|
||||||
{/* Herrer */}
|
|
||||||
<tr className="bg-blue-50 border-b border-gray-300">
|
|
||||||
<th colSpan={3} className="p-3 text-right text-[10px] font-black text-blue-900 uppercase tracking-widest border-r border-gray-300">
|
|
||||||
Herrer (Navn / CR / Slope)
|
|
||||||
</th>
|
|
||||||
{activeKeys.map(k => (
|
|
||||||
<td key={k} className="p-2 border-r border-gray-300 align-top">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<input placeholder="Eks: Gul" className="w-full p-2 text-xs font-bold text-center border border-blue-200 rounded outline-none focus:border-blue-500 bg-white text-black" value={tees.herrer[k]?.navn_utslag || ''} onChange={e => updateTee('herrer', k, 'navn_utslag', e.target.value)} />
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<input placeholder="CR" className="w-1/2 p-2 text-xs text-center border border-blue-200 rounded outline-none focus:border-blue-500 bg-white text-black" value={tees.herrer[k]?.baneverdi || ''} onChange={e => updateTee('herrer', k, 'baneverdi', e.target.value)} />
|
|
||||||
<input placeholder="Slope" className="w-1/2 p-2 text-xs text-center border border-blue-200 rounded outline-none focus:border-blue-500 bg-white text-black" value={tees.herrer[k]?.slopeverdi || ''} onChange={e => updateTee('herrer', k, 'slopeverdi', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
{/* Damer */}
|
|
||||||
<tr className="bg-red-50 border-b-4 border-gray-400">
|
|
||||||
<th colSpan={3} className="p-3 text-right text-[10px] font-black text-red-900 uppercase tracking-widest border-r border-gray-300">
|
|
||||||
Damer (Navn / CR / Slope)
|
|
||||||
</th>
|
|
||||||
{activeKeys.map(k => (
|
|
||||||
<td key={k} className="p-2 border-r border-gray-300 align-top">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<input placeholder="Eks: Rød" className="w-full p-2 text-xs font-bold text-center border border-red-200 rounded outline-none focus:border-red-500 bg-white text-black" value={tees.damer[k]?.navn_utslag_damer || ''} onChange={e => updateTee('damer', k, 'navn_utslag_damer', e.target.value)} />
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<input placeholder="CR" className="w-1/2 p-2 text-xs text-center border border-red-200 rounded outline-none focus:border-red-500 bg-white text-black" value={tees.damer[k]?.baneverdi_damer || ''} onChange={e => updateTee('damer', k, 'baneverdi_damer', e.target.value)} />
|
|
||||||
<input placeholder="Slope" className="w-1/2 p-2 text-xs text-center border border-red-200 rounded outline-none focus:border-red-500 bg-white text-black" value={tees.damer[k]?.slopeverdi_damer || ''} onChange={e => updateTee('damer', k, 'slopeverdi_damer', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{holes.map((h, idx) => (
|
|
||||||
<tr key={idx} className="border-b border-gray-200 hover:bg-gray-50">
|
|
||||||
<td className="p-2 font-black text-lg text-gray-800 border-r border-gray-200">{h.hole_number}</td>
|
|
||||||
<td className="p-2 border-r border-gray-200"><input type="number" className="w-full p-3 text-center border-2 border-gray-200 rounded-xl font-bold text-black outline-none focus:border-[#8bc34a] bg-white" value={h.par || ''} onChange={e => updateHole(idx, 'par', e.target.value)} /></td>
|
|
||||||
<td className="p-2 border-r border-gray-300"><input type="number" className="w-full p-3 text-center border-2 border-gray-200 rounded-xl font-bold text-black outline-none focus:border-[#8bc34a] bg-white" value={h.hcp_index || ''} onChange={e => updateHole(idx, 'hcp_index', e.target.value)} /></td>
|
|
||||||
{activeKeys.map(k => (
|
|
||||||
<td key={k} className="p-2 border-r border-gray-300 bg-gray-50/50">
|
|
||||||
<input type="number" placeholder="Lengde" className="w-full p-3 text-center border-2 border-gray-200 rounded-xl font-mono font-bold text-black outline-none focus:border-[#8bc34a] bg-white" value={h.lengths?.[k] || ''} onChange={e => updateHole(idx, 'lengths', e.target.value, k)} />
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4 px-2">
|
|
||||||
<button onClick={addHole} className="text-sm font-black text-[#8bc34a] hover:text-[#11280f] px-4 py-2 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-2 border-2 border-red-500 rounded-xl">- Slett siste hull</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export default function EditFacilityClient({ initialData, allFacilities }: { initialData: any, allFacilities: any[] }) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [formData, setFormData] = useState(initialData);
|
|
||||||
const [activeTab, setActiveTab] = useState('generelt');
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
// Trekk ut unike arkitekter fra alle anlegg
|
|
||||||
const uniqueArchitects = Array.from(new Set(allFacilities.map(f => f.architect).filter(Boolean))).sort();
|
|
||||||
|
|
||||||
// Sørg for at cooperating_clubs er et array
|
|
||||||
const [coopClubs, setCoopClubs] = useState<string[]>(
|
|
||||||
Array.isArray(initialData.cooperating_clubs) ? initialData.cooperating_clubs :
|
|
||||||
(typeof initialData.cooperating_clubs === 'string' ? JSON.parse(initialData.cooperating_clubs) : [])
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChange = (field: string, value: any) => {
|
|
||||||
setFormData((prev: any) => ({ ...prev, [field]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/admin/facilities/${initialData.id}/full`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(formData)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
alert("Lagret suksessfullt!");
|
|
||||||
router.refresh();
|
|
||||||
} else {
|
|
||||||
alert("Noe gikk galt under lagring.");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert("Nettverksfeil.");
|
|
||||||
}
|
|
||||||
setSaving(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'generelt', label: 'Generelt' },
|
|
||||||
{ id: 'lokasjon', label: 'Lokasjon & Kontakt' },
|
|
||||||
{ id: 'linker', label: 'Lenker & Media' },
|
|
||||||
{ id: 'okonomi', label: 'Økonomi & Medlemskap' },
|
|
||||||
{ id: 'baner', label: 'Baner & Scorekort' }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Hjelpefunksjon for å hente ut verdi (spesielt formatert for dato)
|
|
||||||
const getValue = (field: string, type: string) => {
|
|
||||||
let val = formData[field] || "";
|
|
||||||
if (type === 'date' && val) {
|
|
||||||
val = val.split('T')[0];
|
|
||||||
}
|
|
||||||
return val;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-[1400px] mx-auto p-4 md:p-8 relative z-40 bg-white min-h-screen">
|
|
||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-10 pb-6 border-b border-gray-200 gap-6">
|
|
||||||
<div>
|
|
||||||
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block">← Tilbake til oversikten</Link>
|
|
||||||
<h1 className="text-4xl font-black text-[#11280f]">Rediger: <span className="text-[#8bc34a]">{initialData.name}</span></h1>
|
|
||||||
</div>
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{saving ? "Lagrer..." : "Lagre endringer"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-10">
|
|
||||||
{/* SIDEBAR MENY */}
|
|
||||||
<div className="w-full md:w-1/4 flex flex-col gap-3">
|
|
||||||
{tabs.map(tab => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
className={`p-4 rounded-2xl text-left font-black uppercase text-sm tracking-widest transition-all ${activeTab === tab.id ? 'bg-[#8bc34a] text-white shadow-lg translate-x-2' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SKJEMA OMRÅDE */}
|
|
||||||
<div className="w-full md:w-3/4">
|
|
||||||
{activeTab === 'generelt' && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
|
||||||
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Anleggsnavn</label>
|
|
||||||
<input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('name', 'text')} onChange={e => handleChange('name', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Viktig beskjed (Kursiv intro-tekst)</label>
|
|
||||||
<textarea className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base shadow-sm focus:border-[#8bc34a] outline-none" rows={4} value={getValue('footnote', 'textarea')} onChange={e => handleChange('footnote', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Hovedbeskrivelse</label>
|
|
||||||
<textarea className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base shadow-sm focus:border-[#8bc34a] outline-none" rows={4} value={getValue('description', 'textarea')} onChange={e => handleChange('description', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-8">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Banetype (f.eks Park/Skog)</label>
|
|
||||||
<input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('banetype', 'text')} onChange={e => handleChange('banetype', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-8">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Sesong (f.eks April-Oktober)</label>
|
|
||||||
<input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('season', 'text')} onChange={e => handleChange('season', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-8">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Byggeår</label>
|
|
||||||
<input type="number" className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('established_year', 'number')} onChange={e => handleChange('established_year', Number(e.target.value))} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-8">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Arkitekt</label>
|
|
||||||
<input
|
|
||||||
list="architect-list"
|
|
||||||
className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none transition-all"
|
|
||||||
value={getValue('architect', 'text')}
|
|
||||||
onChange={e => handleChange('architect', e.target.value)}
|
|
||||||
placeholder="Velg eller skriv inn ny..."
|
|
||||||
/>
|
|
||||||
<datalist id="architect-list">
|
|
||||||
<option value="Ukjent" />
|
|
||||||
{uniqueArchitects.map((arch: any) => <option key={arch} value={arch} />)}
|
|
||||||
</datalist>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-8">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Totallengde (meter)</label>
|
|
||||||
<input type="number" className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('length_meters', 'number')} onChange={e => handleChange('length_meters', Number(e.target.value))} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MultiSelect
|
|
||||||
label="Samarbeidende Klubber (Gjestespill etc.)"
|
|
||||||
options={allFacilities.filter(f => f.id !== initialData.id)}
|
|
||||||
selected={coopClubs}
|
|
||||||
onChange={(val) => {
|
|
||||||
setCoopClubs(val);
|
|
||||||
handleChange('cooperating_clubs', val);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'lokasjon' && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
|
||||||
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Gateadresse</label>
|
|
||||||
<input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('address', 'text')} onChange={e => handleChange('address', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Postnummer</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('zipcode', 'text')} onChange={e => handleChange('zipcode', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Poststed / By</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('city', 'text')} onChange={e => handleChange('city', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Fylke</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('county', 'text')} onChange={e => handleChange('county', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Telefon</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('phone', 'text')} onChange={e => handleChange('phone', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">E-post</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('email', 'text')} onChange={e => handleChange('email', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Breddegrad (Latitude)</label><input type="number" className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('lat', 'number')} onChange={e => handleChange('lat', Number(e.target.value))} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Lengdegrad (Longitude)</label><input type="number" className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('lng', 'number')} onChange={e => handleChange('lng', Number(e.target.value))} /></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'linker' && (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Nettside URL</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('website_url', 'text')} onChange={e => handleChange('website_url', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Golfbox Booking URL</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('golfbox_booking_url', 'text')} onChange={e => handleChange('golfbox_booking_url', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Golfbox Turnering URL</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('golfbox_tournament_url', 'text')} onChange={e => handleChange('golfbox_tournament_url', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Baneguide URL</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('baneguide_url', 'text')} onChange={e => handleChange('baneguide_url', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Flyfoto URL</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('flyfoto_url', 'text')} onChange={e => handleChange('flyfoto_url', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Vær URL (YR)</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('weather_url', 'text')} onChange={e => handleChange('weather_url', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Webkamera URL</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('webcam_url', 'text')} onChange={e => handleChange('webcam_url', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Video URL (YouTube/Vimeo)</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('video_url', 'text')} onChange={e => handleChange('video_url', e.target.value)} /></div>
|
|
||||||
|
|
||||||
<ListObjectEditor
|
|
||||||
label="Sosiale Medier (Legg inn f.eks facebook, instagram, linkedin)"
|
|
||||||
value={formData.social_links}
|
|
||||||
templateKeys={['platform', 'url']}
|
|
||||||
onChange={(v) => handleChange('social_links', v)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'okonomi' && (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{/* MEDLEMSKAP */}
|
|
||||||
<div className="bg-gray-100 p-6 rounded-2xl mb-8 border border-gray-200">
|
|
||||||
<h3 className="font-black uppercase tracking-widest text-gray-800 mb-6 pb-2 border-b-2 border-gray-200">Medlemskap</h3>
|
|
||||||
<div className="flex flex-col gap-2 mb-8">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Sist Oppdatert (Dato)</label>
|
|
||||||
<input type="date" className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none w-max" value={getValue('membership_updated_at', 'date')} onChange={e => handleChange('membership_updated_at', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Navn på standard medlemskap</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('navn_standard_medlemskap', 'text')} onChange={e => handleChange('navn_standard_medlemskap', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Pris standard (kun tall)</label><input type="number" className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('standard_medlemskap', 'number')} onChange={e => handleChange('standard_medlemskap', Number(e.target.value))} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8 col-span-1 md:col-span-2"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Kommentar standard</label><textarea className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base shadow-sm focus:border-[#8bc34a] outline-none" rows={2} value={getValue('standard_medlemskap_kommentarer', 'textarea')} onChange={e => handleChange('standard_medlemskap_kommentarer', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Navn på rimeligste</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('navn_rimeligste_alternativ', 'text')} onChange={e => handleChange('navn_rimeligste_alternativ', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Pris rimeligste (kun tall)</label><input type="number" className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('rimeligste_alternativ', 'number')} onChange={e => handleChange('rimeligste_alternativ', Number(e.target.value))} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8 col-span-1 md:col-span-2"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Lenke til medlemskapsside</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('medlemskap_url', 'text')} onChange={e => handleChange('medlemskap_url', e.target.value)} /></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* GREENFEE */}
|
|
||||||
<div className="bg-gray-100 p-6 rounded-2xl border border-gray-200 mb-8">
|
|
||||||
<h3 className="font-black uppercase tracking-widest text-gray-800 mb-6 pb-2 border-b-2 border-gray-200">Greenfee / Gjestespill</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Lenke til Greenfee-side</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('greenfee_url', 'text')} onChange={e => handleChange('greenfee_url', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Krav til Gjestespill (f.eks Klubbhandicap)</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('guest_requirements', 'text')} onChange={e => handleChange('guest_requirements', e.target.value)} /></div>
|
|
||||||
</div>
|
|
||||||
<ListObjectEditor
|
|
||||||
label="Greenfee Priser (Legg til rader for Voksen/Junior etc)"
|
|
||||||
value={formData.greenfee}
|
|
||||||
templateKeys={['banenavn', 'priskategori', 'pris_voksne', 'pris_junior']}
|
|
||||||
onChange={(v) => handleChange('greenfee', v)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* VEIEN TIL GOLF (VTG) */}
|
|
||||||
<div className="bg-[#8bc34a]/10 p-6 rounded-2xl border border-[#8bc34a]/30 mb-8">
|
|
||||||
<h3 className="font-black uppercase tracking-widest text-[#11280f] mb-6 pb-2 border-b-2 border-[#8bc34a]/20">Veien til Golf (VTG)</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Pris VTG kurs (kun tall)</label><input type="number" className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('vtg_pris', 'number')} onChange={e => handleChange('vtg_pris', Number(e.target.value))} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Lenke til VTG påmelding</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('vtg_lenke', 'text')} onChange={e => handleChange('vtg_lenke', e.target.value)} /></div>
|
|
||||||
<div className="flex flex-col gap-2 mb-8 col-span-1 md:col-span-2"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Beskrivelse / Hva er inkludert</label><textarea className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base shadow-sm focus:border-[#8bc34a] outline-none" rows={3} value={getValue('vtg_beskrivelse', 'textarea')} onChange={e => handleChange('vtg_beskrivelse', e.target.value)} /></div>
|
|
||||||
</div>
|
|
||||||
<ListObjectEditor
|
|
||||||
label="Kursdatoer"
|
|
||||||
value={formData.vtg_datoer}
|
|
||||||
templateKeys={['dato', 'status']}
|
|
||||||
onChange={(v) => handleChange('vtg_datoer', v)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 border-t-2 border-gray-200 pt-8">
|
|
||||||
<KeyValueEditor label="Fasiliteter (Proshop, Kafé etc.)" value={formData.amenities} onChange={(v) => handleChange('amenities', v)} />
|
|
||||||
<KeyValueEditor label="Norsk Seniorgolf (NSG)" value={formData.nsg_data} onChange={(v) => handleChange('nsg_data', v)} />
|
|
||||||
<KeyValueEditor label="Golfamore Info" value={formData.golfamore_data} onChange={(v) => handleChange('golfamore_data', v)} />
|
|
||||||
|
|
||||||
{/* HER ER GOLFPAKKENE SOM JEG MISTET I FORRIGE RUNDE */}
|
|
||||||
<ListObjectEditor
|
|
||||||
label="Golfpakker"
|
|
||||||
value={formData.golfpakker}
|
|
||||||
templateKeys={['navn', 'pris', 'beskrivelse']}
|
|
||||||
onChange={(v) => handleChange('golfpakker', v)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'baner' && (
|
|
||||||
<div className="flex flex-col gap-8">
|
|
||||||
<div className="bg-[#f1f7ed] p-6 rounded-2xl border-2 border-[#7ca982] mb-4">
|
|
||||||
<h3 className="font-black text-[#11280f] text-lg uppercase tracking-widest mb-2">Baner og Scorekort</h3>
|
|
||||||
<p className="text-sm text-gray-800 font-medium">Bruk det interaktive skjemaet under for å redigere lengder, par og utslag.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formData.courses?.map((course: any, cIdx: number) => (
|
|
||||||
<div key={course.id || cIdx} className="bg-gray-100 p-8 rounded-[2rem] border-2 border-gray-200 shadow-sm mb-8">
|
|
||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4 border-b-2 border-gray-200 pb-4">
|
|
||||||
<h4 className="text-2xl font-black text-black">{course.name}</h4>
|
|
||||||
<span className={`px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest ${course.is_main_course ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-300 text-gray-700'}`}>
|
|
||||||
{course.is_main_course ? 'Hovedbane' : 'Sekundærbane'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Banenavn</label>
|
|
||||||
<input className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.name || ""} onChange={e => {
|
|
||||||
const newCourses = [...formData.courses];
|
|
||||||
newCourses[cIdx] = {...course, name: e.target.value};
|
|
||||||
handleChange('courses', newCourses);
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Status</label>
|
|
||||||
<select className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.status || "ukjent"} onChange={e => {
|
|
||||||
const newCourses = [...formData.courses];
|
|
||||||
newCourses[cIdx] = {...course, status: e.target.value};
|
|
||||||
handleChange('courses', newCourses);
|
|
||||||
}}>
|
|
||||||
<option value="aapen">🟢 Åpen</option>
|
|
||||||
<option value="aapen_med_vintergreener">🟡 Vintergreener</option>
|
|
||||||
<option value="aapner_snart">🟡 Åpner Snart</option>
|
|
||||||
<option value="stengt">🔴 Stengt</option>
|
|
||||||
<option value="nedlagt">⚫ Nedlagt</option>
|
|
||||||
<option value="ukjent">⚪ Ukjent</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Total Par (Bane)</label>
|
|
||||||
<input type="number" className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.par || ""} onChange={e => {
|
|
||||||
const newCourses = [...formData.courses];
|
|
||||||
newCourses[cIdx] = {...course, par: Number(e.target.value)};
|
|
||||||
handleChange('courses', newCourses);
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Utløpsdato Slope</label>
|
|
||||||
<input type="date" className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.slope_valid_until ? course.slope_valid_until.split('T')[0] : ""} onChange={e => {
|
|
||||||
const newCourses = [...formData.courses];
|
|
||||||
newCourses[cIdx] = {...course, slope_valid_until: e.target.value};
|
|
||||||
handleChange('courses', newCourses);
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* DET NYE SCOREKORTET INKLUDERES HER */}
|
|
||||||
<ScorecardBuilder
|
|
||||||
course={course}
|
|
||||||
onChange={(updatedCourse) => {
|
|
||||||
const newCourses = [...formData.courses];
|
|
||||||
newCourses[cIdx] = updatedCourse;
|
|
||||||
handleChange('courses', newCourses);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { API_URL } from "@/config/constants";
|
|
||||||
import EditFacilityClient from "./EditFacilityClient";
|
|
||||||
|
|
||||||
export default async function EditFacilityPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
||||||
const { slug } = await params;
|
|
||||||
|
|
||||||
// Henter anlegget vi skal redigere
|
|
||||||
const res = await fetch(`${API_URL}/facilities/${slug}`, { cache: 'no-store' });
|
|
||||||
const facility = await res.json();
|
|
||||||
|
|
||||||
// Henter ALLE anlegg slik at vi kan bygge lister for samarbeid og arkitekter
|
|
||||||
const allRes = await fetch(`${API_URL}/facilities`, { cache: 'no-store' });
|
|
||||||
const allFacilities = await allRes.json();
|
|
||||||
|
|
||||||
if (!facility || facility.error) {
|
|
||||||
return <div className="p-20 text-center font-bold text-2xl">Fant ikke anlegget...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <EditFacilityClient initialData={facility} allFacilities={allFacilities} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,208 +0,0 @@
|
||||||
"use client";
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { API_URL } from "@/config/constants";
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
export default function VtgWasher() {
|
|
||||||
const [drafts, setDrafts] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const fetchDrafts = () => {
|
|
||||||
setLoading(true);
|
|
||||||
fetch(`${API_URL}/admin/vtg/drafts`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
const editableDrafts = data.map((f: any) => {
|
|
||||||
let parsedDraft = f.vtg_draft;
|
|
||||||
if (typeof parsedDraft === 'string') {
|
|
||||||
try { parsedDraft = JSON.parse(parsedDraft); }
|
|
||||||
catch (e) { console.error("Kunne ikke parse JSON", e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...f,
|
|
||||||
vtg_draft: parsedDraft,
|
|
||||||
edit_pris: parsedDraft?.foreslatt_vtg_pris || f.vtg_pris || '',
|
|
||||||
edit_beskrivelse: parsedDraft?.foreslatt_vtg_beskrivelse || f.vtg_beskrivelse || '',
|
|
||||||
edit_datoer: parsedDraft?.foreslatt_vtg_datoer || []
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setDrafts(editableDrafts);
|
|
||||||
setLoading(false);
|
|
||||||
})
|
|
||||||
.catch(() => setLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => { fetchDrafts(); }, []);
|
|
||||||
|
|
||||||
const toggleSelectAll = (checked: boolean) => {
|
|
||||||
if (checked) setSelectedIds(drafts.map(d => d.id));
|
|
||||||
else setSelectedIds([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleOne = (id: number) => {
|
|
||||||
if (selectedIds.includes(id)) setSelectedIds(selectedIds.filter(i => i !== id));
|
|
||||||
else setSelectedIds([...selectedIds, id]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateField = (facilityId: number, field: string, value: any) => {
|
|
||||||
setDrafts(drafts.map(d => d.id === facilityId ? { ...d, [field]: value } : d));
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateDateRow = (facilityId: number, rowIndex: number, field: string, value: string) => {
|
|
||||||
setDrafts(drafts.map(d => {
|
|
||||||
if (d.id === facilityId) {
|
|
||||||
const newDates = [...d.edit_datoer];
|
|
||||||
newDates[rowIndex] = { ...newDates[rowIndex], [field]: value };
|
|
||||||
return { ...d, edit_datoer: newDates };
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addDateRow = (facilityId: number) => {
|
|
||||||
setDrafts(drafts.map(d => {
|
|
||||||
if (d.id === facilityId) {
|
|
||||||
return { ...d, edit_datoer: [...d.edit_datoer, { dato: '', status: 'Ledig' }] };
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeDateRow = (facilityId: number, rowIndex: number) => {
|
|
||||||
setDrafts(drafts.map(d => {
|
|
||||||
if (d.id === facilityId) {
|
|
||||||
const newDates = [...d.edit_datoer];
|
|
||||||
newDates.splice(rowIndex, 1);
|
|
||||||
return { ...d, edit_datoer: newDates };
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApprove = async () => {
|
|
||||||
const toApprove = drafts.filter(d => selectedIds.includes(d.id)).map(d => ({
|
|
||||||
facility_id: d.id,
|
|
||||||
vtg_pris: Number(d.edit_pris) || null,
|
|
||||||
vtg_beskrivelse: d.edit_beskrivelse,
|
|
||||||
vtg_datoer: d.edit_datoer
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (toApprove.length === 0) return alert("Velg minst ett anlegg å godkjenne.");
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/admin/vtg/approve-bulk`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ approvals: toApprove })
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
alert(`${toApprove.length} anlegg oppdatert!`);
|
|
||||||
setSelectedIds([]);
|
|
||||||
fetchDrafts();
|
|
||||||
} else {
|
|
||||||
alert("Noe gikk galt under lagring.");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert("Nettverksfeil");
|
|
||||||
}
|
|
||||||
setSaving(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <div className="p-20 text-center font-black animate-pulse">Laster VTG-utkast...</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#f1f7ed] p-8 text-[#11280f]">
|
|
||||||
<div className="max-w-[1400px] mx-auto">
|
|
||||||
<div className="flex justify-between items-end mb-10 border-b border-gray-200 pb-6">
|
|
||||||
<div>
|
|
||||||
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block">← Tilbake til oversikten</Link>
|
|
||||||
<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="bg-[#8bc34a] text-white px-8 py-4 rounded-xl font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50">
|
|
||||||
{saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{drafts.length === 0 ? (
|
|
||||||
<div className="bg-white p-20 rounded-[2rem] text-center shadow-sm">
|
|
||||||
<span className="text-6xl mb-4 block">🧹</span>
|
|
||||||
<h2 className="text-2xl font-black text-gray-400">Ingen ventende VTG-utkast!</h2>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-white p-4 rounded-2xl shadow-sm flex items-center gap-4">
|
|
||||||
<input type="checkbox" className="w-5 h-5 accent-[#8bc34a] ml-2" checked={selectedIds.length === drafts.length && drafts.length > 0} onChange={(e) => toggleSelectAll(e.target.checked)} />
|
|
||||||
<span className="font-black uppercase tracking-widest text-xs text-gray-500">Velg Alle</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{drafts.map(draft => (
|
|
||||||
<div key={draft.id} className={`bg-white p-6 rounded-3xl shadow-sm border-2 transition-all ${selectedIds.includes(draft.id) ? 'border-[#8bc34a] bg-[#8bc34a]/5' : 'border-transparent'}`}>
|
|
||||||
<div className="flex gap-6 items-start">
|
|
||||||
<div className="pt-2"><input type="checkbox" className="w-6 h-6 accent-[#8bc34a] cursor-pointer" checked={selectedIds.includes(draft.id)} onChange={() => toggleOne(draft.id)} /></div>
|
|
||||||
<div className="flex-grow space-y-4">
|
|
||||||
<div className="flex justify-between items-center border-b pb-4">
|
|
||||||
<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="text-xs font-bold text-blue-600 hover:underline bg-blue-50 px-4 py-2 rounded-lg">Sjekk Nettside ↗</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{draft.vtg_draft?.ai_begrunnelse && (
|
|
||||||
<div className="bg-blue-50/50 p-4 rounded-xl text-sm italic text-blue-900 border border-blue-100">
|
|
||||||
<strong>🤖 AI Begrunnelse:</strong> {draft.vtg_draft.ai_begrunnelse}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
||||||
{/* Pris & Beskrivelse */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h4 className="text-xs font-black uppercase tracking-widest text-green-600">Pris & Beskrivelse</h4>
|
|
||||||
<div>
|
|
||||||
<label className="text-[10px] font-bold text-gray-500 uppercase">Standardpris for Voksen (kr)</label>
|
|
||||||
<input className="w-full mt-1 p-3 rounded-xl border border-gray-200 text-sm focus:border-[#8bc34a] outline-none" type="number" value={draft.edit_pris} onChange={e => updateField(draft.id, 'edit_pris', e.target.value)} placeholder="Eks: 1990" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-[10px] font-bold text-gray-500 uppercase">Selgende tekst / Inkludert i kurset</label>
|
|
||||||
<textarea className="w-full mt-1 p-3 rounded-xl border border-gray-200 text-sm focus:border-[#8bc34a] outline-none resize-y" rows={5} value={draft.edit_beskrivelse} onChange={e => updateField(draft.id, 'edit_beskrivelse', e.target.value)} placeholder="Beskriv kurset..." />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Kursdatoer */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-black uppercase tracking-widest text-green-600 mb-4">Kursdatoer</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{draft.edit_datoer.length === 0 ? (
|
|
||||||
<div className="p-4 bg-gray-50 rounded-xl text-sm text-gray-500 italic">Fant ingen spesifikke kursdatoer.</div>
|
|
||||||
) : (
|
|
||||||
draft.edit_datoer.map((row: any, idx: number) => (
|
|
||||||
<div key={idx} className="flex gap-2 items-center bg-white border border-gray-200 p-2 rounded-lg relative group">
|
|
||||||
<input className="flex-grow p-2 rounded border border-gray-100 text-xs font-bold focus:border-[#8bc34a] outline-none" value={row.dato} onChange={e => updateDateRow(draft.id, idx, 'dato', e.target.value)} placeholder="F.eks: 12.-14. mai" />
|
|
||||||
<select className="w-32 p-2 rounded border border-gray-100 text-xs focus:border-[#8bc34a] outline-none bg-white" value={row.status} onChange={e => updateDateRow(draft.id, idx, 'status', e.target.value)}>
|
|
||||||
<option value="Ledig">Ledig</option>
|
|
||||||
<option value="Fulltegnet">Fulltegnet</option>
|
|
||||||
<option value="Venteliste">Venteliste</option>
|
|
||||||
<option value="Få plasser">Få plasser</option>
|
|
||||||
</select>
|
|
||||||
<button onClick={() => removeDateRow(draft.id, idx)} className="text-red-400 hover:text-red-600 px-2 opacity-0 group-hover:opacity-100 transition-opacity" title="Slett dato">✕</button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
<button onClick={() => addDateRow(draft.id)} className="text-xs font-bold text-[#8bc34a] hover:underline mt-2 inline-block">
|
|
||||||
+ Legg til ny dato
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
"use client";
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { STATUS_MAP } from "@/config/constants";
|
|
||||||
|
|
||||||
// Designerens definisjon av fargetemaer - Nå med kraftigere tints for kolonnene
|
|
||||||
const getTeeTheme = (label: string) => {
|
|
||||||
const name = label.toLowerCase();
|
|
||||||
if (name.includes("svart") || name.includes("black")) {
|
|
||||||
return { header: "bg-gray-900 text-white", col: "bg-gray-100", text: "text-gray-900" };
|
|
||||||
}
|
|
||||||
if (name.includes("hvit") || name.includes("white")) {
|
|
||||||
return { header: "bg-white text-gray-800 border border-gray-300", col: "bg-gray-50", text: "text-gray-700" };
|
|
||||||
}
|
|
||||||
if (name.includes("gul") || name.includes("yellow")) {
|
|
||||||
return { header: "bg-yellow-400 text-yellow-950", col: "bg-yellow-50", text: "text-yellow-900" };
|
|
||||||
}
|
|
||||||
if (name.includes("blå") || name.includes("bla") || name.includes("blue")) {
|
|
||||||
return { header: "bg-blue-600 text-white", col: "bg-blue-50", text: "text-blue-900" };
|
|
||||||
}
|
|
||||||
if (name.includes("rød") || name.includes("rod") || name.includes("red")) {
|
|
||||||
return { header: "bg-red-500 text-white", col: "bg-red-50", text: "text-red-900" };
|
|
||||||
}
|
|
||||||
if (name.includes("grønn") || name.includes("gronn") || name.includes("green")) {
|
|
||||||
return { header: "bg-emerald-500 text-white", col: "bg-emerald-50", text: "text-emerald-900" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// DEFAULT: Nøytral grå for utslag med tall (f.eks "52", "45")
|
|
||||||
return { header: "bg-gray-200 text-gray-700", col: "bg-gray-100/60", text: "text-gray-600" };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CourseDisplay({ course }: { course: any }) {
|
|
||||||
const [hcp, setHcp] = useState("15.0");
|
|
||||||
const [gender, setGender] = useState<'herrer' | 'damer'>('herrer');
|
|
||||||
const [selectedTeeIndex, setSelectedTeeIndex] = useState(0);
|
|
||||||
|
|
||||||
const allHoles = course.holes || [];
|
|
||||||
const holesOut = allHoles.filter((h: any) => h.hole_number <= 9);
|
|
||||||
const holesIn = allHoles.filter((h: any) => h.hole_number > 9);
|
|
||||||
const hasInHoles = holesIn.length > 0;
|
|
||||||
|
|
||||||
const lengthKeys = ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest'];
|
|
||||||
const availableTees = course.tee_boxes?.[gender] || [];
|
|
||||||
|
|
||||||
const activeColumns = lengthKeys
|
|
||||||
.filter(k => allHoles.some((h: any) => h.lengths?.[k]))
|
|
||||||
.map((key, idx) => {
|
|
||||||
const info = availableTees[idx];
|
|
||||||
const label = info?.navn_utslag || info?.navn_utslag_damer || key.toUpperCase();
|
|
||||||
return { key, label, theme: getTeeTheme(label) };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Kalkulering av SpH
|
|
||||||
const activeTee = availableTees[selectedTeeIndex];
|
|
||||||
let playingHandicap = 0;
|
|
||||||
|
|
||||||
if (activeTee && hcp) {
|
|
||||||
const exactHcp = Number(hcp.replace(',', '.'));
|
|
||||||
const slope = Number(activeTee.slopeverdi || activeTee.slopeverdi_damer || 113);
|
|
||||||
const cr = Number(String(activeTee.baneverdi || activeTee.baneverdi_damer || course.par).replace(',', '.'));
|
|
||||||
playingHandicap = Math.round((exactHcp * (slope / 113)) + (cr - course.par));
|
|
||||||
}
|
|
||||||
|
|
||||||
const getExtraStrokes = (hcpIndex: number) => {
|
|
||||||
if (!hcpIndex || isNaN(playingHandicap)) return 0;
|
|
||||||
const base = Math.floor(playingHandicap / 18);
|
|
||||||
const rem = playingHandicap % 18;
|
|
||||||
return base + (hcpIndex <= rem ? 1 : 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sumPar = (holes: any[]) => holes.reduce((acc, h) => acc + (h.par || 0), 0);
|
|
||||||
const sumLen = (holes: any[], key: string) => holes.reduce((acc, h) => acc + (h.lengths?.[key] || 0), 0);
|
|
||||||
|
|
||||||
// Formater utløpsdato
|
|
||||||
const slopeExpiry = course.slope_valid_until
|
|
||||||
? new Date(course.slope_valid_until).toLocaleDateString('nb-NO', { year: 'numeric', month: 'short', day: 'numeric' })
|
|
||||||
: 'Ukjent';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-[3rem] shadow-sm border border-gray-200 overflow-hidden mb-12">
|
|
||||||
|
|
||||||
{/* HEADER / KALKULATOR */}
|
|
||||||
<div className="p-8 md:p-12 flex flex-col md:flex-row justify-between items-center gap-8 border-b border-gray-100 bg-white">
|
|
||||||
<div className="text-center md:text-left">
|
|
||||||
<h2 className="text-5xl font-black text-[#11280f] tracking-tighter">{course.name}</h2>
|
|
||||||
<p className="text-[#7ca982] font-black uppercase text-xs tracking-[0.2em] mt-2 mb-1">
|
|
||||||
Par {course.par} • {course.length_meters || '--'} meter
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-400 text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Rating utløper: {slopeExpiry}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-6 bg-gray-50 p-6 rounded-[2.5rem] border border-gray-100">
|
|
||||||
<div className="flex flex-col"><span className="text-[9px] font-black text-[#7ca982] uppercase ml-1">Kjønn</span>
|
|
||||||
<select value={gender} onChange={e => { setGender(e.target.value as any); setSelectedTeeIndex(0); }} className="bg-transparent text-[#11280f] font-black outline-none border-b-2 border-[#7ca982]/30 pb-1 cursor-pointer">
|
|
||||||
<option value="herrer">HERRER</option><option value="damer">DAMER</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col"><span className="text-[9px] font-black text-[#7ca982] uppercase ml-1">Utslag</span>
|
|
||||||
<select value={selectedTeeIndex} onChange={e => setSelectedTeeIndex(Number(e.target.value))} className="bg-transparent text-[#11280f] font-black outline-none border-b-2 border-[#7ca982]/30 pb-1 cursor-pointer">
|
|
||||||
{availableTees.map((t: any, i: number) => (<option key={i} value={i}>{t.navn_utslag || t.navn_utslag_damer}</option>))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col"><span className="text-[9px] font-black text-[#7ca982] uppercase ml-1">Ditt HCP</span>
|
|
||||||
<input type="text" value={hcp} onChange={e => setHcp(e.target.value)} className="w-12 bg-transparent text-[#11280f] font-black text-center border-b-2 border-[#7ca982]/30" />
|
|
||||||
</div>
|
|
||||||
<div className="pl-6 border-l border-gray-200 text-center">
|
|
||||||
<p className="text-[9px] uppercase font-black text-[#7ca982] mb-1">SpH</p>
|
|
||||||
<p className="text-4xl font-black text-[#11280f] leading-none">{playingHandicap || 0}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SCOREKORT TABELL */}
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-center border-collapse table-fixed min-w-[850px]">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-white text-[10px] text-gray-400 font-black uppercase">
|
|
||||||
<th className="w-20 p-5 text-left pl-10 border-b border-gray-100">Hull</th>
|
|
||||||
<th className="w-16 p-5 border-l border-gray-100 border-b border-gray-100">Par</th>
|
|
||||||
<th className="w-16 p-5 border-l border-gray-100 border-b border-gray-100">HCP</th>
|
|
||||||
<th className="w-24 p-5 border-l border-gray-100 border-b border-gray-100 bg-[#7ca982]/10 text-[#7ca982]">Mottatt</th>
|
|
||||||
<th className="w-24 p-5 border-l border-gray-100 border-b border-gray-100 bg-[#7ca982]/20 text-[#11280f]">Din Par</th>
|
|
||||||
{activeColumns.map((col, i) => (
|
|
||||||
<th key={i} className={`p-5 border-l border-white font-black ${col.theme.header}`}>{col.label}</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="font-bold text-[#11280f]">
|
|
||||||
{/* UT-RUNDE */}
|
|
||||||
{holesOut.map((h: any) => {
|
|
||||||
const extra = getExtraStrokes(h.hcp_index);
|
|
||||||
return (
|
|
||||||
<tr key={h.id} className="border-t border-gray-100 group hover:bg-white transition-colors">
|
|
||||||
<td className="p-4 text-left pl-10 font-black text-lg text-gray-800">{h.hole_number}</td>
|
|
||||||
<td className="p-4 border-l border-gray-100 bg-white">{h.par}</td>
|
|
||||||
<td className="p-4 border-l border-gray-100 text-gray-300 text-xs font-mono">{h.hcp_index}</td>
|
|
||||||
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/5 text-[#7ca982] font-mono">{extra > 0 ? `+${extra}` : '-'}</td>
|
|
||||||
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/10 text-lg font-mono">{h.par + extra}</td>
|
|
||||||
{activeColumns.map((col, i) => (
|
|
||||||
<td key={i} className={`p-4 border-l border-white font-mono transition-all ${col.theme.col} ${col.theme.text}`}>
|
|
||||||
{h.lengths?.[col.key] || '--'}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* UT RAD */}
|
|
||||||
<tr className="bg-[#f1f7ed]/50 text-[#11280f] font-black border-y border-gray-200">
|
|
||||||
<td className="p-4 text-left pl-10 uppercase tracking-widest text-[10px] text-gray-400">Ut</td>
|
|
||||||
<td className="p-4 border-l border-gray-100">{sumPar(holesOut)}</td>
|
|
||||||
<td colSpan={3} className="border-l border-gray-100 bg-white"></td>
|
|
||||||
{activeColumns.map((col, i) => (
|
|
||||||
<td key={i} className={`p-4 border-l border-white font-mono ${col.theme.col} text-gray-900`}>{sumLen(holesOut, col.key)}</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{/* INN-RUNDE */}
|
|
||||||
{hasInHoles && holesIn.map((h: any) => {
|
|
||||||
const extra = getExtraStrokes(h.hcp_index);
|
|
||||||
return (
|
|
||||||
<tr key={h.id} className="border-t border-gray-100 group hover:bg-white transition-colors">
|
|
||||||
<td className="p-4 text-left pl-10 font-black text-lg text-gray-800">{h.hole_number}</td>
|
|
||||||
<td className="p-4 border-l border-gray-100 bg-white">{h.par}</td>
|
|
||||||
<td className="p-4 border-l border-gray-100 text-gray-300 text-xs font-mono">{h.hcp_index}</td>
|
|
||||||
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/5 text-[#7ca982] font-mono">{extra > 0 ? `+${extra}` : '-'}</td>
|
|
||||||
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/10 text-lg font-mono">{h.par + extra}</td>
|
|
||||||
{activeColumns.map((col, i) => (
|
|
||||||
<td key={i} className={`p-4 border-l border-white font-mono transition-all ${col.theme.col} ${col.theme.text}`}>
|
|
||||||
{h.lengths?.[col.key] || '--'}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* INN RAD */}
|
|
||||||
{hasInHoles && (
|
|
||||||
<tr className="bg-[#f1f7ed]/50 text-[#11280f] font-black border-y border-gray-200">
|
|
||||||
<td className="p-4 text-left pl-10 uppercase tracking-widest text-[10px] text-gray-400">Inn</td>
|
|
||||||
<td className="p-4 border-l border-gray-100">{sumPar(holesIn)}</td>
|
|
||||||
<td colSpan={3} className="border-l border-gray-100 bg-white"></td>
|
|
||||||
{activeColumns.map((col, i) => (
|
|
||||||
<td key={i} className={`p-4 border-l border-white font-mono ${col.theme.col} text-gray-900`}>{sumLen(holesIn, col.key)}</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* TOTAL RAD */}
|
|
||||||
<tr className="bg-[#11280f] text-white text-xl font-black">
|
|
||||||
<td className="p-8 text-left pl-10 uppercase tracking-tighter">Totalt</td>
|
|
||||||
<td className="p-8 border-l border-white/10">{sumPar(allHoles)}</td>
|
|
||||||
<td colSpan={3} className="border-l border-white/10 bg-[#1a3a17]"></td>
|
|
||||||
{activeColumns.map((col, i) => (
|
|
||||||
<td key={i} className={`p-8 border-l border-white/10 font-mono ${col.theme.header.split(' ')[0]}`}>
|
|
||||||
{sumLen(allHoles, col.key)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,543 +0,0 @@
|
||||||
"use client";
|
|
||||||
/**
|
|
||||||
* TEE OFF DETAIL VIEW - COMPLETE v3.4 (FINAL LAYOUT FIX)
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
* FIX: Gjenopprettet "Turneringer" i den flytende knapperaden over bildet.
|
|
||||||
* FIX: Byttet plass på tekst og sidebar (Tekst øverst på mobil).
|
|
||||||
* FIX: Økt padding (pb-32) i Hero-teksten på mobil for å unngå krasj med knapper.
|
|
||||||
* FIX: Alle 4 kontaktpunkter i sidebar er klikkbare (tel:0047 fix inkludert).
|
|
||||||
* NEW: Sosiale Medier, Footnote og Samarbeidende klubber integrert.
|
|
||||||
* NEW: Priser (Medlemskap + Greenfee) i 2-kolonne Grid (xl:grid-cols-2).
|
|
||||||
* NEW: Veien til Golf (VTG) i full bredde under prisene, med robust array-parsing.
|
|
||||||
* REGEL: Beholder monokrome ikoner, 22/78 layout og robust JSON-parsing.
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { STATUS_MAP, FALLBACK_IMAGE } from "@/config/constants";
|
|
||||||
import Link from 'next/link';
|
|
||||||
import CourseDisplay from './CourseDisplay';
|
|
||||||
|
|
||||||
const formatPhoneForUrl = (phone: string) => {
|
|
||||||
if (!phone) return "";
|
|
||||||
return phone.replace('+', '00').replace(/\s/g, '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderValue = (val: string) => {
|
|
||||||
if (!val) return "Nei";
|
|
||||||
const hasLink = val.includes('<a');
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={hasLink ? "text-[#ff5722] font-bold hover:underline" : "text-[#11280f]"}
|
|
||||||
dangerouslySetInnerHTML={{ __html: val }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Icon = ({ children, className = "w-5 h-5" }: { children: React.ReactNode, className?: string }) => (
|
|
||||||
<svg
|
|
||||||
className={`${className} flex-shrink-0 text-current`}
|
|
||||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
|
||||||
style={{ width: '20px', height: '20px' }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ICONS = {
|
|
||||||
web: <><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></>,
|
|
||||||
phone: <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />,
|
|
||||||
mail: <><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" /><polyline points="22,6 12,13 2,6" /></>,
|
|
||||||
pin: <><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" /><circle cx="12" cy="10" r="3" /></>,
|
|
||||||
booking: <><path d="M3 10h18M7 15h.01M11 15h.01M15 15h.01M7 19h.01M11 19h.01M15 19h.01M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2zM16 3v4M8 3v4"/></>,
|
|
||||||
trophy: <><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22M18 2H6v7a6 6 0 0 0 12 0V2z"/></>,
|
|
||||||
guide: <><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 4.5A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1-2.5-2.5V4.5z"/></>,
|
|
||||||
camera: <><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></>,
|
|
||||||
webcam: <><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></>,
|
|
||||||
chart: <><path d="M18 20V10M12 20V4M6 20v-6"/></>,
|
|
||||||
weather: <><path d="M12 2v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="M20 12h2"/><path d="m19.07 4.93-1.41 1.41"/><path d="M15.947 12.65a4 4 0 0 0-5.925-4.128"/><path d="M13 22H7a5 5 0 1 1 4.9-6H13a3 3 0 0 1 0 6Z"/></>
|
|
||||||
};
|
|
||||||
|
|
||||||
const SOCIAL_ICONS: Record<string, React.ReactNode> = {
|
|
||||||
facebook: <path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />,
|
|
||||||
instagram: <><rect x="2" y="2" width="20" height="20" rx="5" ry="5" /><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" /><line x1="17.5" y1="6.5" x2="17.51" y2="6.5" /></>,
|
|
||||||
twitter: <path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z" />,
|
|
||||||
x: <path d="M4 4l16 16M4 20L20 4" />,
|
|
||||||
linkedin: <><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" /><rect x="2" y="9" width="4" height="12" /><circle cx="4" cy="4" r="2" /></>,
|
|
||||||
youtube: <><path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33 2.78 2.78 0 0 0 1.94 2c1.72.46 8.6.46 8.6.46s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.33 29 29 0 0 0-.46-5.33z" /><polygon points="9.75 15.02 15.5 11.75 9.75 8.48 9.75 15.02" /></>,
|
|
||||||
tiktok: <path d="M9 12a4 4 0 1 0 4 4V2a5 5 0 0 0 5 5h-2a3 3 0 0 1-3-3V16a2 2 0 1 1-2-2v-2z" />,
|
|
||||||
snapchat: <path d="M12 2C8.5 2 6 5 6 8.5c0 1.5.5 3 1.5 4-1 .5-2.5 1-3.5 1-.5 0-1 .5-1 1s.5 1 1.5 1h15c1 0 1.5-.5 1.5-1s-.5-1-1-1c-1 0-2.5-.5-3.5-1 1-1 1.5-2.5 1.5-4C18 5 15.5 2 12 2zm0 15c-3 0-5-1-5-1s.5 1.5 1.5 2h7C16.5 17.5 17 16 17 16s-2 1-5 1z" />
|
|
||||||
};
|
|
||||||
|
|
||||||
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; }
|
|
||||||
};
|
|
||||||
|
|
||||||
const rawCourses = parseJson(facility.courses, []);
|
|
||||||
const activeCourses = Array.isArray(rawCourses) ? rawCourses.filter((c: any) => c.holes && (typeof c.holes === 'string' || c.holes.length > 0)) : [];
|
|
||||||
const amenities = parseJson(facility.amenities, {});
|
|
||||||
const galleryRaw = parseJson(facility.gallery, []);
|
|
||||||
const gallery = galleryRaw.length > 0 ? galleryRaw : [facility.image_url || FALLBACK_IMAGE];
|
|
||||||
const shotzoom = parseJson(facility.shotzoom, []);
|
|
||||||
|
|
||||||
// Pris og kurs-arrays
|
|
||||||
const greenfeeRaw = parseJson(facility.greenfee, []);
|
|
||||||
const vtgDatoer = parseJson(facility.vtg_datoer, []);
|
|
||||||
|
|
||||||
const golfamoreData = parseJson(facility.golfamore_data, {});
|
|
||||||
const nsgData = parseJson(facility.nsg_data, {});
|
|
||||||
const socialLinksRaw = parseJson(facility.social_links, []);
|
|
||||||
const socialLinks = Array.isArray(socialLinksRaw) ? socialLinksRaw : [];
|
|
||||||
|
|
||||||
const coopClubsRaw = parseJson(facility.cooperating_clubs, []);
|
|
||||||
const cooperatingClubs = Array.isArray(coopClubsRaw) ? coopClubsRaw : [];
|
|
||||||
|
|
||||||
const hasGolfamore = facility.golfamore === true;
|
|
||||||
const hasNSG = facility.nsg_url || (nsgData && Object.keys(nsgData).length > 0);
|
|
||||||
|
|
||||||
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";
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (gallery.length <= 1) return;
|
|
||||||
const timer = setInterval(() => setCurrentSlide((p) => (p + 1) % gallery.length), 5000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [gallery.length]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleScroll = () => setShowBackToTop(window.scrollY > 500);
|
|
||||||
window.addEventListener('scroll', handleScroll);
|
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const scrollTo = (id: string) => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el) window.scrollTo({ top: el.getBoundingClientRect().top + window.pageYOffset - 80, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString('nb-NO', { day: 'numeric', month: 'long', year: 'numeric' }) : null;
|
|
||||||
const weatherImg = facility.weather_url?.replace("/graf/dag/", "/innhold/").replace(/\/$/, "") + "/meteogram.svg";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-[#f1f7ed] pb-20 relative font-sans text-[#11280f]">
|
|
||||||
|
|
||||||
{/* 1. HERO SLIDER */}
|
|
||||||
<div className="h-[55vh] min-h-[450px] relative overflow-hidden bg-[#11280f]">
|
|
||||||
{gallery.map((img: string, i: number) => (
|
|
||||||
<img key={i} src={img} className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-1000 ${i === currentSlide ? 'opacity-100 z-10' : 'opacity-0 z-0'}`} alt="" />
|
|
||||||
))}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-[#11280f]/90 via-transparent to-black/10 z-20" />
|
|
||||||
|
|
||||||
{/* BANESTATUS BADGES */}
|
|
||||||
<div className="absolute top-8 right-8 z-40 flex flex-col items-end gap-2">
|
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
|
||||||
{activeCourses.map((c: any) => (
|
|
||||||
<span key={c.id} className="px-3 py-1.5 rounded-lg text-[10px] font-black uppercase bg-[#7ca982] text-white shadow-xl">
|
|
||||||
{c.name.toUpperCase()}: {STATUS_MAP[c.status] || c.status}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{facility.status_updated_at && (
|
|
||||||
<span className="text-white/60 text-[10px] uppercase font-black tracking-widest bg-black/20 px-2 py-1 rounded">
|
|
||||||
Sist oppdatert: {formatDate(facility.status_updated_at)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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=$?q=${facility.lat},${facility.lng}`} 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.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>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* HERO TEXT */}
|
|
||||||
<div className="relative z-30 max-w-[1200px] mx-auto px-6 w-full h-full flex flex-col justify-end pb-32 md:pb-12">
|
|
||||||
{facility.logo_url && (
|
|
||||||
<div className="hidden md:block mb-8 w-24 h-24 bg-white p-2 rounded-2xl shadow-2xl border-4 border-white/20 overflow-hidden"><img src={facility.logo_url} className="w-full h-full object-contain" alt="Logo" /></div>
|
|
||||||
)}
|
|
||||||
<h1 className="text-5xl md:text-8xl font-black text-white mb-3 tracking-tighter drop-shadow-2xl">{facility.name}</h1>
|
|
||||||
<p className="text-[#7ca982] uppercase tracking-[0.4em] font-black text-xs md:text-sm pl-1">{facility.county} • {facility.city}</p>
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="max-w-[1200px] mx-auto px-0 md:px-6 space-y-4 md:space-y-12 mt-0 md:mt-12">
|
|
||||||
|
|
||||||
{/* 3. INTRO & SIDEBAR */}
|
|
||||||
<section id="intro" className="flex flex-col lg:flex-row gap-0 md:gap-8 items-stretch">
|
|
||||||
{/* HOVEDINNHOLD (78%) */}
|
|
||||||
<div className="lg:w-[78%] bg-white p-10 md:p-16 md:rounded-[3rem] shadow-sm border-b md:border-none">
|
|
||||||
{facility.footnote && (
|
|
||||||
<div className="mb-8 pb-8 border-b border-gray-50 italic text-[#ff5722] text-lg font-serif">
|
|
||||||
{facility.footnote}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="leading-relaxed text-lg md:text-xl text-gray-600" dangerouslySetInnerHTML={{ __html: facility.description || 'Ingen beskrivelse tilgjengelig.' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SIDEBAR (22%) */}
|
|
||||||
<div className="lg:w-[22%] bg-white p-10 md:rounded-[3rem] shadow-sm flex flex-col order-last lg:order-none">
|
|
||||||
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-widest mb-10">Kontakt & Adresse</h3>
|
|
||||||
<div className="flex-grow space-y-7 text-sm font-bold">
|
|
||||||
<a href={facility.website_url} target="_blank" className={sidebarLinkClass}><Icon children={ICONS.web} /> Besøk nettsiden</a>
|
|
||||||
<a href={`tel:${formatPhoneForUrl(facility.phone)}`} className={sidebarLinkClass}>
|
|
||||||
<Icon children={ICONS.phone} /> {facility.phone || 'Ikke oppgitt'}
|
|
||||||
</a>
|
|
||||||
<a href={`mailto:${facility.email}`} className={sidebarLinkClass}>
|
|
||||||
<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=$?q=${facility.lat},${facility.lng}`} target="_blank" 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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SOSIALE MEDIER IKONER */}
|
|
||||||
{socialLinks.length > 0 && (
|
|
||||||
<div className="pt-6 border-t border-gray-50 mt-6 flex flex-wrap gap-3">
|
|
||||||
{socialLinks.map((social: any, idx: number) => {
|
|
||||||
const platform = (social.platform || '').toLowerCase().trim();
|
|
||||||
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">
|
|
||||||
<Icon children={iconData} className="w-4 h-4 text-current" />
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-10 pt-6 border-t border-gray-50">
|
|
||||||
<Link href={`/`} className="text-[10px] font-black uppercase tracking-widest text-[#7ca982] hover:text-[#11280f] transition-all flex items-center gap-1">
|
|
||||||
Se alle baner i {facility.county} →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 4. 3-KOLONNE INFO */}
|
|
||||||
<section id="details" className="grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-8">
|
|
||||||
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm">
|
|
||||||
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter">Andre Ressurser</h3>
|
|
||||||
<div className="space-y-2.5">
|
|
||||||
{facility.golfbox_booking_url && (
|
|
||||||
<a href={facility.golfbox_booking_url} target="_blank" className={resourceBtnClass}>
|
|
||||||
<span className="flex items-center gap-3"><Icon children={ICONS.booking} className="group-hover:text-white" /> Book Starttid</span><span>→</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{facility.golfbox_tournament_url && (
|
|
||||||
<a href={facility.golfbox_tournament_url} target="_blank" className={resourceBtnClass}>
|
|
||||||
<span className="flex items-center gap-3"><Icon children={ICONS.trophy} className="group-hover:text-white" /> Turneringer</span><span>→</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{facility.baneguide_url && (
|
|
||||||
<a href={facility.baneguide_url} target="_blank" className={resourceBtnClass}>
|
|
||||||
<span className="flex items-center gap-3"><Icon children={ICONS.guide} className="group-hover:text-white" /> Baneguide</span><span>→</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{facility.flyfoto_url && (
|
|
||||||
<a href={facility.flyfoto_url} target="_blank" className={resourceBtnClass}>
|
|
||||||
<span className="flex items-center gap-3"><Icon children={ICONS.camera} className="group-hover:text-white" /> Flyfoto</span><span>→</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{facility.webcam_url && (
|
|
||||||
<a href={facility.webcam_url} target="_blank" className={resourceBtnClass}>
|
|
||||||
<span className="flex items-center gap-3"><Icon children={ICONS.webcam} className="group-hover:text-white" /> Webkamera</span><span>→</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{shotzoom.map((sz: any, i: number) => (
|
|
||||||
<a key={i} href={sz.shotzoom_url} target="_blank" className={resourceBtnClass}>
|
|
||||||
<span className="flex items-center gap-3"><Icon children={ICONS.chart} className="group-hover:text-white" /> Statistikk: {sz.shotzoom_beskrivelse?.replace(/ ?/g, ' ').trim().toUpperCase()}</span><span>→</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm text-sm font-bold text-gray-700 flex flex-col">
|
|
||||||
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter text-[#11280f]">Banen</h3>
|
|
||||||
<div className="space-y-5 flex-grow">
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Hull:</span><span>{amenities.antall_hull || '--'}</span></div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Lengde:</span><span>{facility.length_meters ? `${facility.length_meters}m` : '--'}</span></div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Sesong:</span><span>{facility.season || '--'}</span></div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Byggeår:</span><span>{facility.established_year || '--'}</span></div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Banetype:</span><span>{facility.banetype || 'Park/Skog'}</span></div>
|
|
||||||
<div className="flex justify-between"><span className="text-gray-400">Arkitekt:</span><span className="text-right truncate ml-4">{facility.architect || '--'}</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm text-sm font-bold text-gray-700">
|
|
||||||
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter text-[#11280f]">Andre Tilbud</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Drivingrange:</span><span>{amenities.drivingrange || 'Nei'}</span></div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Nærspill:</span><span>{amenities.treningsgreen || 'Ja'}</span></div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Proshop:</span><span className="text-right ml-4">{renderValue(amenities.proshop)}</span></div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Kølleutleie:</span><span>{amenities.kolleutleie || 'Ja'}</span></div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Bilutleie:</span><span>{amenities.bilutleie || 'Nei'}</span></div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Simulator:</span><span className="text-right ml-4">{renderValue(amenities.simulator)}</span></div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Kafé:</span><span className="text-right ml-4">{renderValue(amenities.kafe)}</span></div>
|
|
||||||
|
|
||||||
{/* Golfamore og NSG */}
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-2">
|
|
||||||
<span className="text-gray-400">Golfamore:</span>
|
|
||||||
<span className="text-right ml-4">
|
|
||||||
{hasGolfamore ? <span className="text-[#ff5722] font-black">{golfamoreData.gyldighet || "Ja"}</span> : "Nei"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-2">
|
|
||||||
<span className="text-gray-400">Seniorgolf (NSG):</span>
|
|
||||||
<span className="text-right ml-4">
|
|
||||||
{hasNSG && facility.nsg_url
|
|
||||||
? <a href={facility.nsg_url} target="_blank" className="text-blue-600 font-black hover:underline">Ja (Vis Avtale)</a>
|
|
||||||
: (hasNSG ? <span className="text-blue-600 font-black">Ja</span> : "Nei")
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SAMARBEIDENDE KLUBBER */}
|
|
||||||
{cooperatingClubs.length > 0 && (
|
|
||||||
<div className="pt-2">
|
|
||||||
<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">
|
|
||||||
{slug.replace('-golfklubb', '').replace(/-/g, ' ')}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 5. VÆR SEKSJON */}
|
|
||||||
<section id="weather" className="bg-white p-0 md:p-12 md:rounded-[3rem] shadow-sm border-b md:border-none overflow-hidden text-center">
|
|
||||||
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-[0.2em] py-8 md:py-0 md:mb-10 flex items-center justify-center gap-3"><Icon children={ICONS.weather} /> Vær for {facility.name}</h3>
|
|
||||||
<div className="w-full flex justify-center px-4 md:px-0">
|
|
||||||
{facility.weather_url ? ( <img src={weatherImg} className="w-full h-auto block max-w-5xl" alt="Vær" /> ) : <p className="text-center py-24 text-gray-300 italic text-sm">Værvarsel ikke tilgjengelig</p>}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 6. KART SEKSJON */}
|
|
||||||
<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=$?q=${facility.lat},${facility.lng}&t=k&z=15&ie=UTF8&iwloc=&output=embed`} allowFullScreen />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 7. VIDEO SEKSJON */}
|
|
||||||
{facility.video_url && (
|
|
||||||
<section id="video" 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">Video <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
|
|
||||||
<div className="w-full md:rounded-[3rem] overflow-hidden shadow-2xl aspect-video bg-black border-y-4 md:border-[12px] border-white">
|
|
||||||
<iframe src={facility.video_url} className="w-full h-full" allowFullScreen />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 8. PRISER (MEDLEMSKAP, GREENFEE & VTG) */}
|
|
||||||
<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" />
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6 lg:gap-8 items-start">
|
|
||||||
|
|
||||||
{/* VENSTRE KOLONNE: MEDLEMSKAP */}
|
|
||||||
{(facility.standard_medlemskap || facility.rimeligste_alternativ) && (
|
|
||||||
<div className="bg-white border-2 border-[#11280f]/5 rounded-3xl p-6 lg:p-8 shadow-sm h-full flex flex-col">
|
|
||||||
<div className="flex justify-between items-center mb-6">
|
|
||||||
<h3 className="text-xl font-black text-[#11280f] uppercase tracking-tighter flex items-center gap-2">
|
|
||||||
<span>⛳</span> Medlemskap
|
|
||||||
</h3>
|
|
||||||
{facility.medlemskap_url && (
|
|
||||||
<a href={facility.medlemskap_url.split(',')[0].trim()} target="_blank" className="text-[10px] font-black uppercase tracking-widest text-[#7ca982] hover:text-[#11280f] transition-colors">
|
|
||||||
Se alle →
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6 flex-grow">
|
|
||||||
{facility.standard_medlemskap && (
|
|
||||||
<div className="bg-gray-50 rounded-2xl p-5 border border-gray-100 relative overflow-hidden group hover:border-[#8bc34a]/30 transition-colors">
|
|
||||||
<div className="absolute top-0 right-0 bg-[#8bc34a] text-white text-[9px] font-black uppercase tracking-widest px-3 py-1 rounded-bl-xl">Mest valgte</div>
|
|
||||||
<span className="block text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Standard</span>
|
|
||||||
<span className="text-3xl font-black text-[#11280f]">{facility.standard_medlemskap},-</span>
|
|
||||||
{facility.standard_medlemskap_navn && <p className="text-xs font-bold mt-1 text-[#8bc34a]">{facility.standard_medlemskap_navn}</p>}
|
|
||||||
{facility.standard_medlemskap_kommentarer && (
|
|
||||||
<p className="text-[10px] text-gray-500 mt-2 uppercase font-bold italic leading-tight border-t border-gray-200 pt-2">
|
|
||||||
{facility.standard_medlemskap_kommentarer.split('\n').map((line: string, i: number) => (
|
|
||||||
<span key={i}>{line}<br /></span>
|
|
||||||
))}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{facility.rimeligste_alternativ && (
|
|
||||||
<div className="bg-white rounded-2xl p-5 border border-gray-100 hover:border-[#8bc34a]/30 transition-colors">
|
|
||||||
<span className="block text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Rimeligste golfkort</span>
|
|
||||||
<span className="text-xl font-black text-gray-600">{facility.rimeligste_alternativ},-</span>
|
|
||||||
{facility.rimeligste_navn && <p className="text-[10px] font-bold mt-1 text-gray-500">{facility.rimeligste_navn}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* HØYRE KOLONNE: GREENFEE */}
|
|
||||||
{greenfeeRaw && greenfeeRaw.length > 0 && (
|
|
||||||
<div className="bg-white border-2 border-[#11280f]/5 rounded-3xl p-6 lg:p-8 shadow-sm h-full flex flex-col">
|
|
||||||
<div className="flex justify-between items-center mb-6">
|
|
||||||
<h3 className="text-xl font-black text-[#11280f] uppercase tracking-tighter flex items-center gap-2">
|
|
||||||
<span>🎫</span> Greenfee
|
|
||||||
</h3>
|
|
||||||
{facility.greenfee_url && (
|
|
||||||
<a href={facility.greenfee_url.split(',')[0].trim()} target="_blank" className="text-[10px] font-black uppercase tracking-widest text-[#7ca982] hover:text-[#11280f] transition-colors">
|
|
||||||
Alle priser →
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto hide-scrollbar flex-grow">
|
|
||||||
<table className="w-full text-left min-w-[300px]">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b-2 border-gray-100 text-[10px] font-black uppercase tracking-widest text-gray-400">
|
|
||||||
<th className="pb-3 pr-2">Bane/Kat.</th>
|
|
||||||
<th className="pb-3 text-right pr-2">Voksen</th>
|
|
||||||
<th className="pb-3 text-right">Junior</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="text-xs font-bold text-[#11280f]">
|
|
||||||
{greenfeeRaw.map((gf: any, idx: number) => (
|
|
||||||
<tr key={idx} className="border-b border-gray-50 hover:bg-gray-50 transition-colors">
|
|
||||||
<td className="py-3 pr-2 leading-tight">
|
|
||||||
<span className="block truncate max-w-[150px] sm:max-w-[200px]" title={gf.banenavn}>{gf.banenavn}</span>
|
|
||||||
<span className="text-[9px] text-gray-400 uppercase tracking-widest block truncate max-w-[150px] sm:max-w-[200px]" title={gf.priskategori}>{gf.priskategori}</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 text-right pr-2 text-[#8bc34a] font-black text-sm whitespace-nowrap">
|
|
||||||
{gf.pris_voksne ? `${gf.pris_voksne},-` : '-'}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 text-right text-blue-400 whitespace-nowrap">
|
|
||||||
{gf.pris_junior ? `${gf.pris_junior},-` : '-'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{facility.guest_requirements && (
|
|
||||||
<p className="mt-4 text-[10px] text-gray-400 font-black uppercase tracking-widest italic pt-4 border-t border-gray-50">
|
|
||||||
Krav: {facility.guest_requirements}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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">
|
|
||||||
<h3 className="text-2xl font-black uppercase tracking-tighter mb-4 flex items-center gap-3">
|
|
||||||
Nybegynnerkurs (Veien til Golf)
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
<div className="w-full flex flex-col items-center gap-20">
|
|
||||||
{activeCourses.map((c: any) => (
|
|
||||||
<div key={c.id} className="w-full overflow-x-auto md:overflow-visible no-scrollbar">
|
|
||||||
<div className="min-w-[800px] md:min-w-0"><CourseDisplay course={c} /></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
// page.tsx
|
|
||||||
import { API_URL } from "@/config/constants";
|
|
||||||
import FacilityDetailView from "./FacilityDetailView";
|
|
||||||
|
|
||||||
export default async function GolfCoursePage({ params }: { params: Promise<{ slug: string }> }) {
|
|
||||||
const { slug } = await params;
|
|
||||||
|
|
||||||
const res = await fetch(`${API_URL}/facilities/${slug}`, { cache: 'no-store' });
|
|
||||||
const facility = await res.json();
|
|
||||||
|
|
||||||
if (!facility || facility.error) {
|
|
||||||
return <div className="p-20 text-center font-bold text-2xl">Fant ikke golfbanen...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vi sender dataene til den navngitte komponenten
|
|
||||||
return <FacilityDetailView facility={facility} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import type { Metadata } from "next";
|
|
||||||
import "./globals.css";
|
|
||||||
import Header from "@/components/Header";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "TeeOff.no - Din guide til norske golfbaner",
|
|
||||||
description: "Oppdatert banestatus, priser og informasjon om alle norske golfanlegg.",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<html lang="nb">
|
|
||||||
<body className="antialiased bg-[#f1f7ed]">
|
|
||||||
<Header />
|
|
||||||
{children}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import HeroSlider from './HeroSlider';
|
|
||||||
import FacilitySearch from './FacilitySearch';
|
|
||||||
import { API_URL } from '@/config/constants';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
export default async function Home() {
|
|
||||||
let facilities = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/facilities`, {
|
|
||||||
next: { revalidate: 0 },
|
|
||||||
cache: 'no-store'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const errorData = await res.json();
|
|
||||||
console.error("API Error Body:", errorData);
|
|
||||||
throw new Error(`API returnerte status ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
facilities = await res.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Kritisk feil ved henting av data:", error);
|
|
||||||
facilities = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sikrer at vi alltid sender en array til komponentene
|
|
||||||
const safeData = Array.isArray(facilities) ? facilities : [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-[#f1f7ed]">
|
|
||||||
{/* Wrapper slideren i en div som skjuler den på mobil (hidden) og viser den på PC (md:block) */}
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<HeroSlider facilities={safeData} />
|
|
||||||
</div>
|
|
||||||
<FacilitySearch initialFacilities={safeData} />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
"use client";
|
|
||||||
import { useState } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
export default function Header() {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="sticky top-0 z-[100] bg-white/95 backdrop-blur-md border-b border-gray-100 shadow-sm">
|
|
||||||
<div className="max-w-[1400px] mx-auto px-6 h-20 flex items-center justify-between">
|
|
||||||
|
|
||||||
{/* LOGO */}
|
|
||||||
<Link href="/" className="h-10 md:h-12 transition-transform hover:scale-105 active:scale-95">
|
|
||||||
<img src="/TeeOff-logo-Retina-1.png" alt="TeeOff.no" className="h-full w-auto object-contain" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* DESKTOP NAV */}
|
|
||||||
<nav className="hidden md:flex items-center gap-8 text-[11px] font-black uppercase tracking-widest text-gray-500">
|
|
||||||
<Link href="/" className="hover:text-[#8bc34a]">Hjem</Link>
|
|
||||||
<Link href="/golfbaner" className="hover:text-[#8bc34a]">Finn Bane</Link>
|
|
||||||
<Link href="/medlemskap" className="hover:text-[#8bc34a]">Medlemskap</Link>
|
|
||||||
<Link href="/om-oss" className="hover:text-[#8bc34a]">Om oss</Link>
|
|
||||||
<Link href="/admin/login" className="px-5 py-2 bg-[#ff5722] text-white rounded-xl hover:bg-black transition-all font-black uppercase text-[10px] tracking-widest">Admin</Link>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* HAMBURGER (Mobil) */}
|
|
||||||
<button onClick={() => setIsOpen(!isOpen)} className="md:hidden p-2 text-[#11280f]">
|
|
||||||
<div className="w-6 h-0.5 bg-current mb-1.5 transition-all"></div>
|
|
||||||
<div className="w-6 h-0.5 bg-current mb-1.5"></div>
|
|
||||||
<div className="w-6 h-0.5 bg-current"></div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* MOBIL MENY OVERLAY */}
|
|
||||||
{isOpen && (
|
|
||||||
<div className="md:hidden absolute top-20 left-0 w-full bg-white border-b border-gray-100 p-6 flex flex-col gap-6 shadow-2xl animate-in slide-in-from-top duration-300">
|
|
||||||
<Link onClick={() => setIsOpen(false)} href="/" className="text-lg font-black uppercase text-[#11280f]">Hjem</Link>
|
|
||||||
<Link onClick={() => setIsOpen(false)} href="/golfbaner" className="text-lg font-black uppercase text-[#11280f]">Finn Bane</Link>
|
|
||||||
<Link onClick={() => setIsOpen(false)} href="/medlemskap" className="text-lg font-black uppercase text-[#11280f]">Medlemskap</Link>
|
|
||||||
<Link onClick={() => setIsOpen(false)} href="/logg-inn" className="text-[#ff5722] font-black uppercase">Admin Logg inn</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
// Tilpass interface til de dataene du allerede har i frontend
|
|
||||||
interface Facility {
|
|
||||||
id: number;
|
|
||||||
scrape_method?: string;
|
|
||||||
scrape_status_url?: string;
|
|
||||||
scrape_status_selector?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ScrapeMethodSelect({ facility }: { facility: Facility }) {
|
|
||||||
// Setter standardverdi til 'css_selector' hvis den er tom i databasen
|
|
||||||
const [method, setMethod] = useState(facility.scrape_method || 'css_selector');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [statusColor, setStatusColor] = useState('bg-transparent'); // For å gi visuell feedback
|
|
||||||
|
|
||||||
const handleMethodChange = async (newMethod: string) => {
|
|
||||||
setMethod(newMethod);
|
|
||||||
setIsLoading(true);
|
|
||||||
setStatusColor('bg-yellow-200'); // Lyser gult mens den lagrer
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Husk å endre URL-en hvis API-et ditt ligger på et annet domene
|
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/api/admin/facilities/${facility.id}/scrape-settings`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
// Hvis du bruker JWT i headers i stedet for cookies, legg det til her:
|
|
||||||
// 'Authorization': `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
scrape_method: newMethod,
|
|
||||||
scrape_status_url: facility.scrape_status_url, // Beholder eksisterende
|
|
||||||
scrape_status_selector: facility.scrape_status_selector // Beholder eksisterende
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Feil ved lagring');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Suksess! Lyser grønt et kort sekund
|
|
||||||
setStatusColor('bg-green-300');
|
|
||||||
setTimeout(() => setStatusColor('bg-transparent'), 2000);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
setStatusColor('bg-red-300'); // Lyser rødt ved feil
|
|
||||||
alert("Kunne ikke oppdatere skrapemetode.");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
value={method}
|
|
||||||
onChange={(e) => handleMethodChange(e.target.value)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className={`border rounded p-1 text-sm transition-colors duration-300 ${statusColor} ${isLoading ? 'opacity-50' : ''}`}
|
|
||||||
>
|
|
||||||
<option value="css_selector">Standard (CSS)</option>
|
|
||||||
<option value="llm_parse">✨ Gemini AI (LLM)</option>
|
|
||||||
<option value="iframe_golfbox">Golfbox iframe</option>
|
|
||||||
<option value="click_then_css">Auto-klikk + CSS</option>
|
|
||||||
<option value="">Ingen (Avslått)</option>
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
/**
|
|
||||||
* TEE OFF CONFIG CONSTANTS v1.3
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
* REGEL 1: ALDRI trunker eller fjern data fra denne filen.
|
|
||||||
* REGEL 2: Håndterer både intern Docker-kommunikasjon og ekstern browser-kommunikasjon.
|
|
||||||
* REGEL 3: Inneholder alle regionale mappinger for Norge.
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
const isBrowser = typeof window !== 'undefined';
|
|
||||||
|
|
||||||
// Intern URL for server-to-server (Docker-internt)
|
|
||||||
const INTERNAL_API = process.env.API_URL || "http://api:8000/api";
|
|
||||||
|
|
||||||
// Relativ sti for browseren.
|
|
||||||
// Ved å bruke '/api' sørger vi for at nettleseren bruker samme protokoll (https)
|
|
||||||
// og domene (nye.teeoff.no) som resten av siden.
|
|
||||||
const EXTERNAL_API = "/api";
|
|
||||||
|
|
||||||
export const API_URL = isBrowser ? EXTERNAL_API : INTERNAL_API;
|
|
||||||
|
|
||||||
export const FALLBACK_IMAGE = "/Toppbilde-standard.jpg";
|
|
||||||
export const TEEOFF_LOGO = "/TeeOff-logo-Retina-1.png";
|
|
||||||
|
|
||||||
export const STATUS_MAP: Record<string, string> = {
|
|
||||||
"ukjent": "Ukjent status",
|
|
||||||
"aapen": "Åpen",
|
|
||||||
"aapen_med_vintergreener": "Vintergreener",
|
|
||||||
"stengt": "Stengt",
|
|
||||||
"nedlagt": "Nedlagt",
|
|
||||||
"under_utvikling": "Under utvikling",
|
|
||||||
"aapner_snart": "Åpner snart",
|
|
||||||
"stenger_snart": "Stenger snart"
|
|
||||||
};
|
|
||||||
|
|
||||||
export const REGIONS: Record<string, string[]> = {
|
|
||||||
"nord-norge": ["finnmark", "troms", "nordland"],
|
|
||||||
"midt-norge": ["nord-trøndelag", "sør-trøndelag", "trøndelag"],
|
|
||||||
"vestlandet": ["møre og romsdal", "sogn og fjordane", "hordaland", "rogaland", "vestland"],
|
|
||||||
"sørlandet": ["vest-agder", "aust-agder", "agder"],
|
|
||||||
"østlandet": ["telemark", "vestfold", "østfold", "buskerud", "hedmark", "oppland", "oslo", "akershus", "innlandet", "viken"]
|
|
||||||
};
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
/**
|
|
||||||
* TEE OFF SECURITY MIDDLEWARE v1.1
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
* REGEL: Beskytter alle ruter under /admin (unntatt /admin/login).
|
|
||||||
* FUNKSJON: Sjekker for admin_session cookie og omdirigerer hvis den mangler.
|
|
||||||
* RETTING: Flyttet NextRequest til next/server for å fikse build-error.
|
|
||||||
* ---------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextResponse, type NextRequest } from 'next/server';
|
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
|
||||||
const { pathname } = request.nextUrl;
|
|
||||||
const session = request.cookies.get('admin_session');
|
|
||||||
|
|
||||||
// 1. Tillat alltid tilgang til innloggingssiden
|
|
||||||
if (pathname.startsWith('/admin/login')) {
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Beskytt alle andre ruter under /admin
|
|
||||||
if (pathname.startsWith('/admin')) {
|
|
||||||
if (!session) {
|
|
||||||
// Ingen sesjon funnet -> Send til innlogging
|
|
||||||
const loginUrl = new URL('/admin/login', request.url);
|
|
||||||
return NextResponse.redirect(loginUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Definer hvilke ruter middleware skal kjøre på
|
|
||||||
export const config = {
|
|
||||||
matcher: ['/admin/:path*'],
|
|
||||||
};
|
|
||||||
|
|
@ -1,639 +0,0 @@
|
||||||
--
|
|
||||||
-- PostgreSQL database dump
|
|
||||||
--
|
|
||||||
|
|
||||||
-- Dumped from database version 15.8 (Debian 15.8-1.pgdg110+1)
|
|
||||||
-- Dumped by pg_dump version 15.8 (Debian 15.8-1.pgdg110+1)
|
|
||||||
|
|
||||||
SET statement_timeout = 0;
|
|
||||||
SET lock_timeout = 0;
|
|
||||||
SET idle_in_transaction_session_timeout = 0;
|
|
||||||
SET client_encoding = 'UTF8';
|
|
||||||
SET standard_conforming_strings = on;
|
|
||||||
SELECT pg_catalog.set_config('search_path', '', false);
|
|
||||||
SET check_function_bodies = false;
|
|
||||||
SET xmloption = content;
|
|
||||||
SET client_min_messages = warning;
|
|
||||||
SET row_security = off;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tiger; Type: SCHEMA; Schema: -; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SCHEMA tiger;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER SCHEMA tiger OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tiger_data; Type: SCHEMA; Schema: -; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SCHEMA tiger_data;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER SCHEMA tiger_data OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: topology; Type: SCHEMA; Schema: -; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SCHEMA topology;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER SCHEMA topology OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: SCHEMA topology; Type: COMMENT; Schema: -; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON SCHEMA topology IS 'PostGIS Topology schema';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: fuzzystrmatch; Type: EXTENSION; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS fuzzystrmatch WITH SCHEMA public;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: EXTENSION fuzzystrmatch; Type: COMMENT; Schema: -; Owner:
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON EXTENSION fuzzystrmatch IS 'determine similarities and distance between strings';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: postgis; Type: EXTENSION; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS postgis WITH SCHEMA public;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: EXTENSION postgis; Type: COMMENT; Schema: -; Owner:
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON EXTENSION postgis IS 'PostGIS geometry and geography spatial types and functions';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: postgis_tiger_geocoder; Type: EXTENSION; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS postgis_tiger_geocoder WITH SCHEMA tiger;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: EXTENSION postgis_tiger_geocoder; Type: COMMENT; Schema: -; Owner:
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON EXTENSION postgis_tiger_geocoder IS 'PostGIS tiger geocoder and reverse geocoder';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: postgis_topology; Type: EXTENSION; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS postgis_topology WITH SCHEMA topology;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: EXTENSION postgis_topology; Type: COMMENT; Schema: -; Owner:
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON EXTENSION postgis_topology IS 'PostGIS topology spatial types and functions';
|
|
||||||
|
|
||||||
|
|
||||||
SET default_tablespace = '';
|
|
||||||
|
|
||||||
SET default_table_access_method = heap;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: admins; Type: TABLE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.admins (
|
|
||||||
id integer NOT NULL,
|
|
||||||
username character varying(50) NOT NULL,
|
|
||||||
password_hash text NOT NULL,
|
|
||||||
otp_secret character varying(32),
|
|
||||||
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
email character varying(255)
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.admins OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: admins_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE public.admins_id_seq
|
|
||||||
AS integer
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.admins_id_seq OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: admins_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE public.admins_id_seq OWNED BY public.admins.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: courses; Type: TABLE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.courses (
|
|
||||||
id integer NOT NULL,
|
|
||||||
facility_id integer,
|
|
||||||
name character varying(255) NOT NULL,
|
|
||||||
holes integer,
|
|
||||||
par integer,
|
|
||||||
length_meters integer,
|
|
||||||
course_type character varying(255),
|
|
||||||
architect character varying(255),
|
|
||||||
status character varying(255),
|
|
||||||
is_main_course boolean DEFAULT true,
|
|
||||||
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
tee_boxes jsonb,
|
|
||||||
scrape_keyword text,
|
|
||||||
slope_valid_until date
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.courses OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: courses_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE public.courses_id_seq
|
|
||||||
AS integer
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.courses_id_seq OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: courses_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE public.courses_id_seq OWNED BY public.courses.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facilities; Type: TABLE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.facilities (
|
|
||||||
id integer NOT NULL,
|
|
||||||
name character varying(255) NOT NULL,
|
|
||||||
slug character varying(255) NOT NULL,
|
|
||||||
description text,
|
|
||||||
established_year integer,
|
|
||||||
season character varying(255),
|
|
||||||
address character varying(255),
|
|
||||||
zipcode character varying(50),
|
|
||||||
city character varying(255),
|
|
||||||
county character varying(255),
|
|
||||||
lat double precision,
|
|
||||||
lng double precision,
|
|
||||||
email character varying(255),
|
|
||||||
phone character varying(255),
|
|
||||||
website_url character varying(255),
|
|
||||||
golfbox_booking_url character varying(255),
|
|
||||||
golfbox_tournament_url character varying(255),
|
|
||||||
facebook_url character varying(255),
|
|
||||||
instagram_url character varying(255),
|
|
||||||
weather_url character varying(255),
|
|
||||||
webcam_url character varying(255),
|
|
||||||
golfamore boolean DEFAULT false,
|
|
||||||
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
image_url character varying(500),
|
|
||||||
amenities jsonb,
|
|
||||||
greenfee jsonb,
|
|
||||||
architect text,
|
|
||||||
membership jsonb,
|
|
||||||
vtg jsonb,
|
|
||||||
video_url text,
|
|
||||||
baneguide_url text,
|
|
||||||
logo_url text,
|
|
||||||
flyfoto_url text,
|
|
||||||
guest_requirements text,
|
|
||||||
status_updated_at date,
|
|
||||||
gallery jsonb,
|
|
||||||
faqs jsonb DEFAULT '[]'::jsonb,
|
|
||||||
shotzoom jsonb DEFAULT '[]'::jsonb,
|
|
||||||
front_image_url text,
|
|
||||||
nsg_url text,
|
|
||||||
nsg_description text,
|
|
||||||
nsg_data jsonb DEFAULT '{}'::jsonb,
|
|
||||||
golfamore_data jsonb DEFAULT '{}'::jsonb,
|
|
||||||
ngf_number integer,
|
|
||||||
golfbox_club_id integer,
|
|
||||||
golfbox_booking_id text,
|
|
||||||
facebook_id text,
|
|
||||||
instagram_place_id text,
|
|
||||||
tournament_url text,
|
|
||||||
footnote text,
|
|
||||||
social_links jsonb DEFAULT '[]'::jsonb,
|
|
||||||
webcam_html text,
|
|
||||||
length_meters integer,
|
|
||||||
navn_standard_medlemskap text,
|
|
||||||
standard_medlemskap integer,
|
|
||||||
standard_medlemskap_kommentarer text,
|
|
||||||
navn_rimeligste_alternativ text,
|
|
||||||
rimeligste_alternativ integer,
|
|
||||||
rimeligste_alternativ_kommentarer text,
|
|
||||||
medlemskap_url text,
|
|
||||||
banetype text,
|
|
||||||
scrape_status_url text,
|
|
||||||
scrape_status_selector text,
|
|
||||||
scrape_method character varying(50) DEFAULT 'css_selector'::character varying,
|
|
||||||
ai_instruction text,
|
|
||||||
golfpakker jsonb DEFAULT '[]'::jsonb,
|
|
||||||
rabattert_greenfee jsonb DEFAULT '[]'::jsonb,
|
|
||||||
vtg_presentasjon text,
|
|
||||||
vtg_lenke text,
|
|
||||||
vtg_pris integer,
|
|
||||||
vtg_kursdatoer jsonb DEFAULT '[]'::jsonb,
|
|
||||||
membership_draft jsonb,
|
|
||||||
membership_updated_at timestamp with time zone,
|
|
||||||
greenfee_url character varying(255) DEFAULT NULL::character varying,
|
|
||||||
greenfee_draft jsonb,
|
|
||||||
greenfee_updated_at timestamp with time zone,
|
|
||||||
vtg_beskrivelse text,
|
|
||||||
vtg_datoer jsonb,
|
|
||||||
vtg_draft jsonb,
|
|
||||||
vtg_updated_at timestamp with time zone
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.facilities OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facilities_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE public.facilities_id_seq
|
|
||||||
AS integer
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.facilities_id_seq OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facilities_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE public.facilities_id_seq OWNED BY public.facilities.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facility_images; Type: TABLE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.facility_images (
|
|
||||||
id integer NOT NULL,
|
|
||||||
facility_id integer,
|
|
||||||
image_url character varying(255) NOT NULL,
|
|
||||||
display_order integer DEFAULT 0,
|
|
||||||
sort_order integer DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.facility_images OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facility_images_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE public.facility_images_id_seq
|
|
||||||
AS integer
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.facility_images_id_seq OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facility_images_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE public.facility_images_id_seq OWNED BY public.facility_images.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: hole_lengths; Type: TABLE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.hole_lengths (
|
|
||||||
id integer NOT NULL,
|
|
||||||
hole_id integer,
|
|
||||||
tee_id integer,
|
|
||||||
length_meters integer
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.hole_lengths OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: hole_lengths_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE public.hole_lengths_id_seq
|
|
||||||
AS integer
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.hole_lengths_id_seq OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: hole_lengths_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE public.hole_lengths_id_seq OWNED BY public.hole_lengths.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: holes; Type: TABLE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.holes (
|
|
||||||
id integer NOT NULL,
|
|
||||||
course_id integer,
|
|
||||||
hole_number integer NOT NULL,
|
|
||||||
par integer,
|
|
||||||
hcp_index integer,
|
|
||||||
lengths jsonb
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.holes OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: holes_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE public.holes_id_seq
|
|
||||||
AS integer
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.holes_id_seq OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: holes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE public.holes_id_seq OWNED BY public.holes.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tees; Type: TABLE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.tees (
|
|
||||||
id integer NOT NULL,
|
|
||||||
course_id integer,
|
|
||||||
name character varying(50) NOT NULL,
|
|
||||||
cr_men numeric(4,1),
|
|
||||||
slope_men integer,
|
|
||||||
cr_women numeric(4,1),
|
|
||||||
slope_women integer
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.tees OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tees_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE public.tees_id_seq
|
|
||||||
AS integer
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.tees_id_seq OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tees_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE public.tees_id_seq OWNED BY public.tees.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: admins id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.admins ALTER COLUMN id SET DEFAULT nextval('public.admins_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: courses id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.courses ALTER COLUMN id SET DEFAULT nextval('public.courses_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facilities id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.facilities ALTER COLUMN id SET DEFAULT nextval('public.facilities_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facility_images id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.facility_images ALTER COLUMN id SET DEFAULT nextval('public.facility_images_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: hole_lengths id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.hole_lengths ALTER COLUMN id SET DEFAULT nextval('public.hole_lengths_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: holes id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.holes ALTER COLUMN id SET DEFAULT nextval('public.holes_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tees id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.tees ALTER COLUMN id SET DEFAULT nextval('public.tees_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: admins admins_email_key; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.admins
|
|
||||||
ADD CONSTRAINT admins_email_key UNIQUE (email);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: admins admins_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.admins
|
|
||||||
ADD CONSTRAINT admins_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: admins admins_username_key; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.admins
|
|
||||||
ADD CONSTRAINT admins_username_key UNIQUE (username);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: courses courses_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.courses
|
|
||||||
ADD CONSTRAINT courses_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facilities facilities_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.facilities
|
|
||||||
ADD CONSTRAINT facilities_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facilities facilities_slug_key; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.facilities
|
|
||||||
ADD CONSTRAINT facilities_slug_key UNIQUE (slug);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facility_images facility_images_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.facility_images
|
|
||||||
ADD CONSTRAINT facility_images_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: hole_lengths hole_lengths_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.hole_lengths
|
|
||||||
ADD CONSTRAINT hole_lengths_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: holes holes_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.holes
|
|
||||||
ADD CONSTRAINT holes_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tees tees_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.tees
|
|
||||||
ADD CONSTRAINT tees_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facilities unique_slug; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.facilities
|
|
||||||
ADD CONSTRAINT unique_slug UNIQUE (slug);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: courses courses_facility_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.courses
|
|
||||||
ADD CONSTRAINT courses_facility_id_fkey FOREIGN KEY (facility_id) REFERENCES public.facilities(id) ON DELETE CASCADE;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: hole_lengths hole_lengths_hole_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.hole_lengths
|
|
||||||
ADD CONSTRAINT hole_lengths_hole_id_fkey FOREIGN KEY (hole_id) REFERENCES public.holes(id) ON DELETE CASCADE;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: hole_lengths hole_lengths_tee_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.hole_lengths
|
|
||||||
ADD CONSTRAINT hole_lengths_tee_id_fkey FOREIGN KEY (tee_id) REFERENCES public.tees(id) ON DELETE CASCADE;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: holes holes_course_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.holes
|
|
||||||
ADD CONSTRAINT holes_course_id_fkey FOREIGN KEY (course_id) REFERENCES public.courses(id) ON DELETE CASCADE;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tees tees_course_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.tees
|
|
||||||
ADD CONSTRAINT tees_course_id_fkey FOREIGN KEY (course_id) REFERENCES public.courses(id) ON DELETE CASCADE;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- PostgreSQL database dump complete
|
|
||||||
--
|
|
||||||
|
|
||||||
|
|
@ -1,639 +0,0 @@
|
||||||
--
|
|
||||||
-- PostgreSQL database dump
|
|
||||||
--
|
|
||||||
|
|
||||||
-- Dumped from database version 15.8 (Debian 15.8-1.pgdg110+1)
|
|
||||||
-- Dumped by pg_dump version 15.8 (Debian 15.8-1.pgdg110+1)
|
|
||||||
|
|
||||||
SET statement_timeout = 0;
|
|
||||||
SET lock_timeout = 0;
|
|
||||||
SET idle_in_transaction_session_timeout = 0;
|
|
||||||
SET client_encoding = 'UTF8';
|
|
||||||
SET standard_conforming_strings = on;
|
|
||||||
SELECT pg_catalog.set_config('search_path', '', false);
|
|
||||||
SET check_function_bodies = false;
|
|
||||||
SET xmloption = content;
|
|
||||||
SET client_min_messages = warning;
|
|
||||||
SET row_security = off;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tiger; Type: SCHEMA; Schema: -; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SCHEMA tiger;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER SCHEMA tiger OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tiger_data; Type: SCHEMA; Schema: -; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SCHEMA tiger_data;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER SCHEMA tiger_data OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: topology; Type: SCHEMA; Schema: -; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SCHEMA topology;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER SCHEMA topology OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: SCHEMA topology; Type: COMMENT; Schema: -; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON SCHEMA topology IS 'PostGIS Topology schema';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: fuzzystrmatch; Type: EXTENSION; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS fuzzystrmatch WITH SCHEMA public;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: EXTENSION fuzzystrmatch; Type: COMMENT; Schema: -; Owner:
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON EXTENSION fuzzystrmatch IS 'determine similarities and distance between strings';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: postgis; Type: EXTENSION; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS postgis WITH SCHEMA public;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: EXTENSION postgis; Type: COMMENT; Schema: -; Owner:
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON EXTENSION postgis IS 'PostGIS geometry and geography spatial types and functions';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: postgis_tiger_geocoder; Type: EXTENSION; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS postgis_tiger_geocoder WITH SCHEMA tiger;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: EXTENSION postgis_tiger_geocoder; Type: COMMENT; Schema: -; Owner:
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON EXTENSION postgis_tiger_geocoder IS 'PostGIS tiger geocoder and reverse geocoder';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: postgis_topology; Type: EXTENSION; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS postgis_topology WITH SCHEMA topology;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: EXTENSION postgis_topology; Type: COMMENT; Schema: -; Owner:
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON EXTENSION postgis_topology IS 'PostGIS topology spatial types and functions';
|
|
||||||
|
|
||||||
|
|
||||||
SET default_tablespace = '';
|
|
||||||
|
|
||||||
SET default_table_access_method = heap;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: admins; Type: TABLE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.admins (
|
|
||||||
id integer NOT NULL,
|
|
||||||
username character varying(50) NOT NULL,
|
|
||||||
password_hash text NOT NULL,
|
|
||||||
otp_secret character varying(32),
|
|
||||||
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
email character varying(255)
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.admins OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: admins_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE public.admins_id_seq
|
|
||||||
AS integer
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.admins_id_seq OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: admins_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE public.admins_id_seq OWNED BY public.admins.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: courses; Type: TABLE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.courses (
|
|
||||||
id integer NOT NULL,
|
|
||||||
facility_id integer,
|
|
||||||
name character varying(255) NOT NULL,
|
|
||||||
holes integer,
|
|
||||||
par integer,
|
|
||||||
length_meters integer,
|
|
||||||
course_type character varying(255),
|
|
||||||
architect character varying(255),
|
|
||||||
status character varying(255),
|
|
||||||
is_main_course boolean DEFAULT true,
|
|
||||||
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
tee_boxes jsonb,
|
|
||||||
scrape_keyword text,
|
|
||||||
slope_valid_until date
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.courses OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: courses_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE public.courses_id_seq
|
|
||||||
AS integer
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.courses_id_seq OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: courses_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE public.courses_id_seq OWNED BY public.courses.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facilities; Type: TABLE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.facilities (
|
|
||||||
id integer NOT NULL,
|
|
||||||
name character varying(255) NOT NULL,
|
|
||||||
slug character varying(255) NOT NULL,
|
|
||||||
description text,
|
|
||||||
established_year integer,
|
|
||||||
season character varying(255),
|
|
||||||
address character varying(255),
|
|
||||||
zipcode character varying(50),
|
|
||||||
city character varying(255),
|
|
||||||
county character varying(255),
|
|
||||||
lat double precision,
|
|
||||||
lng double precision,
|
|
||||||
email character varying(255),
|
|
||||||
phone character varying(255),
|
|
||||||
website_url character varying(255),
|
|
||||||
golfbox_booking_url character varying(255),
|
|
||||||
golfbox_tournament_url character varying(255),
|
|
||||||
facebook_url character varying(255),
|
|
||||||
instagram_url character varying(255),
|
|
||||||
weather_url character varying(255),
|
|
||||||
webcam_url character varying(255),
|
|
||||||
golfamore boolean DEFAULT false,
|
|
||||||
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
image_url character varying(500),
|
|
||||||
amenities jsonb,
|
|
||||||
greenfee jsonb,
|
|
||||||
architect text,
|
|
||||||
membership jsonb,
|
|
||||||
vtg jsonb,
|
|
||||||
video_url text,
|
|
||||||
baneguide_url text,
|
|
||||||
logo_url text,
|
|
||||||
flyfoto_url text,
|
|
||||||
guest_requirements text,
|
|
||||||
status_updated_at date,
|
|
||||||
gallery jsonb,
|
|
||||||
faqs jsonb DEFAULT '[]'::jsonb,
|
|
||||||
shotzoom jsonb DEFAULT '[]'::jsonb,
|
|
||||||
front_image_url text,
|
|
||||||
nsg_url text,
|
|
||||||
nsg_description text,
|
|
||||||
nsg_data jsonb DEFAULT '{}'::jsonb,
|
|
||||||
golfamore_data jsonb DEFAULT '{}'::jsonb,
|
|
||||||
ngf_number integer,
|
|
||||||
golfbox_club_id integer,
|
|
||||||
golfbox_booking_id text,
|
|
||||||
facebook_id text,
|
|
||||||
instagram_place_id text,
|
|
||||||
tournament_url text,
|
|
||||||
footnote text,
|
|
||||||
social_links jsonb DEFAULT '[]'::jsonb,
|
|
||||||
webcam_html text,
|
|
||||||
length_meters integer,
|
|
||||||
navn_standard_medlemskap text,
|
|
||||||
standard_medlemskap integer,
|
|
||||||
standard_medlemskap_kommentarer text,
|
|
||||||
navn_rimeligste_alternativ text,
|
|
||||||
rimeligste_alternativ integer,
|
|
||||||
rimeligste_alternativ_kommentarer text,
|
|
||||||
medlemskap_url text,
|
|
||||||
banetype text,
|
|
||||||
scrape_status_url text,
|
|
||||||
scrape_status_selector text,
|
|
||||||
scrape_method character varying(50) DEFAULT 'css_selector'::character varying,
|
|
||||||
ai_instruction text,
|
|
||||||
golfpakker jsonb DEFAULT '[]'::jsonb,
|
|
||||||
rabattert_greenfee jsonb DEFAULT '[]'::jsonb,
|
|
||||||
vtg_presentasjon text,
|
|
||||||
vtg_lenke text,
|
|
||||||
vtg_pris integer,
|
|
||||||
vtg_kursdatoer jsonb DEFAULT '[]'::jsonb,
|
|
||||||
membership_draft jsonb,
|
|
||||||
membership_updated_at timestamp with time zone,
|
|
||||||
greenfee_url character varying(255) DEFAULT NULL::character varying,
|
|
||||||
greenfee_draft jsonb,
|
|
||||||
greenfee_updated_at timestamp with time zone,
|
|
||||||
vtg_beskrivelse text,
|
|
||||||
vtg_datoer jsonb,
|
|
||||||
vtg_draft jsonb,
|
|
||||||
vtg_updated_at timestamp with time zone
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.facilities OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facilities_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE public.facilities_id_seq
|
|
||||||
AS integer
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.facilities_id_seq OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facilities_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE public.facilities_id_seq OWNED BY public.facilities.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facility_images; Type: TABLE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.facility_images (
|
|
||||||
id integer NOT NULL,
|
|
||||||
facility_id integer,
|
|
||||||
image_url character varying(255) NOT NULL,
|
|
||||||
display_order integer DEFAULT 0,
|
|
||||||
sort_order integer DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.facility_images OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facility_images_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE public.facility_images_id_seq
|
|
||||||
AS integer
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.facility_images_id_seq OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facility_images_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE public.facility_images_id_seq OWNED BY public.facility_images.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: hole_lengths; Type: TABLE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.hole_lengths (
|
|
||||||
id integer NOT NULL,
|
|
||||||
hole_id integer,
|
|
||||||
tee_id integer,
|
|
||||||
length_meters integer
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.hole_lengths OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: hole_lengths_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE public.hole_lengths_id_seq
|
|
||||||
AS integer
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.hole_lengths_id_seq OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: hole_lengths_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE public.hole_lengths_id_seq OWNED BY public.hole_lengths.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: holes; Type: TABLE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.holes (
|
|
||||||
id integer NOT NULL,
|
|
||||||
course_id integer,
|
|
||||||
hole_number integer NOT NULL,
|
|
||||||
par integer,
|
|
||||||
hcp_index integer,
|
|
||||||
lengths jsonb
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.holes OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: holes_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE public.holes_id_seq
|
|
||||||
AS integer
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.holes_id_seq OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: holes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE public.holes_id_seq OWNED BY public.holes.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tees; Type: TABLE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.tees (
|
|
||||||
id integer NOT NULL,
|
|
||||||
course_id integer,
|
|
||||||
name character varying(50) NOT NULL,
|
|
||||||
cr_men numeric(4,1),
|
|
||||||
slope_men integer,
|
|
||||||
cr_women numeric(4,1),
|
|
||||||
slope_women integer
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.tees OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tees_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE public.tees_id_seq
|
|
||||||
AS integer
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.tees_id_seq OWNER TO teeoff_admin;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tees_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE public.tees_id_seq OWNED BY public.tees.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: admins id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.admins ALTER COLUMN id SET DEFAULT nextval('public.admins_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: courses id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.courses ALTER COLUMN id SET DEFAULT nextval('public.courses_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facilities id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.facilities ALTER COLUMN id SET DEFAULT nextval('public.facilities_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facility_images id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.facility_images ALTER COLUMN id SET DEFAULT nextval('public.facility_images_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: hole_lengths id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.hole_lengths ALTER COLUMN id SET DEFAULT nextval('public.hole_lengths_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: holes id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.holes ALTER COLUMN id SET DEFAULT nextval('public.holes_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tees id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.tees ALTER COLUMN id SET DEFAULT nextval('public.tees_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: admins admins_email_key; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.admins
|
|
||||||
ADD CONSTRAINT admins_email_key UNIQUE (email);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: admins admins_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.admins
|
|
||||||
ADD CONSTRAINT admins_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: admins admins_username_key; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.admins
|
|
||||||
ADD CONSTRAINT admins_username_key UNIQUE (username);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: courses courses_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.courses
|
|
||||||
ADD CONSTRAINT courses_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facilities facilities_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.facilities
|
|
||||||
ADD CONSTRAINT facilities_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facilities facilities_slug_key; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.facilities
|
|
||||||
ADD CONSTRAINT facilities_slug_key UNIQUE (slug);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facility_images facility_images_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.facility_images
|
|
||||||
ADD CONSTRAINT facility_images_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: hole_lengths hole_lengths_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.hole_lengths
|
|
||||||
ADD CONSTRAINT hole_lengths_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: holes holes_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.holes
|
|
||||||
ADD CONSTRAINT holes_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tees tees_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.tees
|
|
||||||
ADD CONSTRAINT tees_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: facilities unique_slug; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.facilities
|
|
||||||
ADD CONSTRAINT unique_slug UNIQUE (slug);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: courses courses_facility_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.courses
|
|
||||||
ADD CONSTRAINT courses_facility_id_fkey FOREIGN KEY (facility_id) REFERENCES public.facilities(id) ON DELETE CASCADE;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: hole_lengths hole_lengths_hole_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.hole_lengths
|
|
||||||
ADD CONSTRAINT hole_lengths_hole_id_fkey FOREIGN KEY (hole_id) REFERENCES public.holes(id) ON DELETE CASCADE;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: hole_lengths hole_lengths_tee_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.hole_lengths
|
|
||||||
ADD CONSTRAINT hole_lengths_tee_id_fkey FOREIGN KEY (tee_id) REFERENCES public.tees(id) ON DELETE CASCADE;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: holes holes_course_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.holes
|
|
||||||
ADD CONSTRAINT holes_course_id_fkey FOREIGN KEY (course_id) REFERENCES public.courses(id) ON DELETE CASCADE;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: tees tees_course_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.tees
|
|
||||||
ADD CONSTRAINT tees_course_id_fkey FOREIGN KEY (course_id) REFERENCES public.courses(id) ON DELETE CASCADE;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- PostgreSQL database dump complete
|
|
||||||
--
|
|
||||||
|
|
||||||
Loading…
Reference in a new issue