develop #3

Merged
behnam merged 39 commits from develop into main 2024-11-26 15:47:00 +00:00
122 changed files with 7456 additions and 5878 deletions
Showing only changes of commit 6de0bb3f31 - Show all commits

View File

@ -1,3 +1,95 @@
{ {
"extends": ["next/core-web-vitals", "next/typescript"] "overrides": [
{
"files": [
"src/**/*-vm.ts"
],
"rules": {
"react-hooks/rules-of-hooks": "off"
}
}
],
"plugins": [
"prettier"
],
"settings": {
"react": {
"version": "detect"
},
"import/resolver": {
"alias": {
"map": [
[
"~",
"./src"
]
],
"extensions": [
".js",
".ts",
".tsx",
".d.ts",
".test.ts",
".json"
]
}
}
},
"rules": {
"no-use-before-define": "off",
"class-methods-use-this": "off",
"import/prefer-default-export": "off",
"import/no-cycle": "off",
"no-promise-executor-return": "off",
"@typescript-eslint/no-shadow": "off",
"react/require-default-props": "off",
"no-shadow": "off",
"prettier/prettier": [
"warn",
{
"printWidth": 80,
"tabWidth": 2,
"endOfLine":"auto",
"useTabs": false,
"semi": true,
"singleQuote": false,
"quoteProps": "as-needed",
"jsxSingleQuote": false,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always"
}
],
"import/extensions": [
"error",
"ignorePackages",
{
"js": "never",
"jsx": "never",
"ts": "never",
"tsx": "never"
}
],
"react/jsx-filename-extension": [
1,
{
"extensions": [
".ts",
".tsx"
]
}
],
"import/no-extraneous-dependencies": [
"error",
{
"devDependencies": true
}
]
},
"extends": [
"airbnb",
"next/core-web-vitals",
"next/typescript",
"prettier"
]
} }

2
.gitignore vendored
View File

@ -9,7 +9,7 @@
!.yarn/plugins !.yarn/plugins
!.yarn/releases !.yarn/releases
!.yarn/versions !.yarn/versions
.vscode
# testing # testing
/coverage /coverage

70
Dockerfile Normal file
View File

@ -0,0 +1,70 @@
# syntax=docker.io/docker/dockerfile:1
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED=1
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

20
components.json Normal file
View File

@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/app/components",
"utils": "@/bootstrap/helpers/lib/ui-utils",
"ui": "@/app/components",
"lib": "@/bootstrap/helpers/lib",
"hooks": "@/bootstrap/helpers/hooks"
}
}

27
docker-compose.yml Normal file
View File

@ -0,0 +1,27 @@
services:
db:
image: postgres
restart: always
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- 5432:5432
environment:
POSTGRES_PASSWORD: example
POSTGRES_USER: admin
POSTGRES_DB: nextbp
app:
build: .
ports:
- 3000:3000
environment:
POSTGRES_HOST: db
POSTGRES_PORT: 5432
POSTGRES_USER: admin
POSTGRES_PASS: example
POSTGRES_DB: nextbp
volumes:
postgres_data:

View File

@ -2,6 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
output: "standalone",
}; };
export default nextConfig; export default nextConfig;

5724
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,22 +5,60 @@
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start --port 4000",
"lint": "next lint" "lint": "next lint --fix",
"test": "vitest",
"seed": "node -r dotenv/config ./src/bootstrap/boundaries/db/seed.js"
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.1.5",
"@radix-ui/react-icons": "^1.3.1",
"@radix-ui/react-slot": "^1.1.0",
"accept-language": "^3.0.20",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"fp-ts": "^2.16.9",
"i18next": "^23.16.4",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-resources-to-backend": "^1.2.1",
"lucide-react": "^0.454.0",
"next": "15.0.2",
"next-i18n-router": "^5.5.1",
"postgres": "^3.4.5",
"react": "19.0.0-rc-69d4b800-20241021", "react": "19.0.0-rc-69d4b800-20241021",
"react-cookie": "^7.2.2",
"react-dom": "19.0.0-rc-69d4b800-20241021", "react-dom": "19.0.0-rc-69d4b800-20241021",
"next": "15.0.1" "react-i18next": "^15.1.0",
"reflect-metadata": "^0.2.2",
"server-only": "^0.0.1",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"tsyringe": "^4.8.0",
"zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5", "@faker-js/faker": "^9.1.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.0.1",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"postcss": "^8", "@vitejs/plugin-react": "^4.3.3",
"tailwindcss": "^3.4.1", "bcrypt": "^5.1.1",
"dotenv": "^16.4.5",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "15.0.1" "eslint-config-airbnb": "^19.0.4",
"eslint-config-next": "15.0.1",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-import-resolver-typescript": "^3.6.3",
"eslint-plugin-prettier": "^5.2.1",
"jsdom": "^25.0.1",
"moq.ts": "^10.0.8",
"postcss": "^8",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.1",
"typescript": "^5",
"vitest": "^2.1.4"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1019 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@ -1 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg> <svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clipRule="evenodd" fill="#666" fillRule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

After

Width:  |  Height:  |  Size: 389 B

View File

@ -1 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg> <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fillRule="evenodd" clipRule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg> <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fillRule="evenodd" clipRule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 383 B

View File

@ -0,0 +1,13 @@
"use client";
import Button from "@/app/components/button/button";
import CreateRandomInvoiceButtonVM from "@/app/[lang]/dashboard/vm/create-random-invoice-button-vm";
import { useDI } from "@/bootstrap/di/di-context";
import { useRef } from "react";
export default function CreateRandomInvoiceContainer() {
const di = useDI();
const vm = useRef(di.resolve(CreateRandomInvoiceButtonVM));
return <Button vm={vm.current} />;
}

View File

@ -0,0 +1,28 @@
import { DocumentIcon } from "@/app/components/icons/document";
import HomeIcon from "@/app/components/icons/home";
import { UserIcon } from "@/app/components/icons/user";
import { usePathname } from "next/navigation";
type LinkItem = {
name: string;
href: string;
icon: (props: { className?: string }) => JSX.Element;
};
export default function navLinkPersonalVM() {
const pathname = usePathname();
// Map of links to display in the side navigation.
// Depending on the size of the application, this would be stored in a database.
const links: LinkItem[] = [
{ name: "Home", href: "/dashboard", icon: HomeIcon },
{
name: "Invoices",
href: "/dashboard/invoices",
icon: DocumentIcon,
},
{ name: "Customers", href: "/dashboard/customers", icon: UserIcon },
];
return {
links,
isLinkActive: (link: LinkItem) => pathname === link.href,
};
}

View File

@ -0,0 +1,31 @@
"use client";
import navLinkPersonalVM from "@/app/[lang]/dashboard/components/client/nav-links/nav-link-vm";
import clsx from "clsx";
import Link from "next/link";
export default function NavLinks() {
const { links, isLinkActive } = navLinkPersonalVM();
return (
<>
{links.map((link) => {
const LinkIcon = link.icon;
return (
<Link
key={link.name}
href={link.href}
className={clsx(
"flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3",
{
"bg-sky-100 text-blue-600": isLinkActive(link),
},
)}
>
<LinkIcon className="w-6" />
<p className="hidden md:block">{link.name}</p>
</Link>
);
})}
</>
);
}

View File

@ -0,0 +1,21 @@
import {
BanknotesIcon,
ClockIcon,
UserGroupIcon,
InboxIcon,
} from "@heroicons/react/24/outline";
export default function cardController(props: {
type: "invoices" | "customers" | "pending" | "collected";
}) {
const { type } = props;
const iconMap = {
collected: BanknotesIcon,
customers: UserGroupIcon,
pending: ClockIcon,
invoices: InboxIcon,
};
return {
Icon: iconMap[type],
};
}

View File

@ -0,0 +1,25 @@
import cardController from "@/app/[lang]/dashboard/components/server/card/card-controller";
export function Card({
title,
value,
type,
}: {
title: string;
value: number | string;
type: "invoices" | "customers" | "pending" | "collected";
}) {
const { Icon } = cardController({ type });
return (
<div className="rounded-xl bg-gray-50 p-2 shadow-sm">
<div className="flex p-4">
{Icon ? <Icon className="h-5 w-5 text-gray-700" /> : null}
<h3 className="ml-2 text-sm font-medium">{title}</h3>
</div>
<p className="rounded-xl bg-white px-4 py-8 text-center text-2xl">
{value}
</p>
</div>
);
}

View File

@ -0,0 +1,7 @@
import fetchSummaryInfoUsecase from "@/feature/core/summary-info/domain/usecase/fetch-summary-info-usecase";
import { connection } from "next/server";
export default function cardsController() {
connection();
return fetchSummaryInfoUsecase();
}

View File

@ -0,0 +1,16 @@
import { Card } from "@/app/[lang]/dashboard/components/server/card/card";
import cardsController from "@/app/[lang]/dashboard/components/server/cards/cards-controller";
export default async function CardWrapper() {
const { customersNumber, invoicesNumber, invoicesSummary } =
await cardsController();
return (
<>
<Card title="Collected" value={invoicesSummary.paid} type="collected" />
<Card title="Pending" value={invoicesSummary.pending} type="pending" />
<Card title="Total Invoices" value={invoicesNumber} type="invoices" />
<Card title="Total Customers" value={customersNumber} type="customers" />
</>
);
}

