From 2fe0ee1af5cba453104d0efa5652eefe99654e22 Mon Sep 17 00:00:00 2001 From: sina_sajjadi Date: Tue, 21 Apr 2026 19:06:55 +0330 Subject: [PATCH] feat: add question detail and list pages with dynamic routing - Implemented `QuestionDetailPage` to display individual questions based on slug. - Created `QuestionsListPage` to list all questions with navigation. - Added various question components: `QuestionButton`, `QuestionDate`, `QuestionDropdown`, `QuestionFile`, `QuestionNumber`, `QuestionRadio`, `QuestionSlider`, and `QuestionText`. - Introduced `BookingTermsCard` for displaying terms and conditions. - Added SVG icon for play action. - Integrated `InformationSheet` component for user guidance. - Created mock data for questions and structured data handling in `question-data.ts`. - Developed a utility to add data locators for easier debugging in development. --- .babelrc | 4 + public/assets/images/Frame 1116607110.svg | 3 + public/assets/images/Group 2.svg | 5 + .../assets/images/Inner Plugdsain Iframe.svg | 110 ++++++++ public/assets/images/Vector.svg | 3 + .../assets/images/cuida_history-outline.svg | 11 + .../assets/images/mingcute_user-info-fill.svg | 3 + public/assets/images/stash_play-solid.svg | 9 + src/app/globals.css | 21 ++ src/app/intro/page.tsx | 6 +- src/app/layout.tsx | 14 +- src/app/page.tsx | 44 ++- src/app/questions-list/[slug]/page.tsx | 92 ++++++ src/app/questions-list/page.tsx | 55 ++++ src/components/dev/dev-click-to-component.tsx | 99 +++++++ src/components/dev/locator-paths.ts | 29 ++ .../questions/booking-terms-card.tsx | 29 ++ src/components/questions/question-button.tsx | 30 ++ src/components/questions/question-card.tsx | 119 ++++++++ src/components/questions/question-date.tsx | 11 + .../questions/question-dropdown.tsx | 11 + src/components/questions/question-file.tsx | 11 + src/components/questions/question-number.tsx | 11 + src/components/questions/question-radio.tsx | 11 + src/components/questions/question-slider.tsx | 11 + src/components/questions/question-text.tsx | 11 + src/components/sliders/slider-slide-three.tsx | 58 ++-- src/components/ui/info-progress-card.tsx | 168 ++++++----- src/components/ui/information-sheet.tsx | 206 ++++++++++++++ src/components/utils/page-background.tsx | 4 +- src/data/mock-questions.json | 266 ++++++++++++++++++ src/data/question-data.ts | 83 ++++++ src/plugins/add-data-locator.cjs | 40 +++ 33 files changed, 1485 insertions(+), 103 deletions(-) create mode 100644 .babelrc create mode 100644 public/assets/images/Frame 1116607110.svg create mode 100644 public/assets/images/Group 2.svg create mode 100644 public/assets/images/Inner Plugdsain Iframe.svg create mode 100644 public/assets/images/Vector.svg create mode 100644 public/assets/images/cuida_history-outline.svg create mode 100644 public/assets/images/mingcute_user-info-fill.svg create mode 100644 public/assets/images/stash_play-solid.svg create mode 100644 src/app/questions-list/[slug]/page.tsx create mode 100644 src/app/questions-list/page.tsx create mode 100644 src/components/dev/dev-click-to-component.tsx create mode 100644 src/components/dev/locator-paths.ts create mode 100644 src/components/questions/booking-terms-card.tsx create mode 100644 src/components/questions/question-button.tsx create mode 100644 src/components/questions/question-card.tsx create mode 100644 src/components/questions/question-date.tsx create mode 100644 src/components/questions/question-dropdown.tsx create mode 100644 src/components/questions/question-file.tsx create mode 100644 src/components/questions/question-number.tsx create mode 100644 src/components/questions/question-radio.tsx create mode 100644 src/components/questions/question-slider.tsx create mode 100644 src/components/questions/question-text.tsx create mode 100644 src/components/ui/information-sheet.tsx create mode 100644 src/data/mock-questions.json create mode 100644 src/data/question-data.ts create mode 100644 src/plugins/add-data-locator.cjs diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..fe49131 --- /dev/null +++ b/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["next/babel"], + "plugins": ["./src/plugins/add-data-locator.cjs"] +} diff --git a/public/assets/images/Frame 1116607110.svg b/public/assets/images/Frame 1116607110.svg new file mode 100644 index 0000000..9b9270b --- /dev/null +++ b/public/assets/images/Frame 1116607110.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/images/Group 2.svg b/public/assets/images/Group 2.svg new file mode 100644 index 0000000..33f39ba --- /dev/null +++ b/public/assets/images/Group 2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/assets/images/Inner Plugdsain Iframe.svg b/public/assets/images/Inner Plugdsain Iframe.svg new file mode 100644 index 0000000..a8109dc --- /dev/null +++ b/public/assets/images/Inner Plugdsain Iframe.svg @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/images/Vector.svg b/public/assets/images/Vector.svg new file mode 100644 index 0000000..8b0055c --- /dev/null +++ b/public/assets/images/Vector.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/images/cuida_history-outline.svg b/public/assets/images/cuida_history-outline.svg new file mode 100644 index 0000000..8a00504 --- /dev/null +++ b/public/assets/images/cuida_history-outline.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/assets/images/mingcute_user-info-fill.svg b/public/assets/images/mingcute_user-info-fill.svg new file mode 100644 index 0000000..b4e869d --- /dev/null +++ b/public/assets/images/mingcute_user-info-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/images/stash_play-solid.svg b/public/assets/images/stash_play-solid.svg new file mode 100644 index 0000000..9208652 --- /dev/null +++ b/public/assets/images/stash_play-solid.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/app/globals.css b/src/app/globals.css index 823d594..e147d86 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -9,6 +9,8 @@ --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + --font-arabic: var(--font-amiri); + --font-ryling: var(--font-amiri); --font-faminela: var(--font-faminela-local); } @@ -25,6 +27,25 @@ body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } +html:lang(ar) body, +:lang(ar) { + font-family: var(--font-arabic), "Times New Roman", serif; +} + +.font-arabic, +.font-ryling { + font-family: var(--font-arabic), "Times New Roman", serif; +} + +.font-faminela:lang(ar), +.font-ryling:lang(ar), +[dir="rtl"] .font-faminela, +[dir="rtl"] .font-ryling, +[dir="rtl"].font-faminela, +[dir="rtl"].font-ryling { + font-family: var(--font-arabic), "Times New Roman", serif; +} + .app-shell { width: min(100%, 375px); min-height: 100vh; diff --git a/src/app/intro/page.tsx b/src/app/intro/page.tsx index ac9bb8b..148cafb 100644 --- a/src/app/intro/page.tsx +++ b/src/app/intro/page.tsx @@ -1,6 +1,6 @@ +import Image from "next/image"; import Button from "@/components/ui/button"; import NavigationButton from "@/components/ui/navigation-button"; -import Image from "next/image"; export default function Intro() { return ( @@ -86,9 +86,7 @@ export default function Intro() { />
- +
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ca984b2..610494f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,7 @@ import type { Metadata } from "next"; +import { Amiri } from "next/font/google"; import localFont from "next/font/local"; +import DevClickToComponent from "@/components/dev/dev-click-to-component"; import "./globals.css"; const faminela = localFont({ @@ -8,6 +10,13 @@ const faminela = localFont({ display: "swap", }); +const amiri = Amiri({ + weight: ["400", "700"], + subsets: ["arabic"], + variable: "--font-amiri", + display: "swap", +}); + export const metadata: Metadata = { title: "Create Next App", description: "Generated by create next app", @@ -20,8 +29,11 @@ export default function RootLayout({ }>) { return ( - +
{children}
+ {process.env.NODE_ENV === "development" ? ( + + ) : null} ); diff --git a/src/app/page.tsx b/src/app/page.tsx index 6d3849b..1f71712 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,15 +1,43 @@ import InfoProgressCard from "@/components/ui/info-progress-card"; +import NavigationButton from "@/components/ui/navigation-button"; +import Image from "next/image"; +import QUESTIONS from "@/data/mock-questions.json" export default function Home() { return ( -
-
- +
+
+ +

Profile registration

+ +
+
+
+

Bookings Terms & Conditions

+ arrow up +
+
+
    +
  • You will be contacted by your consultant.
  • +
  • The call may start 10–15 minutes earlier or later than scheduled.
  • +
  • Make sure you are available and in a quiet place at least 10 minutes before the session.
  • +
+
+
+
+ { + QUESTIONS.map((question) => ( + + )) + }
); diff --git a/src/app/questions-list/[slug]/page.tsx b/src/app/questions-list/[slug]/page.tsx new file mode 100644 index 0000000..9511af2 --- /dev/null +++ b/src/app/questions-list/[slug]/page.tsx @@ -0,0 +1,92 @@ +import { notFound } from "next/navigation"; + +import QuestionButton from "@/components/questions/question-button"; +import QuestionDate from "@/components/questions/question-date"; +import QuestionDropdown from "@/components/questions/question-dropdown"; +import QuestionFile from "@/components/questions/question-file"; +import QuestionNumber from "@/components/questions/question-number"; +import QuestionRadio from "@/components/questions/question-radio"; +import QuestionSlider from "@/components/questions/question-slider"; +import QuestionText from "@/components/questions/question-text"; +import Button from "@/components/ui/button"; +import InformationSheet from "@/components/ui/information-sheet"; +import { PageBackground } from "@/components/utils/page-background"; +import { + getQuestionListItemBySlug, + type QuestionField, + questionListItems, +} from "@/data/question-data"; + +export function generateStaticParams() { + return questionListItems.map((item) => ({ + slug: item.slug, + })); +} + +type QuestionDetailPageProps = { + params: Promise<{ + slug: string; + }>; +}; + +function renderQuestion(question: QuestionField) { + switch (question.type) { + case "button": + return ; + case "date": + return ; + case "dropdown": + return ; + case "file": + return ; + case "number": + return ; + case "radio": + return ; + case "slider": + return ; + case "text": + return ; + default: + return null; + } +} + +export default async function QuestionDetailPage({ + params, +}: QuestionDetailPageProps) { + const { slug } = await params; + const item = getQuestionListItemBySlug(slug); + + if (!item) { + notFound(); + } + + return ( + <> + + + +
+
+
+ {item.questions.map((question) => ( +
+ {renderQuestion(question)} +
+ ))} +
+ + +
+
+ + ); +} diff --git a/src/app/questions-list/page.tsx b/src/app/questions-list/page.tsx new file mode 100644 index 0000000..9e6ef19 --- /dev/null +++ b/src/app/questions-list/page.tsx @@ -0,0 +1,55 @@ +import BookingTermsCard from "@/components/questions/booking-terms-card"; +import QuestionCard from "@/components/questions/question-card"; +import Button from "@/components/ui/button"; +import NavigationButton from "@/components/ui/navigation-button"; +import { PageBackground } from "@/components/utils/page-background"; +import { bookingTerms, questionListItems } from "@/data/question-data"; + +export default function QuestionsListPage() { + return ( + <> + + +
+
+
+ +
+
+ +

+ Profile registration +

+ +
+ +
+ +
+ +
+ {questionListItems.map((item) => ( + + ))} +
+ +
+ +
+
+
+ + ); +} diff --git a/src/components/dev/dev-click-to-component.tsx b/src/components/dev/dev-click-to-component.tsx new file mode 100644 index 0000000..ea17ecb --- /dev/null +++ b/src/components/dev/dev-click-to-component.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useEffect } from "react"; + +const IDE_SCHEMES = [ + { + matches: ["antigravity"], + createUrl: (locator: string) => `antigravity://file/${locator}`, + }, + { + matches: ["cursor"], + createUrl: (locator: string) => `cursor://file/${locator}`, + }, + { + matches: ["vscode", "code"], + createUrl: (locator: string) => `vscode://file/${locator}`, + }, + { + matches: ["webstorm", "intellij"], + createUrl: (locator: string) => `webstorm://open?file=${locator}`, + }, + { + matches: ["sublime"], + createUrl: (locator: string) => `subl://open?url=file://${locator}`, + }, + { + matches: ["atom", "nova"], + createUrl: (locator: string) => `atom://open?url=file://${locator}`, + }, +] as const; + +function parseLocator(locator: string) { + const match = locator.match(/^(.*):(\d+|unknown):(\d+|unknown)$/); + + if (!match) { + return { filePath: locator, line: null, column: null }; + } + + const [, filePath, line, column] = match; + + return { + filePath, + line: line === "unknown" ? null : Number(line), + column: column === "unknown" ? null : Number(column), + }; +} + +export function DevClickToComponent() { + useEffect(() => { + const userAgent = navigator.userAgent.toLowerCase(); + const handleClick = (event: MouseEvent) => { + if (!event.altKey) { + return; + } + + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + const locator = target + .closest("[data-locator]") + ?.getAttribute("data-locator"); + + if (!locator) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const { filePath, line, column } = parseLocator(locator); + const positionSuffix = + line === null ? "" : `:${line}${column === null ? "" : `:${column}`}`; + + const ideUrl = + IDE_SCHEMES.find(({ matches }) => + matches.some((match) => userAgent.includes(match)), + )?.createUrl(`${filePath}${positionSuffix}`) ?? + `vscode://file/${filePath}${positionSuffix}`; + + try { + window.location.href = ideUrl; + } catch { + window.open(`file://${filePath}`, "_blank", "noopener,noreferrer"); + } + }; + + document.addEventListener("click", handleClick, true); + + return () => { + document.removeEventListener("click", handleClick, true); + }; + }, []); + + return null; +} + +export default DevClickToComponent; diff --git a/src/components/dev/locator-paths.ts b/src/components/dev/locator-paths.ts new file mode 100644 index 0000000..c854c92 --- /dev/null +++ b/src/components/dev/locator-paths.ts @@ -0,0 +1,29 @@ +export const LOCATORS = { + appHomePage: "D:/sajjadi/marriage/src/app/page.tsx", + appIntroPage: "D:/sajjadi/marriage/src/app/intro/page.tsx", + appQuestionsListPage: "D:/sajjadi/marriage/src/app/questions-list/page.tsx", + appQuestionDetailPage: + "D:/sajjadi/marriage/src/app/questions-list/[slug]/page.tsx", + appSliderPage: "D:/sajjadi/marriage/src/app/slider/page.tsx", + bookingTermsCard: + "D:/sajjadi/marriage/src/components/questions/booking-terms-card.tsx", + questionCard: + "D:/sajjadi/marriage/src/components/questions/question-card.tsx", + sliderPage: "D:/sajjadi/marriage/src/components/sliders/slider-page.tsx", + sliderSlide: "D:/sajjadi/marriage/src/components/sliders/slider-slide.tsx", + sliderSlideOne: + "D:/sajjadi/marriage/src/components/sliders/slider-slide-one.tsx", + sliderSlideTwo: + "D:/sajjadi/marriage/src/components/sliders/slider-slide-two.tsx", + sliderSlideThree: + "D:/sajjadi/marriage/src/components/sliders/slider-slide-three.tsx", + sliderSlideFour: + "D:/sajjadi/marriage/src/components/sliders/slider-slide-four.tsx", + button: "D:/sajjadi/marriage/src/components/ui/button.tsx", + infoProgressCard: + "D:/sajjadi/marriage/src/components/ui/info-progress-card.tsx", + navigationButton: + "D:/sajjadi/marriage/src/components/ui/navigation-button.tsx", + pageBackground: + "D:/sajjadi/marriage/src/components/utils/page-background.tsx", +} as const; diff --git a/src/components/questions/booking-terms-card.tsx b/src/components/questions/booking-terms-card.tsx new file mode 100644 index 0000000..139f9f6 --- /dev/null +++ b/src/components/questions/booking-terms-card.tsx @@ -0,0 +1,29 @@ +import { IoChevronUp } from "react-icons/io5"; + +type BookingTermsCardProps = { + title: string; + items: readonly string[]; +}; + +export function BookingTermsCard({ title, items }: BookingTermsCardProps) { + return ( +
+
+

+ {title} +

+ + +
+ +
    + {items.map((item) => ( +
  • {item}
  • + ))} +
+
+ ); +} + +export default BookingTermsCard; diff --git a/src/components/questions/question-button.tsx b/src/components/questions/question-button.tsx new file mode 100644 index 0000000..de8b5ab --- /dev/null +++ b/src/components/questions/question-button.tsx @@ -0,0 +1,30 @@ +import type { QuestionField } from "@/data/question-data"; +import { IoInformation } from "react-icons/io5"; + +type QuestionButtonProps = { + question: QuestionField; +}; + +export function QuestionButton({ question }: QuestionButtonProps) { + return ( +
+
+

{question.title}

+ {question.tooltip ? ( + + + ) : ( +
+
+

+ {question.description} +

+
+
+ ); +} + +export default QuestionButton; diff --git a/src/components/questions/question-card.tsx b/src/components/questions/question-card.tsx new file mode 100644 index 0000000..b1ae7e7 --- /dev/null +++ b/src/components/questions/question-card.tsx @@ -0,0 +1,119 @@ +import Link from "next/link"; +import type { IconType } from "react-icons"; +import { + IoCheckbox, + IoDocumentText, + IoInformation, + IoPeople, + IoPerson, + IoSchool, +} from "react-icons/io5"; +import type { QuestionCardIcon, QuestionListItem } from "@/data/question-data"; + +type QuestionCardProps = { + item: QuestionListItem; +}; + +const RADIUS = 8; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +const iconMap: Record = { + profile: IoPerson, + education: IoSchool, + details: IoDocumentText, + checklist: IoCheckbox, + contact: IoPeople, +}; + +export function QuestionCard({ item }: QuestionCardProps) { + const normalizedProgress = Math.max(0, Math.min(item.progress, 100)); + const dashOffset = CIRCUMFERENCE - (normalizedProgress / 100) * CIRCUMFERENCE; + const CardIcon = iconMap[item.icon]; + + return ( + +
+
+
+
+ +
+
+

+ {item.title} +

+ {item.required ? ( + + (Required) + + ) : null} +
+

+ Estimate time: {item.estimate} +

+
+ +
+ {item.showInfoBadge ? ( + + + ) : ( +
+
+ + {item.note ? ( +

+ {item.note} +

+ ) : null} +
+ + ); +} + +export default QuestionCard; diff --git a/src/components/questions/question-date.tsx b/src/components/questions/question-date.tsx new file mode 100644 index 0000000..e31f63e --- /dev/null +++ b/src/components/questions/question-date.tsx @@ -0,0 +1,11 @@ +import type { QuestionField } from "@/data/question-data"; + +type QuestionDateProps = { + question: QuestionField; +}; + +export function QuestionDate({ question }: QuestionDateProps) { + return
; +} + +export default QuestionDate; diff --git a/src/components/questions/question-dropdown.tsx b/src/components/questions/question-dropdown.tsx new file mode 100644 index 0000000..0662f25 --- /dev/null +++ b/src/components/questions/question-dropdown.tsx @@ -0,0 +1,11 @@ +import type { QuestionField } from "@/data/question-data"; + +type QuestionDropdownProps = { + question: QuestionField; +}; + +export function QuestionDropdown({ question }: QuestionDropdownProps) { + return
; +} + +export default QuestionDropdown; diff --git a/src/components/questions/question-file.tsx b/src/components/questions/question-file.tsx new file mode 100644 index 0000000..c7c39b3 --- /dev/null +++ b/src/components/questions/question-file.tsx @@ -0,0 +1,11 @@ +import type { QuestionField } from "@/data/question-data"; + +type QuestionFileProps = { + question: QuestionField; +}; + +export function QuestionFile({ question }: QuestionFileProps) { + return
; +} + +export default QuestionFile; diff --git a/src/components/questions/question-number.tsx b/src/components/questions/question-number.tsx new file mode 100644 index 0000000..19a7a17 --- /dev/null +++ b/src/components/questions/question-number.tsx @@ -0,0 +1,11 @@ +import type { QuestionField } from "@/data/question-data"; + +type QuestionNumberProps = { + question: QuestionField; +}; + +export function QuestionNumber({ question }: QuestionNumberProps) { + return
; +} + +export default QuestionNumber; diff --git a/src/components/questions/question-radio.tsx b/src/components/questions/question-radio.tsx new file mode 100644 index 0000000..b091e8d --- /dev/null +++ b/src/components/questions/question-radio.tsx @@ -0,0 +1,11 @@ +import type { QuestionField } from "@/data/question-data"; + +type QuestionRadioProps = { + question: QuestionField; +}; + +export function QuestionRadio({ question }: QuestionRadioProps) { + return
; +} + +export default QuestionRadio; diff --git a/src/components/questions/question-slider.tsx b/src/components/questions/question-slider.tsx new file mode 100644 index 0000000..f330de8 --- /dev/null +++ b/src/components/questions/question-slider.tsx @@ -0,0 +1,11 @@ +import type { QuestionField } from "@/data/question-data"; + +type QuestionSliderProps = { + question: QuestionField; +}; + +export function QuestionSlider({ question }: QuestionSliderProps) { + return
; +} + +export default QuestionSlider; diff --git a/src/components/questions/question-text.tsx b/src/components/questions/question-text.tsx new file mode 100644 index 0000000..317ed59 --- /dev/null +++ b/src/components/questions/question-text.tsx @@ -0,0 +1,11 @@ +import type { QuestionField } from "@/data/question-data"; + +type QuestionTextProps = { + question: QuestionField; +}; + +export function QuestionText({ question }: QuestionTextProps) { + return
; +} + +export default QuestionText; diff --git a/src/components/sliders/slider-slide-three.tsx b/src/components/sliders/slider-slide-three.tsx index b32e54a..bbc78a6 100644 --- a/src/components/sliders/slider-slide-three.tsx +++ b/src/components/sliders/slider-slide-three.tsx @@ -1,30 +1,40 @@ -import { SliderSlide, type SliderSlideProps } from "@/components/sliders/slider-slide"; import Image from "next/image"; +import type { SliderSlideProps } from "@/components/sliders/slider-slide"; export function SliderSlideThree({ index }: SliderSlideProps) { - return ( -
-
-

Submit Process

-

- {index + 1}/4 terms & conditions -

-
+ return ( +
+
+

Submit Process

+

+ {index + 1}/4 terms & conditions +

+
-
-
- man -

Submit Man

-
-
- man -

Submit Woman

-
+
+
+ man +

Submit Man

+
+
+ man +

Submit Woman

-
-
- ); +
+
+ + ); } diff --git a/src/components/ui/info-progress-card.tsx b/src/components/ui/info-progress-card.tsx index 730b440..cbe80cc 100644 --- a/src/components/ui/info-progress-card.tsx +++ b/src/components/ui/info-progress-card.tsx @@ -1,10 +1,14 @@ -import { IoInformation, IoInformationCircle, IoPerson } from "react-icons/io5"; +import Image from "next/image"; +import Link from "next/link"; +import { useId } from "react"; type InfoProgressCardProps = { title: string; - requiredLabel?: string; + requiredLabel: boolean; estimate: string; progress: number; + tooltip?: string; + slug: string; }; const RADIUS = 12; @@ -12,81 +16,115 @@ const CIRCUMFERENCE = 2 * Math.PI * RADIUS; export function InfoProgressCard({ title, - requiredLabel = "(Required)", + requiredLabel = false, estimate, progress, + tooltip, + slug, }: InfoProgressCardProps) { + const tooltipId = useId(); const normalizedProgress = Math.max(0, Math.min(progress, 100)); const dashOffset = CIRCUMFERENCE - (normalizedProgress / 100) * CIRCUMFERENCE; return ( -
-
-
+ +
+
+ User Info +
-
-
-
-

- {title}{" "} - - {requiredLabel} - -

-

- Estimate time: {estimate} -

-
+
+
+
+

+ {title}{" "} + + {requiredLabel && "(Required)"} + +

+
- -
+ {tooltip ? ( +
+ + +
+ ) : null} +
-
-
- - - {normalizedProgress}% - +
+

+ Estimate time: {estimate} +

+
+ {normalizedProgress < 100 ? ( + + ) : ( + checked + )} + + {normalizedProgress}% + +
-
-
+
+ ); } diff --git a/src/components/ui/information-sheet.tsx b/src/components/ui/information-sheet.tsx new file mode 100644 index 0000000..49b5f56 --- /dev/null +++ b/src/components/ui/information-sheet.tsx @@ -0,0 +1,206 @@ +"use client"; + +import Image, { type StaticImageData } from "next/image"; +import type { HTMLAttributes, ReactNode } from "react"; +import { useEffect, useState } from "react"; +import Button from "@/components/ui/button"; + +type InformationSheetPresetIcon = + | "play" + | "warning" + | "coin" + | "stash_play-solid.svg" + | "warning.svg" + | "coin.svg"; + +type InformationSheetIcon = + | InformationSheetPresetIcon + | string + | { + src: string | StaticImageData; + alt?: string; + width?: number; + height?: number; + }; + +export type InformationSheetProps = Omit< + HTMLAttributes, + "children" | "title" +> & { + icon?: InformationSheetIcon; + title: ReactNode; + description?: ReactNode; + buttons?: ReactNode; + closeOnOutside?: boolean; +}; + +const DEFAULT_ICON = { + src: "/assets/images/stash_play-solid.svg", + alt: "Play", + width: 50, + height: 50, +} as const; + +const ICON_PRESETS: Record< + InformationSheetPresetIcon, + { + src: string; + alt: string; + width: number; + height: number; + } +> = { + play: DEFAULT_ICON, + "stash_play-solid.svg": DEFAULT_ICON, + warning: { + src: "/assets/images/Vector.svg", + alt: "Warning", + width: 36, + height: 36, + }, + "warning.svg": { + src: "/assets/images/Vector.svg", + alt: "Warning", + width: 36, + height: 36, + }, + coin: { + src: "/assets/images/Inner Plugdsain Iframe.svg", + alt: "Coin", + width: 50, + height: 50, + }, + "coin.svg": { + src: "/assets/images/Inner Plugdsain Iframe.svg", + alt: "Coin", + width: 50, + height: 50, + }, +}; + +function resolveIcon(icon: InformationSheetIcon | undefined) { + if (!icon) { + return DEFAULT_ICON; + } + + if (typeof icon === "string") { + return ( + ICON_PRESETS[icon as InformationSheetPresetIcon] ?? { + src: icon, + alt: "Information", + width: DEFAULT_ICON.width, + height: DEFAULT_ICON.height, + } + ); + } + + return { + src: icon.src, + alt: icon.alt ?? "Information", + width: icon.width ?? DEFAULT_ICON.width, + height: icon.height ?? DEFAULT_ICON.height, + }; +} + +export function InformationSheet({ + icon, + title, + description, + buttons, + closeOnOutside = true, + className, + ...props +}: InformationSheetProps) { + const [isOpen, setIsOpen] = useState(true); + const resolvedIcon = resolveIcon(icon); + const resolvedButtons = + typeof buttons === "string" ? ( + + ) : ( + buttons + ); + + useEffect(() => { + if (!isOpen) { + return; + } + + const previousBodyOverflow = document.body.style.overflow; + const previousHtmlOverflow = document.documentElement.style.overflow; + + document.body.style.overflow = "hidden"; + document.documentElement.style.overflow = "hidden"; + + return () => { + document.body.style.overflow = previousBodyOverflow; + document.documentElement.style.overflow = previousHtmlOverflow; + }; + }, [isOpen]); + + if (!isOpen) { + return null; + } + + return ( +
{ + if (closeOnOutside && event.target === event.currentTarget) { + setIsOpen(false); + } + }} + onKeyDown={(event) => { + if ( + closeOnOutside && + event.target === event.currentTarget && + (event.key === "Escape" || event.key === "Enter" || event.key === " ") + ) { + event.preventDefault(); + setIsOpen(false); + } + }} + > +
+
+ {resolvedIcon.alt} + +

+ {title} +

+ + {description ? ( +

{description}

+ ) : null} + + {resolvedButtons ? ( +
+ {resolvedButtons} +
+ ) : null} +
+
+
+ ); +} + +export default InformationSheet; diff --git a/src/components/utils/page-background.tsx b/src/components/utils/page-background.tsx index 6c95ec0..fb66646 100644 --- a/src/components/utils/page-background.tsx +++ b/src/components/utils/page-background.tsx @@ -14,7 +14,9 @@ export function PageBackground({ useEffect(() => { const { body } = document; const previousMode = body.dataset.pageBackground; - const previousImage = body.style.getPropertyValue("--page-background-image"); + const previousImage = body.style.getPropertyValue( + "--page-background-image", + ); if (disabled) { body.dataset.pageBackground = "none"; diff --git a/src/data/mock-questions.json b/src/data/mock-questions.json new file mode 100644 index 0000000..d8e5f9b --- /dev/null +++ b/src/data/mock-questions.json @@ -0,0 +1,266 @@ +[ + { + "title": "Personal Information", + "icon": "user-circle", + "slug": "personal-information", + "required": true, + "estimateTime": "5 minutes", + "tooltip": "Basic identity and profile details for the applicant.", + "progress": 35, + "description": "Collects general personal details to start the marriage application flow.", + "questions": [ + { + "title": "Full Name", + "type": "text", + "required": true, + "description": "Enter your legal full name as it appears on official documents.", + "tooltip": "Use your passport or national ID spelling.", + "extras": { + "placeHolder": "e.g. Sara Ahmadi", + "range": [0, 0], + "options": [] + } + }, + { + "title": "Date of Birth", + "type": "date", + "required": true, + "description": "Select your date of birth.", + "tooltip": "Make sure the date matches your official record.", + "extras": { + "placeHolder": "YYYY-MM-DD", + "range": [0, 0], + "options": [] + } + }, + { + "title": "Gender", + "type": "radio", + "required": true, + "description": "Choose the gender option that applies to you.", + "tooltip": "Only one option can be selected.", + "extras": { + "placeHolder": "", + "range": [0, 0], + "options": ["Female", "Male", "Prefer not to say"] + } + }, + { + "title": "Age", + "type": "number", + "required": true, + "description": "Provide your current age in years.", + "tooltip": "Numbers only.", + "extras": { + "placeHolder": "e.g. 29", + "range": [18, 80], + "options": [] + } + } + ] + }, + { + "title": "Preferences", + "icon": "heart-handshake", + "slug": "preferences", + "required": false, + "estimateTime": "7 minutes", + "tooltip": "Relationship expectations and lifestyle preferences.", + "progress": 60, + "description": "Captures values, preferences, and match expectations.", + "questions": [ + { + "title": "Preferred City", + "type": "dropdown", + "required": false, + "description": "Select the city you prefer to live in after marriage.", + "tooltip": "You can use this to help with compatibility matching.", + "extras": { + "placeHolder": "Choose a city", + "range": [0, 0], + "options": ["Tehran", "Isfahan", "Shiraz", "Tabriz", "Mashhad"] + } + }, + { + "title": "Describe Your Ideal Partner", + "type": "text", + "required": false, + "description": "Write a short description of the qualities you value most.", + "tooltip": "Keep it concise and specific.", + "extras": { + "placeHolder": "Kind, family-oriented, emotionally mature...", + "range": [0, 0], + "options": [] + } + }, + { + "title": "Importance of Family Values", + "type": "slider", + "required": true, + "description": "Rate how important family values are to you.", + "tooltip": "Move the slider from low to high importance.", + "extras": { + "placeHolder": "", + "range": [1, 10], + "options": [] + } + }, + { + "title": "Ready to Continue", + "type": "button", + "required": false, + "description": "Confirms that you want to proceed to the next section.", + "tooltip": "This can be used as a UI action trigger.", + "extras": { + "placeHolder": "Continue", + "range": [0, 0], + "options": ["Continue"] + } + } + ] + }, + { + "title": "Documents", + "icon": "file-text", + "slug": "documents", + "required": true, + "estimateTime": "3 minutes", + "tooltip": "Upload supporting documents required for verification.", + "progress": 100, + "description": "Handles document uploads and verification-related information.", + "questions": [ + { + "title": "National ID Upload", + "type": "file", + "required": true, + "description": "Upload a clear image or PDF of your national ID.", + "tooltip": "Accepted formats can be restricted in the UI layer.", + "extras": { + "placeHolder": "Choose a file", + "range": [0, 0], + "options": [".jpg", ".png", ".pdf"] + } + }, + { + "title": "Marriage Timeline", + "type": "dropdown", + "required": false, + "description": "Choose your preferred timeline for marriage.", + "tooltip": "This helps prioritize urgency and compatibility.", + "extras": { + "placeHolder": "Select a timeline", + "range": [0, 0], + "options": ["Within 6 months", "6-12 months", "1-2 years", "Flexible"] + } + } + ] + }, + { + "title": "Question Type Showcase", + "icon": "layout-grid", + "slug": "question-type-showcase", + "required": false, + "estimateTime": "10 minutes", + "tooltip": "A complete sample box that demonstrates every supported question type with all fields populated.", + "progress": 0, + "description": "Includes one example of each question type so the UI can be tested against a complete dataset.", + "questions": [ + { + "title": "Short Biography", + "type": "text", + "required": true, + "description": "Provide a short written introduction.", + "tooltip": "This exercises the text input type.", + "extras": { + "placeHolder": "Write a short introduction about yourself", + "range": [0, 0], + "options": [] + } + }, + { + "title": "Preferred Wedding Date", + "type": "date", + "required": false, + "description": "Select your preferred wedding date.", + "tooltip": "This exercises the date picker type.", + "extras": { + "placeHolder": "YYYY-MM-DD", + "range": [0, 0], + "options": [] + } + }, + { + "title": "Preferred Contact Method", + "type": "radio", + "required": true, + "description": "Choose one preferred contact method.", + "tooltip": "This exercises the radio selection type.", + "extras": { + "placeHolder": "Select one option", + "range": [0, 0], + "options": ["Phone", "WhatsApp", "Email"] + } + }, + { + "title": "Household Size Preference", + "type": "number", + "required": false, + "description": "Enter the household size you are comfortable with.", + "tooltip": "This exercises the number input type.", + "extras": { + "placeHolder": "e.g. 4", + "range": [1, 12], + "options": [] + } + }, + { + "title": "Current City", + "type": "dropdown", + "required": true, + "description": "Choose the city you currently live in.", + "tooltip": "This exercises the dropdown type.", + "extras": { + "placeHolder": "Select your city", + "range": [0, 0], + "options": ["Tehran", "Karaj", "Shiraz", "Mashhad"] + } + }, + { + "title": "Importance of Shared Goals", + "type": "slider", + "required": true, + "description": "Rate how important shared long-term goals are to you.", + "tooltip": "This exercises the slider type.", + "extras": { + "placeHolder": "Move the slider", + "range": [1, 10], + "options": [] + } + }, + { + "title": "Review Answers", + "type": "button", + "required": false, + "description": "Use this action to review the current section.", + "tooltip": "This exercises the button type.", + "extras": { + "placeHolder": "Review", + "range": [0, 0], + "options": ["Review"] + } + }, + { + "title": "Profile Photo Upload", + "type": "file", + "required": false, + "description": "Upload a profile photo or supporting image.", + "tooltip": "This exercises the file upload type.", + "extras": { + "placeHolder": "Choose an image", + "range": [0, 0], + "options": [".jpg", ".jpeg", ".png"] + } + } + ] + } +] diff --git a/src/data/question-data.ts b/src/data/question-data.ts new file mode 100644 index 0000000..580a95c --- /dev/null +++ b/src/data/question-data.ts @@ -0,0 +1,83 @@ +import rawQuestions from "@/data/mock-questions.json"; + +export const bookingTerms = [ + "You will be contacted by your consultant.", + "The call may start 10-15 minutes earlier or later than scheduled.", + "Make sure you are available and in a quiet place at least 10 minutes before the session.", +] as const; + +export type QuestionCardIcon = + | "profile" + | "education" + | "details" + | "checklist" + | "contact"; + +type QuestionExtras = { + placeHolder: string; + range: [number, number]; + options: string[]; +}; + +export type QuestionField = { + title: string; + type: string; + required: boolean; + description: string; + tooltip: string; + extras: QuestionExtras; +}; + +export type QuestionListItem = { + slug: string; + title: string; + estimate: string; + progress: number; + icon: QuestionCardIcon; + required?: boolean; + note?: string; + showInfoBadge?: boolean; + summary: string; + checkpoints: readonly string[]; + tooltip: string; + questions: readonly QuestionField[]; +}; + +type RawQuestionListItem = { + title: string; + icon: string; + slug: string; + required?: boolean; + estimateTime: string; + tooltip: string; + progress: number; + description: string; + questions: QuestionField[]; +}; + +const iconMap: Record = { + "user-circle": "profile", + "heart-handshake": "details", + "file-text": "contact", + "layout-grid": "checklist", +}; + +export const questionListItems: readonly QuestionListItem[] = ( + rawQuestions as RawQuestionListItem[] +).map((item) => ({ + slug: item.slug, + title: item.title, + estimate: item.estimateTime, + progress: item.progress, + icon: iconMap[item.icon] ?? "details", + required: item.required, + showInfoBadge: Boolean(item.tooltip), + summary: item.description, + checkpoints: item.questions.map((question) => question.title), + tooltip: item.tooltip, + questions: item.questions, +})); + +export function getQuestionListItemBySlug(slug: string) { + return questionListItems.find((item) => item.slug === slug); +} diff --git a/src/plugins/add-data-locator.cjs b/src/plugins/add-data-locator.cjs new file mode 100644 index 0000000..af2482b --- /dev/null +++ b/src/plugins/add-data-locator.cjs @@ -0,0 +1,40 @@ +module.exports = function addDataLocator({ types: t }) { + return { + name: "add-data-locator", + visitor: { + JSXOpeningElement(path, state) { + if (process.env.NODE_ENV !== "development") { + return; + } + + const filePath = state.file.opts.filename; + + if (!filePath || filePath.includes("node_modules")) { + return; + } + + const attributeExists = path.node.attributes.some( + (attribute) => + t.isJSXAttribute(attribute) && + t.isJSXIdentifier(attribute.name) && + attribute.name.name === "data-locator", + ); + + if (attributeExists) { + return; + } + + const lineNumber = path.node.loc?.start.line ?? "unknown"; + const columnNumber = path.node.loc?.start.column ?? "unknown"; + const locatorValue = `${filePath}:${lineNumber}:${columnNumber}`; + + path.node.attributes.push( + t.jsxAttribute( + t.jsxIdentifier("data-locator"), + t.stringLiteral(locatorValue), + ), + ); + }, + }, + }; +};