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)
|
||||
|
||||
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
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