View File

@ -0,0 +1,7 @@
import fetchCustomerInvoicesUsecase from "@/feature/core/customer-invoice/domain/usecase/fetch-customer-invoices-usecase";
import { connection } from "next/server";
export default function latestInvoicesController() {
connection();
return fetchCustomerInvoicesUsecase();
}

View File

@ -0,0 +1,55 @@
import CreateRandomInvoiceContainer from "@/app/[lang]/dashboard/components/client/create-random-invoice/create-random-invoice";
import latestInvoicesController from "@/app/[lang]/dashboard/components/server/latest-invoices/latest-invoices-controller";
import { ArrowPathIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";
import { isLeft } from "fp-ts/lib/Either";
import Image from "next/image";
export default async function LatestInvoices() {
const latestInvoices = await latestInvoicesController();
if (isLeft(latestInvoices)) return <div>Error</div>;
const invoices = latestInvoices.right.map((invoice, i) => (
<div
key={invoice.id}
className={clsx("flex flex-row items-center justify-between py-4", {
"border-t": i !== 0,
})}
>
<div className="flex items-center">
<Image
src={invoice.customerImageUrl}
alt={`${invoice.customerName}'s profile picture`}
className="mr-4 rounded-full"
width={32}
height={32}
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold md:text-base">
{invoice.customerName}
</p>
<p className="hidden text-sm text-gray-500 sm:block">
{invoice.customerEmail}
</p>
</div>
</div>
<p className="truncate text-sm font-medium md:text-base">
{invoice.invoicesAmount}
</p>
</div>
));
return (
<div className="flex w-full flex-col md:col-span-4">
<h2 className="mb-4 text-xl md:text-2xl">Latest Invoices</h2>
<div className="flex grow flex-col max-h-[66.5vh] justify-between rounded-xl bg-gray-50 p-4">
<div className="bg-white px-6 h-full overflow-y-auto">{invoices}</div>
<div className="flex items-end mt-auto pb-2 pt-6">
<ArrowPathIcon className="h-5 w-5 text-gray-500" />
<h3 className="ml-2 text-sm text-gray-500 ">Updated just now</h3>
</div>
<CreateRandomInvoiceContainer />
</div>
</div>
);
}

View File

@ -0,0 +1,30 @@
import Revenue from "@/feature/core/revenue/domain/entity/revenue";
import fetchRevenuesUsecase from "@/feature/core/revenue/domain/usecase/fetch-revenues-usecase";
export default async function revenueChartController() {
const revenue = await fetchRevenuesUsecase();
const chartHeight = 350;
const { yAxisLabels, topLabel } = generateYAxis(revenue);
return {
revenue,
chartHeight,
yAxisLabels,
topLabel,
};
}
function generateYAxis(revenue: Revenue[]) {
// Calculate what labels we need to display on the y-axis
// based on highest record and in 1000s
const yAxisLabels = [];
const highestRecord = Math.max(...revenue.map((month) => month.revenue));
const topLabel = Math.ceil(highestRecord / 1000) * 1000;
for (let i = topLabel; i >= 0; i -= 1000) {
yAxisLabels.push(`$${i / 1000}K`);
}
return { yAxisLabels, topLabel };
}

View File

@ -0,0 +1,47 @@
import revenueChartController from "@/app/[lang]/dashboard/components/server/revenue-chart/revenue-chart-controller";
import { CalendarIcon } from "@heroicons/react/24/outline";
export default async function RevenueChart() {
const { chartHeight, revenue, topLabel, yAxisLabels } =
await revenueChartController();
if (!revenue || revenue.length === 0) {
return <p className="mt-4 text-gray-400">No data available.</p>;
}
return (
<div className="w-full md:col-span-4">
<h2 className={` mb-4 text-xl md:text-2xl`}>Recent Revenue</h2>
<div className="rounded-xl bg-gray-50 p-4">
<div className="sm:grid-cols-13 mt-0 grid grid-cols-12 items-end gap-2 rounded-md bg-white p-4 md:gap-4">
<div
className="mb-6 hidden flex-col justify-between text-sm text-gray-400 sm:flex"
style={{ height: `${chartHeight}px` }}
>
{yAxisLabels.map((label) => (
<p key={label}>{label}</p>
))}
</div>
{revenue.map((month) => (
<div key={month.month} className="flex flex-col items-center gap-2">
<div
className="w-full rounded-md bg-blue-300"
style={{
height: `${(chartHeight / topLabel) * month.revenue}px`,
}}
/>
<p className="-rotate-90 text-sm text-gray-400 sm:rotate-0">
{month.month}
</p>
</div>
))}
</div>
<div className="flex items-center pb-2 pt-6">
<CalendarIcon className="h-5 w-5 text-gray-500" />
<h3 className="ml-2 text-sm text-gray-500 ">Last 12 months</h3>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,19 @@
import NavLinks from "@/app/[lang]/dashboard/components/client/nav-links/nav-links";
import Link from "next/link";
export default function SideNav() {
return (
<div className="flex h-full flex-col px-3 py-4 md:px-2">
<Link
className="mb-2 flex h-20 items-end justify-start rounded-md bg-blue-600 p-4 md:h-40"
href="/"
>
<div className="w-32 text-white md:w-40">Home</div>
</Link>
<div className="flex grow flex-row justify-between space-x-2 md:flex-col md:space-x-0 md:space-y-2">
<NavLinks />
<div className="hidden h-auto w-full grow rounded-md bg-gray-50 md:block" />
</div>
</div>
);
}

View File

@ -0,0 +1,221 @@
// Loading animation
const shimmer =
"before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/60 before:to-transparent";
export function CardSkeleton() {
return (
<div
className={`${shimmer} relative overflow-hidden rounded-xl bg-gray-100 p-2 shadow-sm`}
>
<div className="flex p-4">
<div className="h-5 w-5 rounded-md bg-gray-200" />
<div className="ml-2 h-6 w-16 rounded-md bg-gray-200 text-sm font-medium" />
</div>
<div className="flex items-center justify-center truncate rounded-xl bg-white px-4 py-8">
<div className="h-7 w-20 rounded-md bg-gray-200" />
</div>
</div>
);
}
export function CardsSkeleton() {
return (
<>
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</>
);
}
export function RevenueChartSkeleton() {
return (
<div className={`${shimmer} relative w-full overflow-hidden md:col-span-4`}>
<div className="mb-4 h-8 w-36 rounded-md bg-gray-100" />
<div className="rounded-xl bg-gray-100 p-4">
<div className="mt-0 grid h-[410px] grid-cols-12 items-end gap-2 rounded-md bg-white p-4 sm:grid-cols-13 md:gap-4" />
<div className="flex items-center pb-2 pt-6">
<div className="h-5 w-5 rounded-full bg-gray-200" />
<div className="ml-2 h-4 w-20 rounded-md bg-gray-200" />
</div>
</div>
</div>
);
}
export function InvoiceSkeleton() {
return (
<div className="flex flex-row items-center justify-between border-b border-gray-100 py-4">
<div className="flex items-center">
<div className="mr-2 h-8 w-8 rounded-full bg-gray-200" />
<div className="min-w-0">
<div className="h-5 w-40 rounded-md bg-gray-200" />
<div className="mt-2 h-4 w-12 rounded-md bg-gray-200" />
</div>
</div>
<div className="mt-2 h-4 w-12 rounded-md bg-gray-200" />
</div>
);
}
export function LatestInvoicesSkeleton() {
return (
<div
className={`${shimmer} relative flex w-full flex-col overflow-hidden md:col-span-4`}
>
<div className="mb-4 h-8 w-36 rounded-md bg-gray-100" />
<div className="flex grow flex-col justify-between rounded-xl bg-gray-100 p-4">
<div className="bg-white px-6">
<InvoiceSkeleton />
<InvoiceSkeleton />
<InvoiceSkeleton />
<InvoiceSkeleton />
<InvoiceSkeleton />
<div className="flex items-center pb-2 pt-6">
<div className="h-5 w-5 rounded-full bg-gray-200" />
<div className="ml-2 h-4 w-20 rounded-md bg-gray-200" />
</div>
</div>
</div>
</div>
);
}
export default function DashboardSkeleton() {
return (
<>
<div
className={`${shimmer} relative mb-4 h-8 w-36 overflow-hidden rounded-md bg-gray-100`}
/>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
<RevenueChartSkeleton />
<LatestInvoicesSkeleton />
</div>
</>
);
}
export function TableRowSkeleton() {
return (
<tr className="w-full border-b border-gray-100 last-of-type:border-none [&:first-child>td:first-child]:rounded-tl-lg [&:first-child>td:last-child]:rounded-tr-lg [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg">
{/* Customer Name and Image */}
<td
aria-label="Customer name"
className="relative overflow-hidden whitespace-nowrap py-3 pl-6 pr-3"
>
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-gray-100" />
<div className="h-6 w-24 rounded bg-gray-100" />
</div>
</td>
{/* Email */}
<td aria-label="Email" className="whitespace-nowrap px-3 py-3">
<div className="h-6 w-32 rounded bg-gray-100" />
</td>
{/* Amount */}
<td aria-label="Amount" className="whitespace-nowrap px-3 py-3">
<div className="h-6 w-16 rounded bg-gray-100" />
</td>
{/* Date */}
<td aria-label="Date" className="whitespace-nowrap px-3 py-3">
<div className="h-6 w-16 rounded bg-gray-100" />
</td>
{/* Status */}
<td aria-label="Status" className="whitespace-nowrap px-3 py-3">
<div className="h-6 w-16 rounded bg-gray-100" />
</td>
{/* Actions */}
<td aria-label="Actions" className="whitespace-nowrap py-3 pl-6 pr-3">
<div className="flex justify-end gap-3">
<div className="h-[38px] w-[38px] rounded bg-gray-100" />
<div className="h-[38px] w-[38px] rounded bg-gray-100" />
</div>
</td>
</tr>
);
}
export function InvoicesMobileSkeleton() {
return (
<div className="mb-2 w-full rounded-md bg-white p-4">
<div className="flex items-center justify-between border-b border-gray-100 pb-8">
<div className="flex items-center">
<div className="mr-2 h-8 w-8 rounded-full bg-gray-100" />
<div className="h-6 w-16 rounded bg-gray-100" />
</div>
<div className="h-6 w-16 rounded bg-gray-100" />
</div>
<div className="flex w-full items-center justify-between pt-4">
<div>
<div className="h-6 w-16 rounded bg-gray-100" />
<div className="mt-2 h-6 w-24 rounded bg-gray-100" />
</div>
<div className="flex justify-end gap-2">
<div className="h-10 w-10 rounded bg-gray-100" />
<div className="h-10 w-10 rounded bg-gray-100" />
</div>
</div>
</div>
);
}
export function InvoicesTableSkeleton() {
return (
<div className="mt-6 flow-root">
<div className="inline-block min-w-full align-middle">
<div className="rounded-lg bg-gray-50 p-2 md:pt-0">
<div className="md:hidden">
<InvoicesMobileSkeleton />
<InvoicesMobileSkeleton />
<InvoicesMobileSkeleton />
<InvoicesMobileSkeleton />
<InvoicesMobileSkeleton />
<InvoicesMobileSkeleton />
</div>
<table className="hidden min-w-full text-gray-900 md:table">
<thead className="rounded-lg text-left text-sm font-normal">
<tr>
<th scope="col" className="px-4 py-5 font-medium sm:pl-6">
Customer
</th>
<th scope="col" className="px-3 py-5 font-medium">
Email
</th>
<th scope="col" className="px-3 py-5 font-medium">
Amount
</th>
<th scope="col" className="px-3 py-5 font-medium">
Date
</th>
<th scope="col" className="px-3 py-5 font-medium">
Status
</th>
<th
scope="col"
className="relative pb-4 pl-3 pr-6 pt-2 sm:pr-6"
>
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody className="bg-white">
<TableRowSkeleton />
<TableRowSkeleton />
<TableRowSkeleton />
<TableRowSkeleton />
<TableRowSkeleton />
<TableRowSkeleton />
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,22 @@
"use client";
import SideNav from "@/app/[lang]/dashboard/components/server/sidenav";
import dashboardAppModule from "@/app/[lang]/dashboard/module/dashboard-app-module";
import { DiContext } from "@/bootstrap/di/di-context";
import { useRef } from "react";
export default function Layout({ children }: { children: React.ReactNode }) {
const di = useRef(dashboardAppModule());
return (
<DiContext.Provider value={di.current}>
<div className="flex h-screen flex-col md:flex-row md:overflow-hidden">
<div className="w-full flex-none md:w-64">
<SideNav />
</div>
<div className="flex-grow p-6 md:overflow-y-auto md:p-12">
{children}
</div>
</div>
</DiContext.Provider>
);
}

View File

@ -0,0 +1,5 @@
import DashboardSkeleton from "@/app/[lang]/dashboard/components/server/skeletons/skeletons";
export default function Loading() {
return <DashboardSkeleton />;
}

View File

@ -0,0 +1,16 @@
import CreateRandomInvoiceButtonVM from "@/app/[lang]/dashboard/vm/create-random-invoice-button-vm";
import di from "@/bootstrap/di/init-di";
import createInvoiceUsecase from "@/feature/core/invoice/domain/usecase/create-invoice-usecase";
export default function dashboardAppModule() {
const dashboardDi = di.createChildContainer();
dashboardDi.register(createInvoiceUsecase.name, {
useValue: createInvoiceUsecase,
});
dashboardDi.register(
CreateRandomInvoiceButtonVM,
CreateRandomInvoiceButtonVM,
);
return dashboardDi;
}

View File

@ -0,0 +1,36 @@
import {
LatestInvoicesSkeleton,
RevenueChartSkeleton,
} from "@/app/[lang]/dashboard/components/server/skeletons/skeletons";
import CardWrapper from "@/app/[lang]/dashboard/components/server/cards/cards";
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 { Suspense } from "react";
import { getServerTranslation } from "@/bootstrap/i18n/i18n";
import langKey from "@/bootstrap/i18n/dictionaries/lang-key";
export default async function Dashboard(props: {
params: Promise<{ lang: string }>;
}) {
const { params } = props;
const { lang } = await params;
const { t } = await getServerTranslation(lang);
return (
<main>
<h1 className="mb-4 text-xl md:text-2xl">
{t(langKey.global.dashboard)}
</h1>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<CardWrapper />
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<LatestInvoicesSkeleton />}>
<LatestInvoices />
</Suspense>
</div>
</main>
);
}

View File

@ -0,0 +1,53 @@
import ButtonVm from "@/app/components/button/button-vm";
import { useServerAction } from "@/bootstrap/helpers/hooks/use-server-action";
import useThrottle from "@/bootstrap/helpers/hooks/use-throttle";
import BaseVM from "@/bootstrap/helpers/vm/base-vm";
import langKey from "@/bootstrap/i18n/dictionaries/lang-key";
import { InvoiceParam } from "@/feature/core/invoice/domain/param/invoice-param";
import createInvoiceUsecase from "@/feature/core/invoice/domain/usecase/create-invoice-usecase";
import { faker } from "@faker-js/faker";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
export default class CreateRandomInvoiceButtonVM extends BaseVM<ButtonVm> {
private createInvoice: typeof createInvoiceUsecase;
constructor() {
super();
this.createInvoice = this.di.resolve(createInvoiceUsecase.name);
}
useVM(): ButtonVm {
const router = useRouter();
const [action, isPending] = useServerAction(() =>
this.onClickHandler(router.refresh),
);
const throttledOnClick = useThrottle(action, 5000);
const { t } = useTranslation();
return {
props: {
title: t(
isPending
? langKey.global.loading
: langKey.dashboard.invoice.createButton,
),
isDisable: !!isPending,
},
onClick: throttledOnClick.bind(this),
};
}
async onClickHandler(refreshPage: () => void) {
const fakedParams: InvoiceParam = {
amount: faker.number.int({
min: 1,
max: 10,
}),
status: "paid",
};
await this.createInvoice(fakedParams);
refreshPage();
}
}

34
src/app/[lang]/layout.tsx Normal file
View File

@ -0,0 +1,34 @@
import { initI18next } from "@/bootstrap/i18n/i18n";
import TranslationsProvider from "@/bootstrap/i18n/i18n-provider";
import localFont from "next/font/local";
import { PropsWithChildren } from "react";
const geistSans = localFont({
src: "./../fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./../fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
export default async function layout(
props: PropsWithChildren & { params: Promise<{ lang: string }> },
) {
const { params, children } = props;
const { lang } = await params;
const { resources } = await initI18next({ lng: lang });
return (
<html lang={lang}>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<TranslationsProvider lng={lang} resources={resources}>
{children}
</TranslationsProvider>
</body>
</html>
);
}

15
src/app/[lang]/page.tsx Normal file
View File

@ -0,0 +1,15 @@
export default function Home() {
return (
<main className="flex min-h-screen flex-col p-6">
<div className="mt-4 flex grow flex-col gap-4 md:flex-row">
<div className="flex flex-col justify-center gap-6 rounded-lg bg-gray-50 px-6 py-10 md:w-2/5 md:px-20">
<div />
<p className="text-xl text-gray-800 md:text-3xl md:leading-normal">
<strong>Welcome to Acme.</strong> This is the example for the ,
brought to you by Vercel.
</p>
</div>
</div>
</main>
);
}

View File

@ -0,0 +1,7 @@
export default interface ButtonVm {
props: {
title: string;
isDisable: boolean;
};
onClick(): void;
}

View File

@ -0,0 +1,74 @@
"use client";
import BaseView, { BuildProps } from "@/bootstrap/helpers/view/base-view";
import ButtonVm from "@/app/components/button/button-vm";
import { ReactNode } from "react";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/bootstrap/helpers/lib/ui-utils";
export default class Button extends BaseView<ButtonVm> {
protected Build(props: BuildProps<ButtonVm>): ReactNode {
const { vm } = props;
return (
<ButtonUi disabled={vm.props.isDisable} onClick={vm.onClick}>
{vm.props.title}
</ButtonUi>
);
}
}
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const ButtonUi = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
);
},
);
ButtonUi.displayName = "Button";
export { buttonVariants };

View File

@ -0,0 +1,20 @@
export function DocumentIcon(props: { className?: string }) {
const { className } = props;
return (
<svg
className={className}
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 2.5C3 2.22386 3.22386 2 3.5 2H9.08579C9.21839 2 9.34557 2.05268 9.43934 2.14645L11.8536 4.56066C11.9473 4.65443 12 4.78161 12 4.91421V12.5C12 12.7761 11.7761 13 11.5 13H3.5C3.22386 13 3 12.7761 3 12.5V2.5ZM3.5 1C2.67157 1 2 1.67157 2 2.5V12.5C2 13.3284 2.67157 14 3.5 14H11.5C12.3284 14 13 13.3284 13 12.5V4.91421C13 4.51639 12.842 4.13486 12.5607 3.85355L10.1464 1.43934C9.86514 1.15804 9.48361 1 9.08579 1H3.5ZM4.5 4C4.22386 4 4 4.22386 4 4.5C4 4.77614 4.22386 5 4.5 5H7.5C7.77614 5 8 4.77614 8 4.5C8 4.22386 7.77614 4 7.5 4H4.5ZM4.5 7C4.22386 7 4 7.22386 4 7.5C4 7.77614 4.22386 8 4.5 8H10.5C10.7761 8 11 7.77614 11 7.5C11 7.22386 10.7761 7 10.5 7H4.5ZM4.5 10C4.22386 10 4 10.2239 4 10.5C4 10.7761 4.22386 11 4.5 11H10.5C10.7761 11 11 10.7761 11 10.5C11 10.2239 10.7761 10 10.5 10H4.5Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
/>
</svg>
);
}

View File

@ -0,0 +1,20 @@
export default function HomeIcon(props: { className?: string }) {
const { className } = props;
return (
<svg
className={className}
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.07926 0.222253C7.31275 -0.007434 7.6873 -0.007434 7.92079 0.222253L14.6708 6.86227C14.907 7.09465 14.9101 7.47453 14.6778 7.71076C14.4454 7.947 14.0655 7.95012 13.8293 7.71773L13 6.90201V12.5C13 12.7761 12.7762 13 12.5 13H2.50002C2.22388 13 2.00002 12.7761 2.00002 12.5V6.90201L1.17079 7.71773C0.934558 7.95012 0.554672 7.947 0.32229 7.71076C0.0899079 7.47453 0.0930283 7.09465 0.32926 6.86227L7.07926 0.222253ZM7.50002 1.49163L12 5.91831V12H10V8.49999C10 8.22385 9.77617 7.99999 9.50002 7.99999H6.50002C6.22388 7.99999 6.00002 8.22385 6.00002 8.49999V12H3.00002V5.91831L7.50002 1.49163ZM7.00002 12H9.00002V8.99999H7.00002V12Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
/>
</svg>
);
}

View File

@ -0,0 +1,19 @@
export function UserIcon(props: { className?: string }) {
const { className } = props;
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"
/>
</svg>
);
}

View File

@ -2,20 +2,71 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body { body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
} }
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -1,35 +1,15 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css"; import "./globals.css";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Create Next App",
description: "Generated by create next app", description: "Generated by create next app",
}; };
export default function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return children;
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
} }

