Etter lenker til golfamore og nsg
This commit is contained in:
parent
d3a967c664
commit
90f833e17b
8 changed files with 362 additions and 18 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB |
BIN
2026-04-19 13.30.00 teeoff.no 44994b2e2831.jpg
Normal file
BIN
2026-04-19 13.30.00 teeoff.no 44994b2e2831.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
129
backend/main.py
129
backend/main.py
|
|
@ -476,6 +476,43 @@ async def send_contact_form_email(
|
||||||
await asyncio.to_thread(_send)
|
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(
|
async def send_comment_notification_email(
|
||||||
*,
|
*,
|
||||||
article_title: str,
|
article_title: str,
|
||||||
|
|
@ -628,6 +665,15 @@ class PublicContactFormRequest(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
website: Optional[str] = ""
|
website: Optional[str] = ""
|
||||||
started_at: Optional[int] = None
|
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 ---
|
# --- FUNKSJONER ---
|
||||||
def format_row(row):
|
def format_row(row):
|
||||||
"""
|
"""
|
||||||
|
|
@ -1345,6 +1391,7 @@ async def ensure_facility_columns(conn):
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
ALTER TABLE facilities
|
ALTER TABLE facilities
|
||||||
ADD COLUMN IF NOT EXISTS footnote_updated_at TIMESTAMPTZ,
|
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_url TEXT,
|
||||||
ADD COLUMN IF NOT EXISTS golfpakker_draft JSONB,
|
ADD COLUMN IF NOT EXISTS golfpakker_draft JSONB,
|
||||||
ADD COLUMN IF NOT EXISTS golfpakker_updated_at TIMESTAMPTZ
|
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")
|
@app.get("/api/public/auth/magic-link/verify")
|
||||||
async def verify_magic_link(
|
async def verify_magic_link(
|
||||||
request: Request,
|
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',
|
'email', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url',
|
||||||
'weather_url', 'webcam_url', 'video_url', 'baneguide_url', 'flyfoto_url',
|
'weather_url', 'webcam_url', 'video_url', 'baneguide_url', 'flyfoto_url',
|
||||||
'amenities', 'greenfee', 'golfpakker', 'rabattert_greenfee',
|
'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_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
|
||||||
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url',
|
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url',
|
||||||
'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris', 'vtg_datoer',
|
'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris', 'vtg_datoer',
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ type Facility = {
|
||||||
lat?: number | null;
|
lat?: number | null;
|
||||||
lng?: number | null;
|
lng?: number | null;
|
||||||
golfamore?: boolean | null;
|
golfamore?: boolean | null;
|
||||||
|
golfamore_url?: string | null;
|
||||||
nsg_url?: string | null;
|
nsg_url?: string | null;
|
||||||
vtg_pris?: number | null;
|
vtg_pris?: number | null;
|
||||||
vtg_lenke?: string | null;
|
vtg_lenke?: string | null;
|
||||||
|
|
@ -511,7 +512,10 @@ export default function FacilitySearch({
|
||||||
const holeValue = String(amenities.antall_hull || "").trim();
|
const holeValue = String(amenities.antall_hull || "").trim();
|
||||||
const primaryStatus = getPrimaryStatus(statuses);
|
const primaryStatus = getPrimaryStatus(statuses);
|
||||||
const normalizedStatuses = statuses.map((status) => normalizeStatus(status.status));
|
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 hasNSG = Boolean(facility.nsg_url) || Object.keys(nsgData).length > 0;
|
||||||
const hasSimulator = hasTruthyAmenity(amenities.simulator);
|
const hasSimulator = hasTruthyAmenity(amenities.simulator);
|
||||||
const hasDrivingRange = hasTruthyAmenity(amenities.drivingrange);
|
const hasDrivingRange = hasTruthyAmenity(amenities.drivingrange);
|
||||||
|
|
|
||||||
|
|
@ -422,7 +422,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
||||||
{ id: 'generelt', label: 'Generelt' },
|
{ id: 'generelt', label: 'Generelt' },
|
||||||
{ id: 'lokasjon', label: 'Lokasjon & Kontakt' },
|
{ id: 'lokasjon', label: 'Lokasjon & Kontakt' },
|
||||||
{ id: 'linker', label: 'Lenker & Media' },
|
{ id: 'linker', label: 'Lenker & Media' },
|
||||||
{ id: 'okonomi', label: 'Økonomi & Medlemskap' },
|
{ id: 'okonomi', label: 'Økonomi, medlemskap og fasiliteter' },
|
||||||
{ id: 'baner', label: 'Baner & Scorekort' }
|
{ 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">
|
<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="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)} />
|
<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)} />
|
<KeyValueEditor label="Golfamore Info" value={formData.golfamore_data} onChange={(v) => handleChange('golfamore_data', v)} />
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-2 mb-8">
|
<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>
|
<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" />
|
<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" />
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export type FacilityRecord = {
|
||||||
lat?: number | null;
|
lat?: number | null;
|
||||||
lng?: number | null;
|
lng?: number | null;
|
||||||
golfamore?: boolean | null;
|
golfamore?: boolean | null;
|
||||||
|
golfamore_url?: string | null;
|
||||||
nsg_url?: string | null;
|
nsg_url?: string | null;
|
||||||
vtg_pris?: number | null;
|
vtg_pris?: number | null;
|
||||||
vtg_lenke?: string | null;
|
vtg_lenke?: string | null;
|
||||||
|
|
@ -249,7 +250,7 @@ export const enrichFacilities = (
|
||||||
regions,
|
regions,
|
||||||
statuses,
|
statuses,
|
||||||
primaryStatus: getPrimaryStatus(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,
|
hasNSG: Boolean(facility.nsg_url) || Object.keys(nsgData).length > 0,
|
||||||
hasSimulator: hasTruthyAmenity(amenities.simulator),
|
hasSimulator: hasTruthyAmenity(amenities.simulator),
|
||||||
hasDrivingRange: hasTruthyAmenity(amenities.drivingrange),
|
hasDrivingRange: hasTruthyAmenity(amenities.drivingrange),
|
||||||
|
|
|
||||||
|
|
@ -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 { STATUS_ICON_PATHS, buildMapUrl, getPrimaryStatus, parseJson as parseSharedJson, slugify } from "@/app/facilityData";
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import CourseDisplay from './CourseDisplay';
|
import CourseDisplay from './CourseDisplay';
|
||||||
|
import FacilityFeedbackForm from './FacilityFeedbackForm';
|
||||||
|
|
||||||
const FacilityDetailLeafletMap = dynamic(() => import("./FacilityDetailLeafletMap"), {
|
const FacilityDetailLeafletMap = dynamic(() => import("./FacilityDetailLeafletMap"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
|
@ -35,13 +36,30 @@ const formatPhoneForUrl = (phone: string) => {
|
||||||
return normalized.startsWith("00") ? `+${normalized.slice(2)}` : normalized;
|
return normalized.startsWith("00") ? `+${normalized.slice(2)}` : normalized;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderValue = (val: string) => {
|
const renderValue = (val: unknown, fallback = "Nei") => {
|
||||||
if (!val) return "Nei";
|
const raw = String(val || "").trim();
|
||||||
const hasLink = val.includes('<a');
|
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 (
|
return (
|
||||||
<span
|
<span
|
||||||
className={hasLink ? "text-[#ff5722] font-bold hover:underline" : "text-[#11280f]"}
|
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 coopClubsRaw = parseJson(facility.cooperating_clubs, []);
|
||||||
const cooperatingClubs = Array.isArray(coopClubsRaw) ? coopClubsRaw : [];
|
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 hasNSG = facility.nsg_url || (nsgData && Object.keys(nsgData).length > 0);
|
||||||
const hasVtg = Boolean(
|
const hasVtg = Boolean(
|
||||||
facility.vtg_pris ||
|
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">
|
<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>
|
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter text-[#11280f]">Andre Tilbud</h3>
|
||||||
<div className="space-y-4">
|
<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">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>{amenities.treningsgreen || 'Ja'}</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">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">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>{amenities.bilutleie || 'Nei'}</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">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>
|
<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">
|
<div className="flex justify-between border-b border-gray-50 pb-2">
|
||||||
<span className="text-gray-400">Golfamore:</span>
|
<span className="text-gray-400">Golfamore:</span>
|
||||||
<span className="text-right ml-4">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-2">
|
<div className="flex justify-between border-b border-gray-50 pb-2">
|
||||||
<span className="text-gray-400">Seniorgolf (NSG):</span>
|
<span className="text-gray-400">Seniorgolf (NSG):</span>
|
||||||
<span className="text-right ml-4">
|
<span className="text-right ml-4">
|
||||||
{hasNSG && facility.nsg_url
|
{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>
|
? <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-blue-600 font-black">Ja</span> : "Nei")
|
: (hasNSG ? <span className="text-[#ff5722] font-black">Ja</span> : "Nei")
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -738,6 +767,11 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<FacilityFeedbackForm
|
||||||
|
facilityId={Number(facility.id)}
|
||||||
|
facilityName={String(facility.name || "dette golfanlegget")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showBackToTop && (
|
{showBackToTop && (
|
||||||
|
|
|
||||||
164
frontend/src/app/golfbaner/[slug]/FacilityFeedbackForm.tsx
Normal file
164
frontend/src/app/golfbaner/[slug]/FacilityFeedbackForm.tsx
Normal 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 må endres, fjernes eller legges til
|
||||||
|
når det gjelder presentasjonen av {facilityName} på 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue