diff --git a/2026-04-18 16.34.15 teeoff.no acc663ad88cd.jpg b/2026-04-18 16.34.15 teeoff.no acc663ad88cd.jpg deleted file mode 100644 index 33a3937..0000000 Binary files a/2026-04-18 16.34.15 teeoff.no acc663ad88cd.jpg and /dev/null differ diff --git a/2026-04-19 13.30.00 teeoff.no 44994b2e2831.jpg b/2026-04-19 13.30.00 teeoff.no 44994b2e2831.jpg new file mode 100644 index 0000000..3519f92 Binary files /dev/null and b/2026-04-19 13.30.00 teeoff.no 44994b2e2831.jpg differ diff --git a/backend/main.py b/backend/main.py index 448f3e8..d2f8504 100644 --- a/backend/main.py +++ b/backend/main.py @@ -476,6 +476,43 @@ async def send_contact_form_email( await asyncio.to_thread(_send) +async def send_facility_feedback_email( + *, + facility_name: str, + facility_slug: str, + sender_name: str, + sender_email: str, + message: str, + ip_hash: str | None, +) -> None: + subject = f"[TeeOff Golfbane] Vedrørende {facility_name}" + facility_url = f"https://teeoff.no/golfbaner/{facility_slug}" if facility_slug else "ukjent" + body = ( + "Ny tilbakemelding fra baneprofil på TeeOff.no\n\n" + f"Golfanlegg: {facility_name}\n" + f"Side: {facility_url}\n" + f"Navn: {sender_name}\n" + f"E-post: {sender_email}\n" + f"IP-hash: {ip_hash or 'ukjent'}\n\n" + "Melding:\n" + f"{message.strip()}\n" + ) + + def _send() -> None: + mail = EmailMessage() + mail["From"] = PUBLIC_FROM_EMAIL + mail["To"] = CONTACT_FORM_TO_EMAIL + mail["Reply-To"] = sender_email + mail["Subject"] = subject + mail.set_content(body) + + with smtplib.SMTP_SSL(SMTP_SERVER, int(SMTP_PORT)) as server: + server.login(SMTP_USER, SMTP_PASS) + server.send_message(mail) + + await asyncio.to_thread(_send) + + async def send_comment_notification_email( *, article_title: str, @@ -628,6 +665,15 @@ class PublicContactFormRequest(BaseModel): message: str website: Optional[str] = "" started_at: Optional[int] = None + + +class PublicFacilityFeedbackRequest(BaseModel): + facility_id: int + name: str + email: str + message: str + website: Optional[str] = "" + started_at: Optional[int] = None # --- FUNKSJONER --- def format_row(row): """ @@ -1345,6 +1391,7 @@ async def ensure_facility_columns(conn): await conn.execute(""" ALTER TABLE facilities ADD COLUMN IF NOT EXISTS footnote_updated_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS golfamore_url TEXT, ADD COLUMN IF NOT EXISTS golfpakker_url TEXT, ADD COLUMN IF NOT EXISTS golfpakker_draft JSONB, ADD COLUMN IF NOT EXISTS golfpakker_updated_at TIMESTAMPTZ @@ -1926,6 +1973,86 @@ async def submit_public_contact_form(request: Request, payload: PublicContactFor } +@app.post("/api/public/facility-feedback") +async def submit_public_facility_feedback(request: Request, payload: PublicFacilityFeedbackRequest): + if not is_contact_form_configured(): + raise HTTPException(status_code=503, detail="Skjemaet er ikke konfigurert ennå.") + + if str(payload.website or "").strip(): + return { + "status": "success", + "detail": "Takk for meldingen. Vi ser på innspillet så snart vi kan.", + } + + name = str(payload.name or "").strip() + email = normalize_public_email(payload.email) + message = str(payload.message or "").strip() + + if len(name) < 2 or len(name) > 120: + raise HTTPException(status_code=400, detail="Oppgi et gyldig navn.") + if not email or "@" not in email or len(email) > 255: + raise HTTPException(status_code=400, detail="Oppgi en gyldig e-postadresse.") + if len(message) < 20 or len(message) > 5000: + raise HTTPException(status_code=400, detail="Meldingen må være mellom 20 og 5000 tegn.") + + now_ts = int(datetime.utcnow().timestamp()) + if payload.started_at and now_ts - int(payload.started_at) < CONTACT_FORM_MIN_FILL_SECONDS: + return { + "status": "success", + "detail": "Takk for meldingen. Vi ser på innspillet så snart vi kan.", + } + + async with app.state.pool.acquire() as conn: + facility = await conn.fetchrow( + "SELECT id, name, slug FROM facilities WHERE id = $1", + payload.facility_id, + ) + + if not facility: + raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet.") + + ip_hash = hash_request_ip(request) + tracker: dict[str, list[int]] = getattr(app.state, "contact_submission_tracker", {}) + cutoff = now_ts - CONTACT_FORM_RATE_LIMIT_WINDOW_SECONDS + + for key in list(tracker.keys()): + recent = [ts for ts in tracker.get(key, []) if ts >= cutoff] + if recent: + tracker[key] = recent + else: + tracker.pop(key, None) + + rate_keys = [f"email:{email}"] + if ip_hash: + rate_keys.append(f"ip:{ip_hash}") + + for key in rate_keys: + attempts = tracker.get(key, []) + if len(attempts) >= CONTACT_FORM_RATE_LIMIT_MAX_SUBMISSIONS: + raise HTTPException( + status_code=429, + detail="For mange meldinger på kort tid. Prøv igjen senere.", + ) + + await send_facility_feedback_email( + facility_name=str(facility["name"] or "").strip() or "ukjent golfanlegg", + facility_slug=str(facility["slug"] or "").strip(), + sender_name=name, + sender_email=email, + message=message, + ip_hash=ip_hash, + ) + + for key in rate_keys: + tracker.setdefault(key, []).append(now_ts) + app.state.contact_submission_tracker = tracker + + return { + "status": "success", + "detail": "Takk for meldingen. Vi ser på innspillet så snart vi kan.", + } + + @app.get("/api/public/auth/magic-link/verify") async def verify_magic_link( request: Request, @@ -2650,7 +2777,7 @@ async def update_facility_full(facility_id: int, request: Request): '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', + 'nsg_url', 'nsg_data', 'golfamore', 'golfamore_url', 'golfamore_data', 'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer', 'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url', 'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris', 'vtg_datoer', diff --git a/frontend/src/app/FacilitySearch.tsx b/frontend/src/app/FacilitySearch.tsx index da184c4..b8fca0d 100755 --- a/frontend/src/app/FacilitySearch.tsx +++ b/frontend/src/app/FacilitySearch.tsx @@ -32,6 +32,7 @@ type Facility = { lat?: number | null; lng?: number | null; golfamore?: boolean | null; + golfamore_url?: string | null; nsg_url?: string | null; vtg_pris?: number | null; vtg_lenke?: string | null; @@ -511,7 +512,10 @@ export default function FacilitySearch({ const holeValue = String(amenities.antall_hull || "").trim(); const primaryStatus = getPrimaryStatus(statuses); const normalizedStatuses = statuses.map((status) => normalizeStatus(status.status)); - const hasGolfamore = facility.golfamore === true || Object.keys(golfamoreData).length > 0; + const hasGolfamore = + facility.golfamore === true || + Boolean(facility.golfamore_url) || + Object.keys(golfamoreData).length > 0; const hasNSG = Boolean(facility.nsg_url) || Object.keys(nsgData).length > 0; const hasSimulator = hasTruthyAmenity(amenities.simulator); const hasDrivingRange = hasTruthyAmenity(amenities.drivingrange); diff --git a/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx b/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx index 16876f5..b459cc9 100644 --- a/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx +++ b/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx @@ -422,7 +422,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini { id: 'generelt', label: 'Generelt' }, { id: 'lokasjon', label: 'Lokasjon & Kontakt' }, { id: 'linker', label: 'Lenker & Media' }, - { id: 'okonomi', label: 'Økonomi & Medlemskap' }, + { id: 'okonomi', label: 'Økonomi, medlemskap og fasiliteter' }, { id: 'baner', label: 'Baner & Scorekort' } ]; @@ -635,8 +635,22 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
+ Hold Informasjonen Ryddig +
++ Bruk gjerne skjemaet her dersom du ser noe må endres, fjernes eller legges til + når det gjelder presentasjonen av {facilityName} på TeeOff. +
+