View File

@ -1,101 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
src/app/page.tsx
</code>
.
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
);
}

View File

@ -0,0 +1,12 @@
import postgres from "postgres";
const envs = process.env;
const dbConfigs = {
host: envs.POSTGRES_HOST,
port: Number(envs.POSTGRES_PORT),
username: envs.POSTGRES_USER,
password: envs.POSTGRES_PASS,
database: envs.POSTGRES_DB,
};
export const sql = postgres(dbConfigs);

View File

@ -0,0 +1,188 @@
// This file contains placeholder data that you'll be replacing with real data in the Data Fetching chapter:
// https://nextjs.org/learn/dashboard-app/fetching-data
const users = [
{
id: "410544b2-4001-4271-9855-fec4b6a6442a",
name: "User",
email: "user@nextmail.com",
password: "123456",
},
];
const customers = [
{
id: "3958dc9e-712f-4377-85e9-fec4b6a6442a",
name: "Delba de Oliveira",
email: "delba@oliveira.com",
image_url: "/customers/delba-de-oliveira.png",
},
{
id: "3958dc9e-742f-4377-85e9-fec4b6a6442a",
name: "Lee Robinson",
email: "lee@robinson.com",
image_url: "/customers/lee-robinson.png",
},
{
id: "3958dc9e-737f-4377-85e9-fec4b6a6442a",
name: "Hector Simpson",
email: "hector@simpson.com",
image_url: "/customers/hector-simpson.png",
},
{
id: "50ca3e18-62cd-11ee-8c99-0242ac120002",
name: "Steven Tey",
email: "steven@tey.com",
image_url: "/customers/steven-tey.png",
},
{
id: "3958dc9e-787f-4377-85e9-fec4b6a6442a",
name: "Steph Dietz",
email: "steph@dietz.com",
image_url: "/customers/steph-dietz.png",
},
{
id: "76d65c26-f784-44a2-ac19-586678f7c2f2",
name: "Michael Novotny",
email: "michael@novotny.com",
image_url: "/customers/michael-novotny.png",
},
{
id: "d6e15727-9fe1-4961-8c5b-ea44a9bd81aa",
name: "Evil Rabbit",
email: "evil@rabbit.com",
image_url: "/customers/evil-rabbit.png",
},
{
id: "126eed9c-c90c-4ef6-a4a8-fcf7408d3c66",
name: "Emil Kowalski",
email: "emil@kowalski.com",
image_url: "/customers/emil-kowalski.png",
},
{
id: "CC27C14A-0ACF-4F4A-A6C9-D45682C144B9",
name: "Amy Burns",
email: "amy@burns.com",
image_url: "/customers/amy-burns.png",
},
{
id: "13D07535-C59E-4157-A011-F8D2EF4E0CBB",
name: "Balazs Orban",
email: "balazs@orban.com",
image_url: "/customers/balazs-orban.png",
},
];
const invoices = [
{
customer_id: customers[0].id,
amount: 15795,
status: "pending",
date: "2022-12-06",
},
{
customer_id: customers[1].id,
amount: 20348,
status: "pending",
date: "2022-11-14",
},
{
customer_id: customers[4].id,
amount: 3040,
status: "paid",
date: "2022-10-29",
},
{
customer_id: customers[3].id,
amount: 44800,
status: "paid",
date: "2023-09-10",
},
{
customer_id: customers[5].id,
amount: 34577,
status: "pending",
date: "2023-08-05",
},
{
customer_id: customers[7].id,
amount: 54246,
status: "pending",
date: "2023-07-16",
},
{
customer_id: customers[6].id,
amount: 666,
status: "pending",
date: "2023-06-27",
},
{
customer_id: customers[3].id,
amount: 32545,
status: "paid",
date: "2023-06-09",
},
{
customer_id: customers[4].id,
amount: 1250,
status: "paid",
date: "2023-06-17",
},
{
customer_id: customers[5].id,
amount: 8546,
status: "paid",
date: "2023-06-07",
},
{
customer_id: customers[1].id,
amount: 500,
status: "paid",
date: "2023-08-19",
},
{
customer_id: customers[5].id,
amount: 8945,
status: "paid",
date: "2023-06-03",
},
{
customer_id: customers[2].id,
amount: 8945,
status: "paid",
date: "2023-06-18",
},
{
customer_id: customers[0].id,
amount: 8945,
status: "paid",
date: "2023-10-04",
},
{
customer_id: customers[2].id,
amount: 1000,
status: "paid",
date: "2022-06-05",
},
];
const revenue = [
{ month: "Jan", revenue: 2000 },
{ month: "Feb", revenue: 1800 },
{ month: "Mar", revenue: 2200 },
{ month: "Apr", revenue: 2500 },
{ month: "May", revenue: 2300 },
{ month: "Jun", revenue: 3200 },
{ month: "Jul", revenue: 3500 },
{ month: "Aug", revenue: 3700 },
{ month: "Sep", revenue: 2500 },
{ month: "Oct", revenue: 2800 },
{ month: "Nov", revenue: 3000 },
{ month: "Dec", revenue: 4800 },
];
module.exports = {
users,
customers,
invoices,
revenue,
};

