├── .docker
├── Dockerfile
└── docker-entrypoint.sh
├── .env.example
├── .gitignore
├── .husky
└── _
│ ├── pre-commit
│ └── prepare-commit-msg
├── .jscpd.json
├── .node-version
├── .npmrc
├── .prettierignore
├── .prettierrc.yml
├── .storybook
├── main.ts
└── preview.tsx
├── README.md
├── app
├── (public)
│ ├── (home)
│ │ ├── _components
│ │ │ └── hero.tsx
│ │ ├── error.tsx
│ │ └── page.tsx
│ └── ui-demo
│ │ └── page.tsx
├── _shared
│ └── utilities
│ │ ├── error-boundary
│ │ ├── error-boundary.stories.tsx
│ │ ├── error-boundary.test.tsx
│ │ ├── error-boundary.tsx
│ │ └── index.ts
│ │ ├── providers
│ │ └── react-query.tsx
│ │ └── responsive.tsx
├── api
│ ├── health
│ │ └── route.ts
│ ├── metrics
│ │ └── route.ts
│ └── ready
│ │ └── route.ts
├── favicon.ico
├── global-error.tsx
├── globals.css
├── layout.tsx
├── manifest.ts
├── not-found.tsx
├── robots.ts
└── sitemap.ts
├── bunfig.toml
├── eslint.config.mjs
├── instrumentation.ts
├── knip.jsonc
├── lefthook.yml
├── lint-staged.config.mjs
├── middleware.ts
├── next-env.d.ts
├── next.config.ts
├── package-lock.json
├── package.json
├── packages
├── api
│ ├── .gitignore
│ ├── axios-client.ts
│ ├── codegen
│ │ └── .gitkeep
│ ├── kubb.config.ts
│ ├── ky-client.ts
│ └── package.json
├── core
│ ├── button
│ │ ├── button.stories.tsx
│ │ ├── button.test.tsx
│ │ ├── button.tsx
│ │ └── index.ts
│ ├── checkbox
│ │ ├── checkbox.stories.tsx
│ │ ├── checkbox.test.tsx
│ │ ├── checkbox.tsx
│ │ └── index.ts
│ ├── cn.ts
│ ├── components.json
│ ├── dialog
│ │ ├── dialog.stories.tsx
│ │ ├── dialog.tsx
│ │ └── index.ts
│ ├── form
│ │ ├── form.tsx
│ │ └── index.ts
│ ├── input
│ │ ├── index.ts
│ │ ├── input.stories.tsx
│ │ ├── input.test.tsx
│ │ └── input.tsx
│ ├── label
│ │ ├── index.ts
│ │ ├── label.stories.tsx
│ │ ├── label.test.tsx
│ │ └── label.tsx
│ ├── package.json
│ ├── radio-group
│ │ ├── index.ts
│ │ ├── radio-group.stories.tsx
│ │ ├── radio-group.test.tsx
│ │ └── radio-group.tsx
│ ├── select
│ │ ├── index.ts
│ │ ├── select.stories.tsx
│ │ └── select.tsx
│ ├── skeleton
│ │ ├── index.ts
│ │ ├── skeleton.stories.tsx
│ │ ├── skeleton.test.tsx
│ │ └── skeleton.tsx
│ ├── sonner
│ │ └── index.tsx
│ ├── switch
│ │ ├── index.ts
│ │ ├── switch.stories.tsx
│ │ ├── switch.test.tsx
│ │ └── switch.tsx
│ ├── tabs
│ │ ├── index.ts
│ │ ├── tabs.stories.tsx
│ │ ├── tabs.test.tsx
│ │ └── tabs.tsx
│ ├── textarea
│ │ ├── index.ts
│ │ ├── textarea.stories.tsx
│ │ ├── textarea.test.tsx
│ │ └── textarea.tsx
│ ├── tooltip
│ │ ├── index.ts
│ │ ├── tooltip.stories.tsx
│ │ ├── tooltip.test.tsx
│ │ └── tooltip.tsx
│ ├── tsconfig.json
│ └── typography
│ │ ├── index.ts
│ │ ├── typography.stories.tsx
│ │ ├── typography.test.tsx
│ │ └── typography.tsx
├── design-tokens
│ ├── json
│ │ └── .gitkeep
│ ├── package.json
│ └── tokens.config.json
├── logger
│ ├── index.ts
│ └── package.json
├── metrics
│ ├── index.ts
│ └── package.json
├── routes
│ ├── README.md
│ ├── declarative-routing.config.json
│ ├── package.json
│ └── src
│ │ ├── hooks.ts
│ │ ├── index.ts
│ │ ├── makeRoute.tsx
│ │ └── utils.ts
└── ts-config
│ ├── base.json
│ └── package.json
├── playwright.config.ts
├── postcss.config.mjs
├── public
└── images
│ └── svg
│ └── logo.svg
├── sentry.client.config.js
├── src
├── env
│ ├── client.ts
│ └── server.ts
├── fonts
│ ├── geist.ts
│ └── source
│ │ └── geist.woff2
├── hooks
│ └── use-responsive.ts
├── tests
│ └── e2e
│ │ └── example.spec.ts
├── types
│ └── global.d.ts
└── utils
│ ├── cn.ts
│ └── get-url.ts
├── tsconfig.json
└── vitest.config.ts
/.docker/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:22.16 AS deps-front
2 |
3 | ENV LOCALTIME Europe/Moscow
4 | RUN ln -snf /usr/share/zoneinfo/$LOCALTIME /etc/localtime && echo $LOCALTIME > /etc/timezone
5 |
6 | RUN set -ex && \
7 | mkdir -p /app && \
8 | chown -R node:node /app && \
9 | chown node:node /app
10 |
11 | WORKDIR /app
12 |
13 | USER node
14 |
15 | COPY --chown=node:node app/. /app/
16 |
17 | RUN npm ci
18 |
19 | FROM node:22.14 AS build-front
20 |
21 | ENV LOCALTIME Europe/Moscow
22 | RUN ln -snf /usr/share/zoneinfo/$LOCALTIME /etc/localtime && echo $LOCALTIME > /etc/timezone
23 |
24 | # LOCAL | WORK | RC | PROD
25 | ARG NEXT_PUBLIC_APP_ENV
26 | # публичный урл front приложения
27 | ARG NEXT_PUBLIC_FRONT_URL
28 | # публичный урл back приложения (опционален)
29 | ARG NEXT_PUBLIC_BACK_URL
30 | # DSN для доступа к Sentry
31 | ARG NEXT_PUBLIC_SENTRY_DSN
32 |
33 | RUN set -ex && \
34 | mkdir -p /app && \
35 | chown -R node:node /app && \
36 | chown node:node /app
37 |
38 | WORKDIR /app
39 |
40 | COPY .docker/docker-entrypoint.sh /entrypoint.sh
41 |
42 | COPY --from=deps-front --chown=node:node /app/. /app/
43 |
44 | USER node
45 |
46 | RUN npm run build
47 |
48 | ENTRYPOINT ["/entrypoint.sh"]
49 |
--------------------------------------------------------------------------------
/.docker/docker-entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 |
4 | if [[ ! -z "$1" ]]; then
5 | echo ${*}
6 | exec ${*}
7 | else
8 | exec node -v
9 | fi
10 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # server
2 | APP_NAME=nextjs_starter
3 | APP_ENV=LOCAL
4 | FRONT_HOST=front
5 | FRONT_PORT=3000
6 | BACK_INTERNAL_URL=http://localhost:3000
7 | HTTP_AUTH_LOGIN=demo
8 | HTTP_AUTH_PASS=demo
9 | CACHE_PUBLIC_MAX_AGE=3600
10 | SENTRY_DSN=https://sentry.w6p.ru/
11 | SENTRY_AUTH_TOKEN=secret
12 | SENTRY_ORG=webpractik
13 | SENTRY_URL=https://sentry.w6p.ru
14 | CI=false
15 |
16 | # client
17 | NEXT_PUBLIC_APP_ENV=LOCAL
18 | NEXT_PUBLIC_FRONT_URL=http://localhost:3000
19 | NEXT_PUBLIC_BFF_PATH=/bff-api
20 | NEXT_PUBLIC_BACK_URL=http://localhost:8080
21 | NEXT_PUBLIC_SENTRY_DSN=https://sentry.w6p.ru/
22 |
23 |
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # custom
4 | .idea
5 | .vscode
6 | .turbo
7 | dist
8 | .env
9 | .eslintcache
10 | .million
11 | storybook-static
12 | coverage-ts
13 | swagger.json
14 | swagger.yaml
15 | openapi.json
16 | openapi.yaml
17 | docs.html
18 | dependency-graph.svg
19 | packages/*/node_modules
20 | storybook.log
21 | report
22 |
23 | # dependencies
24 | /node_modules
25 | /.pnp
26 | .pnp.js
27 |
28 | # testing
29 | /coverage
30 |
31 | # next.js
32 | /.next/
33 | /out/
34 |
35 | # production
36 | /build
37 |
38 | # misc
39 | .DS_Store
40 | *.pem
41 |
42 | # debug
43 | npm-debug.log*
44 | yarn-debug.log*
45 | yarn-error.log*
46 |
47 | # local env files
48 | .env*.local
49 |
50 | # vercel
51 | .vercel
52 |
53 | # typescript
54 | *.tsbuildinfo
55 | next-env.d.ts
56 | /test-results/
57 | /playwright-report/
58 | /blob-report/
59 | /playwright/.cache/
60 |
61 | *storybook.log
--------------------------------------------------------------------------------
/.husky/_/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | if [ "$LEFTHOOK_VERBOSE" = "1" -o "$LEFTHOOK_VERBOSE" = "true" ]; then
4 | set -x
5 | fi
6 |
7 | if [ "$LEFTHOOK" = "0" ]; then
8 | exit 0
9 | fi
10 |
11 | call_lefthook()
12 | {
13 | if test -n "$LEFTHOOK_BIN"
14 | then
15 | "$LEFTHOOK_BIN" "$@"
16 | elif lefthook -h >/dev/null 2>&1
17 | then
18 | lefthook "$@"
19 | else
20 | dir="$(git rev-parse --show-toplevel)"
21 | osArch=$(uname | tr '[:upper:]' '[:lower:]')
22 | cpuArch=$(uname -m | sed 's/aarch64/arm64/;s/x86_64/x64/')
23 | if test -f "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook"
24 | then
25 | "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook" "$@"
26 | elif test -f "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook"
27 | then
28 | "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook" "$@"
29 | elif test -f "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook"
30 | then
31 | "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook" "$@"
32 | elif test -f "$dir/node_modules/lefthook/bin/index.js"
33 | then
34 | "$dir/node_modules/lefthook/bin/index.js" "$@"
35 |
36 | elif go tool lefthook -h >/dev/null 2>&1
37 | then
38 | go tool lefthook "$@"
39 | elif bundle exec lefthook -h >/dev/null 2>&1
40 | then
41 | bundle exec lefthook "$@"
42 | elif yarn lefthook -h >/dev/null 2>&1
43 | then
44 | yarn lefthook "$@"
45 | elif pnpm lefthook -h >/dev/null 2>&1
46 | then
47 | pnpm lefthook "$@"
48 | elif swift package lefthook >/dev/null 2>&1
49 | then
50 | swift package --build-path .build/lefthook --disable-sandbox lefthook "$@"
51 | elif command -v mint >/dev/null 2>&1
52 | then
53 | mint run csjones/lefthook-plugin "$@"
54 | elif uv run lefthook -h >/dev/null 2>&1
55 | then
56 | uv run lefthook "$@"
57 | elif mise exec -- lefthook -h >/dev/null 2>&1
58 | then
59 | mise exec -- lefthook "$@"
60 | else
61 | echo "Can't find lefthook in PATH"
62 | fi
63 | fi
64 | }
65 |
66 | call_lefthook run "pre-commit" "$@"
67 |
--------------------------------------------------------------------------------
/.husky/_/prepare-commit-msg:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | if [ "$LEFTHOOK_VERBOSE" = "1" -o "$LEFTHOOK_VERBOSE" = "true" ]; then
4 | set -x
5 | fi
6 |
7 | if [ "$LEFTHOOK" = "0" ]; then
8 | exit 0
9 | fi
10 |
11 | call_lefthook()
12 | {
13 | if test -n "$LEFTHOOK_BIN"
14 | then
15 | "$LEFTHOOK_BIN" "$@"
16 | elif lefthook -h >/dev/null 2>&1
17 | then
18 | lefthook "$@"
19 | else
20 | dir="$(git rev-parse --show-toplevel)"
21 | osArch=$(uname | tr '[:upper:]' '[:lower:]')
22 | cpuArch=$(uname -m | sed 's/aarch64/arm64/;s/x86_64/x64/')
23 | if test -f "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook"
24 | then
25 | "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook" "$@"
26 | elif test -f "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook"
27 | then
28 | "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook" "$@"
29 | elif test -f "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook"
30 | then
31 | "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook" "$@"
32 | elif test -f "$dir/node_modules/lefthook/bin/index.js"
33 | then
34 | "$dir/node_modules/lefthook/bin/index.js" "$@"
35 |
36 | elif go tool lefthook -h >/dev/null 2>&1
37 | then
38 | go tool lefthook "$@"
39 | elif bundle exec lefthook -h >/dev/null 2>&1
40 | then
41 | bundle exec lefthook "$@"
42 | elif yarn lefthook -h >/dev/null 2>&1
43 | then
44 | yarn lefthook "$@"
45 | elif pnpm lefthook -h >/dev/null 2>&1
46 | then
47 | pnpm lefthook "$@"
48 | elif swift package lefthook >/dev/null 2>&1
49 | then
50 | swift package --build-path .build/lefthook --disable-sandbox lefthook "$@"
51 | elif command -v mint >/dev/null 2>&1
52 | then
53 | mint run csjones/lefthook-plugin "$@"
54 | elif uv run lefthook -h >/dev/null 2>&1
55 | then
56 | uv run lefthook "$@"
57 | elif mise exec -- lefthook -h >/dev/null 2>&1
58 | then
59 | mise exec -- lefthook "$@"
60 | else
61 | echo "Can't find lefthook in PATH"
62 | fi
63 | fi
64 | }
65 |
66 | call_lefthook run "prepare-commit-msg" "$@"
67 |
--------------------------------------------------------------------------------
/.jscpd.json:
--------------------------------------------------------------------------------
1 | {
2 | "threshold": 2,
3 | "reporters": ["html"],
4 | "ignore": [
5 | "**/node_modules/**",
6 | "**/.next/**",
7 | "**/.git/**",
8 | "**/.github/**",
9 | "**/.gitlab/**",
10 | "**/.docker/**",
11 | "**/.idea/**",
12 | "**/report/**",
13 | "**/*.svg"
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------
/.node-version:
--------------------------------------------------------------------------------
1 | 22.16
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | save-exact=true
2 | engine-strict=true
3 | legacy-peer-deps=true
4 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | storybook-static
3 | .next
4 | public
5 |
--------------------------------------------------------------------------------
/.prettierrc.yml:
--------------------------------------------------------------------------------
1 | arrowParens: avoid
2 | plugins:
3 | - prettier-plugin-tailwindcss
4 | printWidth: 100
5 | singleQuote: true
6 | tabWidth: 4
7 | tailwindFunctions:
8 | - cn
9 | - cva
10 | tailwindStylesheet: ./app/globals.css
11 | trailingComma: es5
12 |
--------------------------------------------------------------------------------
/.storybook/main.ts:
--------------------------------------------------------------------------------
1 | import type { StorybookConfig } from '@storybook/nextjs';
2 |
3 | import { join, dirname } from 'path';
4 |
5 | function getAbsolutePath(value: string) {
6 | return dirname(require.resolve(join(value, 'package.json')));
7 | }
8 |
9 | const config: StorybookConfig = {
10 | stories: ['../app/**/*.stories.tsx', '../packages/core/**/*.stories.tsx'],
11 | addons: [
12 | getAbsolutePath('@storybook/addon-onboarding'),
13 | getAbsolutePath('@storybook/addon-links'),
14 | getAbsolutePath('@storybook/addon-essentials'),
15 | getAbsolutePath('@storybook/addon-interactions'),
16 | getAbsolutePath('@storybook/addon-storysource'),
17 | getAbsolutePath('@storybook/addon-designs'),
18 | ],
19 | framework: {
20 | name: getAbsolutePath('@storybook/nextjs'),
21 | options: {},
22 | },
23 | typescript: {
24 | check: false,
25 | reactDocgen: 'react-docgen-typescript',
26 | reactDocgenTypescriptOptions: {
27 | shouldRemoveUndefinedFromOptional: true,
28 | shouldExtractLiteralValuesFromEnum: true,
29 | shouldExtractValuesFromUnion: true,
30 | propFilter: prop => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true),
31 | },
32 | },
33 | features: {
34 | experimentalRSC: true,
35 | },
36 | staticDirs: ['../public'],
37 | };
38 |
39 | export default config;
40 |
--------------------------------------------------------------------------------
/.storybook/preview.tsx:
--------------------------------------------------------------------------------
1 | import '../app/globals.css';
2 | import type { Preview } from '@storybook/react';
3 |
4 | import { geistSans } from '../src/fonts/geist';
5 |
6 | const preview: Preview = {
7 | parameters: {
8 | actions: {
9 | argTypesRegex: '^on[A-Z].*',
10 | },
11 | controls: {
12 | expanded: true,
13 | matchers: {
14 | color: /(background|color)$/i,
15 | date: /Date$/i,
16 | },
17 | },
18 | tags: ['autodocs'],
19 | docs: { toc: true },
20 | layout: 'centered',
21 | },
22 | decorators: [
23 | Story => (
24 |
25 |
26 |
27 | ),
28 | ],
29 | };
30 |
31 | export default preview;
32 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # NextJS Starter
2 |
3 | A robust boilerplate for quickly building web applications with Next.js.
4 |
5 | ## 🪄 Features:
6 |
7 | - Next 15+ (app router, server components)
8 | - React 19
9 | - Typescript
10 | - Tailwind
11 | - ESLint
12 | - Prettier
13 | - Husky
14 | - Commitizen (git-cz)
15 | - Vitest
16 | - Playwright
17 | - Lint-staged
18 | - Storybook
19 | - Sentry
20 | - Bundle analyzer
21 | - React Query
22 | - Kubb API Codegen
23 | - Figma tokens
24 | - Env validation
25 |
26 | ## 🚀 Get started
27 |
28 | 1. Install the project using `npx create-next-app -e https://github.com/webpractik/nextjs-starter --use-npm`
29 | 2. Copy environment variables to .env (`cp .env.example .env`) and configure them.
30 | 3. Start the development server with `npm run dev`
31 |
32 | ## 🎯 Deploy
33 |
34 | - **Node:** `^22`
35 | - **Npm:** `^10`
36 | - **App Port:** `3000`
37 | - **Healthcheck:** `/api/health`
38 | - **Ready:** `/api/ready`
39 | - **Prometheus Metrics:** `/api/metrics`
40 |
41 | ## Run production mode:
42 |
43 | - `npm ci`
44 | - `npm run build`
45 | - `npm run prod`
46 |
47 | ## 📦 Additional utilities:
48 |
49 | - [nanoid](https://www.npmjs.com/package/nanoid) - Generate unique IDs
50 | - [lodash-es](https://lodash.com/docs) - Utility library
51 | - [react-use](https://github.com/streamich/react-use#readme) - Collection of hooks for React
52 | - [isomorphic-dompurify](https://www.npmjs.com/package/isomorphic-dompurify) - DOM sanitization library
53 | - [clsx](https://www.npmjs.com/package/clsx) - Utility for constructing CSS class names
54 |
--------------------------------------------------------------------------------
/app/(public)/(home)/_components/hero.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { Typography } from '@repo/core/typography';
3 | import { motion } from 'framer-motion';
4 |
5 | const initialState = { filter: 'blur(10px)', opacity: 0, y: 40 };
6 |
7 | const transition = {
8 | delay: 0.7,
9 | duration: 1,
10 | ease: 'easeInOut',
11 | };
12 |
13 | const whileInView = { filter: 'blur(0px)', opacity: 1, y: 0 };
14 |
15 | export function Hero() {
16 | return (
17 |
18 |
24 |
28 | Next Starter
29 |
30 |
31 | Этот стартовый комплект нацелен на предоставление разработчикам надежной основы
32 | для создания приложений на Next.js, обеспечивая соблюдение лучших практик по
33 | качеству кода, стилю и эффективности рабочих процессов.
34 |
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/app/(public)/(home)/error.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { ErrorFallback } from '@/_shared/utilities/error-boundary';
4 | import * as Sentry from '@sentry/nextjs';
5 | import { useEffect } from 'react';
6 |
7 | type ErrorProps = {
8 | error: { digest?: string } & Error;
9 | reset: () => void;
10 | };
11 |
12 | export default function ErrorPage({ error, reset }: Readonly) {
13 | useEffect(() => {
14 | Sentry.captureException(error);
15 | }, [error]);
16 |
17 | return ;
18 | }
19 |
--------------------------------------------------------------------------------
/app/(public)/(home)/page.tsx:
--------------------------------------------------------------------------------
1 | import { Hero } from './_components/hero';
2 |
3 | export default function HomePage() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/app/(public)/ui-demo/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { Button } from '@repo/core/button';
3 | import { Checkbox } from '@repo/core/checkbox';
4 | import {
5 | Dialog,
6 | DialogClose,
7 | DialogContent,
8 | DialogDescription,
9 | DialogFooter,
10 | DialogHeader,
11 | DialogTitle,
12 | DialogTrigger,
13 | } from '@repo/core/dialog';
14 | import { Input } from '@repo/core/input';
15 | import { Label } from '@repo/core/label';
16 | import { RadioGroup, RadioGroupItem } from '@repo/core/radio-group';
17 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@repo/core/select';
18 | import { Skeleton } from '@repo/core/skeleton';
19 | import { toast } from '@repo/core/sonner';
20 | import { Switch } from '@repo/core/switch';
21 | import { Tabs } from '@repo/core/tabs';
22 | import { TabsContent, TabsList, TabsTrigger } from '@repo/core/tabs/tabs';
23 | import { Textarea } from '@repo/core/textarea';
24 | import {
25 | Tooltip,
26 | TooltipContent,
27 | TooltipProvider,
28 | TooltipTrigger,
29 | } from '@repo/core/tooltip/tooltip';
30 | import { Typography } from '@repo/core/typography';
31 | import { CircleHelp } from 'lucide-react';
32 |
33 | const gridItemClassName =
34 | 'flex place-content-center items-center col-span-1 border border-dashed border-slate-200 p-4 rounded-2xl';
35 |
36 | export default function UiPage() {
37 | return (
38 |
39 |
40 |
41 |
42 | Заголовок
43 |
44 | Простой текст
45 |
46 |
47 |
48 |
49 |
50 |
51 | Кнопка
52 |
53 | Кнопка
54 |
55 | Загрузка...
56 |
57 |
58 |
59 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | Вариант 1
79 |
80 |
81 |
82 | Вариант 2
83 |
84 |
85 |
86 | Вариант 3
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 | Вариант 1
97 |
98 |
99 |
100 | Вариант 2
101 |
102 |
103 |
104 | Вариант 3
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 | Вариант 1
115 |
116 |
117 |
118 | Вариант 2
119 |
120 |
121 |
122 | Вариант 3
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 | Светлая
134 | Темная
135 |
136 | Системная
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 | Диалог
146 |
147 |
148 |
149 | Вы точно хотите закрыть?
150 |
151 | Это действие не может быть отменено. Это приведет к
152 | безвозвратному удалению вашей учетной записи и удалению ваших
153 | данных с наших серверов.
154 |
155 |
156 |
157 |
158 |
159 |
160 | Точно
161 | Отменить
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 | Контент тултипа
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 | Триггер 1
188 | Триггер 2
189 |
190 | Контент первого таба.
191 | Контент второго таба.
192 |
193 |
194 |
195 |
196 | {
198 | toast('Вызов уведомления');
199 | }}
200 | >
201 | Тост
202 |
203 |
204 |
205 |
206 | );
207 | }
208 |
--------------------------------------------------------------------------------
/app/_shared/utilities/error-boundary/error-boundary.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import { noop } from 'lodash-es';
4 |
5 | import { ErrorBoundary, ErrorFallback } from './error-boundary';
6 |
7 | const meta: Meta = {
8 | component: ErrorBoundary,
9 | title: 'shared/utilities/ErrorBoundary',
10 | };
11 |
12 | export default meta;
13 |
14 | type Story = StoryObj;
15 |
16 | const message = { message: 'Example error message' };
17 |
18 | export const Primary: Story = {
19 | args: {},
20 | render: () => ,
21 | };
22 |
--------------------------------------------------------------------------------
/app/_shared/utilities/error-boundary/error-boundary.test.tsx:
--------------------------------------------------------------------------------
1 | import { fireEvent, render, screen } from '@testing-library/react';
2 | import { noop } from 'lodash-es';
3 | import { describe, expect, it, vi } from 'vitest';
4 |
5 | import { ErrorFallback } from './error-boundary';
6 |
7 | describe(' ', () => {
8 | const errorMessage = 'Example error message';
9 | const error = new Error(errorMessage);
10 |
11 | it('it renders correctly', () => {
12 | render( );
13 |
14 | const errorBoundary = screen.getByTestId('error-boundary');
15 |
16 | expect(errorBoundary).toBeInTheDocument();
17 | });
18 |
19 | it('it renders error message', () => {
20 | render( );
21 |
22 | const errorBoundary = screen.getByText(errorMessage);
23 |
24 | expect(errorBoundary).toBeInTheDocument();
25 | });
26 |
27 | it('it handles reset error', () => {
28 | const mockFunction = vi.fn();
29 |
30 | render( );
31 |
32 | fireEvent.click(screen.getByText('Попробовать еще'));
33 |
34 | expect(mockFunction).toHaveBeenCalled();
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/app/_shared/utilities/error-boundary/error-boundary.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@repo/core/button';
2 | import { Typography } from '@repo/core/typography';
3 | import { type ErrorBoundaryProps, ErrorBoundary as SentryBoundary } from '@sentry/nextjs';
4 |
5 | import { cn } from '~/src/utils/cn';
6 |
7 | type ErrorFallbackProps = {
8 | description?: string;
9 | error: unknown;
10 | resetError: () => void;
11 | title?: string;
12 | };
13 |
14 | const DEFAULT_ERROR_TITLE = 'Техническая ошибка';
15 |
16 | const DEFAULT_ERROR_TEXT = `Извините, возникла неожиданная техническая неполадка.
17 | Мы прилагаем все усилия для решения этой проблемы как можно скорее.
18 | Пожалуйста, попробуйте обновить страницу или вернуться позже.`;
19 |
20 | export function ErrorFallback({ description, error, resetError, title }: ErrorFallbackProps) {
21 | return (
22 |
28 |
29 | {title ?? DEFAULT_ERROR_TITLE}
30 |
31 |
32 |
33 | {description ?? DEFAULT_ERROR_TEXT}
34 |
35 |
36 |
{error instanceof Error ? error.message : String(error)}
37 |
38 |
39 | Попробовать еще
40 |
41 |
42 | );
43 | }
44 |
45 | export function ErrorBoundary({ children, fallback, onError, onReset }: ErrorBoundaryProps) {
46 | return (
47 |
48 | {children}
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/app/_shared/utilities/error-boundary/index.ts:
--------------------------------------------------------------------------------
1 | export { ErrorBoundary, ErrorFallback } from './error-boundary';
2 |
--------------------------------------------------------------------------------
/app/_shared/utilities/providers/react-query.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { ReactNode } from 'react';
4 |
5 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
6 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
7 | import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental';
8 |
9 | // 30 sec
10 | export const DEFAULT_STALE_TIME = 30_000;
11 |
12 | const queryClient = new QueryClient({
13 | defaultOptions: {
14 | queries: {
15 | staleTime: DEFAULT_STALE_TIME,
16 | throwOnError: true,
17 | },
18 | },
19 | });
20 |
21 | type ReactQueryProviderProps = Readonly<{
22 | children: ReactNode;
23 | showDevtools?: boolean;
24 | }>;
25 |
26 | export function ReactQueryProvider({ children, showDevtools = true }: ReactQueryProviderProps) {
27 | return (
28 |
29 | {children}
30 |
31 | {showDevtools ? : null}
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/app/_shared/utilities/responsive.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from 'react';
2 |
3 | import {
4 | useDesktopMediaQuery,
5 | useLaptopMediaQuery,
6 | useMobileMediaQuery,
7 | useTabletMediaQuery,
8 | } from '#/hooks/use-responsive';
9 |
10 | export function Desktop({ children }: { children: ReactNode }): ReactNode {
11 | const isDesktop = useDesktopMediaQuery();
12 |
13 | return isDesktop ? children : null;
14 | }
15 |
16 | export function Laptop({ children }: { children: ReactNode }): ReactNode {
17 | const isLaptop = useLaptopMediaQuery();
18 |
19 | return isLaptop ? children : null;
20 | }
21 |
22 | export function Mobile({ children }: { children: ReactNode }): ReactNode {
23 | const isMobile = useMobileMediaQuery();
24 |
25 | return isMobile ? children : null;
26 | }
27 |
28 | export function Tablet({ children }: { children: ReactNode }): ReactNode {
29 | const isTablet = useTabletMediaQuery();
30 |
31 | return isTablet ? children : null;
32 | }
33 |
--------------------------------------------------------------------------------
/app/api/health/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 |
3 | export function GET() {
4 | return NextResponse.json({ message: 'OK' }, { status: 200 });
5 | }
6 |
--------------------------------------------------------------------------------
/app/api/metrics/route.ts:
--------------------------------------------------------------------------------
1 | import { register } from '@repo/metrics';
2 | import { NextResponse } from 'next/server';
3 |
4 | export async function GET() {
5 | const metrics = await register.metrics();
6 |
7 | const newHeaders = new Headers();
8 |
9 | newHeaders.set('Content-Type', register.contentType);
10 |
11 | return NextResponse.json(metrics, { headers: newHeaders });
12 | }
13 |
--------------------------------------------------------------------------------
/app/api/ready/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 |
3 | export function GET() {
4 | return NextResponse.json({ message: 'OK' }, { status: 200 });
5 | }
6 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webpractik/nextjs-starter/5a88da1157a4e783656f18970d311d83556b906a/app/favicon.ico
--------------------------------------------------------------------------------
/app/global-error.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { ErrorFallback } from '@/_shared/utilities/error-boundary';
3 | import logger from '@repo/logger';
4 | import * as Sentry from '@sentry/nextjs';
5 | import { useEffect } from 'react';
6 |
7 | type GlobalErrorProps = {
8 | error: { digest?: string } & Error;
9 | reset: () => void;
10 | };
11 |
12 | export default function GlobalError({ error, reset }: GlobalErrorProps) {
13 | useEffect(() => {
14 | logger.error(error);
15 | Sentry.captureException(error);
16 | }, [error]);
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 |
3 | @theme {
4 | --animate-accordion-down: accordion-down 0.2s ease-out;
5 | --animate-accordion-up: accordion-up 0.2s ease-out;
6 |
7 | --radius-lg: var(--radius);
8 | --radius-md: calc(var(--radius) - 2px);
9 | --radius-sm: calc(var(--radius) - 4px);
10 |
11 | --color-accent: hsl(var(--accent));
12 | --color-accent-foreground: hsl(var(--accent-foreground));
13 |
14 | --color-background: hsl(var(--background));
15 | --color-border: hsl(var(--border));
16 |
17 | --color-card: hsl(var(--card));
18 | --color-card-foreground: hsl(var(--card-foreground));
19 |
20 | --color-destructive: hsl(var(--destructive));
21 | --color-destructive-foreground: hsl(var(--destructive-foreground));
22 |
23 | --color-foreground: hsl(var(--foreground));
24 | --color-input: hsl(var(--input));
25 |
26 | --color-muted: hsl(var(--muted));
27 | --color-muted-foreground: hsl(var(--muted-foreground));
28 |
29 | --color-popover: hsl(var(--popover));
30 | --color-popover-foreground: hsl(var(--popover-foreground));
31 |
32 | --color-primary: hsl(var(--primary));
33 | --color-primary-foreground: hsl(var(--primary-foreground));
34 |
35 | --color-ring: hsl(var(--ring));
36 |
37 | --color-secondary: hsl(var(--secondary));
38 | --color-secondary-foreground: hsl(var(--secondary-foreground));
39 |
40 | --font-sans: var(--font-geist-sans);
41 |
42 | @keyframes accordion-down {
43 | from {
44 | height: 0;
45 | }
46 | to {
47 | height: var(--radix-accordion-content-height);
48 | }
49 | }
50 | @keyframes accordion-up {
51 | from {
52 | height: var(--radix-accordion-content-height);
53 | }
54 | to {
55 | height: 0;
56 | }
57 | }
58 | }
59 |
60 | @utility container {
61 | margin-inline: auto;
62 | padding-inline: 2rem;
63 | @media (width >= --theme(--breakpoint-sm)) {
64 | max-width: none;
65 | }
66 | @media (width >= 1400px) {
67 | max-width: 1400px;
68 | }
69 | }
70 |
71 | /*
72 | The default border color has changed to `currentColor` in Tailwind CSS v4,
73 | so we've added these compatibility styles to make sure everything still
74 | looks the same as it did with Tailwind CSS v3.
75 |
76 | If we ever want to remove these styles, we need to add an explicit border
77 | color utility to any element that depends on these defaults.
78 | */
79 | @layer base {
80 | *,
81 | ::after,
82 | ::before,
83 | ::backdrop,
84 | ::file-selector-button {
85 | border-color: var(--color-gray-200, currentColor);
86 | }
87 | }
88 |
89 | html,
90 | body {
91 | overscroll-behavior: none;
92 | }
93 |
94 | @layer base {
95 | :root {
96 | --background: 0 0% 100%;
97 | --foreground: 222.2 84% 4.9%;
98 |
99 | --card: 0 0% 100%;
100 | --card-foreground: 222.2 84% 4.9%;
101 |
102 | --popover: 0 0% 100%;
103 | --popover-foreground: 222.2 84% 4.9%;
104 |
105 | --primary: 222.2 47.4% 11.2%;
106 | --primary-foreground: 210 40% 98%;
107 |
108 | --secondary: 210 40% 96.1%;
109 | --secondary-foreground: 222.2 47.4% 11.2%;
110 |
111 | --muted: 210 40% 96.1%;
112 | --muted-foreground: 215.4 16.3% 46.9%;
113 |
114 | --accent: 210 40% 96.1%;
115 | --accent-foreground: 222.2 47.4% 11.2%;
116 |
117 | --destructive: 0 84.2% 60.2%;
118 | --destructive-foreground: 210 40% 98%;
119 |
120 | --border: 214.3 31.8% 91.4%;
121 | --input: 214.3 31.8% 91.4%;
122 | --ring: 222.2 84% 4.9%;
123 |
124 | --radius: 0.5rem;
125 | }
126 | }
127 |
128 | @layer components {
129 | #__next {
130 | @apply h-full;
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import './globals.css';
2 |
3 | import type { Metadata } from 'next';
4 | import type { ReactNode } from 'react';
5 |
6 | import { Toaster } from '@repo/core/sonner';
7 | import { geistSans } from '#/fonts/geist';
8 | import { cn } from '#/utils/cn';
9 |
10 | export const dynamic = 'auto';
11 | // 'auto' | 'force-dynamic' | 'error' | 'force-static'
12 |
13 | export const revalidate = false;
14 | // false | 0 | number
15 |
16 | export const fetchCache = 'auto';
17 | // 'auto' | 'default-cache' | 'only-cache' | 'force-cache' | 'force-no-store' | 'default-no-store' | 'only-no-store'
18 |
19 | export const experimental_ppr = false;
20 | // true | false
21 |
22 | export const runtime = 'nodejs';
23 |
24 | export const metadata: Metadata = {
25 | description: 'Default starter for projects',
26 | title: 'Next Starter',
27 | };
28 |
29 | export type RootLayoutProps = Readonly<{ children: ReactNode }>;
30 |
31 | export default function RootLayout({ children }: RootLayoutProps) {
32 | return (
33 |
34 |
35 |
36 | {children}
37 |
38 |
39 |
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/app/manifest.ts:
--------------------------------------------------------------------------------
1 | import type { MetadataRoute } from 'next';
2 |
3 | export default function manifest(): MetadataRoute.Manifest {
4 | return {
5 | background_color: '#fff',
6 | description: 'Next.js App',
7 | display: 'standalone',
8 | icons: [
9 | {
10 | sizes: 'any',
11 | src: '/favicon.ico',
12 | type: 'image/x-icon',
13 | },
14 | ],
15 | name: 'Next.js App',
16 | short_name: 'Next.js App',
17 | start_url: '/',
18 | theme_color: '#fff',
19 | };
20 | }
21 |
--------------------------------------------------------------------------------
/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Error from 'next/error';
4 |
5 | export default function NotFound() {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/app/robots.ts:
--------------------------------------------------------------------------------
1 | import type { MetadataRoute } from 'next';
2 |
3 | import { environment } from '#/env/client';
4 |
5 | export default function robots(): MetadataRoute.Robots {
6 | const notIndexed = ['RC', 'WORK'].includes(environment.NEXT_PUBLIC_APP_ENV);
7 |
8 | if (notIndexed) {
9 | return {
10 | rules: {
11 | disallow: '/',
12 | userAgent: '*',
13 | },
14 | };
15 | }
16 |
17 | return {
18 | rules: {
19 | allow: '/',
20 | userAgent: '*',
21 | },
22 | };
23 | }
24 |
--------------------------------------------------------------------------------
/app/sitemap.ts:
--------------------------------------------------------------------------------
1 | import type { MetadataRoute } from 'next';
2 |
3 | import { environment } from '#/env/client';
4 |
5 | export default function sitemap(): MetadataRoute.Sitemap {
6 | return [
7 | {
8 | changeFrequency: 'yearly',
9 | lastModified: new Date(),
10 | priority: 1,
11 | url: environment.NEXT_PUBLIC_FRONT_URL,
12 | },
13 | ];
14 | }
15 |
--------------------------------------------------------------------------------
/bunfig.toml:
--------------------------------------------------------------------------------
1 | [install]
2 | exact = true
3 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import wpConfigNext from '@webpractik/eslint-config-next';
2 |
3 | export const ignores = [
4 | '.next',
5 | 'next.config.ts',
6 | 'next-env.d.ts',
7 | '*.config.js',
8 | 'report',
9 | 'packages/api/**/*.ts',
10 | 'packages/routes/**/*.ts',
11 | 'packages/routes/**/*.tsx',
12 | '.storybook/preview.tsx',
13 | ];
14 |
15 | export default [
16 | ...wpConfigNext,
17 | {
18 | rules: {
19 | 'sonarjs/function-return-type': 0,
20 |
21 | '@typescript-eslint/naming-convention': 0,
22 | '@typescript-eslint/consistent-type-definitions': [2, 'type'],
23 |
24 | 'react-perf/jsx-no-new-object-as-prop': 1,
25 | 'react-perf/jsx-no-new-array-as-prop': 1,
26 | 'react-perf/jsx-no-new-function-as-prop': 1,
27 | 'react-perf/jsx-no-jsx-as-prop': 1,
28 |
29 | 'no-restricted-imports': [
30 | 2,
31 | {
32 | patterns: [
33 | {
34 | group: ['~/packages/core/*'],
35 | message: 'Please use import from @repo/core instead',
36 | },
37 | {
38 | group: ['~/packages/api/*'],
39 | message: 'Please use import from @repo/api instead',
40 | },
41 | {
42 | group: ['~/packages/routes/*'],
43 | message: 'Please use import from @repo/routes instead',
44 | },
45 | {
46 | group: ['~/packages/logger/*'],
47 | message: 'Please use import from @repo/logger instead',
48 | },
49 | ],
50 | },
51 | ],
52 | },
53 | },
54 | { ignores },
55 | ];
56 |
--------------------------------------------------------------------------------
/instrumentation.ts:
--------------------------------------------------------------------------------
1 | import * as Sentry from '@sentry/nextjs';
2 | import { registerOTel } from '@vercel/otel';
3 | import { environment } from '#/env/server';
4 |
5 | export const onRequestError = Sentry.captureRequestError;
6 |
7 | export function register() {
8 | registerOTel({ serviceName: environment.APP_NAME });
9 |
10 | if (process.env.NEXT_RUNTIME === 'nodejs') {
11 | Sentry.init({
12 | dsn: environment.SENTRY_DSN,
13 | environment: `${environment.APP_ENV}-server`,
14 | tracesSampleRate: 1,
15 | });
16 | }
17 |
18 | if (process.env.NEXT_RUNTIME === 'edge') {
19 | Sentry.init({
20 | dsn: environment.SENTRY_DSN,
21 | environment: `${environment.APP_ENV}-edge`,
22 | sampleRate: 0,
23 | tracesSampleRate: 0,
24 | });
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/knip.jsonc:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/knip@3/schema-jsonc.json",
3 | "project": ["**/*.{js,jsx,ts,tsx}"],
4 | }
5 |
--------------------------------------------------------------------------------
/lefthook.yml:
--------------------------------------------------------------------------------
1 | # Refer for explanation to following link:
2 | # https://lefthook.dev/configuration/
3 | #
4 | pre-commit:
5 | jobs:
6 | - run: npx lint-staged
7 |
8 | prepare-commit-msg:
9 | commands:
10 | commitizen:
11 | interactive: true
12 | run: npx cz --hook
13 | env:
14 | LEFTHOOK: 0
15 |
--------------------------------------------------------------------------------
/lint-staged.config.mjs:
--------------------------------------------------------------------------------
1 | import { relative } from 'node:path';
2 | import { minimatch } from 'minimatch';
3 |
4 | export const ignores = [
5 | '.next',
6 | 'next.config.ts',
7 | 'next-env.d.ts',
8 | '*.config.js',
9 | 'report',
10 | 'packages/routes/**/*.ts',
11 | 'packages/api/**/*.ts',
12 | '.storybook',
13 | ];
14 |
15 | function lintCommand(filenames) {
16 | try {
17 | const nonIgnoredFiles = filenames
18 | .map(name => relative(process.cwd(), name))
19 | .filter(filePath => !ignores.some(pattern => minimatch(filePath, pattern)));
20 |
21 | const paths = nonIgnoredFiles.join(' --file ');
22 |
23 | return `npm run check:lint -- --fix --file ${paths}`;
24 | } catch (error) {
25 | console.error(error);
26 | }
27 | }
28 |
29 | /**
30 | * @type {import('lint-staged').Configuration}
31 | */
32 | export default {
33 | '*.ts?(x)': () => 'npm run check:ts',
34 | '*.{js?(x),ts?(x)}': [lintCommand],
35 | '*.{js,jsx,ts,tsx,md,html,css,json}': () => 'npm run check:format',
36 | '*.test.{js,jsx,ts,tsx}': () => 'npm run check:test',
37 | };
38 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { type NextRequest, NextResponse } from 'next/server';
2 |
3 | export const config = {
4 | matcher: ['/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)'],
5 | };
6 |
7 | export function middleware(request: NextRequest) {
8 | const requestHeaders = new Headers(request.headers);
9 |
10 | requestHeaders.set('x-url', request.url);
11 |
12 | return NextResponse.next({
13 | request: {
14 | headers: requestHeaders,
15 | },
16 | });
17 | }
18 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | // NOTE: This file should not be edited
4 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
5 |
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import { withSentryConfig } from '@sentry/nextjs';
2 | import { createJiti } from 'jiti';
3 | import { nanoid } from 'nanoid';
4 | import type { NextConfig } from 'next';
5 | import { RsdoctorWebpackPlugin } from '@rsdoctor/webpack-plugin';
6 | import { environment as clientEnv } from '#/env/client';
7 | import { environment as serverEnv } from '#/env/server';
8 |
9 | const jiti = createJiti(import.meta.url);
10 |
11 | (async () => {
12 | try {
13 | await jiti.import('./src/env/client', {});
14 | await jiti.import('./src/env/server', {});
15 | } catch {}
16 | })();
17 |
18 | const nextConfig: NextConfig = {
19 | reactStrictMode: true,
20 |
21 | poweredByHeader: false,
22 |
23 | cleanDistDir: true,
24 |
25 | webpack: (config, { isServer, nextRuntime }) => {
26 | if (isServer) {
27 | config.ignoreWarnings = [{ module: /opentelemetry/ }];
28 | }
29 |
30 | if (process.env.ANALYZE === 'true') {
31 | if (config.name === 'client') {
32 | config.plugins.push(
33 | new RsdoctorWebpackPlugin({
34 | disableClientServer: true,
35 | })
36 | );
37 | } else if (config.name === 'server') {
38 | config.plugins.push(
39 | new RsdoctorWebpackPlugin({
40 | disableClientServer: true,
41 | output: {
42 | reportDir: './.next/server',
43 | },
44 | })
45 | );
46 | }
47 | }
48 |
49 | return config;
50 | },
51 |
52 | experimental: {
53 | // ppr: 'incremental',
54 | webpackBuildWorker: true,
55 | parallelServerCompiles: true,
56 | parallelServerBuildTraces: true,
57 | serverSourceMaps: true,
58 | webpackMemoryOptimizations: true,
59 | optimizePackageImports: ['react-use', 'lodash-es', 'lucide-react'],
60 | },
61 |
62 | generateBuildId: () => `${nanoid()}-${new Date().toISOString()}`,
63 |
64 | devIndicators: {
65 | position: 'top-right',
66 | },
67 |
68 | images: {
69 | disableStaticImages: true,
70 | dangerouslyAllowSVG: true,
71 | contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
72 | },
73 |
74 | async rewrites() {
75 | if (!clientEnv.NEXT_PUBLIC_BFF_PATH || !serverEnv.BACK_INTERNAL_URL) {
76 | throw new Error('Missing bff envs');
77 | }
78 |
79 | return {
80 | beforeFiles: [
81 | {
82 | source: `${clientEnv.NEXT_PUBLIC_BFF_PATH}/:path*`,
83 | destination: `${serverEnv.BACK_INTERNAL_URL}/:path*`,
84 | },
85 | ],
86 | };
87 | },
88 |
89 | headers: async () => {
90 | if (process.env.NODE_ENV !== 'production') {
91 | return [];
92 | }
93 |
94 | return [
95 | {
96 | source: '/:all*(svg|jpg|png|jpeg|woff|woff2|webp|ico)',
97 | locale: false,
98 | headers: [
99 | {
100 | key: 'Cache-Control',
101 | value: `public, max-age=${process.env.CACHE_PUBLIC_MAX_AGE ?? 3600}, stale-while-revalidate`,
102 | },
103 | ],
104 | },
105 | {
106 | source: '/:path*',
107 | headers: [
108 | {
109 | key: 'X-DNS-Prefetch-Control',
110 | value: 'on',
111 | },
112 | {
113 | key: 'X-XSS-Protection',
114 | value: '0',
115 | },
116 | {
117 | key: 'X-Content-Type-Options',
118 | value: 'nosniff',
119 | },
120 | {
121 | key: 'X-Permitted-Cross-Domain-Policies',
122 | value: 'none',
123 | },
124 | {
125 | key: 'Content-Security-Policy',
126 | value: `
127 | default-src 'self';
128 | script-src 'self' 'unsafe-eval' 'unsafe-inline';
129 | style-src 'self' 'unsafe-inline';
130 | img-src 'self' blob: data:;
131 | font-src 'self';
132 | object-src 'none';
133 | base-uri 'self';
134 | form-action 'self';
135 | frame-ancestors 'none';
136 | upgrade-insecure-requests;
137 | `.replace(/\n/g, ''),
138 | },
139 | {
140 | key: 'Cross-Origin-Embedder-Policy',
141 | value: 'require-corp',
142 | },
143 | {
144 | key: 'Cross-Origin-Opener-Policy',
145 | value: 'same-origin',
146 | },
147 | {
148 | key: 'Cross-Origin-Resource-Policy',
149 | value: 'same-origin',
150 | },
151 | {
152 | key: 'Referrer-Policy',
153 | value: 'no-referrer',
154 | },
155 | {
156 | key: 'Strict-Transport-Security',
157 | value: 'max-age=31536000; includeSubDomains',
158 | },
159 | {
160 | key: 'Permissions-Policy',
161 | value: 'accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(self), usb=(), web-share=(), xr-spatial-tracking=(), clipboard-read=(), clipboard-write=(), gamepad=(), hid=(), idle-detection=(), interest-cohort=(), serial=(), unload=()',
162 | },
163 | ],
164 | },
165 | ];
166 | },
167 |
168 | logging: {
169 | fetches: {
170 | fullUrl: true,
171 | },
172 | },
173 | };
174 |
175 | const isProduction =
176 | process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'PROD';
177 |
178 | const withSentry = () => {
179 | if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_PUBLIC_SENTRY_DSN?.length > 0) {
180 | return withSentryConfig(nextConfig, {
181 | org: process.env.SENTRY_ORG,
182 | project: process.env.APP_NAME,
183 | authToken: process.env.SENTRY_AUTH_TOKEN,
184 | sentryUrl: process.env.SENTRY_URL,
185 | silent: true,
186 | widenClientFileUpload: true,
187 | sourcemaps: { deleteSourcemapsAfterUpload: true },
188 | reactComponentAnnotation: { enabled: true },
189 | disableLogger: true,
190 | telemetry: false,
191 | bundleSizeOptimizations: {
192 | excludeDebugStatements: true,
193 | excludeReplayShadowDom: true,
194 | excludeReplayIframe: true,
195 | },
196 | });
197 | }
198 |
199 | return nextConfig;
200 | };
201 |
202 | export default isProduction ? withSentry() : nextConfig;
203 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs-starter",
3 | "version": "7.0.0",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "build": "next build",
8 | "dev": "kill-port 3000 && next dev -p 3000 --turbo",
9 | "prod": "next start",
10 | "check:ts": "tsc --project tsconfig.json --noEmit --pretty",
11 | "check:lint": "next lint",
12 | "check:format": "prettier . --write --cache --log-level=silent",
13 | "check:test": "vitest run",
14 | "check:e2e": "playwright test",
15 | "check:all": "npm run check:ts && npm run check:lint && npm run check:test && npm run check:format",
16 | "test:watch": "vitest",
17 | "test:coverage": "vitest run --coverage",
18 | "analyze": "ANALYZE=true npm run build",
19 | "client:rsdoctor": "rsdoctor analyze --profile .next/.rsdoctor/manifest.json",
20 | "server:rsdoctor": "rsdoctor analyze --profile .next/server/.rsdoctor/manifest.json",
21 | "knip": "knip --strict",
22 | "jscpd": "npx jscpd",
23 | "clean": "rimraf .next",
24 | "storybook": "kill-port 6006 && storybook dev -p 6006",
25 | "build-storybook": "storybook build"
26 | },
27 | "dependencies": {
28 | "@hookform/resolvers": "5.0.1",
29 | "@repo/api": "*",
30 | "@repo/core": "*",
31 | "@repo/design-tokens": "*",
32 | "@repo/logger": "*",
33 | "@repo/metrics": "*",
34 | "@repo/routes": "*",
35 | "@sentry/nextjs": "9.22.0",
36 | "@t3-oss/env-nextjs": "0.13.4",
37 | "@tanstack/react-query": "5.77.0",
38 | "@tanstack/react-query-devtools": "5.77.0",
39 | "@tanstack/react-query-next-experimental": "5.77.0",
40 | "@vercel/otel": "1.12.0",
41 | "adze": "2.2.3",
42 | "class-variance-authority": "0.7.1",
43 | "clsx": "2.1.1",
44 | "dayjs": "1.11.13",
45 | "framer-motion": "12.12.2",
46 | "isomorphic-dompurify": "2.25.0",
47 | "lodash-es": "4.17.21",
48 | "lucide-react": "0.511.0",
49 | "nanoid": "5.1.5",
50 | "next": "15.3.2",
51 | "react": "19.1.0",
52 | "react-dom": "19.1.0",
53 | "react-hook-form": "7.56.4",
54 | "react-use": "17.6.0",
55 | "server-only": "0.0.1",
56 | "tailwind-merge": "3.3.0",
57 | "tsafe": "1.8.5",
58 | "zod": "3.25.28"
59 | },
60 | "devDependencies": {
61 | "@playwright/test": "1.52.0",
62 | "@repo/ts-config": "*",
63 | "@rsdoctor/cli": "1.1.2",
64 | "@rsdoctor/webpack-plugin": "1.1.2",
65 | "@storybook/addon-designs": "8.2.1",
66 | "@storybook/addon-essentials": "8.6.14",
67 | "@storybook/addon-interactions": "8.6.14",
68 | "@storybook/addon-links": "8.6.14",
69 | "@storybook/addon-onboarding": "8.6.14",
70 | "@storybook/addon-storysource": "8.6.14",
71 | "@storybook/blocks": "8.6.14",
72 | "@storybook/nextjs": "8.6.14",
73 | "@storybook/react": "8.6.14",
74 | "@storybook/test": "8.6.14",
75 | "@tailwindcss/postcss": "4.1.7",
76 | "@testing-library/react": "16.3.0",
77 | "@testing-library/user-event": "14.6.1",
78 | "@total-typescript/ts-reset": "0.6.1",
79 | "@types/lodash-es": "4.17.12",
80 | "@types/node": "22.15.21",
81 | "@types/react": "19.1.5",
82 | "@types/react-dom": "19.1.5",
83 | "@typescript-eslint/utils": "8.32.1",
84 | "@vitejs/plugin-react": "4.5.0",
85 | "@vitest/browser": "3.1.4",
86 | "@vitest/coverage-v8": "3.1.4",
87 | "@webpractik/eslint-config-next": "2.0.0",
88 | "ajv": "8.17.1",
89 | "commitizen": "4.3.1",
90 | "cssnano": "7.0.7",
91 | "cz-conventional-changelog": "3.3.0",
92 | "eslint": "9.27.0",
93 | "jiti": "2.4.2",
94 | "jsdom": "26.1.0",
95 | "kill-port": "2.0.1",
96 | "knip": "5.58.0",
97 | "lefthook": "1.11.13",
98 | "lint-staged": "16.0.0",
99 | "minimatch": "10.0.1",
100 | "postcss": "8.5.3",
101 | "prettier": "3.5.3",
102 | "prettier-plugin-tailwindcss": "0.6.11",
103 | "rimraf": "6.0.1",
104 | "storybook": "8.6.14",
105 | "tailwindcss": "4.1.7",
106 | "typed-query-selector": "2.12.0",
107 | "typescript": "5.8.3",
108 | "vite-tsconfig-paths": "5.1.4",
109 | "vitest": "3.1.4"
110 | },
111 | "engines": {
112 | "node": "^22",
113 | "npm": "^10"
114 | },
115 | "config": {
116 | "commitizen": {
117 | "path": "./node_modules/cz-conventional-changelog"
118 | }
119 | },
120 | "browserslist": [
121 | "defaults",
122 | "not dead"
123 | ],
124 | "workspaces": [
125 | "packages/*"
126 | ],
127 | "trustedDependencies": [
128 | "@sentry/cli",
129 | "sharp"
130 | ]
131 | }
132 |
--------------------------------------------------------------------------------
/packages/api/.gitignore:
--------------------------------------------------------------------------------
1 | swagger.yaml
2 | swagger.json
3 | openapi.yaml
4 | openapi.json
5 |
--------------------------------------------------------------------------------
/packages/api/axios-client.ts:
--------------------------------------------------------------------------------
1 | import axios, { type AxiosError, type AxiosRequestConfig, type AxiosResponse } from 'axios';
2 |
3 | export type RequestConfig = {
4 | data?: TData;
5 | headers?: AxiosRequestConfig['headers'];
6 | method: 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT';
7 | params?: unknown;
8 | responseType?: 'arraybuffer' | 'blob' | 'document' | 'json' | 'stream' | 'text';
9 | signal?: AbortSignal;
10 | url?: string;
11 | };
12 |
13 | export type ResponseConfig = {
14 | data: TData;
15 | headers?: AxiosResponse['headers'];
16 | status: number;
17 | statusText: string;
18 | };
19 |
20 | export const axiosInstance = axios.create({
21 | baseURL: process.env.BACK_INTERNAL_URL,
22 | withCredentials: true,
23 | });
24 |
25 | const client = async (
26 | config: RequestConfig
27 | ): Promise> => {
28 | return axiosInstance
29 | .request>({ ...config })
30 | .catch((error: AxiosError) => {
31 | throw error;
32 | });
33 | };
34 |
35 | export default client;
36 |
--------------------------------------------------------------------------------
/packages/api/codegen/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webpractik/nextjs-starter/5a88da1157a4e783656f18970d311d83556b906a/packages/api/codegen/.gitkeep
--------------------------------------------------------------------------------
/packages/api/kubb.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@kubb/core';
2 | import { pluginClient } from '@kubb/plugin-client';
3 | import { pluginOas } from '@kubb/plugin-oas';
4 | import { pluginTs } from '@kubb/plugin-ts';
5 | import { pluginZod } from '@kubb/plugin-zod';
6 |
7 | const importPath = '../../../ky-client.ts';
8 |
9 | export default defineConfig(() => {
10 | return {
11 | hooks: {
12 | done: ['prettier ./codegen --write'],
13 | },
14 | input: {
15 | path: 'swagger.json',
16 | },
17 | output: {
18 | clean: true,
19 | path: './codegen',
20 | },
21 | plugins: [
22 | pluginOas({ output: { path: 'swagger' }, validate: true }),
23 |
24 | pluginTs({
25 | dateType: 'date',
26 | enumType: 'enum',
27 | group: { type: 'tag' },
28 | optionalType: 'questionToken',
29 | output: { path: 'models' },
30 | unknownType: 'unknown',
31 | }),
32 |
33 | pluginClient({
34 | group: { type: 'tag' },
35 | importPath,
36 | output: { path: 'ky' },
37 | }),
38 |
39 | // pluginReactQuery({
40 | // client: { dataReturnType: 'data', importPath },
41 | // group: { type: 'tag' },
42 | // output: {
43 | // path: './hooks',
44 | // },
45 | // paramsType: 'object',
46 | // parser: 'zod',
47 | // }),
48 |
49 | pluginZod({
50 | dateType: 'date',
51 | group: { type: 'tag' },
52 | inferred: true,
53 | output: { path: 'zod' },
54 | unknownType: 'unknown',
55 | }),
56 | ],
57 | root: '.',
58 | };
59 | });
60 |
--------------------------------------------------------------------------------
/packages/api/ky-client.ts:
--------------------------------------------------------------------------------
1 | import logger from '@repo/logger';
2 | import ky, { HTTPError, type SearchParamsOption } from 'ky';
3 |
4 | export type RequestConfig = {
5 | data?: TData;
6 | headers?: Record;
7 | method: 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT';
8 | params?: SearchParamsOption;
9 | responseType?: 'arraybuffer' | 'blob' | 'document' | 'json' | 'stream' | 'text';
10 | signal?: AbortSignal;
11 | url: string;
12 | };
13 |
14 | export type ResponseConfig = {
15 | data: TData;
16 | headers?: Record;
17 | status: number;
18 | statusText: string;
19 | };
20 |
21 | export const logRequest = (
22 | method: string,
23 | url: string,
24 | headers: Record,
25 | body?: unknown
26 | ) => {
27 | logger.info(`[REQ] ${method} ${url}`, {
28 | body,
29 | headers,
30 | });
31 | };
32 |
33 | export const logResponse = (
34 | method: string,
35 | url: string,
36 | status: number,
37 | duration: number,
38 | data: unknown
39 | ) => {
40 | logger.success(`[RES] ${method} ${url} - ${String(status)} (${String(duration)}ms)`, {
41 | data,
42 | });
43 | };
44 |
45 | export const logError = (method: string, url: string, error: unknown) => {
46 | logger.error(`[ERR] ${method} ${url}`);
47 | };
48 |
49 | const apiClient = ky.create({
50 | credentials: 'include',
51 | hooks: {
52 | afterResponse: [
53 | async (request, _options, response) => {
54 | const start = performance.now();
55 | const clonedResponse = response.clone();
56 |
57 | const parseBody = async () => {
58 | try {
59 | const contentType = response.headers.get('content-type') || '';
60 |
61 | if (contentType.includes('application/json')) {
62 | return await clonedResponse.json();
63 | }
64 | if (contentType.includes('text/')) {
65 | return await clonedResponse.text();
66 | }
67 |
68 | return {};
69 | } catch {
70 | return {};
71 | }
72 | };
73 |
74 | const data = await parseBody();
75 | const duration = Math.round(performance.now() - start);
76 |
77 | logResponse(request.method, request.url, response.status, duration, data);
78 |
79 | return response;
80 | },
81 | ],
82 | beforeRequest: [
83 | request => {
84 | logRequest(
85 | request.method,
86 | request.url,
87 | Object.fromEntries(request.headers),
88 | request.body ? JSON.parse(String(request.body)) : undefined
89 | );
90 | },
91 | ],
92 | },
93 | prefixUrl: process.env.BACK_INTERNAL_URL,
94 | });
95 |
96 | const client = async (
97 | config: RequestConfig
98 | ): Promise> => {
99 | const { data, headers, method, params, responseType = 'json', signal, url } = config;
100 |
101 | try {
102 | logger.log('prefixURL', process.env.BACK_INTERNAL_URL);
103 |
104 | const kyMethod = apiClient.extend({
105 | headers,
106 | searchParams: params,
107 | signal,
108 | })[method.toLowerCase() as 'delete' | 'get' | 'patch' | 'post' | 'put'];
109 |
110 | const requestUrl = url.startsWith('/') ? url.slice(1) : url;
111 |
112 | const response = await kyMethod(requestUrl || '', {
113 | body: method === 'GET' ? undefined : JSON.stringify(data),
114 | json: data,
115 | });
116 |
117 | const dataResponse = (await (async () => {
118 | switch (responseType) {
119 | case 'arraybuffer': {
120 | return response.arrayBuffer();
121 | }
122 | case 'blob': {
123 | return response.blob();
124 | }
125 | case 'document': {
126 | return new DOMParser().parseFromString(await response.text(), 'text/html');
127 | }
128 | case 'json': {
129 | return response.json();
130 | }
131 | case 'stream': {
132 | return response.body;
133 | }
134 | case 'text': {
135 | return response.text();
136 | }
137 | default: {
138 | return response.json();
139 | }
140 | }
141 | })()) as TData;
142 |
143 | return {
144 | data: dataResponse,
145 | headers: Object.fromEntries(response.headers),
146 | status: response.status,
147 | statusText: response.statusText,
148 | };
149 | } catch (error) {
150 | logError(method, url || '', error);
151 |
152 | if (error instanceof HTTPError) {
153 | const errorData = await error.response.json().catch(() => ({}));
154 |
155 | throw new Error(
156 | `HTTP error! status: ${String(error.response.status)}, details: ${JSON.stringify(errorData)}`
157 | );
158 | }
159 |
160 | throw error;
161 | }
162 | };
163 |
164 | export default client;
165 |
--------------------------------------------------------------------------------
/packages/api/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/api",
3 | "version": "1.0.0",
4 | "description": "",
5 | "exports": {
6 | ".": "./codegen/index.ts",
7 | "./api-client": "./ky-client.ts"
8 | },
9 | "scripts": {
10 | "generate": "kubb generate --config kubb.config.ts"
11 | },
12 | "author": "",
13 | "license": "ISC",
14 | "dependencies": {
15 | "@repo/logger": "*",
16 | "@kubb/cli": "3.10.12",
17 | "@kubb/core": "3.10.12",
18 | "@kubb/plugin-client": "3.10.12",
19 | "@kubb/plugin-oas": "3.10.12",
20 | "@kubb/plugin-react-query": "3.10.12",
21 | "@kubb/plugin-ts": "3.10.12",
22 | "@kubb/plugin-zod": "3.10.12",
23 | "axios": "1.9.0",
24 | "ky": "1.8.1"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/packages/core/button/button.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import Link from 'next/link';
4 |
5 | import { Button } from './button';
6 |
7 | const meta: Meta = {
8 | args: {
9 | children: 'Button',
10 | },
11 | component: Button,
12 | title: 'core/Button',
13 | };
14 |
15 | export default meta;
16 |
17 | type Story = StoryObj;
18 |
19 | export const Primary: Story = {
20 | args: {
21 | size: 'default',
22 | variant: 'default',
23 | },
24 | };
25 |
26 | export const Outline: Story = {
27 | args: {
28 | size: 'default',
29 | variant: 'outline',
30 | },
31 | };
32 |
33 | export const LinkButton: Story = {
34 | args: {
35 | size: 'default',
36 | variant: 'link',
37 | },
38 | };
39 |
40 | export const Small: Story = {
41 | args: {
42 | size: 'sm',
43 | variant: 'default',
44 | },
45 | };
46 |
47 | export const Large: Story = {
48 | args: {
49 | size: 'lg',
50 | variant: 'default',
51 | },
52 | };
53 |
54 | export const IconButton = {
55 | args: {
56 | children: 👋 ,
57 | size: 'icon',
58 | variant: 'default',
59 | },
60 | };
61 |
62 | export const AsChild = {
63 | args: {
64 | asChild: true,
65 | children: Link Button,
66 | },
67 | };
68 |
--------------------------------------------------------------------------------
/packages/core/button/button.test.tsx:
--------------------------------------------------------------------------------
1 | import { fireEvent, render } from '@testing-library/react';
2 | import { describe, expect, it, vi } from 'vitest';
3 |
4 | import { Button, buttonVariants } from './button';
5 |
6 | describe(' ', () => {
7 | it('renders the button with default variant and size', () => {
8 | const { getByRole } = render(Button );
9 |
10 | expect(getByRole('button')).toHaveClass(
11 | buttonVariants({ size: 'default', variant: 'default' })
12 | );
13 | });
14 |
15 | it('renders the button with the outline variant and small size', () => {
16 | const { getByRole } = render(
17 |
18 | Button
19 |
20 | );
21 |
22 | expect(getByRole('button')).toHaveClass(buttonVariants({ size: 'sm', variant: 'outline' }));
23 | });
24 |
25 | it('renders the button with the link variant', () => {
26 | const { getByRole } = render(Button );
27 |
28 | expect(getByRole('button')).toHaveClass(buttonVariants({ variant: 'link' }));
29 | });
30 |
31 | it('triggers onClick', () => {
32 | const onClick = vi.fn();
33 | const { getByRole } = render(Button );
34 |
35 | fireEvent.click(getByRole('button'));
36 |
37 | expect(onClick).toHaveBeenCalled();
38 | });
39 |
40 | it('renders button as A tag', () => {
41 | const { getByRole } = render(
42 |
43 | Link Button
44 |
45 | );
46 |
47 | const linkElement = getByRole('link');
48 |
49 | expect(linkElement).toBeInTheDocument();
50 | expect(linkElement).toHaveAttribute('href', 'https://example.com');
51 | expect(linkElement.tagName).toBe('A');
52 | });
53 |
54 | it('does not render a button element when asChild is true', () => {
55 | const { container } = render(
56 |
57 | Child Button
58 |
59 | );
60 |
61 | expect(container.querySelector('button')).toBeNull();
62 | });
63 | });
64 |
--------------------------------------------------------------------------------
/packages/core/button/button.tsx:
--------------------------------------------------------------------------------
1 | import type { ButtonHTMLAttributes, Ref } from 'react';
2 |
3 | import { Slot } from '@radix-ui/react-slot';
4 | import { cva, type VariantProps } from 'class-variance-authority';
5 | import { LoaderCircle } from 'lucide-react';
6 |
7 | import { cn } from '../cn';
8 |
9 | const buttonVariants = cva(
10 | 'inline-flex cursor-pointer items-center justify-center rounded-md text-sm font-medium whitespace-nowrap ring-offset-background transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50',
11 | {
12 | defaultVariants: {
13 | size: 'default',
14 | variant: 'default',
15 | },
16 | variants: {
17 | size: {
18 | default: 'h-10 px-4 py-2',
19 | icon: 'size-10',
20 | lg: 'h-11 rounded-md px-8',
21 | sm: 'h-9 rounded-md px-3',
22 | },
23 | variant: {
24 | default: 'bg-primary text-primary-foreground hover:bg-primary/90',
25 | destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
26 | ghost: 'hover:bg-accent hover:text-accent-foreground',
27 | link: 'text-primary underline-offset-4 hover:underline',
28 | outline:
29 | 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
30 | secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
31 | },
32 | },
33 | }
34 | );
35 |
36 | export type ButtonProps = {
37 | asChild?: boolean;
38 | loading?: boolean;
39 | ref?: Ref;
40 | } & ButtonHTMLAttributes &
41 | VariantProps;
42 |
43 | function Button({
44 | asChild = false,
45 | children,
46 | className,
47 | loading,
48 | ref,
49 | size,
50 | type,
51 | variant,
52 | ...props
53 | }: Readonly) {
54 | if (asChild) {
55 | return {children} ;
56 | }
57 |
58 | return (
59 |
66 | {loading ? (
67 |
68 |
69 |
70 | ) : null}
71 | {children}
72 |
73 | );
74 | }
75 |
76 | Button.displayName = 'Button';
77 |
78 | export { Button, buttonVariants };
79 |
--------------------------------------------------------------------------------
/packages/core/button/index.ts:
--------------------------------------------------------------------------------
1 | export { Button } from './button';
2 |
--------------------------------------------------------------------------------
/packages/core/checkbox/checkbox.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { CheckboxProps } from '@radix-ui/react-checkbox';
2 | import type { Meta, StoryObj } from '@storybook/react';
3 |
4 | import { Checkbox } from './checkbox';
5 |
6 | const meta = {
7 | component: Checkbox,
8 | title: 'core/Checkbox',
9 | } satisfies Meta;
10 |
11 | export default meta;
12 |
13 | type Story = StoryObj;
14 |
15 | export const Primary: Story = {
16 | render: (props: CheckboxProps) => {
17 | return ;
18 | },
19 | };
20 |
21 | export const AllStates: Story = {
22 | render: () => {
23 | return (
24 |
25 |
26 |
27 |
28 |
29 | );
30 | },
31 | };
32 |
--------------------------------------------------------------------------------
/packages/core/checkbox/checkbox.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import userEvent from '@testing-library/user-event';
3 | import { describe, expect, it } from 'vitest';
4 |
5 | import { Checkbox } from './checkbox';
6 |
7 | describe(' ', () => {
8 | it('renders correctly', () => {
9 | render( );
10 | const checkbox = screen.getByRole('checkbox');
11 |
12 | expect(checkbox).toBeInTheDocument();
13 | });
14 |
15 | it('can be toggled by clicking', async () => {
16 | render( );
17 | const checkbox = screen.getByRole('checkbox');
18 |
19 | expect(checkbox).not.toBeChecked();
20 |
21 | await userEvent.click(checkbox);
22 | expect(checkbox).toBeChecked();
23 |
24 | await userEvent.click(checkbox);
25 | expect(checkbox).not.toBeChecked();
26 | });
27 |
28 | it('displays the CheckIcon when checked', async () => {
29 | render( );
30 | const checkbox = screen.getByRole('checkbox');
31 |
32 | await userEvent.click(checkbox);
33 |
34 | const checkIcon = screen.getByTestId('check-icon');
35 |
36 | expect(checkIcon).toBeInTheDocument();
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/packages/core/checkbox/checkbox.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { ComponentProps, Ref } from 'react';
4 |
5 | import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
6 | import { Check } from 'lucide-react';
7 |
8 | import { cn } from '../cn';
9 |
10 | type CheckboxProps = {
11 | ref?: Ref;
12 | } & ComponentProps;
13 |
14 | export function Checkbox({ className, ref, ...props }: CheckboxProps) {
15 | return (
16 |
24 |
27 |
28 |
29 |
30 | );
31 | }
32 |
33 | Checkbox.displayName = CheckboxPrimitive.Root.displayName;
34 |
--------------------------------------------------------------------------------
/packages/core/checkbox/index.ts:
--------------------------------------------------------------------------------
1 | export { Checkbox } from './checkbox';
2 |
--------------------------------------------------------------------------------
/packages/core/cn.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/packages/core/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@repo/core",
15 | "utils": "@repo/core/utils/cn",
16 | "ui": "@repo/core"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/packages/core/dialog/dialog.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import { Dialog } from './dialog';
4 |
5 | const meta: Meta = {
6 | component: Dialog,
7 | title: 'core/Dialog',
8 | };
9 |
10 | export default meta;
11 |
12 | type Story = StoryObj;
13 |
14 | export const Primary: Story = {
15 | args: {},
16 | };
17 |
--------------------------------------------------------------------------------
/packages/core/dialog/dialog.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { ComponentProps, HTMLAttributes, Ref } from 'react';
4 |
5 | import * as DialogPrimitive from '@radix-ui/react-dialog';
6 | import { X } from 'lucide-react';
7 |
8 | import { cn } from '../cn';
9 |
10 | export const Dialog = DialogPrimitive.Root;
11 |
12 | export const DialogTrigger = DialogPrimitive.Trigger;
13 |
14 | export const DialogPortal = DialogPrimitive.Portal;
15 |
16 | export const DialogClose = DialogPrimitive.Close;
17 |
18 | type DialogOverlayProps = {
19 | ref?: Ref;
20 | } & ComponentProps;
21 |
22 | export function DialogOverlay({ className, ref, ...props }: DialogOverlayProps) {
23 | return (
24 |
32 | );
33 | }
34 |
35 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
36 |
37 | type DialogContentProps = {
38 | ref?: Ref;
39 | } & ComponentProps;
40 |
41 | export function DialogContent({ children, className, ref, ...props }: DialogContentProps) {
42 | return (
43 |
44 |
45 |
53 | {children}
54 |
55 |
56 | Close
57 |
58 |
59 |
60 | );
61 | }
62 |
63 | DialogContent.displayName = DialogPrimitive.Content.displayName;
64 |
65 | export function DialogHeader({ className, ...props }: HTMLAttributes) {
66 | return (
67 |
71 | );
72 | }
73 |
74 | DialogHeader.displayName = 'DialogHeader';
75 |
76 | export function DialogFooter({ className, ...props }: HTMLAttributes) {
77 | return (
78 |
85 | );
86 | }
87 |
88 | DialogFooter.displayName = 'DialogFooter';
89 |
90 | type DialogTitleProps = {
91 | ref?: Ref;
92 | } & ComponentProps;
93 |
94 | export function DialogTitle({ className, ref, ...props }: DialogTitleProps) {
95 | return (
96 |
101 | );
102 | }
103 |
104 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
105 |
106 | type DialogDescriptionProps = {
107 | ref?: Ref;
108 | } & ComponentProps;
109 |
110 | export function DialogDescription({ className, ref, ...props }: DialogDescriptionProps) {
111 | return (
112 |
117 | );
118 | }
119 |
120 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
121 |
--------------------------------------------------------------------------------
/packages/core/dialog/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | Dialog,
3 | DialogClose,
4 | DialogContent,
5 | DialogDescription,
6 | DialogFooter,
7 | DialogHeader,
8 | DialogOverlay,
9 | DialogPortal,
10 | DialogTitle,
11 | DialogTrigger,
12 | } from './dialog';
13 |
--------------------------------------------------------------------------------
/packages/core/form/form.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type * as LabelPrimitive from '@radix-ui/react-label';
4 |
5 | import { Slot } from '@radix-ui/react-slot';
6 | import { Label } from '@repo/core/label';
7 | import {
8 | type ComponentProps,
9 | createContext,
10 | type HTMLAttributes,
11 | type Ref,
12 | useContext,
13 | useId,
14 | useMemo,
15 | } from 'react';
16 | import {
17 | Controller,
18 | type ControllerProps,
19 | type FieldPath,
20 | type FieldValues,
21 | useFormContext,
22 | } from 'react-hook-form';
23 |
24 | import { cn } from '../cn';
25 |
26 | type FormFieldContextValue<
27 | TFieldValues extends FieldValues = FieldValues,
28 | TName extends FieldPath = FieldPath,
29 | > = {
30 | name: TName;
31 | };
32 |
33 | const FormFieldContext = createContext({} as FormFieldContextValue);
34 |
35 | type FormItemContextValue = {
36 | id: string;
37 | };
38 |
39 | export const FormItemContext = createContext({} as FormItemContextValue);
40 |
41 | export function FormField<
42 | TFieldValues extends FieldValues = FieldValues,
43 | TName extends FieldPath = FieldPath,
44 | >({ ...props }: ControllerProps) {
45 | return (
46 | ({ name: props.name }), [props.name])}>
47 |
48 |
49 | );
50 | }
51 |
52 | export const useFormField = () => {
53 | const fieldContext = useContext(FormFieldContext);
54 | const itemContext = useContext(FormItemContext);
55 | const { formState, getFieldState } = useFormContext();
56 |
57 | const fieldState = getFieldState(fieldContext.name, formState);
58 |
59 | if (!fieldContext.name) {
60 | throw new Error('field should have name and should be used within ');
61 | }
62 |
63 | const { id } = itemContext;
64 |
65 | return {
66 | formDescriptionId: `${id}-form-item-description`,
67 | formItemId: `${id}-form-item`,
68 | formMessageId: `${id}-form-item-message`,
69 | id,
70 | name: fieldContext.name,
71 | ...fieldState,
72 | };
73 | };
74 |
75 | type FormItemProps = { ref?: Ref } & HTMLAttributes;
76 |
77 | export function FormItem({ className, ref, ...props }: FormItemProps) {
78 | const id = useId();
79 |
80 | return (
81 | ({ id }), [id])}>
82 |
83 |
84 | );
85 | }
86 |
87 | FormItem.displayName = 'FormItem';
88 |
89 | type FormLabelProps = {
90 | ref?: Ref;
91 | } & ComponentProps;
92 |
93 | export function FormLabel({ className, ref, ...props }: FormLabelProps) {
94 | const { error, formItemId } = useFormField();
95 |
96 | return (
97 |
103 | );
104 | }
105 |
106 | FormLabel.displayName = 'FormLabel';
107 |
108 | type FormControlProps = { ref?: Ref } & ComponentProps;
109 |
110 | export function FormControl({ ref, ...props }: FormControlProps) {
111 | const { error, formDescriptionId, formItemId, formMessageId } = useFormField();
112 |
113 | return (
114 |
121 | );
122 | }
123 |
124 | FormControl.displayName = 'FormControl';
125 |
126 | type FormDescriptionProps = {
127 | ref?: Ref;
128 | } & HTMLAttributes;
129 |
130 | export function FormDescription({ className, ref, ...props }: FormDescriptionProps) {
131 | const { formDescriptionId } = useFormField();
132 |
133 | return (
134 |
140 | );
141 | }
142 |
143 | FormDescription.displayName = 'FormDescription';
144 |
145 | type FormMessageProps = { ref?: Ref } & HTMLAttributes;
146 |
147 | export function FormMessage({ children, className, ref, ...props }: FormMessageProps) {
148 | const { error, formMessageId } = useFormField();
149 | const body = error ? String(error.message) : children;
150 |
151 | if (!body) {
152 | return null;
153 | }
154 |
155 | return (
156 |
162 | {body}
163 |
164 | );
165 | }
166 |
167 | FormMessage.displayName = 'FormMessage';
168 |
169 | export { FormProvider as Form } from 'react-hook-form';
170 |
--------------------------------------------------------------------------------
/packages/core/form/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | Form,
3 | FormControl,
4 | FormDescription,
5 | FormField,
6 | FormItem,
7 | FormLabel,
8 | FormMessage,
9 | useFormField,
10 | } from './form';
11 |
--------------------------------------------------------------------------------
/packages/core/input/index.ts:
--------------------------------------------------------------------------------
1 | export { Input } from './input';
2 |
--------------------------------------------------------------------------------
/packages/core/input/input.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import { Input } from './input';
4 |
5 | const meta: Meta = {
6 | argTypes: {
7 | placeholder: { control: 'text' },
8 | type: { control: 'text' },
9 | },
10 | component: Input,
11 | title: 'core/Input',
12 | };
13 |
14 | export default meta;
15 |
16 | type Story = StoryObj;
17 |
18 | export const Primary: Story = {
19 | args: {
20 | placeholder: 'Text input',
21 | type: 'text',
22 | },
23 | };
24 |
25 | export const EmailDisabled: Story = {
26 | args: {
27 | disabled: true,
28 | placeholder: 'Email input',
29 | type: 'email',
30 | },
31 | };
32 |
33 | export const Password: Story = {
34 | args: {
35 | placeholder: 'Password input',
36 | type: 'password',
37 | },
38 | };
39 |
--------------------------------------------------------------------------------
/packages/core/input/input.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import userEvent from '@testing-library/user-event';
3 | import { describe, expect, it, vi } from 'vitest';
4 |
5 | import { Input } from './input';
6 |
7 | describe(' ', () => {
8 | it('renders', () => {
9 | render( );
10 | const inputElement = screen.getByRole('textbox');
11 |
12 | expect(inputElement).toBeInTheDocument();
13 | });
14 |
15 | it('handles user input correctly', async () => {
16 | const user = userEvent.setup();
17 |
18 | render( );
19 | const input = screen.getByRole('textbox');
20 |
21 | await user.type(input, 'Hello, world!');
22 | expect(input).toHaveValue('Hello, world!');
23 | });
24 |
25 | it('accepts type prop', () => {
26 | render( );
27 | const inputElement = screen.getByRole('textbox');
28 |
29 | expect(inputElement).toHaveAttribute('type', 'email');
30 | });
31 |
32 | it('accepts custom className', () => {
33 | const customClass = 'my-custom-class';
34 |
35 | render( );
36 | const inputElement = screen.getByRole('textbox');
37 |
38 | expect(inputElement).toHaveClass(customClass);
39 | });
40 |
41 | it('calls custom onChange handler', async () => {
42 | const user = userEvent.setup();
43 | const onChangeMock = vi.fn();
44 |
45 | render( );
46 | const input = screen.getByRole('textbox');
47 |
48 | await user.type(input, 'a');
49 | expect(onChangeMock).toHaveBeenCalledTimes(1);
50 | });
51 | });
52 |
--------------------------------------------------------------------------------
/packages/core/input/input.tsx:
--------------------------------------------------------------------------------
1 | import type { InputHTMLAttributes, Ref } from 'react';
2 |
3 | import { cn } from '../cn';
4 |
5 | export type InputProps = {
6 | ref?: Ref;
7 | } & InputHTMLAttributes;
8 |
9 | export function Input({ className, ref, type, ...props }: InputProps) {
10 | return (
11 |
20 | );
21 | }
22 |
23 | Input.displayName = 'Input';
24 |
--------------------------------------------------------------------------------
/packages/core/label/index.ts:
--------------------------------------------------------------------------------
1 | export { Label } from './label';
2 |
--------------------------------------------------------------------------------
/packages/core/label/label.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import { Label } from './label';
4 |
5 | const meta: Meta = {
6 | component: Label,
7 | title: 'core/Label',
8 | };
9 |
10 | export default meta;
11 |
12 | type Story = StoryObj;
13 |
14 | export const Primary: Story = {
15 | args: {},
16 | };
17 |
--------------------------------------------------------------------------------
/packages/core/label/label.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { describe, expect, it } from 'vitest';
3 |
4 | import { Label } from './label';
5 |
6 | describe(' ', () => {
7 | it('it should mount', () => {
8 | render( );
9 |
10 | const label = screen.getByTestId('Label');
11 |
12 | expect(label).toBeInTheDocument();
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/packages/core/label/label.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { ComponentProps, Ref } from 'react';
4 |
5 | import * as LabelPrimitive from '@radix-ui/react-label';
6 | import { cva, type VariantProps } from 'class-variance-authority';
7 |
8 | import { cn } from '../cn';
9 |
10 | const labelVariants = cva(
11 | 'text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
12 | );
13 |
14 | type LabelProps = {
15 | ref?: Ref;
16 | } & ComponentProps &
17 | VariantProps;
18 |
19 | export function Label({ className, ref, ...props }: LabelProps) {
20 | return ;
21 | }
22 |
23 | Label.displayName = LabelPrimitive.Root.displayName;
24 |
--------------------------------------------------------------------------------
/packages/core/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/core",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "add": "npx shadcn@latest add"
8 | },
9 | "keywords": [],
10 | "author": "",
11 | "license": "ISC",
12 | "dependencies": {
13 | "@hookform/resolvers": "5.0.1",
14 | "@radix-ui/react-checkbox": "1.3.2",
15 | "@radix-ui/react-dialog": "1.1.14",
16 | "@radix-ui/react-label": "2.1.7",
17 | "@radix-ui/react-radio-group": "1.3.7",
18 | "@radix-ui/react-scroll-area": "1.2.9",
19 | "@radix-ui/react-select": "2.2.5",
20 | "@radix-ui/react-slot": "1.2.3",
21 | "@radix-ui/react-switch": "1.2.5",
22 | "@radix-ui/react-tabs": "1.1.12",
23 | "@radix-ui/react-tooltip": "1.2.7",
24 | "next-themes": "0.4.6",
25 | "react-hook-form": "7.56.4",
26 | "sonner": "2.0.3",
27 | "zod": "3.25.28"
28 | },
29 | "peerDependencies": {
30 | "@storybook/react": "^8",
31 | "@testing-library/react": "^16",
32 | "@testing-library/user-event": "^14",
33 | "@vitest/browser": "*",
34 | "class-variance-authority": "^0",
35 | "lucide-react": "*",
36 | "next": "^15",
37 | "react": "^19",
38 | "react-dom": "^19",
39 | "vitest": "^4"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/packages/core/radio-group/index.ts:
--------------------------------------------------------------------------------
1 | export { RadioGroup, RadioGroupItem } from './radio-group';
2 |
--------------------------------------------------------------------------------
/packages/core/radio-group/radio-group.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import { RadioGroup, RadioGroupItem } from './radio-group';
4 |
5 | const meta: Meta = {
6 | component: RadioGroup,
7 | title: 'core/RadioGroup',
8 | };
9 |
10 | export default meta;
11 |
12 | type Story = StoryObj;
13 |
14 | export const Primary: Story = {
15 | args: {},
16 |
17 | render: () => {
18 | return (
19 |
20 |
21 | Option 1
22 |
23 |
24 |
25 |
26 |
27 |
28 | );
29 | },
30 | };
31 |
--------------------------------------------------------------------------------
/packages/core/radio-group/radio-group.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import userEvent from '@testing-library/user-event';
3 | import { useState } from 'react';
4 | import { describe, expect, it } from 'vitest';
5 |
6 | import { RadioGroup, RadioGroupItem } from './radio-group';
7 |
8 | describe(' ', () => {
9 | const stateAttribute = 'data-state';
10 |
11 | it('renders RadioGroup with RadioGroupItems', () => {
12 | render(
13 |
14 |
15 |
16 |
17 | );
18 |
19 | expect(screen.getByTestId('option1')).toBeInTheDocument();
20 | expect(screen.getByTestId('option1')).toBeInTheDocument();
21 | });
22 |
23 | it('allows selecting different RadioGroupItems', async () => {
24 | const user = userEvent.setup();
25 |
26 | render(
27 |
28 |
29 |
30 |
31 | );
32 |
33 | const option1 = screen.getByTestId('option1');
34 | const option2 = screen.getByTestId('option2');
35 |
36 | await user.click(option1);
37 | expect(option1).toHaveAttribute(stateAttribute, 'checked');
38 | expect(option2).not.toHaveAttribute(stateAttribute, 'checked');
39 |
40 | await user.click(option2);
41 | expect(option2).toHaveAttribute(stateAttribute, 'checked');
42 | expect(option1).not.toHaveAttribute(stateAttribute, 'checked');
43 | });
44 |
45 | it('does not allow selecting a disabled RadioGroupItem', async () => {
46 | const user = userEvent.setup();
47 |
48 | render(
49 |
50 |
51 | Option 1
52 |
53 |
54 | Option 2
55 |
56 |
57 | );
58 |
59 | const option2 = screen.getByTestId('option2');
60 |
61 | await user.click(option2);
62 |
63 | expect(option2).not.toHaveAttribute(stateAttribute, 'checked');
64 | });
65 |
66 | it('selects the RadioGroupItem based on controlled value', async () => {
67 | const user = userEvent.setup();
68 |
69 | function ControlledRadioGroup() {
70 | const [value, setValue] = useState('option1');
71 |
72 | return (
73 |
74 |
75 | Option 1
76 |
77 |
78 | Option 2
79 |
80 |
81 | );
82 | }
83 |
84 | render( );
85 |
86 | const option1 = screen.getByTestId('option1');
87 | const option2 = screen.getByTestId('option2');
88 |
89 | expect(option1).toHaveAttribute(stateAttribute, 'checked');
90 |
91 | await user.click(option2);
92 |
93 | expect(option2).toHaveAttribute(stateAttribute, 'checked');
94 | });
95 | });
96 |
--------------------------------------------------------------------------------
/packages/core/radio-group/radio-group.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { ComponentProps, Ref } from 'react';
4 |
5 | import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
6 | import { Circle } from 'lucide-react';
7 |
8 | import { cn } from '../cn';
9 |
10 | type RadioGroupProps = {
11 | ref?: Ref;
12 | } & ComponentProps;
13 |
14 | export function RadioGroup({ className, ref, ...props }: RadioGroupProps) {
15 | return (
16 |
17 | );
18 | }
19 |
20 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
21 |
22 | type RadioGroupItemProps = {
23 | ref?: Ref;
24 | } & ComponentProps;
25 |
26 | export function RadioGroupItem({ className, ref, ...props }: RadioGroupItemProps) {
27 | return (
28 |
36 |
37 |
38 |
39 |
40 | );
41 | }
42 |
43 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
44 |
--------------------------------------------------------------------------------
/packages/core/select/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | Select,
3 | SelectContent,
4 | SelectGroup,
5 | SelectItem,
6 | SelectLabel,
7 | SelectScrollDownButton,
8 | SelectScrollUpButton,
9 | SelectSeparator,
10 | SelectTrigger,
11 | SelectValue,
12 | } from './select';
13 |
--------------------------------------------------------------------------------
/packages/core/select/select.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import { Select } from './select';
4 |
5 | const meta: Meta = {
6 | component: Select,
7 | title: 'core/Select',
8 | };
9 |
10 | export default meta;
11 |
12 | type Story = StoryObj;
13 |
14 | export const Primary: Story = {
15 | args: {},
16 | };
17 |
--------------------------------------------------------------------------------
/packages/core/select/select.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { ComponentProps, Ref } from 'react';
4 |
5 | import * as SelectPrimitive from '@radix-ui/react-select';
6 | import { Check, ChevronDown, ChevronUp } from 'lucide-react';
7 |
8 | import { cn } from '../cn';
9 |
10 | export const Select = SelectPrimitive.Root;
11 |
12 | export const SelectGroup = SelectPrimitive.Group;
13 |
14 | export const SelectValue = SelectPrimitive.Value;
15 |
16 | type SelectTriggerProps = {
17 | ref?: Ref;
18 | } & ComponentProps;
19 |
20 | export function SelectTrigger({ children, className, ref, ...props }: SelectTriggerProps) {
21 | return (
22 | span]:line-clamp-1',
25 | className
26 | )}
27 | ref={ref}
28 | {...props}
29 | >
30 | {children}
31 |
32 |
33 |
34 |
35 | );
36 | }
37 |
38 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
39 |
40 | type SelectScrollUpButtonProps = {
41 | ref?: Ref;
42 | } & ComponentProps;
43 |
44 | export function SelectScrollUpButton({ className, ref, ...props }: SelectScrollUpButtonProps) {
45 | return (
46 |
51 |
52 |
53 | );
54 | }
55 |
56 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
57 |
58 | type SelectScrollDownButtonProps = {
59 | ref?: Ref;
60 | } & ComponentProps;
61 |
62 | export function SelectScrollDownButton({ className, ref, ...props }: SelectScrollDownButtonProps) {
63 | return (
64 |
69 |
70 |
71 | );
72 | }
73 |
74 | SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
75 |
76 | type SelectContentProps = {
77 | ref?: Ref;
78 | } & ComponentProps;
79 |
80 | export function SelectContent({
81 | children,
82 | className,
83 | position = 'popper',
84 | ref,
85 | ...props
86 | }: SelectContentProps) {
87 | return (
88 |
89 |
100 |
101 |
108 | {children}
109 |
110 |
111 |
112 |
113 | );
114 | }
115 |
116 | SelectContent.displayName = SelectPrimitive.Content.displayName;
117 |
118 | type SelectLabelProps = {
119 | ref?: Ref;
120 | } & ComponentProps;
121 |
122 | export function SelectLabel({ className, ref, ...props }: SelectLabelProps) {
123 | return (
124 |
129 | );
130 | }
131 |
132 | SelectLabel.displayName = SelectPrimitive.Label.displayName;
133 |
134 | type SelectItemProps = {
135 | ref?: Ref;
136 | } & ComponentProps;
137 |
138 | export function SelectItem({ children, className, ref, ...props }: SelectItemProps) {
139 | return (
140 |
148 |
149 |
150 |
151 |
152 |
153 |
154 | {children}
155 |
156 | );
157 | }
158 |
159 | SelectItem.displayName = SelectPrimitive.Item.displayName;
160 |
161 | type SelectSeparatorProps = {
162 | ref?: Ref;
163 | } & ComponentProps;
164 |
165 | export function SelectSeparator({ className, ref, ...props }: SelectSeparatorProps) {
166 | return (
167 |
172 | );
173 | }
174 |
175 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
176 |
--------------------------------------------------------------------------------
/packages/core/skeleton/index.ts:
--------------------------------------------------------------------------------
1 | export { Skeleton } from './skeleton';
2 |
--------------------------------------------------------------------------------
/packages/core/skeleton/skeleton.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import { Skeleton } from './skeleton';
4 |
5 | const meta: Meta = {
6 | component: Skeleton,
7 | title: 'core/Skeleton',
8 | };
9 |
10 | export default meta;
11 |
12 | type Story = StoryObj;
13 |
14 | export const Primary: Story = {
15 | args: {},
16 | render: () => {
17 | return (
18 |
19 |
20 |
21 |
22 |
23 | );
24 | },
25 | };
26 |
--------------------------------------------------------------------------------
/packages/core/skeleton/skeleton.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { describe, expect, it } from 'vitest';
3 |
4 | import { Skeleton } from './skeleton';
5 |
6 | describe(' ', () => {
7 | it('it should mount', () => {
8 | render( );
9 |
10 | const skeleton = screen.getByTestId('Skeleton');
11 |
12 | expect(skeleton).toBeInTheDocument();
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/packages/core/skeleton/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import type { HTMLAttributes } from 'react';
2 |
3 | import { cn } from '../cn';
4 |
5 | export function Skeleton({ className, ...props }: HTMLAttributes) {
6 | return
;
7 | }
8 |
--------------------------------------------------------------------------------
/packages/core/sonner/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Toaster as Sonner, toast, type ToasterProps } from 'sonner';
4 |
5 | const toastOptions = {
6 | classNames: {
7 | actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
8 | cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
9 | description: 'group-[.toast]:text-muted-foreground',
10 | toast: 'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
11 | },
12 | };
13 |
14 | function Toaster({ ...props }: ToasterProps) {
15 | return ;
16 | }
17 |
18 | export { toast, Toaster };
19 |
--------------------------------------------------------------------------------
/packages/core/switch/index.ts:
--------------------------------------------------------------------------------
1 | export { Switch } from './switch';
2 |
--------------------------------------------------------------------------------
/packages/core/switch/switch.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import { Switch } from './switch';
4 |
5 | const meta: Meta = {
6 | component: Switch,
7 | title: 'core/Switch',
8 | };
9 |
10 | export default meta;
11 |
12 | type Story = StoryObj;
13 |
14 | export const Primary: Story = {
15 | args: {},
16 | render: () => {
17 | return (
18 |
19 |
20 |
21 |
22 |
23 | );
24 | },
25 | };
26 |
--------------------------------------------------------------------------------
/packages/core/switch/switch.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import userEvent from '@testing-library/user-event';
3 | import { describe, expect, it, vi } from 'vitest';
4 |
5 | import { Switch } from './switch';
6 |
7 | describe(' ', () => {
8 | it('it renders correctly', () => {
9 | render( );
10 |
11 | const switchElement = screen.getByTestId('Switch');
12 |
13 | expect(switchElement).toBeInTheDocument();
14 | });
15 |
16 | it('can be toggled', async () => {
17 | render( );
18 | const attribute = 'aria-checked';
19 |
20 | const switchComponent = screen.getByRole('switch');
21 |
22 | expect(switchComponent).toHaveAttribute(attribute, 'false');
23 |
24 | await userEvent.click(switchComponent);
25 | expect(switchComponent).toHaveAttribute(attribute, 'true');
26 |
27 | await userEvent.click(switchComponent);
28 | expect(switchComponent).toHaveAttribute(attribute, 'false');
29 | });
30 |
31 | it('forwards ref correctly', () => {
32 | const ref = vi.fn();
33 |
34 | render( );
35 | expect(ref).toHaveBeenCalled();
36 | expect(ref.mock.calls[0]?.[0]).toBeInstanceOf(HTMLElement);
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/packages/core/switch/switch.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { ComponentProps, Ref } from 'react';
4 |
5 | import * as SwitchPrimitives from '@radix-ui/react-switch';
6 |
7 | import { cn } from '../cn';
8 |
9 | type SwitchProps = {
10 | ref?: Ref;
11 | } & ComponentProps;
12 |
13 | export function Switch({ className, ref, ...props }: SwitchProps) {
14 | return (
15 |
23 |
28 |
29 | );
30 | }
31 |
32 | Switch.displayName = SwitchPrimitives.Root.displayName;
33 |
--------------------------------------------------------------------------------
/packages/core/tabs/index.ts:
--------------------------------------------------------------------------------
1 | export { Tabs } from './tabs';
2 |
--------------------------------------------------------------------------------
/packages/core/tabs/tabs.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import { Tabs } from './tabs';
4 |
5 | const meta: Meta = {
6 | component: Tabs,
7 | title: 'core/Tabs',
8 | };
9 |
10 | export default meta;
11 |
12 | type Story = StoryObj;
13 |
14 | export const Primary: Story = {
15 | args: {},
16 | };
17 |
--------------------------------------------------------------------------------
/packages/core/tabs/tabs.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { describe, expect, it } from 'vitest';
3 |
4 | import { Tabs } from './tabs';
5 |
6 | describe(' ', () => {
7 | it('it should mount', () => {
8 | render( );
9 |
10 | const tabs = screen.getByTestId('Tabs');
11 |
12 | expect(tabs).toBeInTheDocument();
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/packages/core/tabs/tabs.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { ComponentProps, Ref } from 'react';
4 |
5 | import * as TabsPrimitive from '@radix-ui/react-tabs';
6 |
7 | import { cn } from '../cn';
8 |
9 | export const Tabs = TabsPrimitive.Root;
10 |
11 | type TabsListProps = {
12 | ref?: Ref;
13 | } & ComponentProps;
14 |
15 | export function TabsList({ className, ref, ...props }: TabsListProps) {
16 | return (
17 |
25 | );
26 | }
27 |
28 | TabsList.displayName = TabsPrimitive.List.displayName;
29 |
30 | type TabsTriggerProps = {
31 | ref?: Ref;
32 | } & ComponentProps;
33 |
34 | export function TabsTrigger({ className, ref, ...props }: TabsTriggerProps) {
35 | return (
36 |
44 | );
45 | }
46 |
47 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
48 |
49 | type TabsContentProps = {
50 | ref?: Ref;
51 | } & ComponentProps;
52 |
53 | export function TabsContent({ className, ref, ...props }: TabsContentProps) {
54 | return (
55 |
63 | );
64 | }
65 |
66 | TabsContent.displayName = TabsPrimitive.Content.displayName;
67 |
--------------------------------------------------------------------------------
/packages/core/textarea/index.ts:
--------------------------------------------------------------------------------
1 | export { Textarea } from './textarea';
2 |
--------------------------------------------------------------------------------
/packages/core/textarea/textarea.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import { Textarea } from './textarea';
4 |
5 | const meta: Meta = {
6 | component: Textarea,
7 | title: 'core/Textarea',
8 | };
9 |
10 | export default meta;
11 |
12 | type Story = StoryObj;
13 |
14 | export const Primary: Story = {
15 | args: {
16 | cols: 30,
17 | placeholder: 'Type text here...',
18 | rows: 8,
19 | },
20 | };
21 |
--------------------------------------------------------------------------------
/packages/core/textarea/textarea.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import userEvent from '@testing-library/user-event';
3 | import { describe, expect, it, vi } from 'vitest';
4 |
5 | import { Textarea } from './textarea';
6 |
7 | describe('', () => {
8 | it('renders correctly', () => {
9 | render();
10 | const textarea = screen.getByTestId('Textarea');
11 |
12 | expect(textarea).toBeInTheDocument();
13 | });
14 |
15 | it('passes props to the textarea element', () => {
16 | const placeholderText = 'Placeholder...';
17 |
18 | render();
19 | const textarea = screen.getByPlaceholderText(placeholderText);
20 |
21 | expect(textarea).toBeInTheDocument();
22 | });
23 |
24 | it('handles user input correctly', async () => {
25 | const user = userEvent.setup();
26 |
27 | render();
28 | const textarea = screen.getByTestId('Textarea');
29 |
30 | await user.type(textarea, 'Hello, world!');
31 | expect(textarea).toHaveValue('Hello, world!');
32 | });
33 |
34 | it('can be disabled', async () => {
35 | const user = userEvent.setup();
36 |
37 | render();
38 | const textarea = screen.getByTestId('Textarea');
39 |
40 | expect(textarea).toBeDisabled();
41 | await user.type(textarea, 'Text that should not appear');
42 | expect(textarea).toHaveValue('');
43 | });
44 |
45 | it('calls custom onChange handler', async () => {
46 | const user = userEvent.setup();
47 | const onChangeMock = vi.fn();
48 |
49 | render();
50 | const textarea = screen.getByTestId('Textarea');
51 |
52 | await user.type(textarea, 'a');
53 | expect(onChangeMock).toHaveBeenCalledTimes(1);
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/packages/core/textarea/textarea.tsx:
--------------------------------------------------------------------------------
1 | import type { Ref, TextareaHTMLAttributes } from 'react';
2 |
3 | import { cn } from '../cn';
4 |
5 | type TextareaProps = {
6 | ref?: Ref;
7 | } & TextareaHTMLAttributes;
8 |
9 | export function Textarea({ className, ref, ...props }: TextareaProps) {
10 | return (
11 |
19 | );
20 | }
21 |
22 | Textarea.displayName = 'Textarea';
23 |
--------------------------------------------------------------------------------
/packages/core/tooltip/index.ts:
--------------------------------------------------------------------------------
1 | export { Tooltip } from './tooltip';
2 |
--------------------------------------------------------------------------------
/packages/core/tooltip/tooltip.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { TooltipProps } from '@radix-ui/react-tooltip';
2 | import type { Meta, StoryObj } from '@storybook/react';
3 |
4 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip';
5 |
6 | const meta: Meta = {
7 | component: Tooltip,
8 | title: 'core/Tooltip',
9 | };
10 |
11 | export default meta;
12 |
13 | type Story = StoryObj;
14 |
15 | export const Primary: Story = {
16 | args: {
17 | delayDuration: 0,
18 | },
19 | render: (props: TooltipProps) => (
20 |
21 |
22 | Hover me
23 |
24 | Tooltip content
25 |
26 |
27 |
28 | ),
29 | };
30 |
--------------------------------------------------------------------------------
/packages/core/tooltip/tooltip.test.tsx:
--------------------------------------------------------------------------------
1 | import { fireEvent, render, screen } from '@testing-library/react';
2 | import userEvent from '@testing-library/user-event';
3 | import { describe, expect, it } from 'vitest';
4 |
5 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip';
6 |
7 | describe(' ', () => {
8 | const setup = () => {
9 | render(
10 |
11 |
12 | Hover me
13 | Tooltip text
14 |
15 |
16 | );
17 | const trigger = screen.getByText('Hover me');
18 |
19 | return { trigger };
20 | };
21 |
22 | it('renders without crashing', () => {
23 | const { trigger } = setup();
24 |
25 | expect(trigger).toBeInTheDocument();
26 | });
27 |
28 | it('shows tooltip content on hover', async () => {
29 | const { trigger } = setup();
30 |
31 | await userEvent.hover(trigger);
32 | expect(await screen.findByRole('tooltip', { name: 'Tooltip text' })).toBeVisible();
33 | });
34 |
35 | it('shows tooltip content on focus', async () => {
36 | const { trigger } = setup();
37 |
38 | trigger.tabIndex = 0;
39 | fireEvent.focus(trigger);
40 | expect(await screen.findByRole('tooltip', { name: 'Tooltip text' })).toBeVisible();
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/packages/core/tooltip/tooltip.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { ComponentProps, Ref } from 'react';
4 |
5 | import * as TooltipPrimitive from '@radix-ui/react-tooltip';
6 |
7 | import { cn } from '../cn';
8 |
9 | export const TooltipProvider = TooltipPrimitive.Provider;
10 |
11 | export const Tooltip = TooltipPrimitive.Root;
12 |
13 | export const TooltipTrigger = TooltipPrimitive.Trigger;
14 |
15 | type TooltipContentProps = {
16 | ref?: Ref;
17 | } & ComponentProps;
18 |
19 | export function TooltipContent({ className, ref, sideOffset = 4, ...props }: TooltipContentProps) {
20 | return (
21 |
30 | );
31 | }
32 |
33 | TooltipContent.displayName = TooltipPrimitive.Content.displayName;
34 |
--------------------------------------------------------------------------------
/packages/core/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/ts-config/base.json",
3 | "compilerOptions": {
4 | "types": ["@vitest/browser/providers/playwright"]
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/packages/core/typography/index.ts:
--------------------------------------------------------------------------------
1 | export { Typography } from './typography';
2 |
--------------------------------------------------------------------------------
/packages/core/typography/typography.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import { Typography } from './typography';
4 |
5 | const meta: Meta = {
6 | component: Typography,
7 | title: 'core/Typography',
8 | };
9 |
10 | export default meta;
11 |
12 | type Story = StoryObj;
13 |
14 | export const Heading1: Story = {
15 | args: {
16 | children: 'Заголовок 1 (H1)',
17 | variant: 'h1',
18 | },
19 | };
20 |
21 | export const Heading2: Story = {
22 | args: {
23 | children: 'Заголовок 2 (H2)',
24 | variant: 'h2',
25 | },
26 | };
27 |
28 | export const Heading3: Story = {
29 | args: {
30 | children: 'Заголовок 3 (H3)',
31 | variant: 'h3',
32 | },
33 | };
34 |
35 | export const Heading4: Story = {
36 | args: {
37 | children: 'Заголовок 4 (H4)',
38 | variant: 'h4',
39 | },
40 | };
41 |
42 | export const Heading5: Story = {
43 | args: {
44 | children: 'Заголовок 5 (H5)',
45 | variant: 'h5',
46 | },
47 | };
48 |
49 | export const Heading6: Story = {
50 | args: {
51 | children: 'Заголовок 6 (H6)',
52 | variant: 'h6',
53 | },
54 | };
55 |
56 | export const PrimaryText = {
57 | args: {
58 | children:
59 | 'Повседневная практика показывает, что начало повседневной работы по формированию позиции обеспечивает широкому кругу (специалистов) участие в формировании существенных финансовых и административных условий. Значимость этих проблем настолько очевидна, что постоянное информационно-пропагандистское обеспечение нашей деятельности играет важную роль в формировании позиций, занимаемых участниками в отношении поставленных задач.',
60 | color: 'primary',
61 | variant: 'p',
62 | },
63 | };
64 |
65 | export const SecondaryText = {
66 | args: {
67 | children:
68 | 'Равным образом начало повседневной работы по формированию позиции влечет за собой процесс внедрения и модернизации направлений прогрессивного развития. Товарищи! сложившаяся структура организации способствует подготовки и реализации направлений прогрессивного развития.',
69 | color: 'secondary',
70 | variant: 'p',
71 | },
72 | };
73 |
--------------------------------------------------------------------------------
/packages/core/typography/typography.test.tsx:
--------------------------------------------------------------------------------
1 | import { render } from '@testing-library/react';
2 | import { describe, expect, it } from 'vitest';
3 |
4 | import { Typography, typographyVariants } from './typography';
5 |
6 | describe(' ', () => {
7 | it('renders correctly with variant and children', () => {
8 | const { getByText } = render(Heading 1 );
9 |
10 | expect(getByText('Heading 1').tagName).toBe('H1');
11 | expect(getByText('Heading 1')).toHaveClass(typographyVariants({ variant: 'h1' }));
12 | });
13 |
14 | it('applies color classes correctly', () => {
15 | const { getByText } = render(Primary Text );
16 |
17 | expect(getByText('Primary Text')).toHaveClass(
18 | typographyVariants({ color: 'primary', variant: 'p' })
19 | );
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/packages/core/typography/typography.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentProps, JSX, ReactNode, Ref } from 'react';
2 |
3 | import { cva } from 'class-variance-authority';
4 |
5 | export const typographyVariants = cva('text-balance', {
6 | defaultVariants: {
7 | variant: 'p',
8 | },
9 | variants: {
10 | center: {
11 | true: 'text-center',
12 | },
13 | color: {
14 | primary: 'text-black',
15 | secondary: 'text-slate-',
16 | },
17 | variant: {
18 | h1: 'text-4xl font-bold',
19 | h2: 'text-3xl font-bold',
20 | h3: 'text-2xl font-semibold',
21 | h4: 'text-xl font-medium',
22 | h5: 'text-lg font-medium',
23 | h6: 'text-base font-medium',
24 | p: 'text-base font-normal',
25 | span: '',
26 | },
27 | },
28 | });
29 |
30 | type TypographyProps = {
31 | center?: boolean;
32 | children: ReactNode;
33 | className?: string;
34 | color?: 'primary' | 'secondary';
35 | ref?: Ref;
36 | variant?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span';
37 | } & ComponentProps<'p'>;
38 |
39 | export function Typography({
40 | center,
41 | children,
42 | className = '',
43 | color,
44 | ref,
45 | variant = 'p',
46 | ...props
47 | }: TypographyProps) {
48 | const Component: keyof JSX.IntrinsicElements = variant;
49 |
50 | return (
51 |
56 | {children}
57 |
58 | );
59 | }
60 |
61 | Typography.displayName = 'Typography';
62 |
--------------------------------------------------------------------------------
/packages/design-tokens/json/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webpractik/nextjs-starter/5a88da1157a4e783656f18970d311d83556b906a/packages/design-tokens/json/.gitkeep
--------------------------------------------------------------------------------
/packages/design-tokens/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/design-tokens",
3 | "version": "1.0.0",
4 | "description": "",
5 | "files": [
6 | "variables.css"
7 | ],
8 | "scripts": {
9 | "build-tokens": "npx style-dictionary build --config ./tokens.config.json"
10 | },
11 | "keywords": [],
12 | "author": "",
13 | "license": "ISC"
14 | }
15 |
--------------------------------------------------------------------------------
/packages/design-tokens/tokens.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "source": ["./json/**/*.json"],
3 | "platforms": {
4 | "css": {
5 | "transformGroup": "css",
6 | "buildPath": "./",
7 | "files": [
8 | {
9 | "destination": "variables.css",
10 | "format": "css/variables"
11 | }
12 | ]
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/packages/logger/index.ts:
--------------------------------------------------------------------------------
1 | import adze, { setup } from 'adze';
2 |
3 | const appName = process.env.APP_NAME as string;
4 |
5 | const store = setup({
6 | activeLevel: 'info',
7 | format: 'pretty',
8 | });
9 |
10 | store.addListener('alert', (log: adze) => {
11 | console.info(log);
12 | });
13 |
14 | const logger = adze.withEmoji.timestamp.ns(appName).seal();
15 |
16 | export default logger;
17 |
--------------------------------------------------------------------------------
/packages/logger/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/logger",
3 | "version": "1.0.0",
4 | "main": "index.ts",
5 | "keywords": [],
6 | "author": "",
7 | "license": "ISC",
8 | "description": "",
9 | "type": "module",
10 | "exports": {
11 | ".": "./index.ts"
12 | },
13 | "dependencies": {
14 | "adze": "2.2.3"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/packages/metrics/index.ts:
--------------------------------------------------------------------------------
1 | import client from 'prom-client';
2 |
3 | const { collectDefaultMetrics } = client;
4 |
5 | const { Registry } = client;
6 |
7 | export const register = new Registry();
8 |
9 | collectDefaultMetrics({
10 | prefix: process.env.APP_NAME as string,
11 | register,
12 | });
13 |
--------------------------------------------------------------------------------
/packages/metrics/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/metrics",
3 | "version": "1.0.0",
4 | "main": "index.ts",
5 | "type": "module",
6 | "exports": {
7 | ".": "./index.ts"
8 | },
9 | "keywords": [],
10 | "author": "",
11 | "license": "ISC",
12 | "description": "",
13 | "dependencies": {
14 | "prom-client": "15.1.3"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/packages/routes/README.md:
--------------------------------------------------------------------------------
1 | This application supports typesafe routing for NextJS using the `declarative-routing` system.
2 |
3 | # What is `declarative-routing`?
4 |
5 | Declarative Routes is a system for typesafe routing in React. It uses a combination of TypeScript and a custom routing system to ensure that your routes are always in sync with your code. You'll never have to worry about broken links or missing routes again.
6 |
7 | In NextJS applications, Declarative Routes also handles API routes, so you'll have typesafe input and output from all of your APIs. In addition to `fetch` functions that are written for you automatically.
8 |
9 | # Route List
10 |
11 | Here are the routes of the application:
12 |
13 | | Route | Verb | Route Name | Using It |
14 | | ----------------- | ---- | ------------ | -------------------- |
15 | | `/(home)` | - | `Home` | `` |
16 | | `/(home)/ui-demo` | - | `HomeUiDemo` | `` |
17 | | `/api/health` | GET | `ApiHealth` | `getApiHealth(...)` |
18 | | `/api/metrics` | GET | `ApiMetrics` | `getApiMetrics(...)` |
19 | | `/api/ready` | GET | `ApiReady` | `getApiReady(...)` |
20 |
21 | To use the routes, you can import them from `@/routes` and use them in your code.
22 |
23 | # Using the routes in your application
24 |
25 | For pages, use the `Link` component (built on top of `next/link`) to link to other pages. For example:
26 |
27 | ```tsx
28 | import { ProductDetail } from '@repo/routes"
29 |
30 | return Product abc123 ;
31 | ```
32 |
33 | This is the equivalent of doing ` Product abc123` but with typesafety. And you never have to remember the URL. If the route moves, the typesafe route will be updated automatically.
34 |
35 | For APIs, use the exported `fetch` wrapping functions. For example:
36 |
37 | ```tsx
38 | import { useEffect } from 'react';
39 | import { getProductInfo } from '@repo/routes';
40 |
41 | useEffect(() => {
42 | // Parameters are typed to the input of the API
43 | getProductInfo({ productId: 'abc123' }).then(data => {
44 | // Data is typed to the result of the API
45 | console.log(data);
46 | });
47 | }, []);
48 | ```
49 |
50 | This is the equivalent of doing `fetch('/api/product/abc123')` but with typesafety, and you never have to remember the URL. If the API moves, the typesafe route will be updated automatically.
51 |
52 | ## Using typed hooks
53 |
54 | The system provides three typed hooks to use in your application `usePush`, `useParams`, and `useSearchParams`.
55 |
56 | - `usePush` wraps the NextJS `useRouter` hook and returns a typed version of the `push` function.
57 | - `useParams` wraps `useNextParams` and returns the typed parameters for the route.
58 | - `useSearchParams` wraps `useNextSearchParams` and returns the typed search parameters for the route.
59 |
60 | For each hook you give the route to get the appropriate data back.
61 |
62 | ```ts
63 | import { Search } from "@/routes";
64 | import { useSearchParams } from "@repo/routes/hooks";
65 |
66 | export default MyClientComponent() {
67 | const searchParams = useSearchParams(Search);
68 | return {searchParams.query}
;
69 | }
70 | ```
71 |
72 | We had to extract the hooks into a seperate module because NextJS would not allow the routes to include hooks directly if
73 | they were used by React Server Components (RSCs).
74 |
75 | # Configure declarative-routing
76 |
77 | After running `npx declarative-routing init`, you don't need to configure anything to use it.
78 | However, you may want to customize some options to change the behavior of route generation.
79 |
80 | You can edit `declarative-routing.config.json` in the root of your project. The following options are available:
81 |
82 | - `mode`: choose between `react-router`, `nextjs` or `qwikcity`. It is automatically picked on init based on the project type.
83 | - `routes`: the directory where the routes are defined. It is picked from the initial wizard (and defaults to `./src/components/declarativeRoutes`).
84 | - `importPathPrefix`: the path prefix to add to the import path of the self-generated route objects, in order to be able to resolve them. It defaults to `@/app`.
85 |
86 | # When your routes change
87 |
88 | You'll need to run `npm run dr:build` to update the generated files. This will update the types and the `@/routes` module to reflect the changes.
89 |
90 | The way the system works the `.info.ts` files are link to the `@/routes/index.ts` file. So changing the Zod schemas for the routes does **NOT** require a rebuild. You need to run the build command when:
91 |
92 | - You change the name of the route in the `.info.ts` file
93 | - You change the location of the route (e.g. `/product` to `/products`)
94 | - You change the parameters of the route (e.g. `/product/[id]` to `/product/[productId]`)
95 | - You add or remove routes
96 | - You add or remove verbs from API routes (e.g. adding `POST` to an existing route)
97 |
98 | You can also run the build command in watch mode using `npm run dr:build:watch` but we don't recommend using that unless you are changing routes a lot. It's a neat party trick to change a route directory name and to watch the links automagically change with hot module reloading, but routes really don't change that much.
99 |
100 | # Finishing your setup
101 |
102 | Post setup there are some additional tasks that you need to complete to completely typesafe your routes. We've compiled a handy check list so you can keep track of your progress.
103 |
104 | - [ ] `/(home)/page.info.ts`: Add search typing to if the page supports search paramaters
105 | - [ ] Convert `Link` components for `/(home)` to ``
106 | - [ ] `/(home)/ui-demo/page.info.ts`: Add search typing to if the page supports search paramaters
107 | - [ ] Convert `Link` components for `/(home)/ui-demo` to ``
108 | - [ ] `/api/health/route.info.ts`: Add typing for `GET`
109 | - [ ] Convert `GET` fetch calls to `/api/health` to `getApiHealth(...)` calls
110 | - [ ] `/api/metrics/route.info.ts`: Add typing for `GET`
111 | - [ ] Convert `GET` fetch calls to `/api/metrics` to `getApiMetrics(...)` calls
112 | - [ ] `/api/ready/route.info.ts`: Add typing for `GET`
113 | - [ ] Convert `GET` fetch calls to `/api/ready` to `getApiReady(...)` calls
114 | Once you've got that done you can remove this section.
115 |
116 | # Why is `makeRoute` copied into the `@repo/routes` module?
117 |
118 | You **own** this routing system once you install it. And we anticipate as part of that ownership you'll want to customize the routing system. That's why we create a `makeRoute.tsx` file in the `@repo/routes` module. This file is a copy of the `makeRoute.tsx` file from the `declarative-routing` package. You can modify this file to change the behavior of the routing system.
119 |
120 | For example, you might want to change the way `GET`, `POST`, `PUT`, and `DELETE` are handled. Or you might want to change the way the `Link` component works. You can do all of that by modifying the `makeRoute.tsx` file.
121 |
122 | We do **NOT** recommend changing the parameters of `makeRoute`, `makeGetRoute`, `makePostRoute`, `makePutRoute`, or `makeDeleteRoute` functions because that would cause incompatibility with the `build` command of `declarative-routing`.
123 |
--------------------------------------------------------------------------------
/packages/routes/declarative-routing.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "mode": "nextjs",
3 | "src": "../../app",
4 | "routes": "./src"
5 | }
6 |
--------------------------------------------------------------------------------
/packages/routes/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/routes",
3 | "version": "1.0.0",
4 | "description": "This application supports typesafe routing for NextJS using the `declarative-routing` system.",
5 | "main": "index.js",
6 | "scripts": {
7 | "build": "npx declarative-routing build",
8 | "watch": "npx declarative-routing build --watch"
9 | },
10 | "keywords": [],
11 | "author": "",
12 | "license": "ISC",
13 | "peerDependencies": {
14 | "next": "^15",
15 | "react": "^19",
16 | "react-dom": "^19",
17 | "zod": "^4"
18 | },
19 | "exports": {
20 | ".": "./src/index.ts"
21 | },
22 | "publishConfig": {
23 | "access": "public"
24 | },
25 | "dependencies": {
26 | "query-string": "9.2.0"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/packages/routes/src/hooks.ts:
--------------------------------------------------------------------------------
1 | import {
2 | useParams as useNextParams,
3 | useSearchParams as useNextSearchParams,
4 | useRouter,
5 | } from 'next/navigation';
6 | import { z } from 'zod';
7 |
8 | import type { RouteBuilder } from './makeRoute';
9 |
10 | const emptySchema = z.object({});
11 |
12 | type PushOptions = Parameters['push']>[1];
13 |
14 | export function usePush<
15 | Params extends z.ZodSchema,
16 | Search extends z.ZodSchema = typeof emptySchema,
17 | >(builder: RouteBuilder) {
18 | const { push } = useRouter();
19 |
20 | return (p: z.input, search?: z.input, options?: PushOptions) => {
21 | push(builder(p, search), options);
22 | };
23 | }
24 |
25 | export function useParams<
26 | Params extends z.ZodSchema,
27 | Search extends z.ZodSchema = typeof emptySchema,
28 | >(builder: RouteBuilder): z.output {
29 | const res = builder.paramsSchema.safeParse(useNextParams());
30 |
31 | if (!res.success) {
32 | throw new Error(
33 | `Invalid route params for route ${builder.routeName}: ${res.error.message}`
34 | );
35 | }
36 |
37 | return res.data;
38 | }
39 |
40 | export function useSearchParams<
41 | Params extends z.ZodSchema,
42 | Search extends z.ZodSchema = typeof emptySchema,
43 | >(builder: RouteBuilder): z.output {
44 | const res = builder.searchSchema.safeParse(
45 | convertURLSearchParamsToObject(useNextSearchParams())
46 | );
47 |
48 | if (!res.success) {
49 | throw new Error(
50 | `Invalid search params for route ${builder.routeName}: ${res.error.message}`
51 | );
52 | }
53 |
54 | return res.data;
55 | }
56 |
57 | function convertURLSearchParamsToObject(
58 | params: null | Readonly
59 | ): Record {
60 | if (!params) {
61 | return {};
62 | }
63 |
64 | const object: Record = {};
65 |
66 | // @ts-ignore
67 | for (const [key, value] of params.entries()) {
68 | object[key] = params.getAll(key).length > 1 ? params.getAll(key) : value;
69 | }
70 |
71 | return object;
72 | }
73 |
--------------------------------------------------------------------------------
/packages/routes/src/index.ts:
--------------------------------------------------------------------------------
1 | // Automatically generated by declarative-routing, do NOT edit
2 | import { z } from 'zod';
3 | import { makeGetRoute, makeRoute } from './makeRoute';
4 |
5 | const defaultInfo = {
6 | search: z.object({}),
7 | };
8 |
--------------------------------------------------------------------------------
/packages/routes/src/makeRoute.tsx:
--------------------------------------------------------------------------------
1 | /*
2 | Derived from: https://www.flightcontrol.dev/blog/fix-nextjs-routing-to-have-full-type-safety
3 | */
4 | import { z } from 'zod';
5 | import queryString from 'query-string';
6 | import Link from 'next/link';
7 |
8 | type LinkProps = Parameters[0];
9 |
10 | export type RouteInfo = {
11 | name: string;
12 | params: Params;
13 | search: Search;
14 | description?: string;
15 | };
16 |
17 | export type GetInfo = {
18 | result: Result;
19 | };
20 |
21 | export type PostInfo = {
22 | body: Body;
23 | result: Result;
24 | description?: string;
25 | };
26 |
27 | export type PutInfo = {
28 | body: Body;
29 | result: Result;
30 | description?: string;
31 | };
32 |
33 | type FetchOptions = Parameters[1];
34 |
35 | type CoreRouteElements<
36 | Params extends z.ZodSchema,
37 | Search extends z.ZodSchema = typeof emptySchema,
38 | > = {
39 | params: z.output;
40 | paramsSchema: Params;
41 | search: z.output;
42 | searchSchema: Search;
43 | };
44 |
45 | type PutRouteBuilder<
46 | Params extends z.ZodSchema,
47 | Search extends z.ZodSchema,
48 | Body extends z.ZodSchema,
49 | Result extends z.ZodSchema,
50 | > = CoreRouteElements & {
51 | (
52 | body: z.input,
53 | p?: z.input,
54 | search?: z.input,
55 | options?: FetchOptions
56 | ): Promise>;
57 |
58 | body: z.output;
59 | bodySchema: Body;
60 | result: z.output;
61 | resultSchema: Result;
62 | };
63 |
64 | type PostRouteBuilder<
65 | Params extends z.ZodSchema,
66 | Search extends z.ZodSchema,
67 | Body extends z.ZodSchema,
68 | Result extends z.ZodSchema,
69 | > = CoreRouteElements & {
70 | (
71 | body: z.input,
72 | p?: z.input,
73 | search?: z.input,
74 | options?: FetchOptions
75 | ): Promise>;
76 |
77 | body: z.output;
78 | bodySchema: Body;
79 | result: z.output;
80 | resultSchema: Result;
81 | };
82 |
83 | type GetRouteBuilder<
84 | Params extends z.ZodSchema,
85 | Search extends z.ZodSchema,
86 | Result extends z.ZodSchema,
87 | > = CoreRouteElements & {
88 | (
89 | p?: z.input,
90 | search?: z.input,
91 | options?: FetchOptions
92 | ): Promise>;
93 |
94 | result: z.output;
95 | resultSchema: Result;
96 | };
97 |
98 | type DeleteRouteBuilder = CoreRouteElements<
99 | Params,
100 | z.ZodSchema
101 | > & {
102 | (p?: z.input, search?: z.input, options?: FetchOptions): Promise;
103 | };
104 |
105 | export type RouteBuilder<
106 | Params extends z.ZodSchema,
107 | Search extends z.ZodSchema,
108 | > = CoreRouteElements & {
109 | (p?: z.input, search?: z.input): string;
110 |
111 | routeName: string;
112 |
113 | Link: React.FC<
114 | Omit &
115 | z.input & {
116 | search?: z.input;
117 | } & { children?: React.ReactNode }
118 | >;
119 | ParamsLink: React.FC<
120 | Omit & {
121 | params?: z.input;
122 | search?: z.input;
123 | } & { children?: React.ReactNode }
124 | >;
125 | };
126 |
127 | function createPathBuilder>(
128 | route: string
129 | ): (params: T) => string {
130 | const pathArr = route.split('/');
131 |
132 | let catchAllSegment: ((params: T) => string) | null = null;
133 | if (pathArr.at(-1)?.startsWith('[[...')) {
134 | const catchKey = pathArr.pop()!.replace('[[...', '').replace(']]', '');
135 | catchAllSegment = (params: T) => {
136 | const catchAll = params[catchKey] as unknown as string[];
137 | return catchAll ? `/${catchAll.join('/')}` : '';
138 | };
139 | }
140 |
141 | const elems: ((params: T) => string)[] = [];
142 | for (const elem of pathArr) {
143 | const catchAll = elem.match(/\[\.\.\.(.*)\]/);
144 | const param = elem.match(/\[(.*)\]/);
145 | if (catchAll?.[1]) {
146 | const key = catchAll[1];
147 | elems.push((params: T) => (params[key as unknown as string] as string[]).join('/'));
148 | } else if (param?.[1]) {
149 | const key = param[1];
150 | elems.push((params: T) => params[key as unknown as string] as string);
151 | } else if (!(elem.startsWith('(') && elem.endsWith(')'))) {
152 | elems.push(() => elem);
153 | }
154 | }
155 |
156 | return (params: T): string => {
157 | const p = elems
158 | .map(e => e(params))
159 | .filter(Boolean)
160 | .join('/');
161 | if (catchAllSegment) {
162 | return p + catchAllSegment(params);
163 | } else {
164 | return p.length ? p : '/';
165 | }
166 | };
167 | }
168 |
169 | function createRouteBuilder(
170 | route: string,
171 | info: RouteInfo
172 | ) {
173 | const fn = createPathBuilder>(route);
174 |
175 | return (params?: z.input, search?: z.input) => {
176 | let checkedParams = params || {};
177 | if (info.params) {
178 | const safeParams = info.params.safeParse(checkedParams);
179 | if (!safeParams?.success) {
180 | throw new Error(
181 | `Invalid params for route ${info.name}: ${safeParams.error.message}`
182 | );
183 | } else {
184 | checkedParams = safeParams.data;
185 | }
186 | }
187 | const safeSearch = info.search ? info.search?.safeParse(search || {}) : null;
188 | if (info.search && !safeSearch?.success) {
189 | throw new Error(
190 | `Invalid search params for route ${info.name}: ${safeSearch?.error.message}`
191 | );
192 | }
193 |
194 | const baseUrl = fn(checkedParams);
195 | const searchString = search && queryString.stringify(search);
196 | return [baseUrl, searchString ? `?${searchString}` : ''].join('');
197 | };
198 | }
199 |
200 | const emptySchema = z.object({});
201 |
202 | export function makePostRoute<
203 | Params extends z.ZodSchema,
204 | Search extends z.ZodSchema,
205 | Body extends z.ZodSchema,
206 | Result extends z.ZodSchema,
207 | >(
208 | route: string,
209 | info: RouteInfo,
210 | postInfo: PostInfo
211 | ): PostRouteBuilder {
212 | const urlBuilder = createRouteBuilder(route, info);
213 |
214 | const routeBuilder: PostRouteBuilder = (
215 | body: z.input,
216 | p?: z.input,
217 | search?: z.input,
218 | options?: FetchOptions
219 | ): Promise> => {
220 | const safeBody = postInfo.body.safeParse(body);
221 | if (!safeBody.success) {
222 | throw new Error(`Invalid body for route ${info.name}: ${safeBody.error.message}`);
223 | }
224 |
225 | return fetch(urlBuilder(p, search), {
226 | ...options,
227 | method: 'POST',
228 | body: JSON.stringify(safeBody.data),
229 | headers: {
230 | ...(options?.headers || {}),
231 | 'Content-Type': 'application/json',
232 | },
233 | })
234 | .then(res => {
235 | if (!res.ok) {
236 | throw new Error(`Failed to fetch ${info.name}: ${res.statusText}`);
237 | }
238 | return res.json() as Promise>;
239 | })
240 | .then(data => {
241 | const result = postInfo.result.safeParse(data);
242 | if (!result.success) {
243 | throw new Error(
244 | `Invalid response for route ${info.name}: ${result.error.message}`
245 | );
246 | }
247 | return result.data;
248 | });
249 | };
250 |
251 | routeBuilder.params = undefined as z.output;
252 | routeBuilder.paramsSchema = info.params;
253 | routeBuilder.search = undefined as z.output;
254 | routeBuilder.searchSchema = info.search;
255 | routeBuilder.body = undefined as z.output;
256 | routeBuilder.bodySchema = postInfo.body;
257 | routeBuilder.result = undefined as z.output;
258 | routeBuilder.resultSchema = postInfo.result;
259 |
260 | return routeBuilder;
261 | }
262 |
263 | export function makePutRoute<
264 | Params extends z.ZodSchema,
265 | Search extends z.ZodSchema,
266 | Body extends z.ZodSchema,
267 | Result extends z.ZodSchema,
268 | >(
269 | route: string,
270 | info: RouteInfo,
271 | putInfo: PutInfo
272 | ): PutRouteBuilder {
273 | const urlBuilder = createRouteBuilder(route, info);
274 |
275 | const routeBuilder: PutRouteBuilder = (
276 | body: z.input,
277 | p?: z.input,
278 | search?: z.input,
279 | options?: FetchOptions
280 | ): Promise> => {
281 | const safeBody = putInfo.body.safeParse(body);
282 | if (!safeBody.success) {
283 | throw new Error(`Invalid body for route ${info.name}: ${safeBody.error.message}`);
284 | }
285 |
286 | return fetch(urlBuilder(p, search), {
287 | ...options,
288 | method: 'PUT',
289 | body: JSON.stringify(safeBody.data),
290 | headers: {
291 | ...(options?.headers || {}),
292 | 'Content-Type': 'application/json',
293 | },
294 | })
295 | .then(res => {
296 | if (!res.ok) {
297 | throw new Error(`Failed to fetch ${info.name}: ${res.statusText}`);
298 | }
299 | return res.json() as Promise>;
300 | })
301 | .then(data => {
302 | const result = putInfo.result.safeParse(data);
303 | if (!result.success) {
304 | throw new Error(
305 | `Invalid response for route ${info.name}: ${result.error.message}`
306 | );
307 | }
308 | return result.data;
309 | });
310 | };
311 |
312 | routeBuilder.params = undefined as z.output;
313 | routeBuilder.paramsSchema = info.params;
314 | routeBuilder.search = undefined as z.output;
315 | routeBuilder.searchSchema = info.search;
316 | routeBuilder.body = undefined as z.output;
317 | routeBuilder.bodySchema = putInfo.body;
318 | routeBuilder.result = undefined as z.output;
319 | routeBuilder.resultSchema = putInfo.result;
320 |
321 | return routeBuilder;
322 | }
323 |
324 | export function makeGetRoute<
325 | Params extends z.ZodSchema,
326 | Search extends z.ZodSchema,
327 | Result extends z.ZodSchema,
328 | >(
329 | route: string,
330 | info: RouteInfo,
331 | getInfo: GetInfo
332 | ): GetRouteBuilder {
333 | const urlBuilder = createRouteBuilder(route, info);
334 |
335 | const routeBuilder: GetRouteBuilder = (
336 | p?: z.input,
337 | search?: z.input,
338 | options?: FetchOptions
339 | ): Promise> => {
340 | return fetch(urlBuilder(p, search), options)
341 | .then(res => {
342 | if (!res.ok) {
343 | throw new Error(`Failed to fetch ${info.name}: ${res.statusText}`);
344 | }
345 | return res.json() as Promise>;
346 | })
347 | .then(data => {
348 | const result = getInfo.result.safeParse(data);
349 | if (!result.success) {
350 | throw new Error(
351 | `Invalid response for route ${info.name}: ${result.error.message}`
352 | );
353 | }
354 | return result.data;
355 | });
356 | };
357 |
358 | routeBuilder.params = undefined as z.output;
359 | routeBuilder.paramsSchema = info.params;
360 | routeBuilder.search = undefined as z.output;
361 | routeBuilder.searchSchema = info.search;
362 | routeBuilder.result = undefined as z.output;
363 | routeBuilder.resultSchema = getInfo.result;
364 |
365 | return routeBuilder;
366 | }
367 |
368 | export function makeDeleteRoute(
369 | route: string,
370 | info: RouteInfo
371 | ): DeleteRouteBuilder {
372 | const urlBuilder = createRouteBuilder(route, info);
373 |
374 | const routeBuilder: DeleteRouteBuilder = (
375 | p?: z.input,
376 | search?: z.input,
377 | options?: FetchOptions
378 | ): Promise => {
379 | return fetch(urlBuilder(p, search), {
380 | ...options,
381 | method: 'DELETE',
382 | headers: {
383 | ...(options?.headers || {}),
384 | 'Content-Type': 'application/json',
385 | },
386 | }).then(res => {
387 | if (!res.ok) {
388 | throw new Error(`Failed to fetch ${info.name}: ${res.statusText}`);
389 | }
390 | });
391 | };
392 |
393 | routeBuilder.params = undefined as z.output;
394 | routeBuilder.paramsSchema = info.params;
395 | routeBuilder.search = undefined as z.output;
396 | routeBuilder.searchSchema = info.search;
397 |
398 | return routeBuilder;
399 | }
400 |
401 | export function makeRoute<
402 | Params extends z.ZodSchema,
403 | Search extends z.ZodSchema = typeof emptySchema,
404 | >(route: string, info: RouteInfo): RouteBuilder {
405 | const urlBuilder: RouteBuilder = createRouteBuilder(
406 | route,
407 | info
408 | ) as RouteBuilder;
409 |
410 | urlBuilder.routeName = info.name;
411 |
412 | urlBuilder.ParamsLink = function RouteLink({
413 | params: linkParams,
414 | search: linkSearch,
415 | children,
416 | ...props
417 | }: Omit & {
418 | params?: z.input;
419 | search?: z.input;
420 | } & { children?: React.ReactNode }) {
421 | return (
422 |
423 | {children}
424 |
425 | );
426 | };
427 |
428 | urlBuilder.Link = function RouteLink({
429 | search: linkSearch,
430 | children,
431 | ...props
432 | }: Omit &
433 | z.input & {
434 | search?: z.input;
435 | } & { children?: React.ReactNode }) {
436 | const params = info.params.parse(props);
437 | const extraProps = { ...props };
438 | for (const key of Object.keys(params)) {
439 | delete extraProps[key];
440 | }
441 | return (
442 |
443 | {children}
444 |
445 | );
446 | };
447 |
448 | urlBuilder.params = undefined as z.output;
449 | urlBuilder.paramsSchema = info.params;
450 | urlBuilder.search = undefined as z.output;
451 | urlBuilder.searchSchema = info.search;
452 |
453 | return urlBuilder;
454 | }
455 |
--------------------------------------------------------------------------------
/packages/routes/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 |
3 | type ParsedData = { data?: T; error?: string };
4 |
5 | function processSchema(
6 | schema: z.ZodTypeAny,
7 | paramsArray: Record
8 | ): Record {
9 | if (schema instanceof z.ZodOptional) {
10 | schema = schema._def.innerType;
11 | }
12 | switch (schema.constructor) {
13 | case z.ZodObject: {
14 | const { shape } = schema as z.ZodObject;
15 |
16 | return parseShape(shape, paramsArray);
17 | }
18 | case z.ZodUnion: {
19 | const { options } = (
20 | schema as z.ZodUnion<[z.ZodObject, ...z.ZodObject[]]>
21 | )._def;
22 |
23 | for (const option of options) {
24 | const { shape } = option;
25 | const requireds = getRequireds(shape);
26 |
27 | const result = parseShape(shape, paramsArray, true);
28 | const keys = Object.keys(result);
29 |
30 | if (requireds.every(key => keys.includes(key))) {
31 | return result;
32 | }
33 | }
34 |
35 | return {};
36 | }
37 | default: {
38 | throw new Error('Unsupported schema type');
39 | }
40 | }
41 | }
42 |
43 | function getRequireds(shape: z.ZodRawShape) {
44 | const keys: string[] = [];
45 |
46 | for (const key in shape) {
47 | const fieldShape = shape[key];
48 |
49 | if (!(fieldShape instanceof z.ZodDefault) && !(fieldShape instanceof z.ZodOptional)) {
50 | keys.push(key);
51 | }
52 | }
53 |
54 | return keys;
55 | }
56 |
57 | function parseShape(
58 | shape: z.ZodRawShape,
59 | paramsArray: Record,
60 | isPartOfUnion = false
61 | ): Record {
62 | const parsed: Record = {};
63 |
64 | for (const key in shape) {
65 | if (shape.hasOwnProperty(key)) {
66 | const fieldSchema: z.ZodTypeAny = shape[key]!;
67 |
68 | if (paramsArray[key]) {
69 | const fieldData = convertToRequiredType(paramsArray[key], fieldSchema);
70 |
71 | if (fieldData.error) {
72 | if (isPartOfUnion) {
73 | return {};
74 | }
75 | continue;
76 | }
77 | const result = fieldSchema.safeParse(fieldData.data);
78 |
79 | if (result.success) {
80 | parsed[key] = result.data;
81 | }
82 | } else if (fieldSchema instanceof z.ZodDefault) {
83 | const result = fieldSchema.safeParse();
84 |
85 | if (result.success) {
86 | parsed[key] = result.data;
87 | }
88 | }
89 | }
90 | }
91 |
92 | return parsed;
93 | }
94 |
95 | function getAllParamsAsArrays(searchParams: URLSearchParams): Record {
96 | const params: Record = {};
97 |
98 | for (const [key, value] of searchParams.entries()) {
99 | if (!params[key]) {
100 | params[key] = [];
101 | }
102 | params[key].push(value);
103 | }
104 |
105 | return params;
106 | }
107 |
108 | function convertToRequiredType(values: string[], schema: z.ZodTypeAny): ParsedData {
109 | const usedSchema = getInnerType(schema);
110 |
111 | if (values.length > 1 && !(usedSchema instanceof z.ZodArray)) {
112 | return { error: 'Multiple values for non-array field' };
113 | }
114 | const value = parseValues(usedSchema, values);
115 |
116 | if (value.error && schema.constructor === z.ZodDefault) {
117 | return { data: undefined };
118 | }
119 |
120 | return value;
121 | }
122 |
123 | function parseValues(schema: any, values: string[]): ParsedData {
124 | switch (schema.constructor) {
125 | case z.ZodBoolean: {
126 | return parseBoolean(values[0]!);
127 | }
128 | case z.ZodNumber: {
129 | return parseNumber(values[0]!);
130 | }
131 | case z.ZodString: {
132 | return { data: values[0] };
133 | }
134 | case z.ZodArray: {
135 | const elementSchema = schema._def.type;
136 |
137 | switch (elementSchema.constructor) {
138 | case z.ZodBoolean: {
139 | return parseArray(values, parseBoolean);
140 | }
141 | case z.ZodNumber: {
142 | return parseArray(values, parseNumber);
143 | }
144 | case z.ZodString: {
145 | return { data: values };
146 | }
147 | default: {
148 | return {
149 | error: `unsupported array element type ${String(elementSchema.constructor)}`,
150 | };
151 | }
152 | }
153 | }
154 | default: {
155 | return { error: `unsupported type ${String(schema.constructor)}` };
156 | }
157 | }
158 | }
159 |
160 | function getInnerType(schema: z.ZodTypeAny) {
161 | switch (schema.constructor) {
162 | case z.ZodDefault:
163 | case z.ZodOptional: {
164 | return schema._def.innerType;
165 | }
166 | default: {
167 | return schema;
168 | }
169 | }
170 | }
171 |
172 | function parseNumber(string_: string): ParsedData {
173 | const number_ = +string_;
174 |
175 | return isNaN(number_) ? { error: `${string_} is NaN` } : { data: number_ };
176 | }
177 |
178 | function parseBoolean(string_: string): ParsedData {
179 | switch (string_) {
180 | case 'false': {
181 | return { data: false };
182 | }
183 | case 'true': {
184 | return { data: true };
185 | }
186 | default: {
187 | return { error: `${string_} is not a boolean` };
188 | }
189 | }
190 | }
191 |
192 | function parseArray(
193 | values: string[],
194 | parseFunction: (string_: string) => ParsedData
195 | ): ParsedData {
196 | const numbers = values.map(parseFunction);
197 | const error = numbers.find(n => n.error)?.error;
198 |
199 | if (error) {
200 | return { error };
201 | }
202 |
203 | return { data: numbers.map(n => n.data!) };
204 | }
205 |
206 | export function safeParseSearchParams(
207 | schema: T,
208 | searchParams: URLSearchParams
209 | ): z.infer {
210 | const paramsArray = getAllParamsAsArrays(searchParams);
211 |
212 | return processSchema(schema, paramsArray);
213 | }
214 |
--------------------------------------------------------------------------------
/packages/ts-config/base.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "Default",
4 | "compilerOptions": {
5 | "strict": true,
6 | "module": "ESNext",
7 | "moduleResolution": "bundler",
8 | "target": "ESNext",
9 | "lib": ["dom", "dom.iterable", "esnext"],
10 | "jsx": "preserve",
11 | "skipLibCheck": true,
12 | "esModuleInterop": true,
13 | "forceConsistentCasingInFileNames": true,
14 | "noEmit": true,
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "declaration": true,
18 | "declarationMap": true,
19 | "experimentalDecorators": true,
20 | "useDefineForClassFields": true,
21 | "noUncheckedIndexedAccess": true,
22 | "noImplicitReturns": true,
23 | "allowJs": false,
24 | "checkJs": false,
25 | "removeComments": true,
26 | "incremental": true,
27 | "allowImportingTsExtensions": false,
28 | "allowSyntheticDefaultImports": true,
29 | "verbatimModuleSyntax": true,
30 | "noFallthroughCasesInSwitch": true,
31 | "exactOptionalPropertyTypes": false,
32 | "tsBuildInfoFile": "${configDir}/node_modules/.cache/tsbuildinfo.json",
33 | "erasableSyntaxOnly": true
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/packages/ts-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/ts-config",
3 | "version": "1.0.0",
4 | "private": true,
5 | "license": "MIT",
6 | "publishConfig": {
7 | "access": "public"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, devices } from '@playwright/test';
2 |
3 | const port = process.env.FRONT_PORT ?? '3000';
4 |
5 | const baseURL = `http://localhost:${port}`;
6 |
7 | process.env.ENVIRONMENT_URL = baseURL;
8 |
9 | const CI = process.env.CI === 'true';
10 |
11 | /**
12 | * See https://playwright.dev/docs/test-configuration.
13 | */
14 | export default defineConfig({
15 | forbidOnly: CI,
16 | fullyParallel: true,
17 | projects: [
18 | {
19 | name: 'chromium',
20 | use: { ...devices['Desktop Chrome'] },
21 | },
22 |
23 | {
24 | name: 'firefox',
25 | use: { ...devices['Desktop Firefox'] },
26 | },
27 |
28 | {
29 | name: 'webkit',
30 | use: { ...devices['Desktop Safari'] },
31 | },
32 |
33 | /* Test against mobile viewports. */
34 | // {
35 | // name: 'Mobile Chrome',
36 | // use: { ...devices['Pixel 5'] },
37 | // },
38 | // {
39 | // name: 'Mobile Safari',
40 | // use: { ...devices['iPhone 12'] },
41 | // },
42 |
43 | /* Test against branded browsers. */
44 | // {
45 | // name: 'Microsoft Edge',
46 | // use: { ...devices['Desktop Edge'], channel: 'msedge' },
47 | // },
48 | // {
49 | // name: 'Google Chrome',
50 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' },
51 | // },
52 | ],
53 | reporter: 'html',
54 | retries: CI ? 2 : 0,
55 | testDir: './src/tests/e2e',
56 | timeout: 30 * 1000,
57 | use: {
58 | baseURL,
59 | trace: 'on-first-retry',
60 | },
61 | webServer: {
62 | command: CI ? 'npm run prod' : 'npm run dev',
63 | reuseExistingServer: !CI,
64 | timeout: 2 * 60 * 1000,
65 | url: baseURL,
66 | },
67 | });
68 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | '@tailwindcss/postcss': {},
4 | ...(process.env.NODE_ENV === 'production' ? { cssnano: {} } : {}),
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/images/svg/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/sentry.client.config.js:
--------------------------------------------------------------------------------
1 | import * as Sentry from '@sentry/nextjs';
2 | import { env } from './src/env/client';
3 |
4 | Sentry.init({
5 | dsn: env.NEXT_PUBLIC_SENTRY_DSN,
6 | tracesSampleRate: 1,
7 | environment: `${env.NEXT_PUBLIC_APP_ENV}-client`,
8 | debug: false,
9 | integrations: [
10 | Sentry.replayIntegration({
11 | maskAllText: true,
12 | blockAllMedia: true,
13 | }),
14 | Sentry.reportingObserverIntegration(),
15 | Sentry.browserTracingIntegration(),
16 | ],
17 | replaysSessionSampleRate: 0.1,
18 | replaysOnErrorSampleRate: 1.0,
19 | });
20 |
--------------------------------------------------------------------------------
/src/env/client.ts:
--------------------------------------------------------------------------------
1 | import { createEnv } from '@t3-oss/env-nextjs';
2 | import { z } from 'zod';
3 |
4 | export const environment = createEnv({
5 | client: {
6 | NEXT_PUBLIC_APP_ENV: z.enum(['LOCAL', 'WORK', 'RC', 'PROD']),
7 | NEXT_PUBLIC_BFF_PATH: z.string(),
8 | NEXT_PUBLIC_FRONT_URL: z.string().url(),
9 | NEXT_PUBLIC_SENTRY_DSN: z.string().url(),
10 | },
11 | emptyStringAsUndefined: true,
12 | runtimeEnv: {
13 | NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV,
14 | NEXT_PUBLIC_BFF_PATH: process.env.NEXT_PUBLIC_BFF_PATH,
15 | NEXT_PUBLIC_FRONT_URL: process.env.NEXT_PUBLIC_FRONT_URL,
16 | NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
17 | },
18 | });
19 |
--------------------------------------------------------------------------------
/src/env/server.ts:
--------------------------------------------------------------------------------
1 | import { createEnv } from '@t3-oss/env-nextjs';
2 | import { z } from 'zod';
3 |
4 | export const environment = createEnv({
5 | emptyStringAsUndefined: true,
6 | experimental__runtimeEnv: process.env,
7 | server: {
8 | APP_ENV: z.enum(['LOCAL', 'WORK', 'RC', 'PROD']),
9 | APP_NAME: z.string(),
10 | BACK_INTERNAL_URL: z.string().url(),
11 | CACHE_PUBLIC_MAX_AGE: z.string().transform(Number).pipe(z.number()).optional(),
12 | CI: z.enum(['true', 'false']).transform(value => value === 'true'),
13 | FRONT_HOST: z.string(),
14 | FRONT_PORT: z.string().transform(Number).pipe(z.number()),
15 | HTTP_AUTH_LOGIN: z.string().optional(),
16 | HTTP_AUTH_PASS: z.string().optional(),
17 | SENTRY_AUTH_TOKEN: z.string(),
18 | SENTRY_DSN: z.string().url(),
19 | SENTRY_ORG: z.string(),
20 | SENTRY_URL: z.string().url(),
21 | },
22 | });
23 |
--------------------------------------------------------------------------------
/src/fonts/geist.ts:
--------------------------------------------------------------------------------
1 | import localFont from 'next/font/local';
2 |
3 | export const geistSans = localFont({
4 | src: './source/geist.woff2',
5 | variable: '--font-geist-sans',
6 | weight: '100 900',
7 | });
8 |
--------------------------------------------------------------------------------
/src/fonts/source/geist.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webpractik/nextjs-starter/5a88da1157a4e783656f18970d311d83556b906a/src/fonts/source/geist.woff2
--------------------------------------------------------------------------------
/src/hooks/use-responsive.ts:
--------------------------------------------------------------------------------
1 | import { useMedia } from 'react-use';
2 |
3 | export function useDesktopMediaQuery() {
4 | return useMedia('(min-width: 1280px)', false);
5 | }
6 |
7 | export function useLaptopMediaQuery() {
8 | return useMedia('(min-width: 1024px)', false);
9 | }
10 |
11 | export function useMobileMediaQuery() {
12 | return useMedia('(min-width: 320px)', true);
13 | }
14 |
15 | export function useTabletMediaQuery() {
16 | return useMedia('(min-width: 768px)', false);
17 | }
18 |
19 | export function useBreakpoints() {
20 | const gtMobile = useMobileMediaQuery();
21 | const gtTablet = useTabletMediaQuery();
22 | const gtLaptop = useLaptopMediaQuery();
23 | const gtDesktop = useDesktopMediaQuery();
24 |
25 | return {
26 | gtDesktop,
27 | gtLaptop,
28 | gtMobile,
29 | gtTablet,
30 | };
31 | }
32 |
--------------------------------------------------------------------------------
/src/tests/e2e/example.spec.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from '@playwright/test';
2 |
3 | test('example', async ({ page }) => {
4 | await page.goto('http://localhost:3000/');
5 |
6 | await expect(page.getByRole('heading')).toContainText('Next Starter');
7 | });
8 |
--------------------------------------------------------------------------------
/src/types/global.d.ts:
--------------------------------------------------------------------------------
1 | import '@total-typescript/ts-reset';
2 | import 'typed-query-selector/strict';
3 |
--------------------------------------------------------------------------------
/src/utils/cn.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/src/utils/get-url.ts:
--------------------------------------------------------------------------------
1 | import { headers } from 'next/headers';
2 |
3 | export async function getURL() {
4 | const headersList = await headers();
5 |
6 | const url = headersList.get('x-url') ?? '';
7 |
8 | return new URL(url);
9 | }
10 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/ts-config/base.json",
3 | "compilerOptions": {
4 | "paths": {
5 | "~/*": ["./*"],
6 | "@/*": ["./app/*"],
7 | "#/*": ["./src/*"]
8 | },
9 | "plugins": [{ "name": "next" }],
10 | "types": ["@vitest/browser/providers/playwright", "./src/types/global.d.ts"]
11 | },
12 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".storybook"],
13 | "exclude": ["node_modules", ".next", "build", "dist", "packages/routes"]
14 | }
15 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react';
2 | import tsconfigPaths from 'vite-tsconfig-paths';
3 | import { defineConfig } from 'vitest/config';
4 |
5 | // https://vitest.dev/config/
6 | export default defineConfig({
7 | define: {
8 | 'process.env': JSON.stringify({}),
9 | },
10 | plugins: [react(), tsconfigPaths()],
11 | test: {
12 | browser: {
13 | enabled: true,
14 | headless: true,
15 | instances: [{ browser: 'chromium' }],
16 | provider: 'playwright',
17 | },
18 | css: false,
19 | globals: true,
20 | include: ['**/?(*.)test.ts?(x)'],
21 | },
22 | });
23 |
--------------------------------------------------------------------------------