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
15 changes: 11 additions & 4 deletions frontend/src/components/Slider.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from 'react';
import durian from '@/assets/pycon/durian.png';
import { cn } from '@/utils/classes';
import * as SliderPrimitive from '@radix-ui/react-slider';

Expand All @@ -12,11 +13,17 @@ const Slider = React.forwardRef<React.ElementRef<typeof SliderPrimitive.Root>, R
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary-200 dark:bg-background-100">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full dark:bg-background-100 bg-pycon-custard-light">
<SliderPrimitive.Range className="absolute h-full bg-pycon-orange" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow-sm transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow-sm transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className="block relative size-4 rounded-full border border-primary/50 bg-background shadow-sm transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50">
<div
className="absolute -inset-x-1 -inset-y-1 size-6 bg-cover bg-no-repeat bg-center transition-transform hover:scale-110 focus-visible:outline-none disabled:opacity-50"
style={{
backgroundImage: `url(${durian})`
}}
/>
</SliderPrimitive.Thumb>
</SliderPrimitive.Root>
)
);
Expand Down
15 changes: 11 additions & 4 deletions frontend/src/components/TextArea.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
import * as React from 'react';
import { cn } from '@/utils/classes';

export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
variant?: 'default' | 'custard';
}

const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, variant = 'default', ...props }, ref) => {
const variantClass = variant === 'custard' ? 'bg-[#feefdb] text-[#312541] placeholder:text-gray-600' : 'bg-input';

const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
return (
<textarea
ref={ref}
className={cn(
'flex min-h-[100px] w-full rounded-md border border-border bg-input px-3 py-1 text-sm shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-5',
'flex min-h-[100px] w-full rounded-md border border-border px-3 py-1 text-sm shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',

variantClass,
className
)}
ref={ref}
{...props}
/>
);
});

Textarea.displayName = 'Textarea';

export { Textarea };
100 changes: 100 additions & 0 deletions frontend/src/hooks/usePyconEvaluationForm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { ClaimCertificateResponse, postEvaluation } from '@/api/evaluations';
import { CustomAxiosError } from '@/api/utils/createApi';
import { QuestionConfigItem, mapFormValuesToEvaluateCreate } from '@/model/pycon/evaluations';
import { useNotifyToast } from '@/hooks/useNotifyToast';
import { questionSchemaBuilder } from '@/pages/client/pycon/evaluate/questionBuilder/questionSchemaBuilder';
import { EVALUATION_QUESTIONS_1, EVALUATION_QUESTIONS_2 } from '@/pages/client/pycon/evaluate/questionBuilder/questionsConfig';
import { EvaluateStepId } from '@/pages/client/pycon/evaluate/steps/EvaluationSteps';
import { useApi } from './useApi';
import { zodResolver } from '@hookform/resolvers/zod';

export interface DefaultEvaluateFormValues {
email: string;
certificate: ClaimCertificateResponse;
[x: string]: any;
}

export type DefaultEvaluateField = string;
export type DefaultEvaluateFieldMap = Partial<Record<EvaluateStepId, DefaultEvaluateField[]>>;

export const usePyconEvaluationForm = (questions: QuestionConfigItem[], eventId: string) => {
const EvaluateFormSchema = questionSchemaBuilder(questions).extend({
email: z.string().optional(),
certificate: z.custom<ClaimCertificateResponse>().optional()
});

type EvaluateFormValues = z.infer<typeof EvaluateFormSchema> & {
certificate?: ClaimCertificateResponse;
};

const api = useApi();
const { errorToast, successToast } = useNotifyToast();

const form = useForm<EvaluateFormValues>({
mode: 'onChange',
resolver: zodResolver(EvaluateFormSchema),
defaultValues: {
email: '',
...questions.reduce(
(acc, question) => {
if (question.questionType === 'slider') {
acc[question.name] = [];
} else if (question.questionType === 'radio_buttons') {
acc[question.name] = '';
} else if (question.questionType === 'multiple_answers') {
acc[question.name] = [];
} else {
acc[question.name] = '';
}
return acc;
},
{} as Record<string, string | number | number[]>
)
}
});

type EvaluateField = keyof EvaluateFormValues;
type EvaluateFieldMap = Partial<Record<EvaluateStepId, EvaluateField[]>>;

const EVALUATE_FIELDS: EvaluateFieldMap = {
EventDetails: [],
Evaluation_1: EVALUATION_QUESTIONS_1.map((question) => question.name) as EvaluateField[],
Evaluation_2: EVALUATION_QUESTIONS_2.map((question) => question.name) as EvaluateField[]
};

const submit = form.handleSubmit(async (values) => {
const { certificate } = values;
if (!certificate) {
errorToast({
title: 'Error',
description: 'Certificate information is missing. Please try again.'
});
return;
}

const { registrationId } = certificate;
try {
const response = await api.execute(postEvaluation(eventId, registrationId, mapFormValuesToEvaluateCreate(values)));
if (response.status === 200) {
successToast({
title: 'Evaluation submitted successfully!',
description: 'Thank you for submitting your evaluation. You may now access your certificate.'
});
}
} catch (e) {
const { errorData } = e as CustomAxiosError;
errorToast({
title: 'Error in submitting evaluation',
description: errorData?.message || errorData?.detail?.[0]?.msg || 'An error occurred while submitting'
});
}
});

return {
form,
submit,
EVALUATE_FIELDS
};
};
118 changes: 118 additions & 0 deletions frontend/src/model/pycon/evaluations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
type QuestionType =
| 'text_short'
| 'text_long'
| 'multiple_choice_dropdown'
| 'multiple_choice'
| 'multiple_choice_dropdown'
| 'multiple_answers'
| 'slider'
| 'radio_buttons';

