B2B-88: add starter kit structure and elements

This commit is contained in:
devmc-ee
2025-06-08 16:18:30 +03:00
parent 657a36a298
commit e7b25600cb
1280 changed files with 77893 additions and 5688 deletions

12
packages/ui/README.md Normal file
View File

@@ -0,0 +1,12 @@
# UI - @kit/ui
This package is responsible for managing the UI components and styles across the app.
This package define two sets of components:
- `Shadcn UI`: A set of UI components that can be used across the app using shadcn UI
- `Makerkit-specific`: Components specific to MakerKit
## Installing a Shadcn UI component
Please refer to the [documentation](https://makerkit.dev/docs/next-supabase-turbo/components/shadcn).

View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "./tailwind.config.ts",
"css": "../../apps/web/styles/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "~/components",
"utils": "~/utils",
"lib": "~/lib",
"hooks": "~/hooks",
"ui": "~/ui"
}
}

View File

@@ -0,0 +1,3 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

17
packages/ui/node_modules/.bin/acorn generated vendored Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/acorn@8.14.1/node_modules/acorn/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/acorn@8.14.1/node_modules/acorn/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/acorn@8.14.1/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/acorn@8.14.1/node_modules/acorn/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/acorn@8.14.1/node_modules/acorn/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/acorn@8.14.1/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../../../../node_modules/.pnpm/acorn@8.14.1/node_modules/acorn/bin/acorn" "$@"
else
exec node "$basedir/../../../../node_modules/.pnpm/acorn@8.14.1/node_modules/acorn/bin/acorn" "$@"
fi

17
packages/ui/node_modules/.bin/eslint generated vendored Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/eslint@9.28.0_jiti@2.4.2/node_modules/eslint/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/eslint@9.28.0_jiti@2.4.2/node_modules/eslint/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/eslint@9.28.0_jiti@2.4.2/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/eslint@9.28.0_jiti@2.4.2/node_modules/eslint/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/eslint@9.28.0_jiti@2.4.2/node_modules/eslint/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/eslint@9.28.0_jiti@2.4.2/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../eslint/bin/eslint.js" "$@"
else
exec node "$basedir/../eslint/bin/eslint.js" "$@"
fi

17
packages/ui/node_modules/.bin/jiti generated vendored Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/jiti@2.4.2/node_modules/jiti/lib/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/jiti@2.4.2/node_modules/jiti/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/jiti@2.4.2/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/jiti@2.4.2/node_modules/jiti/lib/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/jiti@2.4.2/node_modules/jiti/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/jiti@2.4.2/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../../../../node_modules/.pnpm/jiti@2.4.2/node_modules/jiti/lib/jiti-cli.mjs" "$@"
else
exec node "$basedir/../../../../node_modules/.pnpm/jiti@2.4.2/node_modules/jiti/lib/jiti-cli.mjs" "$@"
fi

17
packages/ui/node_modules/.bin/next generated vendored Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../next/dist/bin/next" "$@"
else
exec node "$basedir/../next/dist/bin/next" "$@"
fi

17
packages/ui/node_modules/.bin/prettier generated vendored Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/prettier@3.5.3/node_modules/prettier/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/prettier@3.5.3/node_modules/prettier/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/prettier@3.5.3/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/prettier@3.5.3/node_modules/prettier/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/prettier@3.5.3/node_modules/prettier/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/prettier@3.5.3/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../prettier/bin/prettier.cjs" "$@"
else
exec node "$basedir/../prettier/bin/prettier.cjs" "$@"
fi

17
packages/ui/node_modules/.bin/tsc generated vendored Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
else
exec node "$basedir/../typescript/bin/tsc" "$@"
fi

17
packages/ui/node_modules/.bin/tsserver generated vendored Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
else
exec node "$basedir/../typescript/bin/tsserver" "$@"
fi

1
packages/ui/node_modules/@hookform/resolvers generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@hookform+resolvers@5.0.1_react-hook-form@7.57.0_react@19.1.0_/node_modules/@hookform/resolvers

1
packages/ui/node_modules/@kit/eslint-config generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../tooling/eslint

1
packages/ui/node_modules/@kit/prettier-config generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../tooling/prettier

1
packages/ui/node_modules/@kit/tsconfig generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../tooling/typescript

1
packages/ui/node_modules/@radix-ui/react-accordion generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@radix-ui+react-accordion@1.2.10_@types+react-dom@19.1.5_@types+react@19.1.4__@types+react@19_276ts6szh5d7wceq5mmbnr3r34/node_modules/@radix-ui/react-accordion

1
packages/ui/node_modules/@radix-ui/react-alert-dialog generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@radix-ui+react-alert-dialog@1.1.14_@types+react-dom@19.1.5_@types+react@19.1.4__@types+react_5eo2wpyry5lq6otvq6vukhap6u/node_modules/@radix-ui/react-alert-dialog

1
packages/ui/node_modules/@radix-ui/react-avatar generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@radix-ui+react-avatar@1.1.10_@types+react-dom@19.1.5_@types+react@19.1.4__@types+react@19.1._k4cxjn22xmk3d6pfeutggmwlxu/node_modules/@radix-ui/react-avatar

1
packages/ui/node_modules/@radix-ui/react-checkbox generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@radix-ui+react-checkbox@1.3.2_@types+react-dom@19.1.5_@types+react@19.1.4__@types+react@19.1_i43z5xuo5cvwh2ajr2nuyuvbiu/node_modules/@radix-ui/react-checkbox

1
packages/ui/node_modules/@radix-ui/react-collapsible generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@radix-ui+react-collapsible@1.1.10_@types+react-dom@19.1.5_@types+react@19.1.4__@types+react@_olxjlfafkwzr54726esnyk7x6i/node_modules/@radix-ui/react-collapsible

1
packages/ui/node_modules/@radix-ui/react-dialog generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@radix-ui+react-dialog@1.1.14_@types+react-dom@19.1.5_@types+react@19.1.4__@types+react@19.1._rkbehisjntwsub56xkrsj32e6i/node_modules/@radix-ui/react-dialog

1
packages/ui/node_modules/@radix-ui/react-dropdown-menu generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@radix-ui+react-dropdown-menu@2.1.15_@types+react-dom@19.1.5_@types+react@19.1.4__@types+reac_hg5bonns56tjkcevnbgns3gx4a/node_modules/@radix-ui/react-dropdown-menu

1
packages/ui/node_modules/@radix-ui/react-icons generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@19.1.0/node_modules/@radix-ui/react-icons

1
packages/ui/node_modules/@radix-ui/react-label generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@radix-ui+react-label@2.1.7_@types+react-dom@19.1.5_@types+react@19.1.4__@types+react@19.1.4__nsqt2f5qehtsflc4wfzvi6atei/node_modules/@radix-ui/react-label

View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@radix-ui+react-navigation-menu@1.2.13_@types+react-dom@19.1.5_@types+react@19.1.4__@types+re_25l3odzhnomqo2lplr4kbsu544/node_modules/@radix-ui/react-navigation-menu

1
packages/ui/node_modules/@radix-ui/react-popover generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@radix-ui+react-popover@1.1.14_@types+react-dom@19.1.5_@types+react@19.1.4__@types+react@19.1_65m3pafqiqarw3txyhscrjgc4m/node_modules/@radix-ui/react-popover

1
packages/ui/node_modules/@radix-ui/react-progress generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@radix-ui+react-progress@1.1.7_@types+react-dom@19.1.5_@types+react@19.1.4__@types+react@19.1_7z57k5bis3kw4znwzmaswqrek4/node_modules/@radix-ui/react-progress

1
packages/ui/node_modules/@radix-ui/react-radio-group generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@radix-ui+react-radio-group@1.3.7_@types+react-dom@19.1.5_@types+react@19.1.4__@types+react@1_nvrils4nuk25wzasvf5d65rawm/node_modules/@radix-ui/react-radio-group

1
packages/ui/node_modules/@radix-ui/react-scroll-area generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@radix-ui+react-scroll-area@1.2.9_@types+react-dom@19.1.5_@types+react@19.1.4__@types+react@1_s7m47d63jehkjk322rozzgjzli/node_modules/@radix-ui/react-scroll-area

1
packages/ui/node_modules/@radix-ui/react-select generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@radix-ui+react-select@2.2.5_@types+react-dom@19.1.5_@types+react@19.1.4__@types+react@19.1.4_idwjmazqqb5inr24r4uoit5qoq/node_modules/@radix-ui/react-select

1
packages/ui/node_modules/@radix-ui/react-separator generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@radix-ui+react-separator@1.1.7_@types+react-dom@19.1.5_@types+react@19.1.4__@types+react@19._bxta4gfgljvxdwq2fwealllwhi/node_modules/@radix-ui/react-separator

1
packages/ui/node_modules/@radix-ui/react-slot generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@radix-ui+react-slot@1.2.3_@types+react@19.1.4_react@19.1.0/node_modules/@radix-ui/react-slot

1
packages/ui/node_modules/@radix-ui/react-switch generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@radix-ui+react-switch@1.2.5_@types+react-dom@19.1.5_@types+react@19.1.4__@types+react@19.1.4_qbdkpezz7pkilhnnxsmlwaciya/node_modules/@radix-ui/react-switch

1
packages/ui/node_modules/@radix-ui/react-tabs generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@radix-ui+react-tabs@1.1.12_@types+react-dom@19.1.5_@types+react@19.1.4__@types+react@19.1.4__7p6etykbuyytgn4vfknnthm3zi/node_modules/@radix-ui/react-tabs

1
packages/ui/node_modules/@radix-ui/react-toast generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@radix-ui+react-toast@1.2.14_@types+react-dom@19.1.5_@types+react@19.1.4__@types+react@19.1.4_rnvc3r5kiytboqem4byg4a5oiu/node_modules/@radix-ui/react-toast

