feat(targeted): add targeted practice functionality
feat(analytics); add analytics page
This commit is contained in:
@ -20,8 +20,10 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"framer-motion": "^12.30.0",
|
||||||
"katex": "^0.16.28",
|
"katex": "^0.16.28",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-katex": "^3.1.0",
|
"react-katex": "^3.1.0",
|
||||||
|
|||||||
968
pnpm-lock.yaml
generated
968
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -19,6 +19,7 @@ import { StudentLayout } from "./pages/student/StudentLayout";
|
|||||||
import { TargetedPractice } from "./pages/student/targeted-practice/page";
|
import { TargetedPractice } from "./pages/student/targeted-practice/page";
|
||||||
import { Drills } from "./pages/student/drills/page";
|
import { Drills } from "./pages/student/drills/page";
|
||||||
import { HardTestModules } from "./pages/student/hard-test-modules/page";
|
import { HardTestModules } from "./pages/student/hard-test-modules/page";
|
||||||
|
import { Analytics } from "./pages/student/Analytics";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
@ -53,6 +54,10 @@ function App() {
|
|||||||
path: "profile",
|
path: "profile",
|
||||||
element: <Profile />,
|
element: <Profile />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "analytics",
|
||||||
|
element: <Analytics />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "practice/:sheetId",
|
path: "practice/:sheetId",
|
||||||
element: <Pretest />,
|
element: <Pretest />,
|
||||||
|
|||||||
0
src/components/ChoiceCard.tsx
Normal file
0
src/components/ChoiceCard.tsx
Normal file
73
src/components/CircularProgress.tsx
Normal file
73
src/components/CircularProgress.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
type CircularProgressProps = {
|
||||||
|
value: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
size?: number;
|
||||||
|
strokeWidth?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CircularProgress({
|
||||||
|
value,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
size = 80,
|
||||||
|
strokeWidth = 6,
|
||||||
|
}: CircularProgressProps) {
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
|
||||||
|
// normalize value to 0–1
|
||||||
|
const normalized = max === min ? 0 : (value - min) / (max - min);
|
||||||
|
|
||||||
|
// clamp between 0 and 1
|
||||||
|
const clamped = Math.min(1, Math.max(0, normalized));
|
||||||
|
|
||||||
|
const offset = circumference * (1 - clamped);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size}>
|
||||||
|
{/* background */}
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
stroke="#fff"
|
||||||
|
strokeOpacity={0.5}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="transparent"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* progress */}
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
stroke="#fff"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="transparent"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
style={{
|
||||||
|
transform: "rotate(-90deg)",
|
||||||
|
transformOrigin: "50% 50%",
|
||||||
|
transition: "stroke-dashoffset 0.4s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* label */}
|
||||||
|
<text
|
||||||
|
x="50%"
|
||||||
|
y="50%"
|
||||||
|
textAnchor="middle"
|
||||||
|
dy=".3em"
|
||||||
|
fontSize="24"
|
||||||
|
fontWeight="600"
|
||||||
|
fontFamily="Satoshi"
|
||||||
|
color="#fff"
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
src/components/ui/field.tsx
Normal file
246
src/components/ui/field.tsx
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
import { useMemo } from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
data-slot="field-set"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-6",
|
||||||
|
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLegend({
|
||||||
|
className,
|
||||||
|
variant = "legend",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||||
|
return (
|
||||||
|
<legend
|
||||||
|
data-slot="field-legend"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"mb-3 font-medium",
|
||||||
|
"data-[variant=legend]:text-base",
|
||||||
|
"data-[variant=label]:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-group"
|
||||||
|
className={cn(
|
||||||
|
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldVariants = cva(
|
||||||
|
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||||
|
horizontal: [
|
||||||
|
"flex-row items-center",
|
||||||
|
"[&>[data-slot=field-label]]:flex-auto",
|
||||||
|
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
],
|
||||||
|
responsive: [
|
||||||
|
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
|
||||||
|
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||||
|
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="field"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(fieldVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-content"
|
||||||
|
className={cn(
|
||||||
|
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Label>) {
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||||
|
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
||||||
|
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="field-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||||
|
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
||||||
|
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-separator"
|
||||||
|
data-content={!!children}
|
||||||
|
className={cn(
|
||||||
|
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Separator className="absolute inset-0 top-1/2" />
|
||||||
|
{children && (
|
||||||
|
<span
|
||||||
|
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||||
|
data-slot="field-separator-content"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
errors,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
errors?: Array<{ message?: string } | undefined>
|
||||||
|
}) {
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (children) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errors?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueErrors = [
|
||||||
|
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (uniqueErrors?.length == 1) {
|
||||||
|
return uniqueErrors[0]?.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||||
|
{uniqueErrors.map(
|
||||||
|
(error, index) =>
|
||||||
|
error?.message && <li key={index}>{error.message}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}, [children, errors])
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-slot="field-error"
|
||||||
|
className={cn("text-destructive text-sm font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldSet,
|
||||||
|
FieldContent,
|
||||||
|
FieldTitle,
|
||||||
|
}
|
||||||
22
src/components/ui/label.tsx
Normal file
22
src/components/ui/label.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Label as LabelPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
29
src/components/ui/progress.tsx
Normal file
29
src/components/ui/progress.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Progress as ProgressPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(
|
||||||
|
"bg-purple-100/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className="bg-black h-full w-full flex-1 transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress };
|
||||||
28
src/components/ui/separator.tsx
Normal file
28
src/components/ui/separator.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@ -81,3 +81,20 @@ export const formatTime = (seconds: number) => {
|
|||||||
const s = seconds % 60;
|
const s = seconds % 60;
|
||||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const slideVariants = {
|
||||||
|
initial: (direction: number) => ({
|
||||||
|
x: direction > 0 ? 100 : -100,
|
||||||
|
opacity: 0,
|
||||||
|
}),
|
||||||
|
animate: {
|
||||||
|
x: 0,
|
||||||
|
opacity: 1,
|
||||||
|
transition: { duration: 0.35 },
|
||||||
|
},
|
||||||
|
exit: (direction: number) => ({
|
||||||
|
x: direction > 0 ? -100 : 100,
|
||||||
|
opacity: 0,
|
||||||
|
transition: { duration: 0.25 },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|||||||
71
src/pages/student/Analytics.tsx
Normal file
71
src/pages/student/Analytics.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { List, SquarePen, DecimalsArrowRight, MapPin } from "lucide-react";
|
||||||
|
import { Progress } from "../../components/ui/progress";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
import { Field, FieldLabel } from "../../components/ui/field";
|
||||||
|
import { CircularProgress } from "../../components/CircularProgress";
|
||||||
|
|
||||||
|
export const Analytics = () => {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4">
|
||||||
|
<h1 className="font-satoshi-bold text-3xl text-center tracking-tight">
|
||||||
|
Analytics
|
||||||
|
</h1>
|
||||||
|
<section className="flex w-full gap-3 justify-between">
|
||||||
|
<Card className="w-1/3 relative bg-linear-to-br from-purple-600 to-purple-700 rounded-4xl">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CardContent className="md:w-full space-y-4 flex flex-col items-center justify-center h-50">
|
||||||
|
<MapPin size={60} color="white" />
|
||||||
|
<h1 className="text-4xl font-satoshi-bold text-white flex">
|
||||||
|
<span>145</span> <span className="text-xl">th</span>
|
||||||
|
</h1>
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden opacity-0 -rotate-45 absolute -top-2 -right-30 ">
|
||||||
|
<DecimalsArrowRight size={380} color="white" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card
|
||||||
|
className="w-2/3 relative bg-linear-to-br from-gray-100 to-gray-300 rounded-4xl
|
||||||
|
flex-row"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CardHeader className="md:w-full">
|
||||||
|
<CardTitle className="font-satoshi-bold tracking-tight text-3xl ">
|
||||||
|
Details
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="md:w-full space-y-4"></CardContent>
|
||||||
|
<CardFooter className="flex justify-between"></CardFooter>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden opacity-30 -rotate-45 absolute -top-2 -right-30 ">
|
||||||
|
<DecimalsArrowRight size={380} color="white" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Field className="w-full max-w-sm">
|
||||||
|
<FieldLabel htmlFor="progress-upload">
|
||||||
|
<span className="font-satoshi text-xl">Score</span>
|
||||||
|
<span className="ml-auto font-satoshi">
|
||||||
|
<span className="text-5xl">854</span>
|
||||||
|
<span className="text-lg">/1600</span>
|
||||||
|
</span>
|
||||||
|
</FieldLabel>
|
||||||
|
<Progress value={55} id="progress-upload" max={100} />
|
||||||
|
</Field>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -6,7 +6,14 @@ import {
|
|||||||
TabsContent,
|
TabsContent,
|
||||||
} from "../../components/ui/tabs";
|
} from "../../components/ui/tabs";
|
||||||
import { useAuthStore } from "../../stores/authStore";
|
import { useAuthStore } from "../../stores/authStore";
|
||||||
import { CheckCircle, Search } from "lucide-react";
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
DecimalsArrowRight,
|
||||||
|
DraftingCompass,
|
||||||
|
List,
|
||||||
|
Search,
|
||||||
|
SquarePen,
|
||||||
|
} from "lucide-react";
|
||||||
import { api } from "../../utils/api";
|
import { api } from "../../utils/api";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@ -21,6 +28,8 @@ import { Button } from "../../components/ui/button";
|
|||||||
import type { PracticeSheet } from "../../types/sheet";
|
import type { PracticeSheet } from "../../types/sheet";
|
||||||
import { formatStatus } from "../../lib/utils";
|
import { formatStatus } from "../../lib/utils";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Progress } from "../../components/ui/progress";
|
||||||
|
import { Field, FieldLabel } from "../../components/ui/field";
|
||||||
|
|
||||||
export const Home = () => {
|
export const Home = () => {
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
@ -78,15 +87,68 @@ export const Home = () => {
|
|||||||
fetchPracticeSheets();
|
fetchPracticeSheets();
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const handleStartPractice = (sheetId: string) => {
|
const handleStartPracticeSheet = (sheetId: string) => {
|
||||||
navigate(`/student/practice/${sheetId}`);
|
navigate(`/student/practice/${sheetId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-gray-50 flex flex-col gap-12 max-w-full mx-auto px-8 sm:px-6 lg:px-8 py-12">
|
<main className="min-h-screen bg-gray-50 space-y-6 max-w-full mx-auto px-8 sm:px-6 lg:px-8 py-12">
|
||||||
<h1 className="text-4xl font-satoshi-bold tracking-tight text-gray-800 text-center">
|
<h1 className="text-4xl font-satoshi-bold tracking-tight text-gray-800 text-center">
|
||||||
Welcome, {user?.name || "Student"}
|
Welcome, {user?.name || "Student"}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
{/* <section className="border rounded-3xl p-5 space-y-4">
|
||||||
|
<p className="font-satoshi">
|
||||||
|
Your predictive SAT score is low. Take a practice test to increase
|
||||||
|
your scores now!
|
||||||
|
</p>
|
||||||
|
</section> */}
|
||||||
|
<Card
|
||||||
|
className="relative bg-linear-to-br from-red-600 to-red-700 rounded-4xl
|
||||||
|
flex-row"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CardHeader className="w-[200%] md:w-full">
|
||||||
|
<CardTitle className="font-satoshi-bold tracking-tight text-3xl text-white">
|
||||||
|
Your score is low!
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="md:w-full space-y-4">
|
||||||
|
<Field className="w-full max-w-sm">
|
||||||
|
<FieldLabel htmlFor="progress-upload">
|
||||||
|
<span className="font-satoshi text-white">Score</span>
|
||||||
|
<span className="ml-auto font-satoshi text-white">
|
||||||
|
854/1600
|
||||||
|
</span>
|
||||||
|
</FieldLabel>
|
||||||
|
<Progress value={55} id="progress-upload" max={100} />
|
||||||
|
</Field>
|
||||||
|
<p className="font-satoshi text-white">
|
||||||
|
Taking more practice tests can increase your score today!
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex justify-between">
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate("/student/analytics")}
|
||||||
|
className="bg-transparent border-2 py-3 px-5 text-md font-satoshi rounded-full"
|
||||||
|
>
|
||||||
|
<List />
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
<Button className="bg-gray-50 py-3 px-5 text-md font-satoshi text-black rounded-full">
|
||||||
|
<SquarePen />
|
||||||
|
Take a practice test
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden opacity-30 -rotate-45 absolute -top-2 -right-30 ">
|
||||||
|
<DecimalsArrowRight size={380} color="white" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<h1 className="font-satoshi-bold text-2xl tracking-tight">
|
||||||
|
What are you looking for?
|
||||||
|
</h1>
|
||||||
<section className="relative w-full">
|
<section className="relative w-full">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -133,7 +195,7 @@ export const Home = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleStartPractice(sheet?.id)}
|
onClick={() => handleStartPracticeSheet(sheet?.id)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="font-satoshi rounded-3xl w-full text-lg py-6 bg-linear-to-br from-purple-500 to-purple-600 text-white"
|
className="font-satoshi rounded-3xl w-full text-lg py-6 bg-linear-to-br from-purple-500 to-purple-600 text-white"
|
||||||
>
|
>
|
||||||
@ -203,7 +265,7 @@ export const Home = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleStartPractice(sheet?.id)}
|
onClick={() => handleStartPracticeSheet(sheet?.id)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="font-satoshi rounded-3xl w-full text-lg py-6 bg-linear-to-br from-purple-500 to-purple-600 text-white"
|
className="font-satoshi rounded-3xl w-full text-lg py-6 bg-linear-to-br from-purple-500 to-purple-600 text-white"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,16 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Outlet, replace, useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { api } from "../../../utils/api";
|
import { api } from "../../../utils/api";
|
||||||
import { useAuthStore } from "../../../stores/authStore";
|
import { useAuthStore } from "../../../stores/authStore";
|
||||||
import type { PracticeSheet } from "../../../types/sheet";
|
import type { PracticeSheet } from "../../../types/sheet";
|
||||||
import {
|
import { CircleQuestionMark, Clock, Layers, Loader, Tag } from "lucide-react";
|
||||||
CircleQuestionMark,
|
|
||||||
Clock,
|
|
||||||
Layers,
|
|
||||||
Loader,
|
|
||||||
Loader2,
|
|
||||||
Tag,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
Carousel,
|
Carousel,
|
||||||
CarouselContent,
|
CarouselContent,
|
||||||
@ -19,8 +12,12 @@ import {
|
|||||||
} from "../../../components/ui/carousel";
|
} from "../../../components/ui/carousel";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
||||||
|
|
||||||
export const Pretest = () => {
|
export const Pretest = () => {
|
||||||
|
const { setSheetId, setMode, storeDuration, setQuestionCount } =
|
||||||
|
useExamConfigStore();
|
||||||
|
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const { sheetId } = useParams<{ sheetId: string }>();
|
const { sheetId } = useParams<{ sheetId: string }>();
|
||||||
const [carouselApi, setCarouselApi] = useState<CarouselApi>();
|
const [carouselApi, setCarouselApi] = useState<CarouselApi>();
|
||||||
@ -37,6 +34,12 @@ export const Pretest = () => {
|
|||||||
console.error("Sheet ID is required to start the test.");
|
console.error("Sheet ID is required to start the test.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSheetId(sheetId);
|
||||||
|
setMode("MODULE");
|
||||||
|
storeDuration(practiceSheet?.time_limit ?? 0);
|
||||||
|
setQuestionCount(2);
|
||||||
|
|
||||||
navigate(`/student/practice/${sheetId}/test`, { replace: true });
|
navigate(`/student/practice/${sheetId}/test`, { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@ -10,7 +10,7 @@ import { Check, Loader2 } from "lucide-react";
|
|||||||
|
|
||||||
import { api } from "../../../utils/api";
|
import { api } from "../../../utils/api";
|
||||||
import { useAuthStore } from "../../../stores/authStore";
|
import { useAuthStore } from "../../../stores/authStore";
|
||||||
import type { PracticeSheet, Question } from "../../../types/sheet";
|
import type { Question } from "../../../types/sheet";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import { useSatExam } from "../../../stores/useSatExam";
|
import { useSatExam } from "../../../stores/useSatExam";
|
||||||
import { useSatTimer } from "../../../hooks/useSatTimer";
|
import { useSatTimer } from "../../../hooks/useSatTimer";
|
||||||
@ -38,8 +38,10 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "../../../components/ui/dialog";
|
} from "../../../components/ui/dialog";
|
||||||
import { useExamNavigationGuard } from "../../../hooks/useExamNavGuard";
|
import { useExamNavigationGuard } from "../../../hooks/useExamNavGuard";
|
||||||
|
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
||||||
|
|
||||||
export const Test = () => {
|
export const Test = () => {
|
||||||
|
const sheetId = localStorage.getItem("activePracticeSheetId");
|
||||||
const blocker = useExamNavigationGuard();
|
const blocker = useExamNavigationGuard();
|
||||||
const [eliminated, setEliminated] = useState<Record<string, Set<string>>>({});
|
const [eliminated, setEliminated] = useState<Record<string, Set<string>>>({});
|
||||||
|
|
||||||
@ -54,15 +56,11 @@ export const Test = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const token = useAuthToken();
|
const token = useAuthToken();
|
||||||
const [practiceSheet, setPracticeSheet] = useState<PracticeSheet | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||||
const [showNavigator, setShowNavigator] = useState<boolean>(false);
|
const [showNavigator, setShowNavigator] = useState<boolean>(false);
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
const { sheetId } = useParams<{ sheetId: string }>();
|
|
||||||
|
|
||||||
const time = useSatTimer();
|
const time = useSatTimer();
|
||||||
const phase = useSatExam((s) => s.phase);
|
const phase = useSatExam((s) => s.phase);
|
||||||
@ -84,15 +82,10 @@ export const Test = () => {
|
|||||||
const startExam = async () => {
|
const startExam = async () => {
|
||||||
if (!user || !sheetId) return;
|
if (!user || !sheetId) return;
|
||||||
|
|
||||||
|
const payload = useExamConfigStore.getState().payload;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.startSession(token as string, {
|
const response = await api.startSession(token as string, payload);
|
||||||
sheet_id: sheetId,
|
|
||||||
mode: "MODULE",
|
|
||||||
topic_ids: practiceSheet?.topics.map((t) => t.id) ?? [],
|
|
||||||
difficulty: practiceSheet?.difficulty ?? "EASY",
|
|
||||||
question_count: 2,
|
|
||||||
time_limit_minutes: practiceSheet?.time_limit ?? 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
setSessionId(response.id);
|
setSessionId(response.id);
|
||||||
|
|
||||||
@ -185,6 +178,7 @@ export const Test = () => {
|
|||||||
const next = await api.fetchNextModule(token!, sessionId);
|
const next = await api.fetchNextModule(token!, sessionId);
|
||||||
|
|
||||||
if (next?.status === "COMPLETED") {
|
if (next?.status === "COMPLETED") {
|
||||||
|
useExamConfigStore.getState().clearPayload();
|
||||||
finishExam();
|
finishExam();
|
||||||
} else {
|
} else {
|
||||||
await loadSessionQuestions(sessionId);
|
await loadSessionQuestions(sessionId);
|
||||||
|
|||||||
@ -1,7 +1,327 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "../../../utils/api";
|
||||||
|
import { type Topic } from "../../../types/topic";
|
||||||
|
import { useAuthStore } from "../../../stores/authStore";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { slideVariants } from "../../../lib/utils";
|
||||||
|
import { Badge } from "../../../components/ui/badge";
|
||||||
|
import { useAuthToken } from "../../../hooks/useAuthToken";
|
||||||
|
import type {
|
||||||
|
TargetedSessionRequest,
|
||||||
|
TargetedSessionResponse,
|
||||||
|
} from "../../../types/session";
|
||||||
|
|
||||||
|
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
||||||
|
import { replace, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
type Step = "topic" | "difficulty" | "duration" | "review";
|
||||||
|
|
||||||
|
const ChoiceCard = ({
|
||||||
|
label,
|
||||||
|
selected,
|
||||||
|
subLabel,
|
||||||
|
section,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
selected?: boolean;
|
||||||
|
subLabel?: string;
|
||||||
|
section?: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`rounded-2xl border p-4 text-left transition flex flex-col
|
||||||
|
${selected ? "border-purple-600 bg-purple-50" : "hover:border-gray-300"}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="font-satoshi-bold text-lg">{label}</span>
|
||||||
|
{section && (
|
||||||
|
<Badge
|
||||||
|
variant={"secondary"}
|
||||||
|
className={`font-satoshi text-sm ${section === "EBRW" ? "bg-blue-400 text-blue-100" : "bg-red-400 text-red-100"}`}
|
||||||
|
>
|
||||||
|
{section}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{subLabel && <span className="font-satoshi text-md">{subLabel}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
export const TargetedPractice = () => {
|
export const TargetedPractice = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const {
|
||||||
|
storeTopics,
|
||||||
|
setDifficulty: storeDifficulty,
|
||||||
|
storeDuration,
|
||||||
|
setMode,
|
||||||
|
setQuestionCount,
|
||||||
|
} = useExamConfigStore();
|
||||||
|
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const token = useAuthToken();
|
||||||
|
const [direction, setDirection] = useState<1 | -1>(1);
|
||||||
|
|
||||||
|
const [step, setStep] = useState<Step>("topic");
|
||||||
|
|
||||||
|
const [selectedTopics, setSelectedTopics] = useState<Topic[]>([]);
|
||||||
|
|
||||||
|
const [difficulty, setDifficulty] = useState<
|
||||||
|
"EASY" | "MEDIUM" | "HARD" | null
|
||||||
|
>(null);
|
||||||
|
const [duration, setDuration] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const [topics, setTopics] = useState<Topic[]>([]);
|
||||||
|
|
||||||
|
const difficulties = ["EASY", "MEDIUM", "HARD"] as const;
|
||||||
|
|
||||||
|
const durations = [10, 20, 30, 45];
|
||||||
|
|
||||||
|
const toggleTopic = (topic: Topic) => {
|
||||||
|
setSelectedTopics((prev) => {
|
||||||
|
const exists = prev.some((t) => t.id === topic.id);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
return prev.filter((t) => t.id !== topic.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...prev, topic];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleStartTargetedPractice() {
|
||||||
|
if (!user || !token || !topics || !difficulty || !duration) return;
|
||||||
|
|
||||||
|
navigate(`/student/practice/${topics[0].id}/test`, { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAllTopics = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const authStorage = localStorage.getItem("auth-storage");
|
||||||
|
if (!authStorage) return;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(authStorage) as {
|
||||||
|
state?: { token?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = parsed.state?.token;
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const response = await api.fetchAllTopics(token);
|
||||||
|
setTopics(response);
|
||||||
|
setLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load topics. Reason: " + error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAllTopics();
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4">
|
<main className="relative min-h-screen max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4">
|
||||||
Targeted Practice
|
<h1 className="font-satoshi-bold text-3xl">Targeted Practice</h1>
|
||||||
|
|
||||||
|
<div className="relative overflow-hidden">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{step === "topic" && (
|
||||||
|
<motion.div
|
||||||
|
custom={direction}
|
||||||
|
key="topic"
|
||||||
|
variants={slideVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<h2 className="text-xl font-satoshi-bold">Choose a topic</h2>
|
||||||
|
|
||||||
|
<input
|
||||||
|
placeholder="Search topics..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full rounded-xl border px-4 py-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Loader2
|
||||||
|
size={30}
|
||||||
|
color="purple"
|
||||||
|
className="animate-spin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
topics
|
||||||
|
.filter((t) =>
|
||||||
|
t.name.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
)
|
||||||
|
.map((t) => (
|
||||||
|
<ChoiceCard
|
||||||
|
key={t.id}
|
||||||
|
label={t.name}
|
||||||
|
subLabel={t.parent_name}
|
||||||
|
section={t.section}
|
||||||
|
selected={selectedTopics.some((st) => st.id === t.id)}
|
||||||
|
onClick={() => toggleTopic(t)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
disabled={selectedTopics.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
setTopics(selectedTopics.map((t) => t.id)); // ✅ STORE
|
||||||
|
storeTopics(selectedTopics.map((t) => t.id)); // ✅ STORE
|
||||||
|
setMode("TARGETED"); // ✅ STORE
|
||||||
|
setQuestionCount(7); // ✅ STORE
|
||||||
|
setDirection(1);
|
||||||
|
setStep("difficulty");
|
||||||
|
}}
|
||||||
|
className={`rounded-2xl py-3 px-6 font-satoshi-bold transition
|
||||||
|
${
|
||||||
|
selectedTopics.length === 0
|
||||||
|
? "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||||
|
: "bg-linear-to-br from-purple-500 to-purple-600 text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "difficulty" && (
|
||||||
|
<motion.div
|
||||||
|
key="difficulty"
|
||||||
|
custom={direction}
|
||||||
|
variants={slideVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<h2 className="text-xl font-satoshi-bold">Select difficulty</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
{difficulties.map((d) => (
|
||||||
|
<ChoiceCard
|
||||||
|
key={d}
|
||||||
|
label={d}
|
||||||
|
selected={difficulty === d}
|
||||||
|
onClick={() => {
|
||||||
|
setDifficulty(d); // local UI
|
||||||
|
storeDifficulty(d); // ✅ STORE
|
||||||
|
setDirection(1);
|
||||||
|
setStep("duration");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "duration" && (
|
||||||
|
<motion.div
|
||||||
|
key="duration"
|
||||||
|
custom={direction}
|
||||||
|
variants={slideVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<h2 className="text-xl font-satoshi-bold">Select duration</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||||
|
{durations.map((d) => (
|
||||||
|
<ChoiceCard
|
||||||
|
key={d}
|
||||||
|
label={`${d} minutes`}
|
||||||
|
selected={duration === d}
|
||||||
|
onClick={() => {
|
||||||
|
setDuration(d);
|
||||||
|
storeDuration(d); // ✅ STORE
|
||||||
|
setDirection(1);
|
||||||
|
setStep("review");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "review" && (
|
||||||
|
<motion.div
|
||||||
|
custom={direction}
|
||||||
|
key="review"
|
||||||
|
variants={slideVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<h2 className="text-xl font-satoshi-bold">Review your choices</h2>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border p-4 space-y-2 font-satoshi">
|
||||||
|
<p>
|
||||||
|
<strong>Topics:</strong>{" "}
|
||||||
|
{selectedTopics.map((t) => t.name).join(", ")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Difficulty:</strong> {difficulty}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Duration:</strong> {duration} minutes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
disabled={step === "topic"}
|
||||||
|
onClick={() => {
|
||||||
|
const order: Step[] = ["topic", "difficulty", "duration", "review"];
|
||||||
|
setDirection(-1);
|
||||||
|
setStep(order[order.indexOf(step) - 1]);
|
||||||
|
}}
|
||||||
|
className={`absolute bottom-24 left-10 rounded-2xl py-3 px-6 font-satoshi-bold transition
|
||||||
|
${
|
||||||
|
step === "topic"
|
||||||
|
? "opacity-0 pointer-events-none"
|
||||||
|
: "bg-linear-to-br from-slate-500 to-slate-600 text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
disabled={step !== "review"}
|
||||||
|
className={`absolute bottom-28 right-10 rounded-2xl py-3 px-6 font-satoshi-bold transition
|
||||||
|
${
|
||||||
|
step !== "review"
|
||||||
|
? "opacity-0 pointer-events-none"
|
||||||
|
: "bg-linear-to-br from-purple-500 to-purple-600 text-white"
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
handleStartTargetedPractice();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Start Test
|
||||||
|
</button>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
79
src/stores/useExamConfigStore.ts
Normal file
79
src/stores/useExamConfigStore.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
// stores/useExamConfigStore.ts
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
import type { StartExamPayload, ExamMode } from "../types/test";
|
||||||
|
|
||||||
|
interface ExamConfigState {
|
||||||
|
payload: StartExamPayload | null;
|
||||||
|
|
||||||
|
setSheetId: (id: string) => void;
|
||||||
|
storeTopics: (ids: string[]) => void;
|
||||||
|
setDifficulty: (difficulty: StartExamPayload["difficulty"]) => void;
|
||||||
|
|
||||||
|
setQuestionCount: (count: number) => void;
|
||||||
|
storeDuration: (minutes: number) => void;
|
||||||
|
setMode: (mode: ExamMode) => void;
|
||||||
|
|
||||||
|
clearPayload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useExamConfigStore = create<ExamConfigState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
payload: null,
|
||||||
|
|
||||||
|
setSheetId: (sheet_id) =>
|
||||||
|
set({
|
||||||
|
payload: {
|
||||||
|
...(get().payload ?? {}),
|
||||||
|
sheet_id,
|
||||||
|
} as StartExamPayload,
|
||||||
|
}),
|
||||||
|
|
||||||
|
storeTopics: (topic_ids) =>
|
||||||
|
set({
|
||||||
|
payload: {
|
||||||
|
...(get().payload ?? {}),
|
||||||
|
topic_ids,
|
||||||
|
} as StartExamPayload,
|
||||||
|
}),
|
||||||
|
|
||||||
|
setDifficulty: (difficulty) =>
|
||||||
|
set({
|
||||||
|
payload: {
|
||||||
|
...(get().payload ?? {}),
|
||||||
|
difficulty,
|
||||||
|
} as StartExamPayload,
|
||||||
|
}),
|
||||||
|
|
||||||
|
setQuestionCount: (question_count) =>
|
||||||
|
set({
|
||||||
|
payload: {
|
||||||
|
...(get().payload ?? {}),
|
||||||
|
question_count,
|
||||||
|
} as StartExamPayload,
|
||||||
|
}),
|
||||||
|
|
||||||
|
storeDuration: (time_limit_minutes) =>
|
||||||
|
set({
|
||||||
|
payload: {
|
||||||
|
...(get().payload ?? {}),
|
||||||
|
time_limit_minutes,
|
||||||
|
} as StartExamPayload,
|
||||||
|
}),
|
||||||
|
|
||||||
|
setMode: (mode) =>
|
||||||
|
set({
|
||||||
|
payload: {
|
||||||
|
...(get().payload ?? {}),
|
||||||
|
mode,
|
||||||
|
} as StartExamPayload,
|
||||||
|
}),
|
||||||
|
|
||||||
|
clearPayload: () => set({ payload: null }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "exam-config-storage",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import type { Question } from "./sheet";
|
import type { Question } from "./sheet";
|
||||||
|
import type { ExamMode } from "./test";
|
||||||
|
|
||||||
type Answer = {
|
type Answer = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -14,14 +15,27 @@ type Answer = {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface SessionRequest {
|
export interface SessionRequest {
|
||||||
sheet_id: string;
|
sheet_id?: string;
|
||||||
mode: string;
|
mode: ExamMode;
|
||||||
topic_ids: string[];
|
|
||||||
difficulty: string;
|
|
||||||
question_count: number;
|
|
||||||
time_limit_minutes: number;
|
time_limit_minutes: number;
|
||||||
|
topic_ids?: string[];
|
||||||
|
difficulty?: string;
|
||||||
|
question_count?: number;
|
||||||
|
section?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// export interface TargetedSessionResponse {
|
||||||
|
// id: string;
|
||||||
|
// practice_sheet_id: null;
|
||||||
|
// status: string;
|
||||||
|
// current_module_index: number;
|
||||||
|
// current_model_id: null;
|
||||||
|
// current_module_title: null;
|
||||||
|
// answers: Answer[];
|
||||||
|
// started_at: string;
|
||||||
|
// score: number;
|
||||||
|
// }
|
||||||
|
|
||||||
export interface SessionResponse {
|
export interface SessionResponse {
|
||||||
id: string;
|
id: string;
|
||||||
practice_sheet_id: string;
|
practice_sheet_id: string;
|
||||||
|
|||||||
11
src/types/test.ts
Normal file
11
src/types/test.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// types/exam.ts
|
||||||
|
export type ExamMode = "MODULE" | "TARGETED" | "SIMULATION" | "DRILLS";
|
||||||
|
|
||||||
|
export interface StartExamPayload {
|
||||||
|
sheet_id: string;
|
||||||
|
topic_ids: string[];
|
||||||
|
difficulty: "EASY" | "MEDIUM" | "HARD";
|
||||||
|
question_count: number;
|
||||||
|
time_limit_minutes: number;
|
||||||
|
mode: ExamMode;
|
||||||
|
}
|
||||||
8
src/types/topic.ts
Normal file
8
src/types/topic.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export interface Topic {
|
||||||
|
name: string;
|
||||||
|
section: "EBRW" | "MATH";
|
||||||
|
parent_id: string;
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
parent_name: string;
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import type {
|
|||||||
SubmitAnswer,
|
SubmitAnswer,
|
||||||
} from "../types/session";
|
} from "../types/session";
|
||||||
import type { PracticeSheet } from "../types/sheet";
|
import type { PracticeSheet } from "../types/sheet";
|
||||||
|
import type { Topic } from "../types/topic";
|
||||||
|
|
||||||
const API_URL = "https://ed-dev-api.omukk.dev";
|
const API_URL = "https://ed-dev-api.omukk.dev";
|
||||||
|
|
||||||
@ -130,6 +131,29 @@ class ApiClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// async startPracticeSession(
|
||||||
|
// token: string,
|
||||||
|
// sessionData: PracticeSessionRequest,
|
||||||
|
// ): Promise<SessionResponse> {
|
||||||
|
// return this.authenticatedRequest<SessionResponse>(`/sessions/`, token, {
|
||||||
|
// method: "POST",
|
||||||
|
// body: JSON.stringify(sessionData),
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// async startTargetedPracticeSession(
|
||||||
|
// token: string,
|
||||||
|
// sessionData: TargetedSessionRequest,
|
||||||
|
// ): Promise<TargetedSessionResponse> {
|
||||||
|
// return this.authenticatedRequest<TargetedSessionResponse>(
|
||||||
|
// `/sessions/`,
|
||||||
|
// token,
|
||||||
|
// {
|
||||||
|
// method: "POST",
|
||||||
|
// body: JSON.stringify(sessionData),
|
||||||
|
// },
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
async startSession(
|
async startSession(
|
||||||
token: string,
|
token: string,
|
||||||
sessionData: SessionRequest,
|
sessionData: SessionRequest,
|
||||||
@ -191,5 +215,12 @@ class ApiClient {
|
|||||||
async fetchLessonById(token: string, lessonId: string): Promise<Lesson> {
|
async fetchLessonById(token: string, lessonId: string): Promise<Lesson> {
|
||||||
return this.authenticatedRequest<Lesson>(`/lessons/${lessonId}`, token);
|
return this.authenticatedRequest<Lesson>(`/lessons/${lessonId}`, token);
|
||||||
}
|
}
|
||||||
|
async fetchAllTopics(token: string): Promise<Topic[]> {
|
||||||
|
return this.authenticatedRequest<Topic[]>(`/topics/`, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchTopicById(token: string, topicId: string): Promise<Topic> {
|
||||||
|
return this.authenticatedRequest<Topic>(`/topics/${topicId}`, token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export const api = new ApiClient(API_URL);
|
export const api = new ApiClient(API_URL);
|
||||||
|
|||||||
Reference in New Issue
Block a user