From aeac2781aa24df1ed2573cf5d0e4921b030de38e Mon Sep 17 00:00:00 2001 From: Kasper Date: Fri, 9 Oct 2026 15:30:55 +0200 Subject: [PATCH 1/2] feat: add challenges page with spin-the-wheel for places and challenges --- client/src/App.tsx | 8 +- client/src/api/challenges.ts | 17 + client/src/api/places.ts | 11 +- client/src/components/MainNav.tsx | 8 +- .../src/components/wheel/ChallengeResult.tsx | 10 + .../src/components/wheel/CombinedResult.tsx | 24 + client/src/components/wheel/PlaceResult.tsx | 28 ++ client/src/components/wheel/ResultDialog.tsx | 73 ++++ client/src/components/wheel/SpinWheel.tsx | 284 ++++++++++++ client/src/data/mockChallenges.ts | 72 +++ client/src/data/mockPlaces.ts | 6 +- client/src/hooks/useFetch.ts | 32 ++ client/src/hooks/useRoute.ts | 5 +- client/src/pages/ChallengesPage.css | 412 ++++++++++++++++++ client/src/pages/ChallengesPage.tsx | 218 +++++++++ client/src/types/challenge.ts | 6 + client/src/types/place.ts | 3 +- client/src/utils/wheel.ts | 80 ++++ 18 files changed, 1289 insertions(+), 8 deletions(-) create mode 100644 client/src/api/challenges.ts create mode 100644 client/src/components/wheel/ChallengeResult.tsx create mode 100644 client/src/components/wheel/CombinedResult.tsx create mode 100644 client/src/components/wheel/PlaceResult.tsx create mode 100644 client/src/components/wheel/ResultDialog.tsx create mode 100644 client/src/components/wheel/SpinWheel.tsx create mode 100644 client/src/data/mockChallenges.ts create mode 100644 client/src/hooks/useFetch.ts create mode 100644 client/src/pages/ChallengesPage.css create mode 100644 client/src/pages/ChallengesPage.tsx create mode 100644 client/src/types/challenge.ts create mode 100644 client/src/utils/wheel.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 4dac9c2..a33c6e4 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,10 +1,16 @@ import './App.css' import { useRoute } from './hooks/useRoute' +import { ChallengesPage } from './pages/ChallengesPage' import { HomePage } from './pages/HomePage' import { MapPage } from './pages/MapPage' import { UserPage } from './pages/UserPage' -const pages = { home: HomePage, map: MapPage, user: UserPage } +const pages = { + home: HomePage, + map: MapPage, + user: UserPage, + challenges: ChallengesPage, +} function App() { const Page = pages[useRoute()] diff --git a/client/src/api/challenges.ts b/client/src/api/challenges.ts new file mode 100644 index 0000000..c248173 --- /dev/null +++ b/client/src/api/challenges.ts @@ -0,0 +1,17 @@ +import { mockChallenges } from '../data/mockChallenges' +import type { Challenge } from '../types/challenge' + +/** Simulerer nettverksforsinkelse så lastetilstander kan sees i grensesnittet. */ +const LATENCY_MS = 200 + +/** + * Henter alle utfordringene som kan trekkes på hjulet. + * + * Når GraphQL-backenden er klar, byttes innholdet her ut med et kall mot + * serveren (med samme signatur). + */ +export async function fetchChallenges(): Promise { + await new Promise((resolve) => setTimeout(resolve, LATENCY_MS)) + + return mockChallenges +} diff --git a/client/src/api/places.ts b/client/src/api/places.ts index 4583c1e..35789cd 100644 --- a/client/src/api/places.ts +++ b/client/src/api/places.ts @@ -1,5 +1,5 @@ import { mockPlaces } from '../data/mockPlaces' -import type { PlacesPage } from '../types/place' +import type { Place, PlacesPage } from '../types/place' /** Simulerer nettverksforsinkelse så lastetilstander kan sees i grensesnittet. */ const LATENCY_MS = 200 @@ -22,3 +22,12 @@ export async function fetchPlaces( totalCount: mockPlaces.length, } } + +/** Så mange steder hentes når en side trenger hele listen (kart og hjul). */ +const ALL_LIMIT = 500 + +/** Henter alle steder. Byttes ut med egne spørringer når backenden er klar. */ +export async function fetchAllPlaces(): Promise { + const page = await fetchPlaces(0, ALL_LIMIT) + return page.items +} diff --git a/client/src/components/MainNav.tsx b/client/src/components/MainNav.tsx index 4155d32..0ef9132 100644 --- a/client/src/components/MainNav.tsx +++ b/client/src/components/MainNav.tsx @@ -1,5 +1,10 @@ import type { ReactNode } from 'react' -import { HOME_HREF, MAP_HREF, USER_HREF } from '../hooks/useRoute' +import { + CHALLENGES_HREF, + HOME_HREF, + MAP_HREF, + USER_HREF, +} from '../hooks/useRoute' /** Felles tegning for ikonene: små, enkle strek-ikoner. */ function Icon({ children }: { children: ReactNode }) { @@ -63,6 +68,7 @@ const links: { }, { label: 'Utfordringer', + href: CHALLENGES_HREF, // Flagg icon: ( diff --git a/client/src/components/wheel/ChallengeResult.tsx b/client/src/components/wheel/ChallengeResult.tsx new file mode 100644 index 0000000..7b2ee8d --- /dev/null +++ b/client/src/components/wheel/ChallengeResult.tsx @@ -0,0 +1,10 @@ +import type { Challenge } from '../../types/challenge' + +/** Utfordringen hjulet stoppet på. */ +export function ChallengeResult({ challenge }: { challenge: Challenge }) { + return ( +
+

{challenge.description}

+
+ ) +} diff --git a/client/src/components/wheel/CombinedResult.tsx b/client/src/components/wheel/CombinedResult.tsx new file mode 100644 index 0000000..c8c8e5b --- /dev/null +++ b/client/src/components/wheel/CombinedResult.tsx @@ -0,0 +1,24 @@ +import type { Challenge } from '../../types/challenge' +import type { Place } from '../../types/place' +import { ChallengeResult } from './ChallengeResult' +import { PlaceResult } from './PlaceResult' + +type CombinedResultProps = { place: Place; challenge: Challenge; now: Date } + +/** Sted og utfordring side om side, når begge hjulene er snurret samtidig. */ +export function CombinedResult({ place, challenge, now }: CombinedResultProps) { + return ( +
+
+

Hvor

+

{place.name}

+ +
+
+

Hva

+

{challenge.title}

+ +
+
+ ) +} diff --git a/client/src/components/wheel/PlaceResult.tsx b/client/src/components/wheel/PlaceResult.tsx new file mode 100644 index 0000000..c005aa7 --- /dev/null +++ b/client/src/components/wheel/PlaceResult.tsx @@ -0,0 +1,28 @@ +import type { Place } from '../../types/place' +import { OpeningStatus } from '../OpeningStatus' +import { PlaceImage } from '../PlaceImage' + +/** Informasjon om stedet hjulet stoppet på. */ +export function PlaceResult({ place, now }: { place: Place; now: Date }) { + return ( +
+
+ +
{place.type}
+
+ +

{place.description}

+ +
+
Åpningstid
+
+ +
+
Område
+
{place.area}
+
Adresse
+
{place.address}
+
+
+ ) +} diff --git a/client/src/components/wheel/ResultDialog.tsx b/client/src/components/wheel/ResultDialog.tsx new file mode 100644 index 0000000..8820db7 --- /dev/null +++ b/client/src/components/wheel/ResultDialog.tsx @@ -0,0 +1,73 @@ +import { useEffect, useId, useRef } from 'react' +import type { ReactNode } from 'react' + +type ResultDialogProps = { + isOpen: boolean + title: string + /** Skjuler overskriften, når innholdet har egne. */ + hideTitle?: boolean + wide?: boolean + onClose: () => void + children: ReactNode +} + +/** Et lite vindu med resultatet fra hjulet. Esc og X-knappen lukker det. */ +export function ResultDialog({ + isOpen, + title, + hideTitle, + wide, + onClose, + children, +}: ResultDialogProps) { + const dialogRef = useRef(null) + const titleId = useId() + + // Nettleserens egen dialog tar seg av fokus, Esc og mørk bakgrunn. + useEffect(() => { + const dialog = dialogRef.current + if (!dialog) return + + if (isOpen && !dialog.open) dialog.showModal() + else if (!isOpen && dialog.open) dialog.close() + }, [isOpen]) + + return ( + { + if (event.target === event.currentTarget) onClose() + }} + > +
+

+ {title} +

+ +
+ {children} +
+ ) +} diff --git a/client/src/components/wheel/SpinWheel.tsx b/client/src/components/wheel/SpinWheel.tsx new file mode 100644 index 0000000..7998e03 --- /dev/null +++ b/client/src/components/wheel/SpinWheel.tsx @@ -0,0 +1,284 @@ +import { + useCallback, + useMemo, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react' +import type { KeyboardEvent, ReactNode, Ref } from 'react' +import { + layoutSegments, + nextRotation, + segmentPath, + INNER_RADIUS, + SPOKE_COUNT, + truncate, +} from '../../utils/wheel' +import { ResultDialog } from './ResultDialog' + +/** `openDialog = false` lar siden selv bestemme hva som vises når hjulet stopper. */ +export type SpinWheelHandle = { spin: (openDialog?: boolean) => void } + +type SpinWheelProps = { + ref?: Ref + id: string + title: string + items: T[] + getId: (item: T) => string + getLabel: (item: T) => string + /** Adressen til kompass-knappen ved siden av resultatet, hvis den finnes. */ + getLink?: (item: T) => string + dialogTitle: (item: T) => string + renderResult: (item: T) => ReactNode + /** For eksempel et filter, som vises ved siden av overskriften. */ + controls?: ReactNode + emptyText: string + onSpinningChange?: (isSpinning: boolean) => void + /** Kalles når hjulet har stoppet på et felt. */ + onResult?: (item: T) => void + hidden?: boolean +} + +const SPIN_MS = 4500 +const COLOR_COUNT = 4 +/** Pinne med håndtak, tegnet rett opp fra midten av roret. */ +const SPOKE_PATH = + 'M-2.6 -14V-106L-3.4 -112Q-6.2 -120 -4.8 -128Q0 -135 4.8 -128Q6.2 -120 3.4 -112L2.6 -106V-14Z' + +const prefersReducedMotion = () => + window.matchMedia('(prefers-reduced-motion: reduce)').matches + +/** Fargen til et felt. Første og siste felt skal ikke ha samme farge. */ +function colorOf(index: number, count: number) { + const isClashing = + count > 1 && index === count - 1 && index % COLOR_COUNT === 0 + return isClashing ? 2 : index % COLOR_COUNT +} + +/** Lite kompass som lenker til stedets nettside. */ +function CompassIcon() { + return ( + + ) +} + +/** + * Et lykkehjul. Hjulet snurrer når man trykker på midten, og stopper på et + * tilfeldig felt. Resultatet vises i et lite vindu og lagres i et felt under + * hjulet, så det kan åpnes igjen. + */ +export function SpinWheel({ + ref, + id, + title, + items, + getId, + getLabel, + getLink, + dialogTitle, + renderResult, + controls, + emptyText, + onSpinningChange, + onResult, + hidden, +}: SpinWheelProps) { + const [rotation, setRotation] = useState(0) + const [isSpinning, setIsSpinning] = useState(false) + const [result, setResult] = useState(null) + const [isDialogOpen, setIsDialogOpen] = useState(false) + const timerRef = useRef(0) + const onResultRef = useRef(onResult) + + useEffect(() => { + onResultRef.current = onResult + }, [onResult]) + + const count = items.length + const segments = useMemo(() => layoutSegments(count), [count]) + + useEffect( + () => onSpinningChange?.(isSpinning), + [isSpinning, onSpinningChange], + ) + useEffect(() => () => window.clearTimeout(timerRef.current), []) + + const spin = useCallback( + (openDialog = true) => { + if (isSpinning || count === 0) return + + const index = Math.floor(Math.random() * count) + const duration = prefersReducedMotion() ? 0 : SPIN_MS + + setResult(null) + setIsDialogOpen(false) + setIsSpinning(true) + setRotation(nextRotation(rotation, segments[index], Math.random())) + + timerRef.current = window.setTimeout(() => { + setIsSpinning(false) + setResult(items[index]) + setIsDialogOpen(openDialog) + onResultRef.current?.(items[index]) + }, duration) + }, + [isSpinning, count, rotation, items, segments], + ) + + useImperativeHandle(ref, () => ({ spin }), [spin]) + + const handleHubKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + spin() + } + } + + // Jo flere felt, desto mindre tekst, og desto kortere navn. + const narrowest = Math.min(...segments.map(({ start, end }) => end - start)) + const fontSize = Math.min(7, ((narrowest * Math.PI) / 180) * 50 * 0.7) + const maxChars = Math.floor(58 / (0.55 * fontSize)) + const link = result && getLink ? getLink(result) : null + + const status = isSpinning + ? 'Hjulet snurrer …' + : result + ? `Valgt: ${getLabel(result)}` + : '' + + return ( + + ) +} diff --git a/client/src/data/mockChallenges.ts b/client/src/data/mockChallenges.ts new file mode 100644 index 0000000..95f4881 --- /dev/null +++ b/client/src/data/mockChallenges.ts @@ -0,0 +1,72 @@ +import type { Challenge } from '../types/challenge' + +type ChallengeSeed = Omit + +/** + * Mockdata til utvikling. Alle utfordringene er ufarlige og kan gjøres med + * alkoholfri drikke. Erstattes av data fra backend senere. + */ +const seeds: ChallengeSeed[] = [ + { + title: 'Bestill med aksent', + description: 'Bestill neste drikke med en utenlandsk aksent.', + }, + { + title: 'Motsatt hånd', + description: 'Hold drikken i den hånden du ikke bruker, i ti minutter.', + }, + { + title: 'Skål med en fremmed', + description: 'Skål vennlig med noen du ikke kjenner.', + }, + { + title: 'Ingen ja eller nei', + description: 'Si verken «ja» eller «nei» de neste ti minuttene.', + }, + { + title: 'Bytt drikk', + description: 'Bytt drikk med en i følget og smak på den.', + }, + { + title: 'Noe nytt', + description: 'Bestill noe du aldri har smakt før.', + }, + { + title: 'Sjømannsvise', + description: 'Syng første vers av en sjømannsvise sammen med følget.', + }, + { + title: 'Bartenderens valg', + description: 'Spør bartenderen om dagens anbefaling og bestill den.', + }, + { + title: 'Piratprat', + description: 'Snakk som en pirat i fem minutter.', + }, + { + title: 'Gjett ingrediensene', + description: 'Gjett tre ingredienser i drikken din før du leser menyen.', + }, + { + title: 'Skål på rim', + description: 'Lag en skål på rim og si den høyt.', + }, + { + title: 'Den rareste på menyen', + description: 'Finn den mest uvanlige drikken på menyen og bestill den.', + }, + { + title: 'Vits', + description: 'Fortell en vits som får minst én person til å smile.', + }, + { + title: 'Bildet av kvelden', + description: + 'Ta et bilde av følget med stedets fineste detalj i bakgrunnen.', + }, +] + +export const mockChallenges: Challenge[] = seeds.map((seed, index) => ({ + ...seed, + id: `challenge-${index + 1}`, +})) diff --git a/client/src/data/mockPlaces.ts b/client/src/data/mockPlaces.ts index 014fc27..e86ced5 100644 --- a/client/src/data/mockPlaces.ts +++ b/client/src/data/mockPlaces.ts @@ -90,7 +90,7 @@ const seeds: PlaceSeed[] = [ }, { name: 'Kjøpmannsgata Kjeller', - type: 'Bar', + type: 'Nattklubb', area: 'Midtbyen', address: 'Eksempelgata 15, Trondheim', openingHours: '21:00–03:30', @@ -265,8 +265,8 @@ const seeds: PlaceSeed[] = [ tags: ['rolig', 'koselig', 'lokalt øl'], }, { - name: 'Nattergal Bar', - type: 'Bar', + name: 'Nattergal Klubb', + type: 'Nattklubb', area: 'Midtbyen', address: 'Eksempelgata 27, Trondheim', openingHours: '22:00–04:00', diff --git a/client/src/hooks/useFetch.ts b/client/src/hooks/useFetch.ts new file mode 100644 index 0000000..88ecbad --- /dev/null +++ b/client/src/hooks/useFetch.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react' + +type Status = 'loading' | 'ready' | 'error' + +/** + * Henter data én gang når komponenten vises. Funksjonen må ha en fast + * identitet (definert utenfor komponenten), ellers hentes dataene på nytt. + */ +export function useFetch(fetcher: () => Promise) { + const [data, setData] = useState(null) + const [status, setStatus] = useState('loading') + + useEffect(() => { + let isCurrent = true + + fetcher() + .then((result) => { + if (!isCurrent) return + setData(result) + setStatus('ready') + }) + .catch(() => { + if (isCurrent) setStatus('error') + }) + + return () => { + isCurrent = false + } + }, [fetcher]) + + return { data, status } +} diff --git a/client/src/hooks/useRoute.ts b/client/src/hooks/useRoute.ts index 7039a67..c57db54 100644 --- a/client/src/hooks/useRoute.ts +++ b/client/src/hooks/useRoute.ts @@ -1,11 +1,12 @@ import { useSyncExternalStore } from 'react' -export type Route = 'home' | 'map' | 'user' +export type Route = 'home' | 'map' | 'user' | 'challenges' /** Adressene i nettleseren. Hash-adresser trenger ingen serverkonfigurasjon. */ export const HOME_HREF = '#/' export const MAP_HREF = '#/kart' export const USER_HREF = '#/bruker' +export const CHALLENGES_HREF = '#/utfordringer' function subscribe(onChange: () => void) { window.addEventListener('hashchange', onChange) @@ -18,6 +19,8 @@ function getRoute(): Route { return 'map' case USER_HREF: return 'user' + case CHALLENGES_HREF: + return 'challenges' default: return 'home' } diff --git a/client/src/pages/ChallengesPage.css b/client/src/pages/ChallengesPage.css new file mode 100644 index 0000000..9e89763 --- /dev/null +++ b/client/src/pages/ChallengesPage.css @@ -0,0 +1,412 @@ +/* ---------- Utfordringer ---------- */ + +.challenges-header { + display: flex; + align-items: center; + justify-content: center; + min-height: 10rem; + padding: 4rem 3.5rem 3.5rem; +} + +.challenges-header { + flex-direction: column; +} + +.wheel-section { + --wood: #a56a2c; + --wood-dark: #4d2c0e; + --wood-light: #d29a52; + --brass: #e0b44a; +} + +.challenges-page { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1.5rem; + padding: 1.5rem 1rem 2rem; +} + +/* Valg av hjul: tre knapper som henger sammen. */ +.mode-toggle { + display: flex; + justify-self: center; + margin: 0; + padding: 0; + border: 2px solid var(--navy-700); + border-radius: 999px; + overflow: hidden; +} + +.mode-option span { + display: block; + padding: 0.55rem 1.1rem; + color: var(--navy-700); + font-weight: 600; + cursor: pointer; +} + +.mode-option input:checked + span { + background: var(--navy-700); + color: var(--eggshell); +} + +.mode-option input:focus-visible + span { + outline: 3px solid var(--blue-300); + outline-offset: -3px; +} + +.mode-toggle:disabled span { + cursor: not-allowed; + opacity: 0.6; +} + +.wheels { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 30rem)); + justify-content: center; + gap: 2rem; +} + +.wheel-section { + display: grid; + align-content: start; + gap: 0.75rem; +} + +.wheel-section[hidden] { + display: none; +} + +.wheel-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding-bottom: 0.4rem; + border-bottom: 2px solid var(--navy-700); +} + +.wheel-header h2 { + margin: 0; + font-size: 1.5rem; +} + +.wheel-filter { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--muted); + font-weight: 600; +} + +.wheel-filter select { + padding: 0.35rem 0.6rem; + border: 2px solid var(--navy-700); + border-radius: 0.5rem; + background: var(--surface); + color: var(--navy-900); + font: inherit; +} + +.wheel { + width: 100%; + max-height: 32rem; + overflow: visible; +} + +.wheel-empty { + display: grid; + place-items: center; + min-height: 12rem; + margin: 0; + color: var(--muted); + text-align: center; +} + +.wheel-rotor { + transform-origin: 0 0; + transition-property: transform; + transition-timing-function: cubic-bezier(0.12, 0.6, 0.1, 1); +} + +.wheel-color-0 path { + fill: var(--navy-700); +} +.wheel-color-1 path { + fill: var(--blue-100); +} +.wheel-color-2 path { + fill: var(--navy-500); +} +.wheel-color-3 path { + fill: var(--blue-300); +} + +.wheel-color-0 text, +.wheel-color-2 text { + fill: var(--eggshell); +} +.wheel-color-1 text, +.wheel-color-3 text { + fill: var(--navy-900); +} + +.wheel-color-0 path, +.wheel-color-1 path, +.wheel-color-2 path, +.wheel-color-3 path { + stroke: var(--surface); + stroke-width: 0.6; +} + +.wheel-section text { + font-weight: 600; + pointer-events: none; +} + +.wheel-spoke { + fill: var(--wood); + stroke: var(--wood-dark); + stroke-width: 1; +} + +/* Bakgrunnen mellom feltene er tre, så pinnene ligger på en trekake. */ +.wheel-backing { + fill: var(--wood-light); +} + +.wheel-rim { + fill: none; + stroke: var(--wood); + stroke-width: 13; + paint-order: stroke; +} + +.wheel-rim-inlay { + fill: none; + stroke: var(--wood-light); + stroke-width: 1.6; +} + +.wheel-boss { + fill: none; + stroke: var(--wood); + stroke-width: 4; +} + +.wheel-pointer { + fill: var(--brass); + stroke: var(--navy-900); + stroke-width: 1.5; + stroke-linejoin: round; +} + +.wheel-hub { + cursor: pointer; + outline: none; + -webkit-tap-highlight-color: transparent; +} + +.wheel-hub circle { + fill: #f1e2b3; + stroke: var(--wood-dark); + stroke-width: 2; +} + +.wheel-hub text { + fill: var(--navy-900); + font-size: 6px; + font-weight: 800; + letter-spacing: 0.05em; + pointer-events: none; +} + +.wheel-hub:hover circle, +.wheel-hub:focus-visible circle { + fill: var(--blue-100); +} + +.wheel-hub:focus-visible circle { + stroke: var(--navy-500); + stroke-width: 4.5; +} + +.wheel-hub[aria-disabled='true'] { + cursor: not-allowed; + opacity: 0.6; +} + +/* Feltet under hjulet, med kompass ved siden av når det finnes. */ +.wheel-result { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-items: center; + gap: 0.6rem; +} + +.wheel-result.has-link { + grid-template-columns: minmax(0, 1fr) auto; +} + +.result-field { + min-height: 2.75rem; + padding: 0.5rem 1rem; + border: 2px solid var(--navy-700); + border-radius: 0.6rem; + background: var(--surface); + color: var(--navy-900); + font: inherit; + font-weight: 700; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +.result-field:hover:not(:disabled) { + background: var(--blue-100); +} + +.result-field:disabled { + border-style: dashed; + color: var(--muted); + font-weight: 400; + cursor: default; +} + +.result-field:focus-visible { + outline: 3px solid var(--blue-300); + outline-offset: 2px; +} + +.result-link { + display: grid; + place-items: center; +} + +.result-link svg { + width: 1.6rem; + height: 1.6rem; +} + +/* Vinduet med resultatet. */ +.result-dialog { + width: min(22rem, calc(100vw - 2rem)); + padding: 1rem; + border: 2px solid var(--navy-700); + border-radius: 0.9rem; + background: var(--surface); + color: var(--navy-900); +} + +.result-dialog::backdrop { + background: rgb(10 34 64 / 0.6); +} + +.result-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.result-dialog-header h2 { + margin: 0; + font-size: 1.25rem; + line-height: 1.2; +} + +.close-button svg { + width: 1.2rem; + height: 1.2rem; +} + +.result-body { + display: grid; + gap: 0.75rem; +} + +.result-body p { + margin: 0; +} + +.challenge-text { + font-size: 1.1rem; +} + +.fine-print { + color: var(--muted); + font-size: 0.85rem; +} + +.spin-all { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; +} + +.close-button, +.result-link { + width: 2.75rem; + height: 2.75rem; + padding: 0; + border-radius: 50%; + color: var(--navy-900); +} + +.close-button { + flex: none; + width: 2.25rem; + height: 2.25rem; +} + +.result-dialog.wide { + width: min(46rem, calc(100vw - 2rem)); +} + +.combined-result { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr)); + gap: 1.25rem; +} + +.combined-result article { + display: grid; + align-content: start; + gap: 0.5rem; +} + +.combined-result article + article { + padding-left: 1.25rem; + border-left: 2px solid var(--blue-300); +} + +.combined-result h3 { + margin: 0; + color: var(--muted); + font-size: 0.85rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.combined-name { + margin: 0; + font-size: 1.25rem; + font-weight: 700; +} + +@media (max-width: 40rem) { + .combined-result article + article { + padding: 1.25rem 0 0; + border-top: 2px solid var(--blue-300); + border-left: 0; + } +} + +.close-button { + margin-left: auto; +} diff --git a/client/src/pages/ChallengesPage.tsx b/client/src/pages/ChallengesPage.tsx new file mode 100644 index 0000000..d37a1f7 --- /dev/null +++ b/client/src/pages/ChallengesPage.tsx @@ -0,0 +1,218 @@ +import { useMemo, useRef, useState } from 'react' +import { fetchAllPlaces } from '../api/places' +import { fetchChallenges } from '../api/challenges' +import { Gulls } from '../components/Gulls' +import { HelpButton } from '../components/HelpButton' +import { HomeLink } from '../components/HomeLink' +import { MenuButton } from '../components/MenuButton' +import { ChallengeResult } from '../components/wheel/ChallengeResult' +import { CombinedResult } from '../components/wheel/CombinedResult' +import { PlaceResult } from '../components/wheel/PlaceResult' +import { ResultDialog } from '../components/wheel/ResultDialog' +import { SpinWheel } from '../components/wheel/SpinWheel' +import type { SpinWheelHandle } from '../components/wheel/SpinWheel' +import { useFetch } from '../hooks/useFetch' +import { useNow } from '../hooks/useNow' +import type { Challenge } from '../types/challenge' +import type { Place } from '../types/place' +import './ChallengesPage.css' + +type Mode = 'both' | 'places' | 'challenges' + +const MODES: { value: Mode; label: string }[] = [ + { value: 'both', label: 'Begge' }, + { value: 'places', label: 'Steder' }, + { value: 'challenges', label: 'Utfordringer' }, +] + +const FILTERS = ['Alle', 'Nattklubb', 'Pub', 'Bar', 'Vinbar'] as const +type Filter = (typeof FILTERS)[number] + +export function ChallengesPage() { + const now = useNow() + const { data: places, status: placesStatus } = useFetch(fetchAllPlaces) + const { data: challenges, status: challengesStatus } = + useFetch(fetchChallenges) + + const [mode, setMode] = useState('both') + const [filter, setFilter] = useState('Alle') + const [isPlaceSpinning, setIsPlaceSpinning] = useState(false) + const [isChallengeSpinning, setIsChallengeSpinning] = useState(false) + + // Når begge hjulene snurres samtidig, vises resultatene i ett felles vindu. + const [combined, setCombined] = useState<{ + place: Place + challenge: Challenge + } | null>(null) + const [isCombinedOpen, setIsCombinedOpen] = useState(false) + const pending = useRef<{ place?: Place; challenge?: Challenge } | null>(null) + + const collect = (part: { place?: Place; challenge?: Challenge }) => { + if (!pending.current) return + pending.current = { ...pending.current, ...part } + const { place, challenge } = pending.current + + if (place && challenge) { + pending.current = null + setCombined({ place, challenge }) + setIsCombinedOpen(true) + } + } + + const placeWheel = useRef(null) + const challengeWheel = useRef(null) + + const isSpinning = isPlaceSpinning || isChallengeSpinning + const showPlaces = mode !== 'challenges' + const showChallenges = mode !== 'places' + + const filteredPlaces = useMemo(() => { + if (!places) return [] + return filter === 'Alle' + ? places + : places.filter((place) => place.type === filter) + }, [places, filter]) + + const spinAll = () => { + const isJoint = showPlaces && showChallenges + + pending.current = isJoint ? {} : null + setIsCombinedOpen(false) + if (showPlaces) placeWheel.current?.spin(!isJoint) + if (showChallenges) challengeWheel.current?.spin(!isJoint) + } + + const placesEmptyText = + placesStatus === 'loading' + ? 'Laster steder …' + : placesStatus === 'error' + ? 'Kunne ikke hente steder.' + : 'Ingen steder passer filteret.' + + const challengesEmptyText = + challengesStatus === 'loading' + ? 'Laster utfordringer …' + : 'Kunne ikke hente utfordringer.' + + return ( + <> +
+ + + + Her lar du tilfeldighetene bestemme kvelden. Velg øverst om du vil se + begge hjulene, bare stedene eller bare utfordringene. Stedshjulet kan + filtreres på type sted, og hjulet fylles med alle stedene som passer. + Trykk på midten av et hjul, eller på «Spin»-knappen nederst, for å + snurre, så åpnes stedet eller utfordringen i et lite vindu. Lukker du + vinduet, åpner du det igjen ved å trykke på feltet under hjulet. + + + +
+

Take a Chance

+

A smooth sea never made a skilled sailor

+
+
+ +
+
+ Vis hjul + {MODES.map(({ value, label }) => ( + + ))} +
+ +
+ {/* Skjules i stedet for å fjernes, så hjulene husker resultatet. */} + + key={filter} + ref={placeWheel} + id="place-wheel" + title="Place" + items={filteredPlaces} + getId={(place) => place.id} + getLabel={(place) => place.name} + getLink={(place) => place.websiteUrl} + dialogTitle={(place) => place.name} + renderResult={(place) => } + emptyText={placesEmptyText} + onSpinningChange={setIsPlaceSpinning} + onResult={(place) => collect({ place })} + hidden={!showPlaces} + controls={ + + } + /> + + + ref={challengeWheel} + id="challenge-wheel" + title="Challenge" + items={challenges ?? []} + getId={(challenge) => challenge.id} + getLabel={(challenge) => challenge.title} + dialogTitle={(challenge) => challenge.title} + renderResult={(challenge) => ( + + )} + emptyText={challengesEmptyText} + onSpinningChange={setIsChallengeSpinning} + onResult={(challenge) => collect({ challenge })} + hidden={!showChallenges} + /> +
+ +
+ +
+
+ + {combined && ( + setIsCombinedOpen(false)} + > + + + )} + + ) +} diff --git a/client/src/types/challenge.ts b/client/src/types/challenge.ts new file mode 100644 index 0000000..b1c5630 --- /dev/null +++ b/client/src/types/challenge.ts @@ -0,0 +1,6 @@ +export type Challenge = { + id: string + title: string + /** Kort forklaring av hva man skal gjøre. */ + description: string +} diff --git a/client/src/types/place.ts b/client/src/types/place.ts index 83b4603..2fa709c 100644 --- a/client/src/types/place.ts +++ b/client/src/types/place.ts @@ -1,4 +1,5 @@ -export type VenueType = 'Bar' | 'Pub' | 'Bryggeri' | 'Vinbar' | 'Cocktailbar' +export type VenueType = + 'Bar' | 'Pub' | 'Bryggeri' | 'Vinbar' | 'Cocktailbar' | 'Nattklubb' export type Place = { id: string diff --git a/client/src/utils/wheel.ts b/client/src/utils/wheel.ts new file mode 100644 index 0000000..007b3ce --- /dev/null +++ b/client/src/utils/wheel.ts @@ -0,0 +1,80 @@ +/** Hjulets ytre radius i tegningens egne enheter. */ +export const WHEEL_RADIUS = 100 +/** Feltene starter utenfor navet. */ +export const INNER_RADIUS = 24 +/** Antall pinner på roret. Feltene deles likt mellom dem. */ +export const SPOKE_COUNT = 8 +/** Hvor mange grader på hver side av en pinne som holdes fri for felt. */ +const SPOKE_CLEARANCE = 4 + +export type Segment = { start: number; end: number; mid: number } + +const round = (value: number) => Number(value.toFixed(3)) + +/** Punkt på en sirkel, målt i grader fra toppen og med klokka. */ +function pointAt(angle: number, radius: number) { + const radians = (angle * Math.PI) / 180 + return { + x: round(radius * Math.sin(radians)), + y: round(-radius * Math.cos(radians)), + } +} + +/** + * Plasserer feltene mellom pinnene. Pinne `k` står på `k * 45` grader, og + * feltene fordeles jevnt over rommene mellom dem uten å krysse en pinne. + */ +export function layoutSegments(count: number): Segment[] { + const sector = 360 / SPOKE_COUNT + const segments: Segment[] = [] + + for (let room = 0; room < SPOKE_COUNT; room++) { + const first = Math.floor((count * room) / SPOKE_COUNT) + const last = Math.floor((count * (room + 1)) / SPOKE_COUNT) + const inRoom = last - first + const from = room * sector + SPOKE_CLEARANCE + const step = (sector - 2 * SPOKE_CLEARANCE) / Math.max(inRoom, 1) + + for (let i = 0; i < inRoom; i++) { + const start = from + i * step + segments.push({ start, end: start + step, mid: start + step / 2 }) + } + } + + return segments +} + +/** Tegner ett felt som en ringbit mellom navet og kanten. */ +export function segmentPath({ start, end }: Segment): string { + const outerStart = pointAt(start, WHEEL_RADIUS) + const outerEnd = pointAt(end, WHEEL_RADIUS) + const innerStart = pointAt(start, INNER_RADIUS) + const innerEnd = pointAt(end, INNER_RADIUS) + const largeArc = end - start > 180 ? 1 : 0 + + return `M${innerStart.x} ${innerStart.y}L${outerStart.x} ${outerStart.y}A${WHEEL_RADIUS} ${WHEEL_RADIUS} 0 ${largeArc} 1 ${outerEnd.x} ${outerEnd.y}L${innerEnd.x} ${innerEnd.y}A${INNER_RADIUS} ${INNER_RADIUS} 0 ${largeArc} 0 ${innerStart.x} ${innerStart.y}Z` +} + +/** + * Hvor mye hjulet skal være rotert for at feltet stopper under pekeren + * øverst. `jitter` (0–1) flytter stoppen litt innenfor feltet, så pekeren + * aldri havner på en pinne. Hjulet snurrer `extraTurns` hele runder først. + */ +export function nextRotation( + current: number, + { start, end }: Segment, + jitter: number, + extraTurns = 5, +): number { + const landing = (start + end) / 2 + (jitter - 0.5) * (end - start) * 0.7 + const delta = ((-(current + landing) % 360) + 360) % 360 + + return current + extraTurns * 360 + delta +} + +/** Kutter en tekst så den passer i feltet. */ +export function truncate(text: string, maxLength: number): string { + return text.length <= maxLength + ? text + : `${text.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…` +} From 400fca6af391ab5cade117c6d5a1e74589d921dc Mon Sep 17 00:00:00 2001 From: Kasper Date: Fri, 9 Oct 2026 15:41:51 +0200 Subject: [PATCH 2/2] chore: translate remaining English UI text to Norwegian --- client/src/components/user/BadgeSlots.tsx | 2 +- client/src/components/wheel/CombinedResult.tsx | 4 ++-- client/src/components/wheel/SpinWheel.tsx | 2 +- client/src/pages/ChallengesPage.tsx | 8 ++++---- client/src/pages/UserPage.tsx | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/client/src/components/user/BadgeSlots.tsx b/client/src/components/user/BadgeSlots.tsx index 4cb09b5..a7dfddd 100644 --- a/client/src/components/user/BadgeSlots.tsx +++ b/client/src/components/user/BadgeSlots.tsx @@ -14,7 +14,7 @@ export function BadgeSlots() { ))}