1
packages/ui/node_modules/@radix-ui/react-tooltip generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@radix-ui+react-tooltip@1.2.6_@types+react-dom@19.1.5_@types+react@19.1.4__@types+react@19.1._jlcsazbxuccgkjjedxl77qni6a/node_modules/@radix-ui/react-tooltip

1
packages/ui/node_modules/@tanstack/react-query generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@tanstack+react-query@5.76.1_react@19.1.0/node_modules/@tanstack/react-query

1
packages/ui/node_modules/@tanstack/react-table generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@tanstack+react-table@8.21.3_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-table

1
packages/ui/node_modules/@types/react generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@types+react@19.1.4/node_modules/@types/react

1
packages/ui/node_modules/@types/react-dom generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@types+react-dom@19.1.5_@types+react@19.1.4/node_modules/@types/react-dom

1
packages/ui/node_modules/class-variance-authority generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/class-variance-authority@0.7.1/node_modules/class-variance-authority

1
packages/ui/node_modules/clsx generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/clsx@2.1.1/node_modules/clsx

1
packages/ui/node_modules/cmdk generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/cmdk@1.1.1_@types+react-dom@19.1.5_@types+react@19.1.4__@types+react@19.1.4_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/cmdk

1
packages/ui/node_modules/date-fns generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/date-fns@4.1.0/node_modules/date-fns

1
packages/ui/node_modules/eslint generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/eslint@9.28.0_jiti@2.4.2/node_modules/eslint

1
packages/ui/node_modules/input-otp generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/input-otp@1.4.2_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/input-otp

1
packages/ui/node_modules/lucide-react generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/lucide-react@0.510.0_react@19.1.0/node_modules/lucide-react

1
packages/ui/node_modules/next generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next

1
packages/ui/node_modules/next-themes generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/next-themes@0.4.6_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/next-themes

1
packages/ui/node_modules/prettier generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/prettier@3.5.3/node_modules/prettier

1
packages/ui/node_modules/react-day-picker generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/react-day-picker@8.10.1_date-fns@4.1.0_react@19.1.0/node_modules/react-day-picker

1
packages/ui/node_modules/react-hook-form generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/react-hook-form@7.57.0_react@19.1.0/node_modules/react-hook-form

1
packages/ui/node_modules/react-i18next generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/react-i18next@15.5.2_i18next@25.1.3_typescript@5.8.3__react-dom@19.1.0_react@19.1.0__react@19.1.0_typescript@5.8.3/node_modules/react-i18next

1
packages/ui/node_modules/react-top-loading-bar generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/react-top-loading-bar@3.0.2_react@19.1.0/node_modules/react-top-loading-bar

1
packages/ui/node_modules/recharts generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/recharts@2.15.3_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/recharts

1
packages/ui/node_modules/sonner generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/sonner@2.0.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/sonner

1
packages/ui/node_modules/tailwind-merge generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/tailwind-merge@3.3.0/node_modules/tailwind-merge

1
packages/ui/node_modules/tailwindcss generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/tailwindcss@4.1.7/node_modules/tailwindcss

1
packages/ui/node_modules/tailwindcss-animate generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/tailwindcss-animate@1.0.7_tailwindcss@4.1.7/node_modules/tailwindcss-animate

1
packages/ui/node_modules/typescript generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript

1
packages/ui/node_modules/zod generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/zod@3.25.56/node_modules/zod

135
packages/ui/package.json Normal file
View File

@@ -0,0 +1,135 @@
{
"name": "@kit/ui",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-accordion": "1.2.10",
"@radix-ui/react-alert-dialog": "^1.1.13",
"@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-collapsible": "1.1.10",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-navigation-menu": "^1.2.12",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-progress": "^1.1.6",
"@radix-ui/react-radio-group": "^1.3.6",
"@radix-ui/react-scroll-area": "^1.2.8",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-toast": "^1.2.13",
"@radix-ui/react-tooltip": "1.2.6",
"clsx": "^2.1.1",
"cmdk": "1.1.1",
"input-otp": "1.4.2",
"lucide-react": "^0.510.0",
"react-top-loading-bar": "3.0.2",
"recharts": "2.15.3",
"tailwind-merge": "^3.3.0"
},
"devDependencies": {
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@radix-ui/react-icons": "^1.3.2",
"@tanstack/react-query": "5.76.1",
"@tanstack/react-table": "^8.21.3",
"@types/react": "19.1.4",
"@types/react-dom": "19.1.5",
"class-variance-authority": "^0.7.1",
"date-fns": "^4.1.0",
"eslint": "^9.26.0",
"next": "15.3.2",
"next-themes": "0.4.6",
"prettier": "^3.5.3",
"react-day-picker": "^8.10.1",
"react-hook-form": "^7.56.3",
"react-i18next": "^15.5.1",
"sonner": "^2.0.3",
"tailwindcss": "4.1.7",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.8.3",
"zod": "^3.24.4"
},
"prettier": "@kit/prettier-config",
"exports": {
"./accordion": "./src/shadcn/accordion.tsx",
"./alert-dialog": "./src/shadcn/alert-dialog.tsx",
"./avatar": "./src/shadcn/avatar.tsx",
"./button": "./src/shadcn/button.tsx",
"./calendar": "./src/shadcn/calendar.tsx",
"./card": "./src/shadcn/card.tsx",
"./checkbox": "./src/shadcn/checkbox.tsx",
"./command": "./src/shadcn/command.tsx",
"./data-table": "./src/shadcn/data-table.tsx",
"./dialog": "./src/shadcn/dialog.tsx",
"./dropdown-menu": "./src/shadcn/dropdown-menu.tsx",
"./navigation-menu": "./src/shadcn/navigation-menu.tsx",
"./form": "./src/shadcn/form.tsx",
"./input": "./src/shadcn/input.tsx",
"./label": "./src/shadcn/label.tsx",
"./popover": "./src/shadcn/popover.tsx",
"./scroll-area": "./src/shadcn/scroll-area.tsx",
"./select": "./src/shadcn/select.tsx",
"./sheet": "./src/shadcn/sheet.tsx",
"./table": "./src/shadcn/table.tsx",
"./tabs": "./src/shadcn/tabs.tsx",
"./tooltip": "./src/shadcn/tooltip.tsx",
"./sonner": "./src/shadcn/sonner.tsx",
"./heading": "./src/shadcn/heading.tsx",
"./alert": "./src/shadcn/alert.tsx",
"./badge": "./src/shadcn/badge.tsx",
"./radio-group": "./src/shadcn/radio-group.tsx",
"./separator": "./src/shadcn/separator.tsx",
"./input-otp": "./src/shadcn/input-otp.tsx",
"./textarea": "./src/shadcn/textarea.tsx",
"./switch": "./src/shadcn/switch.tsx",
"./breadcrumb": "./src/shadcn/breadcrumb.tsx",
"./chart": "./src/shadcn/chart.tsx",
"./skeleton": "./src/shadcn/skeleton.tsx",
"./shadcn-sidebar": "./src/shadcn/sidebar.tsx",
"./collapsible": "./src/shadcn/collapsible.tsx",
"./utils": "./src/lib/utils/index.ts",
"./if": "./src/makerkit/if.tsx",
"./trans": "./src/makerkit/trans.tsx",
"./sidebar": "./src/makerkit/sidebar.tsx",
"./navigation-schema": "./src/makerkit/navigation-config.schema.ts",
"./bordered-navigation-menu": "./src/makerkit/bordered-navigation-menu.tsx",
"./spinner": "./src/makerkit/spinner.tsx",
"./page": "./src/makerkit/page.tsx",
"./image-uploader": "./src/makerkit/image-uploader.tsx",
"./global-loader": "./src/makerkit/global-loader.tsx",
"./auth-change-listener": "./src/makerkit/auth-change-listener.tsx",
"./loading-overlay": "./src/makerkit/loading-overlay.tsx",
"./profile-avatar": "./src/makerkit/profile-avatar.tsx",
"./mode-toggle": "./src/makerkit/mode-toggle.tsx",
"./enhanced-data-table": "./src/makerkit/data-table.tsx",
"./language-selector": "./src/makerkit/language-selector.tsx",
"./stepper": "./src/makerkit/stepper.tsx",
"./cookie-banner": "./src/makerkit/cookie-banner.tsx",
"./card-button": "./src/makerkit/card-button.tsx",
"./version-updater": "./src/makerkit/version-updater.tsx",
"./multi-step-form": "./src/makerkit/multi-step-form.tsx",
"./app-breadcrumbs": "./src/makerkit/app-breadcrumbs.tsx",
"./empty-state": "./src/makerkit/empty-state.tsx",
"./marketing": "./src/makerkit/marketing/index.tsx"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
}
}

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
const MOBILE_BREAKPOINT = 1024;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener('change', onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener('change', onChange);
}, []);
return !!isMobile;
}

View File

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

View File

@@ -0,0 +1,2 @@
export * from './cn';
export * from './is-route-active';

View File

