diff --git a/src/app/actions/onboarding.ts b/src/app/actions/onboarding.ts new file mode 100644 index 0000000..2aa762e --- /dev/null +++ b/src/app/actions/onboarding.ts @@ -0,0 +1,92 @@ +'use server'; + +import db from '@/lib/db'; +import { participant } from '@/lib/model/participant'; +import { eq, and } from 'drizzle-orm'; +import { auth } from '@/lib/auth'; +import { headers } from 'next/headers'; + +export async function isOnboardingDone(experimentId: number): Promise { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) throw new Error('Nicht angemeldet'); + + try { + const participantRecord = await db + .select() + .from(participant) + .where( + and( + eq(participant.experimentId, experimentId), + eq(participant.userId, session.user.id) + ) + ) + .limit(1); + + // If no participant record exists, onboarding is not done + if (participantRecord.length === 0) { + return false; + } + + // Check if onboarding answers are filled + const onboardingAnswers = participantRecord[0].onboardingAnswers as Record | null; + return onboardingAnswers !== null && Object.keys(onboardingAnswers).length > 0; + } catch (error) { + console.error('Error checking onboarding status:', error); + throw new Error('Failed to check onboarding status'); + } +} + +export async function getOnboardingAnswers(experimentId: number) { + try { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) throw new Error('Nicht angemeldet'); + + const participantRecord = await db + .select() + .from(participant) + .where( + and( + eq(participant.experimentId, experimentId), + eq(participant.userId, session.user.id) + ) + ) + .limit(1); + + if (participantRecord.length === 0) { + return { success: true, data: null }; + } + + return { success: true, data: participantRecord[0].onboardingAnswers }; + } catch (error) { + console.error('Error fetching onboarding answers:', error); + throw new Error('Failed to fetch onboarding answers'); + } +} + +export async function saveOnboardingAnswers( + experimentId: number, + answers: Record +): Promise { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) throw new Error('Nicht angemeldet'); + + try { + // Upsert participant record with onboarding answers + await db + .insert(participant) + .values({ + experimentId, + userId: session.user.id, + onboardingAnswers: answers, + }) + .onConflictDoUpdate({ + target: [participant.experimentId, participant.userId], + set: { + onboardingAnswers: answers, + }, + }); + } catch (error) { + console.error('Error saving onboarding answers:', error); + throw new Error('Failed to save onboarding answers'); + } +} diff --git a/src/app/annotate/[experimentId]/page.tsx b/src/app/annotate/[experimentId]/page.tsx index ae6654a..a6d1713 100644 --- a/src/app/annotate/[experimentId]/page.tsx +++ b/src/app/annotate/[experimentId]/page.tsx @@ -4,8 +4,10 @@ import Link from 'next/link'; import { auth } from '@/lib/auth'; import { getAnnotationEntries} from '@/app/actions/annotations'; import { isCalibrationDone } from '@/app/actions/calibration-scoring'; +import { isOnboardingDone } from '@/app/actions/onboarding'; import { getExperimentById } from '@/app/actions/experiment'; import AnnotationPageView from '@/components/AnnotationViews/AnnotationPageView'; +import OnboardingPhase from '@/components/OnboardingViews/OnboardingPhase'; import CalibrationPhase from '@/components/CalibrationViews/CalibrationPhase'; interface Props { @@ -30,6 +32,12 @@ export default async function AnnotatePage({ params }: Props) { ); } + // Check if onboarding is required and not yet done + const onboardingDone = await isOnboardingDone(experimentId); + if (!onboardingDone) { + return ; + } + // Check if calibration is required and not yet done const calibrationDone = await isCalibrationDone(experimentId); if (!calibrationDone) { diff --git a/src/components/OnboardingViews/OnboardingFormView.tsx b/src/components/OnboardingViews/OnboardingFormView.tsx new file mode 100644 index 0000000..22f229c --- /dev/null +++ b/src/components/OnboardingViews/OnboardingFormView.tsx @@ -0,0 +1,399 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { useRouter } from 'next/navigation'; +import { saveOnboardingAnswers } from '@/app/actions/onboarding'; +import { DIALECT_LABELS_WITHOUT_DE } from '@/lib/dialects'; + +interface OnboardingFormViewProps { + experimentId: number; +} + +interface ResidenceEntry { + region: string; + years: string; +} + +interface FormData { + age?: string; + residences?: ResidenceEntry[]; + dialectQualities?: Record; + listeningExperience?: string; + additionalNotes?: string; +} + +type Step = 1 | 2 | 3; + +export default function OnboardingFormView({ experimentId }: OnboardingFormViewProps) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [currentStep, setCurrentStep] = useState(1); + + // Initialize dialect qualities with default value of '3' for each dialect + const initializeDialectQualities = (): Record => { + const qualities: Record = {}; + Object.keys(DIALECT_LABELS_WITHOUT_DE).forEach(key => { + qualities[key] = '3'; + }); + return qualities; + }; + + const [formData, setFormData] = useState({ + age: '', + residences: [{ region: '', years: '' }], + dialectQualities: initializeDialectQualities() + }); + const [error, setError] = useState(null); + + const handleChange = (field: keyof FormData, value: string) => { + setFormData((prev) => ({ + ...prev, + [field]: value, + })); + }; + + const handleDialectQualityChange = (region: string, value: string) => { + setFormData((prev) => ({ + ...prev, + dialectQualities: { + ...prev.dialectQualities, + [region]: value, + }, + })); + }; + + const handleResidenceChange = ( + index: number, + field: keyof ResidenceEntry, + value: string + ) => { + setFormData((prev) => ({ + ...prev, + residences: prev.residences?.map((r, i) => + i === index ? { ...r, [field]: value } : r + ), + })); + }; + + const addResidence = () => { + setFormData((prev) => ({ + ...prev, + residences: [...(prev.residences || []), { region: '', years: '' }], + })); + }; + + const removeResidence = (index: number) => { + setFormData((prev) => ({ + ...prev, + residences: prev.residences?.filter((_, i) => i !== index), + })); + }; + + const validateStep = (step: Step): boolean => { + switch (step) { + case 1: + if (!formData.age) { + setError('Bitte wählen Sie eine Altersgruppe aus.'); + return false; + } + break; + case 2: + const hasValidResidence = formData.residences?.some( + (r) => r.region && r.years + ); + if (!hasValidResidence) { + setError('Bitte fügen Sie mindestens einen Wohnort mit Jahresangabe hinzu.'); + return false; + } + break; + case 3: + const allDialects = Object.keys(DIALECT_LABELS_WITHOUT_DE); + const hasAllDialectQualities = allDialects.every( + (region) => formData.dialectQualities?.[region] + ); + if (!hasAllDialectQualities) { + setError('Bitte bewerten Sie Ihre Fähigkeit für alle Dialekte.'); + return false; + } + break; + } + return true; + }; + + const handleNext = () => { + setError(null); + if (validateStep(currentStep)) { + if (currentStep < 3) { + setCurrentStep((currentStep + 1) as Step); + } + } + }; + + const handlePrevious = () => { + setError(null); + if (currentStep > 1) { + setCurrentStep((currentStep - 1) as Step); + } + }; + + const handleSubmit = () => { + setError(null); + + startTransition(async () => { + try { + await saveOnboardingAnswers(experimentId, formData); + // Redirect to the experiment annotation page to trigger a refresh + router.refresh(); + } catch (err) { + console.error('Error saving onboarding answers:', err); + setError('Fehler beim Speichern des Formulars. Bitte versuchen Sie es erneut.'); + } + }); + }; + + const stepTitles: Record = { + 1: 'Persönliche Informationen', + 2: 'Aufenthaltsorte', + 3: 'Selbsteinschätzung', + }; + + return ( +
+
+
+ {/* Progress Bar */} +
+
+

+ Schritt {currentStep} von 3 +

+
{Math.round((currentStep / 3) * 100)}%
+
+
+
+
+
+ + {/* Header */} +
+

{stepTitles[currentStep]}

+
+ + {/* Form */} +
+ {/* Error Message */} + {error && ( +
+

{error}

+
+ )} + + {/* Step 1: Persönliche Informationen */} + {currentStep === 1 && ( +
+ + +
+ )} + + {/* Step 2: Aufenthaltsorte */} + {currentStep === 2 && ( +
+

+ Bitte geben Sie die Regionen an, in denen Sie länger als 1 Jahr gelebt haben, sowie die Anzahl der Jahre. Diese Informationen helfen uns, Ihre Dialektkenntnisse besser einzuschätzen. +

+ {/* Map Placeholder */} +
+
+
🗺️
+

Karte kommt hier (mit Dialektregionen)

+
+
+ + {/* Residence Entries */} +
+ + +
+ {formData.residences?.map((residence, index) => ( +
+
+ + +
+ +
+ + + handleResidenceChange(index, 'years', e.target.value) + } + placeholder="z.B. 10" + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm" + /> +
+ + +
+ ))} +
+ + +
+
+ )} + + {/* Step 3: Selbsteinschätzung */} + {currentStep === 3 && ( +
+
+

+ Bewerten Sie Ihre Fähigkeit, die folgenden Dialekte zu identifizieren, auf einer Skala von "sehr schlecht" bis "sehr gut". Diese Selbsteinschätzung hilft uns, Ihre nachfolgendenKalibrierungsergebnisse besser zu interpretieren. +

+ +
+ {Object.entries(DIALECT_LABELS_WITHOUT_DE).map(([key, label]) => { + const qualityLabels: Record = { + '1': 'Sehr schlecht', + '2': 'Schlecht', + '3': 'Weder noch', + '4': 'Gut', + '5': 'Sehr gut', + }; + const currentValue = formData.dialectQualities?.[key] || '3'; + return ( +
+
+ + + {qualityLabels[currentValue]} + +
+
+ + handleDialectQualityChange(key, e.target.value) + } + className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600" + /> +
+
+ Sehr schlecht + Sehr gut +
+
+ ); + })} +
+
+
+ )} + + {/* Navigation Buttons */} +
+ + + {currentStep < 3 ? ( + + ) : ( + + )} +
+
+ + {/* Info */} +
+