View File

@ -0,0 +1,189 @@
/* eslint-disable no-console */
/* eslint-disable @typescript-eslint/no-require-imports */
const bcrypt = require("bcrypt");
const postgres = require("postgres");
const {
invoices,
customers,
revenue,
users,
// eslint-disable-next-line import/extensions
} = require("./placeholder-data.js");
async function seedUsers(sql) {
try {
await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`;
// Create the "users" table if it doesn't exist
const createTable = await sql`
CREATE TABLE IF NOT EXISTS users (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL
);
`;
console.log(`Created "users" table`);
// Insert data into the "users" table
const insertedUsers = await Promise.all(
users.map(async (user) => {
const hashedPassword = await bcrypt.hash(user.password, 10);
return sql`
INSERT INTO users (id, name, email, password)
VALUES (${user.id}, ${user.name}, ${user.email}, ${hashedPassword})
ON CONFLICT (id) DO NOTHING;
`;
}),
);
console.log(`Seeded ${insertedUsers.length} users`);
return {
createTable,
users: insertedUsers,
};
} catch (error) {
console.error("Error seeding users:", error);
throw error;
}
}
async function seedInvoices(sql) {
try {
await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`;
// Create the "invoices" table if it doesn't exist
const createTable = await sql`
CREATE TABLE IF NOT EXISTS invoices (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
customer_id UUID NOT NULL,
amount INT NOT NULL,
status VARCHAR(255) NOT NULL,
date DATE NOT NULL
);
`;
console.log(`Created "invoices" table`);
// Insert data into the "invoices" table
const insertedInvoices = await Promise.all(
invoices.map(
(invoice) => sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${invoice.customer_id}, ${invoice.amount}, ${invoice.status}, ${invoice.date})
ON CONFLICT (id) DO NOTHING;
`,
),
);
console.log(`Seeded ${insertedInvoices.length} invoices`);
return {
createTable,
invoices: insertedInvoices,
};
} catch (error) {
console.error("Error seeding invoices:", error);
throw error;
}
}
async function seedCustomers(sql) {
try {
await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`;
// Create the "customers" table if it doesn't exist
const createTable = await sql`
CREATE TABLE IF NOT EXISTS customers (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
image_url VARCHAR(255) NOT NULL
);
`;
console.log(`Created "customers" table`);
// Insert data into the "customers" table
const insertedCustomers = await Promise.all(
customers.map(
(customer) => sql`
INSERT INTO customers (id, name, email, image_url)
VALUES (${customer.id}, ${customer.name}, ${customer.email}, ${customer.image_url})
ON CONFLICT (id) DO NOTHING;
`,
),
);
console.log(`Seeded ${insertedCustomers.length} customers`);
return {
createTable,
customers: insertedCustomers,
};
} catch (error) {
console.error("Error seeding customers:", error);
throw error;
}
}
async function seedRevenue(sql) {
try {
// Create the "revenue" table if it doesn't exist
const createTable = await sql`
CREATE TABLE IF NOT EXISTS revenue (
month VARCHAR(4) NOT NULL UNIQUE,
revenue INT NOT NULL
);
`;
console.log(`Created "revenue" table`);
// Insert data into the "revenue" table
const insertedRevenue = await Promise.all(
revenue.map(
(rev) => sql`
INSERT INTO revenue (month, revenue)
VALUES (${rev.month}, ${rev.revenue})
ON CONFLICT (month) DO NOTHING;
`,
),
);
console.log(`Seeded ${insertedRevenue.length} revenue`);
return {
createTable,
revenue: insertedRevenue,
};
} catch (error) {
console.error("Error seeding revenue:", error);
throw error;
}
}
async function main() {
const envs = process.env;
const dbConfigs = {
host: process.env.POSTGRES_HOST,
port: envs.POSTGRES_PORT,
username: envs.POSTGRES_USER,
password: envs.POSTGRES_PASS,
database: envs.POSTGRES_DB,
};
const sql = postgres(dbConfigs);
await seedUsers(sql);
await seedCustomers(sql);
await seedInvoices(sql);
await seedRevenue(sql);
}
main().catch((err) => {
console.error(
"An error occurred while attempting to seed the database:",
err,
);
});

View File

@ -0,0 +1,19 @@
"use client";
import di from "@/bootstrap/di/init-di";
import { createContext, use } from "react";
import { DependencyContainer } from "tsyringe";
const DiContext = createContext<null | DependencyContainer>(di);
const useDI = () => {
const di = use(DiContext);
if (!di) {
throw new Error("Di has not provided");
}
return di;
};
export { DiContext, useDI };

View File

@ -0,0 +1,18 @@
// "use client"
import "reflect-metadata";
import { container, DependencyContainer } from "tsyringe";
/**
* Serves as a central point for initializing and configuring
* the DI container, ensuring that all necessary dependencies
* are registered and available for injection throughout the application.
*/
const InitDI = (): DependencyContainer => {
const di = container.createChildContainer();
return di;
};
const di = InitDI();
export default di;

View File

@ -0,0 +1 @@
export const isServer = typeof window === "undefined";

View File

@ -0,0 +1,40 @@
import { useState, useEffect, useTransition, useRef } from "react";
/**
*
* @param action Main server action to run
* @param onFinished Callback to run after action
* @returns transitioned action to run and is pending variable
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const useServerAction = <P extends any[], R>(
action: (...args: P) => Promise<R>,
onFinished?: (_: R | undefined) => void,
): [(...args: P) => Promise<R | undefined>, boolean] => {
const [isPending, startTransition] = useTransition();
const [result, setResult] = useState<R>();
const [finished, setFinished] = useState(false);
const resolver = useRef<(value?: R | PromiseLike<R>) => void>();
useEffect(() => {
if (!finished) return;
if (onFinished) onFinished(result);
resolver.current?.(result);
}, [result, finished, onFinished]);
const runAction = async (...args: P): Promise<R | undefined> => {
startTransition(() => {
action(...args).then((data) => {
setResult(data);
setFinished(true);
});
});
return new Promise((resolve) => {
resolver.current = resolve;
});
};
return [runAction, isPending];
};

View File

@ -0,0 +1,22 @@
"use client";
import { useRef } from "react";
/**
*
* @param callback
* @param time In miliseconds
*/
export default function useThrottle<T extends () => unknown>(
callback: T,
time: number = 2000,
) {
const lastRun = useRef(Date.now());
// eslint-disable-next-line func-names
return function () {
if (Date.now() - lastRun.current <= time) return;
lastRun.current = Date.now();
callback();
};
}

View File

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

View File

@ -0,0 +1,8 @@
declare const _: unique symbol;
type Forbidden = { [_]: typeof _ };
/**
* You can use this type to make your parent class method forbidden to overwrite
*/
export type NoOverride<T = void> = T & Forbidden;

View File

@ -0,0 +1,96 @@
"use client";
// import gdi from "@/bootstrap/di/init-di";
/* eslint-disable react/display-name */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/jsx-props-no-spreading */
import IBaseVM from "@/bootstrap/helpers/vm/i-base-vm";
import { Component, ReactNode, FC, PropsWithChildren, memo } from "react";
/* -------------------------------------------------------------------------- */
/* Connector Component */
/* -------------------------------------------------------------------------- */
interface IVvmConnector<IVM, PROPS> extends PropsWithChildren {
View: FC<any & { vm: IVM }>;
Vm: IBaseVM<IVM>;
restProps?: PROPS;
memoizedByVM?: boolean;
}
/**
* This function is just will be used in
*/
const VvmConnector = memo(
<IVM, PROPS>(props: IVvmConnector<IVM, PROPS>) => {
const { View, Vm, restProps, children } = props;
const vm = Vm.useVM();
const allProps = {
restProps,
vm,
};
return <View {...allProps}>{children}</View>;
},
(prevProps) => {
if (prevProps.memoizedByVM) return true;
return false;
},
);
/* -------------------------------------------------------------------------- */
/* BaseView */
/* -------------------------------------------------------------------------- */
type IVMParent = Record<string, any>;
type IPropParent = Record<string, any> | undefined;
type BaseProps<IVM extends IVMParent, PROPS extends IPropParent = undefined> = {
vm: IBaseVM<IVM>;
restProps?: PROPS;
/**
* By default it's true.
* If you pass true this view will update just by changes of vm not rest props
*
*/
memoizedByVM?: boolean;
children?: ReactNode;
};
export type BuildProps<
IVM extends IVMParent,
PROPS extends IPropParent = undefined,
> = {
vm: IVM;
restProps: PROPS;
children?: ReactNode;
};
export default abstract class BaseView<
IVM extends IVMParent,
PROPS extends IPropParent = undefined,
> extends Component<BaseProps<IVM, PROPS>> {
protected get componentName() {
return this.constructor.name;
}
protected abstract Build(props: BuildProps<IVM, PROPS>): ReactNode;
render(): ReactNode {
const { vm, restProps, memoizedByVM, children, ...rest } = this.props;
VvmConnector.displayName = this.componentName;
return (
<VvmConnector
View={this.Build}
Vm={vm}
memoizedByVM={typeof memoizedByVM === "undefined" ? true : memoizedByVM}
restProps={{ ...restProps, ...rest }}
>
{children}
</VvmConnector>
);
}
/* -------------------------------------------------------------------------- */
}

View File

@ -0,0 +1,53 @@
"use client";
import { useDI } from "@/bootstrap/di/di-context";
import { NoOverride } from "@/bootstrap/helpers/type-helper";
import IBaseVM from "@/bootstrap/helpers/vm/i-base-vm";
import { useState } from "react";
export default abstract class BaseVM<
IVM,
DEP extends object | undefined = undefined,
> implements IBaseVM<IVM>
{
/* ------------------------------ Dependencies ------------------------------ */
protected deps!: DEP;
/* -------------------------------- Abstracts ------------------------------- */
abstract useVM(): IVM;
/* -------------------------------------------------------------------------- */
produce(dep?: DEP) {
if (dep) this.deps = dep;
return this;
}
/* --------------------------------- Getters -------------------------------- */
/**
* You can pass your rerender method after calling useRerender on your vm
* so you can access to it in any method
*/
protected rerender?: () => void;
/* -------------------------------------------------------------------------- */
protected get di() {
return useDI();
}
/* -------------------------------------------------------------------------- */
/**
* You can use this hook in your useVm method to get rerender method
* @returns Rerender Method that when ever you call it you can rerender your component
* for showing new values
*/
protected useRerender(): NoOverride<() => void> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, reState] = useState(false);
const rerender = () => reState((prev) => !prev);
return rerender as NoOverride<() => void>;
}
/* -------------------------------------------------------------------------- */
}