export interface QuestionConfigItem {
questionType: QuestionType;
name: string;
question: string;
options?: string | string[];
required?: boolean;
answer?: string | string[];
}

export interface EvaluationResponse {
answer: string | null;
answerScale: number | null;
multipleAnswers: string[] | null;
booleanAnswer: boolean | null;
questionType: string | null;
question: string | null;
eventId: string;
createDate: string;
updateDate: string;
}

export interface Evaluation {
registration: {
firstName: string;
lastName: string;
contactNumber: string;
email: string;
registrationId: string;
};
evaluationList: EvaluationResponse[];
}

const sliderQuestions = [
'how_would_you_rate_pycon_davao_overall',
'how_satisfied_were_you_with_the_content_and_presentations',
'how_likely_are_you_to_recommend_this_event_to_other_python_developers_or_enthusiasts_based_on_the_community_support_provided',
'how_would_you_rate_event_organization_and_logistics',
'were_the_event_schedule_and_timing_convenient_for_you',
'did_you_find_opportunities_for_networking_valuable',
'how_would_you_rate_the_networking_opportunities_provided',
'how_likely_are_you_to_attend_future_pycon_davao_events',
'were_the_venue_facilities_adequate'
];

const textLongQuestions = [
'what_was_the_highlight_of_the_event_for_you',
'is_there_anything_specific_you_would_like_to_commend_about_the_event',
'which_sessions_or_topics_did_you_find_most_valuable',
'were_there_any_topics_or_sessions_you_felt_were_missing',
'have_you_attended_python_events_before_if_so_what_kind_of_community_support_did_you_find_helpful_in_previous_events',
'did_you_encounter_any_problems_with_organization_and_logistics',
'what_could_have_been_improved_related_to_the_networking_and_interaction_aspects_of_the_event',
'what_suggestions_do_you_have_for_improving_future_pycon_events',
'is_there_anything_else_you_would_like_to_share_about_your_experience_at_the_event'
];

const radioButtonQuestions: string[] = [];

export const PYCON_EVALUATION_QUESTIONS = [...sliderQuestions, ...textLongQuestions, ...radioButtonQuestions];

export const mapFormValuesToEvaluateCreate = (data: any) => {
const values = { ...data };
delete values.email;
delete values.certificate;
return convertToEvaluationList(values);
};