@@ -0,0 +1,108 @@
const ROOT_PATH = '/';
/**
* @name isRouteActive
* @description A function to check if a route is active. This is used to
* @param end
* @param path
* @param currentPath
*/
export function isRouteActive(
path: string,
currentPath: string,
end?: boolean | ((path: string) => boolean),
) {
// if the path is the same as the current path, we return true
if (path === currentPath) {
return true;
}
// if the end prop is a function, we call it with the current path
if (typeof end === 'function') {
return !end(currentPath);
}
// otherwise - we use the evaluateIsRouteActive function
const defaultEnd = end ?? true;
const oneLevelDeep = 1;
const threeLevelsDeep = 3;
// how far down should segments be matched?
const depth = defaultEnd ? oneLevelDeep : threeLevelsDeep;
return checkIfRouteIsActive(path, currentPath, depth);
}
/**
* @name checkIfRouteIsActive
* @description A function to check if a route is active. This is used to
* highlight the active link in the navigation.
* @param targetLink - The link to check against
* @param currentRoute - the current route
* @param depth - how far down should segments be matched?
*/
export function checkIfRouteIsActive(
targetLink: string,
currentRoute: string,
depth = 1,
) {
// we remove any eventual query param from the route's URL
const currentRoutePath = currentRoute.split('?')[0] ?? '';
if (!isRoot(currentRoutePath) && isRoot(targetLink)) {
return false;
}
if (!currentRoutePath.includes(targetLink)) {
return false;
}
const isSameRoute = targetLink === currentRoutePath;
if (isSameRoute) {
return true;
}
return hasMatchingSegments(targetLink, currentRoutePath, depth);
}
function splitIntoSegments(href: string) {
return href.split('/').filter(Boolean);
}
function hasMatchingSegments(
targetLink: string,
currentRoute: string,
depth: number,
) {
const segments = splitIntoSegments(targetLink);
const matchingSegments = numberOfMatchingSegments(currentRoute, segments);
if (targetLink === currentRoute) {
return true;
}
// how far down should segments be matched?
// - if depth = 1 => only highlight the links of the immediate parent
// - if depth = 2 => for url = /account match /account/organization/members
return matchingSegments > segments.length - (depth - 1);
}
function numberOfMatchingSegments(href: string, segments: string[]) {
let count = 0;
for (const segment of splitIntoSegments(href)) {
// for as long as the segments match, keep counting + 1
if (segments.includes(segment)) {
count += 1;
} else {
return count;
}
}
return count;
}
function isRoot(path: string) {
return path === ROOT_PATH;
}

View File

@@ -0,0 +1,89 @@
'use client';
import { Fragment } from 'react';
import { usePathname } from 'next/navigation';
import {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from '../shadcn/breadcrumb';
import { If } from './if';
import { Trans } from './trans';
const unslugify = (slug: string) => slug.replace(/-/g, ' ');
export function AppBreadcrumbs(props: {
values?: Record<string, string>;
maxDepth?: number;
}) {
const pathName = usePathname();
const splitPath = pathName.split('/').filter(Boolean);
const values = props.values ?? {};
const maxDepth = props.maxDepth ?? 6;
const Ellipsis = (
<BreadcrumbItem>
<BreadcrumbEllipsis className="h-4 w-4" />
</BreadcrumbItem>
);
const showEllipsis = splitPath.length > maxDepth;
const visiblePaths = showEllipsis
? ([splitPath[0], ...splitPath.slice(-maxDepth + 1)] as string[])
: splitPath;
return (
<Breadcrumb>
<BreadcrumbList>
{visiblePaths.map((path, index) => {
const label =
path in values ? (
values[path]
) : (
<Trans
i18nKey={`common:routes.${unslugify(path)}`}
defaults={unslugify(path)}
/>
);
return (
<Fragment key={index}>
<BreadcrumbItem className={'capitalize lg:text-xs'}>
<If
condition={index < visiblePaths.length - 1}
fallback={label}
>
<BreadcrumbLink
href={
'/' +
splitPath.slice(0, splitPath.indexOf(path) + 1).join('/')
}
>
{label}
</BreadcrumbLink>
</If>
</BreadcrumbItem>
{index === 0 && showEllipsis && (
<>
<BreadcrumbSeparator />
{Ellipsis}
</>
)}
<If condition={index !== visiblePaths.length - 1}>
<BreadcrumbSeparator />
</If>
</Fragment>
);
})}
</BreadcrumbList>
</Breadcrumb>
);
}

View File

@@ -0,0 +1,17 @@
'use client';
export function AuthenticityToken() {
const token = useCsrfToken();
return <input type="hidden" name="csrf_token" value={token} />;
}
function useCsrfToken() {
if (typeof window === 'undefined') return '';
return (
document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content') ?? ''
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn, isRouteActive } from '../lib/utils';
import { Button } from '../shadcn/button';
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuList,
} from '../shadcn/navigation-menu';
import { Trans } from './trans';
export function BorderedNavigationMenu(props: React.PropsWithChildren) {
return (
<NavigationMenu>
<NavigationMenuList className={'relative h-full space-x-2'}>
{props.children}
</NavigationMenuList>
</NavigationMenu>
);
}
export function BorderedNavigationMenuItem(props: {
path: string;
label: React.ReactNode | string;
end?: boolean | ((path: string) => boolean);
active?: boolean;
className?: string;
buttonClassName?: string;
}) {
const pathname = usePathname();
const active = props.active ?? isRouteActive(props.path, pathname, props.end);
return (
<NavigationMenuItem className={props.className}>
<Button
asChild
variant={'ghost'}
className={cn('relative active:shadow-xs', props.buttonClassName)}
>
<Link
href={props.path}
className={cn('text-sm', {
'text-secondary-foreground': active,
'text-secondary-foreground/80 hover:text-secondary-foreground':
!active,
})}
>
{typeof props.label === 'string' ? (
<Trans i18nKey={props.label} defaults={props.label} />
) : (
props.label
)}
{active ? (
<span
className={cn(
'bg-primary animate-in fade-in zoom-in-90 absolute -bottom-2.5 left-0 h-0.5 w-full',
)}
/>
) : null}
</Link>
</Button>
</NavigationMenuItem>
);
}

View File

@@ -0,0 +1,117 @@
import * as React from 'react';
import { Slot, Slottable } from '@radix-ui/react-slot';
import { ChevronRight } from 'lucide-react';
import { cn } from '../lib/utils';
export const CardButton: React.FC<
{
asChild?: boolean;
className?: string;
children: React.ReactNode;
} & React.ButtonHTMLAttributes<HTMLButtonElement>
> = function CardButton({ className, asChild, ...props }) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(
'group hover:bg-secondary/20 active:bg-secondary active:bg-secondary/50 dark:shadow-primary/20 relative flex h-36 flex-col rounded-lg border transition-all hover:shadow-xs active:shadow-lg',
className,
)}
{...props}
>
<Slottable>{props.children}</Slottable>
</Comp>
);
};
export const CardButtonTitle: React.FC<
{
asChild?: boolean;
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>
> = function CardButtonTitle({ className, asChild, ...props }) {
const Comp = asChild ? Slot : 'div';
return (
<Comp
className={cn(
className,
'text-muted-foreground group-hover:text-secondary-foreground align-super text-sm font-medium transition-colors',
)}
{...props}
>
<Slottable>{props.children}</Slottable>
</Comp>
);
};
export const CardButtonHeader: React.FC<
{
children: React.ReactNode;
asChild?: boolean;
displayArrow?: boolean;
} & React.HTMLAttributes<HTMLDivElement>
> = function CardButtonHeader({
className,
asChild,
displayArrow = true,
...props
}) {
const Comp = asChild ? Slot : 'div';
return (
<Comp className={cn(className, 'p-4')} {...props}>
<Slottable>
{props.children}
<ChevronRight
className={cn(
'text-muted-foreground group-hover:text-secondary-foreground absolute top-4 right-2 h-4 transition-colors',
{
hidden: !displayArrow,
},
)}
/>
</Slottable>
</Comp>
);
};
export const CardButtonContent: React.FC<
{
asChild?: boolean;
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>
> = function CardButtonContent({ className, asChild, ...props }) {
const Comp = asChild ? Slot : 'div';
return (
<Comp className={cn(className, 'flex flex-1 flex-col px-4')} {...props}>
<Slottable>{props.children}</Slottable>
</Comp>
);
};
export const CardButtonFooter: React.FC<
{
asChild?: boolean;
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>
> = function CardButtonFooter({ className, asChild, ...props }) {
const Comp = asChild ? Slot : 'div';
return (
<Comp
className={cn(
className,
'mt-auto flex h-0 w-full flex-col justify-center border-t px-4',
)}
{...props}
>
<Slottable>{props.children}</Slottable>
</Comp>
);
};

View File

@@ -0,0 +1,11 @@
import { createContext } from 'react';
const SidebarContext = createContext<{
collapsed: boolean;
setCollapsed: (collapsed: boolean) => void;
}>({
collapsed: false,
setCollapsed: (_) => _,
});
export { SidebarContext };

View File

@@ -0,0 +1,118 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { Button } from '../shadcn/button';
import { Heading } from '../shadcn/heading';
import { Trans } from './trans';
// configure this as you wish
const COOKIE_CONSENT_STATUS = 'cookie_consent_status';
enum ConsentStatus {
Accepted = 'accepted',
Rejected = 'rejected',
Unknown = 'unknown',
}
export function CookieBanner() {
const { status, accept, reject } = useCookieConsent();
if (!isBrowser()) {
return null;
}
if (status !== ConsentStatus.Unknown) {
return null;
}
return (
<DialogPrimitive.Root open modal={false}>
<DialogPrimitive.Content
onOpenAutoFocus={(e) => e.preventDefault()}
className={`dark:shadow-primary-500/40 bg-background animate-in fade-in zoom-in-95 slide-in-from-bottom-16 fill-mode-both fixed bottom-0 w-full max-w-lg border p-6 shadow-2xl delay-1000 duration-1000 lg:bottom-[2rem] lg:left-[2rem] lg:h-48 lg:rounded-lg`}
>
<div className={'flex flex-col space-y-4'}>
<div>
<Heading level={3}>
<Trans i18nKey={'cookieBanner.title'} />
</Heading>
</div>
<div className={'text-gray-500 dark:text-gray-400'}>
<Trans i18nKey={'cookieBanner.description'} />
</div>
<div className={'flex justify-end space-x-2.5'}>
<Button variant={'ghost'} onClick={reject}>
<Trans i18nKey={'cookieBanner.reject'} />
</Button>
<Button autoFocus onClick={accept}>
<Trans i18nKey={'cookieBanner.accept'} />
</Button>
</div>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Root>
);
}
export function useCookieConsent() {
const initialState = getStatusFromLocalStorage();
const [status, setStatus] = useState<ConsentStatus>(initialState);
const accept = useCallback(() => {
const status = ConsentStatus.Accepted;
setStatus(status);
storeStatusInLocalStorage(status);
}, []);
const reject = useCallback(() => {
const status = ConsentStatus.Rejected;
setStatus(status);
storeStatusInLocalStorage(status);
}, []);
const clear = useCallback(() => {
const status = ConsentStatus.Unknown;
setStatus(status);
storeStatusInLocalStorage(status);
}, []);
return useMemo(() => {
return {
clear,
status,
accept,
reject,
};
}, [clear, status, accept, reject]);
}
function storeStatusInLocalStorage(status: ConsentStatus) {
if (!isBrowser()) {
return;
}
localStorage.setItem(COOKIE_CONSENT_STATUS, status);
}
function getStatusFromLocalStorage() {
if (!isBrowser()) {
return ConsentStatus.Unknown;
}
const status = localStorage.getItem(COOKIE_CONSENT_STATUS) as ConsentStatus;
return status ?? ConsentStatus.Unknown;
}
function isBrowser() {
return typeof window !== 'undefined';
}

View File

@@ -0,0 +1,285 @@
'use client';
import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import type {
ColumnDef,
ColumnFiltersState,
PaginationState,
Table as ReactTable,
Row,
SortingState,
VisibilityState,
} from '@tanstack/react-table';
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from 'lucide-react';
import { Button } from '../shadcn/button';
import {
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from '../shadcn/table';
import { Trans } from './trans';
interface ReactTableProps<T extends object> {
data: T[];
columns: ColumnDef<T>[];
renderSubComponent?: (props: { row: Row<T> }) => React.ReactElement;
pageIndex?: number;
pageSize?: number;
pageCount?: number;
onPaginationChange?: (pagination: PaginationState) => void;
onSortingChange?: (sorting: SortingState) => void;
manualPagination?: boolean;
manualSorting?: boolean;
sorting?: SortingState;
tableProps?: React.ComponentProps<typeof Table> &
Record<`data-${string}`, string>;
}
export function DataTable<T extends object>({
data,
columns,
pageIndex,
pageSize,
pageCount,
onPaginationChange,
onSortingChange,
tableProps,
manualPagination = true,
manualSorting = false,
sorting: initialSorting,
}: ReactTableProps<T>) {
'use no memo';
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: pageIndex ?? 0,
pageSize: pageSize ?? 15,
});
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? []);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const navigateToPage = useNavigateToNewPage();
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
manualPagination,
manualSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
pageCount,
state: {
pagination,
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
onSortingChange: (updater) => {
if (typeof updater === 'function') {
const nextState = updater(sorting);
setSorting(nextState);
if (onSortingChange) {
onSortingChange(nextState);
}
} else {
setSorting(updater);
if (onSortingChange) {
onSortingChange(updater);
}
}
},
onPaginationChange: (updater) => {
const navigate = (page: number) => setTimeout(() => navigateToPage(page));
if (typeof updater === 'function') {
setPagination((prevState) => {
const nextState = updater(prevState);
if (onPaginationChange) {
onPaginationChange(nextState);
} else {
navigate(nextState.pageIndex);
}
return nextState;
});
} else {
setPagination(updater);
if (onPaginationChange) {
onPaginationChange(updater);
} else {
navigate(updater.pageIndex);
}
}
},
});
return (
<div className={'rounded-lg border'}>
<Table {...tableProps}>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
colSpan={header.colSpan}
style={{
width: header.column.getSize(),
}}
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
<Trans i18nKey={'common:noData'} />
</TableCell>
</TableRow>
)}
</TableBody>
<TableFooter className={'bg-background'}>
<TableRow>
<TableCell colSpan={columns.length}>
<Pagination table={table} />
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
);
}
function Pagination<T>({
table,
}: React.PropsWithChildren<{
table: ReactTable<T>;
}>) {
return (
<div className="flex items-center gap-x-4">
<span className="text-muted-foreground flex items-center text-sm">
<Trans
i18nKey={'common:pageOfPages'}
values={{
page: table.getState().pagination.pageIndex + 1,
total: table.getPageCount(),
}}
/>
</span>
<div className="flex items-center gap-x-1">
<Button
size={'icon'}
variant={'ghost'}
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<ChevronsLeft className={'h-4'} />
</Button>
<Button
size={'icon'}
variant={'ghost'}
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className={'h-4'} />
</Button>
<Button
size={'icon'}
variant={'ghost'}
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRight className={'h-4'} />
</Button>
<Button
size={'icon'}
variant={'ghost'}
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<ChevronsRight className={'h-4'} />
</Button>
</div>
</div>
);
}
/**
* Navigates to a new page using the provided page index and optional page parameter.
*/
function useNavigateToNewPage(
props: { pageParam?: string } = {
pageParam: 'page',
},
) {
const router = useRouter();
const param = props.pageParam ?? 'page';
return useCallback(
(pageIndex: number) => {
const url = new URL(window.location.href);
url.searchParams.set(param, String(pageIndex + 1));
router.push(url.pathname + url.search);
},
[param, router],
);
}

