B2B-88: add starter kit structure and elements
This commit is contained in:
12
packages/ui/README.md
Normal file
12
packages/ui/README.md
Normal 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).
|
||||
20
packages/ui/components.json
Normal file
20
packages/ui/components.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
packages/ui/eslint.config.mjs
Normal file
3
packages/ui/eslint.config.mjs
Normal 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
17
packages/ui/node_modules/.bin/acorn
generated
vendored
Executable 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
17
packages/ui/node_modules/.bin/eslint
generated
vendored
Executable 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
17
packages/ui/node_modules/.bin/jiti
generated
vendored
Executable 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
17
packages/ui/node_modules/.bin/next
generated
vendored
Executable 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
17
packages/ui/node_modules/.bin/prettier
generated
vendored
Executable 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
17
packages/ui/node_modules/.bin/tsc
generated
vendored
Executable 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
17
packages/ui/node_modules/.bin/tsserver
generated
vendored
Executable 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
1
packages/ui/node_modules/@hookform/resolvers
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../tooling/eslint
|
||||
1
packages/ui/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
1
packages/ui/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../tooling/prettier
|
||||
1
packages/ui/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
1
packages/ui/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../tooling/typescript
|
||||
1
packages/ui/node_modules/@radix-ui/react-accordion
generated
vendored
Symbolic link
1
packages/ui/node_modules/@radix-ui/react-accordion
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@radix-ui/react-alert-dialog
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@radix-ui/react-avatar
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@radix-ui/react-checkbox
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@radix-ui/react-collapsible
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@radix-ui/react-dialog
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@radix-ui/react-dropdown-menu
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@radix-ui/react-icons
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@radix-ui/react-label
generated
vendored
Symbolic link
@@ -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
|
||||
1
packages/ui/node_modules/@radix-ui/react-navigation-menu
generated
vendored
Symbolic link
1
packages/ui/node_modules/@radix-ui/react-navigation-menu
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@radix-ui/react-popover
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@radix-ui/react-progress
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@radix-ui/react-radio-group
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@radix-ui/react-scroll-area
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@radix-ui/react-select
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@radix-ui/react-separator
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@radix-ui/react-slot
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@radix-ui/react-switch
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@radix-ui/react-tabs
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@radix-ui/react-toast
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@radix-ui/react-tooltip
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@tanstack/react-query
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@tanstack/react-table
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@types/react
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/@types/react-dom
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/class-variance-authority
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/clsx
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../node_modules/.pnpm/clsx@2.1.1/node_modules/clsx
|
||||
1
packages/ui/node_modules/cmdk
generated
vendored
Symbolic link
1
packages/ui/node_modules/cmdk
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/date-fns
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/eslint
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/input-otp
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/lucide-react
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/next
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/next-themes
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/prettier
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/react-day-picker
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/react-hook-form
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/react-i18next
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/react-top-loading-bar
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/recharts
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/sonner
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/tailwind-merge
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/tailwindcss
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../node_modules/.pnpm/tailwindcss@4.1.7/node_modules/tailwindcss
|
||||
1
packages/ui/node_modules/tailwindcss-animate
generated
vendored
Symbolic link
1
packages/ui/node_modules/tailwindcss-animate
generated
vendored
Symbolic link
@@ -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
1
packages/ui/node_modules/typescript
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript
|
||||
1
packages/ui/node_modules/zod
generated
vendored
Symbolic link
1
packages/ui/node_modules/zod
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../node_modules/.pnpm/zod@3.25.56/node_modules/zod
|
||||
135
packages/ui/package.json
Normal file
135
packages/ui/package.json
Normal 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/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
21
packages/ui/src/hooks/use-mobile.tsx
Normal file
21
packages/ui/src/hooks/use-mobile.tsx
Normal 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;
|
||||
}
|
||||
7
packages/ui/src/lib/utils/cn.ts
Normal file
7
packages/ui/src/lib/utils/cn.ts
Normal 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));
|
||||
}
|
||||
2
packages/ui/src/lib/utils/index.ts
Normal file
2
packages/ui/src/lib/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './cn';
|
||||
export * from './is-route-active';
|
||||
108
packages/ui/src/lib/utils/is-route-active.ts
Normal file
108
packages/ui/src/lib/utils/is-route-active.ts
Normal 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;
|
||||
}
|
||||
89
packages/ui/src/makerkit/app-breadcrumbs.tsx
Normal file
89
packages/ui/src/makerkit/app-breadcrumbs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
packages/ui/src/makerkit/authenticity-token.tsx
Normal file
17
packages/ui/src/makerkit/authenticity-token.tsx
Normal 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') ?? ''
|
||||
);
|
||||
}
|
||||
69
packages/ui/src/makerkit/bordered-navigation-menu.tsx
Normal file
69
packages/ui/src/makerkit/bordered-navigation-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
packages/ui/src/makerkit/card-button.tsx
Normal file
117
packages/ui/src/makerkit/card-button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
11
packages/ui/src/makerkit/context/sidebar.context.ts
Normal file
11
packages/ui/src/makerkit/context/sidebar.context.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
const SidebarContext = createContext<{
|
||||
collapsed: boolean;
|
||||
setCollapsed: (collapsed: boolean) => void;
|
||||
}>({
|
||||
collapsed: false,
|
||||
setCollapsed: (_) => _,
|
||||
});
|
||||
|
||||
export { SidebarContext };
|
||||
118
packages/ui/src/makerkit/cookie-banner.tsx
Normal file
118
packages/ui/src/makerkit/cookie-banner.tsx
Normal 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';
|
||||
}
|
||||
285
packages/ui/src/makerkit/data-table.tsx
Normal file
285
packages/ui/src/makerkit/data-table.tsx
Normal 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],
|
||||
);
|
||||
}
|
||||
79
packages/ui/src/makerkit/empty-state.tsx
Normal file
79
packages/ui/src/makerkit/empty-state.tsx
Normal 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 };
|
||||
36
packages/ui/src/makerkit/global-loader.tsx
Normal file
36
packages/ui/src/makerkit/global-loader.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
packages/ui/src/makerkit/if.tsx
Normal file
29
packages/ui/src/makerkit/if.tsx
Normal 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]);
|
||||
}
|
||||
200
packages/ui/src/makerkit/image-upload-input.tsx
Normal file
200
packages/ui/src/makerkit/image-upload-input.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
113
packages/ui/src/makerkit/image-uploader.tsx
Normal file
113
packages/ui/src/makerkit/image-uploader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
packages/ui/src/makerkit/language-selector.tsx
Normal file
79
packages/ui/src/makerkit/language-selector.tsx
Normal 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);
|
||||
}
|
||||
62
packages/ui/src/makerkit/lazy-render.tsx
Normal file
62
packages/ui/src/makerkit/lazy-render.tsx
Normal 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>;
|
||||
}
|
||||
33
packages/ui/src/makerkit/loading-overlay.tsx
Normal file
33
packages/ui/src/makerkit/loading-overlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
packages/ui/src/makerkit/marketing/coming-soon.tsx
Normal file
107
packages/ui/src/makerkit/marketing/coming-soon.tsx
Normal 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,
|
||||
};
|
||||
22
packages/ui/src/makerkit/marketing/cta-button.tsx
Normal file
22
packages/ui/src/makerkit/marketing/cta-button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
28
packages/ui/src/makerkit/marketing/feature-card.tsx
Normal file
28
packages/ui/src/makerkit/marketing/feature-card.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
18
packages/ui/src/makerkit/marketing/feature-grid.tsx
Normal file
18
packages/ui/src/makerkit/marketing/feature-grid.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
51
packages/ui/src/makerkit/marketing/feature-showcase.tsx
Normal file
51
packages/ui/src/makerkit/marketing/feature-showcase.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
packages/ui/src/makerkit/marketing/footer.tsx
Normal file
98
packages/ui/src/makerkit/marketing/footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
18
packages/ui/src/makerkit/marketing/gradient-text.tsx
Normal file
18
packages/ui/src/makerkit/marketing/gradient-text.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
33
packages/ui/src/makerkit/marketing/header.tsx
Normal file
33
packages/ui/src/makerkit/marketing/header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
23
packages/ui/src/makerkit/marketing/hero-title.tsx
Normal file
23
packages/ui/src/makerkit/marketing/hero-title.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
90
packages/ui/src/makerkit/marketing/hero.tsx
Normal file
90
packages/ui/src/makerkit/marketing/hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
packages/ui/src/makerkit/marketing/index.tsx
Normal file
15
packages/ui/src/makerkit/marketing/index.tsx
Normal 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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
71
packages/ui/src/makerkit/marketing/newsletter-signup.tsx
Normal file
71
packages/ui/src/makerkit/marketing/newsletter-signup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
packages/ui/src/makerkit/marketing/pill.tsx
Normal file
59
packages/ui/src/makerkit/marketing/pill.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
42
packages/ui/src/makerkit/marketing/secondary-hero.tsx
Normal file
42
packages/ui/src/makerkit/marketing/secondary-hero.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
72
packages/ui/src/makerkit/mobile-navigation-dropdown.tsx
Normal file
72
packages/ui/src/makerkit/mobile-navigation-dropdown.tsx
Normal 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;
|
||||
77
packages/ui/src/makerkit/mobile-navigation-menu.tsx
Normal file
77
packages/ui/src/makerkit/mobile-navigation-menu.tsx
Normal 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;
|
||||
141
packages/ui/src/makerkit/mode-toggle.tsx
Normal file
141
packages/ui/src/makerkit/mode-toggle.tsx
Normal 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" />;
|
||||
}
|
||||
}
|
||||
435
packages/ui/src/makerkit/multi-step-form.tsx
Normal file
435
packages/ui/src/makerkit/multi-step-form.tsx
Normal 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
Reference in New Issue
Block a user