From 33045c9e514f813e5631ed6bfdbafc663ea2cc40 Mon Sep 17 00:00:00 2001 From: Martine Date: Wed, 14 Oct 2026 10:35:08 +0200 Subject: [PATCH 1/5] =?UTF-8?q?F=C3=B8rsteutkast=20til=20fremside=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/hero.svg | 60 ++++++++++++++++++++++++++++ src/Home.css | 103 ++++++++++++++++++++++++++++++++++++++++++++++++ src/Home.tsx | 48 ++++++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 public/hero.svg create mode 100644 src/Home.css create mode 100644 src/Home.tsx diff --git a/public/hero.svg b/public/hero.svg new file mode 100644 index 0000000..9ec1f68 --- /dev/null +++ b/public/hero.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Home.css b/src/Home.css new file mode 100644 index 0000000..7eae331 --- /dev/null +++ b/src/Home.css @@ -0,0 +1,103 @@ +/* Forsiden. Header-stilen kommer fra App.css. */ +.home-header .header-actions { + display: flex; + gap: 1rem; + align-items: center; +} + +/* Forsiden skal gå kant i kant, så vi overstyrer max-width/padding fra main i App.css */ +.home-main { + max-width: none; + padding: 0; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +/* ---------- Hero med bakgrunnsbilde ---------- */ +.home-hero { + position: relative; + min-height: 560px; + display: flex; + align-items: center; + justify-content: center; + padding: 80px 5%; + background-color: #2c5a49; /* vises hvis bildet mangler eller ikke er lastet ennå */ + background-size: cover; + background-position: 72% center; /* holder fotturisten i bildet også på smale skjermer */ +} + +/* Mørk toning over bildet så den hvite teksten får god kontrast (WCAG) */ +.home-hero::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(rgb(0 0 0 / 0.35), rgb(0 0 0 / 0.45)); +} + +.home-hero-content { + position: relative; + width: min(100%, 960px); + text-align: center; +} + +.home-hero h1 { + color: #fff; + margin: 0 0 40px; + text-shadow: 0 2px 12px rgb(0 0 0 / 0.35); +} + +.home-search { + display: flex; + align-items: center; + gap: 14px; + background: #fff; + border-radius: 10px; + padding: 0 24px; + box-shadow: 0 8px 30px rgb(0 0 0 / 0.2); + color: #2c6e57; +} + +.home-search input { + flex: 1; + min-width: 0; + border: 0; + padding: 24px 0; + font: inherit; + font-size: 20px; + color: #203b32; + background: transparent; +} + +.home-search input::placeholder { + color: #2c6e57; +} + +/* Skjuler den oransje fokusrammen (fra App.css) rundt søkefeltet */ +.home-search input:focus-visible { + outline: none; +} + +/* ---------- Turforslag ---------- */ +.home-nearby { + max-width: 1200px; + margin: 0 auto; + padding: 72px 5%; + text-align: center; +} + +.home-nearby h2 { + font-size: clamp(26px, 3.5vw, 38px); + margin: 0; +} + +@media (max-width: 800px) { + .home-hero { min-height: 440px; } + .home-search input { font-size: 17px; padding: 18px 0; } +} diff --git a/src/Home.tsx b/src/Home.tsx new file mode 100644 index 0000000..d5c5962 --- /dev/null +++ b/src/Home.tsx @@ -0,0 +1,48 @@ +import './Home.css' + +/** + * Lenke til bakgrunnsbildet øverst på forsiden. + * Er feltet tomt, vises en grønn bakgrunn i stedet. + */ +const HERO_IMAGE_URL = `${import.meta.env.BASE_URL}hero.svg` // egen illustrasjon i public/hero.svg + +/** + * Forsiden som vises før innlogging. Foreløpig bare en mal for oppsettet: + * header, hero med tittel og søkefelt, og en seksjon for turforslag. + * Søk og turforslag fra databasen kobles på senere. + */ +export default function Home({ onLoginClick }: { + onLoginClick: () => void + onSelectRoute?: (route: { name: string; number: string }) => void +}) { + return <> +
+ △ Norwegian Explorer + +
+
+
+
+

Ut på tur, aldri sur

+
event.preventDefault()}> + + + +
+
+
+ +
+

Velg din neste tur

+ {/* Turforslag fra databasen kommer her */} +
+
+ +} From 08e0199237d383d1458c0e9ec4d28a5df639ed41 Mon Sep 17 00:00:00 2001 From: Martine Date: Wed, 14 Oct 2026 10:51:04 +0200 Subject: [PATCH 2/5] =?UTF-8?q?Forsiden=20vises=20f=C3=B8rst=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 8 +- src/Profile.css | 232 +++++++++++++++++++++++++++++++++++++++++------- src/Profile.tsx | 8 +- 3 files changed, 212 insertions(+), 36 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index ddaf3dd..d9fbeda 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import 'maplibre-gl/dist/maplibre-gl.css' import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url' import './App.css' import './Filters.css' +import Home from './Home' import Profile, { AuthGate } from './Profile' maplibregl.setWorkerUrl(workerUrl) @@ -118,8 +119,10 @@ function RouteMap({ route }: { route: Route }) { export default function App() { const [authenticated, setAuthenticated] = useState(false) + // Forsiden vises først. Innloggingssiden vises når man trykker «Logg inn». + const [showLogin, setShowLogin] = useState(false) const handleAuthenticated = useCallback(() => setAuthenticated(true), []) - const handleLogout = useCallback(() => setAuthenticated(false), []) + const handleLogout = useCallback(() => { setAuthenticated(false); setShowLogin(false) }, []) const detailsRef = useRef(null) const [route, setRoute] = useState(null) const [routeVariants, setRouteVariants] = useState([]) @@ -195,7 +198,8 @@ export default function App() { void load() return () => controller.abort() }, [selected, attempt]) - if (!authenticated) return + if (!authenticated && !showLogin) return setShowLogin(true)} /> + if (!authenticated) return setShowLogin(false)} /> return <> Hopp til ruteinformasjon diff --git a/src/Profile.css b/src/Profile.css index e78bde8..aa14195 100644 --- a/src/Profile.css +++ b/src/Profile.css @@ -1,32 +1,200 @@ -.profile-panel { margin-top: 32px; padding: 26px; background: #fff; border: 1px solid #dce1d7; border-radius: 16px; } -.profile-panel h2 { margin: 5px 0 18px; } -.profile-panel h3 { margin-top: 26px; } -.profile-form { display: grid; gap: 14px; max-width: 420px; } -.profile-form label { display: grid; gap: 6px; font-weight: 600; } -.profile-form input { font: inherit; border: 1px solid #9cac9e; border-radius: 8px; padding: 11px; } -.profile-heading { display: flex; align-items: start; justify-content: space-between; gap: 20px; } -.text-button { background: transparent; color: #185b45; padding: 7px 0; text-decoration: underline; } -.favorite-list { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } -.favorite-list button { width: 100%; text-align: left; background: #edf2e9; color: #203b32; } -.favorite-list span, .favorite-list strong { display: block; } -.favorite-list span { font-size: 11px; margin-top: 5px; } -@media (max-width: 800px) { .favorite-list { grid-template-columns: 1fr; } } -.profile-identity { display: flex; align-items: center; gap: 14px; } -.profile-avatar { width: 58px; height: 58px; border-radius: 50%; object-fit: cover; border: 2px solid #dce1d7; } -.profile-initial { display: grid; place-items: center; background: #126b51; color: white; font-size: 24px; font-weight: 700; } -.profile-settings { margin: 20px 0; padding: 16px 0; border-top: 1px solid #e1e5dd; border-bottom: 1px solid #e1e5dd; } -.profile-settings summary { cursor: pointer; font-weight: 700; } -.profile-settings .profile-form { margin-top: 18px; } -.profile-settings input[type=file] { background: #f7f8f4; } -.remove-avatar { justify-self: start; } -.auth-page { min-height: 100vh; background: linear-gradient(135deg, #f7f7f1 0%, #e7efe5 100%); } -.auth-main { min-height: calc(100vh - 90px); display: grid; place-items: center; padding: 40px 20px; } -.auth-card { width: min(100%, 540px); padding: 42px; background: #fff; border: 1px solid #dce1d7; border-radius: 20px; box-shadow: 0 20px 60px #233a3018; } -.auth-intro { margin-bottom: 28px; } -.auth-intro h1 { font-size: clamp(34px, 5vw, 50px); line-height: 1.05; margin: 10px 0 14px; } -.auth-intro > p:last-child { color: #5a6c61; } -.auth-card .profile-form { max-width: none; } -.auth-card .profile-form button { margin-top: 4px; } -.auth-card > .text-button { margin-top: 18px; } -.auth-error { color: #9b2c20; } -@media (max-width: 600px) { .auth-main { min-height: calc(100vh - 72px); padding: 24px 16px; } .auth-card { padding: 28px 22px; } } +.profile-panel { + margin-top: 32px; + padding: 26px; + background: #fff; + border: 1px solid #dce1d7; + border-radius: 16px; +} + +.profile-panel h2 { + margin: 5px 0 18px; +} + +.profile-panel h3 { + margin-top: 26px; +} + +.profile-form { + display: grid; + gap: 14px; + max-width: 420px; +} + +.profile-form label { + display: grid; + gap: 6px; + font-weight: 600; +} + +.profile-form input { + font: inherit; + border: 1px solid #9cac9e; + border-radius: 8px; + padding: 11px; +} + +.profile-heading { + display: flex; + align-items: start; + justify-content: space-between; + gap: 20px; +} + +.text-button { + background: transparent; + color: #185b45; + padding: 7px 0; + text-decoration: underline; +} + +.favorite-list { + list-style: none; + padding: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.favorite-list button { + width: 100%; + text-align: left; + background: #edf2e9; + color: #203b32; +} + +.favorite-list span, +.favorite-list strong { + display: block; +} + +.favorite-list span { + font-size: 11px; + margin-top: 5px; +} + +@media (max-width: 800px) { + .favorite-list { + grid-template-columns: 1fr; + } +} + +.profile-identity { + display: flex; + align-items: center; + gap: 14px; +} + +.profile-avatar { + width: 58px; + height: 58px; + border-radius: 50%; + object-fit: cover; + border: 2px solid #dce1d7; +} + +.profile-initial { + display: grid; + place-items: center; + background: #126b51; + color: white; + font-size: 24px; + font-weight: 700; +} + +.profile-settings { + margin: 20px 0; + padding: 16px 0; + border-top: 1px solid #e1e5dd; + border-bottom: 1px solid #e1e5dd; +} + +.profile-settings summary { + cursor: pointer; + font-weight: 700; +} + +.profile-settings .profile-form { + margin-top: 18px; +} + +.profile-settings input[type=file] { + background: #f7f8f4; +} + +.remove-avatar { + justify-self: start; +} + +.auth-page { + min-height: 100vh; + background: linear-gradient(135deg, #f7f7f1 0%, #e7efe5 100%); +} + +.auth-main { + min-height: calc(100vh - 90px); + display: grid; + place-items: center; + padding: 40px 20px; +} + +.auth-card { + width: min(100%, 540px); + padding: 42px; + background: #fff; + border: 1px solid #dce1d7; + border-radius: 20px; + box-shadow: 0 20px 60px #233a3018; +} + +.auth-intro { + margin-bottom: 28px; +} + +.auth-intro h1 { + font-size: clamp(34px, 5vw, 50px); + line-height: 1.05; + margin: 10px 0 14px; +} + +.auth-intro>p:last-child { + color: #5a6c61; +} + +.auth-card .profile-form { + max-width: none; +} + +.auth-card .profile-form button { + margin-top: 4px; +} + +.auth-card>.text-button { + margin-top: 18px; +} + +.auth-error { + color: #9b2c20; +} + +@media (max-width: 600px) { + .auth-main { + min-height: calc(100vh - 72px); + padding: 24px 16px; + } + + .auth-card { + padding: 28px 22px; + } +} + +/* Logoen på innloggingssiden er en knapp. Nullstill knappestilen så den ser ut som før, + men vis pekermus. Fokusmarkeringen for tastatur beholdes av hensyn til tilgjengelighet. */ +.brand-button { + background: none; + border: 0; + border-radius: 0; + padding: 0; + color: inherit; + font-family: inherit; + cursor: pointer; +} \ No newline at end of file diff --git a/src/Profile.tsx b/src/Profile.tsx index 9af2b31..4dc84ac 100644 --- a/src/Profile.tsx +++ b/src/Profile.tsx @@ -16,7 +16,7 @@ async function graphQL(query: string, variables: Record, signal return result.data } -export function AuthGate({ onAuthenticated }: { onAuthenticated: () => void }) { +export function AuthGate({ onAuthenticated, onHome }: { onAuthenticated: () => void, onHome?: () => void }) { const [mode, setMode] = useState<'login'|'register'>('login') const [username, setUsername] = useState('') const [password, setPassword] = useState('') @@ -47,7 +47,11 @@ export function AuthGate({ onAuthenticated }: { onAuthenticated: () => void }) { } return
-
△ Norwegian ExplorerFjellområder
+
+ {/* Logoen tar brukeren tilbake til forsiden. Det er en knapp, men ser ut som før (kun pekermus). */} + {onHome + ? + : △ Norwegian Explorer}Fjellområder

