Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
2 changes: 2 additions & 0 deletions app/composables/useCurrentStudent.ts
Comment thread
chanabyte marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type StudentSettings = {
dyslexiaFont: boolean
language: string
fontSize: number
theme: string
}

export const useCurrentStudent = () => {
Expand All @@ -13,6 +14,7 @@ export const useCurrentStudent = () => {
const raw = (student.value?.settings as Partial<StudentSettings>) || {}

return {
theme: typeof raw.theme === 'string' && raw.theme !== 'light' ? raw.theme : 'light',
dyslexiaFont: Boolean(raw.dyslexiaFont),
language: raw.language || 'en',
fontSize: Number(raw.fontSize) || 1,
Expand Down
132 changes: 100 additions & 32 deletions app/pages/reader/decor.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,78 @@
<script setup lang="ts">
definePageMeta({ ssr: false })

const settings = reactive({ theme: 'light', dyslexiaFont: false, language: 'en', fontSize: 1 })
const stats = reactive({ xp: 1250, booksRead: 14, streak: 5, tickets: 3 })
const { student, settings, saveSettings, restoreStudent, updateExp } = useCurrentStudent()
const { tickets, loadProgress } = useCurrentStudentProgress()

type ShopItemUi = {
id: number
type: string
name: string
cost: number
owned: boolean
class: string
previewBg?: string
previewGrad?: string
}

const stats = computed(() => ({
xp: student.value ? student.value.exp : 0,
tickets: tickets.value ? tickets.value : 0,
}))

async function loadShopItems() {
if (!student.value?.id) return

const rawItems = await $fetch<any[]>('/api/shop', {
query: { studentId: student.value.id },
})

// Fallback mapping keeps current visual cards while moving source of truth to DB.
const styleMap: Record<string, { class: string; previewBg: string; previewGrad: string }> = {
'Light Bloom': { class: 'light', previewBg: '#f5ede3', previewGrad: 'radial-gradient(at 0% 0%, hsla(25,95%,75%,0.3) 0px, transparent 50%)' },
'Galaxy Night': { class: 'dark', previewBg: '#1f3b7c', previewGrad: 'radial-gradient(at 0% 0%, hsla(250,20%,20%,0.5) 0px, transparent 50%)' },
'Old Parchment': { class: 'sepia', previewBg: '#f4ecd8', previewGrad: 'none' },
Sunset: { class: 'sunset', previewBg: '#fff5f5', previewGrad: 'radial-gradient(at 0% 0%, hsla(10,90%,75%,0.25) 0px, transparent 50%)' },
Ocean: { class: 'ocean', previewBg: '#f0f9ff', previewGrad: 'radial-gradient(at 0% 0%, hsla(200,90%,75%,0.25) 0px, transparent 50%)' },
Forest: { class: 'forest', previewBg: '#f0fdf4', previewGrad: 'radial-gradient(at 0% 0%, hsla(140,80%,70%,0.25) 0px, transparent 50%)' },
Candy: { class: 'candy', previewBg: '#fdf2f8', previewGrad: 'radial-gradient(at 0% 0%, hsla(330,90%,85%,0.35) 0px, transparent 50%)' },
Fire: { class: 'fire', previewBg: '#fff7ed', previewGrad: 'radial-gradient(at 30% 40%, hsla(20,95%,65%,0.3) 0px, transparent 50%)' },
Ice: { class: 'ice', previewBg: '#f0f9ff', previewGrad: 'radial-gradient(at 0% 0%, hsla(200,100%,95%,0.4) 0px, transparent 50%)' },
}

shopItems.value = rawItems.map((item) => {
const mapped = styleMap[item.name] || {
class: item.type === 'theme' ? 'light' : '',
previewBg: '#f5ede3',
previewGrad: 'none',
}

return {
id: item.id,
type: item.type,
name: item.name,
cost: item.cost,
owned: Boolean(item.owned) || item.cost === 0,
class: mapped.class,
previewBg: mapped.previewBg,
previewGrad: mapped.previewGrad,
} as ShopItemUi
})
}

onMounted(async () => {
if (!student.value) {
await restoreStudent()
}
if (student.value) {
await loadProgress()
await loadShopItems()
}
})

const themeClass = computed(() => {
const t = settings.theme !== 'light' ? `theme-${settings.theme}` : ''
const d = settings.dyslexiaFont ? 'dyslexia-font' : ''
const t = settings.value.theme !== 'light' ? `theme-${settings.value.theme}` : ''
const d = settings.value.dyslexiaFont ? 'dyslexia-font' : ''
return `reader-app ${t} ${d}`.trim()
})

Expand All @@ -32,42 +98,44 @@ function triggerTicketClick() {
//

// ── Shop items — themes ──
const shopItems = ref([
{ id: 1, type:'theme', name:'Light Bloom', cost:0, class:'light', owned:true, previewBg:'#f5ede3', previewGrad:'radial-gradient(at 0% 0%, hsla(25,95%,75%,0.3) 0px, transparent 50%)' },
{ id: 2, type:'theme', name:'Galaxy Night', cost:500, class:'blue', owned:false, previewBg:'#1f3b7c', previewGrad:'radial-gradient(at 0% 0%, hsla(250,20%,20%,0.5) 0px, transparent 50%)' },
{ id: 3, type:'theme', name:'Old Parchment', cost:300, class:'sepia', owned:false, previewBg:'#f4ecd8', previewGrad:'none' },
{ id: 10, type:'theme', name:'Sunset', cost:100, class:'sunset', owned:false, previewBg:'#fff5f5', previewGrad:'radial-gradient(at 0% 0%, hsla(10,90%,75%,0.25) 0px, transparent 50%)' },
{ id: 11, type:'theme', name:'Ocean', cost:100, class:'ocean', owned:false, previewBg:'#f0f9ff', previewGrad:'radial-gradient(at 0% 0%, hsla(200,90%,75%,0.25) 0px, transparent 50%)' },
{ id: 12, type:'theme', name:'Forest', cost:150, class:'forest', owned:false, previewBg:'#f0fdf4', previewGrad:'radial-gradient(at 0% 0%, hsla(140,80%,70%,0.25) 0px, transparent 50%)' },
{ id: 13, type:'theme', name:'Candy', cost:150, class:'candy', owned:false, previewBg:'#fdf2f8', previewGrad:'radial-gradient(at 0% 0%, hsla(330,90%,85%,0.35) 0px, transparent 50%)' },
{ id: 14, type:'theme', name:'Fire', cost:150, class:'fire', owned:false, previewBg:'#fff7ed', previewGrad:'radial-gradient(at 30% 40%, hsla(20,95%,65%,0.3) 0px, transparent 50%)' },
{ id: 15, type:'theme', name:'Ice', cost:150, class:'ice', owned:false, previewBg:'#f0f9ff', previewGrad:'radial-gradient(at 0% 0%, hsla(200,100%,95%,0.4) 0px, transparent 50%)' },
{ id: 4, type:'animation', name:'Twinkling Stars', cost:200, owned:false },
{ id: 5, type:'animation', name:'Confetti Rain', cost:1000, owned:false },
{ id: 6, type:'animation', name:'Magic Sparkles', cost:400, owned:false },
{ id: 7, type:'animation', name:'Fireflies', cost:400, owned:false },
{ id: 8, type:'animation', name:'Fluttering Butterflies', cost:500, owned:false },
{ id: 9, type:'animation', name:'Falling Leaves', cost:800, owned:false },
])
const shopItems = ref<ShopItemUi[]>([])

const activeAnimations = ref<string[]>([])
const showShopCelebration = ref('')

function buyItem(item: any) {
if (stats.xp >= item.cost) {
stats.xp -= item.cost
item.owned = true
showShopCelebration.value = item.name
setTimeout(() => { showShopCelebration.value = '' }, 2500)
applyItem(item)
async function buyItem(item: any) {
if (stats.value.xp >= item.cost) {
try {
// await updateExp(-item.cost)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove if shop/unlock now handles exp update

if (student.value?.id) {
await $fetch('/api/shop/unlock', {
method: 'POST',
body: {
studentId: student.value.id,
shopItemId: item.id,
},
})
}

await loadShopItems()
item.owned = true
showShopCelebration.value = item.name
setTimeout(() => { showShopCelebration.value = '' }, 2500)
await applyItem(item)
await restoreStudent()
} catch (e) {
console.error('Failed to purchase item', e)
alert('Purchase failed. Please try again.')
}
} else {
alert('Not enough XP!')
}
}

function applyItem(item: any) {
async function applyItem(item: any) {
if (item.type === 'theme') {
settings.theme = item.class
await saveSettings({ theme: item.class })
return
} else if (item.type === 'animation') {
if (activeAnimations.value.includes(item.name)) {
activeAnimations.value = activeAnimations.value.filter(a => a !== item.name)
Expand Down Expand Up @@ -114,9 +182,9 @@ function applyItem(item: any) {
<span v-for="id in flyTickets" :key="id" class="absolute text-lg animate-ticket-fly pointer-events-none"
style="left:50%;top:50%;transform:translateX(-50%) translateY(-50%)">🎟️</span>
</div>
<NuxtLink to="/reader"
<NuxtLink to="/reader/settings"
class="w-14 h-14 bg-white/90 backdrop-blur-md rounded-xl flex items-center justify-center text-2xl border-2 border-white shadow-xl hover:scale-110 active:scale-95 transition-all"
style="text-decoration:none">🏠</NuxtLink>
style="text-decoration:none">⚙️</NuxtLink>
</div>
</header>

Expand Down
3 changes: 2 additions & 1 deletion app/pages/reader/forms.vue

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'light' to 'default' again

Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ const stats = computed(() => ({
}))

const themeClass = computed(() => {
const t = settings.value.theme !== 'light' ? `theme-${settings.value.theme}` : ''
const d = settings.value.dyslexiaFont ? 'dyslexia-font' : ''
return `reader-app ${d}`.trim()
return `reader-app ${t} ${d}`.trim()
})

const currentFormComponentsWithVideo = computed(() => {
Expand Down
4 changes: 2 additions & 2 deletions app/pages/reader/home.vue

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see other comment about changing 'light' to 'default'

Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ onMounted(async () => {

// ── Theme class ──
const themeClass = computed(() => {
const t = 'light'
const t = settings.value.theme !== 'light' ? `theme-${settings.value.theme}` : ''
const d = settings.value.dyslexiaFont ? 'dyslexia-font' : ''
return `reader-app ${t} ${d}`.trim()
})
Expand Down Expand Up @@ -141,7 +141,7 @@ const completionMessage = computed(() => {
<NuxtLink
to="/reader/settings"
class="w-14 h-14 bg-white/90 backdrop-blur-md rounded-xl flex items-center justify-center text-2xl transition-all border-2 border-white shadow-xl hover:scale-110 active:scale-95"
style="hover:color: var(--brand-indigo)"
style="text-decoration: none;"
>⚙️</NuxtLink>
</div>
</header>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
Warnings:

- You are about to drop the `_ShopItemToStudent` table. If the table is not empty, all the data it contains will be lost.

*/
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "_ShopItemToStudent";
PRAGMA foreign_keys=on;

-- CreateTable
CREATE TABLE "StudentShopItem" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"studentId" INTEGER NOT NULL,
"shopItemId" INTEGER NOT NULL,
"unlockedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StudentShopItem_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "StudentShopItem_shopItemId_fkey" FOREIGN KEY ("shopItemId") REFERENCES "ShopItem" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);

-- CreateIndex
CREATE INDEX "StudentShopItem_studentId_idx" ON "StudentShopItem"("studentId");

-- CreateIndex
CREATE INDEX "StudentShopItem_shopItemId_idx" ON "StudentShopItem"("shopItemId");

-- CreateIndex
CREATE UNIQUE INDEX "StudentShopItem_studentId_shopItemId_key" ON "StudentShopItem"("studentId", "shopItemId");
36 changes: 25 additions & 11 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,12 @@ model Student {
settings Json?
exp Int @default(0)
parentUserId String

Parent User @relation(fields: [parentUserId], references: [id], onDelete: Cascade)
Classes Class[]
Submissions FormSubmission[]
ShopItems StudentShopItem[]
WinnerOf FormGroup[] @relation("RaffleWinner")
unlockedShopItems ShopItem[]
@@index([parentUserId])
}

Expand Down Expand Up @@ -200,15 +200,15 @@ model SubmissionResponse {
}

model ShopItem {
id Int @id @default(autoincrement())
type String
name String
dateAvailable DateTime
cost Int @default(0)

Theme ShopTheme?
Animation ShopAnimation?
unlockedByStudents Student[]
id Int @id @default(autoincrement())
type String
name String
dateAvailable DateTime
cost Int @default(0)

Theme ShopTheme?
Animation ShopAnimation?
StudentUnlocks StudentShopItem[]
}

model ShopTheme {
Expand All @@ -229,6 +229,20 @@ model ShopAnimation {
ShopItem ShopItem @relation(fields: [shopItem], references: [id], onDelete: Cascade)
}

model StudentShopItem {
id Int @id @default(autoincrement())
studentId Int
shopItemId Int
unlockedAt DateTime @default(now())

Student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
ShopItem ShopItem @relation(fields: [shopItemId], references: [id], onDelete: Cascade)

@@unique([studentId, shopItemId])
@@index([studentId])
@@index([shopItemId])
}

model PendingEmailChange {
id String @id @default(cuid())
userId String @unique
Expand Down
81 changes: 81 additions & 0 deletions prisma/seed.ts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

coach implementation's admin change makes the seed script fail before it can seed all of the themes since the admin table no longer exists.

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,86 @@ function makeLocalDate(dateString: string) {
return new Date(year, month - 1, day)
}




const shopThemes = [
{ id: 1, name: 'Light Bloom', cost: 0, class: 'light', color: '#f5ede3', grad: 'radial-gradient(at 0% 0%, hsla(25,95%,75%,0.3) 0px, transparent 50%)' },
{ id: 2, name: 'Galaxy Night', cost: 500, class: 'dark', color: '#1f3b7c', grad: 'radial-gradient(at 0% 0%, hsla(250,20%,20%,0.5) 0px, transparent 50%)' },
{ id: 3, name: 'Old Parchment', cost: 300, class: 'sepia', color: '#f4ecd8', grad: 'none' },
{ id: 4, name: 'Sunset', cost: 100, class: 'sunset', color: '#fff5f5', grad: 'radial-gradient(at 0% 0%, hsla(10,90%,75%,0.25) 0px, transparent 50%)' },
{ id: 5, name: 'Ocean', cost: 100, class: 'ocean', color: '#f0f9ff', grad: 'radial-gradient(at 0% 0%, hsla(200,90%,75%,0.25) 0px, transparent 50%)' },
{ id: 6, name: 'Forest', cost: 150, class: 'forest', color: '#f0fdf4', grad: 'radial-gradient(at 0% 0%, hsla(140,80%,70%,0.25) 0px, transparent 50%)' },
{ id: 7, name: 'Candy', cost: 150, class: 'candy', color: '#fdf2f8', grad: 'radial-gradient(at 0% 0%, hsla(330,90%,85%,0.35) 0px, transparent 50%)' },
{ id: 8, name: 'Fire', cost: 150, class: 'fire', color: '#fff7ed', grad: 'radial-gradient(at 30% 40%, hsla(20,95%,65%,0.3) 0px, transparent 50%)' },
{ id: 9, name: 'Ice', cost: 150, class: 'ice', color: '#f0f9ff', grad: 'radial-gradient(at 0% 0%, hsla(200,100%,95%,0.4) 0px, transparent 50%)' },
]

const shopAnimations = [
{ id: 20, name: 'Twinkling Stars', cost: 200 },
{ id: 21, name: 'Confetti Rain', cost: 1000 },
{ id: 22, name: 'Magic Sparkles', cost: 400 },
{ id: 23, name: 'Fireflies', cost: 400 },
{ id: 24, name: 'Fluttering Butterflies', cost: 500 },
{ id: 25, name: 'Falling Leaves', cost: 800 },
]

async function seedShopItems() {
for (const t of shopThemes) {
await prisma.shopItem.upsert({
where: { id: t.id },
update: {
type: 'theme',
name: t.name,
cost: t.cost,
Theme: {
upsert: {
update: { themeColor: t.color, themeEffect: { class: t.class, previewGrad: t.grad } },
create: { themeColor: t.color, themeEffect: { class: t.class, previewGrad: t.grad } },
},
},
},
create: {
id: t.id,
type: 'theme',
name: t.name,
cost: t.cost,
dateAvailable: new Date(),
Theme: {
create: { themeColor: t.color, themeEffect: { class: t.class, previewGrad: t.grad } },
},
},
})
}

for (const a of shopAnimations) {
await prisma.shopItem.upsert({
where: { id: a.id },
update: {
type: 'animation',
name: a.name,
cost: a.cost,
Animation: {
upsert: {
update: { animationType: a.name },
create: { animationType: a.name },
},
},
},
create: {
id: a.id,
type: 'animation',
name: a.name,
cost: a.cost,
dateAvailable: new Date(),
Animation: {
create: { animationType: a.name },
},
},
})
}
}

async function main() {
const seededEmails = [
'parent1@example.com',
Expand Down Expand Up @@ -167,6 +247,7 @@ async function main() {
},
})

await seedShopItems()
// Clean old progress-testing data
await prisma.submissionResponse.deleteMany()
await prisma.formSubmission.deleteMany()
Expand Down
Loading