This commit is contained in:
gofnnp 2025-09-23 20:39:40 +04:00
parent 50c873678e
commit 40e1d6ca21
53 changed files with 8466 additions and 255 deletions

6
.gitignore vendored
View File

@ -39,3 +39,9 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
*storybook.log
storybook-static
.vscode
tailwind.config.js

22
.storybook/main.ts Normal file
View File

@ -0,0 +1,22 @@
import type { StorybookConfig } from "@storybook/nextjs-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@chromatic-com/storybook",
"@storybook/addon-docs",
"@storybook/addon-onboarding",
"@storybook/addon-a11y",
"@storybook/addon-vitest",
"@storybook/addon-styling-webpack"
],
framework: {
name: "@storybook/nextjs-vite",
options: {},
},
staticDirs: ["../public"],
core: {
disableTelemetry: true, // 👈 Disables telemetry
},
};
export default config;

64
.storybook/preview.tsx Normal file
View File

@ -0,0 +1,64 @@
import type { Preview } from "@storybook/nextjs-vite";
import { Geist, Geist_Mono, Inter, Manrope } from "next/font/google";
import "../src/app/globals.css";
import React from "react";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const manrope = Manrope({
variable: "--font-manrope",
subsets: ["latin", "cyrillic"],
weight: ["200", "300", "400", "500", "600", "700", "800"],
});
const inter = Inter({
variable: "--font-inter",
subsets: ["latin", "cyrillic"],
weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"],
});
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: "todo",
},
layout: "padded",
backgrounds: {
options: {
light: { name: "Light", value: "#fff" },
dark: { name: "Dark", value: "#333" },
},
},
},
decorators: [
(Story) => (
<div
className={`${geistSans.variable} ${geistMono.variable} ${manrope.variable} ${inter.variable} flex items-center justify-center size-full max-w-[560px] min-w-xs mx-auto antialiased`}
>
<Story />
</div>
),
],
};
export default preview;

View File

@ -0,0 +1,7 @@
import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
import { setProjectAnnotations } from '@storybook/nextjs-vite';
import * as projectAnnotations from './preview';
// This is an important step to apply the right configuration when testing your stories.
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);

22
components.json Normal file
View File

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@ -1,3 +1,6 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from "eslint-plugin-storybook";
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
@ -9,17 +12,14 @@ const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
const eslintConfig = [...compat.extends("next/core-web-vitals", "next/typescript"), {
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
}, ...storybook.configs["flat/recommended"]];
export default eslintConfig;

5708
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,20 +6,47 @@
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"storybook": "storybook dev -p 6006 --ci",
"build-storybook": "storybook build"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.544.0",
"next": "15.5.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"next": "15.5.3"
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"typescript": "^5",
"@chromatic-com/storybook": "^4.1.1",
"@eslint/eslintrc": "^3",
"@storybook/addon-a11y": "^9.1.6",
"@storybook/addon-docs": "^9.1.6",
"@storybook/addon-onboarding": "^9.1.6",
"@storybook/addon-styling-webpack": "^2.0.0",
"@storybook/addon-vitest": "^9.1.6",
"@storybook/nextjs-vite": "^9.1.6",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9",
"eslint-config-next": "15.5.3",
"@eslint/eslintrc": "^3"
"eslint-plugin-storybook": "^9.1.6",
"playwright": "^1.55.0",
"storybook": "^9.1.6",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.8",
"typescript": "^5",
"vitest": "^3.2.4"
}
}

5
postcss.config.mjs Normal file
View File

@ -0,0 +1,5 @@
const config = {
plugins: { "@tailwindcss/postcss": {} },
};
export default config;

View File

@ -1,42 +1,164 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-manrope: var(--font-manrope);
--font-inter: var(--font-inter);
--font-geist-sans: var(--font-geist-sans);
--font-geist-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
/* Тени */
--shadow-blue-glow: 0px 5px 14px 0px rgba(59, 130, 246, 0.4),
0px 4px 6px 0px rgba(59, 130, 246, 0.1);
--shadow-blue-glow-2: 0px 0px 19px 0px rgba(59, 130, 246, 0.3),
0px 0px 0px 0px rgba(59, 130, 246, 0.2);
--shadow-black-glow: 0px 8px 15px 0px #00000026, 0px 4px 6px 0px #00000014;
--shadow-coupon: 0px 20px 40px 0px #0000004d, 0px 8px 16px 0px #00000033;
}
:root {
--background: #ffffff;
--foreground: #171717;
--radius: 0.625rem;
/* === ПЕРЕОПРЕДЕЛЯЕМ SHADCN ПЕРЕМЕННЫЕ ПОД НАШИ НУЖДЫ === */
/* Основные цвета */
--background: oklch(1 0 0); /* Белый фон */
--foreground: oklch(0 0 0); /* Черный текст */
--card: oklch(1 0 0); /* Белый фон карточек */
--card-foreground: oklch(
0.2781 0.0296 256.85
); /* #1f2937 - Темно-синий текст карточек */
--popover: oklch(1 0 0); /* Белый фон попапов */
--popover-foreground: oklch(0 0 0); /* Черный текст попапов */
/* Primary - наш синий */
--primary: oklch(0.6231 0.188 259.81); /* #3B82F6 */
--primary-foreground: oklch(1 0 0); /* Белый текст на синем */
/* Secondary - светло-серый */
--secondary: oklch(0.97 0 0); /* Светло-серый фон */
--secondary-foreground: oklch(0.2795 0.0368 260.03); /* Темно-серый текст */
/* Muted - для второстепенного контента */
--muted: oklch(0.97 0 0); /* Светло-серый фон */
--muted-foreground: oklch(0.59 0.02 260.8); /* #64748B - серый текст */
/* Accent - для акцентов */
--accent: oklch(0.97 0 0); /* Светло-серый фон */
--accent-foreground: oklch(0.2795 0.0368 260.03); /* Темно-серый текст */
/* Destructive - красный для ошибок */
--destructive: oklch(0.577 0.245 27.325);
/* Success - зеленый для успешных действий */
--success: oklch(0.36 0.245 27.325);
/* Border и Input */
--border: oklch(0.9288 0.0126 255.51); /* Светло-серая граница */
--input: oklch(0.922 0 0); /* Светло-серый фон инпутов */
--ring: oklch(0.6231 0.188 259.81); /* Синий фокус */
/* Chart цвета - можно оставить как есть или переопределить */
--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);
/* Sidebar - можно оставить как есть */
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* === ДОПОЛНИТЕЛЬНЫЕ ПЕРЕМЕННЫЕ ТОЛЬКО ДЛЯ ГРАДИЕНТОВ === */
--primary-light: oklch(0.954 0.025 259.8); /* #EBF5FF - для градиента */
--primary-lighter: oklch(0.909 0.045 259.8); /* #DBEAFE - для градиента */
--primary-dark: oklch(0.5461 0.2152 262.88); /* #2563EB - для градиента */
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--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.205 0 0);
--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.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Geist, Geist_Mono, Inter, Manrope } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
@ -12,6 +12,18 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
const manrope = Manrope({
variable: "--font-manrope",
subsets: ["latin", "cyrillic"],
weight: ["200", "300", "400", "500", "600", "700", "800"],
});
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
@ -24,7 +36,9 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<body
className={`${geistSans.variable} ${geistMono.variable} ${manrope.variable} ${inter.variable} antialiased`}
>
{children}
</body>
</html>