View File

@ -0,0 +1,3 @@
export default interface IBaseVM<VM> {
useVM(): VM;
}

View File

@ -0,0 +1,16 @@
import langKey from "@/bootstrap/i18n/dictionaries/lang-key";
const en: typeof langKey = {
global: {
home: "Home",
loading: "Loading",
dashboard: "Dashboard",
},
dashboard: {
invoice: {
createButton: "Create random Invoice",
},
},
};
export default en;

View File

@ -0,0 +1,14 @@
const langKey = {
global: {
home: "global.home",
dashboard: "global.dashboard",
loading: "global.loading",
},
dashboard: {
invoice: {
createButton: "dashboard.invoice.createButton",
},
},
};
export default langKey;

View File

@ -0,0 +1,16 @@
import langKey from "@/bootstrap/i18n/dictionaries/lang-key";
const ru: typeof langKey = {
global: {
home: "Дом",
loading: "Загрузка",
dashboard: "Панель приборов",
},
dashboard: {
invoice: {
createButton: "Создать случайный счет-фактуру",
},
},
};
export default ru;

View File

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

View File

@ -0,0 +1,53 @@
import { getOptions, languages } from "@/bootstrap/i18n/settings";
import { createInstance, i18n, Resource } from "i18next";
import resourcesToBackend from "i18next-resources-to-backend";
import { initReactI18next } from "react-i18next/initReactI18next";
const initI18nextInstance = createInstance();
export const initI18next = async (params: {
lng: string;
i18n?: i18n;
resources?: Resource;
ns?: string;
}) => {
const { lng, i18n, ns, resources } = params;
const i18nInstance = i18n || initI18nextInstance;
await i18nInstance
.use(initReactI18next)
.use(
resourcesToBackend(
(language: string) => import(`./dictionaries/${language}.ts`),
),
)
.init({
...getOptions(lng, ns),
resources,
preload: resources ? [] : languages,
});
await i18nInstance.init();
return {
i18n: i18nInstance,
resources: i18nInstance.services.resourceStore.data,
t: i18nInstance.t,
};
};
export async function getServerTranslation(
lng: string,
ns?: string,
options: { keyPrefix?: string } = {},
) {
const i18nextInstance = (await initI18next({ lng, ns })).i18n;
return {
t: i18nextInstance.getFixedT(
lng,
Array.isArray(ns) ? ns[0] : ns,
options?.keyPrefix,
),
i18n: i18nextInstance,
};
}