View File

@@ -0,0 +1,79 @@
import React from 'react';
import { cn } from '../lib/utils';
import { Button } from '../shadcn/button';
const EmptyStateHeading: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = ({
className,
...props
}) => (
<h3
className={cn('text-2xl font-bold tracking-tight', className)}
{...props}
/>
);
EmptyStateHeading.displayName = 'EmptyStateHeading';
const EmptyStateText: React.FC<React.HTMLAttributes<HTMLParagraphElement>> = ({
className,
...props
}) => (
<p className={cn('text-muted-foreground text-sm', className)} {...props} />
);
EmptyStateText.displayName = 'EmptyStateText';
const EmptyStateButton: React.FC<
React.ComponentPropsWithoutRef<typeof Button>
> = ({ className, ...props }) => (
<Button className={cn('mt-4', className)} {...props} />
);
EmptyStateButton.displayName = 'EmptyStateButton';
const EmptyState: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
children,
className,
...props
}) => {
const childrenArray = React.Children.toArray(children);
const heading = childrenArray.find(
(child) => React.isValidElement(child) && child.type === EmptyStateHeading,
);
const text = childrenArray.find(
(child) => React.isValidElement(child) && child.type === EmptyStateText,
);
const button = childrenArray.find(
(child) => React.isValidElement(child) && child.type === EmptyStateButton,
);
const cmps = [EmptyStateHeading, EmptyStateText, EmptyStateButton];
const otherChildren = childrenArray.filter(
(child) =>
React.isValidElement(child) &&
!cmps.includes(child.type as (typeof cmps)[number]),
);
return (
<div
className={cn(
'flex flex-1 items-center justify-center rounded-lg border border-dashed shadow-xs',
className,
)}
{...props}
>
<div className="flex flex-col items-center gap-1 text-center">
{heading}
{text}
{button}
{otherChildren}
</div>
</div>
);
};
EmptyState.displayName = 'EmptyState';
export { EmptyState, EmptyStateHeading, EmptyStateText, EmptyStateButton };

View File

@@ -0,0 +1,36 @@
import { If } from './if';
import { LoadingOverlay } from './loading-overlay';
import { TopLoadingBarIndicator } from './top-loading-bar-indicator';
export function GlobalLoader({
displayLogo = false,
fullPage = false,
displaySpinner = true,
displayTopLoadingBar = true,
children,
}: React.PropsWithChildren<{
displayLogo?: boolean;
fullPage?: boolean;
displaySpinner?: boolean;
displayTopLoadingBar?: boolean;
}>) {
return (
<>
<If condition={displayTopLoadingBar}>
<TopLoadingBarIndicator />
</If>
<If condition={displaySpinner}>
<div
className={
'zoom-in-80 animate-in fade-in slide-in-from-bottom-12 flex flex-1 flex-col items-center justify-center duration-500'
}
>
<LoadingOverlay displayLogo={displayLogo} fullPage={fullPage} />
{children}
</div>
</If>
</>
);
}

View File

@@ -0,0 +1,29 @@
import { useMemo } from 'react';
type Condition<Value = unknown> = Value | false | null | undefined | 0 | '';
export function If<Value = unknown>({
condition,
children,
fallback,
}: React.PropsWithoutRef<{
condition: Condition<Value>;
children: React.ReactNode | ((value: Value) => React.ReactNode);
fallback?: React.ReactNode;
}>) {
return useMemo(() => {
if (condition) {
if (typeof children === 'function') {
return <>{children(condition)}</>;
}
return <>{children}</>;
}
if (fallback) {
return <>{fallback}</>;
}
return null;
}, [condition, fallback, children]);
}

View File

