diff --git a/app/composables/useStudentClasses.ts b/app/composables/useStudentClasses.ts new file mode 100644 index 0000000..ef8961e --- /dev/null +++ b/app/composables/useStudentClasses.ts @@ -0,0 +1,111 @@ +import { useCurrentStudent } from './useCurrentStudent' + +export type ClassForm = { + id: number + order: number + startDate: string + endDate: string | null + published: boolean + formGroup: number + title: string + completed: boolean +} + +export type ClassFormGroup = { + id: number + startDate: string + endDate: string | null + forms: ClassForm[] +} + +export type StudentClass = { + id: string + joinToken: string + name: string + isFriendsOfMLK: boolean + activeFormGroups: ClassFormGroup[] + ticketCount: number +} + +const MLK_CLASS_NAME = 'Friends of MLK' + +export const useStudentClasses = () => { + const { student } = useCurrentStudent() + + const classes = useState('studentClasses', () => []) + const loading = ref(false) + const error = ref(null) + + /** + * Fetch all classes for the active student, sorted alphabetically + * with Friends of MLK always pinned to the bottom. + */ + const loadClasses = async () => { + if (!student.value?.id) { + classes.value = [] + return + } + + loading.value = true + error.value = null + + try { + const raw = await $fetch('/api/reader/classes', { + query: { studentId: student.value.id }, + }) + + classes.value = [...raw].sort((a, b) => { + if (a.isFriendsOfMLK) return 1 + if (b.isFriendsOfMLK) return -1 + return a.name.localeCompare(b.name) + }) + } catch (e: any) { + console.error('Failed to load student classes:', e) + error.value = e?.statusMessage ?? 'Failed to load classes' + classes.value = [] + } finally { + loading.value = false + } + } + + /** Grand total of raffle tickets across all classes */ + const totalTickets = computed(() => + classes.value.reduce((sum, cls) => sum + cls.ticketCount, 0) + ) + + /** Total active forms across all classes */ + const totalActiveForms = computed(() => + classes.value.reduce( + (sum, cls) => + sum + cls.activeFormGroups.reduce((s2, fg) => s2 + fg.forms.length, 0), + 0 + ) + ) + + /** + * Mark a form as completed locally so the UI updates instantly without + * waiting for a full reload. + */ + const markFormCompleted = (formId: number) => { + for (const cls of classes.value) { + for (const fg of cls.activeFormGroups) { + const form = fg.forms.find((f) => f.id === formId) + if (form && !form.completed) { + form.completed = true + cls.ticketCount++ + return + } + } + } + } + + return { + classes, + loading, + error, + loadClasses, + totalTickets, + totalActiveForms, + markFormCompleted, + } +} diff --git a/app/pages/reader/forms.vue b/app/pages/reader/forms.vue index 5a7f48c..b519fae 100644 --- a/app/pages/reader/forms.vue +++ b/app/pages/reader/forms.vue @@ -2,88 +2,41 @@ definePageMeta({ ssr: false }) const { student, settings, updateExp, restoreStudent } = useCurrentStudent() -const { FormGroup, loadActiveFormGroup } = useCurrentFormGroup() -const { tickets, completedFormIds, logFormSubmission, logSubmissionResponse, loadProgress } = useCurrentStudentProgress() +const { classes, loading, loadClasses, totalTickets, markFormCompleted } = useStudentClasses() +const { logFormSubmission, logSubmissionResponse } = useCurrentStudentProgress() +// ── Initialise ────────────────────────────────────────────────────────────── const dataLoaded = ref(false) onMounted(async () => { - if (!student.value) { - await restoreStudent() - } - if (!student.value) { - await navigateTo('/reader/profile') - return - } - await loadActiveFormGroup() - await loadProgress() + if (!student.value) await restoreStudent() + if (!student.value) { await navigateTo('/reader/profile'); return } + await loadClasses() dataLoaded.value = true }) -const stats = computed(() => ({ - xp: student.value ? student.value.exp : 0, - tickets: tickets.value? tickets.value : 0, -})) - -const themeClass = computed(() => { - const d = settings.value.dyslexiaFont ? 'dyslexia-font' : '' - return `reader-app ${d}`.trim() -}) - -const currentFormComponentsWithVideo = computed(() => { - if (!activeForm.value?.id) return [] - return FormGroup.value.formComponents[activeForm.value.id] || [] -}) +// ── Per-class form-component cache ─────────────────────────────────────────── +const formComponents = ref>({}) -function embedURL(rawUrl: string) { - const match = rawUrl.match(/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/i) - if (match && match[1]) { - return `https://www.youtube.com/embed/${match[1]}` +async function ensureComponents(formId: number) { + if (formComponents.value[formId]) return + try { + const comps = await $fetch('/api/formComponent', { query: { form: formId } }) + formComponents.value[formId] = Array.isArray(comps) ? comps : [] + } catch { + formComponents.value[formId] = [] } - return rawUrl } -const firstVideoComponent = computed(() => { - return currentFormComponentsWithVideo.value.find(c => (c as any).questionType === 'video') as any || null -}) - -const firstVideoUrl = computed(() => { - const vc = firstVideoComponent.value - if (!vc) return null - const rawUrl = vc.questionOptions?.url || null - return rawUrl ? embedURL(rawUrl) : null -}) - -const firstVideoContext = computed(() => { - const vc = firstVideoComponent.value - if (!vc) return '' - return vc.questionText || '' -}) - -const firstVideoContextEs = computed(() => { - const vc = firstVideoComponent.value - if (!vc) return '' - return vc.questionOptions?.textEs || '' -}) - -const currentFormComponents = computed(() => { - const components = currentFormComponentsWithVideo.value - - // Find the first video component (the reading resource) - const firstVideoIndex = components.findIndex(c => c.questionType === 'video') - let filteredComponents = [...components] - - // Always remove the first video component so it doesn't appear in the form flow itself, only pre-form stage - if (firstVideoIndex !== -1) { - filteredComponents.splice(firstVideoIndex, 1) - } +// ── Stats ──────────────────────────────────────────────────────────────────── +const stats = computed(() => ({ xp: student.value?.exp ?? 0, tickets: totalTickets.value })) - return filteredComponents +const themeClass = computed(() => { + const d = settings.value.dyslexiaFont ? 'dyslexia-font' : '' + return `reader-app ${d}`.trim() }) -const currentComponent = computed(() => currentFormComponents.value[currentComponentID.value]) - -// Click badge animations +// ── Badge click animations ──────────────────────────────────────────────────── const xpClicked = ref(false) const ticketClicked = ref(false) const burstCoins = ref<{id:number;tx:number;ty:number}[]>([]) @@ -102,68 +55,81 @@ function triggerTicketClick() { setTimeout(() => { ticketClicked.value = false; flyTickets.value = [] }, 1000) } -// Flow state +// ── Collapsed state for each class section ──────────────────────────────────── +const collapsedClasses = ref>({}) +function toggleClass(classId: string) { + collapsedClasses.value[classId] = !collapsedClasses.value[classId] +} + +// ── Active form flow ────────────────────────────────────────────────────────── const activeForm = ref(null) -const preFormStep = ref(null) // 'ask' | 'has-book' | 'no-book' | null +const activeClassId = ref(null) +const preFormStep = ref(null) // 'ask' | 'no-book' | null const hasOwnBook = ref(false) -const currentComponentID = ref(0) +const currentComponentID = ref(0) const answers = ref>({}) const feedbackVisible = ref>({}) +const currentFormComponents = computed(() => { + if (!activeForm.value?.id) return [] + const all = formComponents.value[activeForm.value.id] || [] + const firstVideoIdx = all.findIndex((c: any) => c.questionType === 'video') + const filtered = [...all] + if (firstVideoIdx !== -1) filtered.splice(firstVideoIdx, 1) + return filtered +}) + +const currentComponent = computed(() => currentFormComponents.value[currentComponentID.value]) + +const firstVideoComponent = computed(() => { + if (!activeForm.value?.id) return null + const all = formComponents.value[activeForm.value.id] || [] + return all.find((c: any) => c.questionType === 'video') || null +}) + +const firstVideoUrl = computed(() => { + const vc = firstVideoComponent.value + if (!vc) return null + const raw = vc.questionOptions?.url || null + return raw ? embedURL(raw) : null +}) +const firstVideoContext = computed(() => firstVideoComponent.value?.questionText || '') +const firstVideoContextEs = computed(() => firstVideoComponent.value?.questionOptions?.textEs || '') + +function embedURL(rawUrl: string) { + const match = rawUrl.match(/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/i) + return match?.[1] ? `https://www.youtube.com/embed/${match[1]}` : rawUrl +} + const isCurrentComponentCorrect = computed(() => { const q = currentComponent.value if (!q || !answers.value[q.id]) return true if (q.questionType !== 'mcq') return true const options = q.questionOptions as any - if (!options || !options.choices) return true - const choiceIndex = Number(answers.value[q.id]) - const choice = options.choices[choiceIndex] + if (!options?.choices) return true + const choice = options.choices[Number(answers.value[q.id])] return choice ? choice.correct : false }) - const correctAnswerText = computed(() => { const q = currentComponent.value if (!q || q.questionType !== 'mcq') return '' const options = q.questionOptions as any - if (!options || !options.choices) return '' - const correctChoice = options.choices.find((c: any) => c.correct) - return correctChoice ? correctChoice.text : '' -}) - -const feedbackReferenceText = computed(() => { - const q = currentComponent.value - if (!q) return '' - const options = q.questionOptions as any - return options?.reference || '' + return options?.choices?.find((c: any) => c.correct)?.text ?? '' }) +const feedbackReferenceText = computed(() => (currentComponent.value?.questionOptions as any)?.reference || '') +const feedbackReferenceTextEs = computed(() => (currentComponent.value?.questionOptions as any)?.referenceEs || '') -const feedbackReferenceTextEs = computed(() => { - const q = currentComponent.value - if (!q) return '' - const options = q.questionOptions as any - return options?.referenceEs || '' -}) - -// Raffle reward -const showRaffleReward = ref(false) -const ticketDropped = ref(false) -const ticketOverBox = ref(false) -const ticketStyle = ref>({}) -let touchStartY = 0 - -function startChallenge(form: any) { +async function startChallenge(form: any, classId: string) { + await ensureComponents(form.id) activeForm.value = form + activeClassId.value = classId preFormStep.value = 'ask' - currentComponentID.value = 0 + currentComponentID.value = 0 feedbackVisible.value = {} - //load answers answers.value = {} } -function preFormSkipToForm() { - hasOwnBook.value = false - preFormStep.value = null -} +function preFormSkipToForm() { hasOwnBook.value = false; preFormStep.value = null } function checkAnswer() { const q = currentFormComponents.value[currentComponentID.value] @@ -172,22 +138,22 @@ function checkAnswer() { function nextStep() { const qs = currentFormComponents.value - if (currentComponentID.value < qs.length - 1) { - currentComponentID.value++ - } else { - submitChallenge() - } + if (currentComponentID.value < qs.length - 1) { currentComponentID.value++ } + else { submitChallenge() } } +// ── Raffle reward ───────────────────────────────────────────────────────────── +const showRaffleReward = ref(false) +const ticketDropped = ref(false) +const ticketOverBox = ref(false) +const ticketStyle = ref>({}) +let touchStartY = 0 + async function submitChallenge() { const formId = activeForm.value.id - - // Persist completion and XP - const submission = await logFormSubmission(formId) + await logFormSubmission(formId) await updateExp(100) - - // grab newly posted submission ID - const submissionID = Number(submission?.id) + markFormCompleted(formId) showRaffleReward.value = true ticketDropped.value = false @@ -195,7 +161,8 @@ async function submitChallenge() { ticketStyle.value = {} setTimeout(() => { activeForm.value = null - currentComponentID.value = 0 + activeClassId.value = null + currentComponentID.value = 0 feedbackVisible.value = {} answers.value = {} }, 500) @@ -218,9 +185,9 @@ function onTicketTouchMove(e: TouchEvent) { function onTicketTouchEnd() { ticketOverBox.value ? onTicketDrop() : (ticketStyle.value = {}) } function getBadgeClass(type: string) { - if (type === 'text') return 'bg-blue-100 text-blue-700 border-2 border-blue-200' - if (type === 'mcq') return 'bg-yellow-100 text-yellow-700 border-2 border-yellow-200' - if (type === 'video') return 'bg-pink-100 text-pink-600 border-2 border-pink-200' + if (type === 'text') return 'bg-blue-100 text-blue-700 border-2 border-blue-200' + if (type === 'mcq') return 'bg-yellow-100 text-yellow-700 border-2 border-yellow-200' + if (type === 'video') return 'bg-pink-100 text-pink-600 border-2 border-pink-200' return 'bg-gray-100 text-gray-500 border-2 border-gray-200' } @@ -243,7 +210,7 @@ function getBadgeClass(type: string) { style="border-color:rgba(245,158,11,0.3)" :class="(xpClicked || ticketClicked) ? 'scale-90 transition-transform duration-100' : 'transition-transform duration-100'" @click="triggerXpClick"> - 🪙 + 🪙 {{ stats.xp }} | 🎟️ @@ -251,16 +218,12 @@ function getBadgeClass(type: string) { 🪙 - - 📦 - + 📦 🎟️ - + ⚙️ @@ -269,60 +232,124 @@ function getBadgeClass(type: string) {
- -
+ +
📝