OPPDAG NORGE TIL FOTS

{checking ? 'Velkommen tilbake.' : mode === 'login' ? 'Logg inn for å utforske.' : 'Opprett din profil.'}

Lagre favoritter og finn registrerte turruter fra hele Norge.

From c3040cff7a5ca00a3810d95c526fa428ee0a32db Mon Sep 17 00:00:00 2001 From: Martine Date: Wed, 14 Oct 2026 12:39:45 +0200 Subject: [PATCH 3/5] Legg til valg av sted i profilen (#21) --- server/index.mjs | 261 ++++++++++++++++----------- server/sql/app/home_municipality.sql | 7 + src/Profile.tsx | 47 ++++- 3 files changed, 201 insertions(+), 114 deletions(-) create mode 100644 server/sql/app/home_municipality.sql diff --git a/server/index.mjs b/server/index.mjs index a15d232..44dfb53 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -9,7 +9,7 @@ import { mountainResolvers, mountainTypeDefs } from './server.ts' const schemaName = process.env.DATA_SCHEMA || 'turogfriluftsruter_1b2541ab00554564ae88dabc8bb90c02' if (!/^[a-z_][a-z0-9_]*$/.test(schemaName)) throw new Error('Invalid DATA_SCHEMA') const scrypt = promisify(scryptCallback) -const appSchemaReady = pool.query(` +const appSchemaSetup = pool.query(` CREATE SCHEMA IF NOT EXISTS norwegian_explorer_app; CREATE TABLE IF NOT EXISTS norwegian_explorer_app.users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, @@ -26,6 +26,8 @@ const appSchemaReady = pool.query(` ALTER TABLE norwegian_explorer_app.users ADD COLUMN IF NOT EXISTS email text; ALTER TABLE norwegian_explorer_app.users ADD COLUMN IF NOT EXISTS phone text; ALTER TABLE norwegian_explorer_app.users ADD COLUMN IF NOT EXISTS avatar text; + -- Kommunenummeret brukeren har valgt som sitt nærområde (vises på forsiden) + ALTER TABLE norwegian_explorer_app.users ADD COLUMN IF NOT EXISTS home_municipality text; CREATE TABLE IF NOT EXISTS norwegian_explorer_app.favorites ( user_id bigint NOT NULL REFERENCES norwegian_explorer_app.users(id) ON DELETE CASCADE, route_name text NOT NULL, @@ -38,13 +40,34 @@ const appSchemaReady = pool.query(` ON norwegian_explorer_app.favorites (user_id, route_id) WHERE route_id IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_idx ON norwegian_explorer_app.users (lower(username)); `) +// Oppsettet over krever at databasebrukeren eier tabellene. Lokalt kobler vi til som «gruppe», som kan lese +// og skrive, men ikke endre tabellene. Feiler oppsettet, men tabellene finnes allerede, fortsetter vi likevel. +const appSchemaReady = appSchemaSetup.catch(async error => { + console.error('Kunne ikke klargjøre appens tabeller:', error.message) + const { rows } = await pool.query("SELECT to_regclass('norwegian_explorer_app.users') AS users") + if (!rows[0].users) throw error + console.error('Tabellene finnes allerede, så backend fortsetter med dem som de er.') +}) +// Kolonnen for «Mitt sted» kan mangle hvis den ikke er lagt til av eieren av tabellen ennå +// (se server/sql/app/home_municipality.sql). Da virker alt annet, men stedsvalget er slått av. +const hasHomeMunicipality = appSchemaReady.then(async () => { + const { rowCount } = await pool.query(`SELECT 1 FROM information_schema.columns + WHERE table_schema='norwegian_explorer_app' AND table_name='users' AND column_name='home_municipality'`) + if (!rowCount) console.error('Kolonnen users.home_municipality mangler. Kjør server/sql/app/home_municipality.sql for å slå på stedsvalg.') + return rowCount > 0 +}) +/** Kolonneuttrykket for brukerens sted, eller NULL hvis kolonnen ikke finnes i databasen */ +async function homeColumn(prefix = '') { + return (await hasHomeMunicipality) ? `${prefix}home_municipality` : 'NULL AS home_municipality' +} const routeCatalogReady = appSchemaReady.then(async () => { const { rows } = await pool.query("SELECT to_regclass('norwegian_explorer_app.route_catalog') AS catalog") if (!rows[0].catalog) throw new Error('Route catalog is missing. Run scripts/create-route-catalog.sql first.') }) -// Får ikke databasebrukeren lage tabellene over, skal bare innlogging og ruter feile, ikke hele serveren -appSchemaReady.catch(error => console.error('Kunne ikke klargjøre appens tabeller:', error.message)) +// Mangler tabellene helt, skal bare innlogging og ruter feile, ikke hele serveren +appSchemaReady.catch(error => console.error('Appens tabeller finnes ikke:', error.message)) routeCatalogReady.catch(error => console.error(error.message)) +hasHomeMunicipality.catch(() => undefined) // feilen er allerede logget over function validateCredentials(username, password) { const normalized = username.trim() @@ -62,7 +85,7 @@ async function createSession(client, userId) { async function userFromToken(token) { if (!token || token.length > 200) return null await appSchemaReady - const { rows } = await pool.query(`SELECT u.id,u.username,u.email,u.phone,u.avatar FROM norwegian_explorer_app.sessions s + const { rows } = await pool.query(`SELECT u.id,u.username,u.email,u.phone,u.avatar,${await homeColumn('u.')} FROM norwegian_explorer_app.sessions s JOIN norwegian_explorer_app.users u ON u.id=s.user_id WHERE s.token_hash=$1 AND s.expires_at>now()`, [tokenHash(token)]) return rows[0] || null @@ -75,7 +98,18 @@ async function profileFor(user) { FROM norwegian_explorer_app.favorites f LEFT JOIN norwegian_explorer_app.route_catalog c ON c.id=f.route_id WHERE f.user_id=$1 ORDER BY f.created_at DESC`, [user.id]) - return { username: user.username, email: user.email, phone: user.phone, avatar: user.avatar, favorites: rows } + return { + username: user.username, email: user.email, phone: user.phone, avatar: user.avatar, + homeMunicipality: await municipalityByCode(user.home_municipality), favorites: rows + } +} +// Slår opp navnet på en kommune ut fra kommunenummeret. Gir null hvis kommunen ikke har registrerte ruter. +async function municipalityByCode(code) { + if (!code) return null + await routeCatalogReady + const { rows } = await pool.query(`SELECT municipality_number AS code,min(municipality_name) AS name + FROM norwegian_explorer_app.route_areas WHERE municipality_number=$1 GROUP BY municipality_number`, [code]) + return rows[0] || null } async function requireUser(token) { const user = await userFromToken(token) @@ -108,7 +142,8 @@ const schema = createSchema({ type AreaOptions { counties: [AreaOption!]!, municipalities: [AreaOption!]! } type OsmPage { geojson: String!, truncated: Boolean! } type Favorite { id: ID!, name: String!, number: String!, createdAt: String! } - type UserProfile { username: String!, email: String, phone: String, avatar: String, favorites: [Favorite!]! } + type Municipality { code: String!, name: String! } + type UserProfile { username: String!, email: String, phone: String, avatar: String, homeMunicipality: Municipality, favorites: [Favorite!]! } type AuthPayload { token: String!, user: UserProfile! } type Query { osmNearby(west: Float!, south: Float!, east: Float!, north: Float!): OsmPage! @@ -125,12 +160,15 @@ const schema = createSchema({ logout(token: String!): Boolean! toggleFavorite(token: String!, routeId: ID!): UserProfile! updateProfile(token: String!, email: String, phone: String, avatar: String): UserProfile! + # Setter kommunen brukeren vil se turforslag fra på forsiden. code = null fjerner valget. + setHomeMunicipality(token: String!, code: String): UserProfile! } `, mountainTypeDefs], - resolvers: [{ Query: { - osmNearby: async (_, { west, south, east, north }) => { - if (![west,south,east,north].every(Number.isFinite) || west < -180 || east > 180 || south < -90 || north > 90 || west >= east || south >= north || east-west > 0.5 || north-south > 0.5) throw new GraphQLError('Velg et mindre kartområde for OSM-data.') - const { rows } = await pool.query(` + resolvers: [{ + Query: { + osmNearby: async (_, { west, south, east, north }) => { + if (![west, south, east, north].every(Number.isFinite) || west < -180 || east > 180 || south < -90 || north > 90 || west >= east || south >= north || east - west > 0.5 || north - south > 0.5) throw new GraphQLError('Velg et mindre kartområde for OSM-data.') + const { rows } = await pool.query(` SELECT osm_type,osm_id::text,name,category, coalesce(tags->>'amenity',tags->>'tourism',tags->>'natural',tags->>'highway') AS kind, ST_AsGeoJSON(CASE WHEN category='place' THEN ST_PointOnSurface(geom) @@ -142,31 +180,31 @@ const schema = createSchema({ AND coalesce(tags->>'access','') NOT IN ('no','private') AND coalesce(tags->>'foot','') NOT IN ('no','private') AND (category='place' OR GeometryType(geom) IN ('LINESTRING','MULTILINESTRING')) - ORDER BY category,osm_type,osm_id LIMIT 1001`, [west,south,east,north]) - return { truncated: rows.length>1000, geojson: JSON.stringify({ type:'FeatureCollection', features: rows.slice(0,1000).map(r => ({ type:'Feature', geometry:r.geometry, properties:{ id:r.osm_type+'/'+r.osm_id, name:r.name, category:r.category, kind:r.kind } })) }) } - }, - areas: async (_, { county }) => { - await routeCatalogReady - const [countyResult, municipalityResult] = await Promise.all([ - pool.query(`SELECT county_number AS code,min(county_name) AS name,count(DISTINCT route_id)::int AS "routeCount" + ORDER BY category,osm_type,osm_id LIMIT 1001`, [west, south, east, north]) + return { truncated: rows.length > 1000, geojson: JSON.stringify({ type: 'FeatureCollection', features: rows.slice(0, 1000).map(r => ({ type: 'Feature', geometry: r.geometry, properties: { id: r.osm_type + '/' + r.osm_id, name: r.name, category: r.category, kind: r.kind } })) }) } + }, + areas: async (_, { county }) => { + await routeCatalogReady + const [countyResult, municipalityResult] = await Promise.all([ + pool.query(`SELECT county_number AS code,min(county_name) AS name,count(DISTINCT route_id)::int AS "routeCount" FROM norwegian_explorer_app.route_areas GROUP BY county_number ORDER BY name`), - pool.query(`SELECT municipality_number AS code,min(municipality_name) AS name,count(DISTINCT route_id)::int AS "routeCount" + pool.query(`SELECT municipality_number AS code,min(municipality_name) AS name,count(DISTINCT route_id)::int AS "routeCount" FROM norwegian_explorer_app.route_areas WHERE ($1::text IS NULL OR county_number=$1) GROUP BY municipality_number ORDER BY name`, [county || null]), - ]) - return { counties: countyResult.rows, municipalities: municipalityResult.rows } - }, - routes: async (_, { search, minLength, maxLength, difficulty, routeType, county, municipality, west, south, east, north, offset, limit }) => { - await routeCatalogReady - const bounds = [west,south,east,north] - const hasBounds = bounds.every(Number.isFinite) - if (search == null || search.length > 120 || !Number.isInteger(offset) || offset < 0 || offset > 100000 || !Number.isInteger(limit) || limit < 1 || limit > 30 || + ]) + return { counties: countyResult.rows, municipalities: municipalityResult.rows } + }, + routes: async (_, { search, minLength, maxLength, difficulty, routeType, county, municipality, west, south, east, north, offset, limit }) => { + await routeCatalogReady + const bounds = [west, south, east, north] + const hasBounds = bounds.every(Number.isFinite) + if (search == null || search.length > 120 || !Number.isInteger(offset) || offset < 0 || offset > 100000 || !Number.isInteger(limit) || limit < 1 || limit > 30 || (minLength != null && (!Number.isFinite(minLength) || minLength < 0)) || (maxLength != null && (!Number.isFinite(maxLength) || maxLength < 0)) || (minLength != null && maxLength != null && minLength > maxLength) || (bounds.some(value => value != null) && !hasBounds) || (hasBounds && (west < -180 || east > 180 || south < -90 || north > 90 || west >= east || south >= north))) { - throw new GraphQLError('Ugyldig søk eller sidestørrelse.') - } - const { rows } = await pool.query(` + throw new GraphQLError('Ugyldig søk eller sidestørrelse.') + } + const { rows } = await pool.query(` WITH base AS ( SELECT c.id,c.name,c.number,c.length_meters,c.difficulty,c.route_type,c.geom, d.duplicate_count AS "duplicateCount" @@ -207,17 +245,17 @@ const schema = createSchema({ SELECT (SELECT count(*)::int FROM grouped) AS total, coalesce((SELECT json_agg(page ORDER BY name) FROM page),'[]'::json) AS items `, [search.trim(), minLength ?? null, maxLength ?? null, hasBounds ? west : null, hasBounds ? south : null, hasBounds ? east : null, hasBounds ? north : null, difficulty || null, routeType || null, county || null, municipality || null, limit, offset]) - return rows[0] - }, - exampleRoute: () => loadRoute('Romsdalseggen', 'F_20140827_2'), - route: (_, { name, number }) => { - if (!name.trim() || name.length > 500 || !number.trim() || number.length > 500) throw new GraphQLError('Ugyldig rute.') - return loadRoute(name, number) - }, - routeVariants: async (_, { name }) => { - if (!name.trim() || name.length > 500) throw new GraphQLError('Ugyldig rutenavn.') - await routeCatalogReady - const { rows } = await pool.query(` + return rows[0] + }, + exampleRoute: () => loadRoute('Romsdalseggen', 'F_20140827_2'), + route: (_, { name, number }) => { + if (!name.trim() || name.length > 500 || !number.trim() || number.length > 500) throw new GraphQLError('Ugyldig rute.') + return loadRoute(name, number) + }, + routeVariants: async (_, { name }) => { + if (!name.trim() || name.length > 500) throw new GraphQLError('Ugyldig rutenavn.') + await routeCatalogReady + const { rows } = await pool.query(` WITH variants AS ( SELECT c.id,c.name,c.number,c.length_meters,c.difficulty,c.route_type, d.duplicate_count AS "duplicateCount" @@ -232,75 +270,86 @@ const schema = createSchema({ SELECT id,name,number,length_meters AS "lengthMeters",difficulty, route_type AS "routeType","duplicateCount",count(*) OVER ()::int AS "variantCount" FROM variants ORDER BY CASE WHEN number='SAMLET_RUNDE' THEN 0 ELSE 1 END,length_meters,number`, [name.trim()]) - return rows - }, - me: async (_, { token }) => { - const user = await userFromToken(token) - return user ? profileFor(user) : null - }, - }, Mutation: { - register: async (_, { username, password }) => { - await appSchemaReady - const cleanUsername = validateCredentials(username, password) - const salt = randomBytes(16) - const hash = await passwordHash(password, salt) - const client = await pool.connect() - try { - await client.query('BEGIN') - const { rows } = await client.query(`INSERT INTO norwegian_explorer_app.users(username,password_hash,password_salt) + return rows + }, + me: async (_, { token }) => { + const user = await userFromToken(token) + return user ? profileFor(user) : null + }, + }, Mutation: { + register: async (_, { username, password }) => { + await appSchemaReady + const cleanUsername = validateCredentials(username, password) + const salt = randomBytes(16) + const hash = await passwordHash(password, salt) + const client = await pool.connect() + try { + await client.query('BEGIN') + const { rows } = await client.query(`INSERT INTO norwegian_explorer_app.users(username,password_hash,password_salt) VALUES ($1,$2,$3) RETURNING id,username`, [cleanUsername, hash, salt]) - const token = await createSession(client, rows[0].id) - await client.query('COMMIT') - return { token, user: { username: rows[0].username, favorites: [] } } - } catch (error) { - await client.query('ROLLBACK') - if (error?.code === '23505') throw new GraphQLError('Brukernavnet er allerede i bruk.') - throw error - } finally { client.release() } - }, - login: async (_, { username, password }) => { - await appSchemaReady - const { rows } = await pool.query('SELECT id,username,email,phone,avatar,password_hash,password_salt FROM norwegian_explorer_app.users WHERE lower(username)=lower($1)', [username.trim()]) - const user = rows[0] - const candidate = user ? await passwordHash(password, user.password_salt) : Buffer.alloc(64) - if (!user || candidate.length !== user.password_hash.length || !timingSafeEqual(candidate, user.password_hash)) throw new GraphQLError('Feil brukernavn eller passord.') - const token = await createSession(pool, user.id) - return { token, user: await profileFor(user) } - }, - logout: async (_, { token }) => { - await appSchemaReady - await pool.query('DELETE FROM norwegian_explorer_app.sessions WHERE token_hash=$1', [tokenHash(token)]) - return true - }, - toggleFavorite: async (_, { token, routeId }) => { - if (!/^[a-f0-9]{32}$/.test(routeId)) throw new GraphQLError('Ugyldig rute.') - const user = await requireUser(token) - await routeCatalogReady - const routeResult = await pool.query(`SELECT name,number FROM norwegian_explorer_app.route_catalog WHERE id=$1 + const token = await createSession(client, rows[0].id) + await client.query('COMMIT') + return { token, user: { username: rows[0].username, homeMunicipality: null, favorites: [] } } + } catch (error) { + await client.query('ROLLBACK') + if (error?.code === '23505') throw new GraphQLError('Brukernavnet er allerede i bruk.') + throw error + } finally { client.release() } + }, + login: async (_, { username, password }) => { + await appSchemaReady + const { rows } = await pool.query(`SELECT id,username,email,phone,avatar,${await homeColumn()},password_hash,password_salt FROM norwegian_explorer_app.users WHERE lower(username)=lower($1)`, [username.trim()]) + const user = rows[0] + const candidate = user ? await passwordHash(password, user.password_salt) : Buffer.alloc(64) + if (!user || candidate.length !== user.password_hash.length || !timingSafeEqual(candidate, user.password_hash)) throw new GraphQLError('Feil brukernavn eller passord.') + const token = await createSession(pool, user.id) + return { token, user: await profileFor(user) } + }, + logout: async (_, { token }) => { + await appSchemaReady + await pool.query('DELETE FROM norwegian_explorer_app.sessions WHERE token_hash=$1', [tokenHash(token)]) + return true + }, + toggleFavorite: async (_, { token, routeId }) => { + if (!/^[a-f0-9]{32}$/.test(routeId)) throw new GraphQLError('Ugyldig rute.') + const user = await requireUser(token) + await routeCatalogReady + const routeResult = await pool.query(`SELECT name,number FROM norwegian_explorer_app.route_catalog WHERE id=$1 UNION ALL SELECT name,number FROM norwegian_explorer_app.route_loops WHERE id=$1`, [routeId]) - if (!routeResult.rows[0]) throw new GraphQLError('Ruten finnes ikke.') - const removed = await pool.query('DELETE FROM norwegian_explorer_app.favorites WHERE user_id=$1 AND route_id=$2 RETURNING 1', [user.id,routeId]) - if (removed.rowCount === 0) await pool.query('INSERT INTO norwegian_explorer_app.favorites(user_id,route_id,route_name,route_number) VALUES ($1,$2,$3,$4)', [user.id,routeId,routeResult.rows[0].name,routeResult.rows[0].number]) - return profileFor(user) - }, - updateProfile: async (_, { token, email, phone, avatar }) => { - const user = await requireUser(token) - const cleanEmail = email?.trim() || null - const cleanPhone = phone?.trim() || null - const cleanAvatar = avatar?.trim() || null - if (cleanEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cleanEmail)) throw new GraphQLError('Skriv inn en gyldig e-postadresse.') - if (cleanPhone && !/^\+?[0-9 ()-]{6,20}$/.test(cleanPhone)) throw new GraphQLError('Skriv inn et gyldig telefonnummer.') - if (cleanAvatar && (!cleanAvatar.startsWith('data:image/') || cleanAvatar.length > 600000)) throw new GraphQLError('Profilbildet må være et bilde på under 450 KB.') - const { rows } = await pool.query(`UPDATE norwegian_explorer_app.users - SET email=$2,phone=$3,avatar=$4 WHERE id=$1 RETURNING id,username,email,phone,avatar`, - [user.id, cleanEmail, cleanPhone, cleanAvatar]) - return profileFor(rows[0]) - }, - } }, mountainResolvers], + if (!routeResult.rows[0]) throw new GraphQLError('Ruten finnes ikke.') + const removed = await pool.query('DELETE FROM norwegian_explorer_app.favorites WHERE user_id=$1 AND route_id=$2 RETURNING 1', [user.id, routeId]) + if (removed.rowCount === 0) await pool.query('INSERT INTO norwegian_explorer_app.favorites(user_id,route_id,route_name,route_number) VALUES ($1,$2,$3,$4)', [user.id, routeId, routeResult.rows[0].name, routeResult.rows[0].number]) + return profileFor(user) + }, + updateProfile: async (_, { token, email, phone, avatar }) => { + const user = await requireUser(token) + const cleanEmail = email?.trim() || null + const cleanPhone = phone?.trim() || null + const cleanAvatar = avatar?.trim() || null + if (cleanEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cleanEmail)) throw new GraphQLError('Skriv inn en gyldig e-postadresse.') + if (cleanPhone && !/^\+?[0-9 ()-]{6,20}$/.test(cleanPhone)) throw new GraphQLError('Skriv inn et gyldig telefonnummer.') + if (cleanAvatar && (!cleanAvatar.startsWith('data:image/') || cleanAvatar.length > 600000)) throw new GraphQLError('Profilbildet må være et bilde på under 450 KB.') + const { rows } = await pool.query(`UPDATE norwegian_explorer_app.users + SET email=$2,phone=$3,avatar=$4 WHERE id=$1 RETURNING id,username,email,phone,avatar,${await homeColumn()}`, + [user.id, cleanEmail, cleanPhone, cleanAvatar]) + return profileFor(rows[0]) + }, + setHomeMunicipality: async (_, { token, code }) => { + const user = await requireUser(token) + const cleanCode = code?.trim() || null + // Godta bare kommuner som faktisk finnes i rutedataene + if (cleanCode && !(await municipalityByCode(cleanCode))) throw new GraphQLError('Ukjent kommune.') + if (!(await hasHomeMunicipality)) throw new GraphQLError('Stedsvalg er ikke slått på i databasen ennå.') + const { rows } = await pool.query(`UPDATE norwegian_explorer_app.users SET home_municipality=$2 + WHERE id=$1 RETURNING id,username,email,phone,avatar,home_municipality`, [user.id, cleanCode]) + return profileFor(rows[0]) + }, + } + }, mountainResolvers], }) async function loadRoute(name, number) { - await routeCatalogReady - const { rows } = await pool.query(` + await routeCatalogReady + const { rows } = await pool.query(` SELECT id,name,number,segment_count AS "segmentCount",length_meters AS "lengthMeters", connected,maintainer,difficulty,route_type AS "routeType",description, name_quality AS "nameQuality",d.duplicate_count AS "duplicateCount", @@ -323,7 +372,7 @@ async function loadRoute(name, number) { WHERE lower(c2.name)=lower(l.name)) AS "variantCount", ST_AsGeoJSON(ST_Transform(l.geom,4326)) AS geojson FROM norwegian_explorer_app.route_loops l WHERE l.name=$1 AND l.number=$2`, [name.trim(), number.trim()]) - return rows[0] || null + return rows[0] || null } const yoga = createYoga({ schema, cors: false, graphiql: process.env.NODE_ENV !== 'production' }) const server = createServer(yoga) diff --git a/server/sql/app/home_municipality.sql b/server/sql/app/home_municipality.sql new file mode 100644 index 0000000..8996937 --- /dev/null +++ b/server/sql/app/home_municipality.sql @@ -0,0 +1,7 @@ +-- Legger til kolonnen for «Mitt sted» (kommunen brukeren vil se turforslag fra på forsiden). +-- +-- Må kjøres én gang av en databasebruker som eier tabellen norwegian_explorer_app.users. +-- Brukeren «gruppe» har ikke lov til å endre tabellen, så kjør den på VM-en, f.eks.: +-- sudo -u postgres psql -d norwegian_explorer -f server/sql/app/home_municipality.sql +-- Fila kan trygt kjøres flere ganger. +ALTER TABLE norwegian_explorer_app.users ADD COLUMN IF NOT EXISTS home_municipality text; diff --git a/src/Profile.tsx b/src/Profile.tsx index 4dc84ac..3b08431 100644 --- a/src/Profile.tsx +++ b/src/Profile.tsx @@ -2,7 +2,8 @@ import { useEffect, useState, type ChangeEvent, type FormEvent } from 'react' import './Profile.css' type Favorite = { id: string; name: string; number: string; createdAt: string } -type UserProfile = { username: string; email: string | null; phone: string | null; avatar: string | null; favorites: Favorite[] } +type Municipality = { code: string; name: string } +type UserProfile = { username: string; email: string | null; phone: string | null; avatar: string | null; homeMunicipality: Municipality | null; favorites: Favorite[] } type SelectedRoute = { id?: string; name: string; number: string } const TOKEN_KEY = 'norwegian-explorer-session' @@ -17,7 +18,7 @@ async function graphQL(query: string, variables: Record, signal } export function AuthGate({ onAuthenticated, onHome }: { onAuthenticated: () => void, onHome?: () => void }) { - const [mode, setMode] = useState<'login'|'register'>('login') + const [mode, setMode] = useState<'login' | 'register'>('login') const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [message, setMessage] = useState('') @@ -71,17 +72,29 @@ export function AuthGate({ onAuthenticated, onHome }: { onAuthenticated: () => v export default function Profile({ selected, onSelectRoute, disabled, onLogout }: { selected: SelectedRoute | null, onSelectRoute: (route: SelectedRoute) => void, disabled: boolean, onLogout?: () => void }) { const [user, setUser] = useState(null) - const [mode, setMode] = useState<'login'|'register'>('login') + const [mode, setMode] = useState<'login' | 'register'>('login') const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [message, setMessage] = useState('') const [busy, setBusy] = useState(false) const [loadingProfile, setLoadingProfile] = useState(true) const token = localStorage.getItem(TOKEN_KEY) + const [municipalities, setMunicipalities] = useState([]) + const loggedIn = Boolean(user) + + // Kommunelisten til «Mitt sted» hentes først når brukeren er logget inn + useEffect(() => { + if (!loggedIn) return + const controller = new AbortController() + void graphQL('query{areas{municipalities{code name}}}', {}, controller.signal) + .then(data => setMunicipalities(data.areas.municipalities)) + .catch(() => undefined) + return () => controller.abort() + }, [loggedIn]) useEffect(() => { if (!token || disabled) return - void graphQL('query($token:String!){me(token:$token){username email phone avatar favorites{id name number createdAt}}}', { token }) + void graphQL('query($token:String!){me(token:$token){username email phone avatar homeMunicipality{code name} favorites{id name number createdAt}}}', { token }) .then(data => { setUser(data.me); if (!data.me) { localStorage.removeItem(TOKEN_KEY); onLogout?.() } }) .catch(() => setMessage('Kunne ikke hente profilen. Oppdater siden for å prøve igjen.')) .finally(() => setLoadingProfile(false)) @@ -91,7 +104,7 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout }: event.preventDefault(); setBusy(true); setMessage('') try { const mutation = mode === 'login' ? 'login' : 'register' - const data = await graphQL(`mutation($username:String!,$password:String!){${mutation}(username:$username,password:$password){token user{username email phone avatar favorites{id name number createdAt}}}}`, { username, password }) + const data = await graphQL(`mutation($username:String!,$password:String!){${mutation}(username:$username,password:$password){token user{username email phone avatar homeMunicipality{code name} favorites{id name number createdAt}}}}`, { username, password }) localStorage.setItem(TOKEN_KEY, data[mutation].token); setUser(data[mutation].user); setPassword('') } catch (error) { setMessage(error instanceof Error ? error.message : 'Noe gikk galt.') } finally { setBusy(false) } @@ -102,7 +115,7 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout }: if (!currentToken || !selected?.id) return setBusy(true); setMessage('') try { - const data = await graphQL('mutation($token:String!,$routeId:ID!){toggleFavorite(token:$token,routeId:$routeId){username email phone avatar favorites{id name number createdAt}}}', { token: currentToken, routeId: selected.id }) + const data = await graphQL('mutation($token:String!,$routeId:ID!){toggleFavorite(token:$token,routeId:$routeId){username email phone avatar homeMunicipality{code name} favorites{id name number createdAt}}}', { token: currentToken, routeId: selected.id }) setUser(data.toggleFavorite) } catch (error) { setMessage(error instanceof Error ? error.message : 'Noe gikk galt.') } finally { setBusy(false) } @@ -121,7 +134,7 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout }: setBusy(true); setMessage('') const form = new FormData(event.currentTarget) try { - const data = await graphQL('mutation($token:String!,$email:String,$phone:String,$avatar:String){updateProfile(token:$token,email:$email,phone:$phone,avatar:$avatar){username email phone avatar favorites{id name number createdAt}}}', { + const data = await graphQL('mutation($token:String!,$email:String,$phone:String,$avatar:String){updateProfile(token:$token,email:$email,phone:$phone,avatar:$avatar){username email phone avatar homeMunicipality{code name} favorites{id name number createdAt}}}', { token: currentToken, email: String(form.get('email') || ''), phone: String(form.get('phone') || ''), avatar: user.avatar || '', }) setUser(data.updateProfile); setMessage('Profilen er lagret.') @@ -129,6 +142,18 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout }: finally { setBusy(false) } } + // Lagrer stedet brukeren vil se turforslag fra på forsiden. Lagres med en gang det velges. + async function saveHomeMunicipality(code: string) { + const currentToken = localStorage.getItem(TOKEN_KEY) + if (!currentToken) return + setBusy(true); setMessage('') + try { + const data = await graphQL('mutation($token:String!,$code:String){setHomeMunicipality(token:$token,code:$code){username email phone avatar homeMunicipality{code name} favorites{id name number createdAt}}}', { token: currentToken, code }) + setUser(data.setHomeMunicipality); setMessage('Stedet er lagret.') + } catch (error) { setMessage(error instanceof Error ? error.message : 'Noe gikk galt.') } + finally { setBusy(false) } + } + function chooseAvatar(event: ChangeEvent) { const file = event.target.files?.[0] if (!file) return @@ -160,12 +185,18 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout }: {user.avatar && } + {selected?.id && } {message &&

{message}

}

