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
handleChange('amenities', v)} /> - handleChange('nsg_data', v)} /> - handleChange('golfamore_data', v)} /> +
+ +
+ + handleChange('nsg_url', e.target.value)} /> +
+ handleChange('nsg_data', v)} /> +
+
+ +
+ + handleChange('golfamore_url', e.target.value)} /> +
+ handleChange('golfamore_data', v)} /> +
handleChange('golfpakker_url', e.target.value)} placeholder="Tomt felt bruker ordinær nettside som fallback" /> diff --git a/frontend/src/app/facilityData.ts b/frontend/src/app/facilityData.ts index f53da11..249f7bf 100755 --- a/frontend/src/app/facilityData.ts +++ b/frontend/src/app/facilityData.ts @@ -25,6 +25,7 @@ export type FacilityRecord = { 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; @@ -249,7 +250,7 @@ export const enrichFacilities = ( regions, statuses, primaryStatus: getPrimaryStatus(statuses), - hasGolfamore: facility.golfamore === true || Object.keys(golfamoreData).length > 0, + hasGolfamore: facility.golfamore === true || Boolean(facility.golfamore_url) || Object.keys(golfamoreData).length > 0, hasNSG: Boolean(facility.nsg_url) || Object.keys(nsgData).length > 0, hasSimulator: hasTruthyAmenity(amenities.simulator), hasDrivingRange: hasTruthyAmenity(amenities.drivingrange), diff --git a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx index cccb295..9d75962 100644 --- a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx +++ b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx @@ -19,6 +19,7 @@ import { STATUS_MAP, FALLBACK_IMAGE } from "@/config/constants"; import { STATUS_ICON_PATHS, buildMapUrl, getPrimaryStatus, parseJson as parseSharedJson, slugify } from "@/app/facilityData"; import Link from 'next/link'; import CourseDisplay from './CourseDisplay'; +import FacilityFeedbackForm from './FacilityFeedbackForm'; const FacilityDetailLeafletMap = dynamic(() => import("./FacilityDetailLeafletMap"), { ssr: false, @@ -35,13 +36,30 @@ const formatPhoneForUrl = (phone: string) => { return normalized.startsWith("00") ? `+${normalized.slice(2)}` : normalized; }; -const renderValue = (val: string) => { - if (!val) return "Nei"; - const hasLink = val.includes(' { + const raw = String(val || "").trim(); + if (!raw) return fallback; + + const hasInlineHtml = /<\s*a\b/i.test(raw) || /<\s*(strong|b|em|i|br)\b/i.test(raw); + const html = hasInlineHtml + ? sanitizeRichText(raw) + : raw.replace( + /((?:https?:\/\/|mailto:|tel:|www\.)[^\s<]+)/gi, + (match: string) => { + const href = sanitizeHref(match.startsWith("www.") ? `https://${match}` : match); + const escapedHref = escapeHtml(href); + const escapedLabel = escapeHtml(match); + if (isInternalTeeoffHref(href)) { + return `${escapedLabel}`; + } + return `${escapedLabel}`; + } + ); + const hasLink = html.includes(" ); }; @@ -185,7 +203,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) { const coopClubsRaw = parseJson(facility.cooperating_clubs, []); const cooperatingClubs = Array.isArray(coopClubsRaw) ? coopClubsRaw : []; - const hasGolfamore = facility.golfamore === true; + const hasGolfamore = facility.golfamore === true || Boolean(facility.golfamore_url) || Object.keys(golfamoreData).length > 0; const hasNSG = facility.nsg_url || (nsgData && Object.keys(nsgData).length > 0); const hasVtg = Boolean( facility.vtg_pris || @@ -448,11 +466,11 @@ export default function FacilityDetailView({ facility }: { facility: any }) {

Andre Tilbud

-
Drivingrange:{amenities.drivingrange || 'Nei'}
-
Nærspill:{amenities.treningsgreen || 'Ja'}
+
Drivingrange:{renderValue(amenities.drivingrange, 'Nei')}
+
Nærspill:{renderValue(amenities.treningsgreen, 'Ja')}
Proshop:{renderValue(amenities.proshop)}
-
Kølleutleie:{amenities.kolleutleie || 'Ja'}
-
Bilutleie:{amenities.bilutleie || 'Nei'}
+
Kølleutleie:{renderValue(amenities.kolleutleie, 'Ja')}
+
Bilutleie:{renderValue(amenities.bilutleie, 'Nei')}
Simulator:{renderValue(amenities.simulator)}
Kafé:{renderValue(amenities.kafe)}
@@ -460,15 +478,26 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
Seniorgolf (NSG): {hasNSG && facility.nsg_url - ? Ja (Vis Avtale) - : (hasNSG ? Ja : "Nei") + ? Ja (Vis avtale) + : (hasNSG ? Ja : "Nei") }
@@ -738,6 +767,11 @@ export default function FacilityDetailView({ facility }: { facility: any }) { ))}
+ +
{showBackToTop && ( diff --git a/frontend/src/app/golfbaner/[slug]/FacilityFeedbackForm.tsx b/frontend/src/app/golfbaner/[slug]/FacilityFeedbackForm.tsx new file mode 100644 index 0000000..ee6dd70 --- /dev/null +++ b/frontend/src/app/golfbaner/[slug]/FacilityFeedbackForm.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useState } from "react"; + +type SubmitState = "idle" | "success" | "error"; + +type FacilityFeedbackFormProps = { + facilityId: number; + facilityName: string; +}; + +export default function FacilityFeedbackForm({ + facilityId, + facilityName, +}: FacilityFeedbackFormProps) { + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [message, setMessage] = useState(""); + const [website, setWebsite] = useState(""); + const [submitState, setSubmitState] = useState("idle"); + const [submitMessage, setSubmitMessage] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [startedAt] = useState(() => Math.floor(Date.now() / 1000)); + + const resetForm = () => { + setName(""); + setEmail(""); + setMessage(""); + setWebsite(""); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setIsSubmitting(true); + setSubmitState("idle"); + setSubmitMessage(""); + + try { + const response = await fetch("/api/public/facility-feedback", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + facility_id: facilityId, + name, + email, + message, + website, + started_at: startedAt, + }), + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(String(payload?.detail || "Noe gikk galt ved sending.")); + } + + setSubmitState("success"); + setSubmitMessage(String(payload?.detail || "Takk for meldingen.")); + resetForm(); + } catch (error) { + setSubmitState("error"); + setSubmitMessage(error instanceof Error ? error.message : "Noe gikk galt ved sending."); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+
+

+ Hold Informasjonen Ryddig +

+

+ Ser du noe som bør endres? +

+

+ Bruk gjerne skjemaet her dersom du ser noe må endres, fjernes eller legges til + når det gjelder presentasjonen av {facilityName} på TeeOff. +

+
+ +
+
+ + + +
+ +