diff --git a/app/layout.tsx b/app/layout.tsx index 439463b..ed236a6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import { Footer } from "@/components/footer"; import HeaderComponent from "@/components/header"; import { LoadingScreen } from "@/components/loadingscreen"; import { Maintenance, MaintenanceType } from "@/components/pages/maintenance"; +import { Onboarding } from "@/components/pages/onboarding"; import { ToasterWrapper } from "@/components/toaster-wrapper"; import { cn } from "@/lib/utils"; import type { Metadata, Viewport } from "next"; @@ -128,6 +129,7 @@ export default function RootLayout({ diff --git a/components/onboarding-wrapper.tsx b/components/onboarding-wrapper.tsx new file mode 100644 index 0000000..e69de29 diff --git a/components/pages/onboarding.tsx b/components/pages/onboarding.tsx new file mode 100644 index 0000000..d8fb083 --- /dev/null +++ b/components/pages/onboarding.tsx @@ -0,0 +1,98 @@ +"use client"; +import { usePreferences } from "@/components/preferences-provider"; +import { SettingsFormForOnboarding } from "@/components/settings-modal"; +import { TemplateSelector } from "@/components/template-selector"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Highlight } from "@/components/ui/card-stack"; +import { useDevice } from "@/lib/hooks/useMediaQuery"; +import { PreferencesTranslations } from "@/lib/translationObjects"; +import { cn } from "@/lib/utils"; +import { motion } from "framer-motion"; +import useTranslation from "next-translate/useTranslation"; +import { useState } from "react"; + +export function Onboarding() { + const { t } = useTranslation("common"); + const preferences = usePreferences(); + const { isMobile, isTablet, isDesktop } = useDevice(); + + const preferencesTranslations: PreferencesTranslations = { + title: t("preferences.title"), + description: t("preferences.description"), + gradeDecimals: t("preferences.grade-decimals"), + gradeDecimalsDescription: t("preferences.grade-decimals-description"), + gradeDecimalsPlaceholder: t("preferences.grade-decimals-placeholder"), + keepModalsOpen: t("preferences.keep-modals-open"), + keepModalsOpenDescription: t("preferences.keep-modals-open-description"), + passingGrade: t("preferences.passing-grade"), + passingGradeDescription: t("preferences.passing-grade-description"), + passingGradePlaceholder: t("preferences.passing-grade-placeholder"), + minimumGrade: t("preferences.minimum-grade"), + minimumGradeDescription: t("preferences.minimum-grade-description"), + minimumGradePlaceholder: t("preferences.minimum-grade-placeholder"), + maximumGrade: t("preferences.maximum-grade"), + maximumGradeDescription: t("preferences.maximum-grade-description"), + maximumGradePlaceholder: t("preferences.maximum-grade-placeholder"), + passingInverse: t("preferences.passing-inverse"), + passingInverseDescription: t("preferences.passing-inverse-description"), + alertTitle: t("preferences.alert-title"), + alertDescription: t("preferences.alert-description"), + }; + + const [selectedTemplate, setSelectedTemplate] = useState(); + + if (preferences.isDefault) + return ( +
+
+

+ Grades +
+ Onboarding +

+
+
+ + + {selectedTemplate && ( + + + Advanced Settings + + Hit Save to apply your changes and + complete the onboarding. +
You can change this in the settings at any time. +
+
+ + + +
+ )} +
+
+
+ ); +} diff --git a/components/preferences-provider.tsx b/components/preferences-provider.tsx index 4b3c346..0b7685e 100644 --- a/components/preferences-provider.tsx +++ b/components/preferences-provider.tsx @@ -6,30 +6,41 @@ import { getDefaultPreferences } from "@/lib/utils"; import { useSession } from "next-auth/react"; import { createContext, useContext, useEffect, useState } from "react"; - type PreferencesContextType = { preferences: Preferences | undefined; setPreferences: (preferences: Preferences) => void; + isDefault: boolean; + setIsDefault: (isDefault: boolean) => void; loading: boolean; }; const defaultContextValue: PreferencesContextType = { preferences: undefined, setPreferences: () => void 0, + isDefault: false, + setIsDefault: () => void 0, loading: true, }; const PreferencesContext = createContext(defaultContextValue); -export function PreferencesProvider({ children }: { children: React.ReactNode }) { - const [preferences, setPreferences] = useState(getDefaultPreferences()); +export function PreferencesProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [preferences, setPreferences] = useState( + getDefaultPreferences() + ); + const [isDefault, setIsDefault] = useState(false); const [loading, setLoading] = useState(true); const session = useSession(); useEffect(() => { if (session.status === "authenticated") { getPreferencesElseGetDefault().then((result): void => { - setPreferences(catchProblem(result)); + setPreferences(catchProblem(result).preferences); + setIsDefault(catchProblem(result).isDefault); setLoading(false); }); } @@ -37,11 +48,11 @@ export function PreferencesProvider({ children }: { children: React.ReactNode }) return ( {children} ); } -export const usePreferences = () => useContext(PreferencesContext); \ No newline at end of file +export const usePreferences = () => useContext(PreferencesContext); diff --git a/components/settings-modal.tsx b/components/settings-modal.tsx index 770b596..111c092 100644 --- a/components/settings-modal.tsx +++ b/components/settings-modal.tsx @@ -29,7 +29,8 @@ import { PreferencesTranslations, } from "@/lib/translationObjects"; import { getDefaultPreferences } from "@/lib/utils"; -import { Settings, Trash2 } from "lucide-react"; +import { templates } from "@/templates"; +import { RotateCcwIcon, SaveIcon, Settings, Trash2 } from "lucide-react"; import { useSession } from "next-auth/react"; import useTranslation from "next-translate/useTranslation"; import { useEffect, useState } from "react"; @@ -343,6 +344,289 @@ export function SettingsModalForm({ ); } +export function SettingsFormForOnboarding({ + translations, + selectedTemplate, +}: { + translations: PreferencesTranslations; + selectedTemplate: string; +}) { + const preferences = usePreferences(); + const preferencesFromTemplate = templates.find( + (t) => t.id === selectedTemplate + ); + const { t } = useTranslation("common"); + const [maxLtMin, setMaxLtMin] = useState(false); + const [passLtMin, setPassLtMin] = useState(false); + const [passGtMax, setPassGtMax] = useState(false); + const [decimals, setDecimals] = useState(3); + const [submitted, setSubmitted] = useState(false); + + type FormValues = NewPreferences; + const defaultValues: DefaultValues = { + gradeDecimals: 3, + newEntitySheetShouldStayOpen: false, + passingInverse: preferencesFromTemplate?.passingInverse ?? false, + passingGrade: preferencesFromTemplate?.passingGrade ?? 50, + minimumGrade: preferencesFromTemplate?.minimumGrade ?? 0, + maximumGrade: preferencesFromTemplate?.maximumGrade ?? 100, + } satisfies FormValues; + + const FormSchema = z.object({ + gradeDecimals: z.number().gte(0), + newEntitySheetShouldStayOpen: z.boolean({}), + passingInverse: z.boolean({}), + passingGrade: z.number({}), + minimumGrade: z.number({}), + maximumGrade: z.number({}), + }); + + const form = useForm>({ + resolver: zodResolver(FormSchema), + }); + + function onSubmit(data: z.infer) { + setSubmitted(true); + const newPreferences = { + gradeDecimals: data.gradeDecimals, + newEntitySheetShouldStayOpen: data.newEntitySheetShouldStayOpen, + passingInverse: data.passingInverse, + passingGrade: data.passingGrade, + minimumGrade: data.minimumGrade, + maximumGrade: data.maximumGrade, + } satisfies NewPreferences; + preferences.setPreferences(newPreferences as any); + preferences.setIsDefault(false); + savePreferences(newPreferences).then(() => { + setSubmitted(false); + }); + } + + function onReset(event: any) { + event.preventDefault(); + form.reset(getDefaultPreferences() as any); + } + + useEffect(() => { + form.reset(defaultValues as any); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedTemplate]); + + return ( +
+ + ( + + + {translations.gradeDecimals} + + {translations.gradeDecimalsDescription} + + +
+ + { + if (e.target.value === "") field.onChange(""); + else field.onChange(Math.floor(Number(e.target.value))); + }} + /> + + + field.onChange(value[0])} + defaultValue={[decimals]} + max={10} + step={1} + /> + +
+ +
+ )} + /> + ( + + + {translations.minimumGrade} + + {translations.minimumGradeDescription} + + + + { + if (e.target.value === "") field.onChange(""); + else { + field.onChange(Number(e.target.value)); + if ( + Number(e.target.value) >= form.getValues().maximumGrade + ) + setMaxLtMin(true); + else setMaxLtMin(false); + + if ( + Number(e.target.value) > form.getValues().passingGrade + ) + setPassLtMin(true); + else setPassLtMin(false); + } + }} + /> + + + + )} + /> + ( + + + {translations.maximumGrade} + + {translations.maximumGradeDescription} + + + + { + if (e.target.value === "") field.onChange(""); + else { + field.onChange(Number(e.target.value)); + if ( + Number(e.target.value) <= form.getValues().minimumGrade + ) + setMaxLtMin(true); + else setMaxLtMin(false); + + if ( + Number(e.target.value) < form.getValues().passingGrade + ) + setPassGtMax(true); + else setPassGtMax(false); + } + }} + /> + + + + )} + /> + {maxLtMin ? {t("errors.max-lt-min")} : null} + ( + + + {translations.passingGrade} + + {translations.passingGradeDescription} + + + + { + if (e.target.value === "") field.onChange(""); + else { + field.onChange(Number(e.target.value)); + if ( + Number(e.target.value) > form.getValues().maximumGrade + ) + setPassGtMax(true); + else setPassGtMax(false); + + if ( + Number(e.target.value) < form.getValues().minimumGrade + ) + setPassLtMin(true); + else setPassLtMin(false); + } + }} + /> + + + + )} + /> + {passLtMin ? ( + {t("errors.pass-lt-min")} + ) : null} + {passGtMax ? ( + {t("errors.pass-gt-max")} + ) : null} + + ( + +
+ {translations.passingInverse} + + {translations.passingInverseDescription} + +
+ + + + +
+ )} + /> + + + + + + + + ); +} + export function SettingsModal({ translations, clearDataTranslations, diff --git a/components/template-selector.tsx b/components/template-selector.tsx new file mode 100644 index 0000000..c930521 --- /dev/null +++ b/components/template-selector.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Check, ChevronsUpDown, Wrench } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { PreferenceTemplate, templates } from "@/templates"; +import { useState } from "react"; + +const preferenceTemplates: PreferenceTemplate[] = templates; + +const FormSchema = z.object({ + preferenceTemplate: z.string({ + required_error: "Please select a template.", + }), +}); + +export function TemplateSelector({ + setSelectedTemplate, +}: { + setSelectedTemplate: (template: string) => void; +}) { + const form = useForm>({ + resolver: zodResolver(FormSchema), + }); + + const [open, setOpen] = useState(false); + + function onSubmit(data: z.infer) { + setSelectedTemplate(data.preferenceTemplate); + } + + return ( +
+ + ( + + Template + + Select a template or use the advanced settings. + + + + + + + + + + + No template found. + + + {preferenceTemplates.map((template) => ( + { + form.setValue("preferenceTemplate", template.id); + setOpen(false); + }} + > + + {template.title} + + ))} + + + + + + + + )} + /> + + + + ); +} diff --git a/lib/services/grade-service.ts b/lib/services/grade-service.ts index fe94ecd..2687f10 100644 --- a/lib/services/grade-service.ts +++ b/lib/services/grade-service.ts @@ -131,7 +131,7 @@ export async function getGradeAverageWithSubjectBySubject( gradeAmount: grades.length, passing: doesGradePass( weightedSum / totalGradesWithWeight, - catchProblem(await getPreferencesElseGetDefault()) + catchProblem(await getPreferencesElseGetDefault()).preferences ), }, subject: grades[0].subjects, @@ -175,7 +175,7 @@ export async function getGradeAverageBySubject( gradeSum: sum, passing: doesGradePass( weightedSum / totalGradesWithWeight, - catchProblem(await getPreferencesElseGetDefault()) + catchProblem(await getPreferencesElseGetDefault()).preferences ), }; }; diff --git a/lib/services/preferences-service.ts b/lib/services/preferences-service.ts index b630c8e..38c541d 100644 --- a/lib/services/preferences-service.ts +++ b/lib/services/preferences-service.ts @@ -1,7 +1,11 @@ "use server"; import { Grade, NewPreferences, Preferences } from "@/db/schema"; import { Problem, catchProblem, getProblem } from "@/lib/problem"; -import { addPreferencesToDb, getPreferencesFromDb, updatePreferencesInDb } from "@/lib/repositories/preferences-repo"; +import { + addPreferencesToDb, + getPreferencesFromDb, + updatePreferencesInDb, +} from "@/lib/repositories/preferences-repo"; import { getAllGrades, updateGrade } from "@/lib/services/grade-service"; import { getUserId, setUserId } from "@/lib/services/service-util"; import { getDefaultPreferences } from "@/lib/utils"; @@ -19,15 +23,16 @@ export async function getPreferences(): Promise { } } -export async function getPreferencesElseGetDefault(): Promise { +export async function getPreferencesElseGetDefault(): Promise< + { preferences: Preferences; isDefault: boolean } | Problem +> { try { const userId = await getUserId(); let result = await getPreferencesFromDb(userId); if (result.length === 1) { - return result[0]; + return { preferences: result[0], isDefault: false }; } - return getDefaultPreferences(); - + return { preferences: getDefaultPreferences(), isDefault: true }; } catch (e: any) { return getProblem({ errorMessage: e.message, @@ -58,23 +63,21 @@ export async function savePreferences( } } -export async function adjustGradesToPreferences( - preferences: Preferences -){ +export async function adjustGradesToPreferences(preferences: Preferences) { try { let grades: Grade[] = catchProblem(await getAllGrades()); grades.map((grade) => { let modifiedGrade = grade; let wasModified = false; - if(grade.value! > preferences.maximumGrade!){ + if (grade.value! > preferences.maximumGrade!) { modifiedGrade.value = preferences.maximumGrade; wasModified = true; } - if(grade.value! < preferences.minimumGrade!){ + if (grade.value! < preferences.minimumGrade!) { modifiedGrade.value = preferences.minimumGrade; wasModified = true; } - if(wasModified){ + if (wasModified) { updateGrade(modifiedGrade); } }); diff --git a/package-lock.json b/package-lock.json index 3b915bc..8176f21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "notenrechner-next", - "version": "2.3.2", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "notenrechner-next", - "version": "2.3.2", + "version": "2.4.0", "dependencies": { "@auth/drizzle-adapter": "^0.8.2", "@hookform/resolvers": "^3.3.4", diff --git a/package.json b/package.json index dc2bf88..d8775c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "notenrechner-next", - "version": "2.3.2", + "version": "2.4.0", "private": false, "scripts": { "dev": "next dev", diff --git a/templates.tsx b/templates.tsx new file mode 100644 index 0000000..0b8d921 --- /dev/null +++ b/templates.tsx @@ -0,0 +1,83 @@ +export type PreferenceTemplate = { + id: string; + title: string; + passingGrade: number | null; + minimumGrade: number | null; + maximumGrade: number | null; + passingInverse: boolean | null; +}; + +export const templates: PreferenceTemplate[] = [ + { + id: "custom", + title: "✒️ Custom", + passingGrade: 5, + minimumGrade: 1, + maximumGrade: 10, + passingInverse: false, + }, + { + id: "percentage", + title: "🌍 Percentage", + passingGrade: 50, + minimumGrade: 0, + maximumGrade: 100, + passingInverse: false, + }, + { + id: "switzerland", + title: "🇨🇭 Switzerland", + passingGrade: 4, + minimumGrade: 1, + maximumGrade: 6, + passingInverse: false, + }, + { + id: "germany", + title: "🇩🇪 Germany", + passingGrade: 4, + minimumGrade: 1, + maximumGrade: 6, + passingInverse: true, + }, + { + id: "poland_hs", + title: "🇵🇱 Poland", + passingGrade: 2, + minimumGrade: 1, + maximumGrade: 6, + passingInverse: false, + }, + { + id: "poland_he", + title: "🇵🇱 Poland (Higher Ed.)", + passingGrade: 3, + minimumGrade: 2, + maximumGrade: 5.5, + passingInverse: false, + }, + { + id: "france", + title: "🇫🇷 France", + passingGrade: 10, + minimumGrade: 0, + maximumGrade: 20, + passingInverse: false, + }, + { + id: "italy_hs", + title: "🇮🇹 Italy", + passingGrade: 6, + minimumGrade: 0, + maximumGrade: 10, + passingInverse: false, + }, + { + id: "italy_he", + title: "🇮🇹 Italy (Higher Ed.)", + passingGrade: 18, + minimumGrade: 0, + maximumGrade: 10, + passingInverse: false, + }, +];