@@ -42,4 +45,4 @@ function Header({
);
}
-export { Header };
+export { Header };
\ No newline at end of file
diff --git a/src/components/layout/LayoutQuestion/LayoutQuestion.tsx b/src/components/layout/LayoutQuestion/LayoutQuestion.tsx
index 8e89e96..e64deb4 100644
--- a/src/components/layout/LayoutQuestion/LayoutQuestion.tsx
+++ b/src/components/layout/LayoutQuestion/LayoutQuestion.tsx
@@ -2,22 +2,16 @@
import { cn } from "@/lib/utils";
import { Header } from "@/components/layout/Header/Header";
-import Typography, {
- TypographyProps,
-} from "@/components/ui/Typography/Typography";
-import {
- BottomActionButton,
- BottomActionButtonProps,
-} from "@/components/widgets/BottomActionButton/BottomActionButton";
-import { useEffect, useRef, useState } from "react";
+import Typography, { TypographyProps } from "@/components/ui/Typography/Typography";
export interface LayoutQuestionProps
extends Omit
, "title" | "content"> {
headerProps?: React.ComponentProps;
- title: TypographyProps<"h2">;
+ title?: TypographyProps<"h2">;
subtitle?: TypographyProps<"p">;
children: React.ReactNode;
- bottomActionButtonProps?: BottomActionButtonProps;
+ contentProps?: React.ComponentProps<"div">;
+ childrenWrapperProps?: React.ComponentProps<"div">;
}
function LayoutQuestion({
@@ -26,32 +20,29 @@ function LayoutQuestion({
title,
subtitle,
children,
- bottomActionButtonProps,
+ contentProps,
+ childrenWrapperProps,
...props
}: LayoutQuestionProps) {
- const bottomActionButtonRef = useRef(null);
- const [bottomActionButtonHeight, setBottomActionButtonHeight] =
- useState(132);
-
- useEffect(() => {
- if (bottomActionButtonRef.current) {
- console.log(bottomActionButtonRef.current.clientHeight);
-
- setBottomActionButtonHeight(bottomActionButtonRef.current.clientHeight);
- }
- }, [bottomActionButtonProps]);
-
return (
{headerProps && }
-
+
+
{title && (
)}
+
{subtitle && (
)}
- {children}
- {bottomActionButtonProps && (
-
- )}
+
+
+ {children}
+
);
}
-export { LayoutQuestion };
+export { LayoutQuestion };
\ No newline at end of file
diff --git a/src/components/templates/Coupon/Coupon.stories.tsx b/src/components/templates/Coupon/Coupon.stories.tsx
new file mode 100644
index 0000000..9700e3a
--- /dev/null
+++ b/src/components/templates/Coupon/Coupon.stories.tsx
@@ -0,0 +1,80 @@
+import { Meta, StoryObj } from "@storybook/nextjs-vite";
+import { Coupon } from "./Coupon";
+import { fn } from "storybook/test";
+import {
+ LayoutQuestion,
+ LayoutQuestionProps,
+} from "@/components/layout/LayoutQuestion/LayoutQuestion";
+
+const layoutQuestionProps: Omit
= {
+ headerProps: {
+ onBack: fn(),
+ },
+ title: {
+ children: "Тебе повезло!",
+ align: "center",
+ },
+ subtitle: {
+ children: "Ты получил специальную эксклюзивную скидку на 94%",
+ align: "center",
+ },
+};
+
+/** Reusable Coupon page Component */
+const meta: Meta = {
+ title: "Templates/Coupon",
+ component: Coupon,
+ tags: ["autodocs"],
+ parameters: {
+ layout: "fullscreen",
+ },
+ args: {
+ couponProps: {
+ title: {
+ children: "Special Offer",
+ },
+ offer: {
+ title: {
+ children: "94% OFF",
+ },
+ description: {
+ children: "Одноразовая эксклюзивная скидка",
+ },
+ },
+ promoCode: {
+ children: "HAIR50",
+ },
+ footer: {
+ children: (
+ <>
+ Скопируйте или нажмите Continue
+ >
+ ),
+ },
+ onCopyPromoCode: fn(),
+ },
+ bottomActionButtonProps: {
+ actionButtonProps: {
+ children: "Continue",
+ onClick: fn(),
+ },
+ },
+ },
+ argTypes: {
+ bottomActionButtonProps: {
+ control: { type: "object" },
+ },
+ },
+ render: (args) => {
+ return (
+
+
+
+ );
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default = {} satisfies Story;
diff --git a/src/components/templates/Coupon/Coupon.tsx b/src/components/templates/Coupon/Coupon.tsx
new file mode 100644
index 0000000..3b3b7d0
--- /dev/null
+++ b/src/components/templates/Coupon/Coupon.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import {
+ BottomActionButton,
+ BottomActionButtonProps,
+} from "@/components/widgets/BottomActionButton/BottomActionButton";
+import { Coupon as CouponWidget } from "@/components/widgets/Coupon/Coupon";
+import { useDynamicSize } from "@/hooks/DOM/useDynamicSize";
+import { cn } from "@/lib/utils";
+
+interface CouponProps extends Omit, "title"> {
+ couponProps: React.ComponentProps;
+ bottomActionButtonProps?: BottomActionButtonProps;
+}
+
+function Coupon({
+ couponProps,
+ bottomActionButtonProps,
+ ...props
+}: CouponProps) {
+ const {
+ height: bottomActionButtonHeight,
+ elementRef: bottomActionButtonRef,
+ } = useDynamicSize({
+ defaultHeight: 132,
+ });
+
+ return (
+
+ {/* {title && (
+
+ )}
+ {subtitle && (
+
+ )} */}
+
+ {bottomActionButtonProps && (
+
+ )}
+
+ );
+}
+
+export { Coupon };
diff --git a/src/components/templates/Email/Email.stories.tsx b/src/components/templates/Email/Email.stories.tsx
new file mode 100644
index 0000000..5e3b30d
--- /dev/null
+++ b/src/components/templates/Email/Email.stories.tsx
@@ -0,0 +1,104 @@
+import { Meta, StoryObj } from "@storybook/nextjs-vite";
+import { Email } from "./Email";
+import { fn } from "storybook/test";
+import {
+ LayoutQuestion,
+ LayoutQuestionProps,
+} from "@/components/layout/LayoutQuestion/LayoutQuestion";
+import Image from "next/image";
+
+const layoutQuestionProps: Omit = {
+ headerProps: {
+ onBack: fn(),
+ },
+ title: {
+ children: "Портрет твоей второй половинки готов! Куда нам его отправить?",
+ align: "center",
+ },
+ contentProps: {
+ className: "pt-0!",
+ },
+};
+
+/** Reusable Email page Component */
+const meta: Meta = {
+ title: "Templates/Email",
+ component: Email,
+ tags: ["autodocs"],
+ parameters: {
+ layout: "fullscreen",
+ },
+ args: {
+ textInputProps: {
+ label: "Email",
+ placeholder: "Enter your Email",
+ type: "email",
+ onChange: fn(),
+ },
+ bottomActionButtonProps: {
+ actionButtonProps: {
+ children: "Continue",
+ onClick: fn(),
+ },
+ },
+ image: (
+
+ ),
+ privacyTermsConsentProps: {
+ privacyPolicy: {
+ children: "Privacy Policy",
+ href: "#privacy-policy",
+ },
+ termsOfUse: {
+ children: "Terms of use",
+ href: "#terms-of-use",
+ },
+ },
+ privacySecurityBannerProps: {
+ text: {
+ children:
+ "Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем.",
+ },
+ },
+ },
+ argTypes: {
+ textInputProps: {
+ control: { type: "object" },
+ },
+ bottomActionButtonProps: {
+ control: { type: "object" },
+ },
+ },
+ render: (args) => {
+ return (
+
+
+
+ );
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default = {} satisfies Story;
+
+export const FemalePortrait = {
+ args: {
+ image: (
+
+ ),
+ },
+} satisfies Story;
diff --git a/src/components/templates/Email/Email.tsx b/src/components/templates/Email/Email.tsx
new file mode 100644
index 0000000..b2f44cf
--- /dev/null
+++ b/src/components/templates/Email/Email.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import { TextInput } from "@/components/ui/TextInput/TextInput";
+import {
+ BottomActionButton,
+ BottomActionButtonProps,
+} from "@/components/widgets/BottomActionButton/BottomActionButton";
+import { useDynamicSize } from "@/hooks/DOM/useDynamicSize";
+import { cn } from "@/lib/utils";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { useEffect, useState } from "react";
+import PrivacyTermsConsent from "@/components/widgets/PrivacyTermsConsent/PrivacyTermsConsent";
+import PrivacySecurityBanner from "@/components/widgets/PrivacySecurityBanner/PrivacySecurityBanner";
+
+const formSchema = z.object({
+ email: z.email({
+ message: "Please enter a valid email address",
+ }),
+});
+
+interface EmailProps extends Omit, "title"> {
+ textInputProps: React.ComponentProps;
+ bottomActionButtonProps?: BottomActionButtonProps;
+ image?: React.ReactNode;
+ privacyTermsConsentProps?: React.ComponentProps;
+ privacySecurityBannerProps?: React.ComponentProps<
+ typeof PrivacySecurityBanner
+ >;
+}
+
+function Email({
+ textInputProps,
+ bottomActionButtonProps,
+ image,
+ privacyTermsConsentProps,
+ privacySecurityBannerProps,
+ ...props
+}: EmailProps) {
+ const {
+ height: bottomActionButtonHeight,
+ elementRef: bottomActionButtonRef,
+ } = useDynamicSize({
+ defaultHeight: 132,
+ });
+ const [isTouched, setIsTouched] = useState(false);
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ email: String(textInputProps.value || ""),
+ },
+ });
+
+ useEffect(() => {
+ form.setValue("email", String(textInputProps.value || ""));
+ }, [textInputProps.value, form]);
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const value = e.target.value;
+ form.setValue("email", value);
+ form.trigger("email");
+ textInputProps.onChange?.(e);
+ };
+
+ const isFormValid = form.formState.isValid && form.getValues("email");
+
+ return (
+
+
{
+ setIsTouched(true);
+ form.trigger("email");
+ }}
+ aria-invalid={isTouched && !!form.formState.errors.email}
+ aria-errormessage={
+ isTouched ? form.formState.errors.email?.message : undefined
+ }
+ />
+ {image}
+ {privacySecurityBannerProps && (
+
+ )}
+ {bottomActionButtonProps && (
+
+ )
+ }
+ />
+ )}
+
+ );
+}
+
+export { Email };
diff --git a/src/components/templates/Loaders/Loaders.stories.tsx b/src/components/templates/Loaders/Loaders.stories.tsx
new file mode 100644
index 0000000..214ad2c
--- /dev/null
+++ b/src/components/templates/Loaders/Loaders.stories.tsx
@@ -0,0 +1,108 @@
+import { Meta, StoryObj } from "@storybook/nextjs-vite";
+import { Loaders } from "./Loaders";
+import { fn } from "storybook/test";
+import {
+ LayoutQuestion,
+ LayoutQuestionProps,
+} from "@/components/layout/LayoutQuestion/LayoutQuestion";
+
+const layoutQuestionProps: Omit = {
+ headerProps: {
+ onBack: fn(),
+ },
+ title: {
+ children: "Создаем портрет твоей второй половинки.",
+ align: "center",
+ },
+ contentProps: {
+ className: "pt-5",
+ },
+ childrenWrapperProps: {
+ className: "mt-[57px]",
+ },
+};
+
+/** Reusable Loaders page Component */
+const meta: Meta = {
+ title: "Templates/Loaders",
+ component: Loaders,
+ tags: ["autodocs"],
+ parameters: {
+ layout: "fullscreen",
+ },
+ args: {
+ circularProgressbarsListProps: {
+ progressbarItems: [
+ {
+ processing: {
+ title: { children: "Анализ твоих ответов" },
+ text: {
+ children: "Processing...",
+ },
+ },
+ completed: {
+ title: { children: "Анализ твоих ответов" },
+ text: {
+ children: "Complete",
+ },
+ },
+ },
+ {
+ processing: {
+ title: { children: "Portrait of the Soulmate" },
+ text: {
+ children: "Processing...",
+ },
+ },
+ completed: {
+ title: { children: "Portrait of the Soulmate" },
+ text: {
+ children: "Complete",
+ },
+ },
+ },
+ {
+ processing: {
+ title: { children: "Portrait of the Soulmate" },
+ text: {
+ children: "Processing...",
+ },
+ },
+ completed: {
+ title: { children: "Connection Insights" },
+ text: {
+ children: "Complete",
+ },
+ },
+ },
+ ],
+ onAnimationEnd: fn(),
+ },
+ bottomActionButtonProps: {
+ actionButtonProps: {
+ children: "Continue",
+ onClick: fn(),
+ },
+ },
+ },
+ argTypes: {
+ circularProgressbarsListProps: {
+ control: { type: "object" },
+ },
+ bottomActionButtonProps: {
+ control: { type: "object" },
+ },
+ },
+ render: (args) => {
+ return (
+
+
+
+ );
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default = {} satisfies Story;
diff --git a/src/components/templates/Loaders/Loaders.tsx b/src/components/templates/Loaders/Loaders.tsx
new file mode 100644
index 0000000..d19890a
--- /dev/null
+++ b/src/components/templates/Loaders/Loaders.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import {
+ BottomActionButton,
+ BottomActionButtonProps,
+} from "@/components/widgets/BottomActionButton/BottomActionButton";
+import CircularProgressbarsList from "@/components/widgets/CircularProgressbarsList/CircularProgressbarsList";
+import { useDynamicSize } from "@/hooks/DOM/useDynamicSize";
+import { cn } from "@/lib/utils";
+import { useState } from "react";
+
+interface LoadersProps extends Omit, "title"> {
+ circularProgressbarsListProps: React.ComponentProps<
+ typeof CircularProgressbarsList
+ >;
+ bottomActionButtonProps?: BottomActionButtonProps;
+}
+
+function Loaders({
+ circularProgressbarsListProps,
+ bottomActionButtonProps,
+ ...props
+}: LoadersProps) {
+ const {
+ height: bottomActionButtonHeight,
+ elementRef: bottomActionButtonRef,
+ } = useDynamicSize({
+ defaultHeight: 132,
+ });
+ const [isVisibleButton, setIsVisibleButton] = useState(false);
+
+ const onAnimationEnd = () => {
+ setIsVisibleButton(true);
+ circularProgressbarsListProps.onAnimationEnd?.();
+ };
+
+ return (
+
+
+ {bottomActionButtonProps && (
+
+ )}
+
+ );
+}
+
+export { Loaders };
diff --git a/src/components/templates/Question/Question.stories.tsx b/src/components/templates/Question/Question.stories.tsx
deleted file mode 100644
index 28ff678..0000000
--- a/src/components/templates/Question/Question.stories.tsx
+++ /dev/null
@@ -1,174 +0,0 @@
-import { Meta, StoryObj } from "@storybook/nextjs-vite";
-import { Question } from "./Question";
-import { fn } from "storybook/test";
-import { useState } from "react";
-import { MainButtonProps } from "@/components/ui/MainButton/MainButton";
-import { SelectAnswersListProps } from "@/components/widgets/SelectAnswersList/SelectAnswersList";
-
-/** Reusable Question page Component */
-const meta: Meta = {
- title: "Templates/Question",
- component: Question,
- tags: ["autodocs"],
- parameters: {
- layout: "fullscreen",
- },
- args: {
- layoutQuestionProps: {
- headerProps: {
- progressProps: {
- value: (5 / 15) * 100,
- label: "5 of 15",
- className: "max-w-[198px]",
- },
- onBack: fn(),
- },
- title: {
- children: "Which best represents your hair loss and goals?",
- },
- subtitle: {
- children: "Let's personalize your hair care journey",
- },
- },
- contentType: "radio-answers-list",
- },
- argTypes: {
- contentType: {
- control: { type: "select" },
- options: ["radio-answers-list", "select-answers-list"],
- },
- content: {
- control: { type: "object" },
- },
- },
-};
-
-export default meta;
-type Story = StoryObj;
-
-export const Default = {} satisfies Story;
-
-export const RadioAnswers = {
- args: {
- contentType: "radio-answers-list",
- content: {
- answers: [
- {
- children: "FEMALE",
- emoji: "👩",
- id: "female",
- },
- {
- children: "MALE",
- emoji: "👨",
- isCheckbox: true,
- id: "male",
- },
- {
- children: "Receding hairline, want to slow its progress",
- id: "without-emoji",
- },
- ],
- activeAnswer: {
- children: "MALE",
- emoji: "👨",
- isCheckbox: true,
- id: "male",
- },
- onAnswerClick: fn(),
- onChangeSelectedAnswer: fn(),
- },
- },
-} satisfies Story;
-
-export const SelectAnswers = {
- args: {
- contentType: "select-answers-list",
- content: {
- answers: [
- {
- children: "Receding hairline, want to slow its progress",
- isCheckbox: true,
- id: "hairline",
- },
- {
- children: "Experiencing hair loss, exploring",
- isCheckbox: true,
- id: "exploring",
- },
- {
- children: "Experiencing hair loss, ready to start",
- isCheckbox: true,
- id: "ready-to-start",
- },
- {
- children: "Experiencing hair loss, ready to start",
- id: "ready-to-start-text",
- },
- {
- children: "Experiencing hair loss, ready to start",
- emoji: "👩🏼",
- id: "ready-to-start-emoji",
- },
- {
- children: "Experiencing hair loss, ready to start",
- emoji: "👩🏼",
- isCheckbox: true,
- id: "ready-to-start-emoji-checkbox",
- },
- ],
- activeAnswers: [
- {
- children: "Experiencing hair loss, ready to start",
- isCheckbox: true,
- id: "ready-to-start",
- },
- {
- children: "Experiencing hair loss, ready to start",
- emoji: "👩🏼",
- id: "ready-to-start-emoji",
- },
- ],
- onChangeSelectedAnswers: fn(),
- onAnswerClick: fn(),
- },
- },
- render: (args) => {
- const { layoutQuestionProps, content, ...rest } = args;
- const [selectedAnswers, setSelectedAnswers] = useState<
- MainButtonProps[] | null
- >((content as SelectAnswersListProps).activeAnswers);
-
- const onActionButtonClick = () => {
- fn()(selectedAnswers);
- };
-
- const layoutQuestionArgs = {
- ...layoutQuestionProps,
- bottomActionButtonProps: {
- actionButtonProps: {
- children: "Continue",
- onClick: onActionButtonClick,
- },
- },
- };
-
- const onChangeSelectedAnswers = (answers: MainButtonProps[] | null) => {
- setSelectedAnswers(answers);
- fn()(answers);
- };
-
- const contentArgs = {
- ...content,
- onChangeSelectedAnswers,
- };
-
- return (
-
- );
- },
-} satisfies Story;
diff --git a/src/components/templates/Question/Question.tsx b/src/components/templates/Question/Question.tsx
deleted file mode 100644
index b500478..0000000
--- a/src/components/templates/Question/Question.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-"use client";
-
-import {
- RadioAnswersList,
- RadioAnswersListProps,
-} from "@/components/widgets/RadioAnswersList/RadioAnswersList";
-import {
- LayoutQuestion,
- LayoutQuestionProps,
-} from "@/components/layout/LayoutQuestion/LayoutQuestion";
-import {
- SelectAnswersList,
- SelectAnswersListProps,
-} from "@/components/widgets/SelectAnswersList/SelectAnswersList";
-
-interface QuestionProps
- extends Omit, "title" | "content"> {
- layoutQuestionProps: Omit;
- content: RadioAnswersListProps | SelectAnswersListProps;
- contentType: "radio-answers-list" | "select-answers-list";
-}
-
-function Question({
- layoutQuestionProps,
- content,
- contentType,
- ...props
-}: QuestionProps) {
- return (
-
- {content && (
-
- {contentType === "radio-answers-list" && (
-
- )}
- {contentType === "select-answers-list" && (
-
- )}
-
- )}
-
- );
-}
-
-export { Question };
diff --git a/src/components/templates/QuestionDateAnswers/QuestionDateAnswers.stories.tsx b/src/components/templates/QuestionDateAnswers/QuestionDateAnswers.stories.tsx
new file mode 100644
index 0000000..ae2ac58
--- /dev/null
+++ b/src/components/templates/QuestionDateAnswers/QuestionDateAnswers.stories.tsx
@@ -0,0 +1,127 @@
+import { Meta, StoryObj } from "@storybook/nextjs-vite";
+import { QuestionDateAnswers } from "./QuestionDateAnswers";
+import { fn } from "storybook/test";
+import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
+
+const layoutQuestionProps = {
+ headerProps: {
+ progressProps: {
+ value: (5 / 15) * 100,
+ label: "5 of 15",
+ className: "max-w-[198px]",
+ },
+ onBack: fn(),
+ },
+ title: {
+ children: "When is your birthday?",
+ },
+ subtitle: {
+ children: "We need this information to personalize your experience",
+ },
+};
+
+/** Reusable QuestionDateAnswers page Component */
+const meta: Meta = {
+ title: "Templates/QuestionDateAnswers",
+ component: QuestionDateAnswers,
+ tags: ["autodocs"],
+ parameters: {
+ layout: "fullscreen",
+ },
+ args: {
+ content: {
+ value: null,
+ onChange: fn(),
+ maxYear: new Date().getFullYear() - 11,
+ yearsRange: 100,
+ locale: "en",
+ },
+ bottomActionButtonProps: {
+ actionButtonProps: {
+ children: "Continue",
+ onClick: fn(),
+ },
+ },
+ },
+ argTypes: {
+ content: {
+ control: { type: "object" },
+ },
+ bottomActionButtonProps: {
+ control: { type: "object" },
+ },
+ },
+ render: (args) => {
+ return (
+
+
+
+ );
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default = {} satisfies Story;
+
+export const WithInitialValue = {
+ args: {
+ content: {
+ value: "1990-05-15",
+ onChange: fn(),
+ maxYear: new Date().getFullYear() - 11,
+ yearsRange: 100,
+ locale: "en",
+ },
+ },
+} satisfies Story;
+
+export const WithError = {
+ args: {
+ content: {
+ value: "",
+ onChange: fn(),
+ maxYear: new Date().getFullYear() - 11,
+ yearsRange: 100,
+ locale: "en",
+ },
+ },
+} satisfies Story;
+
+export const WithCustomLocale = {
+ args: {
+ content: {
+ value: null,
+ onChange: fn(),
+ maxYear: new Date().getFullYear() - 11,
+ yearsRange: 100,
+ locale: "ru",
+ },
+ },
+} satisfies Story;
+
+export const WithCustomYearRange = {
+ args: {
+ content: {
+ value: null,
+ onChange: fn(),
+ maxYear: 2000,
+ yearsRange: 50,
+ locale: "en",
+ },
+ },
+} satisfies Story;
+
+export const WithoutBottomButton = {
+ args: {
+ content: {
+ value: null,
+ onChange: fn(),
+ maxYear: new Date().getFullYear() - 11,
+ yearsRange: 100,
+ locale: "en",
+ },
+ bottomActionButtonProps: undefined,
+ },
+} satisfies Story;
diff --git a/src/components/templates/QuestionDateAnswers/QuestionDateAnswers.tsx b/src/components/templates/QuestionDateAnswers/QuestionDateAnswers.tsx
new file mode 100644
index 0000000..bf7c754
--- /dev/null
+++ b/src/components/templates/QuestionDateAnswers/QuestionDateAnswers.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import {
+ BottomActionButton,
+ BottomActionButtonProps,
+} from "@/components/widgets/BottomActionButton/BottomActionButton";
+import DateInput, {
+ DateInputProps,
+} from "@/components/widgets/DateInput/DateInput";
+import { cn } from "@/lib/utils";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { useEffect } from "react";
+
+const formSchema = z.object({
+ date: z.string().min(1, {
+ message: "Please select a date",
+ }),
+});
+
+interface QuestionDateAnswersProps
+ extends Omit, "content"> {
+ content: DateInputProps;
+ bottomActionButtonProps?: BottomActionButtonProps;
+}
+
+function QuestionDateAnswers({
+ content,
+ bottomActionButtonProps,
+ ...props
+}: QuestionDateAnswersProps) {
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ date: content.value || "",
+ },
+ });
+
+ useEffect(() => {
+ form.setValue("date", content.value || "");
+ }, [content.value, form]);
+
+ const handleChange = (value: string | null) => {
+ form.setValue("date", value || "");
+ form.trigger("date");
+ content.onChange?.(value);
+ };
+
+ const isFormValid = form.formState.isValid && form.getValues("date");
+
+ return (
+
+
+ {bottomActionButtonProps && (
+
+ )}
+
+ );
+}
+
+export { QuestionDateAnswers };
diff --git a/src/components/templates/QuestionInformation/QuestionInformation.stories.tsx b/src/components/templates/QuestionInformation/QuestionInformation.stories.tsx
new file mode 100644
index 0000000..764c958
--- /dev/null
+++ b/src/components/templates/QuestionInformation/QuestionInformation.stories.tsx
@@ -0,0 +1,92 @@
+import { Meta, StoryObj } from "@storybook/nextjs-vite";
+import { QuestionInformation } from "./QuestionInformation";
+import { fn } from "storybook/test";
+import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
+import Typography from "@/components/ui/Typography/Typography";
+import Image from "next/image";
+
+const layoutQuestionProps = {
+ headerProps: {
+ progressProps: {
+ value: (3 / 15) * 100,
+ label: "3 of 15",
+ className: "max-w-[198px]",
+ },
+ onBack: fn(),
+ },
+};
+
+/** Reusable QuestionInformation page Component */
+const meta: Meta = {
+ title: "Templates/QuestionInformation",
+ component: QuestionInformation,
+ tags: ["autodocs"],
+ parameters: {
+ layout: "fullscreen",
+ },
+ args: {
+ image: (
+
+ ),
+ text: (
+
+ По нашей статистике 51 % женщин Овнов доверяются эмоциям. Но
+ одной чувствительности мало. Мы покажем, какие качества второй половинки
+ дадут тепло и уверенность, и изобразим её портрет.
+
+ ),
+ bottomActionButtonProps: {
+ actionButtonProps: {
+ children: "Continue",
+ onClick: fn(),
+ },
+ },
+ },
+ argTypes: {
+ image: {
+ control: { type: "object" },
+ },
+ text: {
+ control: { type: "object" },
+ },
+ bottomActionButtonProps: {
+ control: { type: "object" },
+ },
+ },
+ render: (args) => {
+ return (
+
+
+
+ );
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default = {} satisfies Story;
+
+export const WithoutImage = {
+ args: {
+ image: undefined,
+ },
+} satisfies Story;
+
+export const WithoutText = {
+ args: {
+ text: undefined,
+ },
+} satisfies Story;
+
+export const WithoutBottomButton = {
+ args: {
+ bottomActionButtonProps: undefined,
+ },
+} satisfies Story;
diff --git a/src/components/templates/QuestionInformation/QuestionInformation.tsx b/src/components/templates/QuestionInformation/QuestionInformation.tsx
new file mode 100644
index 0000000..b352f2c
--- /dev/null
+++ b/src/components/templates/QuestionInformation/QuestionInformation.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import {
+ BottomActionButton,
+ BottomActionButtonProps,
+} from "@/components/widgets/BottomActionButton/BottomActionButton";
+import { useDynamicSize } from "@/hooks/DOM/useDynamicSize";
+import { cn } from "@/lib/utils";
+
+interface QuestionInformationProps extends React.ComponentProps<"div"> {
+ image?: React.ReactNode;
+ text?: React.ReactNode;
+ bottomActionButtonProps?: BottomActionButtonProps;
+}
+
+function QuestionInformation({
+ image,
+ text,
+ bottomActionButtonProps,
+ ...props
+}: QuestionInformationProps) {
+ const {
+ height: bottomActionButtonHeight,
+ elementRef: bottomActionButtonRef,
+ } = useDynamicSize({
+ defaultHeight: 132,
+ });
+
+ return (
+
+ {image}
+ {text}
+ {bottomActionButtonProps && (
+
+ )}
+
+ );
+}
+
+export { QuestionInformation };
diff --git a/src/components/templates/QuestionRadioAnswers/QuestionRadioAnswers.stories.tsx b/src/components/templates/QuestionRadioAnswers/QuestionRadioAnswers.stories.tsx
new file mode 100644
index 0000000..8f28ef4
--- /dev/null
+++ b/src/components/templates/QuestionRadioAnswers/QuestionRadioAnswers.stories.tsx
@@ -0,0 +1,75 @@
+import { Meta, StoryObj } from "@storybook/nextjs-vite";
+import { QuestionRadioAnswers } from "./QuestionRadioAnswers";
+import { fn } from "storybook/test";
+import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
+
+const layoutQuestionProps = {
+ headerProps: {
+ progressProps: {
+ value: (5 / 15) * 100,
+ label: "5 of 15",
+ className: "max-w-[198px]",
+ },
+ onBack: fn(),
+ },
+ title: {
+ children: "Which best represents your hair loss and goals?",
+ },
+ subtitle: {
+ children: "Let's personalize your hair care journey",
+ },
+};
+
+/** Reusable QuestionRadioAnswers page Component */
+const meta: Meta = {
+ title: "Templates/QuestionRadioAnswers",
+ component: QuestionRadioAnswers,
+ tags: ["autodocs"],
+ parameters: {
+ layout: "fullscreen",
+ },
+ args: {
+ content: {
+ answers: [
+ {
+ children: "FEMALE",
+ emoji: "👩",
+ id: "female",
+ },
+ {
+ children: "MALE",
+ emoji: "👨",
+ isCheckbox: true,
+ id: "male",
+ },
+ {
+ children: "Receding hairline, want to slow its progress",
+ id: "without-emoji",
+ },
+ ],
+ activeAnswer: {
+ children: "MALE",
+ emoji: "👨",
+ isCheckbox: true,
+ id: "male",
+ },
+ onAnswerClick: fn(),
+ onChangeSelectedAnswer: fn(),
+ },
+ },
+ argTypes: {
+ content: {
+ control: { type: "object" },
+ },
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default = {} satisfies Story;
diff --git a/src/components/templates/QuestionRadioAnswers/QuestionRadioAnswers.tsx b/src/components/templates/QuestionRadioAnswers/QuestionRadioAnswers.tsx
new file mode 100644
index 0000000..a57eb12
--- /dev/null
+++ b/src/components/templates/QuestionRadioAnswers/QuestionRadioAnswers.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import {
+ RadioAnswersList,
+ RadioAnswersListProps,
+} from "@/components/widgets/RadioAnswersList/RadioAnswersList";
+
+interface QuestionRadioAnswersProps
+ extends Omit, "content"> {
+ content: RadioAnswersListProps;
+}
+
+function QuestionRadioAnswers({
+ content,
+ ...props
+}: QuestionRadioAnswersProps) {
+ return (
+
+
+
+ );
+}
+
+export { QuestionRadioAnswers };
diff --git a/src/components/templates/QuestionSelectAnswers/QuestionSelectAnswers.stories.tsx b/src/components/templates/QuestionSelectAnswers/QuestionSelectAnswers.stories.tsx
new file mode 100644
index 0000000..9625bf8
--- /dev/null
+++ b/src/components/templates/QuestionSelectAnswers/QuestionSelectAnswers.stories.tsx
@@ -0,0 +1,104 @@
+import { Meta, StoryObj } from "@storybook/nextjs-vite";
+import { QuestionSelectAnswers } from "./QuestionSelectAnswers";
+import { fn } from "storybook/test";
+import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
+
+const layoutQuestionProps = {
+ headerProps: {
+ progressProps: {
+ value: (5 / 15) * 100,
+ label: "5 of 15",
+ className: "max-w-[198px]",
+ },
+ onBack: fn(),
+ },
+ title: {
+ children: "Which best represents your hair loss and goals?",
+ },
+ subtitle: {
+ children: "Let's personalize your hair care journey",
+ },
+};
+
+/** Reusable QuestionSelectAnswers page Component */
+const meta: Meta = {
+ title: "Templates/QuestionSelectAnswers",
+ component: QuestionSelectAnswers,
+ tags: ["autodocs"],
+ parameters: {
+ layout: "fullscreen",
+ },
+ args: {
+ content: {
+ answers: [
+ {
+ children: "Receding hairline, want to slow its progress",
+ isCheckbox: true,
+ id: "hairline",
+ },
+ {
+ children: "Experiencing hair loss, exploring",
+ isCheckbox: true,
+ id: "exploring",
+ },
+ {
+ children: "Experiencing hair loss, ready to start",
+ isCheckbox: true,
+ id: "ready-to-start",
+ },
+ {
+ children: "Experiencing hair loss, ready to start",
+ id: "ready-to-start-text",
+ },
+ {
+ children: "Experiencing hair loss, ready to start",
+ emoji: "👩🏼",
+ id: "ready-to-start-emoji",
+ },
+ {
+ children: "Experiencing hair loss, ready to start",
+ emoji: "👩🏼",
+ isCheckbox: true,
+ id: "ready-to-start-emoji-checkbox",
+ },
+ ],
+ activeAnswers: [
+ {
+ children: "Experiencing hair loss, ready to start",
+ isCheckbox: true,
+ id: "ready-to-start",
+ },
+ {
+ children: "Experiencing hair loss, ready to start",
+ emoji: "👩🏼",
+ id: "ready-to-start-emoji",
+ },
+ ],
+ onChangeSelectedAnswers: fn(),
+ onAnswerClick: fn(),
+ },
+ bottomActionButtonProps: {
+ actionButtonProps: {
+ children: "Continue",
+ onClick: fn(),
+ },
+ },
+ },
+ argTypes: {
+ content: {
+ control: { type: "object" },
+ },
+ },
+ render: (args) => {
+ return (
+
+
+
+ );
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default = {} satisfies Story;
diff --git a/src/components/templates/QuestionSelectAnswers/QuestionSelectAnswers.tsx b/src/components/templates/QuestionSelectAnswers/QuestionSelectAnswers.tsx
new file mode 100644
index 0000000..fae7fed
--- /dev/null
+++ b/src/components/templates/QuestionSelectAnswers/QuestionSelectAnswers.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import { MainButtonProps } from "@/components/ui/MainButton/MainButton";
+import {
+ BottomActionButton,
+ BottomActionButtonProps,
+} from "@/components/widgets/BottomActionButton/BottomActionButton";
+import {
+ SelectAnswersList,
+ SelectAnswersListProps,
+} from "@/components/widgets/SelectAnswersList/SelectAnswersList";
+import { useDynamicSize } from "@/hooks/DOM/useDynamicSize";
+import { cn } from "@/lib/utils";
+import { useState } from "react";
+
+interface QuestionSelectAnswersProps
+ extends Omit, "content"> {
+ content: SelectAnswersListProps;
+ bottomActionButtonProps?: BottomActionButtonProps;
+}
+
+function QuestionSelectAnswers({
+ content,
+ bottomActionButtonProps,
+ ...props
+}: QuestionSelectAnswersProps) {
+ const {
+ height: bottomActionButtonHeight,
+ elementRef: bottomActionButtonRef,
+ } = useDynamicSize({
+ defaultHeight: 132,
+ });
+ const [selectedAnswers, setSelectedAnswers] = useState<
+ MainButtonProps[] | null
+ >(content.activeAnswers);
+
+ const handleChangeSelectedAnswers = (answers: MainButtonProps[] | null) => {
+ setSelectedAnswers(answers);
+ content.onChangeSelectedAnswers?.(answers);
+ };
+
+ return (
+
+
+ {bottomActionButtonProps && (
+
+ )}
+
+ );
+}
+
+export { QuestionSelectAnswers };
diff --git a/src/components/templates/SoulmatePortrait/SoulmatePortrait.stories.tsx b/src/components/templates/SoulmatePortrait/SoulmatePortrait.stories.tsx
new file mode 100644
index 0000000..eeff31e
--- /dev/null
+++ b/src/components/templates/SoulmatePortrait/SoulmatePortrait.stories.tsx
@@ -0,0 +1,40 @@
+import { Meta, StoryObj } from "@storybook/nextjs-vite";
+import SoulmatePortrait from "./SoulmatePortrait";
+import { fn } from "storybook/test";
+
+/** Reusable SoulmatePortrait page Component */
+const meta: Meta = {
+ title: "Templates/SoulmatePortrait",
+ component: SoulmatePortrait,
+ tags: ["autodocs"],
+ parameters: {
+ layout: "fullscreen",
+ },
+ args: {
+ bottomActionButtonProps: {
+ actionButtonProps: {
+ children: "Continue",
+ onClick: fn(),
+ },
+ },
+ privacyTermsConsentProps: {
+ privacyPolicy: {
+ children: "Privacy Policy",
+ href: "#privacy-policy",
+ },
+ termsOfUse: {
+ children: "Terms of use",
+ href: "#terms-of-use",
+ },
+ },
+ title: {
+ children: "Soulmate Portrait",
+ },
+ },
+ argTypes: {},
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default = {} satisfies Story;
diff --git a/src/components/templates/SoulmatePortrait/SoulmatePortrait.tsx b/src/components/templates/SoulmatePortrait/SoulmatePortrait.tsx
new file mode 100644
index 0000000..f16902e
--- /dev/null
+++ b/src/components/templates/SoulmatePortrait/SoulmatePortrait.tsx
@@ -0,0 +1,69 @@
+import Typography, {
+ TypographyProps,
+} from "@/components/ui/Typography/Typography";
+import { BottomActionButton } from "@/components/widgets/BottomActionButton/BottomActionButton";
+import PrivacyTermsConsent from "@/components/widgets/PrivacyTermsConsent/PrivacyTermsConsent";
+import { useDynamicSize } from "@/hooks/DOM/useDynamicSize";
+import { cn } from "@/lib/utils";
+
+export interface SoulmatePortraitProps
+ extends Omit, "title"> {
+ bottomActionButtonProps?: React.ComponentProps;
+ privacyTermsConsentProps?: React.ComponentProps;
+ title?: TypographyProps<"h2">;
+}
+
+export default function SoulmatePortrait({
+ bottomActionButtonProps,
+ privacyTermsConsentProps,
+ title,
+ ...props
+}: SoulmatePortraitProps) {
+ const {
+ height: bottomActionButtonHeight,
+ elementRef: bottomActionButtonRef,
+ } = useDynamicSize({
+ defaultHeight: 132,
+ });
+
+ return (
+
+
+ {title && (
+
+ )}
+
+ {bottomActionButtonProps && (
+
+ )
+ }
+ />
+ )}
+
+ );
+}
diff --git a/src/components/ui/SelectInput/SelectInput.stories.tsx b/src/components/ui/SelectInput/SelectInput.stories.tsx
new file mode 100644
index 0000000..cfb9b41
--- /dev/null
+++ b/src/components/ui/SelectInput/SelectInput.stories.tsx
@@ -0,0 +1,81 @@
+import { Meta, StoryObj } from "@storybook/nextjs-vite";
+import SelectInput from "./SelectInput";
+import { fn } from "storybook/test";
+import { useState } from "react";
+import { useDateInput } from "@/hooks/useDateInput";
+import Typography from "../Typography/Typography";
+
+/** Reusable SelectInput Component */
+const meta: Meta = {
+ title: "UI/SelectInput",
+ component: SelectInput,
+ tags: ["autodocs"],
+ args: {
+ defaultValue: "01",
+ onValueChange: fn(),
+ },
+ argTypes: {
+ value: {
+ control: { type: "text" },
+ },
+ options: {
+ control: { type: "object" },
+ },
+ },
+ render: (args) => {
+ const { dayOptions } = useDateInput({});
+ const [value, setValue] = useState(args.defaultValue);
+
+ return (
+
+ Value: {value}
+ {
+ setValue(value);
+ args.onValueChange?.(value);
+ }}
+ options={dayOptions}
+ />
+
+ );
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default = {} satisfies Story;
+
+export const Disabled = {
+ args: {
+ disabled: true,
+ },
+} satisfies Story;
+
+export const WithLabel = {
+ args: {
+ label: "Month",
+ placeholder: "MM",
+ defaultValue: undefined,
+ },
+} satisfies Story;
+
+export const WithPlaceholder = {
+ args: {
+ placeholder: "Placeholder",
+ defaultValue: undefined,
+ },
+} satisfies Story;
+
+export const WithError = {
+ args: {
+ placeholder: "With Error",
+ // "aria-invalid": true,
+ error: true,
+ errorProps: {
+ children: "Error",
+ },
+ },
+} satisfies Story;
diff --git a/src/components/ui/SelectInput/SelectInput.tsx b/src/components/ui/SelectInput/SelectInput.tsx
new file mode 100644
index 0000000..a23b553
--- /dev/null
+++ b/src/components/ui/SelectInput/SelectInput.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import { useId } from "react";
+import Typography from "../Typography/Typography";
+import { Label } from "../label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "../select";
+
+type Option = {
+ value: string | number;
+ label: string;
+};
+
+export interface SelectInputProps extends React.ComponentProps {
+ error?: boolean;
+ options: Option[];
+ placeholder?: string;
+ label?: string;
+ labelProps?: React.ComponentProps;
+ triggerProps?: React.ComponentProps;
+ contentProps?: React.ComponentProps;
+ itemProps?: React.ComponentProps;
+ errorProps?: React.ComponentProps;
+}
+
+export default function SelectInput({
+ error,
+ options,
+ placeholder,
+ label,
+ labelProps,
+ triggerProps,
+ contentProps,
+ itemProps,
+ errorProps,
+ ...props
+}: SelectInputProps) {
+ const id = useId();
+
+ return (
+
+ {label && (
+
+ )}
+
+ {error && (
+
+ {errorProps?.children}
+
+ )}
+
+ );
+}
diff --git a/src/components/ui/TextInput/TextInput.tsx b/src/components/ui/TextInput/TextInput.tsx
index 4c45e90..ca8abcd 100644
--- a/src/components/ui/TextInput/TextInput.tsx
+++ b/src/components/ui/TextInput/TextInput.tsx
@@ -5,14 +5,23 @@ import { useId } from "react";
interface TextInputProps extends React.ComponentProps {
label?: string;
+ containerProps?: React.ComponentProps<"div">;
}
-function TextInput({ className, label, ...props }: TextInputProps) {
+function TextInput({
+ className,
+ label,
+ containerProps,
+ ...props
+}: TextInputProps) {
const id = useId();
const inputId = props.id || id;
return (
-
+
{label && (