View File

@ -0,0 +1,16 @@
export const fallbackLng = "en";
export const languages = [fallbackLng, "ru"];
export const defaultNS = "translation";
export const cookieName = "i18next";
export function getOptions(lng = fallbackLng, ns = defaultNS) {
return {
// debug: true,
supportedLngs: languages,
fallbackLng,
lng,
fallbackNS: defaultNS,
defaultNS,
ns,
};
}

View File

@ -0,0 +1,11 @@
import { Either } from "fp-ts/lib/Either";
import { TaskEither } from "fp-ts/lib/TaskEither";
import BaseFailure from "@/feature/common/failures/base-failure";
type ApiTask<ResponseType> = TaskEither<BaseFailure<unknown>, ResponseType>;
export type ApiEither<ResponseType> = Either<
BaseFailure<unknown>,
ResponseType
>;
export default ApiTask;

View File

@ -0,0 +1,26 @@
import { makeFailureMessage } from "@/feature/common/failures/failure-helpers";
/**
* This is a class called BaseFailure that extends the Error class. It is
* used as a base class for creating custom failure classes.
*/
export default abstract class BaseFailure<META_DATA> {
/* ------------------------------- Attributes ------------------------------- */
private readonly BASE_FAILURE_MESSAGE = "failure";
/* -------------------------------------------------------------------------- */
/**
* Use this message as key lang for failure messages
*/
message = this.BASE_FAILURE_MESSAGE;
/* -------------------------------------------------------------------------- */
metadata: META_DATA | undefined;
/* -------------------------------------------------------------------------- */
constructor(key: string, metadata?: META_DATA) {
this.message = makeFailureMessage(this.message, key);
this.metadata = metadata ?? undefined;
}
/* -------------------------------------------------------------------------- */
}

View File

@ -0,0 +1,14 @@
import BaseDevFailure from "@/feature/common/failures/dev/base-dev-failure";
/**
* Failure for needed arguments in a method but sent wrong one
*/
export default class ArgumentsFailure<
META_DATA,
> extends BaseDevFailure<META_DATA> {
/* ------------------------------- Constructor ------------------------------ */
constructor(metadata?: META_DATA) {
super("arguments", metadata);
}
/* -------------------------------------------------------------------------- */
}

View File

@ -0,0 +1,5 @@
import BaseFailure from "@/feature/common/failures/base-failure";
export default abstract class BaseDevFailure<
META_DATA,
> extends BaseFailure<META_DATA> {}

View File

@ -0,0 +1,12 @@
import BaseDevFailure from "@/feature/common/failures/dev/base-dev-failure";
/**
* This is a failure of not having specific dependency
*/
export default class DependencyFailure<
META_DATA,
> extends BaseDevFailure<META_DATA> {
constructor(metadata: META_DATA) {
super("DependencyFailure", metadata);
}
}

View File

@ -0,0 +1,99 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import BaseFailure from "@/feature/common/failures/base-failure";
/**
* This method is supposed to save previous failure of TaskEither
* to prevent it from loosing and overriding by the new one.
*
* Usage example:
* ```ts
* tryCatch(
* async () => {
* ...
* throw ValidationFailure();
* ...
* },
* (reason) => failureOr(reason, new UserCreationFailure()),
* )
* ```
* In this example `failureOr` will return already throwed
* instance of `BaseFailure<any>` which is `ValidationFailure`.
*
*
* @param reason is throwed object.
* Basically it can be default `Error` or instance of `BaseFailure<any>`.
* @param failure instance of `BaseFailure<any>` that will be returned
* if reason is not instance of `BaseFailure<any>`.
* @returns `BaseFailure<any>`
*/
export function failureOr(
reason: unknown,
failure: BaseFailure<any>,
): BaseFailure<any> {
if (reason instanceof BaseFailure) {
return reason;
}
return failure;
}
/**
* Returns a function that maps a BaseFailure<any> instance to a new BaseFailure<any> instance of type IfType using the provided mapping function.
* @param f A function that maps an instance of IfType to a new instance of BaseFailure<any>.
* @param ctor A constructor function for IfType.
* @returns A function that maps a BaseFailure<any> instance to a new BaseFailure<any> instance of type IfType.
*/
export function mapToFailureFrom<IfType extends BaseFailure<any>>(
f: (t: IfType) => BaseFailure<any>,
ctor: new (...args: never[]) => IfType,
): (t: BaseFailure<any>) => BaseFailure<any> {
return mapIfInstance<IfType, BaseFailure<any>>(f, ctor);
}
/**
* Maps an instance of a class to a response using a provided function.
*
* @template IfType - The type of the instance to map.
* @template Response - The type of the response to map to.
* @param {function} f - The function to use to map the instance to a response.
* @param {new (...args: never[]) => IfType} ctor - The constructor function of the instance to map.
* @returns {(t: IfType | Response) => IfType | Response} - A function that maps the instance to a response using the provided function.
*/
export function mapIfInstance<IfType, Response>(
f: (t: IfType) => Response,
ctor: new (...args: never[]) => IfType,
) {
return (t: IfType | Response) => {
if (t instanceof ctor) {
return f(t);
}
return t;
};
}
/**
* Maps a function to a value if it is not an instance of a given class.
* @template IfType The type of the value to be mapped.
* @template Response The type of the mapped value.
* @param {function} f The function to map the value with.
* @param {new (...args: never[]) => IfType} ctor The class to check the value against.
* @returns {function} A function that maps the value if it is not an instance of the given class.
*/
export function mapIfNotInstance<IfType, Response>(
f: (t: IfType) => Response,
ctor: new (...args: never[]) => IfType,
) {
return (t: IfType | Response) => {
if (t! instanceof ctor) {
return f(t);
}
return t;
};
}
/**
* Gets Message key and it'll add it to the failure message key hierarchy
*/
export function makeFailureMessage(message: string, key: string) {
if (!key) return message;
return `${message}.${key}`;
}

View File

@ -0,0 +1,12 @@
import BaseFailure from "./base-failure";
/**
* Failure for HTTP response when response dosn't have base structure
*/
export default class NetworkFailure<META_DATA> extends BaseFailure<META_DATA> {
/* ------------------------------- Constructor ------------------------------ */
constructor(metaData?: META_DATA) {
super("network", metaData);
}
/* -------------------------------------------------------------------------- */
}