@@ -0,0 +1,200 @@
'use client';
import type { FormEvent, MouseEventHandler } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import Image from 'next/image';
import { UploadCloud, X } from 'lucide-react';
import { cn } from '../lib/utils';
import { Button } from '../shadcn/button';
import { Label } from '../shadcn/label';
import { If } from './if';
type Props = Omit<React.InputHTMLAttributes<unknown>, 'value'> & {
image?: string | null;
onClear?: () => void;
onValueChange?: (props: { image: string; file: File }) => void;
visible?: boolean;
} & React.ComponentPropsWithRef<'input'>;
const IMAGE_SIZE = 22;
export const ImageUploadInput: React.FC<Props> =
function ImageUploadInputComponent({
children,
image,
onClear,
onInput,
onValueChange,
ref: forwardedRef,
visible = true,
...props
}) {
const localRef = useRef<HTMLInputElement>(null);
const [state, setState] = useState({
image,
fileName: '',
});
const onInputChange = useCallback(
(e: FormEvent<HTMLInputElement>) => {
e.preventDefault();
const files = e.currentTarget.files;
if (files?.length) {
const file = files[0];
if (!file) {
return;
}
const data = URL.createObjectURL(file);
setState({
image: data,
fileName: file.name,
});
if (onValueChange) {
onValueChange({
image: data,
file,
});
}
}
if (onInput) {
onInput(e);
}
},
[onInput, onValueChange],
);
const onRemove = useCallback(() => {
setState({
image: '',
fileName: '',
});
if (localRef.current) {
localRef.current.value = '';
}
if (onClear) {
onClear();
}
}, [onClear]);
const imageRemoved: MouseEventHandler = useCallback(
(e) => {
e.preventDefault();
onRemove();
},
[onRemove],
);
const setRef = useCallback(
(input: HTMLInputElement) => {
localRef.current = input;
if (typeof forwardedRef === 'function') {
forwardedRef(localRef.current);
}
},
[forwardedRef],
);
useEffect(() => {
setState((state) => ({ ...state, image }));
}, [image]);
useEffect(() => {
if (!image) {
onRemove();
}
}, [image, onRemove]);
const Input = () => (
<input
{...props}
className={cn('hidden', props.className)}
ref={setRef}
type={'file'}
onInput={onInputChange}
accept="image/*"
aria-labelledby={'image-upload-input'}
/>
);
if (!visible) {
return <Input />;
}
return (
<label
id={'image-upload-input'}
className={`border-input bg-background ring-primary ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring relative flex h-10 w-full cursor-pointer rounded-md border border-dashed px-3 py-2 text-sm ring-offset-2 outline-hidden transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50`}
>
<Input />
<div className={'flex items-center space-x-4'}>
<div className={'flex'}>
<If condition={!state.image}>
<UploadCloud className={'text-muted-foreground h-5'} />
</If>
<If condition={state.image}>
<Image
loading={'lazy'}
style={{
width: IMAGE_SIZE,
height: IMAGE_SIZE,
}}
className={'object-contain'}
width={IMAGE_SIZE}
height={IMAGE_SIZE}
src={state.image!}
alt={props.alt ?? ''}
/>
</If>
</div>
<If condition={!state.image}>
<div className={'flex flex-auto'}>
<Label className={'cursor-pointer text-xs'}>{children}</Label>
</div>
</If>
<If condition={state.image}>
<div className={'flex flex-auto'}>
<If
condition={state.fileName}
fallback={
<Label className={'cursor-pointer truncate text-xs'}>
{children}
</Label>
}
>
<Label className={'truncate text-xs'}>{state.fileName}</Label>
</If>
</div>
</If>
<If condition={state.image}>
<Button
size={'icon'}
className={'h-5! w-5!'}
onClick={imageRemoved}
>
<X className="h-4" />
</Button>
</If>
</div>
</label>
);
};

View File

