develop #3

Merged
behnam merged 39 commits from develop into main 2024-11-26 15:47:00 +00:00
22 changed files with 4015 additions and 155 deletions
Showing only changes of commit 1675d84cae - Show all commits

1
.eslintignore Normal file
View File

@ -0,0 +1 @@
tailwind.config.ts

View File

@ -5,14 +5,19 @@
"src/**/*-vm.ts" "src/**/*-vm.ts"
], ],
"rules": { "rules": {
"react-hooks/rules-of-hooks": "off" "react-hooks/rules-of-hooks": "off"
} }
},
{
"files": [
"src/app/**/*.stories.tsx"
],
"rules": {
"react/jsx-props-no-spreading": "off"
}
} }
], ],
"plugins": [ "settings": {
"prettier"
],
"settings": {
"react": { "react": {
"version": "detect" "version": "detect"
}, },
@ -35,6 +40,16 @@
} }
} }
}, },
"plugins": [
"prettier"
],
"extends": [
"airbnb",
"next/core-web-vitals",
"next/typescript",
"prettier",
"plugin:storybook/recommended"
],
"rules": { "rules": {
"no-use-before-define": "off", "no-use-before-define": "off",
"class-methods-use-this": "off", "class-methods-use-this": "off",
@ -43,13 +58,24 @@
"no-promise-executor-return": "off", "no-promise-executor-return": "off",
"@typescript-eslint/no-shadow": "off", "@typescript-eslint/no-shadow": "off",
"react/require-default-props": "off", "react/require-default-props": "off",
"import/order": [
"error",
{
"pathGroups": [
{
"pattern": "@/**",
"group": "external"
}
]
}
],
"no-shadow": "off", "no-shadow": "off",
"prettier/prettier": [ "prettier/prettier": [
"warn", "warn",
{ {
"printWidth": 80, "printWidth": 80,
"tabWidth": 2, "tabWidth": 2,
"endOfLine":"auto", "endOfLine": "auto",
"useTabs": false, "useTabs": false,
"semi": true, "semi": true,
"singleQuote": false, "singleQuote": false,
@ -85,11 +111,5 @@
"devDependencies": true "devDependencies": true
} }
] ]
}, }
"extends": [ }
"airbnb",
"next/core-web-vitals",
"next/typescript",
"prettier"
]
}

2
.gitignore vendored
View File

@ -38,3 +38,5 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
*storybook.log

18
.storybook/main.ts Normal file
View File

@ -0,0 +1,18 @@
import type { StorybookConfig } from "@storybook/nextjs";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@storybook/addon-onboarding",
"@storybook/addon-essentials",
"@chromatic-com/storybook",
"@storybook/addon-interactions",
"storybook-dark-mode"
],
framework: {
name: "@storybook/nextjs",
options: {},
},
staticDirs: ["../public"],
};
export default config;

134
.storybook/preview.tsx Normal file
View File

