Skip to content

Commit

Permalink
feat: update stop tasks for option-based submission (#52)
Browse files Browse the repository at this point in the history
* first commit for nyhetskvartalet

* updated to use database instead of mock. Added tests

* fixed mistake

* add canvas-confetti

* add celebration component

* make stopsuccesspage

* fixed another mistake

* fixed tests and api error

* tiny fix in test

* display next stop

* fix: add missing failure translations and next stop button

* wire stop submission for failed/completed task flows

* use task options for news stop

* use headline in metadata json

* lint

---------

Co-authored-by: kristoffer <kristoffersolsen@gmail.com>
  • Loading branch information
eilifhl and kristoffer authored Apr 22, 2026
1 parent 05e9adc commit d71ca63
Show file tree
Hide file tree
Showing 18 changed files with 738 additions and 211 deletions.
19 changes: 19 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.2.2",
"canvas-confetti": "^1.9.4",
"lucide-vue-next": "^1.0.0",
"pinia": "^3.0.4",
"tailwindcss": "^4.2.2",
Expand All @@ -32,6 +33,7 @@
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tsconfig/node24": "^24.0.4",
"@types/canvas-confetti": "^1.9.0",
"@types/jsdom": "^28.0.1",
"@types/node": "^24.12.0",
"@types/three": "^0.184.0",
Expand Down
20 changes: 17 additions & 3 deletions src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,14 @@ export const en = {
stop: {
loading: "Loading tasks…",
finish: "Finish stop",
backToMap: "Back to map",
submitting: "Checking answers…",
answerAllFirst: "Answer all tasks first",
selected: "Selected",
selectThis: "Select this",
answerKey: "Answer key",
correct: "Correct",
incorrect: "Incorrect",
incorrect: "Not quite right",
backToMap: "Back to map",
answerKey: "Answer key",
explanation: "Explanation",
noTasks: "No published tasks are available for this stop yet.",
teacherPlaytestBadge: "Teacher playtest",
Expand Down Expand Up @@ -197,6 +198,19 @@ export const en = {
toggleMusicMute: "Toggle music mute",
toggleSfxMute: "Toggle sound effects mute",
},
success: {
stop_completed_title: "You did it!",
stop_completed_message: "Congratulations on completing this stop!",
return_to_map: "Back to map",
next_stop: "Next Stop",
next_stop_label: "Next Stop",
view_recap: "View Map Recap",
},
failure: {
stop_failed_title: "Oops!",
stop_failed_message: "It didn't work this time.",
try_again: "Try again",
},
avatarEditor: {
detectiveName: "Detective {name}",
fallbackDisplayName: "Unknown",
Expand Down
20 changes: 17 additions & 3 deletions src/locales/nb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,14 @@ export const nb = {
stop: {
loading: "Laster oppgaver…",
finish: "Fullfør stopp",
backToMap: "Tilbake til kartet",
submitting: "Sjekker svar…",
answerAllFirst: "Svar på alle oppgavene først",
selected: "Valgt",
selectThis: "Velg denne",
answerKey: "Fasit",
correct: "Riktig",
incorrect: "Feil",
incorrect: "Ikke helt riktig",
backToMap: "Tilbake til kartet",
answerKey: "Fasit",
explanation: "Forklaring",
noTasks: "Ingen publiserte oppgaver for dette stoppet ennå.",
teacherPlaytestBadge: "Lærerspilltest",
Expand Down Expand Up @@ -197,6 +198,19 @@ export const nb = {
toggleMusicMute: "Slå av eller på musikk",
toggleSfxMute: "Slå av eller på lydeffekter",
},
success: {
stop_completed_title: "Du klarte det!",
stop_completed_message: "Gratulerer med å ha fullført dette stoppestedet!",
return_to_map: "Tilbake til kartet",
next_stop: "Neste stopp",
next_stop_label: "Neste stoppested",
view_recap: "Se kart-oppsummering",
},
failure: {
stop_failed_title: "Oi da!",
stop_failed_message: "Det gikk ikke denne gangen.",
try_again: "Prøv på nytt",
},
avatarEditor: {
detectiveName: "Detektiv {name}",
fallbackDisplayName: "Ukjent",
Expand Down
48 changes: 48 additions & 0 deletions src/map/pages/StopFailurePage.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<template>
<div class="flex flex-col items-center justify-center min-h-screen text-center p-6 bg-white">
<div class="mb-8 p-4 bg-red-100 rounded-full">
<span class="text-6xl">❌</span>
</div>

<h1 class="text-4xl font-bold text-gray-900 mb-2">
{{ t('failure.stop_failed_title') }}
</h1>

<p class="text-2xl text-gray-600 mb-8">
{{ completedStopName }}
</p>

<RouterLink
:to="{ name: 'pupil-map' }"
class="px-8 py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 transition-colors"
>
{{ t('success.return_to_map') }}
</RouterLink>
</div>
</template>

<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapStore } from '@/map/model/map.store';
const route = useRoute();
const { t } = useI18n();
const mapStore = useMapStore();
onMounted(async () => {
if (mapStore.status === 'idle') {
await mapStore.fetchMapOverview();
}
});
const completedStop = computed(() => {
const slug = route.params.slug as string;
return mapStore.stops.find(s => s.slug === slug);
});
const completedStopName = computed(() => {
return completedStop.value?.title || t('common.stop');
});
</script>
75 changes: 75 additions & 0 deletions src/map/pages/StopSuccessPage.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<template>
<AppCelebration>
<div class="flex flex-col items-center justify-center min-h-screen text-center p-6 bg-white">
<div class="mb-8 p-4 bg-yellow-100 rounded-full">
<span class="text-6xl">🏆</span>
</div>

<h1 class="text-4xl font-bold text-gray-900 mb-2">
{{ t('success.stop_completed_title') }}
</h1>

<p class="text-2xl text-gray-600 mb-8">
{{ completedStopName }}
</p>

<div v-if="nextStop" class="mb-12 p-6 bg-blue-50 rounded-2xl border border-blue-100 flex flex-col items-center">
<p class="text-sm uppercase tracking-widest text-blue-500 font-bold mb-1">
{{ t('success.next_stop_label') }}
</p>
<p class="text-xl font-semibold text-blue-900 mb-4">
{{ nextStop.title }}
</p>
<RouterLink
:to="{ name: 'pupil-stop', params: { slug: nextStop.slug } }"
class="px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
{{ t('success.next_stop') }}
</RouterLink>
</div>

<RouterLink
:to="{ name: 'pupil-map' }"
class="px-8 py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 transition-colors"
>
{{ t('success.return_to_map') }}
</RouterLink>
</div>
</AppCelebration>
</template>

<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapStore } from '@/map/model/map.store';
import AppCelebration from '@/shared/components/AppCelebration.vue';
const route = useRoute();
const { t } = useI18n();
const mapStore = useMapStore();
onMounted(async () => {
if (mapStore.status === 'idle') {
await mapStore.fetchMapOverview();
}
});
const completedStop = computed(() => {
const slug = route.params.slug as string;
return mapStore.stops.find(s => s.slug === slug);
});
const completedStopName = computed(() => {
return completedStop.value?.title || t('common.stop');
});
const nextStop = computed(() => {
const current = completedStop.value;
if (!current) return null;
return mapStore.stops
.filter(s => s.displayOrder > current.displayOrder)
.sort((a, b) => a.displayOrder - b.displayOrder)[0];
});
</script>
40 changes: 27 additions & 13 deletions src/map/routes.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,31 @@
import type { RouteRecordRaw } from 'vue-router'

import MapPage from '@/map/pages/MapPage.vue'
import StopSuccessPage from '@/map/pages/StopSuccessPage.vue'
import StopFailurePage from '@/map/pages/StopFailurePage.vue'
import StopPage from '@/stops/pages/StopPage.vue'

export const pupilMapRoutes: RouteRecordRaw[] = [
{
path: 'map',
name: 'pupil-map',
component: MapPage,
},
{
path: 'map/:slug',
name: 'pupil-stop',
component: StopPage,
},
]
export function createMapRoutes(prefix: string): RouteRecordRaw[] {
return [
{
path: 'map',
name: `${prefix}-map`,
component: MapPage,
},
{
path: 'map/:slug',
name: `${prefix}-stop`,
component: StopPage,
},
{
path: 'map/:slug/success',
name: `${prefix}-stop-success`,
component: StopSuccessPage,
},
{
path: 'map/:slug/failure',
name: `${prefix}-stop-failure`,
component: StopFailurePage,
},
];
}
4 changes: 2 additions & 2 deletions src/pupil-portal/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { RouteRecordRaw } from 'vue-router'
import { medalsRoutes } from '@/medals/routes'
import { helpRoutes } from '@/help/routes'
import { leaderboardRoutes } from '@/leaderboard/routes'
import { pupilMapRoutes } from '@/map/routes'
import { createMapRoutes } from '@/map/routes'
import { mysteryRoutes } from '@/mystery/routes'
import { notebookRoutes } from '@/notebook/routes'
import { profileRoutes } from '@/profile/routes'
Expand All @@ -28,7 +28,7 @@ export function createPupilPortalChildRoutes(): RouteRecordRaw[] {
...avatarRoutes,
...helpRoutes,
...leaderboardRoutes,
...pupilMapRoutes,
...createMapRoutes('pupil'),
...notebookRoutes,
...mysteryRoutes,
...medalsRoutes,
Expand Down
46 changes: 46 additions & 0 deletions src/shared/components/AppCelebration.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<template>
<div class="celebration-container">
<slot></slot>
</div>
</template>

<script setup lang="ts">
import confetti from 'canvas-confetti';
import { onMounted } from 'vue';
defineProps<{
trigger?: boolean;
}>();
const launchConfetti = () => {
const duration = 3 * 1000;
const animationEnd = Date.now() + duration;
const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
const interval: ReturnType<typeof setInterval> = setInterval(function() {
const timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
return clearInterval(interval);
}
const particleCount = 50 * (timeLeft / duration);
confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 } });
confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 } });
}, 250);
};
onMounted(() => {
launchConfetti();
});
</script>

<style scoped>
.celebration-container {
position: relative;
width: 100%;
height: 100%;
}
</style>
Loading

0 comments on commit d71ca63

Please sign in to comment.