export const convertToEvaluationList = (data: any) => {
// Question type mappings

return Object.keys(data).map((key) => {
const response: Omit<EvaluationResponse, 'eventId' | 'createDate' | 'updateDate'> = {
answer: null,
answerScale: null,
multipleAnswers: null,
booleanAnswer: null,
questionType: null,
question: null
};

const value = data[key];

if (sliderQuestions.includes(key)) {
response.questionType = 'multiple_choice';
response.answerScale = Array.isArray(value) ? value[0] : value;
} else if (radioButtonQuestions.includes(key)) {
response.questionType = 'boolean';
if (typeof value === 'string') {
const numValue = parseInt(value);
response.booleanAnswer = numValue >= 3;
response.answerScale = numValue;
} else if (typeof value === 'number') {
response.booleanAnswer = value >= 3;
response.answerScale = value;
}
} else if (textLongQuestions.includes(key)) {
response.questionType = 'text';
response.answer = value;
} else {
// Fallback for any unknown question types
response.questionType = 'text';
response.answer = value;
}

response.question = key;
return response;
});
};
92 changes: 92 additions & 0 deletions frontend/src/pages/client/pycon/evaluate/Evaluate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { FC, useState } from 'react';
import { FormProvider } from 'react-hook-form';
import ErrorPage from '@/components/ErrorPage';
import Separator from '@/components/Separator';
import { cn } from '@/utils/classes';
import { useActiveBreakpoints } from '@/hooks/useActiveBreakpoints';
import { DefaultEvaluateField, usePyconEvaluationForm } from '@/hooks/usePyconEvaluationForm';
import EventDetails from '../register/EventDetails';
import Stepper from '../register/steps/Stepper';
import EvaluateFormSkeleton from './EvaluateFormSkeleton';
import EvaluateFooter from './footer/EvaluateFooter';
import QuestionBuilder from './questionBuilder/QuestionBuilder';
import { EVALUATION_QUESTIONS_1, EVALUATION_QUESTIONS_2 } from './questionBuilder/questionsConfig';
import ClaimCertificate from './steps/ClaimCertificate';
import { EvaluateStep, EvaluateSteps, STEP_CLAIM_CERTIFICATE, STEP_EVENT_DETAILS } from './steps/EvaluationSteps';
import { useEvaluatePage } from './useEvaluatePage';

interface Props {
eventId: string;
}

const Evaluate: FC<Props> = ({ eventId }) => {
const [currentStep, setCurrentStep] = useState<EvaluateStep>(STEP_EVENT_DETAILS);
const [shouldBeVertical] = useActiveBreakpoints('md');

const { response, isPending } = useEvaluatePage(eventId);
const { form, EVALUATE_FIELDS, submit } = usePyconEvaluationForm([...EVALUATION_QUESTIONS_1, ...EVALUATION_QUESTIONS_2], eventId);

if (isPending) return <EvaluateFormSkeleton />;
if (!response || (response && !response.data && response.errorData)) return <ErrorPage error={response} />;
if (response.data.status !== 'completed') return <ErrorPage />;

const eventInfo = response.data;
const fieldsToCheck: DefaultEvaluateField[] = EVALUATE_FIELDS[currentStep.id] || [];
const STEPS = EvaluateSteps;
const showStepper = currentStep.id !== 'EventDetails' && currentStep.id !== 'ClaimCertificate';

return (
<section
className={cn(
'flex flex-col grow items-center px-4 h-full w-full text-pycon-custard font-nunito max-w-6xl mx-auto',
currentStep.id === 'ClaimCertificate' && 'grow-0'
)}
>
<div className="w-full h-full flex flex-col space-y-4 grow">
{currentStep.id !== 'EventDetails' && currentStep.id !== 'ClaimCertificate' && <h1 className="text-xl">Evaluation</h1>}

<FormProvider {...form}>
<div className="flex flex-col md:flex-row w-full h-full grow">
{showStepper && (
<div className={cn('my-8', shouldBeVertical && 'h-[700px]')}>
<Stepper
orientation={shouldBeVertical ? 'vertical' : 'horizontal'}
steps={STEPS}
currentStep={currentStep}
stepsToExclude={[STEP_CLAIM_CERTIFICATE]}
hideTitle
/>
</div>
)}

<div
className={cn(
'space-y-4 grow',
currentStep.id !== 'EventDetails' && currentStep.id !== 'ClaimCertificate' && shouldBeVertical && 'ms-[20vw] p-8'
)}
>
{currentStep.id === 'EventDetails' && <EventDetails event={eventInfo} />}
{currentStep.id === 'Evaluation_1' && <QuestionBuilder questions={EVALUATION_QUESTIONS_1} />}
{currentStep.id === 'Evaluation_2' && <QuestionBuilder questions={EVALUATION_QUESTIONS_2} />}
</div>
</div>

{currentStep.id !== 'EventDetails' && currentStep.id !== 'ClaimCertificate' && <Separator className="my-4 bg-pycon-custard-light" />}

<EvaluateFooter
event={eventInfo}
steps={STEPS}
currentStep={currentStep}
fieldsToCheck={fieldsToCheck}
setCurrentStep={setCurrentStep}
submitForm={submit}
/>

{currentStep.id === 'ClaimCertificate' && <ClaimCertificate eventId={eventId!} />}
</FormProvider>
</div>
</section>
);
};

export default Evaluate;
Loading