View File

@ -1,167 +0,0 @@
.page {
--gray-rgb: 0, 0, 0;
--gray-alpha-200: rgba(var(--gray-rgb), 0.08);
--gray-alpha-100: rgba(var(--gray-rgb), 0.05);
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
display: grid;
grid-template-rows: 20px 1fr 20px;
align-items: center;
justify-items: center;
min-height: 100svh;
padding: 80px;
gap: 64px;
font-family: var(--font-geist-sans);
}
@media (prefers-color-scheme: dark) {
.page {
--gray-rgb: 255, 255, 255;
--gray-alpha-200: rgba(var(--gray-rgb), 0.145);
--gray-alpha-100: rgba(var(--gray-rgb), 0.06);
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
}
}
.main {
display: flex;
flex-direction: column;
gap: 32px;
grid-row-start: 2;
}
.main ol {
font-family: var(--font-geist-mono);
padding-left: 0;
margin: 0;
font-size: 14px;
line-height: 24px;
letter-spacing: -0.01em;
list-style-position: inside;
}
.main li:not(:last-of-type) {
margin-bottom: 8px;
}
.main code {
font-family: inherit;
background: var(--gray-alpha-100);
padding: 2px 4px;
border-radius: 4px;
font-weight: 600;
}
.ctas {
display: flex;
gap: 16px;
}
.ctas a {
appearance: none;
border-radius: 128px;
height: 48px;
padding: 0 20px;
border: 1px solid transparent;
transition:
background 0.2s,
color 0.2s,
border-color 0.2s;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
line-height: 20px;
font-weight: 500;
}
a.primary {
background: var(--foreground);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--gray-alpha-200);
min-width: 158px;
}
.footer {
grid-row-start: 3;
display: flex;
gap: 24px;
}
.footer a {
display: flex;
align-items: center;
gap: 8px;
}
.footer img {
flex-shrink: 0;
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
.footer a:hover {
text-decoration: underline;
text-underline-offset: 4px;
}
}
@media (max-width: 600px) {
.page {
padding: 32px;
padding-bottom: 80px;
}
.main {
align-items: center;
}
.main ol {
text-align: center;
}
.ctas {
flex-direction: column;
}
.ctas a {
font-size: 14px;
height: 40px;
padding: 0 16px;
}
a.secondary {
min-width: auto;
}
.footer {
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
}

View File

@ -1,34 +1,39 @@
import Image from "next/image";
import styles from "./page.module.css";
export default function Home() {
return (
<div className={styles.page}>
<main className={styles.main}>
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className={styles.logo}
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol>
<li>
Get started by editing <code>src/app/page.tsx</code>.
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className={styles.ctas}>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className={styles.primary}
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className={styles.logo}
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
@ -37,18 +42,19 @@ export default function Home() {
Deploy now
</a>
<a
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
className={styles.secondary}
>
Read our docs
</a>
</div>
</main>
<footer className={styles.footer}>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
@ -62,7 +68,8 @@ export default function Home() {
Learn
</a>
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
@ -76,7 +83,8 @@ export default function Home() {
Examples
</a>
<a
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>

View File

@ -0,0 +1,24 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { Header } from "./Header";
import { fn } from "storybook/test";
/** Reusable Header Component */
const meta: Meta<typeof Header> = {
title: "Layout/Header",
component: Header,
tags: ["autodocs"],
args: {
progressProps: {
value: (5 / 15) * 100,
label: "5 of 15",
className: "max-w-[198px]",
},
onBack: fn(),
},
argTypes: {},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default = {} satisfies Story;

View File

@ -0,0 +1,32 @@
"use client";
import { cn } from "@/lib/utils";
import { ChevronLeft } from "lucide-react";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
interface HeaderProps extends React.ComponentProps<"header"> {
progressProps?: React.ComponentProps<typeof Progress>;
onBack?: () => void;
}
function Header({ className, progressProps, onBack, ...props }: HeaderProps) {
return (
<header className={cn("w-full p-6 pb-3", className)} {...props}>
<div className="w-full flex justify-left items-center">
<Button
variant="ghost"
className="hover:bg-transparent rounded-full p-0! ml-[-13px] mb-[-9px]"
onClick={onBack}
>
<ChevronLeft size={36} />
</Button>
</div>
<div className="w-full flex justify-center items-center">
<Progress {...progressProps} />
</div>
</header>
);
}
export { Header };

View File

@ -0,0 +1,45 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { LayoutQuestion } from "./LayoutQuestion";
import { fn } from "storybook/test";
/** Reusable LayoutQuestion page Component */
const meta: Meta<typeof LayoutQuestion> = {
title: "Layout/LayoutQuestion",
component: LayoutQuestion,
tags: ["autodocs"],
parameters: {
layout: "fullscreen",
},
args: {
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",
},
children: (
<div className="w-full mt-[30px] text-center p-8 bg-secondary">
Children
</div>
),
bottomActionButtonProps: {
actionButtonProps: {
children: "Continue",
},
},
},
argTypes: {},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default = {} satisfies Story;

View File

@ -0,0 +1,90 @@
"use client";
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";
export interface LayoutQuestionProps
extends Omit<React.ComponentProps<"section">, "title" | "content"> {
headerProps?: React.ComponentProps<typeof Header>;
title: TypographyProps<"h2">;
subtitle: TypographyProps<"p">;
children: React.ReactNode;
bottomActionButtonProps?: BottomActionButtonProps;
}
function LayoutQuestion({
className,
headerProps,
title,
subtitle,
children,
bottomActionButtonProps,
...props
}: LayoutQuestionProps) {
const bottomActionButtonRef = useRef<HTMLDivElement | null>(null);
const [bottomActionButtonHeight, setBottomActionButtonHeight] =
useState<number>(132);
useEffect(() => {
if (bottomActionButtonRef.current) {
console.log(bottomActionButtonRef.current.clientHeight);
setBottomActionButtonHeight(bottomActionButtonRef.current.clientHeight);
}
}, [bottomActionButtonProps]);
return (
<section
className={cn(`block min-h-dvh w-full`, className)}
{...props}
style={{
paddingBottom: `${bottomActionButtonHeight}px`,
...props.style,
}}
>
<Header {...headerProps} />
<div className="w-full flex flex-col justify-center items-center p-6 pt-[30px]">
{title && (
<Typography
as="h2"
font="manrope"
weight="bold"
align="left"
{...title}
className={cn(title.className, "text-[25px] leading-[38px]")}
/>
)}
{subtitle && (
<Typography
as="p"
weight="medium"
align="left"
{...subtitle}
className={cn(
subtitle.className,
"w-full mt-2.5 text-[17px] leading-[26px]"
)}
/>
)}
{children}
{bottomActionButtonProps && (
<BottomActionButton
{...bottomActionButtonProps}
className="max-w-[560px]"
ref={bottomActionButtonRef}
/>
)}
</div>
</section>
);
}
export { LayoutQuestion };

View File

@ -0,0 +1,174 @@
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<typeof Question> = {
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<typeof meta>;
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 (
<Question
{...rest}
layoutQuestionProps={layoutQuestionArgs}
content={contentArgs}
/>
);
},
} satisfies Story;

View File

@ -0,0 +1,45 @@
"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<React.ComponentProps<"div">, "title" | "content"> {
layoutQuestionProps: Omit<LayoutQuestionProps, "children">;
content: RadioAnswersListProps | SelectAnswersListProps;
contentType: "radio-answers-list" | "select-answers-list";
}
function Question({
layoutQuestionProps,
content,
contentType,
...props
}: QuestionProps) {
return (
<LayoutQuestion {...layoutQuestionProps}>
{content && (
<div className="w-full mt-[30px]" {...props}>
{contentType === "radio-answers-list" && (
<RadioAnswersList {...(content as RadioAnswersListProps)} />
)}
{contentType === "select-answers-list" && (
<SelectAnswersList {...(content as SelectAnswersListProps)} />
)}
</div>
)}
</LayoutQuestion>
);
}
export { Question };

View File

@ -0,0 +1,60 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { ActionButton } from "./ActionButton";
import Link from "next/link";
import { fn } from "storybook/test";
/** Reusable ActionButton Component */
const meta: Meta<typeof ActionButton> = {
title: "UI/ActionButton",
component: ActionButton,
tags: ["autodocs"],
args: {
disabled: false,
asChild: false,
cornerRadius: "3xl",
children: "Continue",
onClick: fn(),
},
argTypes: {
cornerRadius: {
control: { type: "select" },
options: ["3xl", "full"],
},
asChild: {
control: { type: "boolean" },
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default = {} satisfies Story;
export const Disabled = {
args: {
children: "Disabled",
disabled: true,
},
} satisfies Story;
export const AsChild = {
args: {
asChild: true,
children: <Link href="#test">Link ActionButton</Link>,
},
} satisfies Story;
export const CornerRadiusFull = {
args: {
cornerRadius: "full",
children: "Full Corner Radius",
},
} satisfies Story;
export const CornerRadius3xl = {
args: {
cornerRadius: "3xl",
children: "3xl Corner Radius",
},
} satisfies Story;

View File

@ -0,0 +1,46 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "../button";
const buttonVariants = cva(
cn(
"w-full",
"bg-gradient-to-r from-[#3B82F6] to-[#2563EB]",
"cursor-pointer",
"inline-flex items-center justify-center gap-2",
"font-inter text-xl/[24px] font-bold text-primary-foreground",
"px-[27px] py-5",
"transition-all",
"disabled:opacity-50",
"shadow-blue-glow"
),
{
variants: {
cornerRadius: {
"3xl": "rounded-3xl",
full: "rounded-full",
},
},
defaultVariants: {
cornerRadius: "3xl",
},
}
);
function ActionButton({
className,
cornerRadius,
...props
}: React.ComponentProps<typeof Button> &
VariantProps<typeof buttonVariants> & {}) {
return (
<Button
data-slot="action-button"
className={cn(buttonVariants({ cornerRadius, className }))}
{...props}
/>
);
}
export { ActionButton, buttonVariants };

View File

@ -0,0 +1,61 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { MainButton } from "./MainButton";
import { fn } from "storybook/test";
import { useState } from "react";
/** Reusable MainButton Component */
const meta: Meta<typeof MainButton> = {
title: "UI/MainButton",
component: MainButton,
tags: ["autodocs"],
args: {
disabled: false,
children: "Title",
onClick: fn(),
},
argTypes: {
active: {
control: { type: "boolean" },
},
},
render: (args) => {
const [active, setActive] = useState(args.active);
return (
<MainButton
{...args}
onClick={() => setActive((prev) => !prev)}
active={active}
/>
);
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default = {} satisfies Story;
export const WithEmoji = {
args: {
emoji: "🩷️",
},
} satisfies Story;
export const WithCheckbox = {
args: {
isCheckbox: true,
checkboxProps: {
onCheckedChange: fn(),
},
},
} satisfies Story;
export const WithCheckboxAndEmoji = {
args: {
isCheckbox: true,
emoji: "🩷️",
checkboxProps: {
onCheckedChange: fn(),
},
},
} satisfies Story;

View File

@ -0,0 +1,89 @@
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "../button";
import { Checkbox } from "../checkbox";
import { Label } from "../label";
const buttonVariants = cva(
cn(
"w-full",
"cursor-pointer",
"inline-flex items-center justify-center gap-4",
"font-manrope text-[18px]/[18px] font-medium",
"pl-[26px] pr-[18px] py-[18px]",
"transition-all",
"disabled:opacity-50",
"border-2"
),
{
variants: {
cornerRadius: {
"3xl": "rounded-3xl",
full: "rounded-full",
},
active: {
true: "bg-gradient-to-r from-[#EBF5FF] to-[#DBEAFE] border-primary shadow-blue-glow-2 text-primary",
false:
"bg-background border-border shadow-black-glow text-secondary-foreground",
},
},
defaultVariants: {
cornerRadius: "3xl",
active: false,
},
}
);
export interface MainButtonProps
extends React.ComponentProps<typeof Button>,
VariantProps<typeof buttonVariants> {
emoji?: string;
isCheckbox?: boolean;
checkboxProps?: React.ComponentProps<typeof Checkbox>;
}
function MainButton({
className,
cornerRadius,
active,
children,
emoji,
isCheckbox,
checkboxProps,
disabled,
...props
}: MainButtonProps) {
return (
<Button
data-slot="main-button"
className={cn(buttonVariants({ cornerRadius, active, className }))}
{...props}
asChild
>
<Label
data-disabled={disabled}
className={cn(
disabled && "pointer-events-none opacity-50 cursor-not-allowed"
)}
>
{emoji && <span className="text-[40px]">{Array.from(emoji)[0]}</span>}
<p className="w-full text-left min-h-[38px] flex items-center">
{children}
</p>
{isCheckbox && (
<Checkbox
{...checkboxProps}
checked={active ?? false}
disabled={disabled}
onClick={(e) => e.stopPropagation()}
/>
)}
</Label>
</Button>
);
}
export { MainButton, buttonVariants };

View File

@ -0,0 +1,69 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { TextInput } from "./TextInput";
import { fn } from "storybook/test";
/** Reusable TextInput Component */
const meta: Meta<typeof TextInput> = {
title: "UI/TextInput",
component: TextInput,
tags: ["autodocs"],
args: {
disabled: false,
placeholder: "Placeholder",
"aria-invalid": false,
"aria-errormessage": "",
label: "",
type: "text",
onChange: fn(),
onFocus: fn(),
onBlur: fn(),
onError: fn(),
},
argTypes: {
"aria-invalid": {
control: { type: "boolean" },
},
"aria-errormessage": {
control: { type: "text" },
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default = {} satisfies Story;
export const Disabled = {
args: {
disabled: true,
},
} satisfies Story;
export const WithLabel = {
args: {
label: "Label",
},
} satisfies Story;
export const Email = {
args: {
type: "email",
label: "Email",
placeholder: "example@email.com",
},
render: (args) => (
<div className="flex flex-col gap-2">
<TextInput {...args} />
<TextInput {...args} value={"Email"} />
</div>
),
} satisfies Story;
export const Invalid = {
args: {
label: "Email",
"aria-invalid": true,
"aria-errormessage": "Invalid email",
},
} satisfies Story;

View File

@ -0,0 +1,45 @@
import { cn } from "@/lib/utils";
import { Input } from "../input";
import { Label } from "../label";
import { useId } from "react";
interface TextInputProps extends React.ComponentProps<typeof Input> {
label?: string;
}
function TextInput({ className, label, ...props }: TextInputProps) {
const id = useId();
const inputId = props.id || id;
return (
<div className="flex flex-col gap-2">
{label && (
<Label
htmlFor={inputId}
className="text-muted-foreground font-inter font-medium text-base"
>
{label}
</Label>
)}
<Input
data-slot="text-input"
className={cn(
"py-3.5 px-4",
"font-inter text-[18px]/[28px] font-semibold text-foreground",
"placeholder:text-muted-foreground placeholder:text-[18px]/[28px] font-medium",
"border-2 border-primary/30 rounded-2xl",
className
)}
id={inputId}
{...props}
/>
{props["aria-invalid"] && (
<p className="text-destructive font-inter font-medium text-xs">
{props["aria-errormessage"]}
</p>
)}
</div>
);
}
export { TextInput };

View File

@ -0,0 +1,205 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import Typography from "./Typography";
/** Reusable Typography Component */
const meta: Meta<typeof Typography> = {
title: "UI/Typography",
component: Typography,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
args: {
children: "Typography Text",
size: "md",
weight: "regular",
color: "default",
align: "center",
as: "span",
font: "inter",
},
argTypes: {
as: {
control: { type: "select" },
options: ["span", "p", "h1", "h2", "h3", "h4", "h5", "h6", "div"],
description: "HTML element to render",
},
size: {
control: { type: "select" },
options: ["xs", "sm", "md", "lg", "xl", "2xl"],
description: "Text size variant",
},
weight: {
control: { type: "select" },
options: ["regular", "medium", "semiBold", "bold", "extraBold", "black"],
description: "Font weight variant",
},
color: {
control: { type: "select" },
options: [
"default",
"primary",
"secondary",
"destructive",
"success",
"muted",
"card",
"accent",
],
description: "Text color variant",
},
align: {
control: { type: "select" },
options: ["center", "left", "right"],
description: "Text alignment",
},
children: {
control: { type: "text" },
description: "Text content",
},
font: {
control: { type: "select" },
options: ["manrope", "inter", "geistSans", "geistMono"],
description: "Font variant",
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default = {
args: {
children: "Default Typography",
},
} satisfies Story;
export const Fonts = {
decorators: [
() => (
<div className="space-y-4">
<Typography font="manrope">Font: manrope</Typography>
<Typography font="inter">Font: inter</Typography>
<Typography font="geistSans">Font: geist Sans</Typography>
<Typography font="geistMono">Font: geist Mono</Typography>
</div>
),
],
} satisfies Story;
export const Sizes = {
decorators: [
() => (
<div className="space-y-4">
<Typography size="xs">Extra Small Text (xs)</Typography>
<Typography size="sm">Small Text (sm)</Typography>
<Typography size="md">Medium Text (md)</Typography>
<Typography size="lg">Large Text (lg)</Typography>
<Typography size="xl">Extra Large Text (xl)</Typography>
<Typography size="2xl">2X Large Text (2xl)</Typography>
</div>
),
],
} satisfies Story;
export const Weights = {
decorators: [
() => (
<div className="space-y-4">
<Typography weight="regular">Regular Weight</Typography>
<Typography weight="medium">Medium Weight</Typography>
<Typography weight="semiBold">Semi Bold Weight</Typography>
<Typography weight="bold">Bold Weight</Typography>
<Typography weight="extraBold">Extra Bold Weight</Typography>
<Typography weight="black">Black Weight</Typography>
</div>
),
],
} satisfies Story;
export const Colors = {
decorators: [
() => (
<div className="space-y-4 flex flex-col gap-4">
<Typography color="default">Default Color</Typography>
<Typography color="primary" className="bg-primary rounded-sm">
Primary Color
</Typography>
<Typography color="secondary">Secondary Color</Typography>
<Typography color="destructive">Destructive Color</Typography>
<Typography color="success">Success Color</Typography>
<Typography color="muted">Muted Color</Typography>
<Typography color="card">Card Color</Typography>
<Typography color="accent">Accent Color</Typography>
</div>
),
],
} satisfies Story;
export const Alignments = {
decorators: [
() => (
<div className="space-y-4 w-full">
<Typography align="left">Left Aligned Text</Typography>
<Typography align="center">Center Aligned Text</Typography>
<Typography align="right">Right Aligned Text</Typography>
</div>
),
],
} satisfies Story;
export const AsElements = {
decorators: [
() => (
<div className="space-y-4">
<Typography as="h1" size="2xl" weight="bold">
Heading 1
</Typography>
<Typography as="h2" size="xl" weight="semiBold">
Heading 2
</Typography>
<Typography as="h3" size="lg" weight="medium">
Heading 3
</Typography>
<Typography as="p" size="md">
Paragraph text with regular styling
</Typography>
<Typography as="span" size="sm" color="muted">
Small span text
</Typography>
</div>
),
],
} satisfies Story;
export const Headings = {
decorators: [
() => (
<div className="space-y-6">
<Typography as="h1" size="2xl" weight="bold" color="default">
Main Heading
</Typography>
<Typography as="h2" size="xl" weight="semiBold" color="secondary">
Section Heading
</Typography>
<Typography as="h3" size="lg" weight="medium" color="default">
Subsection Heading
</Typography>
<Typography as="p" size="md" weight="regular" color="muted">
This is a paragraph of text that demonstrates how the typography
component can be used for body content with proper spacing and
readability.
</Typography>
</div>
),
],
} satisfies Story;
export const CustomStyles = {
args: {
children: "Custom Styled Text",
className: "bg-blue-100 text-blue-800 px-4 py-2 rounded-lg",
size: "lg",
weight: "bold",
},
} satisfies Story;

View File

@ -0,0 +1,92 @@
import { createElement, JSX, ReactNode } from "react";
import { cva, VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const typographyVariants = cva(cn("text-center text-foreground block"), {
variants: {
size: {
xs: "text-xs",
sm: "text-sm",
md: "text-base",
lg: "text-lg",
xl: "text-xl",
"2xl": "text-2xl",
"3xl": "text-3xl",
"4xl": "text-4xl",
},
weight: {
regular: "font-normal",
medium: "font-medium",
semiBold: "font-semibold",
bold: "font-bold",
extraBold: "font-extrabold",
black: "font-black",
},
color: {
default: "text-foreground",
primary: "text-primary-foreground",
secondary: "text-secondary-foreground",
destructive: "text-destructive",
success: "text-success",
card: "text-card-foreground",
accent: "text-accent-foreground",
muted: "text-muted-foreground",
},
align: {
center: "text-center",
left: "text-left",
right: "text-right",
},
font: {
manrope: "font-manrope",
inter: "font-inter",
geistSans: "font-geist-sans",
geistMono: "font-geist-mono",
},
},
defaultVariants: {
size: "md",
weight: "regular",
color: "default",
align: "center",
font: "inter",
},
});
type TypographyElements = Pick<
JSX.IntrinsicElements,
"span" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "div"
>;
export type TypographyProps<T extends keyof TypographyElements = "span"> =
VariantProps<typeof typographyVariants> &
Omit<TypographyElements[T], "color"> & {
as?: T;
children: ReactNode;
};
export default function Typography<
T extends keyof TypographyElements = "span"
>({
as: Component = "span" as T,
children,
className,
weight,
size,
color,
align,
font,
...props
}: TypographyProps<T>) {
return createElement(
Component,
{
className: cn(
typographyVariants({ size, weight, color, align, font }),
className
),
...props,
},
children
);
}

View File

@ -0,0 +1,55 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { Button } from "./button";
import Link from "next/link";
import { fn } from "storybook/test";
/** Reusable Button Component */
const meta: Meta<typeof Button> = {
title: "Shadcn UI/Button",
component: Button,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
args: {
disabled: false,
asChild: false,
onClick: fn(),
},
argTypes: {
asChild: {
control: { type: "boolean" },
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default = {
args: {
children: "Button",
},
} satisfies Story;
export const Disabled = {
args: {
children: "Disabled",
disabled: true,
},
} satisfies Story;
export const AsChild = {
args: {
asChild: true,
children: <Link href="#test">Link Button</Link>,
},
} satisfies Story;
export const CustomStyles = {
args: {
children: "Custom Styled",
className:
"bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600 shadow-lg",
},
} satisfies Story;

View File

@ -0,0 +1,57 @@
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
const buttonVariants = cva(
cn(
"cursor-pointer",
"px-4 py-2",
"disabled:cursor-not-allowed disabled:opacity-70",
"outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive"
),
{
variants: {
variant: {
default: "bg-primary text-primary-foreground",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
);
interface ButtonProps
extends React.ComponentProps<"button">,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
function Button({
className,
asChild = false,
variant,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={buttonVariants({ variant, className })}
{...props}
/>
);
}
export { Button };

View File

@ -0,0 +1,28 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { Checkbox } from "./checkbox";
import { fn } from "storybook/test";
/** Reusable Checkbox Component */
const meta: Meta<typeof Checkbox> = {
title: "Shadcn UI/Checkbox",
component: Checkbox,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
args: {
disabled: false,
onCheckedChange: fn(),
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default = {} satisfies Story;
export const Disabled = {
args: {
disabled: true,
},
} satisfies Story;

View File

@ -0,0 +1,31 @@
"use client";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"cursor-pointer peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-5 shrink-0 rounded-full border-2 shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@ -0,0 +1,78 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { Input } from "./input";
import { fn } from "storybook/test";
/** Reusable Input Component */
const meta: Meta<typeof Input> = {
title: "Shadcn UI/Input",
component: Input,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
args: {
disabled: false,
placeholder: "Placeholder",
onChange: fn(),
onFocus: fn(),
onBlur: fn(),
},
argTypes: {
type: {
control: { type: "select" },
options: ["text", "email", "password", "number"],
},
disabled: {
control: { type: "boolean" },
},
placeholder: {
control: { type: "text" },
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default = {} satisfies Story;
export const WithValue = {
args: {
defaultValue: "Value",
},
} satisfies Story;
export const Email = {
args: {
type: "email",
placeholder: "example@email.com",
},
} satisfies Story;
export const Password = {
args: {
type: "password",
placeholder: "Password",
},
} satisfies Story;
export const Number = {
args: {
type: "number",
placeholder: "Number",
},
} satisfies Story;
export const Disabled = {
args: {
placeholder: "Disabled",
disabled: true,
},
} satisfies Story;
export const WithError = {
args: {
placeholder: "With Error",
"aria-invalid": true,
},
} satisfies Story;

View File

@ -0,0 +1,19 @@
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
);
}
export { Input };

View File

@ -0,0 +1,23 @@
"use client";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
);
}
export { Label };

View File

@ -0,0 +1,103 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { Progress } from "./progress";
import { ActionButton } from "./ActionButton/ActionButton";
import { useState } from "react";
/** Reusable Progress Component */
const meta: Meta<typeof Progress> = {
title: "Shadcn UI/Progress",
component: Progress,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
args: {
value: 50,
},
argTypes: {
value: {
control: { type: "range", min: 0, max: 100, step: 1 },
description: "Progress value from 0 to 100",
},
label: {
control: { type: "text" },
description: "Progress label",
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default = {
args: {
value: 50,
},
} satisfies Story;
export const Empty = {
args: {
value: 0,
},
} satisfies Story;
export const Quarter = {
args: {
value: 25,
},
} satisfies Story;
export const Half = {
args: {
value: 50,
},
} satisfies Story;
export const ThreeQuarters = {
args: {
value: 75,
},
} satisfies Story;
export const Complete = {
args: {
value: 100,
},
} satisfies Story;
export const CustomColors = {
args: {
value: 80,
classNameProgress: "bg-green-200 [&>div]:bg-green-600",
},
} satisfies Story;
export const Animated = {
args: {
value: 0,
},
decorators: [
(Story) => {
const [progress, setProgress] = useState(0);
const handleSetRandomValue = () => {
setProgress(Math.floor(Math.random() * 100));
};
return (
<div className="w-full flex flex-col gap-8">
<Story args={{ value: progress }} />
<ActionButton onClick={handleSetRandomValue}>
Set random value
</ActionButton>
</div>
);
},
],
} satisfies Story;
export const WithLabel = {
args: {
label: "1 of 15",
},
} satisfies Story;

View File

@ -0,0 +1,46 @@
"use client";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
import { Label } from "./label";
interface ProgressProps
extends React.ComponentProps<typeof ProgressPrimitive.Root> {
label?: string;
classNameProgress?: string;
}
function Progress({
className,
classNameProgress,
value,
label,
...props
}: ProgressProps) {
return (
<div className={cn("w-full flex flex-col gap-[3px]", className)}>
{label && (
<Label className="flex-row justify-end w-full px-2 text-right text-muted-foreground font-inter font-medium text-xs">
{label}
</Label>
)}
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-border relative h-1.5 w-full overflow-hidden rounded-full",
classNameProgress
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all rounded-full"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
</div>
);
}
export { Progress };

View File

@ -0,0 +1,27 @@
"use client";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
);
}
export { Separator };

View File

@ -0,0 +1,37 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { AnswersList } from "./AnswersList";
/** Reusable AnswersList Component */
const meta: Meta<typeof AnswersList> = {
title: "Widgets/AnswersList",
component: AnswersList,
tags: ["autodocs"],
args: {
answers: [
{
children: "FEMALE",
emoji: "👩",
isCheckbox: true,
id: "female",
},
{
children: "MALE",
emoji: "👨",
id: "male",
},
{
children: "Receding hairline, want to slow its progress",
id: "without-emoji",
},
],
},
argTypes: {},
render: (args) => {
return <AnswersList {...args} activeAnswers={[args.answers[0]]} />;
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default = {} satisfies Story;

View File

@ -0,0 +1,36 @@
"use client";
import { cn } from "@/lib/utils";
import {
MainButton,
MainButtonProps,
} from "@/components/ui/MainButton/MainButton";
export interface AnswersListProps extends React.ComponentProps<"div"> {
answers: MainButtonProps[];
activeAnswers: MainButtonProps[] | null;
onAnswerClick: (answer: MainButtonProps) => void;
}
function AnswersList({
className,
answers,
activeAnswers,
onAnswerClick,
...props
}: AnswersListProps) {
return (
<div className={cn("flex flex-col gap-3 w-full", className)} {...props}>
{answers.map((answer) => (
<MainButton
key={answer.id}
{...answer}
onClick={() => onAnswerClick(answer)}
active={activeAnswers?.includes(answer)}
/>
))}
</div>
);
}
export { AnswersList };

View File

@ -0,0 +1,53 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { BottomActionButton } from "./BottomActionButton";
import Typography from "@/components/ui/Typography/Typography";
/** Reusable BottomActionButton Component */
const meta: Meta<typeof BottomActionButton> = {
title: "Widgets/BottomActionButton",
component: BottomActionButton,
tags: ["autodocs"],
args: {
actionButtonProps: {
children: "Continue",
},
},
argTypes: {},
render: (args) => (
<div className="relative w-full h-full">
<Typography as="p" size="lg">
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Voluptates
ipsam ea sit libero minus cum consequatur. Atque reprehenderit
perspiciatis dolores accusamus hic, sapiente veniam ex dolorem ullam.
Aspernatur, vitae debitis.
</Typography>
<br />
<Typography as="p" size="lg">
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Voluptates
ipsam ea sit libero minus cum consequatur. Atque reprehenderit
perspiciatis dolores accusamus hic, sapiente veniam ex dolorem ullam.
Aspernatur, vitae debitis.
</Typography>
<br />
<Typography as="p" size="lg">
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Voluptates
ipsam ea sit libero minus cum consequatur. Atque reprehenderit
perspiciatis dolores accusamus hic, sapiente veniam ex dolorem ullam.
Aspernatur, vitae debitis.
</Typography>
<br />
<Typography as="p" size="lg">
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Voluptates
ipsam ea sit libero minus cum consequatur. Atque reprehenderit
perspiciatis dolores accusamus hic, sapiente veniam ex dolorem ullam.
Aspernatur, vitae debitis.
</Typography>
<BottomActionButton {...args} />
</div>
),
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default = {} satisfies Story;

View File

@ -0,0 +1,31 @@
"use client";
import { cn } from "@/lib/utils";
import { GradientBlur } from "../GradientBlur/GradientBlur";
import { ActionButton } from "@/components/ui/ActionButton/ActionButton";
export interface BottomActionButtonProps extends React.ComponentProps<"div"> {
actionButtonProps?: React.ComponentProps<typeof ActionButton>;
}
function BottomActionButton({
actionButtonProps,
className,
...props
}: BottomActionButtonProps) {
return (
<div
className={cn(
"fixed bottom-0 left-[50%] translate-x-[-50%] w-full",
className
)}
{...props}
>
<GradientBlur className="p-6 pt-11">
<ActionButton {...actionButtonProps} />
</GradientBlur>
</div>
);
}
export { BottomActionButton };

View File

@ -0,0 +1,44 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { Coupon } from "./Coupon";
import { fn } from "storybook/test";
import Typography from "@/components/ui/Typography/Typography";
/** Reusable Coupon Component */
const meta: Meta<typeof Coupon> = {
title: "Widgets/Coupon",
component: Coupon,
tags: ["autodocs"],
args: {
title: {
children: "Special Offer",
},
offer: {
title: {
children: "94% OFF",
},
description: {
children: "Одноразовая эксклюзивная скидка",
},
},
promoCode: {
children: "HAIR50",
},
footer: {
children: (
<>
Скопируйте или нажмите{" "}
<Typography size="sm" weight="bold" className="inline text-[#9CA3AF]">
Continue
</Typography>
</>
),
},
onCopyPromoCode: fn(),
},
argTypes: {},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default = {} satisfies Story;

View File

@ -0,0 +1,137 @@
"use client";
import { cn } from "@/lib/utils";
import Typography, {
TypographyProps,
} from "@/components/ui/Typography/Typography";
import { Separator } from "@/components/ui/separator";
import { Files } from "lucide-react";
interface CouponProps extends Omit<React.ComponentProps<"div">, "title"> {
title: TypographyProps<"h3">;
offer: {
title: TypographyProps<"h3">;
description: TypographyProps<"p">;
};
promoCode: TypographyProps<"span">;
footer: TypographyProps<"p">;
onCopyPromoCode: (code: string) => void;
}
function Coupon({
className,
title,
offer,
promoCode,
footer,
onCopyPromoCode,
...props
}: CouponProps) {
return (
<div
className={cn(
"relative",
"overflow-hidden",
"w-full max-w-[342px]",
"px-[32px] pb-3 pt-[34px]",
"flex flex-col items-center",
"bg-gradient-to-r from-[#1F2937] to-[#374151]",
"rounded-3xl",
"shadow-coupon",
"**:z-1",
className
)}
{...props}
>
<svg
width="30"
height="31"
viewBox="0 0 30 31"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.1621 4.78125L13.2012 8.25H13.125H8.90625C7.61133 8.25 6.5625 7.20117 6.5625 5.90625C6.5625 4.61133 7.61133 3.5625 8.90625 3.5625H9.03516C9.9082 3.5625 10.7227 4.02539 11.1621 4.78125ZM3.75 5.90625C3.75 6.75 3.95508 7.54688 4.3125 8.25H1.875C0.837891 8.25 0 9.08789 0 10.125V13.875C0 14.9121 0.837891 15.75 1.875 15.75H28.125C29.1621 15.75 30 14.9121 30 13.875V10.125C30 9.08789 29.1621 8.25 28.125 8.25H25.6875C26.0449 7.54688 26.25 6.75 26.25 5.90625C26.25 3.05859 23.9414 0.75 21.0938 0.75H20.9648C19.0957 0.75 17.3613 1.74023 16.4121 3.35156L15 5.75977L13.5879 3.35742C12.6387 1.74023 10.9043 0.75 9.03516 0.75H8.90625C6.05859 0.75 3.75 3.05859 3.75 5.90625ZM23.4375 5.90625C23.4375 7.20117 22.3887 8.25 21.0938 8.25H16.875H16.7988L18.8379 4.78125C19.2832 4.02539 20.0918 3.5625 20.9648 3.5625H21.0938C22.3887 3.5625 23.4375 4.61133 23.4375 5.90625ZM1.875 17.625V27.9375C1.875 29.4902 3.13477 30.75 4.6875 30.75H13.125V17.625H1.875ZM16.875 30.75H25.3125C26.8652 30.75 28.125 29.4902 28.125 27.9375V17.625H16.875V30.75Z"
fill="#FCD34D"
/>
</svg>
<Typography
as="h3"
size="xl"
weight="bold"
color="primary"
{...title}
className={cn(title.className, "leading-[140%]")}
/>
{offer && (
<div
className={cn(
"w-full",
"bg-gradient-to-r from-[#FCD34D] to-[#F59E0B]",
"rounded-2xl",
"px-5 py-4 mt-3.5"
)}
>
<Typography
as="h3"
size="4xl"
weight="black"
color="card"
{...offer.title}
/>
<Typography
as="p"
weight="semiBold"
color="card"
{...offer.description}
className={cn(
"text-[17px] leading-[100%]",
"mt-2",
offer.description.className
)}
/>
</div>
)}
<div
className={cn(
"relative overflow-hidden",
"h-5",
"mt-[34px] py-2.5 px-8",
"before:content-[''] before:absolute before:top-[50%] before:left-0 before:size-5 before:bg-[#f8fafc] before:rounded-full before:translate-[-50%]",
"after:content-[''] after:absolute after:top-[50%] after:right-0 after:size-5 after:bg-[#f8fafc] after:rounded-full after:translate-x-[50%] after:translate-y-[-50%]"
)}
style={{ width: "calc(100% + 32px * 2)" }}
>
<Separator className="bg-[#4B5563]" decorative />
</div>
{promoCode && (
<div
className="w-full flex items-center justify-center gap-2 mt2 cursor-pointer"
onClick={() => onCopyPromoCode(promoCode.children as string)}
>
<Typography
size="lg"
weight="semiBold"
{...promoCode}
className={cn(promoCode.className, "text-[#FCD34D]")}
/>
<Files color="#FCD34D" size={25} />
</div>
)}
{footer && (
<Typography
as="p"
size="sm"
color="muted"
{...footer}
className={cn(footer.className, "w-full mt-1 mb-5 text-[#9CA3AF]")}
/>
)}
<div className="absolute right-[-16px] top-[-16px] size-16 rounded-full bg-[#5c594a] z-0!" />
<div className="absolute left-[-24px] bottom-[-26px] size-20 rounded-full bg-[#24344c] z-0!" />
</div>
);
}
export { Coupon };

View File

@ -0,0 +1,179 @@
.gradientContainer {
position: relative;
height: 100%;
width: 100%;
text-align: center;
text-align: -webkit-center;
}
.gradientContainer > * {
position: relative;
z-index: 10;
}
.gradientContainer .gradientBlur {
position: absolute;
z-index: 5;
inset: auto 0 0 0;
left: 0;
right: 0;
bottom: -16px;
top: -48px;
height: auto;
pointer-events: none;
}
.gradientContainer .gradientBlur > div,
.gradientContainer .gradientBlur::before,
.gradientContainer .gradientBlur::after {
position: absolute;
inset: 0;
}
.gradientContainer .gradientBlur::before {
content: "";
z-index: 1;
-webkit-backdrop-filter: blur(0.5px);
backdrop-filter: blur(0.5px);
-webkit-mask: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 0%,
black 12.5%,
black 25%,
rgba(0, 0, 0, 0) 37.5%
);
mask: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 0%,
black 12.5%,
black 25%,
rgba(0, 0, 0, 0) 37.5%
);
}
.gradientContainer .gradientBlur > div:nth-of-type(1) {
z-index: 2;
-webkit-backdrop-filter: blur(1px);
backdrop-filter: blur(1px);
-webkit-mask: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 12.5%,
black 25%,
black 37.5%,
rgba(0, 0, 0, 0) 50%
);
mask: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 12.5%,
black 25%,
black 37.5%,
rgba(0, 0, 0, 0) 50%
);
}
.gradientContainer .gradientBlur > div:nth-of-type(2) {
z-index: 3;
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
-webkit-mask: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 25%,
black 37.5%,
black 50%,
rgba(0, 0, 0, 0) 62.5%
);
mask: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 25%,
black 37.5%,
black 50%,
rgba(0, 0, 0, 0) 62.5%
);
}
.gradientContainer .gradientBlur > div:nth-of-type(3) {
z-index: 4;
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
-webkit-mask: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 37.5%,
black 50%,
black 62.5%,
rgba(0, 0, 0, 0) 75%
);
mask: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 37.5%,
black 50%,
black 62.5%,
rgba(0, 0, 0, 0) 75%
);
}
.gradientContainer .gradientBlur > div:nth-of-type(4) {
z-index: 5;
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
-webkit-mask: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 50%,
black 62.5%,
black 75%,
rgba(0, 0, 0, 0) 87.5%
);
mask: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 50%,
black 62.5%,
black 75%,
rgba(0, 0, 0, 0) 87.5%
);
}
.gradientContainer .gradientBlur > div:nth-of-type(5) {
z-index: 6;
-webkit-backdrop-filter: blur(16px);
backdrop-filter: blur(16px);
-webkit-mask: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 62.5%,
black 75%,
black 87.5%,
rgba(0, 0, 0, 0) 100%
);
mask: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 62.5%,
black 75%,
black 87.5%,
rgba(0, 0, 0, 0) 100%
);
}
.gradientContainer .gradientBlur > div:nth-of-type(6) {
z-index: 7;
-webkit-backdrop-filter: blur(32px);
backdrop-filter: blur(32px);
-webkit-mask: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 75%,
black 87.5%,
black 100%
);
mask: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 75%,
black 87.5%,
black 100%
);
}
.gradientContainer .gradientBlur::after {
content: "";
z-index: 8;
-webkit-backdrop-filter: blur(64px);
backdrop-filter: blur(64px);
-webkit-mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 87.5%, black 100%);
mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 87.5%, black 100%);
}

View File

@ -0,0 +1,52 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { GradientBlur } from "./GradientBlur";
import Typography from "@/components/ui/Typography/Typography";
/** Reusable GradientBlur Component */
const meta: Meta<typeof GradientBlur> = {
title: "Widgets/GradientBlur",
component: GradientBlur,
tags: ["autodocs"],
args: {
children: (
<div className="w-[200px] mt-[30px] text-center p-8 bg-secondary h-[100px]">
Children
</div>
),
},
argTypes: {},
render: (args) => (
<div className="relative w-full h-full">
<Typography as="p" size="lg">
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Voluptates
ipsam ea sit libero minus cum consequatur. Atque reprehenderit
perspiciatis dolores accusamus hic, sapiente veniam ex dolorem ullam.
Aspernatur, vitae debitis.
</Typography>
<br />
<Typography as="p" size="lg">
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Voluptates
ipsam ea sit libero minus cum consequatur. Atque reprehenderit
perspiciatis dolores accusamus hic, sapiente veniam ex dolorem ullam.
Aspernatur, vitae debitis.
</Typography>
<Typography as="p" size="lg">
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Voluptates
ipsam ea sit libero minus cum consequatur. Atque reprehenderit
perspiciatis dolores accusamus hic, sapiente veniam ex dolorem ullam.
Aspernatur, vitae debitis.
</Typography>
<div className="fixed bottom-0 left-0 right-0">
<GradientBlur
{...args}
className="py-5"
/>
</div>
</div>
),
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default = {} satisfies Story;

View File

@ -0,0 +1,35 @@
"use client";
import { cn } from "@/lib/utils";
import styles from "./GradientBlur.module.css";
interface GradientBlurProps extends React.ComponentProps<"div"> {
gradientClassName?: string;
isActiveBlur?: boolean;
}
function GradientBlur({
className,
children,
gradientClassName,
isActiveBlur = true,
...props
}: GradientBlurProps) {
return (
<div className={cn(styles.gradientContainer, className)} {...props}>
{children}
{isActiveBlur && (
<div className={cn(styles.gradientBlur, gradientClassName)}>
<div />
<div />
<div />
<div />
<div />
<div />
</div>
)}
</div>
);
}
export { GradientBlur };

View File

@ -0,0 +1,43 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { RadioAnswersList } from "./RadioAnswersList";
import { fn } from "storybook/test";
/** Reusable RadioAnswersList Component */
const meta: Meta<typeof RadioAnswersList> = {
title: "Widgets/RadioAnswersList",
component: RadioAnswersList,
tags: ["autodocs"],
args: {
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",
},
onChangeSelectedAnswer: fn(),
onAnswerClick: fn(),
},
argTypes: {},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default = {} satisfies Story;

View File

@ -0,0 +1,53 @@
"use client";
import { cn } from "@/lib/utils";
import {
MainButton,
MainButtonProps,
} from "@/components/ui/MainButton/MainButton";
import { useEffect, useState } from "react";
export interface RadioAnswersListProps extends React.ComponentProps<"div"> {
answers: MainButtonProps[];
activeAnswer: MainButtonProps | null;
onAnswerClick?: (answer: MainButtonProps | null) => void;
onChangeSelectedAnswer?: (answer: MainButtonProps | null) => void;
}
function RadioAnswersList({
className,
answers,
activeAnswer,
onAnswerClick,
onChangeSelectedAnswer,
...props
}: RadioAnswersListProps) {
const [selectedAnswer, setSelectedAnswer] = useState<MainButtonProps | null>(
activeAnswer
);
const handleAnswerClick = (answer: MainButtonProps) => {
setSelectedAnswer(answer);
onAnswerClick?.(answer);
};
useEffect(() => {
onChangeSelectedAnswer?.(selectedAnswer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedAnswer]);
return (
<div className={cn("flex flex-col gap-3 w-full", className)} {...props}>
{answers.map((answer) => (
<MainButton
key={answer.id}
{...answer}
onClick={() => handleAnswerClick(answer)}
active={selectedAnswer?.id === answer.id}
/>
))}
</div>
);
}
export { RadioAnswersList };

View File

@ -0,0 +1,58 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { SelectAnswersList } from "./SelectAnswersList";
import { fn } from "storybook/test";
/** Reusable SelectAnswersList Component */
const meta: Meta<typeof SelectAnswersList> = {
title: "Widgets/SelectAnswersList",
component: SelectAnswersList,
tags: ["autodocs"],
args: {
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",
},
],
onChangeSelectedAnswers: fn(),
},
argTypes: {},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default = {} satisfies Story;

View File

@ -0,0 +1,59 @@
"use client";
import { cn } from "@/lib/utils";
import {
MainButton,
MainButtonProps,
} from "@/components/ui/MainButton/MainButton";
import { useEffect, useState } from "react";
export interface SelectAnswersListProps extends React.ComponentProps<"div"> {
answers: MainButtonProps[];
activeAnswers: MainButtonProps[] | null;
onChangeSelectedAnswers?: (answers: MainButtonProps[] | null) => void;
onAnswerClick?: (answer: MainButtonProps | null) => void;
}
function SelectAnswersList({
className,
answers,
activeAnswers,
onChangeSelectedAnswers,
onAnswerClick,
...props
}: SelectAnswersListProps) {
const [selectedAnswers, setSelectedAnswers] = useState<
MainButtonProps[] | null
>(activeAnswers);
const handleAnswerClick = (answer: MainButtonProps) => {
if (selectedAnswers?.some((a) => a.id === answer.id)) {
setSelectedAnswers(
(prev) => prev?.filter((a) => a.id !== answer.id) || null
);
} else {
setSelectedAnswers((prev) => [...(prev || []), answer]);
}
onAnswerClick?.(answer);
};
useEffect(() => {
onChangeSelectedAnswers?.(selectedAnswers);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedAnswers]);
return (
<div className={cn("flex flex-col gap-3 w-full", className)} {...props}>
{answers.map((answer) => (
<MainButton
key={answer.id}
{...answer}
onClick={() => handleAnswerClick(answer)}
active={selectedAnswers?.some((a) => a.id === answer.id)}
/>
))}
</div>
);
}
export { SelectAnswersList };

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

35
vitest.config.ts Normal file
View File

@ -0,0 +1,35 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
const dirname =
typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
export default defineConfig({
test: {
projects: [
{
extends: true,
plugins: [
// The plugin will run tests for the stories defined in your Storybook config
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
storybookTest({ configDir: path.join(dirname, '.storybook') }),
],
test: {
name: 'storybook',
browser: {
enabled: true,
headless: true,
provider: 'playwright',
instances: [{ browser: 'chromium' }]
},
setupFiles: ['.storybook/vitest.setup.ts'],
},
},
],
},
});

1
vitest.shims.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="@vitest/browser/providers/playwright" />