Loading forms...

-
-

Daily Forms 📝

+

Daily Forms 📝

+

Your reading challenges, organized by class.

- -
-
-
-
- {{ new Date(form.startDate).toLocaleDateString('en-US', {weekday:'short'}) }} - {{ new Date(form.startDate).getDate() }} + +
+ +
+
🏫
+

No Classes Yet

+

You haven't been added to any classes yet. Check back later!

+
+ + +
+ + + + + +
+ + +
+ 📭 +

No active forms right now. Check back later!

+ + +
-
-
- -
-
+
@@ -344,7 +371,6 @@ function getBadgeClass(type: string) {
-
@@ -352,12 +378,7 @@ function getBadgeClass(type: string) {

Use this provided resource, then we'll do the form.

-

- -

+

@@ -402,11 +423,8 @@ function getBadgeClass(type: string) {

- -

+

@@ -415,10 +433,8 @@ function getBadgeClass(type: string) {

-

+

@@ -430,11 +446,8 @@ function getBadgeClass(type: string) {

""

- -

+

""

@@ -477,10 +490,10 @@ function getBadgeClass(type: string) {
-
{{ isCurrentComponentCorrect ? '✨ Great job!' : '💡 Great Try!' }}
@@ -489,12 +502,8 @@ function getBadgeClass(type: string) {

-

- Keep going! You're doing awesome! -

-

- The correct answer was: You'll get it next time! -

+

Keep going! You're doing awesome!

+

The correct answer was: You'll get it next time!

@@ -505,14 +514,12 @@ function getBadgeClass(type: string) { class="text-gray-400 font-bold hover:text-gray-700 transition text-lg">← Back
- -