View File

@ -0,0 +1,12 @@
import BaseFailure from "./base-failure";
/**
* Failure for params failure
*/
export default class ParamsFailure<META_DATA> extends BaseFailure<META_DATA> {
/* ------------------------------- Constructor ------------------------------ */
constructor(metadata?: META_DATA) {
super("params", metadata);
}
/* -------------------------------------------------------------------------- */
}

View File

@ -0,0 +1,5 @@
export const formatCurrency = (amount: number) =>
(amount / 100).toLocaleString("en-US", {
style: "currency",
currency: "USD",
});

View File

@ -0,0 +1,30 @@
import getCustomerInvoiceDi from "@/feature/core/customer-invoice/data/module/customer-invoice-di";
import { customerInvoiceModuleKey } from "@/feature/core/customer-invoice/invoice-module-key";
import { customerKey } from "@/feature/core/customer/customer-key";
import getCustomerDi from "@/feature/core/customer/data/module/customer-di";
import getInvoiceDi from "@/feature/core/invoice/data/module/invoice-di";
import { invoiceModuleKey } from "@/feature/core/invoice/invoice-module-key";
import { DependencyContainer } from "tsyringe";
import { summaryInfoModuleKey } from "@/feature/core/summary-info/domain/summary-info-module-key";
import getSummaryInfoDi from "@/feature/core/summary-info/data/module/summary-info-di";
import { revenueModuleKey } from "@/feature/core/revenue/domain/revenue-module-key";
import getRevenueDi from "@/feature/core/revenue/data/module/revenue-di";
const memoizedDis: Record<string, DependencyContainer> = {};
export default function serverDi(module: string): DependencyContainer {
if (memoizedDis[module]) return memoizedDis[module];
const getDi = {
[customerKey]: getCustomerDi,
[customerInvoiceModuleKey]: getCustomerInvoiceDi,
[invoiceModuleKey]: getInvoiceDi,
[summaryInfoModuleKey]: getSummaryInfoDi,
[revenueModuleKey]: getRevenueDi,
}[module];
if (!getDi) throw new Error(`Server Di didn't found for module: ${module}`);
const di = getDi();
memoizedDis[module] = di;
return di;
}

View File

@ -0,0 +1,11 @@
import di from "@/bootstrap/di/init-di";
import CustomerInvoiceDbRepo from "@/feature/core/customer-invoice/data/repo/customer-invoice-db-repo";
import { customerInvoiceRepoKey } from "@/feature/core/customer-invoice/domain/i-repo/customer-invoice-repo";
import { DependencyContainer } from "tsyringe";
export default function getCustomerInvoiceDi(): DependencyContainer {
const customerInvoiceDi = di.createChildContainer();
customerInvoiceDi.register(customerInvoiceRepoKey, CustomerInvoiceDbRepo);
return customerInvoiceDi;
}

View File

@ -0,0 +1,58 @@
import { sql } from "@/bootstrap/boundaries/db/db";
import ApiTask from "@/feature/common/data/api-task";
import { failureOr } from "@/feature/common/failures/failure-helpers";
import NetworkFailure from "@/feature/common/failures/network-failure";
import { formatCurrency } from "@/feature/common/feature-helpers";
import CustomerInvoice from "@/feature/core/customer-invoice/domain/entity/customer-invoice";
import CustomerInvoiceRepo from "@/feature/core/customer-invoice/domain/i-repo/customer-invoice-repo";
import { pipe } from "fp-ts/lib/function";
import { tryCatch } from "fp-ts/lib/TaskEither";
import postgres from "postgres";
type customerInvoiceDbResponse = {
id: string;
name: string;
image_url: string;
email: string;
amount: string;
};
export default class CustomerInvoiceDbRepo implements CustomerInvoiceRepo {
fetchList(): ApiTask<CustomerInvoice[]> {
return pipe(
tryCatch(
async () => {
const response = (await sql`
SELECT invoices.amount, customers.name, customers.image_url, customers.email, invoices.id
FROM invoices
JOIN customers ON invoices.customer_id = customers.id
ORDER BY invoices.date DESC
LIMIT 20 `) as postgres.RowList<
customerInvoiceDbResponse[]
>;
return this.customerInvoicesDto(response);
},
(l) => failureOr(l, new NetworkFailure()),
),
);
}
private customerInvoicesDto(
dbCustomers: customerInvoiceDbResponse[],
): CustomerInvoice[] {
return dbCustomers.map((customer) => this.customerInvoiceDto(customer));
}
private customerInvoiceDto(
dbCustomer: customerInvoiceDbResponse,
): CustomerInvoice {
return new CustomerInvoice({
id: dbCustomer.id,
customerName: dbCustomer.name,
customerEmail: dbCustomer.email,
customerImageUrl: dbCustomer.image_url,
invoicesAmount: formatCurrency(+dbCustomer.amount),
});
}
}

View File

@ -0,0 +1,25 @@
export default class CustomerInvoice {
id: string;
customerName: string;
customerImageUrl: string;
customerEmail: string;
invoicesAmount: string;
constructor({
id,
customerEmail,
customerImageUrl,
customerName,
invoicesAmount,
}: CustomerInvoice) {
this.id = id;
this.customerEmail = customerEmail;
this.customerImageUrl = customerImageUrl;
this.customerName = customerName;
this.invoicesAmount = invoicesAmount;
}
}

View File

@ -0,0 +1,8 @@
import ApiTask from "@/feature/common/data/api-task";
import CustomerInvoice from "@/feature/core/customer-invoice/domain/entity/customer-invoice";
export default interface CustomerInvoiceRepo {
fetchList(): ApiTask<CustomerInvoice[]>;
}
export const customerInvoiceRepoKey = "customerInvoiceRepoKey";

View File

@ -0,0 +1,17 @@
import "server-only";
import { ApiEither } from "@/feature/common/data/api-task";
import serverDi from "@/feature/common/server-di";
import CustomerInvoice from "@/feature/core/customer-invoice/domain/entity/customer-invoice";
import CustomerInvoiceRepo, {
customerInvoiceRepoKey,
} from "@/feature/core/customer-invoice/domain/i-repo/customer-invoice-repo";
import { customerInvoiceModuleKey } from "@/feature/core/customer-invoice/invoice-module-key";
export default function fetchCustomerInvoicesUsecase(): Promise<
ApiEither<CustomerInvoice[]>
> {
const repo = serverDi(customerInvoiceModuleKey).resolve<CustomerInvoiceRepo>(
customerInvoiceRepoKey,
);
return repo.fetchList()();
}

View File

@ -0,0 +1 @@
export const customerInvoiceModuleKey = "customerInvoiceModuleKey";

View File

@ -0,0 +1 @@
export const customerKey = "customerKey";

View File

@ -0,0 +1,11 @@
import di from "@/bootstrap/di/init-di";
import CustomerDbRepo from "@/feature/core/customer/data/repo/customer-db-repo";
import { customerRepoKey } from "@/feature/core/customer/domain/i-repo/customer-repo";
import { DependencyContainer } from "tsyringe";
export default function getCustomerDi(): DependencyContainer {
const customerDi = di.createChildContainer();
customerDi.register(customerRepoKey, CustomerDbRepo);
return customerDi;
}

View File

@ -0,0 +1,76 @@
import { sql } from "@/bootstrap/boundaries/db/db";
import ApiTask from "@/feature/common/data/api-task";
import { failureOr } from "@/feature/common/failures/failure-helpers";
import NetworkFailure from "@/feature/common/failures/network-failure";
import { formatCurrency } from "@/feature/common/feature-helpers";
import Customer from "@/feature/core/customer/domain/entity/customer";
import CustomerRepo from "@/feature/core/customer/domain/i-repo/customer-repo";
import { pipe } from "fp-ts/lib/function";
import { map, tryCatch } from "fp-ts/lib/TaskEither";
import postgres from "postgres";
type customerDbResponse = {
id: string;
name: string;
email: string;
image_url: string;
total_invoices: string;
total_pending: string;
total_paid: string;
};
export default class CustomerDbRepo implements CustomerRepo {
fetchList(query: string): ApiTask<Customer[]> {
return pipe(
tryCatch(
async () => {
const data = (await sql`
SELECT
customers.id,
customers.name,
customers.email,
customers.image_url,
COUNT(invoices.id) AS total_invoices,
SUM(CASE WHEN invoices.status = 'pending' THEN invoices.amount ELSE 0 END) AS total_pending,
SUM(CASE WHEN invoices.status = 'paid' THEN invoices.amount ELSE 0 END) AS total_paid
FROM customers
LEFT JOIN invoices ON customers.id = invoices.customer_id
WHERE
customers.name ILIKE ${`%${query}%`} OR
customers.email ILIKE ${`%${query}%`}
GROUP BY customers.id, customers.name, customers.email, customers.image_url
ORDER BY customers.name ASC
`) as postgres.RowList<customerDbResponse[]>;
return data;
},
(l) => failureOr(l, new NetworkFailure(l as Error)),
),
map(this.customersDto.bind(this)),
) as ApiTask<Customer[]>;
}
async fetchCustomersAmount(): Promise<number> {
const data =
(await sql`SELECT COUNT(*) FROM customers`) as postgres.RowList<
unknown[]
>;
return Number(data.count ?? "0");
}
private customersDto(dbCustomers: customerDbResponse[]): Customer[] {
return dbCustomers.map((customer) => this.customerDto(customer));
}
private customerDto(dbCustomer: customerDbResponse): Customer {
return new Customer({
id: dbCustomer.id,
name: dbCustomer.name,
email: dbCustomer.email,
imageUrl: dbCustomer.image_url,
totalInvoices: dbCustomer.total_invoices,
totalPending: formatCurrency(Number(dbCustomer.total_pending)),
totalPaid: formatCurrency(Number(dbCustomer.total_paid)),
});
}
}

