Skip to content

feat: update stop tasks for option-based submission #52

Merged
merged 18 commits into from
Apr 22, 2026
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -117,13 +117,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 @@ -190,6 +191,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 @@ -117,13 +117,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 @@ -190,6 +191,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