Etter lenker til golfamore og nsg

This commit is contained in:
Erol Haagenrud 2026-04-19 13:35:48 +02:00
parent d3a967c664
commit 90f833e17b
8 changed files with 362 additions and 18 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -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',

View file

@ -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);

View file

@ -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
<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)} />
<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]">Norsk Seniorgolf (NSG)</label>
<div className="flex flex-col gap-2">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">NSG 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('nsg_url', 'text')} onChange={e => handleChange('nsg_url', e.target.value)} />
</div>
<KeyValueEditor label="NSG Info" value={formData.nsg_data} onChange={(v) => handleChange('nsg_data', v)} />
</div>
<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]">Golfamore</label>
<div className="flex flex-col gap-2">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Golfamore 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('golfamore_url', 'text')} onChange={e => handleChange('golfamore_url', e.target.value)} />
</div>
<KeyValueEditor label="Golfamore Info" value={formData.golfamore_data} onChange={(v) => handleChange('golfamore_data', v)} />
</div>
<div className="flex flex-col gap-2 mb-8">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Lenke til Golfpakker-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('golfpakker_url', 'text')} onChange={e => handleChange('golfpakker_url', e.target.value)} placeholder="Tomt felt bruker ordinær nettside som fallback" />

View file

@ -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),

View file

@ -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('<a');
const renderValue = (val: unknown, fallback = "Nei") => {
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 `<a href="${escapedHref}">${escapedLabel}</a>`;
}
return `<a href="${escapedHref}" target="_blank" rel="noreferrer noopener">${escapedLabel}</a>`;
}
);
const hasLink = html.includes("<a ");
return (
<span
className={hasLink ? "text-[#ff5722] font-bold hover:underline" : "text-[#11280f]"}
dangerouslySetInnerHTML={{ __html: val }}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
};
@ -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 }) {
<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">Drivingrange:</span><span className="text-right ml-4">{renderValue(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 className="text-right ml-4">{renderValue(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">Kølleutleie:</span><span className="text-right ml-4">{renderValue(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 className="text-right ml-4">{renderValue(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>
@ -460,15 +478,26 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
<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"}
{hasGolfamore && facility.golfamore_url ? (
<a
href={facility.golfamore_url}
target="_blank"
rel="noopener noreferrer"
className="font-black text-[#ff5722] transition-colors hover:underline"
>
{golfamoreData.terms || golfamoreData.gyldighet || "Ja"}
</a>
) : hasGolfamore ? (
<span className="text-[#ff5722] font-black">{golfamoreData.terms || 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="font-black text-blue-600 transition-colors hover:text-[#ff5722] hover:underline">Ja (Vis Avtale)</a>
: (hasNSG ? <span className="text-blue-600 font-black">Ja</span> : "Nei")
? <a href={facility.nsg_url} target="_blank" rel="noopener noreferrer" className="font-black text-[#ff5722] transition-colors hover:underline">Ja (Vis avtale)</a>
: (hasNSG ? <span className="text-[#ff5722] font-black">Ja</span> : "Nei")
}
</span>
</div>
@ -738,6 +767,11 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
))}
</div>
</section>
<FacilityFeedbackForm
facilityId={Number(facility.id)}
facilityName={String(facility.name || "dette golfanlegget")}
/>
</div>
{showBackToTop && (

View file

@ -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<SubmitState>("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<HTMLFormElement>) => {
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 (
<section className="pt-10">
<div className="overflow-hidden rounded-[2.4rem] border border-[#FFD4C5] bg-gradient-to-br from-[#FFF6F1] via-[#FFFDFB] to-[#F4F7EE] shadow-sm">
<div className="border-b border-[#FFD4C5] bg-[#FFEEE6] px-6 py-5 sm:px-8">
<p className="text-[11px] font-black uppercase tracking-[0.28em] text-[#C96C49]">
Hold Informasjonen Ryddig
</p>
<h2 className="mt-2 text-2xl font-black text-[#112015] sm:text-3xl">
Ser du noe som bør endres?
</h2>
<p className="mt-3 max-w-3xl text-sm leading-6 text-[#5A5F58]">
Bruk gjerne skjemaet her dersom du ser noe endres, fjernes eller legges til
når det gjelder presentasjonen av {facilityName} TeeOff.
</p>
</div>
<form onSubmit={handleSubmit} className="px-6 py-6 sm:px-8 sm:py-8">
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-2">
<span className="text-[11px] font-black uppercase tracking-[0.18em] text-[#6A766C]">
Navn
</span>
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
required
className="rounded-[1rem] border border-[#112015]/12 bg-white px-4 py-3 text-sm font-bold text-[#112015] outline-none transition focus:border-[#FF8C5A]"
/>
</label>
<label className="flex flex-col gap-2">
<span className="text-[11px] font-black uppercase tracking-[0.18em] text-[#6A766C]">
E-post
</span>
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
className="rounded-[1rem] border border-[#112015]/12 bg-white px-4 py-3 text-sm font-bold text-[#112015] outline-none transition focus:border-[#FF8C5A]"
/>
</label>
</div>
<label className="mt-4 flex flex-col gap-2">
<span className="text-[11px] font-black uppercase tracking-[0.18em] text-[#6A766C]">
Melding
</span>
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
required
rows={7}
className="rounded-[1rem] border border-[#112015]/12 bg-white px-4 py-3 text-sm font-bold leading-6 text-[#112015] outline-none transition focus:border-[#FF8C5A]"
placeholder="Beskriv hva som bør endres, legges til eller slettes."
/>
</label>
<label className="hidden" aria-hidden="true">
Website
<input
type="text"
tabIndex={-1}
autoComplete="off"
value={website}
onChange={(event) => setWebsite(event.target.value)}
/>
</label>
{submitMessage ? (
<div
className={`mt-5 rounded-[1.25rem] px-4 py-3 text-sm font-bold ${
submitState === "success"
? "bg-[#F4F7EE] text-[#112015]"
: "bg-[#FFF1E8] text-[#8F3B18]"
}`}
>
{submitMessage}
</div>
) : null}
<div className="mt-6 flex flex-wrap items-center gap-3">
<button type="submit" disabled={isSubmitting} className="btn btn-md btn-primary">
{isSubmitting ? "Sender..." : "Send innspill"}
</button>
<p className="text-xs font-bold text-[#6A766C]">
Vi bruker e-postadressen din hvis vi trenger oppfølging.
</p>
</div>
</form>
</div>
</section>
);
}