Favoritter ({user.favorites.length})

- {user.favorites.length === 0 ?

Du har ikke lagret noen ruter ennå.

:
    {user.favorites.map(item =>
  • )}
} + {user.favorites.length === 0 ?

Du har ikke lagret noen ruter ennå.

:
    {user.favorites.map(item =>
  • )}
}
} From 1b1dd0bd6a299b7ee46c0641b7c91113188bfae3 Mon Sep 17 00:00:00 2001 From: Martine Date: Wed, 14 Oct 2026 12:45:24 +0200 Subject: [PATCH 4/5] =?UTF-8?q?Vis=20turforslag=20fra=20valgt=20sted=20ell?= =?UTF-8?q?er=20mest=20popul=C3=A6re=20turer=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/index.mjs | 33 ++++++++++++++ src/App.tsx | 3 +- src/Home.css | 41 ++++++++++++++++++ src/Home.tsx | 109 ++++++++++++++++++++++++++++++++++++++++++++--- src/Profile.tsx | 2 +- src/graphql.ts | 3 ++ 6 files changed, 182 insertions(+), 9 deletions(-) diff --git a/server/index.mjs b/server/index.mjs index 44dfb53..57d9b28 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -138,6 +138,7 @@ const schema = createSchema({ type RouteSummary { id: ID!, name: String!, number: String!, lengthMeters: Float!, difficulty: String, routeType: String, duplicateCount: Int!, variantCount: Int! } type RouteGroup { key: String!, name: String!, variantCount: Int!, minLengthMeters: Float!, maxLengthMeters: Float!, variants: [RouteSummary!]! } type RoutePage { items: [RouteGroup!]!, total: Int! } + type PopularRoute { favoriteCount: Int!, route: RouteSummary! } type AreaOption { code: String!, name: String!, routeCount: Int! } type AreaOptions { counties: [AreaOption!]!, municipalities: [AreaOption!]! } type OsmPage { geojson: String!, truncated: Boolean! } @@ -151,6 +152,8 @@ const schema = createSchema({ areas(county: String): AreaOptions! route(name: String!, number: String!): Route routeVariants(name: String!): [RouteSummary!]! + # De mest populære turene i hele landet, målt i antall brukere som har lagret dem som favoritt + popularRoutes(limit: Int = 6): [PopularRoute!]! exampleRoute: Route me(token: String!): UserProfile } @@ -247,6 +250,36 @@ const schema = createSchema({ `, [search.trim(), minLength ?? null, maxLength ?? null, hasBounds ? west : null, hasBounds ? south : null, hasBounds ? east : null, hasBounds ? north : null, difficulty || null, routeType || null, county || null, municipality || null, limit, offset]) return rows[0] }, + popularRoutes: async (_, { limit }) => { + if (!Number.isInteger(limit) || limit < 1 || limit > 30) throw new GraphQLError('Ugyldig antall.') + await routeCatalogReady + // Én rad per rutenavn (den mest lagrede varianten). Har få turer favoritter ennå, fylles lista opp + // med graderte turer som har beskrivelse, deretter turer som er registrert flere ganger i Kartverkets data. + const { rows } = await pool.query(` + WITH favorite_counts AS ( + SELECT route_id, count(*)::int AS n FROM norwegian_explorer_app.favorites + WHERE route_id IS NOT NULL GROUP BY route_id + ), candidates AS ( + SELECT DISTINCT ON (lower(c.name)) + c.id, c.name, c.number, c.length_meters, c.difficulty, c.route_type, c.description, + d.duplicate_count, coalesce(f.n, 0) AS favorite_count + FROM norwegian_explorer_app.route_catalog c + JOIN norwegian_explorer_app.route_duplicate_map d ON d.route_id=c.id AND d.canonical_id=c.id + LEFT JOIN favorite_counts f ON f.route_id=c.id + WHERE c.name_quality <> 'suspect' AND c.length_meters >= 1000 + AND NOT EXISTS (SELECT 1 FROM norwegian_explorer_app.route_exclusions e WHERE e.route_id=c.id) + ORDER BY lower(c.name), coalesce(f.n, 0) DESC, d.duplicate_count DESC, c.length_meters + ) + SELECT favorite_count AS "favoriteCount", json_build_object( + 'id', id, 'name', name, 'number', number, 'lengthMeters', length_meters, + 'difficulty', difficulty, 'routeType', route_type, 'duplicateCount', duplicate_count, 'variantCount', 1 + ) AS route + FROM candidates + ORDER BY favorite_count DESC, (difficulty IS NOT NULL AND description IS NOT NULL) DESC, + duplicate_count DESC, name + LIMIT $1`, [limit]) + return rows + }, exampleRoute: () => loadRoute('Romsdalseggen', 'F_20140827_2'), route: (_, { name, number }) => { if (!name.trim() || name.length > 500 || !number.trim() || number.length > 500) throw new GraphQLError('Ugyldig rute.') diff --git a/src/App.tsx b/src/App.tsx index d9fbeda..9033957 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -198,7 +198,8 @@ export default function App() { void load() return () => controller.abort() }, [selected, attempt]) - if (!authenticated && !showLogin) return setShowLogin(true)} /> + // Velger man en tur på forsiden, åpnes den etter innlogging + if (!authenticated && !showLogin) return setShowLogin(true)} onSelectRoute={item => { openRoute(item); setShowLogin(true) }} /> if (!authenticated) return setShowLogin(false)} /> return <> diff --git a/src/Home.css b/src/Home.css index 7eae331..1163ccd 100644 --- a/src/Home.css +++ b/src/Home.css @@ -97,7 +97,48 @@ margin: 0; } +.home-nearby-subtitle { + color: #5a6c61; + margin: 8px 0 0; +} + +/* Kort med turforslag: tre i bredden, to på nettbrett og ett på mobil */ +.home-suggestions { + list-style: none; + padding: 0; + margin: 24px 0 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + text-align: left; +} + +.home-suggestions button { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + gap: 6px; + text-align: left; + background: #fff; + color: #203b32; + border: 1px solid #dce1d7; + border-radius: 14px; + padding: 20px; + overflow-wrap: anywhere; +} + +.home-suggestions button:hover { border-color: #126b51; } +.home-suggestions strong { font-size: 18px; } +.home-suggestion-length { font-size: 15px; font-weight: 600; color: #126b51; } +.home-suggestion-meta { font-size: 13px; color: #5a6c61; } + @media (max-width: 800px) { .home-hero { min-height: 440px; } .home-search input { font-size: 17px; padding: 18px 0; } + .home-suggestions { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 480px) { + .home-suggestions { grid-template-columns: 1fr; } } diff --git a/src/Home.tsx b/src/Home.tsx index d5c5962..55abbcc 100644 --- a/src/Home.tsx +++ b/src/Home.tsx @@ -1,3 +1,5 @@ +import { useEffect, useState } from 'react' +import { graphql, TOKEN_KEY } from './graphql' import './Home.css' /** @@ -6,15 +8,92 @@ import './Home.css' */ const HERO_IMAGE_URL = `${import.meta.env.BASE_URL}hero.svg` // egen illustrasjon i public/hero.svg +type Municipality = { code: string; name: string } +type RouteVariant = { name: string; number: string } +type RouteGroup = { + key: string + name: string + variantCount: number + minLengthMeters: number + maxLengthMeters: number + variants: (RouteVariant & { difficulty: string | null; routeType: string | null })[] + /** Bare satt for populære turer: hvor mange brukere som har lagret turen */ + favoriteCount?: number +} +type RoutePage = { routes: { total: number; items: RouteGroup[] } } +type PopularRoute = { favoriteCount: number; route: RouteVariant & { id: string; lengthMeters: number; difficulty: string | null; routeType: string | null } } + +// Turer i én kommune. Samme routes-spørring som turlisten bruker, men med få treff. +const AREA_ROUTES_QUERY = `query($municipality:String!,$limit:Int!){ + routes(search:"",municipality:$municipality,limit:$limit){ + total items{key name variantCount minLengthMeters maxLengthMeters variants{name number difficulty routeType}} + } +}` +// De mest populære turene i hele landet, brukt når brukeren ikke har valgt sted +const POPULAR_QUERY = `query($limit:Int!){ + popularRoutes(limit:$limit){ favoriteCount route{id name number lengthMeters difficulty routeType} } +}` +/** Hvor mange turforslag forsiden viser */ +const SUGGESTION_COUNT = 6 + +/** Gjør en populær tur om til samme form som et søketreff, så kortene kan vises likt */ +function popularToGroup({ favoriteCount, route }: PopularRoute): RouteGroup { + return { key: route.id, name: route.name, variantCount: 1, minLengthMeters: route.lengthMeters, maxLengthMeters: route.lengthMeters, variants: [route], favoriteCount } +} + +function formatLength(group: RouteGroup) { + const km = (meters: number) => (meters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 }) + return group.minLengthMeters === group.maxLengthMeters + ? `${km(group.minLengthMeters)} km` + : `${km(group.minLengthMeters)}–${km(group.maxLengthMeters)} km` +} + /** - * Forsiden som vises før innlogging. Foreløpig bare en mal for oppsettet: - * header, hero med tittel og søkefelt, og en seksjon for turforslag. - * Søk og turforslag fra databasen kobles på senere. + * Forsiden som vises før innlogging: header, hero med tittel og søkefelt, + * og turforslag fra stedet brukeren har valgt i profilen (eller de mest populære turene). + * Søket kobles på i et eget steg. */ -export default function Home({ onLoginClick }: { +export default function Home({ onLoginClick, onSelectRoute }: { onLoginClick: () => void - onSelectRoute?: (route: { name: string; number: string }) => void + onSelectRoute: (route: RouteVariant) => void }) { + // Stedet velges i profilen (Profile.tsx) og lagres på brukeren i databasen. Forsiden leser det bare. + const [token] = useState(() => localStorage.getItem(TOKEN_KEY)) + const [home, setHome] = useState(null) + // Svaret lagres sammen med stedet det hører til. Da kan status avledes under rendering + // i stedet for å settes i effekten (unngår ekstra renders). + const [suggestionResult, setSuggestionResult] = useState<{ key: string; items: RouteGroup[] | null } | null>(null) + + // Henter stedet som er lagret på brukeren, hvis man er logget inn + useEffect(() => { + if (!token) return + const controller = new AbortController() + graphql<{ me: { homeMunicipality: Municipality | null } | null }>( + 'query($token:String!){me(token:$token){homeMunicipality{code name}}}', { token }, controller.signal) + .then(data => setHome(data.me?.homeMunicipality ?? null)) + .catch(() => undefined) + return () => controller.abort() + }, [token]) + + // Turforslag: fra brukerens sted hvis det er valgt, ellers de mest populære turene i hele Norge. + // Filtreringen og sorteringen skjer i databasen, ikke på klienten. + const suggestionKey = home?.code ?? '' + useEffect(() => { + const controller = new AbortController() + const request = suggestionKey + ? graphql(AREA_ROUTES_QUERY, { municipality: suggestionKey, limit: SUGGESTION_COUNT }, controller.signal) + .then(data => data.routes.items) + : graphql<{ popularRoutes: PopularRoute[] }>(POPULAR_QUERY, { limit: SUGGESTION_COUNT }, controller.signal) + .then(data => data.popularRoutes.map(popularToGroup)) + request + .then(items => setSuggestionResult({ key: suggestionKey, items })) + .catch(() => { if (!controller.signal.aborted) setSuggestionResult({ key: suggestionKey, items: null }) }) + return () => controller.abort() + }, [suggestionKey]) + const suggestionStatus = suggestionResult?.key !== suggestionKey ? 'loading' + : suggestionResult.items ? 'ready' : 'error' + const suggestions = suggestionResult?.items ?? [] + return <>
△ Norwegian Explorer @@ -40,8 +119,24 @@ export default function Home({ onLoginClick }: {
-

Velg din neste tur

- {/* Turforslag fra databasen kommer her */} +

{home ? `Velg din neste tur i ${home.name}` : 'Velg din neste tur'}

+ {!home &&

De mest populære turene i Norge

} + {suggestionStatus === 'loading' &&

Henter turforslag …

} + {suggestionStatus === 'error' &&

Kunne ikke hente turforslag. Kontroller at backend kjører.

} + {suggestionStatus === 'ready' && suggestions.length === 0 &&

Fant ingen registrerte turer{home ? ` i ${home.name}` : ''}.

} + {suggestionStatus === 'ready' && suggestions.length > 0 &&
    + {suggestions.map(group => { + const first = group.variants[0] + return
  • + +
  • + })} +
}
diff --git a/src/Profile.tsx b/src/Profile.tsx index 3b08431..ada939a 100644 --- a/src/Profile.tsx +++ b/src/Profile.tsx @@ -1,11 +1,11 @@ import { useEffect, useState, type ChangeEvent, type FormEvent } from 'react' import './Profile.css' +import { TOKEN_KEY } from './graphql' type Favorite = { id: string; name: string; number: string; createdAt: string } type Municipality = { code: string; name: string } type UserProfile = { username: string; email: string | null; phone: string | null; avatar: string | null; homeMunicipality: Municipality | null; favorites: Favorite[] } type SelectedRoute = { id?: string; name: string; number: string } -const TOKEN_KEY = 'norwegian-explorer-session' async function graphQL(query: string, variables: Record, signal = AbortSignal.timeout(15000)) { const response = await fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal, body: JSON.stringify({ query, variables }) }) diff --git a/src/graphql.ts b/src/graphql.ts index 5539adf..9f1e609 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -16,3 +16,6 @@ export async function graphql(query: string, variables: Record Date: Wed, 14 Oct 2026 12:56:21 +0200 Subject: [PATCH 5/5] =?UTF-8?q?S=C3=B8k=20etter=20review=20er=20fikset=20(?= =?UTF-8?q?#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/index.mjs | 16 +++++ src/Home.css | 127 ++++++++++++++++++++++++++++++++++----- src/Home.tsx | 150 ++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 251 insertions(+), 42 deletions(-) diff --git a/server/index.mjs b/server/index.mjs index 57d9b28..f775470 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -154,6 +154,8 @@ const schema = createSchema({ routeVariants(name: String!): [RouteSummary!]! # De mest populære turene i hele landet, målt i antall brukere som har lagret dem som favoritt popularRoutes(limit: Int = 6): [PopularRoute!]! + # Kommuner med navn som inneholder søketeksten, brukt i søkefeltet på forsiden + searchMunicipalities(query: String!, limit: Int = 4): [AreaOption!]! exampleRoute: Route me(token: String!): UserProfile } @@ -280,6 +282,20 @@ const schema = createSchema({ LIMIT $1`, [limit]) return rows }, + searchMunicipalities: async (_, { query, limit }) => { + const search = query.trim() + if (!search || search.length > 120 || !Number.isInteger(limit) || limit < 1 || limit > 20) throw new GraphQLError('Ugyldig søk.') + await routeCatalogReady + // Navn som starter med søketeksten kommer først, deretter kommunene med flest turer + const { rows } = await pool.query(` + SELECT municipality_number AS code, min(municipality_name) AS name, count(DISTINCT route_id)::int AS "routeCount" + FROM norwegian_explorer_app.route_areas + WHERE strpos(lower(municipality_name), lower($1)) > 0 + GROUP BY municipality_number + ORDER BY bool_or(lower(municipality_name) LIKE lower($1) || '%') DESC, "routeCount" DESC, name + LIMIT $2`, [search, limit]) + return rows + }, exampleRoute: () => loadRoute('Romsdalseggen', 'F_20140827_2'), route: (_, { name, number }) => { if (!name.trim() || name.length > 500 || !number.trim() || number.length > 500) throw new GraphQLError('Ugyldig rute.') diff --git a/src/Home.css b/src/Home.css index 1163ccd..5034b38 100644 --- a/src/Home.css +++ b/src/Home.css @@ -28,7 +28,8 @@ align-items: center; justify-content: center; padding: 80px 5%; - background-color: #2c5a49; /* vises hvis bildet mangler eller ikke er lastet ennå */ + background-color: #2c5a49; + /* vises hvis bildet mangler eller ikke er lastet ennå */ background-size: cover; background-position: 72% center; /* holder fotturisten i bildet også på smale skjermer */ } @@ -84,7 +85,70 @@ outline: none; } -/* ---------- Turforslag ---------- */ +.home-search-results { + text-align: left; + margin-top: 10px; +} + +.home-search-results p { + color: #fff; + margin: 8px 4px; + font-size: 14px; +} + +/* Ett hvitt panel med gruppene «Steder» og «Turer» */ +.home-search-panel { + background: #fff; + border-radius: 10px; + box-shadow: 0 8px 30px rgb(0 0 0 / 0.2); + padding: 6px; +} + +.home-search-group { + margin: 10px 16px 4px; + font-size: 12px; + letter-spacing: 1.5px; + text-transform: uppercase; + color: #557365; +} + +.home-search-results ul { + list-style: none; + margin: 0; + padding: 0; +} + +.home-search-results button { + width: 100%; + display: flex; + justify-content: space-between; + gap: 12px; + text-align: left; + background: transparent; + color: #203b32; + padding: 12px 16px; +} + +.home-search-results button:hover { + background: #edf2e9; +} + +.home-search-results span { + color: #5a6c61; + font-size: 14px; + white-space: nowrap; +} + +.home-hero-credit { + position: absolute; + right: 16px; + bottom: 8px; + margin: 0; + font-size: 13px; + color: rgb(255 255 255 / 0.85); +} + +/* ---------- Turforslag i nærområdet ---------- */ .home-nearby { max-width: 1200px; margin: 0 auto; @@ -97,12 +161,10 @@ margin: 0; } -.home-nearby-subtitle { - color: #5a6c61; - margin: 8px 0 0; -} -/* Kort med turforslag: tre i bredden, to på nettbrett og ett på mobil */ + + + .home-suggestions { list-style: none; padding: 0; @@ -128,17 +190,52 @@ overflow-wrap: anywhere; } -.home-suggestions button:hover { border-color: #126b51; } -.home-suggestions strong { font-size: 18px; } -.home-suggestion-length { font-size: 15px; font-weight: 600; color: #126b51; } -.home-suggestion-meta { font-size: 13px; color: #5a6c61; } +.home-suggestions button:hover { + border-color: #126b51; +} + +.home-suggestions strong { + font-size: 18px; +} + +.home-suggestion-length { + font-size: 15px; + font-weight: 600; + color: #126b51; +} + +.home-suggestion-meta { + font-size: 13px; + color: #5a6c61; +} @media (max-width: 800px) { - .home-hero { min-height: 440px; } - .home-search input { font-size: 17px; padding: 18px 0; } - .home-suggestions { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .home-hero { + min-height: 440px; + } + + .home-search input { + font-size: 17px; + padding: 18px 0; + } + + .home-suggestions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } @media (max-width: 480px) { - .home-suggestions { grid-template-columns: 1fr; } + .home-suggestions { + grid-template-columns: 1fr; + } +} +.home-nearby-subtitle { + color: #5a6c61; + margin: 8px 0 0; +} + +.home-nearby:focus { outline: none; } /* får fokus fra koden når et sted velges, trenger ingen ramme */ + +.home-nearby-reset { + margin-top: 8px; } diff --git a/src/Home.tsx b/src/Home.tsx index 55abbcc..c5b33e9 100644 --- a/src/Home.tsx +++ b/src/Home.tsx @@ -1,14 +1,21 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { graphql, TOKEN_KEY } from './graphql' import './Home.css' /** * Lenke til bakgrunnsbildet øverst på forsiden. + * Lim inn URL-en til bildet her (eller legg bildet i public/ og skriv f.eks. '/hero.jpg'). * Er feltet tomt, vises en grønn bakgrunn i stedet. */ const HERO_IMAGE_URL = `${import.meta.env.BASE_URL}hero.svg` // egen illustrasjon i public/hero.svg +/** Kreditering av fotografen, vises nederst i hjørnet av bildet. La stå tom hvis ikke nødvendig. */ +const HERO_IMAGE_CREDIT = '' + +/** Hvor lenge vi venter etter siste tastetrykk før vi søker (debouncing, sparer requests) */ +const SEARCH_DELAY_MS = 300 type Municipality = { code: string; name: string } +type Place = Municipality & { routeCount: number } type RouteVariant = { name: string; number: string } type RouteGroup = { key: string @@ -23,24 +30,31 @@ type RouteGroup = { type RoutePage = { routes: { total: number; items: RouteGroup[] } } type PopularRoute = { favoriteCount: number; route: RouteVariant & { id: string; lengthMeters: number; difficulty: string | null; routeType: string | null } } -// Turer i én kommune. Samme routes-spørring som turlisten bruker, men med få treff. -const AREA_ROUTES_QUERY = `query($municipality:String!,$limit:Int!){ - routes(search:"",municipality:$municipality,limit:$limit){ - total items{key name variantCount minLengthMeters maxLengthMeters variants{name number difficulty routeType}} - } -}` -// De mest populære turene i hele landet, brukt når brukeren ikke har valgt sted const POPULAR_QUERY = `query($limit:Int!){ popularRoutes(limit:$limit){ favoriteCount route{id name number lengthMeters difficulty routeType} } }` -/** Hvor mange turforslag forsiden viser */ -const SUGGESTION_COUNT = 6 /** Gjør en populær tur om til samme form som et søketreff, så kortene kan vises likt */ function popularToGroup({ favoriteCount, route }: PopularRoute): RouteGroup { return { key: route.id, name: route.name, variantCount: 1, minLengthMeters: route.lengthMeters, maxLengthMeters: route.lengthMeters, variants: [route], favoriteCount } } +// Samme routes-spørring som turlisten bruker, men med få treff siden forsiden bare viser et utvalg +const ROUTES_QUERY = `query($search:String!,$municipality:String,$limit:Int!){ + routes(search:$search,municipality:$municipality,limit:$limit){ + total items{key name variantCount minLengthMeters maxLengthMeters variants{name number difficulty routeType}} + } +}` + +// Søkefeltet henter turer og steder (kommuner) i én og samme GraphQL-forespørsel +const SEARCH_QUERY = `query($search:String!,$limit:Int!){ + routes(search:$search,limit:$limit){ + total items{key name variantCount minLengthMeters maxLengthMeters variants{name number difficulty routeType}} + } + searchMunicipalities(query:$search,limit:4){code name routeCount} +}` +type SearchData = { routes: RoutePage['routes']; searchMunicipalities: Place[] } + function formatLength(group: RouteGroup) { const km = (meters: number) => (meters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 }) return group.minLengthMeters === group.maxLengthMeters @@ -48,20 +62,44 @@ function formatLength(group: RouteGroup) { : `${km(group.minLengthMeters)}–${km(group.maxLengthMeters)} km` } -/** - * Forsiden som vises før innlogging: header, hero med tittel og søkefelt, - * og turforslag fra stedet brukeren har valgt i profilen (eller de mest populære turene). - * Søket kobles på i et eget steg. - */ export default function Home({ onLoginClick, onSelectRoute }: { onLoginClick: () => void onSelectRoute: (route: RouteVariant) => void }) { - // Stedet velges i profilen (Profile.tsx) og lagres på brukeren i databasen. Forsiden leser det bare. + // --- Søk --- + const [input, setInput] = useState('') + // Svaret lagres sammen med søket det hører til. Da kan status avledes under rendering + // i stedet for å settes i effekten (unngår ekstra renders). + const [searchResult, setSearchResult] = useState<{ search: string; data: SearchData | null } | null>(null) + + // Søk med debouncing: venter til brukeren har sluttet å skrive, og avbryter utdaterte requests + useEffect(() => { + const search = input.trim() + if (search.length < 2) return + const controller = new AbortController() + const timer = window.setTimeout(() => { + graphql(SEARCH_QUERY, { search, limit: 6 }, controller.signal) + .then(data => setSearchResult({ search, data })) + .catch(() => { if (!controller.signal.aborted) setSearchResult({ search, data: null }) }) + }, SEARCH_DELAY_MS) + return () => { window.clearTimeout(timer); controller.abort() } + }, [input]) + const trimmed = input.trim() + const searchStatus = trimmed.length < 2 ? 'idle' + : searchResult?.search !== trimmed ? 'loading' + : searchResult.data ? 'ready' : 'error' + const results = searchStatus === 'ready' ? searchResult!.data!.routes : null + const places = searchStatus === 'ready' ? searchResult!.data!.searchMunicipalities : [] + + // --- Nærområde --- + // Stedet velges i profilen (Profile.tsx) og lagres på brukeren i databasen. + // Forsiden leser det bare, og viser turforslag derfra. const [token] = useState(() => localStorage.getItem(TOKEN_KEY)) const [home, setHome] = useState(null) - // Svaret lagres sammen med stedet det hører til. Da kan status avledes under rendering - // i stedet for å settes i effekten (unngår ekstra renders). + // Sted valgt fra søkefeltet. Gjelder bare nå og lagres ikke, men går foran stedet i profilen. + const [place, setPlace] = useState(null) + const area = place ?? home + const nearbyRef = useRef(null) const [suggestionResult, setSuggestionResult] = useState<{ key: string; items: RouteGroup[] | null } | null>(null) // Henter stedet som er lagret på brukeren, hvis man er logget inn @@ -76,14 +114,14 @@ export default function Home({ onLoginClick, onSelectRoute }: { }, [token]) // Turforslag: fra brukerens sted hvis det er valgt, ellers de mest populære turene i hele Norge. - // Filtreringen og sorteringen skjer i databasen, ikke på klienten. - const suggestionKey = home?.code ?? '' + // Filtreringen skjer i databasen, ikke på klienten. + const suggestionKey = area?.code ?? '' useEffect(() => { const controller = new AbortController() const request = suggestionKey - ? graphql(AREA_ROUTES_QUERY, { municipality: suggestionKey, limit: SUGGESTION_COUNT }, controller.signal) + ? graphql(ROUTES_QUERY, { search: '', municipality: suggestionKey, limit: 6 }, controller.signal) .then(data => data.routes.items) - : graphql<{ popularRoutes: PopularRoute[] }>(POPULAR_QUERY, { limit: SUGGESTION_COUNT }, controller.signal) + : graphql<{ popularRoutes: PopularRoute[] }>(POPULAR_QUERY, { limit: 6 }, controller.signal) .then(data => data.popularRoutes.map(popularToGroup)) request .then(items => setSuggestionResult({ key: suggestionKey, items })) @@ -94,6 +132,16 @@ export default function Home({ onLoginClick, onSelectRoute }: { : suggestionResult.items ? 'ready' : 'error' const suggestions = suggestionResult?.items ?? [] + function choosePlace(chosen: Place) { + setPlace({ code: chosen.code, name: chosen.name }) + setInput('') + // Flytt synet (og fokus for skjermlesere) ned til turene i det valgte stedet + window.requestAnimationFrame(() => { + nearbyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + nearbyRef.current?.focus({ preventScroll: true }) + }) + } + return <>
△ Norwegian Explorer @@ -113,17 +161,65 @@ export default function Home({ onLoginClick, onSelectRoute }: {
event.preventDefault()}> - + setInput(event.target.value)} + aria-describedby="home-search-status" + />
+
+

+ {searchStatus === 'loading' && 'Søker …'} + {searchStatus === 'error' && 'Søket feilet. Kontroller at backend kjører.'} + {searchStatus === 'ready' && (results?.total || places.length + ? [places.length ? `${places.length} ${places.length === 1 ? 'sted' : 'steder'}` : '', + results?.total ? `${results.total.toLocaleString('nb-NO')} turer${results.total > results.items.length ? ` (viser ${results.items.length})` : ''}` : ''] + .filter(Boolean).join(' og ') + : 'Ingen steder eller turer passer søket.')} +

+ {searchStatus === 'ready' && (places.length > 0 || (results && results.items.length > 0)) &&
+ {places.length > 0 && <> +

Steder

+
    + {places.map(item =>
  • + +
  • )} +
+ } + {results && results.items.length > 0 && <> +

Turer

+
    + {results.items.map(group =>
  • + +
  • )} +
+ } +
} +
+ {HERO_IMAGE_CREDIT &&

{HERO_IMAGE_CREDIT}

} -
-

{home ? `Velg din neste tur i ${home.name}` : 'Velg din neste tur'}

- {!home &&

De mest populære turene i Norge

} +
+

{area ? `Velg din neste tur i ${area.name}` : 'Velg din neste tur'}

+ {!area &&

De mest populære turene i Norge

} + {place && } {suggestionStatus === 'loading' &&

Henter turforslag …

} {suggestionStatus === 'error' &&

Kunne ikke hente turforslag. Kontroller at backend kjører.

} - {suggestionStatus === 'ready' && suggestions.length === 0 &&

Fant ingen registrerte turer{home ? ` i ${home.name}` : ''}.

} + {suggestionStatus === 'ready' && suggestions.length === 0 &&

Fant ingen registrerte turer{area ? ` i ${area.name}` : ''}.

} {suggestionStatus === 'ready' && suggestions.length > 0 &&
    {suggestions.map(group => { const first = group.variants[0]