@@ -0,0 +1,113 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Image as ImageIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { Button } from '../shadcn/button';
import { ImageUploadInput } from './image-upload-input';
import { Trans } from './trans';
export function ImageUploader(
props: React.PropsWithChildren<{
value: string | null | undefined;
onValueChange: (value: File | null) => unknown;
}>,
) {
const [image, setImage] = useState(props.value);
const { setValue, register } = useForm<{
value: string | null | FileList;
}>({
defaultValues: {
value: props.value,
},
mode: 'onChange',
reValidateMode: 'onChange',
});
const control = register('value');
const onClear = useCallback(() => {
props.onValueChange(null);
setValue('value', null);
setImage('');
}, [props, setValue]);
const onValueChange = useCallback(
({ image, file }: { image: string; file: File }) => {
props.onValueChange(file);
setImage(image);
},
[props],
);
const Input = () => (
<ImageUploadInput
{...control}
accept={'image/*'}
className={'absolute h-full w-full'}
visible={false}
multiple={false}
onValueChange={onValueChange}
/>
);
useEffect(() => {
setImage(props.value);
}, [props.value]);
if (!image) {
return (
<FallbackImage descriptionSection={props.children}>
<Input />
</FallbackImage>
);
}
return (
<div className={'flex items-center space-x-4'}>
<label className={'animate-in fade-in zoom-in-50 relative h-20 w-20'}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
decoding="async"
className={'h-20 w-20 rounded-full object-cover'}
src={image}
alt={''}
/>
<Input />
</label>
<div>
<Button onClick={onClear} size={'sm'} variant={'ghost'}>
<Trans i18nKey={'common:clear'} />
</Button>
</div>
</div>
);
}
function FallbackImage(
props: React.PropsWithChildren<{
descriptionSection?: React.ReactNode;
}>,
) {
return (
<div className={'flex items-center space-x-4'}>
<label
className={
'border-border animate-in fade-in zoom-in-50 hover:border-primary relative flex h-20 w-20 cursor-pointer flex-col items-center justify-center rounded-full border'
}
>
<ImageIcon className={'text-primary h-8'} />
{props.children}
</label>
{props.descriptionSection}
</div>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../shadcn/select';
export function LanguageSelector({
onChange,
}: {
onChange?: (locale: string) => unknown;
}) {
const { i18n } = useTranslation();
const { language: currentLanguage, options } = i18n;
const locales = (options.supportedLngs as string[]).filter(
(locale) => locale.toLowerCase() !== 'cimode',
);
const languageNames = useMemo(() => {
return new Intl.DisplayNames([currentLanguage], {
type: 'language',
});
}, [currentLanguage]);
const [value, setValue] = useState(i18n.language);
const languageChanged = useCallback(
async (locale: string) => {
setValue(locale);
if (onChange) {
onChange(locale);
}
await i18n.changeLanguage(locale);
// refresh cached translations
window.location.reload();
},
[i18n, onChange],
);
return (
<Select value={value} onValueChange={languageChanged}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{locales.map((locale) => {
const label = capitalize(languageNames.of(locale) ?? locale);
const option = {
value: locale,
label,
};
return (
<SelectItem value={option.value} key={option.value}>
{option.label}
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}
function capitalize(lang: string) {
return lang.slice(0, 1).toUpperCase() + lang.slice(1);
}

View File

@@ -0,0 +1,62 @@
'use client';
import { createRef, useLayoutEffect, useMemo, useState } from 'react';
/**
* @description Render a component lazily based on the IntersectionObserver
* appConfig provided.
* Full documentation at: https://makerkit.dev/docs/components-utilities#lazyrender
* @param children
* @param threshold
* @param rootMargin
* @param onVisible
* @constructor
*/
export function LazyRender({
children,
threshold,
rootMargin,
onVisible,
}: React.PropsWithChildren<{
threshold?: number;
rootMargin?: string;
onVisible?: () => void;
}>) {
const ref = useMemo(() => createRef<HTMLDivElement>(), []);
const [isVisible, setIsVisible] = useState(false);
useLayoutEffect(() => {
if (!ref.current) {
return;
}
const options = {
rootMargin: rootMargin ?? '0px',
threshold: threshold ?? 1,
};
const isIntersecting = (entry: IntersectionObserverEntry) =>
entry.isIntersecting || entry.intersectionRatio > 0;
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (isIntersecting(entry)) {
setIsVisible(true);
observer.disconnect();
if (onVisible) {
onVisible();
}
}
});
}, options);
observer.observe(ref.current);
return () => {
observer.disconnect();
};
}, [threshold, rootMargin, ref, onVisible]);
return <div ref={ref}>{isVisible ? children : null}</div>;
}

View File

@@ -0,0 +1,33 @@
import type { PropsWithChildren } from 'react';
import { cn } from '../lib/utils';
import { Spinner } from './spinner';
export function LoadingOverlay({
children,
className,
fullPage = true,
spinnerClassName,
}: PropsWithChildren<{
className?: string;
spinnerClassName?: string;
fullPage?: boolean;
displayLogo?: boolean;
}>) {
return (
<div
className={cn(
'flex flex-col items-center justify-center space-y-4',
className,
{
[`bg-background fixed top-0 left-0 z-100 h-screen w-screen`]:
fullPage,
},
)}
>
<Spinner className={spinnerClassName} />
<div className={'text-muted-foreground text-sm'}>{children}</div>
</div>
);
}

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { Button } from '@kit/ui/button';
import { cn } from '@kit/ui/utils';
import { CtaButton } from './cta-button';
import { GradientSecondaryText } from './gradient-secondary-text';
import { HeroTitle } from './hero-title';
const ComingSoonHeading: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = ({
className,
...props
}) => <HeroTitle className={cn(className)} {...props} />;
ComingSoonHeading.displayName = 'ComingSoonHeading';
const ComingSoonText: React.FC<React.HTMLAttributes<HTMLParagraphElement>> = ({
className,
...props
}) => (
<GradientSecondaryText
className={cn('text-muted-foreground text-lg md:text-xl', className)}
{...props}
/>
);
ComingSoonText.displayName = 'ComingSoonText';
const ComingSoonButton: React.FC<
React.ComponentPropsWithoutRef<typeof Button>
> = ({ className, ...props }) => (
<CtaButton className={cn('mt-8', className)} {...props} />
);
ComingSoonButton.displayName = 'ComingSoonButton';
const ComingSoon: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
children,
className,
...props
}) => {
const childrenArray = React.Children.toArray(children);
const logo = childrenArray.find(
(child) => React.isValidElement(child) && child.type === ComingSoonLogo,
);
const heading = childrenArray.find(
(child) => React.isValidElement(child) && child.type === ComingSoonHeading,
);
const text = childrenArray.find(
(child) => React.isValidElement(child) && child.type === ComingSoonText,
);
const button = childrenArray.find(
(child) => React.isValidElement(child) && child.type === ComingSoonButton,
);
const cmps = [
ComingSoonHeading,
ComingSoonText,
ComingSoonButton,
ComingSoonLogo,
];
const otherChildren = childrenArray.filter(
(child) =>
React.isValidElement(child) &&
!cmps.includes(child.type as (typeof cmps)[number]),
);
return (
<div
className={cn(
'container flex min-h-screen flex-col items-center justify-center space-y-12 p-4',
className,
)}
{...props}
>
{logo}
<div className="mx-auto flex w-full max-w-4xl flex-col items-center justify-center space-y-8 text-center">
{heading}
<div className={'mx-auto max-w-2xl'}>{text}</div>
{button}
{otherChildren}
</div>
</div>
);
};
ComingSoon.displayName = 'ComingSoon';
const ComingSoonLogo: React.FC<React.HTMLAttributes<HTMLImageElement>> = ({
className,
...props
}) => <div className={cn(className, 'fixed top-8 left-8')} {...props} />;
ComingSoonLogo.displayName = 'ComingSoonLogo';
export {
ComingSoon,
ComingSoonHeading,
ComingSoonText,
ComingSoonButton,
ComingSoonLogo,
};

View File

@@ -0,0 +1,22 @@
import { cn } from '../../lib/utils';
import { Button } from '../../shadcn/button';
export const CtaButton: React.FC<React.ComponentProps<typeof Button>> =
function CtaButtonComponent({ className, children, ...props }) {
return (
<Button
className={cn(
'h-12 rounded-xl px-4 text-base font-semibold',
className,
{
['dark:shadow-primary/30 transition-all hover:shadow-2xl']:
props.variant === 'default' || !props.variant,
},
)}
asChild
{...props}
>
{children}
</Button>
);
};

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { cn } from '../../lib/utils';
import { CardDescription, CardHeader, CardTitle } from '../../shadcn/card';
interface FeatureCardProps extends React.HTMLAttributes<HTMLDivElement> {
label: string;
description: string;
}
export const FeatureCard: React.FC<FeatureCardProps> = ({
className,
label,
description,
...props
}) => {
return (
<div className={cn('rounded-xl border p-4', className)} {...props}>
<CardHeader>
<CardTitle className="text-xl font-medium">{label}</CardTitle>
<CardDescription className="text-muted-foreground max-w-xs text-sm font-normal">
{description}
</CardDescription>
</CardHeader>
</div>
);
};

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { cn } from '../../lib/utils';
export const FeatureGrid: React.FC<React.HTMLAttributes<HTMLDivElement>> =
function FeatureGridComponent({ className, children, ...props }) {
return (
<div
className={cn(
'mt-2 grid w-full grid-cols-1 gap-4 md:mt-6 md:grid-cols-2 md:grid-cols-3 lg:grid-cols-3',
className,
)}
{...props}
>
{children}
</div>
);
};

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { cn } from '../../lib/utils';
interface FeatureShowcaseProps extends React.HTMLAttributes<HTMLDivElement> {
heading: React.ReactNode;
icon?: React.ReactNode;
}
export const FeatureShowcase: React.FC<FeatureShowcaseProps> =
function FeatureShowcaseComponent({
className,
heading,
icon,
children,
...props
}) {
return (
<div
className={cn('flex flex-col justify-between space-y-8', className)}
{...props}
>
<div className="flex w-full max-w-5xl flex-col gap-y-4">
{icon && <div className="flex">{icon}</div>}
<h3 className="text-3xl font-normal tracking-tight xl:text-5xl">
{heading}
</h3>
</div>
{children}
</div>
);
};
export function FeatureShowcaseIconContainer(
props: React.PropsWithChildren<{
className?: string;
}>,
) {
return (
<div className={'flex'}>
<div
className={cn(
'flex items-center justify-center space-x-4 rounded-lg p-3 font-medium',
props.className,
)}
>
{props.children}
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { cn } from '../../lib/utils';
interface FooterSection {
heading: React.ReactNode;
links: Array<{
href: string;
label: React.ReactNode;
}>;
}
interface FooterProps extends React.HTMLAttributes<HTMLElement> {
logo: React.ReactNode;
description: React.ReactNode;
copyright: React.ReactNode;
sections: FooterSection[];
}
export const Footer: React.FC<FooterProps> = ({
className,
logo,
description,
copyright,
sections,
...props
}) => {
return (
<footer
className={cn(
'site-footer relative mt-auto w-full py-8 2xl:py-20',
className,
)}
{...props}
>
<div className="container">
<div className="flex flex-col space-y-8 lg:flex-row lg:space-y-0">
<div className="flex w-full gap-x-3 lg:w-4/12 xl:w-4/12 xl:space-x-6 2xl:space-x-8">
<div className="flex flex-col gap-y-4">
<div>{logo}</div>
<div className="flex flex-col gap-y-4">
<div>
<p className="text-muted-foreground text-sm tracking-tight">
{description}
</p>
</div>
<div className="text-muted-foreground flex text-xs">
<p>{copyright}</p>
</div>
</div>
</div>
</div>
<div className="flex w-full flex-col gap-y-4 lg:flex-row lg:justify-end lg:gap-x-6 lg:gap-y-0 xl:gap-x-12">
{sections.map((section, index) => (
<div key={index}>
<div className="flex flex-col gap-y-2.5">
<FooterSectionHeading>{section.heading}</FooterSectionHeading>
<FooterSectionList>
{section.links.map((link, linkIndex) => (
<FooterLink key={linkIndex} href={link.href}>
{link.label}
</FooterLink>
))}
</FooterSectionList>
</div>
</div>
))}
</div>
</div>
</div>
</footer>
);
};
function FooterSectionHeading(props: React.PropsWithChildren) {
return (
<span className="font-heading text-sm font-semibold tracking-tight">
{props.children}
</span>
);
}
function FooterSectionList(props: React.PropsWithChildren) {
return <ul className="flex flex-col gap-y-2">{props.children}</ul>;
}
function FooterLink({
href,
children,
}: React.PropsWithChildren<{ href: string }>) {
return (
<li className="text-muted-foreground text-sm tracking-tight hover:underline [&>a]:transition-colors">
<a href={href}>{children}</a>
</li>
);
}

View File

@@ -0,0 +1,23 @@
import { Slot, Slottable } from '@radix-ui/react-slot';
import { cn } from '../../lib/utils';
export const GradientSecondaryText: React.FC<
React.HTMLAttributes<HTMLSpanElement> & {
asChild?: boolean;
}
> = function GradientSecondaryTextComponent({ className, ...props }) {
const Comp = props.asChild ? Slot : 'span';
return (
<Comp
className={cn(
'dark:from-foreground/60 dark:to-foreground text-secondary-foreground dark:bg-linear-to-r dark:bg-clip-text dark:text-transparent',
className,
)}
{...props}
>
<Slottable>{props.children}</Slottable>
</Comp>
);
};

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { cn } from '../../lib/utils';
export const GradientText: React.FC<React.HTMLAttributes<HTMLSpanElement>> =
function GradientTextComponent({ className, children, ...props }) {
return (
<span
className={cn(
'bg-linear-to-r bg-clip-text text-transparent',
className,
)}
{...props}
>
{children}
</span>
);
};

View File

@@ -0,0 +1,33 @@
import { cn } from '../../lib/utils';
interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> {
logo?: React.ReactNode;
navigation?: React.ReactNode;
actions?: React.ReactNode;
}
export const Header: React.FC<HeaderProps> = function ({
className,
logo,
navigation,
actions,
...props
}) {
return (
<div
className={cn(
'site-header bg-background/80 dark:bg-background/50 sticky top-0 z-10 w-full py-1 backdrop-blur-md',
className,
)}
{...props}
>
<div className="container">
<div className="grid h-14 grid-cols-3 items-center">
<div className={'mx-auto md:mx-0'}>{logo}</div>
<div className="order-first md:order-none">{navigation}</div>
<div className="flex items-center justify-end gap-x-2">{actions}</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,23 @@
import { Slot, Slottable } from '@radix-ui/react-slot';
import { cn } from '../../lib/utils';
export const HeroTitle: React.FC<
React.HTMLAttributes<HTMLHeadingElement> & {
asChild?: boolean;
}
> = function HeroTitleComponent({ children, className, ...props }) {
const Comp = props.asChild ? Slot : 'h1';
return (
<Comp
className={cn(
'hero-title flex flex-col text-center font-sans text-4xl font-semibold tracking-tighter sm:text-6xl lg:max-w-5xl lg:text-7xl xl:text-[4.5rem] dark:text-white',
className,
)}
{...props}
>
<Slottable>{children}</Slottable>
</Comp>
);
};

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { cn } from '../../lib/utils';
import { HeroTitle } from './hero-title';
interface HeroProps {
pill?: React.ReactNode;
title: React.ReactNode;
subtitle?: React.ReactNode;
cta?: React.ReactNode;
image?: React.ReactNode;
className?: string;
animate?: boolean;
}
export function Hero({
pill,
title,
subtitle,
cta,
image,
className,
animate = true,
}: HeroProps) {
return (
<div className={cn('mx-auto flex flex-col space-y-20', className)}>
<div
style={{
MozAnimationDuration: '100ms',
}}
className={cn(
'mx-auto flex flex-1 flex-col items-center justify-center duration-800 md:flex-row',
{
['animate-in fade-in zoom-in-90 slide-in-from-top-24']: animate,
},
)}
>
<div className="flex w-full flex-1 flex-col items-center gap-y-6 xl:gap-y-8 2xl:gap-y-12">
{pill && (
<div
className={cn({
['animate-in fade-in fill-mode-both delay-300 duration-700']:
animate,
})}
>
{pill}
</div>
)}
<div className="flex flex-col items-center gap-y-6">
<HeroTitle>{title}</HeroTitle>
{subtitle && (
<div className="flex max-w-lg">
<h3 className="text-muted-foreground p-0 text-center font-sans text-2xl font-normal tracking-tight">
{subtitle}
</h3>
</div>
)}
</div>
{cta && (
<div
className={cn({
['animate-in fade-in fill-mode-both delay-500 duration-1000']:
animate,
})}
>
{cta}
</div>
)}
</div>
</div>
{image && (
<div
style={{
MozAnimationDuration: '100ms',
}}
className={cn('container mx-auto flex justify-center py-8', {
['animate-in fade-in zoom-in-90 slide-in-from-top-32 fill-mode-both delay-600 duration-1000']:
animate,
})}
>
{image}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,15 @@
export * from './hero-title';
export * from './pill';
export * from './gradient-secondary-text';
export * from './gradient-text';
export * from './hero';
export * from './secondary-hero';
export * from './cta-button';
export * from './header';
export * from './footer';
export * from './feature-showcase';
export * from './feature-grid';
export * from './feature-card';
export * from './newsletter-signup';
export * from './newsletter-signup-container';
export * from './coming-soon';

View File

@@ -0,0 +1,86 @@
'use client';
import { useCallback, useState } from 'react';
import { cn } from '../../lib/utils';
import { Alert, AlertDescription, AlertTitle } from '../../shadcn/alert';
import { Heading } from '../../shadcn/heading';
import { Spinner } from '../spinner';
import { NewsletterSignup } from './newsletter-signup';
interface NewsletterSignupContainerProps
extends React.HTMLAttributes<HTMLDivElement> {
onSignup: (email: string) => Promise<void>;
heading?: string;
description?: string;
successMessage?: string;
errorMessage?: string;
}
export function NewsletterSignupContainer({
onSignup,
heading = 'Subscribe to our newsletter',
description = 'Get the latest updates and offers directly to your inbox.',
successMessage = 'Thank you for subscribing!',
errorMessage = 'An error occurred. Please try again.',
className,
...props
}: NewsletterSignupContainerProps) {
const [status, setStatus] = useState<
'idle' | 'loading' | 'success' | 'error'
>('idle');
const handleSubmit = useCallback(
async (data: { email: string }) => {
setStatus('loading');
try {
await onSignup(data.email);
setStatus('success');
} catch (error) {
console.error('Newsletter signup error:', error);
setStatus('error');
}
},
[onSignup],
);
return (
<div
className={cn('flex flex-col items-center space-y-4', className)}
{...props}
>
<div className="text-center">
<Heading level={4}>{heading}</Heading>
<p className="text-muted-foreground">{description}</p>
</div>
{status === 'idle' && <NewsletterSignup onSignup={handleSubmit} />}
{status === 'loading' && (
<div className="flex justify-center">
<Spinner className="h-8 w-8" />
</div>
)}
{status === 'success' && (
<div>
<Alert variant="success">
<AlertTitle>Success!</AlertTitle>
<AlertDescription>{successMessage}</AlertDescription>
</Alert>
</div>
)}
{status === 'error' && (
<div>
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,71 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { cn } from '../../lib/utils';
import { Button } from '../../shadcn/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '../../shadcn/form';
import { Input } from '../../shadcn/input';
const NewsletterFormSchema = z.object({
email: z.string().email('Please enter a valid email address'),
});
type NewsletterFormValues = z.infer<typeof NewsletterFormSchema>;
interface NewsletterSignupProps extends React.HTMLAttributes<HTMLDivElement> {
onSignup: (data: NewsletterFormValues) => void;
buttonText?: string;
placeholder?: string;
}
export function NewsletterSignup({
onSignup,
buttonText = 'Subscribe',
placeholder = 'Enter your email',
className,
...props
}: NewsletterSignupProps) {
const form = useForm<NewsletterFormValues>({
resolver: zodResolver(NewsletterFormSchema),
defaultValues: {
email: '',
},
});
return (
<div className={cn('w-full max-w-sm', className)} {...props}>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSignup)}
className="flex flex-col gap-y-3"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder={placeholder} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
{buttonText}
</Button>
</form>
</Form>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { Slot, Slottable } from '@radix-ui/react-slot';
import { cn } from '../../lib/utils';
import { GradientSecondaryText } from './gradient-secondary-text';
export const Pill: React.FC<
React.HTMLAttributes<HTMLHeadingElement> & {
label?: React.ReactNode;
asChild?: boolean;
}
> = function PillComponent({ className, asChild, ...props }) {
const Comp = asChild ? Slot : 'h3';
return (
<Comp
className={cn(
'bg-muted/50 flex items-center gap-x-1.5 rounded-full border px-2 py-1 pr-2 text-center text-sm font-medium text-transparent',
className,
)}
{...props}
>
{props.label && (
<span
className={
'text-primary-foreground bg-primary rounded-2xl border px-1.5 py-0.5 text-xs font-bold tracking-tight'
}
>
{props.label}
</span>
)}
<Slottable>
<GradientSecondaryText
className={'flex items-center gap-x-2 font-semibold tracking-tight'}
>
{props.children}
</GradientSecondaryText>
</Slottable>
</Comp>
);
};
export const PillActionButton: React.FC<
React.HTMLAttributes<HTMLButtonElement> & {
asChild?: boolean;
}
> = ({ asChild, ...props }) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
{...props}
className={
'text-secondary-foreground bg-input active:bg-primary active:text-primary-foreground hover:ring-muted-foreground/50 rounded-full px-1.5 py-1.5 text-center text-sm font-medium ring ring-transparent transition-colors'
}
>
{props.children}
</Comp>
);
};

View File

@@ -0,0 +1,42 @@
import { cn } from '../../lib/utils';
import { Heading } from '../../shadcn/heading';
interface SecondaryHeroProps extends React.HTMLAttributes<HTMLDivElement> {
pill?: React.ReactNode;
heading: React.ReactNode;
subheading: React.ReactNode;
}
export const SecondaryHero: React.FC<SecondaryHeroProps> =
function SecondaryHeroComponent({
className,
pill,
heading,
subheading,
children,
...props
}) {
return (
<div
className={cn(
'flex flex-col items-center space-y-6 text-center',
className,
)}
{...props}
>
{pill}
<div className="flex flex-col">
<Heading level={2} className="tracking-tighter">
{heading}
</Heading>
<h3 className="text-muted-foreground font-sans text-xl font-normal tracking-tight">
{subheading}
</h3>
</div>
{children}
</div>
);
};

View File

@@ -0,0 +1,72 @@
'use client';
import { useMemo } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ChevronDown } from 'lucide-react';
import { Button } from '../shadcn/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../shadcn/dropdown-menu';
import { Trans } from './trans';
function MobileNavigationDropdown({
links,
}: {
links: {
path: string;
label: string;
}[];
}) {
const path = usePathname();
const currentPathName = useMemo(() => {
return Object.values(links).find((link) => link.path === path)?.label;
}, [links, path]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={'secondary'} className={'w-full'}>
<span
className={'flex w-full items-center justify-between space-x-2'}
>
<span>
<Trans i18nKey={currentPathName} defaults={currentPathName} />
</span>
<ChevronDown className={'h-5'} />
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className={
'dark:divide-dark-700 w-screen divide-y divide-gray-100' +
' rounded-none'
}
>
{Object.values(links).map((link) => {
return (
<DropdownMenuItem asChild key={link.path}>
<Link
className={'flex h-12 w-full items-center'}
href={link.path}
>
<Trans i18nKey={link.label} defaults={link.label} />
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
export default MobileNavigationDropdown;

View File

@@ -0,0 +1,77 @@
'use client';
import { useMemo } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ChevronDown } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../shadcn/dropdown-menu';
import { Trans } from './trans';
function MobileNavigationDropdown({
links,
}: {
links: {
path: string;
label: string;
}[];
}) {
const path = usePathname();
const items = useMemo(
function MenuItems() {
return Object.values(links).map((link) => {
return (
<DropdownMenuItem key={link.path}>
<Link
className={'flex h-full w-full items-center'}
href={link.path}
>
<Trans i18nKey={link.label} defaults={link.label} />
</Link>
</DropdownMenuItem>
);
});
},
[links],
);
const currentPathName = useMemo(() => {
return Object.values(links).find((link) => link.path === path)?.label;
}, [links, path]);
return (
<DropdownMenu>
<DropdownMenuTrigger className={'w-full'}>
<div
className={
'Button dark:ring-dark-700 w-full justify-start ring-2 ring-gray-100'
}
>
<span
className={
'ButtonNormal flex w-full items-center justify-between space-x-2'
}
>
<span>
<Trans i18nKey={currentPathName} defaults={currentPathName} />
</span>
<ChevronDown className={'h-5'} />
</span>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>{items}</DropdownMenuContent>
</DropdownMenu>
);
}
export default MobileNavigationDropdown;

View File

@@ -0,0 +1,141 @@
'use client';
import { useMemo } from 'react';
import { Computer, Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { cn } from '../lib/utils';
import { Button } from '../shadcn/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '../shadcn/dropdown-menu';
import { Trans } from './trans';
const MODES = ['light', 'dark', 'system'];
export function ModeToggle(props: { className?: string }) {
const { setTheme, theme } = useTheme();
const Items = useMemo(() => {
return MODES.map((mode) => {
const isSelected = theme === mode;
return (
<DropdownMenuItem
className={cn('space-x-2', {
'bg-muted': isSelected,
})}
key={mode}
onClick={() => {
setTheme(mode);
setCookeTheme(mode);
}}
>
<Icon theme={mode} />
<span>
<Trans i18nKey={`common:${mode}Theme`} />
</span>
</DropdownMenuItem>
);
});
}, [setTheme, theme]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className={props.className}>
<Sun className="h-[0.9rem] w-[0.9rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[0.9rem] w-[0.9rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">{Items}</DropdownMenuContent>
</DropdownMenu>
);
}
export function SubMenuModeToggle() {
const { setTheme, theme, resolvedTheme } = useTheme();
const MenuItems = useMemo(
() =>
MODES.map((mode) => {
const isSelected = theme === mode;
return (
<DropdownMenuItem
className={cn('flex cursor-pointer items-center space-x-2', {
'bg-muted': isSelected,
})}
key={mode}
onClick={() => {
setTheme(mode);
setCookeTheme(mode);
}}
>
<Icon theme={mode} />
<span>
<Trans i18nKey={`common:${mode}Theme`} />
</span>
</DropdownMenuItem>
);
}),
[setTheme, theme],
);
return (
<>
<DropdownMenuSub>
<DropdownMenuSubTrigger
className={
'hidden w-full items-center justify-between gap-x-3 lg:flex'
}
>
<span className={'flex space-x-2'}>
<Icon theme={resolvedTheme} />
<span>
<Trans i18nKey={'common:theme'} />
</span>
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>{MenuItems}</DropdownMenuSubContent>
</DropdownMenuSub>
<div className={'lg:hidden'}>
<DropdownMenuLabel>
<Trans i18nKey={'common:theme'} />
</DropdownMenuLabel>
{MenuItems}
</div>
</>
);
}
function setCookeTheme(theme: string) {
document.cookie = `theme=${theme}; path=/; max-age=31536000`;
}
function Icon({ theme }: { theme: string | undefined }) {
switch (theme) {
case 'light':
return <Sun className="h-4" />;
case 'dark':
return <Moon className="h-4" />;
case 'system':
return <Computer className="h-4" />;
}
}

View File

@@ -0,0 +1,435 @@
'use client';
import React, {
HTMLProps,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Slot, Slottable } from '@radix-ui/react-slot';
import { useMutation } from '@tanstack/react-query';
import { Path, UseFormReturn } from 'react-hook-form';
import { z } from 'zod';
import { cn } from '../lib/utils';
interface MultiStepFormProps<T extends z.ZodType> {
schema: T;
form: UseFormReturn<z.infer<T>>;
onSubmit: (data: z.infer<T>) => void;
useStepTransition?: boolean;
className?: string;
}
type StepProps = React.PropsWithChildren<
{
name: string;
asChild?: boolean;
} & React.HTMLProps<HTMLDivElement>
>;
const MultiStepFormContext = createContext<ReturnType<
typeof useMultiStepForm
> | null>(null);
/**
* @name MultiStepForm
* @description Multi-step form component for React
* @param schema
* @param form
* @param onSubmit
* @param children
* @param className
* @constructor
*/
export function MultiStepForm<T extends z.ZodType>({
schema,
form,
onSubmit,
children,
className,
}: React.PropsWithChildren<MultiStepFormProps<T>>) {
const steps = useMemo(
() =>
React.Children.toArray(children).filter(
(child): child is React.ReactElement<StepProps> =>
React.isValidElement(child) && child.type === MultiStepFormStep,
),
[children],
);
const header = useMemo(() => {
return React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) && child.type === MultiStepFormHeader,
);
}, [children]);
const footer = useMemo(() => {
return React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) && child.type === MultiStepFormFooter,
);
}, [children]);
const stepNames = steps.map((step) => step.props.name);
const multiStepForm = useMultiStepForm(schema, form, stepNames, onSubmit);
return (
<MultiStepFormContext.Provider value={multiStepForm}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn(className, 'flex size-full flex-col overflow-hidden')}
>
{header}
<div className="relative transition-transform duration-500">
{steps.map((step, index) => {
const isActive = index === multiStepForm.currentStepIndex;
return (
<AnimatedStep
key={step.props.name}
direction={multiStepForm.direction}
isActive={isActive}
index={index}
currentIndex={multiStepForm.currentStepIndex}
>
{step}
</AnimatedStep>
);
})}
</div>
{footer}
</form>
</MultiStepFormContext.Provider>
);
}
export function MultiStepFormContextProvider(props: {
children: (context: ReturnType<typeof useMultiStepForm>) => React.ReactNode;
}) {
const ctx = useMultiStepFormContext();
if (Array.isArray(props.children)) {
const [child] = props.children;
return (
child as (context: ReturnType<typeof useMultiStepForm>) => React.ReactNode
)(ctx);
}
return props.children(ctx);
}
export const MultiStepFormStep: React.FC<
React.PropsWithChildren<
{
asChild?: boolean;
ref?: React.Ref<HTMLDivElement>;
} & HTMLProps<HTMLDivElement>
>
> = function MultiStepFormStep({ children, asChild, ...props }) {
const Cmp = asChild ? Slot : 'div';
return (
<Cmp {...props}>
<Slottable>{children}</Slottable>
</Cmp>
);
};
export function useMultiStepFormContext<Schema extends z.ZodType>() {
const context = useContext(MultiStepFormContext) as ReturnType<
typeof useMultiStepForm<Schema>
>;
if (!context) {
throw new Error(
'useMultiStepFormContext must be used within a MultiStepForm',
);
}
return context;
}
/**
* @name useMultiStepForm
* @description Hook for multi-step forms
* @param schema
* @param form
* @param stepNames
* @param onSubmit
*/
export function useMultiStepForm<Schema extends z.ZodType>(
schema: Schema,
form: UseFormReturn<z.infer<Schema>>,
stepNames: string[],
onSubmit: (data: z.infer<Schema>) => void,
) {
const [state, setState] = useState({
currentStepIndex: 0,
direction: undefined as 'forward' | 'backward' | undefined,
});
const isStepValid = useCallback(() => {
const currentStepName = stepNames[state.currentStepIndex] as Path<
z.TypeOf<Schema>
>;
if (schema instanceof z.ZodObject) {
const currentStepSchema = schema.shape[currentStepName] as z.ZodType;
// the user may not want to validate the current step
// or the step doesn't contain any form field
if (!currentStepSchema) {
return true;
}
const currentStepData = form.getValues(currentStepName) ?? {};
const result = currentStepSchema.safeParse(currentStepData);
return result.success;
}
throw new Error(`Unsupported schema type: ${schema.constructor.name}`);
}, [schema, form, stepNames, state.currentStepIndex]);
const nextStep = useCallback(
<Ev extends React.SyntheticEvent>(e: Ev) => {
// prevent form submission when the user presses Enter
// or if the user forgets [type="button"] on the button
e.preventDefault();
const isValid = isStepValid();
if (!isValid) {
const currentStepName = stepNames[state.currentStepIndex] as Path<
z.TypeOf<Schema>
>;
if (schema instanceof z.ZodObject) {
const currentStepSchema = schema.shape[currentStepName] as z.ZodType;
if (currentStepSchema) {
const fields = Object.keys(
(currentStepSchema as z.ZodObject<never>).shape,
);
const keys = fields.map((field) => `${currentStepName}.${field}`);
// trigger validation for all fields in the current step
for (const key of keys) {
void form.trigger(key as Path<z.TypeOf<Schema>>);
}
return;
}
}
}
if (isValid && state.currentStepIndex < stepNames.length - 1) {
setState((prevState) => {
return {
...prevState,
direction: 'forward',
currentStepIndex: prevState.currentStepIndex + 1,
};
});
}
},
[isStepValid, state.currentStepIndex, stepNames, schema, form],
);
const prevStep = useCallback(
<Ev extends React.SyntheticEvent>(e: Ev) => {
// prevent form submission when the user presses Enter
// or if the user forgets [type="button"] on the button
e.preventDefault();
if (state.currentStepIndex > 0) {
setState((prevState) => {
return {
...prevState,
direction: 'backward',
currentStepIndex: prevState.currentStepIndex - 1,
};
});
}
},
[state.currentStepIndex],
);
const goToStep = useCallback(
(index: number) => {
if (index >= 0 && index < stepNames.length && isStepValid()) {
setState((prevState) => {
return {
...prevState,
direction:
index > prevState.currentStepIndex ? 'forward' : 'backward',
currentStepIndex: index,
};
});
}
},
[isStepValid, stepNames.length],
);
const isValid = form.formState.isValid;
const errors = form.formState.errors;
const mutation = useMutation({
mutationFn: () => {
return form.handleSubmit(onSubmit)();
},
});
return useMemo(
() => ({
form,
currentStep: stepNames[state.currentStepIndex] as string,
currentStepIndex: state.currentStepIndex,
totalSteps: stepNames.length,
isFirstStep: state.currentStepIndex === 0,
isLastStep: state.currentStepIndex === stepNames.length - 1,
nextStep,
prevStep,
goToStep,
direction: state.direction,
isStepValid,
isValid,
errors,
mutation,
}),
[
form,
mutation,
stepNames,
state.currentStepIndex,
state.direction,
nextStep,
prevStep,
goToStep,
isStepValid,
isValid,
errors,
],
);
}
export const MultiStepFormHeader: React.FC<
React.PropsWithChildren<
{
asChild?: boolean;
} & HTMLProps<HTMLDivElement>
>
> = function MultiStepFormHeader({ children, asChild, ...props }) {
const Cmp = asChild ? Slot : 'div';
return (
<Cmp {...props}>
<Slottable>{children}</Slottable>
</Cmp>
);
};
export const MultiStepFormFooter: React.FC<
React.PropsWithChildren<
{
asChild?: boolean;
} & HTMLProps<HTMLDivElement>
>
> = function MultiStepFormFooter({ children, asChild, ...props }) {
const Cmp = asChild ? Slot : 'div';
return (
<Cmp {...props}>
<Slottable>{children}</Slottable>
</Cmp>
);
};
/**
* @name createStepSchema
* @description Create a schema for a multi-step form
* @param steps
*/
export function createStepSchema<T extends Record<string, z.ZodType>>(
steps: T,
) {
return z.object(steps);
}
interface AnimatedStepProps {
direction: 'forward' | 'backward' | undefined;
isActive: boolean;
index: number;
currentIndex: number;
}
function AnimatedStep({
isActive,
direction,
children,
index,
currentIndex,
}: React.PropsWithChildren<AnimatedStepProps>) {
const [shouldRender, setShouldRender] = useState(isActive);
const stepRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isActive) {
setShouldRender(true);
} else {
const timer = setTimeout(() => setShouldRender(false), 300);
return () => clearTimeout(timer);
}
}, [isActive]);
useEffect(() => {
if (isActive && stepRef.current) {
const focusableElement = stepRef.current.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (focusableElement) {
(focusableElement as HTMLElement).focus();
}
}
}, [isActive]);
if (!shouldRender) {
return null;
}
const baseClasses =
' top-0 left-0 w-full h-full transition-all duration-300 ease-in-out animate-in fade-in zoom-in-95';
const visibilityClasses = isActive ? 'opacity-100' : 'opacity-0 absolute';
const transformClasses = cn(
'translate-x-0',
isActive
? {}
: {
'-translate-x-full': direction === 'forward' || index < currentIndex,
'translate-x-full': direction === 'backward' || index > currentIndex,
},
);
const className = cn(baseClasses, visibilityClasses, transformClasses);
return (
<div ref={stepRef} className={className} aria-hidden={!isActive}>
{children}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More