View File

@ -0,0 +1,33 @@
export default class Customer {
id: string;
name: string;
email: string;
imageUrl: string;
totalInvoices: string;
totalPending: string;
totalPaid: string;
constructor({
id,
email,
imageUrl,
name,
totalInvoices,
totalPaid,
totalPending,
}: Customer) {
this.id = id;
this.name = name;
this.email = email;
this.imageUrl = imageUrl;
this.totalInvoices = totalInvoices;
this.totalPaid = totalPaid;
this.totalPending = totalPending;
}
}

View File

@ -0,0 +1,9 @@
import ApiTask from "@/feature/common/data/api-task";
import Customer from "@/feature/core/customer/domain/entity/customer";
export default interface CustomerRepo {
fetchList(query: string): ApiTask<Customer[]>;
fetchCustomersAmount(): Promise<number>;
}
export const customerRepoKey = "customerRepoKey";

View File

@ -0,0 +1,11 @@
import "server-only";
import serverDi from "@/feature/common/server-di";
import { customerKey } from "@/feature/core/customer/customer-key";
import CustomerRepo, {
customerRepoKey,
} from "@/feature/core/customer/domain/i-repo/customer-repo";
export default async function fetchCustomersAmountUsecase(): Promise<number> {
const repo = serverDi(customerKey).resolve<CustomerRepo>(customerRepoKey);
return repo.fetchCustomersAmount();
}

View File

@ -0,0 +1,16 @@
import "server-only";
import { ApiEither } from "@/feature/common/data/api-task";
import serverDi from "@/feature/common/server-di";
import { customerKey } from "@/feature/core/customer/customer-key";
import Customer from "@/feature/core/customer/domain/entity/customer";
import CustomerRepo, {
customerRepoKey,
} from "@/feature/core/customer/domain/i-repo/customer-repo";
export default function fetchCustomersUsecase(
query: string,
): Promise<ApiEither<Customer[]>> {
const repo = serverDi(customerKey).resolve<CustomerRepo>(customerRepoKey);
return repo.fetchList(query)();
}

View File

@ -0,0 +1,11 @@
import di from "@/bootstrap/di/init-di";
import invoiceDbRepo from "@/feature/core/invoice/data/repo/invoice-db-repo";
import { invoiceRepoKey } from "@/feature/core/invoice/domain/i-repo/invoice-repo";
import { DependencyContainer } from "tsyringe";
export default function getInvoiceDi(): DependencyContainer {
const invoiceDi = di.createChildContainer();
invoiceDi.register(invoiceRepoKey, invoiceDbRepo);
return invoiceDi;
}

View File

@ -0,0 +1,69 @@
import { sql } from "@/bootstrap/boundaries/db/db";
import ApiTask from "@/feature/common/data/api-task";
import { failureOr } from "@/feature/common/failures/failure-helpers";
import NetworkFailure from "@/feature/common/failures/network-failure";
import { formatCurrency } from "@/feature/common/feature-helpers";
import InvoiceRepo from "@/feature/core/invoice/domain/i-repo/invoice-repo";
import { InvoiceParam } from "@/feature/core/invoice/domain/param/invoice-param";
import InvoiceStatusSummary from "@/feature/core/invoice/domain/value-object/invoice-status";
import { pipe } from "fp-ts/lib/function";
import { tryCatch } from "fp-ts/lib/TaskEither";
import postgres from "postgres";
type InvoiceSummaryDbResponse = { paid: string; pending: string };
export default class InvoiceDbRepo implements InvoiceRepo {
async fetchAllInvoicesAmount(): Promise<number> {
const data = (await sql`SELECT COUNT(*) FROM invoices`) as postgres.RowList<
unknown[]
>;
return data.count ?? 0;
}
createInvoice(params: InvoiceParam): ApiTask<string> {
return pipe(
tryCatch(
async () => {
const firstCustomerIdDb = await sql`SELECT
id FROM customers
ORDER BY id DESC
LIMIT 1
`;
const customerId = firstCustomerIdDb.at(0)?.id;
if (!customerId) throw new Error("There is no customer");
const { amount, status } = params;
const amountInCents = amount * 100;
const date = new Date().toISOString().split("T")[0];
// Insert data into the database
const result = await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
RETURNING id
`;
return result.at(0)?.id ?? "";
},
(l) => failureOr(l, new NetworkFailure(l as Error)),
),
);
}
async fetchInvoicesStatusSummary(): Promise<InvoiceStatusSummary> {
const invoiceStatusPromise = (await sql`SELECT
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS "paid",
SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS "pending"
FROM invoices`) as postgres.RowList<InvoiceSummaryDbResponse[]>;
return this.invoiceSummaryDto(invoiceStatusPromise.at(0));
}
private invoiceSummaryDto(
dbResponse?: InvoiceSummaryDbResponse,
): InvoiceStatusSummary {
return new InvoiceStatusSummary({
paid: formatCurrency(Number(dbResponse?.paid ?? "0")),
pending: formatCurrency(Number(dbResponse?.pending ?? "0")),
});
}
}

View File

@ -0,0 +1,11 @@
import ApiTask from "@/feature/common/data/api-task";
import { InvoiceParam } from "@/feature/core/invoice/domain/param/invoice-param";
import InvoiceStatusSummary from "@/feature/core/invoice/domain/value-object/invoice-status";
export default interface InvoiceRepo {
fetchAllInvoicesAmount(): Promise<number>;
fetchInvoicesStatusSummary(): Promise<InvoiceStatusSummary>;
createInvoice(params: InvoiceParam): ApiTask<string>;
}
export const invoiceRepoKey = "invoiceRepoKey";

View File

@ -0,0 +1,12 @@
import { z } from "zod";
export const invoiceSchema = z.object({
amount: z.coerce
.number()
.gt(0, { message: "Please enter an amount greater than $0." }),
status: z.enum(["pending", "paid"], {
invalid_type_error: "Please select an invoice status.",
}),
});
export type InvoiceParam = z.infer<typeof invoiceSchema>;

View File

@ -0,0 +1,32 @@
"use server";
import { ApiEither } from "@/feature/common/data/api-task";
import ParamsFailure from "@/feature/common/failures/params-failure";
import serverDi from "@/feature/common/server-di";
import InvoiceRepo, {
invoiceRepoKey,
} from "@/feature/core/invoice/domain/i-repo/invoice-repo";
import {
InvoiceParam,
invoiceSchema,
} from "@/feature/core/invoice/domain/param/invoice-param";
import { invoiceModuleKey } from "@/feature/core/invoice/invoice-module-key";
import { pipe } from "fp-ts/lib/function";
import { chain, fromNullable, left, map, right } from "fp-ts/lib/TaskEither";
export default async function createInvoiceUsecase(
params: InvoiceParam,
): Promise<ApiEither<string>> {
const repo = serverDi(invoiceModuleKey).resolve<InvoiceRepo>(invoiceRepoKey);
return pipe(
fromNullable(new ParamsFailure())(params),
map((params) => invoiceSchema.safeParse(params)),
chain((params) => {
const isParamsValid = invoiceSchema.safeParse(params);
if (!isParamsValid.success) left(new ParamsFailure());
return right(params.data as InvoiceParam);
}),
chain((params) => repo.createInvoice(params)),
)();
}

View File

@ -0,0 +1,12 @@
import "server-only";
import serverDi from "@/feature/common/server-di";
import InvoiceRepo, {
invoiceRepoKey,
} from "@/feature/core/invoice/domain/i-repo/invoice-repo";
import { invoiceModuleKey } from "@/feature/core/invoice/invoice-module-key";
export default function fetchAllInvoicesAmountUsecase(): Promise<number> {
const repo = serverDi(invoiceModuleKey).resolve<InvoiceRepo>(invoiceRepoKey);
return repo.fetchAllInvoicesAmount();
}

View File

@ -0,0 +1,12 @@
import "server-only";
import serverDi from "@/feature/common/server-di";
import InvoiceRepo, {
invoiceRepoKey,
} from "@/feature/core/invoice/domain/i-repo/invoice-repo";
import InvoiceStatusSummary from "@/feature/core/invoice/domain/value-object/invoice-status";
import { invoiceModuleKey } from "@/feature/core/invoice/invoice-module-key";
export default function fetchInvoicesStatusSummary(): Promise<InvoiceStatusSummary> {
const repo = serverDi(invoiceModuleKey).resolve<InvoiceRepo>(invoiceRepoKey);
return repo.fetchInvoicesStatusSummary();
}

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