tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/components/ui/tabs.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/components/ui/tabs.tsx
new file mode 100644
index 000000000..3d6f3acf8
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/components/ui/tabs.tsx
@@ -0,0 +1,64 @@
+import * as React from "react"
+import * as TabsPrimitive from "@radix-ui/react-tabs"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsList({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/components/ui/textarea.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/components/ui/textarea.tsx
new file mode 100644
index 000000000..7f21b5e78
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/components/ui/tooltip.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/components/ui/tooltip.tsx
new file mode 100644
index 000000000..4ee26b38a
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/components/ui/tooltip.tsx
@@ -0,0 +1,61 @@
+"use client"
+
+import * as React from "react"
+import * as TooltipPrimitive from "@radix-ui/react-tooltip"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/components/utils/development-tools/TanStackQueryDevelopmentTools.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/components/utils/development-tools/TanStackQueryDevelopmentTools.tsx
new file mode 100644
index 000000000..d6e058253
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/components/utils/development-tools/TanStackQueryDevelopmentTools.tsx
@@ -0,0 +1,10 @@
+import { isProduction } from "@/lib/utils";
+import React from "react";
+
+export const TanStackQueryDevelopmentTools = isProduction
+ ? (): null => null
+ : React.lazy(() =>
+ import("@tanstack/react-query-devtools").then((result) => ({
+ default: result.ReactQueryDevtools,
+ }))
+ );
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/components/utils/development-tools/TanStackRouterDevelopmentTools.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/components/utils/development-tools/TanStackRouterDevelopmentTools.tsx
new file mode 100644
index 000000000..4492446b2
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/components/utils/development-tools/TanStackRouterDevelopmentTools.tsx
@@ -0,0 +1,10 @@
+import { isProduction } from "@/lib/utils";
+import React from "react";
+
+export const TanStackRouterDevelopmentTools = isProduction
+ ? (): null => null
+ : React.lazy(() =>
+ import("@tanstack/react-router-devtools").then((result) => ({
+ default: result.TanStackRouterDevtools,
+ }))
+ );
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/config/site.ts b/web-citizen-reporting/web-citizen-reporting-template/src/config/site.ts
new file mode 100644
index 000000000..4d1ea1366
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/config/site.ts
@@ -0,0 +1,30 @@
+export const siteConfig = {
+ name: "VM Data Viz",
+ url: "TBD",
+ ogImage: "TBD",
+ description: "a really nice descripiton",
+ links: {
+ github: "https://github.com/idormenco/vote-monitor-data-viz",
+ },
+};
+
+export type SiteConfig = typeof siteConfig;
+
+export const META_THEME_COLORS = {
+ light: "#ffffff",
+ dark: "#09090b",
+};
+
+export const typography = {
+ h1: "scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl",
+ p: "leading-7 [&:not(:first-child)]:mt-6",
+ h2: "mt-10 scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0",
+ a: "font-medium text-primary underline underline-offset-4",
+ blockquote: "mt-6 border-l-2 pl-6 italic",
+ h3: "mt-8 scroll-m-20 text-2xl font-semibold tracking-tight",
+ ul: "my-6 ml-6 list-disc [&>li]:mt-2",
+ table: "w-full",
+ tr: "m-0 border-t p-0 even:bg-muted",
+ th: "border px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right",
+ td: "border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right",
+};
\ No newline at end of file
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/atoms.ts b/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/atoms.ts
new file mode 100644
index 000000000..79258e7fb
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/atoms.ts
@@ -0,0 +1,3 @@
+import { atom } from "jotai";
+
+export const currentFormLanguageAtom = atom("");
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/components/FormQuestion.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/components/FormQuestion.tsx
new file mode 100644
index 000000000..9dcdb4330
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/components/FormQuestion.tsx
@@ -0,0 +1,98 @@
+import { type BaseQuestion } from "@/common/types";
+import { FormDescription, FormLabel } from "@/components/ui/form";
+import { currentFormLanguageAtom } from "@/features/forms/atoms";
+import {
+ isMultiSelectQuestion,
+ isNumberQuestion,
+ isRatingQuestion,
+ isSingleSelectQuestion,
+ isTextQuestion,
+} from "@/lib/utils";
+import { useAtomValue } from "jotai";
+import { useEffect } from "react";
+import { useFormContext } from "react-hook-form";
+import { useShouldDisplayQuestion } from "../hooks/useShouldDisplayQuestion";
+import {
+ FormQuestionNumberInput,
+ FormQuestionTextInput,
+} from "./FormQuestionInputs";
+import { FormQuestionMultiSelectInput } from "./FormQuestionMultiSelectInput";
+import { FormQuestionSingleSelectInput } from "./FormQuestionSingleSelectInput";
+import { FormQuestionRatingInput } from "./FormQuestionRatingInput";
+
+interface FormQuestionProps {
+ question: BaseQuestion;
+ isRequired: boolean;
+}
+
+const BaseFormQuestion = ({ question, isRequired }: FormQuestionProps) => {
+ const language = useAtomValue(currentFormLanguageAtom);
+
+ return (
+
+
+
+ {`${question.code} - ${question.text[language]}`}
+
+
+ {question?.helptext?.[language]}
+
+ {isNumberQuestion(question) && (
+
+ )}
+ {isTextQuestion(question) && (
+
+ )}
+ {isSingleSelectQuestion(question) && (
+
+ )}
+ {isMultiSelectQuestion(question) && (
+
+ )}
+
+ {isRatingQuestion(question) && (
+
+ )}
+
+ );
+};
+
+const QuestionWithDisplayLogic = ({ question }: { question: BaseQuestion }) => {
+ const { control, unregister } = useFormContext();
+ const shouldDisplay = useShouldDisplayQuestion({ question, control });
+
+ useEffect(() => {
+ const fieldName = `question-${question.id}`;
+ return () => {
+ // unregister field if user changes the value of the parent and the condition stops matching
+ if (shouldDisplay) unregister(fieldName);
+ };
+ }, [shouldDisplay]);
+
+ if (!shouldDisplay) return;
+ return ;
+};
+
+export const FormQuestion = ({ question }: { question: BaseQuestion }) => {
+ if (question.displayLogic)
+ return ;
+ return ;
+};
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/components/FormQuestionFreeTextInput.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/components/FormQuestionFreeTextInput.tsx
new file mode 100644
index 000000000..fbfdf14ca
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/components/FormQuestionFreeTextInput.tsx
@@ -0,0 +1,53 @@
+import {
+ type MultiSelectQuestion,
+ type SingleSelectQuestion,
+} from "@/common/types";
+import {
+ FormControl,
+ FormField,
+ FormItem,
+ FormMessage,
+} from "@/components/ui/form";
+import { Textarea } from "@/components/ui/textarea";
+import { useFormContext } from "react-hook-form";
+import { useFreeTextInput } from "../hooks/useFreeTextInput";
+export const FormQuestionFreeTextInput = ({
+ question,
+ language,
+}: {
+ question: SingleSelectQuestion | MultiSelectQuestion;
+ language: string;
+}) => {
+ const { control, setValue } = useFormContext();
+ const {
+ fieldNames,
+ shouldDisplayFreeTextInput,
+ freeTextOption,
+ addFreeTextToOption,
+ } = useFreeTextInput(question);
+ return (
+ (
+
+
+
+
+
+ )}
+ />
+ );
+};
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/components/FormQuestionInputs.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/components/FormQuestionInputs.tsx
new file mode 100644
index 000000000..0c4672f94
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/components/FormQuestionInputs.tsx
@@ -0,0 +1,98 @@
+import {
+ QuestionType,
+ type NumberAnswer,
+ type TextAnswer,
+} from "@/common/types";
+import {
+ FormControl,
+ FormField,
+ FormItem,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { NumberInput } from "@/components/ui/number-input";
+import { useFormContext } from "react-hook-form";
+import { mapFormDataToAnswer } from "../utils";
+
+export const FormQuestionNumberInput = ({
+ questionId,
+ isRequired,
+}: {
+ questionId: string;
+ isRequired?: boolean;
+}) => {
+ const { control } = useFormContext();
+ return (
+
+ value?.value !== undefined,
+ }}
+ render={({ field }) => (
+
+
+
+ field.onChange(
+ mapFormDataToAnswer(
+ QuestionType.NumberQuestionType,
+ questionId,
+ value
+ )
+ )
+ }
+ onBlur={field.onBlur}
+ value={(field?.value as NumberAnswer)?.value}
+ />
+
+
+
+
+ )}
+ />
+ );
+};
+
+export const FormQuestionTextInput = ({
+ questionId,
+ isRequired,
+}: {
+ questionId: string;
+ isRequired?: boolean;
+}) => {
+ const { control } = useFormContext();
+ return (
+ value?.text !== "",
+ }}
+ render={({ field }) => (
+
+
+
+ field.onChange(
+ mapFormDataToAnswer(
+ QuestionType.TextQuestionType,
+ questionId,
+ evemt.target.value
+ )
+ )
+ }
+ onBlur={field.onBlur}
+ value={(field?.value as TextAnswer)?.text}
+ />
+
+
+
+
+ )}
+ />
+ );
+};
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/components/FormQuestionMultiSelectInput.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/components/FormQuestionMultiSelectInput.tsx
new file mode 100644
index 000000000..e7f73a0a5
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/components/FormQuestionMultiSelectInput.tsx
@@ -0,0 +1,123 @@
+import {
+ AnswerType,
+ type MultiSelectAnswer,
+ type MultiSelectQuestion,
+ type SelectedOption,
+} from "@/common/types";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { useFormContext } from "react-hook-form";
+import { useFreeTextInput } from "../hooks/useFreeTextInput";
+import { FormQuestionFreeTextInput } from "./FormQuestionFreeTextInput";
+
+export const FormQuestionMultiSelectInput = ({
+ question,
+ language,
+ isRequired,
+}: {
+ question: MultiSelectQuestion;
+ language: string;
+ isRequired?: boolean;
+}) => {
+ const { control } = useFormContext();
+
+ const { fieldNames, shouldDisplayFreeTextInput } = useFreeTextInput(question);
+
+ const addOptionToMultiSelectAnswer = (
+ questionId: string,
+ currentValue: MultiSelectAnswer,
+ option: SelectedOption
+ ) => {
+ let selections = currentValue?.selection ?? [];
+ selections = [...selections, option];
+ let multiselectAnswer: MultiSelectAnswer = {
+ $answerType: AnswerType.MultiSelectAnswerType,
+ questionId,
+ selection: selections,
+ };
+
+ return multiselectAnswer;
+ };
+ const removeSelectionFromMultiSelectAnswer = (
+ questionId: string,
+ currentValue: MultiSelectAnswer,
+ optionId: string
+ ) => {
+ let selections = currentValue?.selection ?? [];
+ const filteredSelections = selections.filter(
+ (selected) => selected.optionId !== optionId
+ );
+
+ const multiselectAnswer: MultiSelectAnswer = {
+ $answerType: AnswerType.MultiSelectAnswerType,
+ questionId,
+ selection: filteredSelections,
+ };
+ return multiselectAnswer;
+ };
+
+ return (
+
+
+ value?.selection && value.selection.length > 0,
+ }}
+ render={({ field }) => (
+
+ {question.options.map((option) => {
+ const isChecked = field.value?.selection?.some(
+ (opt: SelectedOption) => opt.optionId === option.id
+ );
+
+ return (
+
+
+ {
+ const newValue = checked
+ ? addOptionToMultiSelectAnswer(
+ question.id,
+ field.value,
+ {
+ optionId: option.id,
+ }
+ )
+ : removeSelectionFromMultiSelectAnswer(
+ question.id,
+ field.value,
+ option.id
+ );
+ field.onChange(newValue);
+ }}
+ />
+
+
+ {option.text[language]}
+
+
+ );
+ })}
+
+
+ )}
+ />
+ {shouldDisplayFreeTextInput && (
+
+ )}
+
+ );
+};
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/components/FormQuestionRatingInput.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/components/FormQuestionRatingInput.tsx
new file mode 100644
index 000000000..2155c7633
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/components/FormQuestionRatingInput.tsx
@@ -0,0 +1,62 @@
+import {
+ QuestionType,
+ type NumberAnswer,
+ type RatingAnswer,
+ type RatingQuestion,
+} from "@/common/types";
+import {
+ FormControl,
+ FormField,
+ FormItem,
+ FormMessage,
+} from "@/components/ui/form";
+import { RatingGroup } from "@/components/ui/ratings";
+import { useFormContext } from "react-hook-form";
+import { mapFormDataToAnswer, ratingScaleToNumber } from "../utils";
+
+export const FormQuestionRatingInput = ({
+ question,
+ language,
+ isRequired,
+}: {
+ question: RatingQuestion;
+ language: string;
+ isRequired?: boolean;
+}) => {
+ const { control } = useFormContext();
+ return (
+
+ value?.value !== undefined,
+ }}
+ render={({ field }) => (
+
+
+
+ field.onChange(
+ mapFormDataToAnswer(
+ QuestionType.RatingQuestionType,
+ question.id,
+ value
+ )
+ )
+ }
+ onBlur={field.onBlur}
+ value={(field?.value as RatingAnswer)?.value?.toString()}
+ />
+
+
+
+
+ )}
+ />
+ );
+};
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/components/FormQuestionSingleSelectInput.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/components/FormQuestionSingleSelectInput.tsx
new file mode 100644
index 000000000..a5cb0e924
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/components/FormQuestionSingleSelectInput.tsx
@@ -0,0 +1,79 @@
+import {
+ QuestionType,
+ type SingleSelectAnswer,
+ type SingleSelectQuestion,
+} from "@/common/types";
+import {
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { useFormContext } from "react-hook-form";
+import { useFreeTextInput } from "../hooks/useFreeTextInput";
+import { mapFormDataToAnswer } from "../utils";
+import { FormQuestionFreeTextInput } from "./FormQuestionFreeTextInput";
+
+export const FormQuestionSingleSelectInput = ({
+ question,
+ language,
+ isRequired,
+}: {
+ question: SingleSelectQuestion;
+ language: string;
+ isRequired?: boolean;
+}) => {
+ const { control } = useFormContext();
+ const { fieldNames, shouldDisplayFreeTextInput } = useFreeTextInput(question);
+
+ return (
+
+ (
+
+
+
+ field.onChange(
+ mapFormDataToAnswer(
+ QuestionType.SingleSelectQuestionType,
+ question.id,
+ value
+ )
+ )
+ }
+ value={
+ (field?.value as SingleSelectAnswer)?.selection?.optionId
+ }
+ className="flex flex-col space-y-1"
+ >
+ {question.options.map((option) => (
+
+
+
+
+
+ {option.text[language]}
+
+
+ ))}
+
+
+
+
+ )}
+ />
+ {shouldDisplayFreeTextInput && (
+
+ )}
+
+ );
+};
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/hooks/useFreeTextInput.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/hooks/useFreeTextInput.tsx
new file mode 100644
index 000000000..5b9858ce8
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/hooks/useFreeTextInput.tsx
@@ -0,0 +1,71 @@
+import type {
+ MultiSelectAnswer,
+ MultiSelectQuestion,
+ SelectedOption,
+ SingleSelectAnswer,
+ SingleSelectQuestion,
+} from "@/common/types";
+import { isSingleSelectAnswer } from "@/lib/utils";
+import { useMemo } from "react";
+import { useFormContext, useWatch } from "react-hook-form";
+
+export const useFreeTextInput = (
+ question: SingleSelectQuestion | MultiSelectQuestion
+) => {
+ const freeTextOption = question.options.find((option) => option.isFreeText);
+ const fieldNames = {
+ parent: `question-${question.id}`,
+ freeText: `question-${question.id}-ft-${freeTextOption?.id ?? "0"}`,
+ };
+
+ const { control } = useFormContext();
+ const currentValueFromParent = useWatch({ control, name: fieldNames.parent });
+
+ const isFreeTextOptionSelected = (
+ fieldValue: SingleSelectAnswer | MultiSelectAnswer
+ ) => {
+ if (!freeTextOption || !fieldValue || !fieldValue.selection) return false;
+
+ if (isSingleSelectAnswer(fieldValue))
+ return fieldValue.selection.optionId === freeTextOption.id;
+
+ if (fieldValue.selection.length === 0) return false;
+
+ const currentSelectionSet = new Set(
+ fieldValue.selection.map((opt) => opt.optionId)
+ );
+
+ return currentSelectionSet.has(freeTextOption.id);
+ };
+
+ const addFreeTextToOption = (text: string) => {
+ if (isSingleSelectAnswer(currentValueFromParent)) {
+ return {
+ ...currentValueFromParent,
+ selection: { ...currentValueFromParent.selection, text },
+ };
+ }
+
+ const updatedData = currentValueFromParent?.selection?.map(
+ (selection: SelectedOption) => {
+ if (selection.optionId === freeTextOption?.id)
+ return { ...selection, text };
+ return selection;
+ }
+ );
+ return { ...currentValueFromParent, selection: updatedData };
+ };
+
+ const shouldDisplayFreeTextInput = useMemo(
+ () => isFreeTextOptionSelected(currentValueFromParent),
+ [currentValueFromParent]
+ );
+
+ return {
+ freeTextOption,
+ fieldNames,
+ shouldDisplayFreeTextInput,
+ isFreeTextOptionSelected,
+ addFreeTextToOption,
+ };
+};
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/hooks/useShouldDisplayQuestion.ts b/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/hooks/useShouldDisplayQuestion.ts
new file mode 100644
index 000000000..eb6d66973
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/hooks/useShouldDisplayQuestion.ts
@@ -0,0 +1,128 @@
+import {
+ AnswerType,
+ DisplayLogicCondition,
+ type BaseAnswer,
+ type BaseQuestion,
+ type MultiSelectAnswer,
+ type NumberAnswer,
+ type RatingAnswer,
+ type SingleSelectAnswer,
+ type TextAnswer,
+} from "@/common/types";
+import { useEffect, useState } from "react";
+import { useWatch, type Control } from "react-hook-form";
+
+const mapDisplayConditionToMath = (
+ operand1: string,
+ operand2: string,
+ condition: DisplayLogicCondition
+) => {
+ switch (condition) {
+ case "Equals":
+ return operand1 === operand2;
+ case "GreaterEqual":
+ return operand1 >= operand2;
+ case "GreaterThan":
+ return operand1 > operand2;
+ case "LessEqual":
+ return operand1 <= operand2;
+ case "LessThan":
+ return operand1 < operand2;
+ case "NotEquals":
+ return operand1 !== operand2;
+ default:
+ return false;
+ }
+};
+
+export const useShouldDisplayQuestion = ({
+ question,
+ control,
+}: {
+ question: BaseQuestion;
+ control: Control;
+}) => {
+ const [shouldDisplayQuestion, setShouldDisplay] = useState(false);
+
+ const parentAnswer: BaseAnswer = useWatch({
+ control,
+ name: `question-${question.displayLogic?.parentQuestionId}`,
+ });
+
+ const updateShouldDisplay = () => {
+ let shouldDisplay = false;
+ const condition = question.displayLogic!.condition;
+ const value = question.displayLogic!.value;
+
+ switch (parentAnswer.$answerType) {
+ case AnswerType.NumberAnswerType: {
+ const parent = parentAnswer as NumberAnswer;
+
+ if (!parent.value) {
+ shouldDisplay = false;
+ break;
+ }
+
+ shouldDisplay = mapDisplayConditionToMath(
+ parent.value!.toString(),
+ value,
+ condition
+ );
+ break;
+ }
+
+ case AnswerType.TextAnswerType: {
+ const answer = parentAnswer as TextAnswer;
+ if (!answer.text) break;
+
+ shouldDisplay = mapDisplayConditionToMath(
+ answer.text,
+ value,
+ condition
+ );
+
+ break;
+ }
+
+ case AnswerType.MultiSelectAnswerType: {
+ const parent = parentAnswer as MultiSelectAnswer;
+
+ if (!parent.selection) break;
+ shouldDisplay = !!parent.selection.find(
+ (option) => option.optionId === value
+ );
+ break;
+ }
+
+ case AnswerType.SingleSelectAnswerType: {
+ const parent = parentAnswer as SingleSelectAnswer;
+ if (!parent.selection) break;
+
+ shouldDisplay = parent.selection.optionId === value;
+ break;
+ }
+
+ case AnswerType.RatingAnswerType: {
+ const parent = parentAnswer as RatingAnswer;
+ if (!parent.value) break;
+
+ shouldDisplay = mapDisplayConditionToMath(
+ parent.value.toString(),
+ value,
+ condition
+ );
+ break;
+ }
+ default:
+ break;
+ }
+ setShouldDisplay(shouldDisplay);
+ };
+
+ useEffect(() => {
+ if (!parentAnswer) return;
+ updateShouldDisplay();
+ }, [parentAnswer]);
+
+ return shouldDisplayQuestion;
+};
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/utils.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/utils.tsx
new file mode 100644
index 000000000..7b269788a
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/features/forms/utils.tsx
@@ -0,0 +1,100 @@
+import {
+ AnswerType,
+ QuestionType,
+ RatingScaleType,
+ type MultiSelectAnswer,
+ type NumberAnswer,
+ type RatingAnswer,
+ type SingleSelectAnswer,
+ type TextAnswer,
+} from "@/common/types";
+
+export const mapFormDataToAnswer = (
+ questionType: QuestionType,
+ questionId: string,
+ value: any
+) => {
+ switch (questionType) {
+ case QuestionType.NumberQuestionType:
+ const numberAnswer: NumberAnswer = {
+ $answerType: AnswerType.NumberAnswerType,
+ questionId,
+ value,
+ };
+ return numberAnswer;
+
+ case QuestionType.TextQuestionType:
+ const textAnswer: TextAnswer = {
+ $answerType: AnswerType.TextAnswerType,
+ questionId,
+ text: value,
+ };
+ return textAnswer;
+
+ case QuestionType.MultiSelectQuestionType:
+ let selectionArray = value
+ ? value.map((val: string) => {
+ return { optionId: val };
+ })
+ : [];
+ const multiselectAnswer: MultiSelectAnswer = {
+ $answerType: AnswerType.MultiSelectAnswerType,
+ questionId,
+ selection: selectionArray,
+ };
+ return multiselectAnswer;
+
+ case QuestionType.SingleSelectQuestionType:
+ const singleSelectAnswer: SingleSelectAnswer = {
+ $answerType: AnswerType.SingleSelectAnswerType,
+ questionId,
+ selection: { optionId: value },
+ };
+
+ return singleSelectAnswer;
+
+ case QuestionType.RatingQuestionType:
+ const ratingAnswer: RatingAnswer = {
+ $answerType: AnswerType.RatingAnswerType,
+ questionId,
+ value: Number(value),
+ };
+
+ return ratingAnswer;
+
+ default:
+ return value;
+ }
+};
+
+export function ratingScaleToNumber(scale: RatingScaleType): number {
+ switch (scale) {
+ case RatingScaleType.OneTo3: {
+ return 3;
+ }
+ case RatingScaleType.OneTo4: {
+ return 4;
+ }
+ case RatingScaleType.OneTo5: {
+ return 5;
+ }
+ case RatingScaleType.OneTo6: {
+ return 6;
+ }
+ case RatingScaleType.OneTo7: {
+ return 7;
+ }
+ case RatingScaleType.OneTo8: {
+ return 8;
+ }
+ case RatingScaleType.OneTo9: {
+ return 9;
+ }
+ case RatingScaleType.OneTo10: {
+ return 10;
+ }
+ default: {
+ return 5;
+ }
+ }
+}
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/hooks/use-callback-ref.ts b/web-citizen-reporting/web-citizen-reporting-template/src/hooks/use-callback-ref.ts
new file mode 100644
index 000000000..f853bf1ca
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/hooks/use-callback-ref.ts
@@ -0,0 +1,27 @@
+import * as React from "react";
+
+/**
+ * @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-callback-ref/src/useCallbackRef.tsx
+ */
+
+/**
+ * A custom hook that converts a callback to a ref to avoid triggering re-renders when passed as a
+ * prop or avoid re-executing effects when passed as a dependency
+ */
+function useCallbackRef unknown>(
+ callback: T | undefined
+): T {
+ const callbackRef = React.useRef(callback);
+
+ React.useEffect(() => {
+ callbackRef.current = callback;
+ });
+
+ // https://github.com/facebook/react/issues/19240
+ return React.useMemo(
+ () => ((...args) => callbackRef.current?.(...args)) as T,
+ []
+ );
+}
+
+export { useCallbackRef };
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/hooks/use-controllable-state.ts b/web-citizen-reporting/web-citizen-reporting-template/src/hooks/use-controllable-state.ts
new file mode 100644
index 000000000..2a5880d68
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/hooks/use-controllable-state.ts
@@ -0,0 +1,66 @@
+import * as React from "react";
+import { useCallbackRef } from "./use-callback-ref";
+
+/**
+ * @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/useControllableState.tsx
+ */
+
+type UseControllableStateParams = {
+ prop?: T | undefined;
+ defaultProp?: T | undefined;
+ onChange?: (state: T) => void;
+};
+
+type SetStateFn = (prevState?: T) => T;
+
+function useControllableState({
+ prop,
+ defaultProp,
+ onChange = () => {},
+}: UseControllableStateParams) {
+ const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({
+ defaultProp,
+ onChange,
+ });
+ const isControlled = prop !== undefined;
+ const value = isControlled ? prop : uncontrolledProp;
+ const handleChange = useCallbackRef(onChange);
+
+ const setValue: React.Dispatch> =
+ React.useCallback(
+ (nextValue) => {
+ if (isControlled) {
+ const setter = nextValue as SetStateFn;
+ const value =
+ typeof nextValue === "function" ? setter(prop) : nextValue;
+ if (value !== prop) handleChange(value as T);
+ } else {
+ setUncontrolledProp(nextValue);
+ }
+ },
+ [isControlled, prop, setUncontrolledProp, handleChange]
+ );
+
+ return [value, setValue] as const;
+}
+
+function useUncontrolledState({
+ defaultProp,
+ onChange,
+}: Omit, "prop">) {
+ const uncontrolledState = React.useState(defaultProp);
+ const [value] = uncontrolledState;
+ const prevValueRef = React.useRef(value);
+ const handleChange = useCallbackRef(onChange);
+
+ React.useEffect(() => {
+ if (prevValueRef.current !== value) {
+ handleChange(value as T);
+ prevValueRef.current = value;
+ }
+ }, [value, prevValueRef, handleChange]);
+
+ return uncontrolledState;
+}
+
+export { useControllableState };
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/hooks/use-meta-color.ts b/web-citizen-reporting/web-citizen-reporting-template/src/hooks/use-meta-color.ts
new file mode 100644
index 000000000..a0be86beb
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/hooks/use-meta-color.ts
@@ -0,0 +1,25 @@
+import * as React from "react";
+import { useTheme } from "next-themes";
+
+import { META_THEME_COLORS } from "@/config/site";
+
+export function useMetaColor() {
+ const { resolvedTheme } = useTheme();
+
+ const metaColor = React.useMemo(() => {
+ return resolvedTheme !== "dark"
+ ? META_THEME_COLORS.light
+ : META_THEME_COLORS.dark;
+ }, [resolvedTheme]);
+
+ const setMetaColor = React.useCallback((color: string) => {
+ document
+ .querySelector('meta[name="theme-color"]')
+ ?.setAttribute("content", color);
+ }, []);
+
+ return {
+ metaColor,
+ setMetaColor,
+ };
+}
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/hooks/use-mobile.ts b/web-citizen-reporting/web-citizen-reporting-template/src/hooks/use-mobile.ts
new file mode 100644
index 000000000..2b0fe1dfe
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/hooks/use-mobile.ts
@@ -0,0 +1,19 @@
+import * as React from "react"
+
+const MOBILE_BREAKPOINT = 768
+
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = React.useState(undefined)
+
+ React.useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
+ const onChange = () => {
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ }
+ mql.addEventListener("change", onChange)
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ return () => mql.removeEventListener("change", onChange)
+ }, [])
+
+ return !!isMobile
+}
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/hooks/use-upload-file.ts b/web-citizen-reporting/web-citizen-reporting-template/src/hooks/use-upload-file.ts
new file mode 100644
index 000000000..c60b7ba86
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/hooks/use-upload-file.ts
@@ -0,0 +1,37 @@
+import { API } from "@/api/api";
+import * as React from "react";
+import { toast } from "sonner";
+
+interface UseUploadFileOptions {
+ defaultUploadedFiles?: File[];
+}
+
+export function useUploadFile({ defaultUploadedFiles }: UseUploadFileOptions) {
+ const [uploadedFiles, setUploadedFiles] = React.useState(
+ defaultUploadedFiles ?? []
+ );
+ const [progresses, setProgresses] = React.useState>(
+ {}
+ );
+ const [isUploading, setIsUploading] = React.useState(false);
+
+ async function onUpload(files: File[]) {
+ setIsUploading(true);
+ try {
+ // const res = await API.postForm();
+ // setUploadedFiles((prev) => (prev ? [...prev, ...res] : res));
+ } catch (err) {
+ toast.error("Something went wrong, please try again later.");
+ } finally {
+ setProgresses({});
+ setIsUploading(false);
+ }
+ }
+
+ return {
+ onUpload,
+ uploadedFiles,
+ progresses,
+ isUploading,
+ };
+}
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/lib/utils.ts b/web-citizen-reporting/web-citizen-reporting-template/src/lib/utils.ts
new file mode 100644
index 000000000..1699c2182
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/lib/utils.ts
@@ -0,0 +1,118 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+import {
+ DateAnswerSchema,
+ MultiSelectAnswerSchema,
+ NumberAnswerSchema,
+ QuestionType,
+ RatingAnswerSchema,
+ SingleSelectAnswerSchema,
+ TextAnswerSchema,
+ type BaseAnswer,
+ type BaseQuestion,
+ type DateAnswer,
+ type MultiSelectAnswer,
+ type MultiSelectQuestion,
+ type NumberAnswer,
+ type RatingAnswer,
+ type RatingQuestion,
+ type SingleSelectAnswer,
+ type SingleSelectQuestion,
+ type TextAnswer,
+ type TextQuestion,
+ type NumberQuestion,
+ type DateQuestion,
+} from "@/common/types";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+export const isProduction = import.meta.env.MODE === "production";
+export const electionRoundId = import.meta.env.VITE_ELECTION_ROUND_ID;
+
+export function isDateAnswer(answer: BaseAnswer): answer is DateAnswer {
+ return DateAnswerSchema.safeParse(answer).success;
+}
+
+export function isDateQuestion(
+ question: BaseQuestion
+): question is DateQuestion {
+ return question.$questionType === QuestionType.DateQuestionType;
+}
+
+export function isMultiSelectAnswer(
+ answer: BaseAnswer
+): answer is MultiSelectAnswer {
+ return MultiSelectAnswerSchema.safeParse(answer).success;
+}
+
+export function isMultiSelectQuestion(
+ question: BaseQuestion
+): question is MultiSelectQuestion {
+ return question.$questionType === QuestionType.MultiSelectQuestionType;
+}
+
+export function isNumberAnswer(answer: BaseAnswer): answer is NumberAnswer {
+ return NumberAnswerSchema.safeParse(answer).success;
+}
+
+export function isNumberQuestion(
+ question: BaseQuestion
+): question is NumberQuestion {
+ return question.$questionType === QuestionType.NumberQuestionType;
+}
+
+export function isRatingAnswer(answer: BaseAnswer): answer is RatingAnswer {
+ return RatingAnswerSchema.safeParse(answer).success;
+}
+
+export function isRatingQuestion(
+ question: BaseQuestion
+): question is RatingQuestion {
+ return question.$questionType === QuestionType.RatingQuestionType;
+}
+
+export function isSingleSelectAnswer(
+ answer: BaseAnswer
+): answer is SingleSelectAnswer {
+ return SingleSelectAnswerSchema.safeParse(answer).success;
+}
+
+export function isSingleSelectQuestion(
+ question: BaseQuestion
+): question is SingleSelectQuestion {
+ return question.$questionType === QuestionType.SingleSelectQuestionType;
+}
+
+export function isTextAnswer(answer: BaseAnswer): answer is TextAnswer {
+ return TextAnswerSchema.safeParse(answer).success;
+}
+
+export function isTextQuestion(
+ question: BaseQuestion
+): question is TextQuestion {
+ return question.$questionType === QuestionType.TextQuestionType;
+}
+export const downloadFile = (presignedUrl: string) => {
+ window.open(presignedUrl, "_blank");
+};
+
+export function formatBytes(
+ bytes: number,
+ opts: {
+ decimals?: number;
+ sizeType?: "accurate" | "normal";
+ } = {}
+) {
+ const { decimals = 0, sizeType = "normal" } = opts;
+
+ const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
+ const accurateSizes = ["Bytes", "KiB", "MiB", "GiB", "TiB"];
+ if (bytes === 0) return "0 Byte";
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ return `${(bytes / Math.pow(1024, i)).toFixed(decimals)} ${
+ sizeType === "accurate" ? accurateSizes[i] ?? "Bytes" : sizes[i] ?? "Bytes"
+ }`;
+}
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/main.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/main.tsx
new file mode 100644
index 000000000..4d3a96672
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/main.tsx
@@ -0,0 +1,86 @@
+import { TailwindIndicator } from "@/components/TailwindIndicator.tsx";
+import { ThemeProvider } from "@/components/ThemeProvider.tsx";
+import { Toaster } from "@/components/ui/sonner";
+import { RouterProvider, createRouter } from "@tanstack/react-router";
+import { StrictMode } from "react";
+import ReactDOM from "react-dom/client";
+// Import the generated route tree
+import { routeTree } from "./routeTree.gen";
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { TanStackQueryDevelopmentTools } from "./components/utils/development-tools/TanStackQueryDevelopmentTools.tsx";
+import { TanStackRouterDevelopmentTools } from "./components/utils/development-tools/TanStackRouterDevelopmentTools.tsx";
+import "./styles.css";
+
+import { TooltipProvider } from "./components/ui/tooltip.tsx";
+
+const STALE_TIME = 1000 * 60 * 15; // 15 minutes
+
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: STALE_TIME,
+ },
+ },
+});
+// Create a new router instance
+const router = createRouter({
+ routeTree,
+ context: {
+ queryClient,
+ },
+ defaultPreload: "intent",
+ scrollRestoration: true,
+ defaultStructuralSharing: true,
+ defaultPreloadStaleTime: 0,
+ defaultPreloadDelay: 100,
+});
+
+// Register the router instance for type safety
+declare module "@tanstack/react-router" {
+ interface Register {
+ router: typeof router;
+ }
+}
+
+// Render the app
+const rootElement = document.getElementById("app");
+if (rootElement && !rootElement.innerHTML) {
+ const root = ReactDOM.createRoot(rootElement);
+ root.render(
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// If you want to start measuring performance in your app, pass a function
+// to log results (for example: reportWebVitals(console.log))
+// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
+// reportWebVitals(console.log);
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/pages/About.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/pages/About.tsx
new file mode 100644
index 000000000..7476c9b91
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/pages/About.tsx
@@ -0,0 +1,121 @@
+import { typography } from "../config/site";
+
+export default function About() {
+ return (
+
+
+ The Joke Tax Chronicles
+
+
+ Once upon a time, in a far-off land, there was a very lazy king who
+ spent all day lounging on his throne. One day, his advisors came to him
+ with a problem: the kingdom was running out of money.
+
+
+ The King's Plan
+
+
+ The king thought long and hard, and finally came up with
+
+ a brilliant plan
+
+ : he would tax the jokes in the kingdom.
+
+
+ "After all," he said, "everyone enjoys a good joke, so it's only fair
+ that they should pay for the privilege."
+
+
+ The Joke Tax
+
+
+ The king's subjects were not amused. They grumbled and complained, but
+ the king was firm:
+
+
+ 1st level of puns: 5 gold coins
+ 2nd level of jokes: 10 gold coins
+ 3rd level of one-liners : 20 gold coins
+
+
+ As a result, people stopped telling jokes, and the kingdom fell into a
+ gloom. But there was one person who refused to let the king's
+ foolishness get him down: a court jester named Jokester.
+
+
+ Jokester's Revolt
+
+
+ Jokester began sneaking into the castle in the middle of the night and
+ leaving jokes all over the place: under the king's pillow, in his soup,
+ even in the royal toilet. The king was furious, but he couldn't seem to
+ stop Jokester.
+
+
+ And then, one day, the people of the kingdom discovered that the jokes
+ left by Jokester were so funny that they couldn't help but laugh. And
+ once they started laughing, they couldn't stop.
+
+
+ The People's Rebellion
+
+
+ The people of the kingdom, feeling uplifted by the laughter, started to
+ tell jokes and puns again, and soon the entire kingdom was in on the
+ joke.
+
+
+
+
+
+
+ King's Treasury
+
+
+ People's happiness
+
+
+
+
+
+
+ Empty
+
+
+ Overflowing
+
+
+
+
+ Modest
+
+
+ Satisfied
+
+
+
+
+ Full
+
+
+ Ecstatic
+
+
+
+
+
+
+ The king, seeing how much happier his subjects were, realized the error
+ of his ways and repealed the joke tax. Jokester was declared a hero, and
+ the kingdom lived happily ever after.
+
+
+ The moral of the story is: never underestimate the power of a good laugh
+ and always be careful of bad ideas.
+
+
+ );
+}
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/pages/GuideDetails.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/pages/GuideDetails.tsx
new file mode 100644
index 000000000..0df18afc2
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/pages/GuideDetails.tsx
@@ -0,0 +1,52 @@
+import { Spinner } from "@/components/Spinner";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { downloadFile } from "@/lib/utils";
+import { useGuides } from "@/queries/use-guides";
+import { Route } from "@/routes/guides/$guideId";
+import { notFound } from "@tanstack/react-router";
+import { Download, ExternalLink } from "lucide-react";
+
+function GuideDetails() {
+ const { guideId } = Route.useParams();
+
+ const { data: guide, isLoading } = useGuides((guides) =>
+ guides.find((g) => g.id === guideId)
+ );
+
+ if (isLoading) return ;
+
+ if (!guide) throw notFound();
+ return (
+ <>
+
+
+ {guide.title}
+
+ {guide.guideType === "Text" && (
+
+
+
+ )}
+ {guide.guideType === "Website" && (
+
+ Visit Website
+
+
+ )}
+ {guide.guideType === "Document" && (
+ downloadFile(guide.presignedUrl)}
+ >
+ Download
+
+
+ )}
+
+ >
+ );
+}
+
+export default GuideDetails;
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/pages/GuidesList.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/pages/GuidesList.tsx
new file mode 100644
index 000000000..cabd3d694
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/pages/GuidesList.tsx
@@ -0,0 +1,94 @@
+import { GuideType, type GuideModel } from "@/common/types";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { downloadFile } from "@/lib/utils";
+import { useGuides } from "@/queries/use-guides";
+import { Link } from "@tanstack/react-router";
+import {
+ BookOpen,
+ ChevronRight,
+ Download,
+ ExternalLink,
+ FileDown,
+ Globe,
+} from "lucide-react";
+
+function GuidesList() {
+ const { data: guides } = useGuides();
+
+ return (
+
+
+
+ Citizen guides
+
+
+ Access our comprehensive collection of guides to help you navigate the
+ electoral process. From registration to casting your vote, we provide
+ all the information you need.
+
+
+
+
+ {guides?.map((guide) => (
+
+ ))}
+
+
+ );
+}
+
+function GuideCard({ guide }: { guide: GuideModel }) {
+ return (
+
+
+
+
+ {guide.guideType === GuideType.Text && (
+
+ )}
+ {guide.guideType === GuideType.Website && (
+
+ )}
+ {guide.guideType === GuideType.Document && (
+
+ )}
+ {guide.title}
+
+
+
+
+ {guide.guideType === GuideType.Text && (
+
+
+ Read Guide
+
+
+
+ )}
+ {guide.guideType === GuideType.Website && (
+
+ Visit Website
+
+
+ )}
+ {guide.guideType === GuideType.Document && (
+ downloadFile(guide.presignedUrl)}
+ >
+ Download
+
+
+ )}
+
+
+ );
+}
+
+export default GuidesList;
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/pages/Home.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/pages/Home.tsx
new file mode 100644
index 000000000..dea2ea6cb
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/pages/Home.tsx
@@ -0,0 +1,112 @@
+import { Button } from "@/components/ui/button";
+import { Check, Download, Settings, Smartphone } from "lucide-react";
+import { typography } from "../config/site";
+
+export default function Home() {
+ return (
+ <>
+
+
+
+
+
+
+
+ Help democracy by reporting voting irregularities
+
+
+ Streamline your workflow, boost productivity, and achieve
+ more together. Start your journey today.
+
+
+
+ Get Started
+
+
+ Learn more
+
+
+
+
+
+
+
+
+
+
+
+ Get involved
+
+
+ Follow these simple steps to get started with our
+ application and make the most of its features.
+
+
+
+
+
+
+
+
+
+
+
+ Step 1: Select your country
+
+
+ Visit the App Store or Google Play Store and search for
+ our app. Download and install it on your device.
+
+
+
+
+
+
+
+
+
+ Step 2: Select election
+
+
+ Open the app and follow the prompts to create a new
+ account or sign in with your existing credentials.
+
+
+
+
+
+
+
+
+
+ Step 3: Configure Your Settings
+
+
+ Customize your profile and preferences in the settings
+ menu to personalize your experience.
+
+
+
+
+
+
+
+
+
+ Step 4: Start Using the App
+
+
+ You're all set! Explore the features and functionality
+ of the app to get the most out of your experience.
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/pages/NotFound.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/pages/NotFound.tsx
new file mode 100644
index 000000000..d6f9386c4
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/pages/NotFound.tsx
@@ -0,0 +1,33 @@
+import { Button } from "@/components/ui/button";
+import { Link } from "@tanstack/react-router";
+import { FileQuestion } from "lucide-react";
+import { typography } from "../config/site";
+
+export default function NotFound() {
+ return (
+
+
+
+
+
+
+ 404
+
+
+ Page not found
+
+
+ Sorry, we couldn't find the page you're looking for. It might have
+ been moved, deleted, or never existed.
+
+
+
+
+
+ Return Home
+
+
+
+
+ );
+}
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/pages/NotificationDetails.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/pages/NotificationDetails.tsx
new file mode 100644
index 000000000..bf6eb22b9
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/pages/NotificationDetails.tsx
@@ -0,0 +1,27 @@
+import Notification from "@/components/Notification";
+
+import { Spinner } from "@/components/Spinner";
+import { useNotifications } from "@/queries/use-notifications";
+import { Route } from "@/routes/notifications/$notificationId";
+import { notFound } from "@tanstack/react-router";
+
+function NotificationDetails() {
+ const { notificationId } = Route.useParams();
+ const { data: notification, isLoading } = useNotifications((notification) =>
+ notification.notifications.find((n) => n.id == notificationId)
+ );
+ if (isLoading) return ;
+
+ if (!notification) throw notFound();
+
+ return (
+
+ );
+}
+
+export default NotificationDetails;
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/pages/NotificationsList.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/pages/NotificationsList.tsx
new file mode 100644
index 000000000..d86a3b65d
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/pages/NotificationsList.tsx
@@ -0,0 +1,28 @@
+import Notification from "@/components/Notification";
+import { useNotifications } from "@/queries/use-notifications";
+import { typography } from "../config/site";
+
+function NotificationsList() {
+ const { data: notification } = useNotifications();
+
+ return (
+
+
+ Notifications from {notification?.ngoName}
+
+
+ {notification?.notifications.map((notification) => (
+
+ ))}
+
+
+ );
+}
+
+export default NotificationsList;
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/pages/ReportingForm.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/pages/ReportingForm.tsx
new file mode 100644
index 000000000..9817f2102
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/pages/ReportingForm.tsx
@@ -0,0 +1,145 @@
+import ReportAnswersStep from "@/components/ReportAnswersStep";
+import ReportLocationStep, {
+ locationSchema,
+} from "@/components/ReportLocationStep";
+import ReportReviewStep from "@/components/ReportReviewStep";
+import { Button } from "@/components/ui/button";
+import { Form } from "@/components/ui/form";
+import { defineStepper } from "@/components/ui/stepper";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm, type FieldValues } from "react-hook-form";
+
+const {
+ StepperProvider,
+ StepperControls,
+ StepperNavigation,
+ StepperStep,
+ StepperTitle,
+ useStepper,
+} = defineStepper(
+ {
+ id: "location",
+ title: "Location",
+ Component: ReportLocationStep,
+ schema: locationSchema,
+ },
+ {
+ id: "answers",
+ title: "Answers",
+ Component: ReportAnswersStep,
+ schema: undefined,
+ },
+ {
+ id: "review",
+ title: "Review",
+ Component: ReportReviewStep,
+ schema: undefined,
+ }
+);
+
+export default function SubmitCitizenReport() {
+ return (
+
+
+
+ );
+}
+
+const CitizenReportStepperComponent = () => {
+ const methods = useStepper();
+
+ const form = useForm({
+ mode: "onChange",
+ resolver: methods.current.schema
+ ? zodResolver(methods.current.schema)
+ : undefined,
+ // Pre-populate with any existing values for this step
+ defaultValues: {
+ selectedLevel1: "",
+ selectedLevel2: "",
+ selectedLevel3: "",
+ selectedLevel4: "",
+ selectedLevel5: "",
+ locationId: "",
+ },
+ });
+
+ // // Reset form when step changes
+ // React.useEffect(() => {
+ // form.reset(formValues[methods.current.id] || {});
+ // }, [methods.current.id, form.reset, formValues]);
+
+ const onSubmit = (values: FieldValues) => {
+ console.log(`Form values for step ${methods.current.id}:`, values);
+
+ // Move to next step if not on the last step
+ // if (!methods.isLast) {
+ // methods.next();
+ // }
+ };
+
+ return (
+
+
+ );
+};
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/pages/ReportingFormsList.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/pages/ReportingFormsList.tsx
new file mode 100644
index 000000000..41ce99a5a
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/pages/ReportingFormsList.tsx
@@ -0,0 +1,54 @@
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { formsOptions } from "@/queries/use-forms";
+import { useSuspenseQuery } from "@tanstack/react-query";
+import { Link } from "@tanstack/react-router";
+
+function ReportingFormsList() {
+ const { data: forms } = useSuspenseQuery(formsOptions());
+
+ const sortedForms = [...forms].sort(
+ (a, b) => a.displayOrder - b.displayOrder
+ );
+
+ return (
+
+ {sortedForms.map((form) => (
+
+
+ {form.icon && (
+
+ )}
+
+ {form.name[form.defaultLanguage]}
+
+
+
+
+
+ {form.description[form.defaultLanguage]}
+
+
+ {form.numberOfQuestions} questions
+
+
+
+
+
+ Fill in
+
+
+
+
+ ))}
+
+ );
+}
+
+export default ReportingFormsList;
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/pages/ThankYou.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/pages/ThankYou.tsx
new file mode 100644
index 000000000..06a7371bb
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/pages/ThankYou.tsx
@@ -0,0 +1,115 @@
+import { motion } from "framer-motion";
+import { Card, CardContent } from "@/components/ui/card";
+import { typography } from "../config/site";
+
+interface CheckmarkProps {
+ size?: number;
+ strokeWidth?: number;
+ color?: string;
+ className?: string;
+}
+
+const draw = {
+ hidden: { pathLength: 0, opacity: 0 },
+ visible: (i: number) => ({
+ pathLength: 1,
+ opacity: 1,
+ transition: {
+ pathLength: {
+ delay: i * 0.2,
+ type: "spring",
+ duration: 1.5,
+ bounce: 0.2,
+ ease: "easeInOut",
+ },
+ opacity: { delay: i * 0.2, duration: 0.2 },
+ },
+ }),
+};
+
+function Checkmark({
+ size = 100,
+ strokeWidth = 2,
+ color = "currentColor",
+ className = "",
+}: CheckmarkProps) {
+ return (
+
+ Animated Checkmark
+
+
+
+ );
+}
+
+export default function ThankYou() {
+ return (
+
+
+
+
+
+
+
+
+
+ Thank you for your submission
+
+
+ We will investigate and will contact you if you selected that
+
+
+
+ );
+}
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/queries/use-forms.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/queries/use-forms.tsx
new file mode 100644
index 000000000..1dc8caf04
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/queries/use-forms.tsx
@@ -0,0 +1,37 @@
+import { getFormById, getForms } from "@/api/get-forms";
+import type { FormModel } from "@/common/types";
+import { queryClient } from "@/main";
+import { queryOptions, useQuery } from "@tanstack/react-query";
+const STALE_TIME = 1000 * 60 * 15; // 15 minutes
+
+export const formsOptions = () => {
+ return queryOptions({
+ queryKey: ["forms"],
+ placeholderData: [],
+ queryFn: () => getForms(),
+ staleTime: STALE_TIME,
+ });
+};
+
+export const useForms = () => {
+ return useQuery(formsOptions());
+};
+
+export const formQueryOptions = (id: string) => {
+ return queryOptions({
+ queryKey: ["forms", id],
+ queryFn: () => getFormById(id),
+ staleTime: STALE_TIME,
+ initialData: () => {
+ const formData = (
+ queryClient.getQueryData(["forms"]) as FormModel[]
+ )?.find((form) => form.id === id);
+
+ return formData;
+ },
+ });
+};
+
+export const useFormById = (id: string) => {
+ return useQuery(formQueryOptions(id));
+};
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/queries/use-guides.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/queries/use-guides.tsx
new file mode 100644
index 000000000..fc16d267b
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/queries/use-guides.tsx
@@ -0,0 +1,16 @@
+import { getGuides } from "@/api/get-guides";
+import type { GuideModel } from "@/common/types";
+import { electionRoundId } from "@/lib/utils";
+import { skipToken, useQuery } from "@tanstack/react-query";
+
+export const useGuides = ,>(
+ select?: (elections: Array) => TResult
+) => {
+ return useQuery({
+ queryKey: ["guides"],
+ placeholderData: [],
+ queryFn: electionRoundId ? () => getGuides() : skipToken,
+ select,
+ staleTime: Infinity,
+ });
+};
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/queries/use-locations.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/queries/use-locations.tsx
new file mode 100644
index 000000000..ce4b6edc3
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/queries/use-locations.tsx
@@ -0,0 +1,15 @@
+import { getLocations } from "@/api/get-locations";
+import { queryOptions, useQuery } from "@tanstack/react-query";
+
+export const locationsOptions = () => {
+ return queryOptions({
+ queryKey: ["locations"],
+ placeholderData: {},
+ queryFn: () => getLocations(),
+ staleTime: Infinity,
+ });
+};
+
+export const useLocations = () => {
+ return useQuery(locationsOptions());
+};
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/queries/use-notifications.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/queries/use-notifications.tsx
new file mode 100644
index 000000000..87ba00e5e
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/queries/use-notifications.tsx
@@ -0,0 +1,16 @@
+import { getNotifications } from "@/api/get-notifications";
+import type { NotificationsModel } from "@/common/types";
+import { electionRoundId } from "@/lib/utils";
+import { skipToken, useQuery } from "@tanstack/react-query";
+const STALE_TIME = 1000 * 60 * 15; // 15 minutes
+
+export const useNotifications = (
+ select?: (elections: NotificationsModel) => TResult
+) => {
+ return useQuery({
+ queryKey: ["notifications"],
+ queryFn: electionRoundId ? () => getNotifications() : skipToken,
+ select,
+ staleTime: STALE_TIME,
+ });
+};
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/reportWebVitals.ts b/web-citizen-reporting/web-citizen-reporting-template/src/reportWebVitals.ts
new file mode 100644
index 000000000..16b66b5f6
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/reportWebVitals.ts
@@ -0,0 +1,13 @@
+const reportWebVitals = (onPerfEntry?: () => void) => {
+ if (onPerfEntry && onPerfEntry instanceof Function) {
+ import('web-vitals').then(({ onCLS, onINP, onFCP, onLCP, onTTFB }) => {
+ onCLS(onPerfEntry)
+ onINP(onPerfEntry)
+ onFCP(onPerfEntry)
+ onLCP(onPerfEntry)
+ onTTFB(onPerfEntry)
+ })
+ }
+}
+
+export default reportWebVitals
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/routeTree.gen.ts b/web-citizen-reporting/web-citizen-reporting-template/src/routeTree.gen.ts
new file mode 100644
index 000000000..4bc0cff59
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/routeTree.gen.ts
@@ -0,0 +1,327 @@
+/* eslint-disable */
+
+// @ts-nocheck
+
+// noinspection JSUnusedGlobalSymbols
+
+// This file was automatically generated by TanStack Router.
+// You should NOT make any changes in this file as it will be overwritten.
+// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
+
+// Import Routes
+
+import { Route as rootRoute } from './routes/__root'
+import { Route as TypographyImport } from './routes/typography'
+import { Route as ThankYouImport } from './routes/thank-you'
+import { Route as AboutImport } from './routes/about'
+import { Route as IndexImport } from './routes/index'
+import { Route as NotificationsIndexImport } from './routes/notifications/index'
+import { Route as GuidesIndexImport } from './routes/guides/index'
+import { Route as FormsIndexImport } from './routes/forms/index'
+import { Route as NotificationsNotificationIdImport } from './routes/notifications/$notificationId'
+import { Route as GuidesGuideIdImport } from './routes/guides/$guideId'
+import { Route as FormsFormIdImport } from './routes/forms/$formId'
+
+// Create/Update Routes
+
+const TypographyRoute = TypographyImport.update({
+ id: '/typography',
+ path: '/typography',
+ getParentRoute: () => rootRoute,
+} as any)
+
+const ThankYouRoute = ThankYouImport.update({
+ id: '/thank-you',
+ path: '/thank-you',
+ getParentRoute: () => rootRoute,
+} as any)
+
+const AboutRoute = AboutImport.update({
+ id: '/about',
+ path: '/about',
+ getParentRoute: () => rootRoute,
+} as any)
+
+const IndexRoute = IndexImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRoute,
+} as any)
+
+const NotificationsIndexRoute = NotificationsIndexImport.update({
+ id: '/notifications/',
+ path: '/notifications/',
+ getParentRoute: () => rootRoute,
+} as any)
+
+const GuidesIndexRoute = GuidesIndexImport.update({
+ id: '/guides/',
+ path: '/guides/',
+ getParentRoute: () => rootRoute,
+} as any)
+
+const FormsIndexRoute = FormsIndexImport.update({
+ id: '/forms/',
+ path: '/forms/',
+ getParentRoute: () => rootRoute,
+} as any)
+
+const NotificationsNotificationIdRoute =
+ NotificationsNotificationIdImport.update({
+ id: '/notifications/$notificationId',
+ path: '/notifications/$notificationId',
+ getParentRoute: () => rootRoute,
+ } as any)
+
+const GuidesGuideIdRoute = GuidesGuideIdImport.update({
+ id: '/guides/$guideId',
+ path: '/guides/$guideId',
+ getParentRoute: () => rootRoute,
+} as any)
+
+const FormsFormIdRoute = FormsFormIdImport.update({
+ id: '/forms/$formId',
+ path: '/forms/$formId',
+ getParentRoute: () => rootRoute,
+} as any)
+
+// Populate the FileRoutesByPath interface
+
+declare module '@tanstack/react-router' {
+ interface FileRoutesByPath {
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexImport
+ parentRoute: typeof rootRoute
+ }
+ '/about': {
+ id: '/about'
+ path: '/about'
+ fullPath: '/about'
+ preLoaderRoute: typeof AboutImport
+ parentRoute: typeof rootRoute
+ }
+ '/thank-you': {
+ id: '/thank-you'
+ path: '/thank-you'
+ fullPath: '/thank-you'
+ preLoaderRoute: typeof ThankYouImport
+ parentRoute: typeof rootRoute
+ }
+ '/typography': {
+ id: '/typography'
+ path: '/typography'
+ fullPath: '/typography'
+ preLoaderRoute: typeof TypographyImport
+ parentRoute: typeof rootRoute
+ }
+ '/forms/$formId': {
+ id: '/forms/$formId'
+ path: '/forms/$formId'
+ fullPath: '/forms/$formId'
+ preLoaderRoute: typeof FormsFormIdImport
+ parentRoute: typeof rootRoute
+ }
+ '/guides/$guideId': {
+ id: '/guides/$guideId'
+ path: '/guides/$guideId'
+ fullPath: '/guides/$guideId'
+ preLoaderRoute: typeof GuidesGuideIdImport
+ parentRoute: typeof rootRoute
+ }
+ '/notifications/$notificationId': {
+ id: '/notifications/$notificationId'
+ path: '/notifications/$notificationId'
+ fullPath: '/notifications/$notificationId'
+ preLoaderRoute: typeof NotificationsNotificationIdImport
+ parentRoute: typeof rootRoute
+ }
+ '/forms/': {
+ id: '/forms/'
+ path: '/forms'
+ fullPath: '/forms'
+ preLoaderRoute: typeof FormsIndexImport
+ parentRoute: typeof rootRoute
+ }
+ '/guides/': {
+ id: '/guides/'
+ path: '/guides'
+ fullPath: '/guides'
+ preLoaderRoute: typeof GuidesIndexImport
+ parentRoute: typeof rootRoute
+ }
+ '/notifications/': {
+ id: '/notifications/'
+ path: '/notifications'
+ fullPath: '/notifications'
+ preLoaderRoute: typeof NotificationsIndexImport
+ parentRoute: typeof rootRoute
+ }
+ }
+}
+
+// Create and export the route tree
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+ '/about': typeof AboutRoute
+ '/thank-you': typeof ThankYouRoute
+ '/typography': typeof TypographyRoute
+ '/forms/$formId': typeof FormsFormIdRoute
+ '/guides/$guideId': typeof GuidesGuideIdRoute
+ '/notifications/$notificationId': typeof NotificationsNotificationIdRoute
+ '/forms': typeof FormsIndexRoute
+ '/guides': typeof GuidesIndexRoute
+ '/notifications': typeof NotificationsIndexRoute
+}
+
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+ '/about': typeof AboutRoute
+ '/thank-you': typeof ThankYouRoute
+ '/typography': typeof TypographyRoute
+ '/forms/$formId': typeof FormsFormIdRoute
+ '/guides/$guideId': typeof GuidesGuideIdRoute
+ '/notifications/$notificationId': typeof NotificationsNotificationIdRoute
+ '/forms': typeof FormsIndexRoute
+ '/guides': typeof GuidesIndexRoute
+ '/notifications': typeof NotificationsIndexRoute
+}
+
+export interface FileRoutesById {
+ __root__: typeof rootRoute
+ '/': typeof IndexRoute
+ '/about': typeof AboutRoute
+ '/thank-you': typeof ThankYouRoute
+ '/typography': typeof TypographyRoute
+ '/forms/$formId': typeof FormsFormIdRoute
+ '/guides/$guideId': typeof GuidesGuideIdRoute
+ '/notifications/$notificationId': typeof NotificationsNotificationIdRoute
+ '/forms/': typeof FormsIndexRoute
+ '/guides/': typeof GuidesIndexRoute
+ '/notifications/': typeof NotificationsIndexRoute
+}
+
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths:
+ | '/'
+ | '/about'
+ | '/thank-you'
+ | '/typography'
+ | '/forms/$formId'
+ | '/guides/$guideId'
+ | '/notifications/$notificationId'
+ | '/forms'
+ | '/guides'
+ | '/notifications'
+ fileRoutesByTo: FileRoutesByTo
+ to:
+ | '/'
+ | '/about'
+ | '/thank-you'
+ | '/typography'
+ | '/forms/$formId'
+ | '/guides/$guideId'
+ | '/notifications/$notificationId'
+ | '/forms'
+ | '/guides'
+ | '/notifications'
+ id:
+ | '__root__'
+ | '/'
+ | '/about'
+ | '/thank-you'
+ | '/typography'
+ | '/forms/$formId'
+ | '/guides/$guideId'
+ | '/notifications/$notificationId'
+ | '/forms/'
+ | '/guides/'
+ | '/notifications/'
+ fileRoutesById: FileRoutesById
+}
+
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+ AboutRoute: typeof AboutRoute
+ ThankYouRoute: typeof ThankYouRoute
+ TypographyRoute: typeof TypographyRoute
+ FormsFormIdRoute: typeof FormsFormIdRoute
+ GuidesGuideIdRoute: typeof GuidesGuideIdRoute
+ NotificationsNotificationIdRoute: typeof NotificationsNotificationIdRoute
+ FormsIndexRoute: typeof FormsIndexRoute
+ GuidesIndexRoute: typeof GuidesIndexRoute
+ NotificationsIndexRoute: typeof NotificationsIndexRoute
+}
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+ AboutRoute: AboutRoute,
+ ThankYouRoute: ThankYouRoute,
+ TypographyRoute: TypographyRoute,
+ FormsFormIdRoute: FormsFormIdRoute,
+ GuidesGuideIdRoute: GuidesGuideIdRoute,
+ NotificationsNotificationIdRoute: NotificationsNotificationIdRoute,
+ FormsIndexRoute: FormsIndexRoute,
+ GuidesIndexRoute: GuidesIndexRoute,
+ NotificationsIndexRoute: NotificationsIndexRoute,
+}
+
+export const routeTree = rootRoute
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
+
+/* ROUTE_MANIFEST_START
+{
+ "routes": {
+ "__root__": {
+ "filePath": "__root.tsx",
+ "children": [
+ "/",
+ "/about",
+ "/thank-you",
+ "/typography",
+ "/forms/$formId",
+ "/guides/$guideId",
+ "/notifications/$notificationId",
+ "/forms/",
+ "/guides/",
+ "/notifications/"
+ ]
+ },
+ "/": {
+ "filePath": "index.tsx"
+ },
+ "/about": {
+ "filePath": "about.tsx"
+ },
+ "/thank-you": {
+ "filePath": "thank-you.tsx"
+ },
+ "/typography": {
+ "filePath": "typography.tsx"
+ },
+ "/forms/$formId": {
+ "filePath": "forms/$formId.tsx"
+ },
+ "/guides/$guideId": {
+ "filePath": "guides/$guideId.tsx"
+ },
+ "/notifications/$notificationId": {
+ "filePath": "notifications/$notificationId.tsx"
+ },
+ "/forms/": {
+ "filePath": "forms/index.tsx"
+ },
+ "/guides/": {
+ "filePath": "guides/index.tsx"
+ },
+ "/notifications/": {
+ "filePath": "notifications/index.tsx"
+ }
+ }
+}
+ROUTE_MANIFEST_END */
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/routes/__root.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/routes/__root.tsx
new file mode 100644
index 000000000..1e53cb0ee
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/routes/__root.tsx
@@ -0,0 +1,48 @@
+import {
+ Outlet,
+ createRootRouteWithContext,
+ useLocation,
+ useRouterState,
+} from "@tanstack/react-router";
+
+import Footer from "@/components/Footer";
+import { SiteHeader } from "@/components/SiteHeader";
+import { Spinner } from "@/components/Spinner";
+import NotFound from "@/pages/NotFound";
+import type { QueryClient } from "@tanstack/react-query";
+
+function RouterSpinner() {
+ const isLoading = useRouterState({ select: (s) => s.status === "pending" });
+ return ;
+}
+
+function RootComponent() {
+ const pathname = useLocation({
+ select: (location) => location.pathname,
+ });
+ const isFooterHidden = pathname === "/thank-you";
+
+ return (
+ <>
+
+
+
+
+ {!isFooterHidden &&
}
+
+
+ >
+ );
+}
+
+export const Route = createRootRouteWithContext<{
+ queryClient: QueryClient;
+}>()({
+ component: () => ,
+ notFoundComponent: () => ,
+});
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/routes/about.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/routes/about.tsx
new file mode 100644
index 000000000..fe521729a
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/routes/about.tsx
@@ -0,0 +1,6 @@
+import About from "@/pages/About";
+import { createFileRoute } from "@tanstack/react-router";
+
+export const Route = createFileRoute("/about")({
+ component: About,
+});
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/routes/forms/$formId.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/routes/forms/$formId.tsx
new file mode 100644
index 000000000..f98d0e657
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/routes/forms/$formId.tsx
@@ -0,0 +1,33 @@
+import { currentFormLanguageAtom } from "@/features/forms/atoms";
+import NotFound from "@/pages/NotFound";
+import SubmitCitizenReport from "@/pages/ReportingForm";
+import { formQueryOptions } from "@/queries/use-forms";
+import { useSuspenseQuery } from "@tanstack/react-query";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { useSetAtom } from "jotai";
+import { useEffect } from "react";
+
+export const Route = createFileRoute("/forms/$formId")({
+ loader: async (opts) => {
+ const { formId } = opts.params;
+ const formData = await opts.context.queryClient.ensureQueryData(
+ formQueryOptions(formId)
+ );
+
+ if (!formData) throw notFound();
+ return formData;
+ },
+ component: () => {
+ const { formId } = Route.useParams();
+ const { data } = useSuspenseQuery(formQueryOptions(formId));
+ const setCurrentLanguage = useSetAtom(currentFormLanguageAtom);
+
+ useEffect(() => {
+ if (!data?.defaultLanguage) return;
+ setCurrentLanguage(data.defaultLanguage);
+ }, [data]);
+
+ return ;
+ },
+ notFoundComponent: NotFound,
+});
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/routes/forms/index.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/routes/forms/index.tsx
new file mode 100644
index 000000000..135669cba
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/routes/forms/index.tsx
@@ -0,0 +1,8 @@
+import ReportingFormsList from "@/pages/ReportingFormsList";
+import { formsOptions } from "@/queries/use-forms";
+import { createFileRoute } from "@tanstack/react-router";
+
+export const Route = createFileRoute("/forms/")({
+ loader: (opts) => opts.context.queryClient.ensureQueryData(formsOptions()),
+ component: ReportingFormsList,
+});
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/routes/guides/$guideId.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/routes/guides/$guideId.tsx
new file mode 100644
index 000000000..d490e4ef5
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/routes/guides/$guideId.tsx
@@ -0,0 +1,8 @@
+import GuideDetails from "@/pages/GuideDetails";
+import NotFound from "@/pages/NotFound";
+import { createFileRoute } from "@tanstack/react-router";
+
+export const Route = createFileRoute("/guides/$guideId")({
+ component: GuideDetails,
+ notFoundComponent: NotFound,
+});
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/routes/guides/index.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/routes/guides/index.tsx
new file mode 100644
index 000000000..9e0bdad51
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/routes/guides/index.tsx
@@ -0,0 +1,6 @@
+import GuidesList from "@/pages/GuidesList";
+import { createFileRoute } from "@tanstack/react-router";
+
+export const Route = createFileRoute("/guides/")({
+ component: GuidesList,
+});
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/routes/index.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/routes/index.tsx
new file mode 100644
index 000000000..d80b2fab0
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/routes/index.tsx
@@ -0,0 +1,6 @@
+import Home from "@/pages/Home";
+import { createFileRoute } from "@tanstack/react-router";
+
+export const Route = createFileRoute("/")({
+ component: Home,
+});
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/routes/notifications/$notificationId.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/routes/notifications/$notificationId.tsx
new file mode 100644
index 000000000..522e0ad56
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/routes/notifications/$notificationId.tsx
@@ -0,0 +1,8 @@
+import NotFound from "@/pages/NotFound";
+import NotificationDetails from "@/pages/NotificationDetails";
+import { createFileRoute } from "@tanstack/react-router";
+
+export const Route = createFileRoute("/notifications/$notificationId")({
+ component: NotificationDetails,
+ notFoundComponent: NotFound,
+});
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/routes/notifications/index.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/routes/notifications/index.tsx
new file mode 100644
index 000000000..4af50b226
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/routes/notifications/index.tsx
@@ -0,0 +1,6 @@
+import NotificationsList from "@/pages/NotificationsList";
+import { createFileRoute } from "@tanstack/react-router";
+
+export const Route = createFileRoute("/notifications/")({
+ component: NotificationsList,
+});
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/routes/thank-you.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/routes/thank-you.tsx
new file mode 100644
index 000000000..bb49d3e8e
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/routes/thank-you.tsx
@@ -0,0 +1,6 @@
+import ThankYou from "@/pages/ThankYou";
+import { createFileRoute } from "@tanstack/react-router";
+
+export const Route = createFileRoute("/thank-you")({
+ component: ThankYou,
+});
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/routes/typography.tsx b/web-citizen-reporting/web-citizen-reporting-template/src/routes/typography.tsx
new file mode 100644
index 000000000..84b8e2b8a
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/routes/typography.tsx
@@ -0,0 +1,126 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { typography } from "../config/site";
+
+export const Route = createFileRoute("/typography")({
+ component: TypographyDemo,
+});
+
+function TypographyDemo() {
+ return (
+
+
+ The Joke Tax Chronicles
+
+
+ Once upon a time, in a far-off land, there was a very lazy king who
+ spent all day lounging on his throne. One day, his advisors came to him
+ with a problem: the kingdom was running out of money.
+
+
+ The King's Plan
+
+
+ The king thought long and hard, and finally came up with
+
+ a brilliant plan
+
+ : he would tax the jokes in the kingdom.
+
+
+ "After all," he said, "everyone enjoys a good joke, so it's only fair
+ that they should pay for the privilege."
+
+
+ The Joke Tax
+
+
+ The king's subjects were not amused. They grumbled and complained, but
+ the king was firm:
+
+
+ 1st level of puns: 5 gold coins
+ 2nd level of jokes: 10 gold coins
+ 3rd level of one-liners : 20 gold coins
+
+
+ As a result, people stopped telling jokes, and the kingdom fell into a
+ gloom. But there was one person who refused to let the king's
+ foolishness get him down: a court jester named Jokester.
+
+
+ Jokester's Revolt
+
+
+ Jokester began sneaking into the castle in the middle of the night and
+ leaving jokes all over the place: under the king's pillow, in his soup,
+ even in the royal toilet. The king was furious, but he couldn't seem to
+ stop Jokester.
+
+
+ And then, one day, the people of the kingdom discovered that the jokes
+ left by Jokester were so funny that they couldn't help but laugh. And
+ once they started laughing, they couldn't stop.
+
+
+ The People's Rebellion
+
+
+ The people of the kingdom, feeling uplifted by the laughter, started to
+ tell jokes and puns again, and soon the entire kingdom was in on the
+ joke.
+
+
+
+
+
+
+ King's Treasury
+
+
+ People's happiness
+
+
+
+
+
+
+ Empty
+
+
+ Overflowing
+
+
+
+
+ Modest
+
+
+ Satisfied
+
+
+
+
+ Full
+
+
+ Ecstatic
+
+
+
+
+
+
+ The king, seeing how much happier his subjects were, realized the error
+ of his ways and repealed the joke tax. Jokester was declared a hero, and
+ the kingdom lived happily ever after.
+
+
+ The moral of the story is: never underestimate the power of a good laugh
+ and always be careful of bad ideas.
+
+
+ );
+}
diff --git a/web-citizen-reporting/web-citizen-reporting-template/src/styles.css b/web-citizen-reporting/web-citizen-reporting-template/src/styles.css
new file mode 100644
index 000000000..5cdb37cfa
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/src/styles.css
@@ -0,0 +1,195 @@
+@import 'tailwindcss';
+
+@plugin "tailwindcss-animate";
+
+@custom-variant dark (&:is(.dark *));
+
+
+:root {
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.141 0.005 285.823);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.141 0.005 285.823);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.141 0.005 285.823);
+ --primary: oklch(0.21 0.006 285.885);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.967 0.001 286.375);
+ --secondary-foreground: oklch(0.21 0.006 285.885);
+ --muted: oklch(0.967 0.001 286.375);
+ --muted-foreground: oklch(0.552 0.016 285.938);
+ --accent: oklch(0.967 0.001 286.375);
+ --accent-foreground: oklch(0.21 0.006 285.885);
+ --destructive: oklch(0.577 0.245 27.325);
+ --destructive-foreground: oklch(0.577 0.245 27.325);
+ --border: oklch(0.92 0.004 286.32);
+ --input: oklch(0.92 0.004 286.32);
+ --ring: oklch(0.871 0.006 286.286);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --radius: 0.625rem;
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.141 0.005 285.823);
+ --sidebar-primary: oklch(0.21 0.006 285.885);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.967 0.001 286.375);
+ --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
+ --sidebar-border: oklch(0.92 0.004 286.32);
+ --sidebar-ring: oklch(0.871 0.006 286.286);
+}
+
+.dark {
+ --background: oklch(0.141 0.005 285.823);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.141 0.005 285.823);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.141 0.005 285.823);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.985 0 0);
+ --primary-foreground: oklch(0.21 0.006 285.885);
+ --secondary: oklch(0.274 0.006 286.033);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.274 0.006 286.033);
+ --muted-foreground: oklch(0.705 0.015 286.067);
+ --accent: oklch(0.274 0.006 286.033);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.396 0.141 25.723);
+ --destructive-foreground: oklch(0.637 0.237 25.331);
+ --border: oklch(0.274 0.006 286.033);
+ --input: oklch(0.274 0.006 286.033);
+ --ring: oklch(0.442 0.017 285.786);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.21 0.006 285.885);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.274 0.006 286.033);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(0.274 0.006 286.033);
+ --sidebar-ring: oklch(0.442 0.017 285.786);
+}
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ html {
+ @apply scroll-smooth;
+ }
+ body {
+ @apply bg-background text-foreground overscroll-none;
+ /* font-feature-settings: "rlig" 1, "calt" 1; */font-synthesis-weight: none;
+ text-rendering: optimizeLegibility;
+ }
+
+ @supports (font: -apple-system-body) and (-webkit-appearance: none) {
+ [data-wrapper] {
+ @apply min-[1800px]:border-t;
+ }
+ }
+
+ /* Custom scrollbar styling. Thanks @pranathiperii. */
+ ::-webkit-scrollbar {
+ width: 5px;
+ }
+ ::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ ::-webkit-scrollbar-thumb {
+ background: hsl(var(--border));
+ border-radius: 5px;
+ }
+ * {
+ scrollbar-width: thin;
+ scrollbar-color: hsl(var(--border)) transparent;
+ }
+}
+
+@layer utilities {
+ .step {
+ counter-increment: step;
+ }
+
+ .step:before {
+ @apply md:absolute w-8 h-8 md:w-9 md:h-9 bg-muted rounded-full font-mono font-medium text-center text-base inline-flex items-center justify-center -indent-px border-4 mr-2 border-background;
+ @apply md:ml-[-50px] md:mt-[-4px];
+ content: counter(step);
+ }
+
+ .chunk-container {
+ @apply shadow-none;
+ }
+
+ .chunk-container::after {
+ content: "";
+ @apply absolute -inset-4 shadow-xl rounded-xl border;
+ }
+
+ /* Hide scrollbar for Chrome, Safari and Opera */
+ .no-scrollbar::-webkit-scrollbar {
+ display: none;
+ }
+ /* Hide scrollbar for IE, Edge and Firefox */
+ .no-scrollbar {
+ -ms-overflow-style: none; /* IE and Edge */
+ scrollbar-width: none; /* Firefox */
+ }
+
+ .border-grid {
+ @apply border-border/50 dark:border-border border-dashed;
+ }
+
+ .container-wrapper {
+ @apply max-w-[1400px] min-[1800px]:max-w-screen-2xl min-[1400px]:border-x border-border/70 dark:border-border mx-auto w-full border-dashed;
+ }
+
+ .container {
+ @apply px-4 xl:px-6 mx-auto max-w-screen-2xl;
+ }
+}
diff --git a/web-citizen-reporting/web-citizen-reporting-template/tsconfig.json b/web-citizen-reporting/web-citizen-reporting-template/tsconfig.json
new file mode 100644
index 000000000..7920df93f
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "include": ["**/*.ts", "**/*.tsx"],
+ "compilerOptions": {
+ "target": "ES2022",
+ "jsx": "react-jsx",
+ "module": "ESNext",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["vite/client"],
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+
+ /* Linting */
+ "skipLibCheck": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"],
+ }
+ }
+}
diff --git a/web-citizen-reporting/web-citizen-reporting-template/vite.config.js b/web-citizen-reporting/web-citizen-reporting-template/vite.config.js
new file mode 100644
index 000000000..c6e7e6afd
--- /dev/null
+++ b/web-citizen-reporting/web-citizen-reporting-template/vite.config.js
@@ -0,0 +1,21 @@
+import { defineConfig } from "vite";
+import viteReact from "@vitejs/plugin-react";
+import tailwindcss from "@tailwindcss/vite";
+
+import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
+import { resolve } from "node:path";
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [TanStackRouterVite({ autoCodeSplitting: true }), viteReact(), tailwindcss()],
+ test: {
+ globals: true,
+ environment: "jsdom",
+ },
+ resolve: {
+ alias: {
+ '@': resolve(__dirname, './src'),
+ },
+ },
+
+});