Skip to content
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
3 changes: 3 additions & 0 deletions app/layouts/coach.css
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ html, body { margin: 0; padding: 0; height: 100%; }
.rh-header { min-height:5.5rem; flex-shrink:0; display:flex; align-items:center; justify-content:space-between; gap:1.5rem; padding:1rem 2rem; background:#f8fafc; border-bottom:1px solid #dbe3ec; }
.rh-header-label { margin:0 0 .2rem; color:#94a3b8; font-size:.7rem; font-weight:700; letter-spacing:.1em; text-transform:uppercase; }
.rh-header-title { margin:0; color:#0f172a; font-size:1.35rem; font-weight:600; }
.rh-verified-badge, .rh-pending-badge { display:inline-flex; margin-top:6px; padding:4px 8px; border-radius:999px; font-size:11px; font-weight:700; }
.rh-verified-badge { background:#dcfce7; color:#15803d; }
.rh-pending-badge { background:#fef3c7; color:#b45309; }
.rh-main { min-height:0; flex:1; overflow-y:auto; padding:24px 32px; }
.rh-tab-content { max-width:56rem; margin:0 auto; }

Expand Down
3 changes: 3 additions & 0 deletions app/layouts/coach.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!-- layouts/coach.vue -->
<script setup lang="ts">
const route = useRoute()
const { data: coachProfile } = await useFetch<{ tag: string; verified: boolean } | null>('/api/coach/profile')
const selectedClassToken = useCookie<string | null>('selected-class-token', {
sameSite: 'lax',
})
Expand Down Expand Up @@ -58,6 +59,8 @@ function navigateWithinClass(path: string) {
<div>
<p class="rh-header-label">Reading Huddle</p>
<h1 class="rh-header-title">Reading Coach</h1>
<span v-if="coachProfile?.verified" class="rh-verified-badge">Verified Teacher</span>
<span v-else-if="coachProfile?.tag === 'teacher'" class="rh-pending-badge">Verification pending</span>
</div>

<ClassContextSelect />
Expand Down
16 changes: 15 additions & 1 deletion app/middleware/auth.global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,21 @@ export default defineNuxtRouteMiddleware(async (to) => {
if (isCoachRoute && userRole !== 'admin' && userRole !== 'coach') {
return navigateTo('/reader/profile')
}
if (isCoachRoute && to.path !== '/coach/create-class' && userRole === 'coach') {

if (isCoachRoute && userRole === 'coach') {
const requestFetch = useRequestFetch()
const coach = await requestFetch<{ id: string } | null>('/api/coach/profile')

if (!coach && to.path !== '/coach/onboarding') {
return navigateTo('/coach/onboarding')
}

if (coach && to.path === '/coach/onboarding') {
return navigateTo('/coach')
}
}

if (isCoachRoute && to.path !== '/coach/create-class' && to.path !== '/coach/onboarding' && userRole === 'coach') {
const requestFetch = useRequestFetch()
const classes = await requestFetch<Array<{ joinToken: string }>>('/api/admin/classes')

Expand Down
73 changes: 46 additions & 27 deletions app/pages/admin/teacher-verification.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ useHead({
type VerificationStatus = 'Pending' | 'Approved' | 'Denied'

type TeacherApplication = {
id: number
id: string
name: string
email: string
role: string
Expand All @@ -30,6 +30,23 @@ const statusFilter = ref<VerificationStatus | 'All'>('Pending')

const selectedApplication = ref<TeacherApplication | null>(null)
const modalOpen = ref(false)
const loading = ref(true)

async function loadApplications() {
loading.value = true

try {
const result = await $fetch<TeacherApplication[]>('/api/admin/teacher-verification')
applications.value = result.map(application => ({
...application,
requestedAt: new Date(application.requestedAt).toLocaleDateString(),
}))
} finally {
loading.value = false
}
}

onMounted(loadApplications)

const filteredApplications = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
Expand Down Expand Up @@ -75,40 +92,35 @@ function closeModal() {
selectedApplication.value = null
}

function updateStatus(status: VerificationStatus) {
async function updateStatus(status: VerificationStatus) {
if (!selectedApplication.value) {
return
}

const application = applications.value.find(
item => item.id === selectedApplication.value?.id,
)

if (!application) {
return
}
await $fetch(`/api/admin/teacher-verification/${selectedApplication.value.id}`, {
method: 'PATCH',
body: { status },
})

application.status = status
await loadApplications()
closeModal()
}

function saveApplicationChanges() {
async function saveApplicationChanges() {
if (!selectedApplication.value) {
return
}

const index = applications.value.findIndex(
application => application.id === selectedApplication.value?.id,
)

if (index === -1) {
return
}

applications.value[index] = {
...selectedApplication.value,
}
await $fetch(`/api/admin/teacher-verification/${selectedApplication.value.id}`, {
method: 'PATCH',
body: {
school: selectedApplication.value.school,
district: selectedApplication.value.district,
zipcode: selectedApplication.value.zipcode,
},
})

await loadApplications()
closeModal()
}
</script>
Expand Down Expand Up @@ -163,8 +175,15 @@ function saveApplicationChanges() {
</div>

<div class="application-list">
<div
v-if="loading"
class="empty-state"
>
Loading teacher applications...
</div>

<article
v-for="application in filteredApplications"
v-for="application in loading ? [] : filteredApplications"
:key="application.id"
class="application-row"
>
Expand Down Expand Up @@ -229,7 +248,7 @@ function saveApplicationChanges() {
</article>

<div
v-if="filteredApplications.length === 0"
v-if="!loading && filteredApplications.length === 0"
class="empty-state"
>
No matching teacher applications found.
Expand Down Expand Up @@ -293,18 +312,18 @@ function saveApplicationChanges() {
<div class="modal-fields">
<label>
<span>Name</span>
<input v-model="selectedApplication.name" type="text">
<input v-model="selectedApplication.name" type="text" disabled>
</label>

<label>
<span>Email</span>
<input v-model="selectedApplication.email" type="email">
<input v-model="selectedApplication.email" type="email" disabled>
</label>

<label>
<span>Role</span>

<select v-model="selectedApplication.role">
<select v-model="selectedApplication.role" disabled>
<option value="Teacher">Teacher</option>
<option value="Study Group">Study Group</option>
<option value="Other">Other</option>
Expand Down
105 changes: 105 additions & 0 deletions app/pages/coach/onboarding.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<script setup lang="ts">
definePageMeta({ ssr: false })

const form = reactive({
purpose: '',
school: '',
district: '',
zipcode: '',
})
const isSubmitting = ref(false)
const submissionError = ref('')
const isTeacher = computed(() => form.purpose === 'teacher')

async function submit() {
submissionError.value = ''
isSubmitting.value = true

try {
await $fetch('/api/coach/profile', {
method: 'POST',
body: form,
})
await navigateTo('/coach')
} catch (error: any) {
submissionError.value = error?.data?.statusMessage || 'Your coach account could not be set up.'
} finally {
isSubmitting.value = false
}
}
</script>

<template>
<main class="onboarding-page">
<section class="onboarding-card">
<p class="eyebrow">Reading Coach Setup</p>
<h1>What is the purpose of your account?</h1>

<form @submit.prevent="submit">
<div class="purpose-options">
<label :class="{ selected: form.purpose === 'teacher' }">
<input v-model="form.purpose" type="radio" value="teacher" required>
<strong>Teacher</strong>
</label>

<label :class="{ selected: form.purpose === 'studygroup' }">
<input v-model="form.purpose" type="radio" value="studygroup" required>
<strong>Study Group</strong>
</label>

<label :class="{ selected: form.purpose === 'other' }">
<input v-model="form.purpose" type="radio" value="other" required>
<strong>Other</strong>
</label>
</div>

<div v-if="isTeacher" class="teacher-fields">
<p>Your information will be sent to an administrator for teacher verification.</p>

<label>
<span>School</span>
<input v-model="form.school" required maxlength="160" placeholder="School name">
</label>

<label>
<span>School district</span>
<input v-model="form.district" required maxlength="160" placeholder="District name">
</label>

<label>
<span>ZIP code</span>
<input v-model="form.zipcode" required inputmode="numeric" maxlength="10" pattern="\d{5}(-\d{4})?" placeholder="12345">
</label>
</div>

<p v-if="submissionError" class="error-message" role="alert">{{ submissionError }}</p>

<button type="submit" :disabled="isSubmitting || !form.purpose">
{{ isSubmitting ? 'Saving...' : 'Continue' }}
</button>
</form>
</section>
</main>
</template>

<style scoped>
.onboarding-page { min-height:100vh; display:grid; place-items:center; padding:2rem; background:linear-gradient(135deg, #faefe5, #f7eee6 55%, #fff4da); color:#172033; }
.onboarding-card { width:min(48rem, 100%); padding:2.75rem; background:#fff; border-top:7px solid #f0a446; border-radius:1.5rem; box-shadow:0 20px 60px rgb(80 60 40 / 14%); }
.eyebrow { margin:0 0 .5rem; color:#5b5fe8; font-size:.75rem; font-weight:800; letter-spacing:.12em; text-transform:uppercase; }
h1 { margin:0; font-size:2.25rem; line-height:1.15; }
.purpose-options { display:grid; grid-template-columns:repeat(3, 1fr); gap:.85rem; margin-top:2rem; }
.purpose-options label { position:relative; display:grid; min-height:4.5rem; place-items:center; padding:1rem; border:2px solid #d9dcf8; border-radius:1rem; background:#fff; cursor:pointer; transition:border-color .15s, background .15s, transform .15s; }
.purpose-options label:hover { border-color:#f0a446; transform:translateY(-2px); }
.purpose-options label.selected { border-color:#5b5fe8; background:#eef0ff; color:#393dc4; }
.purpose-options input { position:absolute; width:1px; height:1px; opacity:0; }
.teacher-fields { display:grid; grid-template-columns:1fr 1fr; gap:1rem; margin-top:1.25rem; }
.teacher-fields p { grid-column:1 / -1; margin:0; padding:.85rem 1rem; border:1px solid #fde68a; border-radius:.75rem; background:#fffbeb; color:#92400e; }
.teacher-fields label { display:flex; flex-direction:column; gap:.4rem; font-weight:700; }
.teacher-fields label:last-child { grid-column:1 / -1; }
.teacher-fields input { padding:.8rem .9rem; border:1px solid #cbd5e1; border-radius:.7rem; font:inherit; outline:none; }
.teacher-fields input:focus { border-color:#5b5fe8; box-shadow:0 0 0 3px rgb(91 95 232 / 12%); }
.error-message { color:#b91c1c; font-weight:600; }
button { width:100%; margin-top:1.5rem; padding:.95rem; border:0; border-radius:.75rem; background:linear-gradient(90deg, #5b5fe8, #7867dd 55%, #f0a446); color:#fff; font:inherit; font-weight:800; cursor:pointer; box-shadow:0 10px 24px rgb(91 95 232 / 22%); }
button:disabled { opacity:.7; cursor:not-allowed; }
@media (max-width:640px) { .onboarding-card { padding:1.5rem; } .purpose-options, .teacher-fields { grid-template-columns:1fr; } .teacher-fields label:last-child { grid-column:auto; } }
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
DROP INDEX "Poster_userId_key";

CREATE UNIQUE INDEX "Coach_userId_key" ON "Coach"("userId");
1 change: 0 additions & 1 deletion prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ async function main() {
email: seededEmails[2],
emailVerified: true,
role: 'admin',
admin: true,
accounts: {
create: {
id: 'seed_account_6',
Expand Down
2 changes: 1 addition & 1 deletion server/api/admin/classes.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export default defineEventHandler(async (event) => {
where: {
userId: session.user.id,
},
update: coachData,
update: {},
create: {
...coachData,
userId: session.user.id,
Expand Down
29 changes: 29 additions & 0 deletions server/api/admin/teacher-verification.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { prisma } from '../../utils/prisma'
import { requireAdmin } from '../../utils/require-session'

export default defineEventHandler(async (event) => {
await requireAdmin(event)

const applications = await prisma.coach.findMany({
where: { tag: { in: ['teacher', 'teacher_denied'] } },
include: {
User: {
select: { name: true, email: true, createdAt: true },
},
},
})

return applications.map(application => ({
id: application.id,
name: application.User.name,
email: application.User.email,
role: 'Teacher',
school: application.school ?? '',
district: application.district ?? '',
zipcode: application.zipcode ?? '',
requestedAt: application.User.createdAt,
status: application.tag === 'teacher_denied'
? 'Denied'
: application.verified ? 'Approved' : 'Pending',
}))
})
40 changes: 40 additions & 0 deletions server/api/admin/teacher-verification/[id].patch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { z } from 'zod'
import { prisma } from '../../../utils/prisma'
import { requireAdmin } from '../../../utils/require-session'

const verificationUpdateSchema = z.object({
status: z.enum(['Approved', 'Denied']).optional(),
school: z.string().trim().min(1).max(160).optional(),
district: z.string().trim().min(1).max(160).optional(),
zipcode: z.string().trim().regex(/^\d{5}(?:-\d{4})?$/).optional(),
})

export default defineEventHandler(async (event) => {
await requireAdmin(event)
const id = getRouterParam(event, 'id')
const parsed = verificationUpdateSchema.safeParse(await readBody(event))

if (!id || !parsed.success) {
throw createError({ statusCode: 400, statusMessage: 'Invalid verification update' })
}

const application = await prisma.coach.findUnique({
where: { id },
select: { tag: true },
})

if (!application || !['teacher', 'teacher_denied'].includes(application.tag)) {
throw createError({ statusCode: 404, statusMessage: 'Teacher application not found' })
}

return await prisma.coach.update({
where: { id },
data: {
school: parsed.data.school,
district: parsed.data.district,
zipcode: parsed.data.zipcode,
...(parsed.data.status === 'Approved' ? { tag: 'teacher', verified: true } : {}),
...(parsed.data.status === 'Denied' ? { tag: 'teacher_denied', verified: false } : {}),
},
})
})
Loading