diff --git a/backend/main.py b/backend/main.py index ca84ae9..d92e443 100644 --- a/backend/main.py +++ b/backend/main.py @@ -573,6 +573,10 @@ class QuickEditRequest(BaseModel): class FacilityVisibilityRequest(BaseModel): is_published: bool + +class PlacePageUpsertRequest(BaseModel): + factbox_intro_html: Optional[str] = "" + class GreenfeeApproval(BaseModel): facility_id: int greenfee: List[dict] @@ -715,6 +719,20 @@ def format_row(row): return d +def format_place_page_row(row): + if row is None: + return None + + d = dict(row) + + for key in ["created_at", "updated_at"]: + if isinstance(d.get(key), (date, datetime)): + d[key] = d[key].isoformat() + + d["factbox_intro_html"] = str(d.get("factbox_intro_html") or "") + return d + + def normalize_club_lookup_value(value: str | None) -> str: text = str(value or "").strip().lower() if not text: @@ -1436,6 +1454,17 @@ async def ensure_facility_columns(conn): """) +async def ensure_place_pages_table(conn): + await conn.execute(""" + CREATE TABLE IF NOT EXISTS place_pages ( + slug VARCHAR(255) PRIMARY KEY, + factbox_intro_html TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + + async def ensure_articles_table(conn): await conn.execute(""" CREATE TABLE IF NOT EXISTS articles ( @@ -1602,6 +1631,7 @@ async def lifespan(app: FastAPI): ) async with app.state.pool.acquire() as conn: await ensure_facility_columns(conn) + await ensure_place_pages_table(conn) await ensure_articles_table(conn) await ensure_public_user_tables(conn) await ensure_scrape_jobs_table(conn) @@ -2502,6 +2532,29 @@ async def get_facility(slug: str): return format_row(row) +@app.get("/api/place-pages/{slug}") +async def get_place_page(slug: str): + normalized_slug = str(slug or "").strip().lower() + if not normalized_slug: + raise HTTPException(status_code=400, detail="Slug mangler.") + + async with app.state.pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT * FROM place_pages WHERE slug = $1", + normalized_slug, + ) + + if not row: + return { + "slug": normalized_slug, + "factbox_intro_html": "", + "created_at": None, + "updated_at": None, + } + + return format_place_page_row(row) + + @app.get("/api/admin/facilities") async def get_admin_facilities(): """Henter alle golfanlegg for admin, også upubliserte.""" @@ -2584,6 +2637,52 @@ async def get_admin_facility(slug: str): return format_row(row) +@app.get("/api/admin/place-pages/{slug}") +async def get_admin_place_page(slug: str): + normalized_slug = str(slug or "").strip().lower() + if not normalized_slug: + raise HTTPException(status_code=400, detail="Slug mangler.") + + async with app.state.pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT * FROM place_pages WHERE slug = $1", + normalized_slug, + ) + + if not row: + return { + "slug": normalized_slug, + "factbox_intro_html": "", + "created_at": None, + "updated_at": None, + } + + return format_place_page_row(row) + + +@app.put("/api/admin/place-pages/{slug}") +async def update_admin_place_page(slug: str, request: PlacePageUpsertRequest): + normalized_slug = str(slug or "").strip().lower() + if not normalized_slug: + raise HTTPException(status_code=400, detail="Slug mangler.") + + async with app.state.pool.acquire() as conn: + row = await conn.fetchrow( + """ + INSERT INTO place_pages (slug, factbox_intro_html) + VALUES ($1, $2) + ON CONFLICT (slug) DO UPDATE + SET factbox_intro_html = EXCLUDED.factbox_intro_html, + updated_at = NOW() + RETURNING * + """, + normalized_slug, + request.factbox_intro_html or "", + ) + + return format_place_page_row(row) + + @app.get("/api/course-visits") async def get_course_visits(): """Henter publiserte Banebesøk-artikler.""" diff --git a/frontend/src/app/FacilitySearch.tsx b/frontend/src/app/FacilitySearch.tsx index 9b179d1..3891c7f 100755 --- a/frontend/src/app/FacilitySearch.tsx +++ b/frontend/src/app/FacilitySearch.tsx @@ -394,7 +394,9 @@ const matchesSpecialFilter = (specialFilter: string, flags: SpecialFlags) => { return true; }; -const WEATHER_DAY_OPTIONS = [ +const OSLO_TIME_ZONE = "Europe/Oslo"; + +const WEATHER_DAY_BASE_OPTIONS = [ { value: "", label: "Alle dager" }, { value: "0", label: "I dag" }, { value: "1", label: "I morgen" }, @@ -406,6 +408,75 @@ const WEATHER_DAY_OPTIONS = [ { value: "7", label: "Om en uke" }, ]; +const osloDateFormatter = new Intl.DateTimeFormat("nb-NO", { + timeZone: OSLO_TIME_ZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", +}); + +const osloWeekdayFormatter = new Intl.DateTimeFormat("nb-NO", { + timeZone: OSLO_TIME_ZONE, + weekday: "long", +}); + +const getOsloDateParts = (date: Date) => { + const parts = osloDateFormatter.formatToParts(date); + const year = Number(parts.find((part) => part.type === "year")?.value ?? "0"); + const month = Number(parts.find((part) => part.type === "month")?.value ?? "1"); + const day = Number(parts.find((part) => part.type === "day")?.value ?? "1"); + return { year, month, day }; +}; + +const getOsloDateAtNoonUtc = (date: Date) => { + const { year, month, day } = getOsloDateParts(date); + return new Date(Date.UTC(year, month - 1, day, 12)); +}; + +const getWeatherDayOptions = (date: Date = new Date()) => { + const osloToday = getOsloDateAtNoonUtc(date); + + return WEATHER_DAY_BASE_OPTIONS.map((option) => { + if (!option.value) return option; + + const dayOffset = Number.parseInt(option.value, 10); + const targetDate = new Date(osloToday); + targetDate.setUTCDate(targetDate.getUTCDate() + dayOffset); + + return { + ...option, + label: `${option.label} (${osloWeekdayFormatter.format(targetDate)})`, + }; + }); +}; + +const getOsloDateKey = (date: Date = new Date()) => { + const { year, month, day } = getOsloDateParts(date); + return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +}; + +const getMsUntilNextOsloDateChange = (date: Date = new Date()) => { + const minuteMs = 60_000; + const currentKey = getOsloDateKey(date); + let low = date.getTime(); + let high = low + minuteMs; + + while (getOsloDateKey(new Date(high)) === currentKey) { + high += 30 * minuteMs; + } + + while (high - low > minuteMs) { + const mid = Math.floor((low + high) / 2); + if (getOsloDateKey(new Date(mid)) === currentKey) { + low = mid; + } else { + high = mid; + } + } + + return Math.max(minuteMs, high - date.getTime() + minuteMs); +}; + const getSearchShellClasses = (variant: Variant) => variant === "home" ? "rounded-[2rem] bg-[#39443B] px-4 py-5 text-white shadow-2xl sm:px-6 sm:py-7" @@ -438,6 +509,7 @@ export default function FacilitySearch({ const [holeFilter, setHoleFilter] = useState(""); const [specialFilter, setSpecialFilter] = useState(""); const [weatherDayFilter, setWeatherDayFilter] = useState(""); + const [weatherDayOptions, setWeatherDayOptions] = useState(() => getWeatherDayOptions()); const [architectFilter, setArchitectFilter] = useState(""); const [facilityFilter, setFacilityFilter] = useState(""); const [sortMethod, setSortMethod] = useState("updated"); @@ -448,6 +520,28 @@ export default function FacilitySearch({ setAreaFilter(fixedAreaFilter); }, [fixedAreaFilter]); + useEffect(() => { + let timeoutId: number | undefined; + let cancelled = false; + + const scheduleRefresh = () => { + timeoutId = window.setTimeout(() => { + if (cancelled) return; + setWeatherDayOptions(getWeatherDayOptions()); + scheduleRefresh(); + }, getMsUntilNextOsloDateChange()); + }; + + scheduleRefresh(); + + return () => { + cancelled = true; + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + } + }; + }, []); + useEffect(() => { if (!("geolocation" in navigator)) return; @@ -791,7 +885,7 @@ export default function FacilitySearch({ onChange={setWeatherDayFilter} labelClassName={labelClassName} > - {WEATHER_DAY_OPTIONS.map((option) => ( + {weatherDayOptions.map((option) => ( diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index ffa80f7..febaa8d 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -1308,6 +1308,9 @@ export default function AdminDashboard() {
Innhold
+ setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white"> + Steder + setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white"> Artikler @@ -1367,6 +1370,9 @@ export default function AdminDashboard() {
Innhold
+ + {isSidebarCollapsed ? 'S' : 'Steder'} + {isSidebarCollapsed ? 'A' : 'Artikler'} @@ -1410,6 +1416,12 @@ export default function AdminDashboard() {
+ + Sted-sider + { + if (!value) return "Ikke lagret ennå"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "Ukjent"; + return date.toLocaleString("nb-NO", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +export default function AdminPlacePagesPage() { + const [selectedSlug, setSelectedSlug] = useState(DEFAULT_PLACE_SLUG); + const [html, setHtml] = useState(""); + const [updatedAt, setUpdatedAt] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [feedback, setFeedback] = useState(""); + + const selectedPlace = getPlaceConfigFromSlug(selectedSlug); + + useEffect(() => { + const controller = new AbortController(); + + const loadPlacePage = async () => { + setIsLoading(true); + setFeedback(""); + + try { + const response = await adminFetch(`${API_URL}/admin/place-pages/${selectedSlug}`, { + credentials: "include", + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error("Kunne ikke hente sted-siden."); + } + + const data = (await response.json()) as PlacePageResponse; + setHtml(data.factbox_intro_html || ""); + setUpdatedAt(data.updated_at || null); + } catch (error) { + if (controller.signal.aborted) return; + setHtml(""); + setUpdatedAt(null); + setFeedback(error instanceof Error ? error.message : "Kunne ikke hente sted-siden."); + } finally { + if (!controller.signal.aborted) { + setIsLoading(false); + } + } + }; + + loadPlacePage(); + + return () => controller.abort(); + }, [selectedSlug]); + + const handleSave = async () => { + setIsSaving(true); + setFeedback(""); + + try { + const response = await adminFetch(`${API_URL}/admin/place-pages/${selectedSlug}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ factbox_intro_html: html }), + }); + + if (!response.ok) { + throw new Error("Kunne ikke lagre sted-siden."); + } + + const data = (await response.json()) as PlacePageResponse; + setUpdatedAt(data.updated_at || null); + setFeedback("Lagret."); + } catch (error) { + setFeedback(error instanceof Error ? error.message : "Kunne ikke lagre sted-siden."); + } finally { + setIsSaving(false); + } + }; + + return ( +
+ + +
+ + ← Tilbake til oversikten + +
+
+

Sted-sider

+

Rediger innhold over faktaboksen

+

+ Dette innholdet vises over nøkkeltall-/faktaboksen på `/sted/[slug]`. +

+
+
+ + Åpne sted-siden + + +
+
+
+ +
+
+
+ + +
+

Aktiv side

+

{selectedPlace?.title || selectedSlug}

+

{selectedPlace?.intro || "Ingen intro tilgjengelig."}

+

+ Sist lagret: {formatDateTime(updatedAt)} +

+ {feedback ? ( +

{feedback}

+ ) : null} +
+
+ +
+ {isLoading ? ( +
+ Laster sted-side... +
+ ) : ( + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/app/admin/vtg/page.tsx b/frontend/src/app/admin/vtg/page.tsx index fef14be..5b47617 100644 --- a/frontend/src/app/admin/vtg/page.tsx +++ b/frontend/src/app/admin/vtg/page.tsx @@ -5,6 +5,57 @@ import { adminFetch } from "@/config/adminFetch"; import AdminMobileMenu from "@/components/AdminMobileMenu"; import Link from 'next/link'; +type VtgDateRow = { + dato?: string; + status?: string; +}; + +const normalizeDateRows = (value: any): VtgDateRow[] => { + if (!Array.isArray(value)) return []; + return value.map((row) => ({ + dato: typeof row?.dato === 'string' ? row.dato : '', + status: typeof row?.status === 'string' ? row.status : 'Ledig' + })); +}; + +const datesAreEqual = (left: any, right: any) => ( + JSON.stringify(normalizeDateRows(left)) === JSON.stringify(normalizeDateRows(right)) +); + +const textValue = (value: any) => ( + typeof value === 'string' ? value.trim() : '' +); + +const priceValue = (value: any) => { + if (value === '' || value === null || typeof value === 'undefined') return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +const formatPriceLabel = (value: any) => { + const parsed = priceValue(value); + return parsed === null ? 'Ingen pris registrert' : `${parsed} kr`; +}; + +function ReadOnlyDateList({ dates, emptyLabel }: { dates: any; emptyLabel: string }) { + const normalizedDates = normalizeDateRows(dates); + + if (normalizedDates.length === 0) { + return
{emptyLabel}
; + } + + return ( +
+ {normalizedDates.map((row, idx) => ( +
+
{row.dato || 'Uten dato'}
+
{row.status || 'Ukjent status'}
+
+ ))} +
+ ); +} + export default function VtgWasher() { const [drafts, setDrafts] = useState([]); const [loading, setLoading] = useState(true); @@ -145,11 +196,28 @@ export default function VtgWasher() { {drafts.map((draft, index) => (
+ {(() => { + const priceChanged = priceValue(draft.vtg_pris) !== priceValue(draft.edit_pris); + const descriptionChanged = textValue(draft.vtg_beskrivelse) !== textValue(draft.edit_beskrivelse); + const datesChanged = !datesAreEqual(draft.vtg_datoer, draft.edit_datoer); + const changedCount = [priceChanged, descriptionChanged, datesChanged].filter(Boolean).length; + + return (
toggleOne(draft.id)} />
-

{draft.name} ID: {draft.id}

+
+

{draft.name} ID: {draft.id}

+
+ 0 ? 'bg-amber-100 text-amber-900' : 'bg-gray-100 text-gray-500'}`}> + {changedCount > 0 ? `${changedCount} felt endret` : 'Ingen reell endring'} + + {priceChanged && Pris} + {descriptionChanged && Beskrivelse} + {datesChanged && Kursdatoer} +
+
Sjekk Nettside ↗
@@ -159,48 +227,87 @@ export default function VtgWasher() {
)} -
- {/* Pris & Beskrivelse */} +

Pris & Beskrivelse

-
- - updateField(draft.id, 'edit_pris', e.target.value)} placeholder="Eks: 1990" /> -
-
- -