@ -0,0 +1,134 @@
import React, { useRef } from "react";
import { themes } from '@storybook/theming';
import { ThemeProvider } from "../src/app/[lang]/dashboard/components/client/theme-provider/theme-provider";
import { DARK_MODE_EVENT_NAME, UPDATE_DARK_MODE_EVENT_NAME } from 'storybook-dark-mode';
import { initI18next, LANGS } from "../src/bootstrap/i18n/i18n"
import { addons } from '@storybook/preview-api';
import { i18n } from "i18next";
import { I18nextProvider } from "react-i18next";
const channel = addons.getChannel();
import "../src/app/globals.css"
/**
*
* This function will expand the object with nested properties
* @param obj refrence Object to that
* @param leftKeys keys to be nested object keys
* @param value value to be nested
*
*/
export const recursiveNestedProps = (
obj: Record<string, unknown>,
leftKeys: string[],
value: unknown,
): Record<string, unknown> => {
if (leftKeys.length <= 0) return obj;
if (leftKeys.length === 1) {
// eslint-disable-next-line no-param-reassign
obj[leftKeys[0]] = value;
return obj;
}
const key = leftKeys.shift();
if (!key) return obj;
if (!obj[key]) {
// eslint-disable-next-line no-param-reassign
obj[key] = {};
}
return recursiveNestedProps(
obj[key] as Record<string, unknown>,
leftKeys,
value,
);
};
const preview = {
decorators: [
(Story, data) => {
const [isDark, setDark] = React.useState(true);
const [i18n, setI18n] = React.useState<i18n>()
const parsedProps = {} as Record<string, unknown>;
const { locale } = data.globals
const props = data.allArgs;
Object.entries(props).forEach((prop) => {
const [key, value] = prop;
if (!key.includes("vm")) {
parsedProps[key] = value;
return;
}
const splitedKey = key.split(".");
recursiveNestedProps(parsedProps, splitedKey, value);
});
React.useEffect(() => {
channel.on(DARK_MODE_EVENT_NAME, setDark);
return () => channel.removeListener(DARK_MODE_EVENT_NAME, setDark);
}, [channel, setDark]);
React.useEffect(() => {
(async () => {
setI18n((await initI18next({ lng: locale })).i18n);
})()
}, [])
React.useEffect(() => {
i18n?.changeLanguage(locale);
}, [locale]);
return (
<ThemeProvider
attribute="class"
forcedTheme={isDark ? "dark" : "light"}
enableSystem
disableTransitionOnChange
>
{
i18n && (
<I18nextProvider
i18n={i18n}
>
<Story parsedProps={parsedProps} />
</I18nextProvider>
)
}
</ThemeProvider>
);
},
],
darkMode: {
// Override the default dark theme
dark: { ...themes.dark, appBg: 'black' },
// Override the default light theme
classTarget: 'html',
light: { ...themes.normal, appBg: 'red' },
},
parameters: {
nextjs: {
appDirectory: true,
},
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
globalTypes: {
locale: {
name: 'Locale',
description: 'Internationalization locale',
toolbar: {
icon: 'globe',
items: [
{ value: LANGS.EN, title: 'English' },
{ value: LANGS.RU, title: 'Russian' },
],
showName: true,
},
},
}
};
export default preview;

View File

@ -11,7 +11,6 @@ services:
POSTGRES_USER: admin POSTGRES_USER: admin
POSTGRES_DB: nextbp POSTGRES_DB: nextbp
app: app:
build: . build: .
ports: ports:

View File

@ -8,7 +8,9 @@
"start": "next start --port 4000", "start": "next start --port 4000",
"lint": "next lint --fix", "lint": "next lint --fix",
"test": "vitest", "test": "vitest",
"seed": "node -r dotenv/config ./src/bootstrap/boundaries/db/seed.js" "seed": "node -r dotenv/config ./src/bootstrap/boundaries/db/seed.js",
"storybook": "storybook dev -p 6006 --no-open",
"build-storybook": "storybook build"
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.1.5", "@heroicons/react": "^2.1.5",
@ -24,6 +26,7 @@
"lucide-react": "^0.454.0", "lucide-react": "^0.454.0",
"next": "15.0.2", "next": "15.0.2",
"next-i18n-router": "^5.5.1", "next-i18n-router": "^5.5.1",
"next-themes": "^0.4.3",
"postgres": "^3.4.5", "postgres": "^3.4.5",
"react": "19.0.0-rc-69d4b800-20241021", "react": "19.0.0-rc-69d4b800-20241021",
"react-cookie": "^7.2.2", "react-cookie": "^7.2.2",
@ -37,7 +40,15 @@
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "^3.2.2",
"@faker-js/faker": "^9.1.0", "@faker-js/faker": "^9.1.0",
"@storybook/addon-essentials": "^8.4.5",
"@storybook/addon-interactions": "^8.4.5",
"@storybook/addon-onboarding": "^8.4.5",
"@storybook/blocks": "^8.4.5",
"@storybook/nextjs": "^8.4.5",
"@storybook/react": "^8.4.5",
"@storybook/test": "^8.4.5",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.0.1", "@testing-library/react": "^16.0.1",
"@types/node": "^20", "@types/node": "^20",
@ -53,10 +64,13 @@
"eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-alias": "^1.1.2",
"eslint-import-resolver-typescript": "^3.6.3", "eslint-import-resolver-typescript": "^3.6.3",
"eslint-plugin-prettier": "^5.2.1", "eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-storybook": "^0.11.1",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"moq.ts": "^10.0.8", "moq.ts": "^10.0.8",
"postcss": "^8", "postcss": "^8",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"storybook": "^8.4.5",
"storybook-dark-mode": "^4.0.2",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5", "typescript": "^5",
"vitest": "^2.1.4" "vitest": "^2.1.4"

View File

@ -0,0 +1,11 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@ -6,11 +6,11 @@ import CardWrapper from "@/app/[lang]/dashboard/components/server/cards/cards";
import LatestInvoices from "@/app/[lang]/dashboard/components/server/latest-invoices/latest-invoices"; import LatestInvoices from "@/app/[lang]/dashboard/components/server/latest-invoices/latest-invoices";
import RevenueChart from "@/app/[lang]/dashboard/components/server/revenue-chart/revenue-chart"; import RevenueChart from "@/app/[lang]/dashboard/components/server/revenue-chart/revenue-chart";
import { Suspense } from "react"; import { Suspense } from "react";
import { getServerTranslation } from "@/bootstrap/i18n/i18n"; import { getServerTranslation, LANGS } from "@/bootstrap/i18n/i18n";
import langKey from "@/bootstrap/i18n/dictionaries/lang-key"; import langKey from "@/bootstrap/i18n/dictionaries/lang-key";
export default async function Dashboard(props: { export default async function Dashboard(props: {
params: Promise<{ lang: string }>; params: Promise<{ lang: LANGS }>;
}) { }) {
const { params } = props; const { params } = props;
const { lang } = await params; const { lang } = await params;

View File

@ -1,4 +1,5 @@
import { initI18next } from "@/bootstrap/i18n/i18n"; import { ThemeProvider } from "@/app/[lang]/dashboard/components/client/theme-provider/theme-provider";
import { initI18next, LANGS } from "@/bootstrap/i18n/i18n";
import TranslationsProvider from "@/bootstrap/i18n/i18n-provider"; import TranslationsProvider from "@/bootstrap/i18n/i18n-provider";
import localFont from "next/font/local"; import localFont from "next/font/local";
import { PropsWithChildren } from "react"; import { PropsWithChildren } from "react";
@ -15,19 +16,26 @@ const geistMono = localFont({
}); });
export default async function layout( export default async function layout(
props: PropsWithChildren & { params: Promise<{ lang: string }> }, props: PropsWithChildren & { params: Promise<{ lang: LANGS }> },
) { ) {
const { params, children } = props; const { params, children } = props;
const { lang } = await params; const { lang } = await params;
const { resources } = await initI18next({ lng: lang }); const { resources } = await initI18next({ lng: lang });
return ( return (
<html lang={lang}> <html lang={lang} suppressHydrationWarning>
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
<TranslationsProvider lng={lang} resources={resources}> <ThemeProvider
{children} attribute="class"
</TranslationsProvider> defaultTheme="light"
enableSystem
disableTransitionOnChange
>
<TranslationsProvider lng={lang} resources={resources}>
{children}
</TranslationsProvider>
</ThemeProvider>
</body> </body>
</html> </html>
); );

View File

@ -11,7 +11,6 @@ import { cn } from "@/bootstrap/helpers/lib/ui-utils";
export default class Button extends BaseView<ButtonVm> { export default class Button extends BaseView<ButtonVm> {
protected Build(props: BuildProps<ButtonVm>): ReactNode { protected Build(props: BuildProps<ButtonVm>): ReactNode {
const { vm } = props; const { vm } = props;
return ( return (
<ButtonUi disabled={vm.props.isDisable} onClick={vm.onClick}> <ButtonUi disabled={vm.props.isDisable} onClick={vm.onClick}>
{vm.props.title} {vm.props.title}

View File

@ -0,0 +1,65 @@
import CreateRandomInvoiceButtonVM from "@/app/[lang]/dashboard/vm/create-random-invoice-button-vm";
import Button from "@/app/components/button/button";
import { DiContext, useDI } from "@/bootstrap/di/di-context";
import mockedModuleDi from "@/bootstrap/di/mocked-module-di";
import Story from "@/bootstrap/helpers/view/storybook-base-template-type";
import getArgVM from "@/bootstrap/helpers/view/storybook-with-arg-vm";
import createInvoiceUsecase from "@/feature/core/invoice/domain/usecase/create-invoice-usecase";
import type { Meta } from "@storybook/react";
import { useRef } from "react";
const meta: Meta = {
title: "general/Button",
};
export default meta;
export const Primary: Story = {
argTypes: {
"vm.props.isDisable": {
control: "boolean",
},
"vm.props.title": {
control: "text",
},
},
args: {
"vm.props.title": "Button",
"vm.props.isDisable": false,
},
render: (_props, globalData) => {
const vm = getArgVM(globalData.parsedProps.vm); // You can use parsed props to access your vm properties.
return <Button vm={vm} memoizedByVM={false} />;
},
};
export const WithVM: Story = {
decorators: [
(Story) => {
const di = mockedModuleDi([
{
token: CreateRandomInvoiceButtonVM,
provider: CreateRandomInvoiceButtonVM,
},
{
token: createInvoiceUsecase.name,
// eslint-disable-next-line @typescript-eslint/no-explicit-any, no-console
provider: (args: any) => console.log("clicked", args),
},
]);
return <Story di={di} />;
},
],
render: (_, globalProps) => {
function Child() {
const di = useDI();
const vm = useRef(di.resolve(CreateRandomInvoiceButtonVM));
return <Button vm={vm.current} memoizedByVM={false} />;
}
return (
<DiContext.Provider value={globalProps.di}>
<Child />
</DiContext.Provider>
);
},
};

View File

@ -34,7 +34,7 @@ body {
--chart-5: 27 87% 67%; --chart-5: 27 87% 67%;
--radius: 0.5rem; --radius: 0.5rem;
} }
.dark { :root[class~="dark"] {
--background: 240 10% 3.9%; --background: 240 10% 3.9%;
--foreground: 0 0% 98%; --foreground: 0 0% 98%;
--card: 240 10% 3.9%; --card: 240 10% 3.9%;

View File

@ -0,0 +1,33 @@
import { container, DependencyContainer } from "tsyringe";
import { InjectionToken } from "tsyringe/dist/typings/providers";
import constructor from "tsyringe/dist/typings/types/constructor";
import { isClass } from "../helpers/global-helpers";
/**
* Provides mocked di for test cases and using instead of real di
* @param providers List of providers
* @returns Mocked di with registered providers
*/
const mockedModuleDi = (
providers: {
token: InjectionToken<unknown>;
provider: unknown | constructor<unknown>;
}[],
): DependencyContainer => {
const di = container.createChildContainer();
providers.forEach((provider) => {
if (isClass(provider.provider)) {
di.register(provider.token, provider.provider);
return;
}
di.register(provider.token, {
useValue: provider.provider,
});
});
return di;
};
export default mockedModuleDi;

View File

@ -1 +1,8 @@
import { constructor } from "tsyringe/dist/typings/types";
export const isServer = typeof window === "undefined"; export const isServer = typeof window === "undefined";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isClass(fn: any): fn is constructor<unknown> {
return typeof fn === "function" && /^(class|function [A-Z])/.test(fn);
}

View File

@ -11,11 +11,11 @@ export default function useThrottle<T extends () => unknown>(
callback: T, callback: T,
time: number = 2000, time: number = 2000,
) { ) {
const lastRun = useRef(Date.now()); const lastRun = useRef<number>();
// eslint-disable-next-line func-names // eslint-disable-next-line func-names
return function () { return function () {
if (Date.now() - lastRun.current <= time) return; if (lastRun.current && Date.now() - lastRun.current <= time) return;
lastRun.current = Date.now(); lastRun.current = Date.now();
callback(); callback();
}; };

View File

@ -0,0 +1,5 @@
import { Meta, StoryObj } from "@storybook/react";
type Story = StoryObj<Meta>;
export default Story;

View File

@ -0,0 +1,14 @@
import IBaseVM from "@/bootstrap/helpers/vm/i-base-vm";
const getArgVM = <IVM>(vmObj: IVM) => {
class VM implements IBaseVM<IVM> {
useVM(): IVM {
return {
...vmObj,
};
}
}
return new VM();
};
export default getArgVM;

View File

@ -1,18 +1,17 @@
"use client"; "use client";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider } from "react-i18next";
import { initI18next } from "@/bootstrap/i18n/i18n"; import { i18nInstance, initI18next, LANGS } from "@/bootstrap/i18n/i18n";
import { createInstance, Resource } from "i18next"; import { Resource } from "i18next";
import { PropsWithChildren } from "react"; import { PropsWithChildren } from "react";
export default function TranslationsProvider({ export default function TranslationsProvider({
children, children,
lng, lng,
resources, resources,
}: PropsWithChildren & { lng: string; resources: Resource }) { }: PropsWithChildren & { lng: LANGS; resources: Resource }) {
const i18n = createInstance(); if (!resources) return children;
initI18next({ lng, resources });
initI18next({ lng, i18n, resources }); return <I18nextProvider i18n={i18nInstance}>{children}</I18nextProvider>;
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
} }

View File

@ -1,23 +1,26 @@
import { getOptions, languages } from "@/bootstrap/i18n/settings"; import { getOptions, languages } from "@/bootstrap/i18n/settings";
import { createInstance, i18n, Resource } from "i18next"; import { createInstance, Resource } from "i18next";
import resourcesToBackend from "i18next-resources-to-backend"; import resourcesToBackend from "i18next-resources-to-backend";
import { initReactI18next } from "react-i18next/initReactI18next"; import { initReactI18next } from "react-i18next/initReactI18next";
const initI18nextInstance = createInstance(); export const i18nInstance = createInstance();
export enum LANGS {
EN = "en",
RU = "ru",
}
export const initI18next = async (params: { export const initI18next = async (params: {
lng: string; lng: LANGS;
i18n?: i18n;
resources?: Resource; resources?: Resource;
ns?: string; ns?: string;
}) => { }) => {
const { lng, i18n, ns, resources } = params; const { lng, ns, resources } = params;
const i18nInstance = i18n || initI18nextInstance;
await i18nInstance await i18nInstance
.use(initReactI18next) .use(initReactI18next)
.use( .use(
resourcesToBackend( resourcesToBackend(
(language: string) => import(`./dictionaries/${language}.ts`), (language: LANGS) => import(`./dictionaries/${language}.ts`),
), ),
) )
.init({ .init({
@ -36,18 +39,17 @@ export const initI18next = async (params: {
}; };
export async function getServerTranslation( export async function getServerTranslation(
lng: string, lng: LANGS,
ns?: string, ns?: string,
options: { keyPrefix?: string } = {}, options: { keyPrefix?: string } = {},
) { ) {
const i18nextInstance = (await initI18next({ lng, ns })).i18n; await initI18next({ lng });
return { return {
t: i18nextInstance.getFixedT( t: i18nInstance.getFixedT(
lng, lng,
Array.isArray(ns) ? ns[0] : ns, Array.isArray(ns) ? ns[0] : ns,
options?.keyPrefix, options?.keyPrefix,
), ),
i18n: i18nextInstance, i18n: i18nInstance,
}; };
} }

View File

@ -2,62 +2,62 @@
import type { Config } from "tailwindcss"; import type { Config } from "tailwindcss";
const config: Config = { const config: Config = {
darkMode: ["class"], darkMode: ["class"],
content: [ content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}", "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}", "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}", "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
], ],
theme: { theme: {
extend: { extend: {
colors: { colors: {
background: 'hsl(var(--background))', background: "hsl(var(--background))",
foreground: 'hsl(var(--foreground))', foreground: "hsl(var(--foreground))",
card: { card: {
DEFAULT: 'hsl(var(--card))', DEFAULT: "hsl(var(--card))",
foreground: 'hsl(var(--card-foreground))' foreground: "hsl(var(--card-foreground))",
}, },
popover: { popover: {
DEFAULT: 'hsl(var(--popover))', DEFAULT: "hsl(var(--popover))",
foreground: 'hsl(var(--popover-foreground))' foreground: "hsl(var(--popover-foreground))",
}, },
primary: { primary: {
DEFAULT: 'hsl(var(--primary))', DEFAULT: "hsl(var(--primary))",
foreground: 'hsl(var(--primary-foreground))' foreground: "hsl(var(--primary-foreground))",
}, },
secondary: { secondary: {
DEFAULT: 'hsl(var(--secondary))', DEFAULT: "hsl(var(--secondary))",
foreground: 'hsl(var(--secondary-foreground))' foreground: "hsl(var(--secondary-foreground))",
}, },
muted: { muted: {
DEFAULT: 'hsl(var(--muted))', DEFAULT: "hsl(var(--muted))",
foreground: 'hsl(var(--muted-foreground))' foreground: "hsl(var(--muted-foreground))",
}, },
accent: { accent: {
DEFAULT: 'hsl(var(--accent))', DEFAULT: "hsl(var(--accent))",
foreground: 'hsl(var(--accent-foreground))' foreground: "hsl(var(--accent-foreground))",
}, },
destructive: { destructive: {
DEFAULT: 'hsl(var(--destructive))', DEFAULT: "hsl(var(--destructive))",
foreground: 'hsl(var(--destructive-foreground))' foreground: "hsl(var(--destructive-foreground))",
}, },
border: 'hsl(var(--border))', border: "hsl(var(--border))",
input: 'hsl(var(--input))', input: "hsl(var(--input))",
ring: 'hsl(var(--ring))', ring: "hsl(var(--ring))",
chart: { chart: {
'1': 'hsl(var(--chart-1))', "1": "hsl(var(--chart-1))",
'2': 'hsl(var(--chart-2))', "2": "hsl(var(--chart-2))",
'3': 'hsl(var(--chart-3))', "3": "hsl(var(--chart-3))",
'4': 'hsl(var(--chart-4))', "4": "hsl(var(--chart-4))",
'5': 'hsl(var(--chart-5))' "5": "hsl(var(--chart-5))",
} },
}, },
borderRadius: { borderRadius: {
lg: 'var(--radius)', lg: "var(--radius)",
md: 'calc(var(--radius) - 2px)', md: "calc(var(--radius) - 2px)",
sm: 'calc(var(--radius) - 4px)' sm: "calc(var(--radius) - 4px)",
} },
} },
}, },
plugins: [require("tailwindcss-animate")], plugins: [require("tailwindcss-animate")],
}; };

3643
yarn.lock

File diff suppressed because it is too large Load Diff