+ Die mit * gekennzeichneten Felder sind erforderlich. +

+
+
+
+
+ ); +} diff --git a/src/components/OnboardingViews/OnboardingInfoView.tsx b/src/components/OnboardingViews/OnboardingInfoView.tsx new file mode 100644 index 0000000..139b707 --- /dev/null +++ b/src/components/OnboardingViews/OnboardingInfoView.tsx @@ -0,0 +1,89 @@ +'use client'; + +interface OnboardingInfoViewProps { + onContinue: () => void; + hasExistingAnswers: boolean; +} + +export default function OnboardingInfoView({ + onContinue, + hasExistingAnswers, +}: OnboardingInfoViewProps) { + const handleContinue = () => { + onContinue(); + }; + + return ( +
+
+
+ {/* Header */} +
+

Willkommen

+
+ + {/* Info Section */} +
+

Was ist das Ziel?

+

+ Vielen Dank, dass Sie an diesem Experiment teilnehmen. Bevor Sie mit der Annotation beginnen, möchten wir gerne ein paar grundlegende Informationen über Sie und Ihre Erfahrung mit Dialekten sammeln. +

+

+ Diese Informationen helfen uns, Ihre Annotationsergebnisse besser zu verstehen und zu bewerten. Alle Angaben werden vertraulich behandelt. +

+
+ + {/* Instructions Section */} +
+

Wie funktioniert es?

+
    +
  1. + + 1 + + Sie beantworten ein kurzes Umfrage-Formular +
  2. +
  3. + + 2 + + Die Antworten werden gespeichert +
  4. +
  5. + + 3 + + Danach geht es zur Kalibrierungsphase +
  6. +
  7. + + 4 + + Anschließend können Sie mit der Annotation beginnen +
  8. +
+
+ + {/* Status */} + {hasExistingAnswers && ( +
+

+ ✓ Sie haben diesen Teil bereits ausgefüllt. Klicken Sie auf "Weiter", um fortzufahren. +

+
+ )} + + {/* Continue Button */} +
+ +
+
+
+
+ ); +} diff --git a/src/components/OnboardingViews/OnboardingPhase.tsx b/src/components/OnboardingViews/OnboardingPhase.tsx new file mode 100644 index 0000000..3b447ec --- /dev/null +++ b/src/components/OnboardingViews/OnboardingPhase.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { getOnboardingAnswers } from '@/app/actions/onboarding'; +import OnboardingInfoView from './OnboardingInfoView'; +import OnboardingFormView from './OnboardingFormView'; + +interface OnboardingPhaseProps { + experimentId: number; +} + +export default function OnboardingPhase({ experimentId }: OnboardingPhaseProps) { + const [showInfoPage, setShowInfoPage] = useState(true); + const [hasExistingAnswers, setHasExistingAnswers] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const load = async () => { + try { + // Load existing answers to check if user has completed onboarding before + const answersResult = await getOnboardingAnswers(experimentId); + if (answersResult.success && answersResult.data) { + const answers = answersResult.data as Record | null; + if (answers && Object.keys(answers).length > 0) { + setHasExistingAnswers(true); + } + } + } catch (err) { + console.error(err); + } finally { + setIsLoading(false); + } + }; + + load(); + }, [experimentId]); + + // Show loading view + if (isLoading) { + return ( +
+
+
+

Loading onboarding...

+
+
+ ); + } + + // Show info page first + if (showInfoPage) { + return ( + setShowInfoPage(false)} + hasExistingAnswers={hasExistingAnswers} + /> + ); + } + + // Show onboarding form page + return ; +}