- Badges får du ved å fullføre utfordringer. De dukker opp her. + Merker får du ved å fullføre utfordringer. De dukker opp her.

) diff --git a/client/src/components/wheel/CombinedResult.tsx b/client/src/components/wheel/CombinedResult.tsx index c8c8e5b..05c0715 100644 --- a/client/src/components/wheel/CombinedResult.tsx +++ b/client/src/components/wheel/CombinedResult.tsx @@ -10,12 +10,12 @@ export function CombinedResult({ place, challenge, now }: CombinedResultProps) { return (
-

Hvor

+

Sted

{place.name}

-

Hva

+

Utfordring

{challenge.title}

diff --git a/client/src/components/wheel/SpinWheel.tsx b/client/src/components/wheel/SpinWheel.tsx index 7998e03..1d15feb 100644 --- a/client/src/components/wheel/SpinWheel.tsx +++ b/client/src/components/wheel/SpinWheel.tsx @@ -237,7 +237,7 @@ export function SpinWheel({ > - SPIN + SPINN diff --git a/client/src/pages/ChallengesPage.tsx b/client/src/pages/ChallengesPage.tsx index d37a1f7..989a9e9 100644 --- a/client/src/pages/ChallengesPage.tsx +++ b/client/src/pages/ChallengesPage.tsx @@ -110,8 +110,8 @@ export function ChallengesPage() {
-

Take a Chance

-

A smooth sea never made a skilled sailor

+

Prøv lykken

+

Rolig sjø har aldri skapt en dyktig sjømann

@@ -139,7 +139,7 @@ export function ChallengesPage() { key={filter} ref={placeWheel} id="place-wheel" - title="Place" + title="Sted" items={filteredPlaces} getId={(place) => place.id} getLabel={(place) => place.name} @@ -171,7 +171,7 @@ export function ChallengesPage() { ref={challengeWheel} id="challenge-wheel" - title="Challenge" + title="Utfordring" items={challenges ?? []} getId={(challenge) => challenge.id} getLabel={(challenge) => challenge.title} diff --git a/client/src/pages/UserPage.tsx b/client/src/pages/UserPage.tsx index 4369b3e..5932b84 100644 --- a/client/src/pages/UserPage.tsx +++ b/client/src/pages/UserPage.tsx @@ -63,7 +63,7 @@ export function UserPage() { Dette er profilen din, med navn og profilbilde øverst. Under «Favorittsteder» samler du stedene du liker best, og har du mange, kan du bla sidelengs med pilene eller ved å dra i raden. «Favorittruter» - og «Badges» fylles ut senere, når du kan lage egne ruter og fullføre + og «Merker» fylles ut senere, når du kan lage egne ruter og fullføre utfordringer. Innlogging kommer også senere, så foreløpig vises en eksempelbruker. @@ -104,7 +104,7 @@ export function UserPage() { {[]} - +