first commit

This commit is contained in:
Admin 2025-11-13 08:50:09 +07:00
parent 57a3e75ace
commit 09e47cac29
38 changed files with 4348 additions and 160 deletions

2
.gitignore vendored
View File

@ -39,3 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma

24
components.json Normal file
View File

@ -0,0 +1,24 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@shadcn-editor": "https://shadcn-editor.vercel.app/r/{name}.json"
}
}

2397
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,19 +9,40 @@
"lint": "eslint"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@lexical/react": "^0.38.2",
"@lexical/rich-text": "^0.38.2",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jodit-react": "^5.2.38",
"lexical": "^0.38.2",
"lucide-react": "^0.553.0",
"mysql2": "^3.15.3",
"next": "15.5.6",
"next-themes": "^0.4.6",
"react": "19.1.0",
"react-dom": "19.1.0",
"next": "15.5.6"
"react-hook-form": "^7.66.0",
"reflect-metadata": "^0.2.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"typeorm": "^0.3.27",
"zod": "^4.1.12"
},
"devDependencies": {
"typescript": "^5",
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9",
"eslint-config-next": "15.5.6",
"@eslint/eslintrc": "^3"
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

View File

@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { initDB } from "@/utils/init-db";
import { AppDataSource } from "@/utils/data-source";
import { Sku } from "@/entities/Sku";
export async function GET(req: NextRequest) {
await initDB();
const repo = AppDataSource.getRepository(Sku);
// Lấy id từ pathname
// /api/skus/123 => ["", "api", "skus", "123"]
const segments = req.nextUrl.pathname.split("/");
const id = Number(segments[segments.length - 1]);
if (!id) {
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
}
const sku = await repo.findOne({
where: { id },
select: [
"id",
"sku",
"status",
"normalized_title",
"normalized_short_description",
"normalized_html",
],
});
if (!sku) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(sku);
}

71
src/app/api/skus/route.ts Normal file
View File

@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { initDB } from "@/utils/init-db";
import { AppDataSource } from "@/utils/data-source";
import { Sku } from "@/entities/Sku";
export async function GET(req: NextRequest) {
await initDB();
const repo = AppDataSource.getRepository(Sku);
const { searchParams } = new URL(req.url);
const page = Number(searchParams.get("page") || 1);
const limit = Number(searchParams.get("limit") || 10);
const search = searchParams.get("search")?.trim() || "";
const skip = (page - 1) * limit;
const queryBuilder = repo.createQueryBuilder("sku");
if (search) {
queryBuilder.where("sku.sku LIKE :search", { search: `%${search}%` });
}
queryBuilder
.select([
"sku.id",
"sku.sku",
"sku.normalized_title",
"sku.normalized_short_description",
"sku.status",
"sku.normalized_html",
])
.orderBy("sku.created_at", "DESC")
.addOrderBy("sku.id", "DESC") // <--- quan trọng, tránh trùng record
.skip(skip)
.take(limit);
const [data, total] = await queryBuilder.getManyAndCount();
return NextResponse.json({
data,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
});
}
export async function PUT(req: NextRequest) {
await initDB();
const body = await req.json();
const repo = AppDataSource.getRepository(Sku);
const sku = await repo.findOneBy({ id: body.id });
if (!sku) return NextResponse.json({ error: "Not found" }, { status: 404 });
const result = await repo.update({ id: sku.id }, { ...body });
if (!result)
return NextResponse.json(
{ error: "Can't update " + sku.sku },
{ status: 400 }
);
return NextResponse.json({
error: false,
message: `Updated ${sku.sku} success`,
});
}

View File

@ -0,0 +1,47 @@
"use client"
import { useState } from "react"
import { SerializedEditorState } from "lexical"
import { Editor } from "@/components/blocks/editor-00/editor"
export const initialValue = {
root: {
children: [
{
children: [
{
detail: 0,
format: 0,
mode: "normal",
style: "",
text: "Hello World 🚀",
type: "text",
version: 1,
},
],
direction: "ltr",
format: "",
indent: 0,
type: "paragraph",
version: 1,
},
],
direction: "ltr",
format: "",
indent: 0,
type: "root",
version: 1,
},
} as unknown as SerializedEditorState
export default function EditorPage() {
const [editorState, setEditorState] =
useState<SerializedEditorState>(initialValue)
return (
<Editor
editorSerializedState={editorState}
onSerializedChange={(value) => setEditorState(value)}
/>
)
}

View File

@ -1,26 +1,122 @@
@import "tailwindcss";
@import "tw-animate-css";
:root {
--background: #ffffff;
--foreground: #171717;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/sonner";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -25,9 +26,11 @@ export default function RootLayout({
return (
<html lang="en">
<body
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<Toaster expand={true} richColors={true} position="top-right" />
</body>
</html>
);

10
src/app/loading.tsx Normal file
View File

@ -0,0 +1,10 @@
import { Spinner } from "@/components/ui/spinner";
import * as React from "react";
export default function Loading() {
return (
<div className="w-screen h-screen flex items-center justify-center">
<Spinner className="size-20" />
</div>
);
}

View File

@ -1,103 +1,59 @@
import Image from "next/image";
import HomePagination from "@/components/home/home-pagination";
import Item from "@/components/home/item";
import SKUListSidebar from "@/components/home/sku-list-sidebar";
import { Sku } from "@/entities/Sku";
import axios from "@/lib/axios";
import { use } from "react";
const getData = async (query: { page: number; search: string }) => {
const { data } = await axios({
method: "GET",
url: "skus",
params: query,
});
return data || [];
};
export default function Home({
searchParams,
}: {
searchParams: Promise<{ page?: string; search?: string }>;
}) {
const query = use(searchParams);
const page = Number(query?.page) || 1;
const search = query?.search || "";
const data = use(getData({ page, search }));
export default function Home() {
return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] 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="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
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] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
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 font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
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 className="grid grid-cols-12 min-h-[1000px] px-5 gap-4 py-2 pb-20">
<div className="col-span-8 display flex flex-col gap-10">
{(data.data || []).map(
(
item: Pick<
Sku,
| "id"
| "normalized_title"
| "normalized_short_description"
| "sku"
| "normalized_html"
| "status"
>,
index: number
) => {
return <Item key={item?.sku || index} data={item} />;
}
)}
</div>
<div className="col-span-4 -my-2 border-l">
<div className="sticky z-50 top-0">
<SKUListSidebar data={data?.data} />
</div>
</main>
<footer className="row-start-3 flex gap-[24px] 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>
<HomePagination page={page} totalPages={data.pagination.totalPages} />
</div>
);
}

View File

@ -0,0 +1,61 @@
"use client"
import {
InitialConfigType,
LexicalComposer,
} from "@lexical/react/LexicalComposer"
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"
import { EditorState, SerializedEditorState } from "lexical"
import { editorTheme } from "@/components/editor/themes/editor-theme"
import { TooltipProvider } from "@/components/ui/tooltip"
import { nodes } from "./nodes"
import { Plugins } from "./plugins"
const editorConfig: InitialConfigType = {
namespace: "Editor",
theme: editorTheme,
nodes,
onError: (error: Error) => {
console.error(error)
},
}
export function Editor({
editorState,
editorSerializedState,
onChange,
onSerializedChange,
}: {
editorState?: EditorState
editorSerializedState?: SerializedEditorState
onChange?: (editorState: EditorState) => void
onSerializedChange?: (editorSerializedState: SerializedEditorState) => void
}) {
return (
<div className="bg-background overflow-hidden rounded-lg border shadow">
<LexicalComposer
initialConfig={{
...editorConfig,
...(editorState ? { editorState } : {}),
...(editorSerializedState
? { editorState: JSON.stringify(editorSerializedState) }
: {}),
}}
>
<TooltipProvider>
<Plugins />
<OnChangePlugin
ignoreSelectionChange={true}
onChange={(editorState) => {
onChange?.(editorState)
onSerializedChange?.(editorState.toJSON())
}}
/>
</TooltipProvider>
</LexicalComposer>
</div>
)
}

View File

@ -0,0 +1,11 @@
import { HeadingNode, QuoteNode } from "@lexical/rich-text"
import {
Klass,
LexicalNode,
LexicalNodeReplacement,
ParagraphNode,
TextNode,
} from "lexical"
export const nodes: ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement> =
[HeadingNode, ParagraphNode, TextNode, QuoteNode]

View File

@ -0,0 +1,36 @@
import { useState } from "react"
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"
import { ContentEditable } from "@/components/editor/editor-ui/content-editable"
export function Plugins() {
const [floatingAnchorElem, setFloatingAnchorElem] =
useState<HTMLDivElement | null>(null)
const onRef = (_floatingAnchorElem: HTMLDivElement) => {
if (_floatingAnchorElem !== null) {
setFloatingAnchorElem(_floatingAnchorElem)
}
}
return (
<div className="relative">
{/* toolbar plugins */}
<div className="relative">
<RichTextPlugin
contentEditable={
<div className="">
<div className="" ref={onRef}>
<ContentEditable placeholder={"Start typing ..."} />
</div>
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
{/* editor plugins */}
</div>
{/* actions plugins */}
</div>
)
}

View File

@ -0,0 +1,34 @@
import { JSX } from "react"
import { ContentEditable as LexicalContentEditable } from "@lexical/react/LexicalContentEditable"
type Props = {
placeholder: string
className?: string
placeholderClassName?: string
}
export function ContentEditable({
placeholder,
className,
placeholderClassName,
}: Props): JSX.Element {
return (
<LexicalContentEditable
className={
className ??
`ContentEditable__root relative block min-h-72 min-h-full overflow-auto px-8 py-4 focus:outline-none`
}
aria-placeholder={placeholder}
placeholder={
<div
className={
placeholderClassName ??
`text-muted-foreground pointer-events-none absolute top-0 left-0 overflow-hidden px-8 py-[18px] text-ellipsis select-none`
}
>
{placeholder}
</div>
}
/>
)
}

View File

@ -0,0 +1,91 @@
.EditorTheme__code {
background-color: transparent;
font-family: Menlo, Consolas, Monaco, monospace;
display: block;
padding: 8px 8px 8px 52px;
line-height: 1.53;
font-size: 13px;
margin: 0;
margin-top: 8px;
margin-bottom: 8px;
overflow-x: auto;
border: 1px solid #ccc;
position: relative;
border-radius: 8px;
tab-size: 2;
}
.EditorTheme__code:before {
content: attr(data-gutter);
position: absolute;
background-color: transparent;
border-right: 1px solid #ccc;
left: 0;
top: 0;
padding: 8px;
color: #777;
white-space: pre-wrap;
text-align: right;
min-width: 25px;
}
.EditorTheme__table {
border-collapse: collapse;
border-spacing: 0;
overflow-y: scroll;
overflow-x: scroll;
table-layout: fixed;
width: fit-content;
width: 100%;
margin: 0px 0px 30px 0px;
}
.EditorTheme__tokenComment {
color: slategray;
}
.EditorTheme__tokenPunctuation {
color: #999;
}
.EditorTheme__tokenProperty {
color: #905;
}
.EditorTheme__tokenSelector {
color: #690;
}
.EditorTheme__tokenOperator {
color: #9a6e3a;
}
.EditorTheme__tokenAttr {
color: #07a;
}
.EditorTheme__tokenVariable {
color: #e90;
}
.EditorTheme__tokenFunction {
color: #dd4a68;
}
.Collapsible__container {
background-color: var(--background);
border: 1px solid #ccc;
border-radius: 0.5rem;
margin-bottom: 0.5rem;
}
.Collapsible__title{
padding: 0.25rem;
padding-left: 1rem;
position: relative;
font-weight: bold;
outline: none;
cursor: pointer;
list-style-type: disclosure-closed;
list-style-position: inside;
}
.Collapsible__title p{
display: inline-flex;
}
.Collapsible__title::marker{
color: lightgray;
}
.Collapsible__container[open] >.Collapsible__title {
list-style-type: disclosure-open;
}

View File

@ -0,0 +1,130 @@
import { EditorThemeClasses } from "lexical"
import "./editor-theme.css"
export const editorTheme: EditorThemeClasses = {
ltr: "text-left",
rtl: "text-right",
heading: {
h1: "scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl",
h2: "scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0",
h3: "scroll-m-20 text-2xl font-semibold tracking-tight",
h4: "scroll-m-20 text-xl font-semibold tracking-tight",
h5: "scroll-m-20 text-lg font-semibold tracking-tight",
h6: "scroll-m-20 text-base font-semibold tracking-tight",
},
paragraph: "leading-7 [&:not(:first-child)]:mt-6",
quote: "mt-6 border-l-2 pl-6 italic",
link: "text-blue-600 hover:underline hover:cursor-pointer",
list: {
checklist: "relative",
listitem: "mx-8",
listitemChecked:
'relative mx-2 px-6 list-none outline-none line-through before:content-[""] before:w-4 before:h-4 before:top-0.5 before:left-0 before:cursor-pointer before:block before:bg-cover before:absolute before:border before:border-primary before:rounded before:bg-primary before:bg-no-repeat after:content-[""] after:cursor-pointer after:border-white after:border-solid after:absolute after:block after:top-[6px] after:w-[3px] after:left-[7px] after:right-[7px] after:h-[6px] after:rotate-45 after:border-r-2 after:border-b-2 after:border-l-0 after:border-t-0',
listitemUnchecked:
'relative mx-2 px-6 list-none outline-none before:content-[""] before:w-4 before:h-4 before:top-0.5 before:left-0 before:cursor-pointer before:block before:bg-cover before:absolute before:border before:border-primary before:rounded',
nested: {
listitem: "list-none before:hidden after:hidden",
},
ol: "m-0 p-0 list-decimal [&>li]:mt-2",
olDepth: [
"list-outside !list-decimal",
"list-outside !list-[upper-roman]",
"list-outside !list-[lower-roman]",
"list-outside !list-[upper-alpha]",
"list-outside !list-[lower-alpha]",
],
ul: "m-0 p-0 list-outside [&>li]:mt-2",
ulDepth: [
"list-outside !list-disc",
"list-outside !list-disc",
"list-outside !list-disc",
"list-outside !list-disc",
"list-outside !list-disc",
],
},
hashtag: "text-blue-600 bg-blue-100 rounded-md px-1",
text: {
bold: "font-bold",
code: "bg-gray-100 p-1 rounded-md",
italic: "italic",
strikethrough: "line-through",
subscript: "sub",
superscript: "sup",
underline: "underline",
underlineStrikethrough: "underline line-through",
},
image: "relative inline-block user-select-none cursor-default editor-image",
inlineImage:
"relative inline-block user-select-none cursor-default inline-editor-image",
keyword: "text-purple-900 font-bold",
code: "EditorTheme__code",
codeHighlight: {
atrule: "EditorTheme__tokenAttr",
attr: "EditorTheme__tokenAttr",
boolean: "EditorTheme__tokenProperty",
builtin: "EditorTheme__tokenSelector",
cdata: "EditorTheme__tokenComment",
char: "EditorTheme__tokenSelector",
class: "EditorTheme__tokenFunction",
"class-name": "EditorTheme__tokenFunction",
comment: "EditorTheme__tokenComment",
constant: "EditorTheme__tokenProperty",
deleted: "EditorTheme__tokenProperty",
doctype: "EditorTheme__tokenComment",
entity: "EditorTheme__tokenOperator",
function: "EditorTheme__tokenFunction",
important: "EditorTheme__tokenVariable",
inserted: "EditorTheme__tokenSelector",
keyword: "EditorTheme__tokenAttr",
namespace: "EditorTheme__tokenVariable",
number: "EditorTheme__tokenProperty",
operator: "EditorTheme__tokenOperator",
prolog: "EditorTheme__tokenComment",
property: "EditorTheme__tokenProperty",
punctuation: "EditorTheme__tokenPunctuation",
regex: "EditorTheme__tokenVariable",
selector: "EditorTheme__tokenSelector",
string: "EditorTheme__tokenSelector",
symbol: "EditorTheme__tokenProperty",
tag: "EditorTheme__tokenProperty",
url: "EditorTheme__tokenOperator",
variable: "EditorTheme__tokenVariable",
},
characterLimit: "!bg-destructive/50",
table: "EditorTheme__table w-fit overflow-scroll border-collapse",
tableCell:
'EditorTheme__tableCell w-24 relative border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right"',
tableCellActionButton:
"EditorTheme__tableCellActionButton bg-background block border-0 rounded-2xl w-5 h-5 text-foreground cursor-pointer",
tableCellActionButtonContainer:
"EditorTheme__tableCellActionButtonContainer block right-1 top-1.5 absolute z-10 w-5 h-5",
tableCellEditing: "EditorTheme__tableCellEditing rounded-sm shadow-sm",
tableCellHeader:
"EditorTheme__tableCellHeader bg-muted border px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right",
tableCellPrimarySelected:
"EditorTheme__tableCellPrimarySelected border border-primary border-solid block h-[calc(100%-2px)] w-[calc(100%-2px)] absolute -left-[1px] -top-[1px] z-10 ",
tableCellResizer:
"EditorTheme__tableCellResizer absolute -right-1 h-full w-2 cursor-ew-resize z-10 top-0",
tableCellSelected: "EditorTheme__tableCellSelected bg-muted",
tableCellSortedIndicator:
"EditorTheme__tableCellSortedIndicator block opacity-50 bsolute bottom-0 left-0 w-full h-1 bg-muted",
tableResizeRuler:
"EditorTheme__tableCellResizeRuler block absolute w-[1px] h-full bg-primary top-0",
tableRowStriping:
"EditorTheme__tableRowStriping m-0 border-t p-0 even:bg-muted",
tableSelected: "EditorTheme__tableSelected ring-2 ring-primary ring-offset-2",
tableSelection: "EditorTheme__tableSelection bg-transparent",
layoutItem: "border border-dashed px-4 py-2",
layoutContainer: "grid gap-2.5 my-2.5 mx-0",
autocomplete: "text-muted-foreground",
blockCursor: "",
embedBlock: {
base: "user-select-none",
focus: "ring-2 ring-primary ring-offset-2",
},
hr: 'p-0.5 border-none my-1 mx-0 cursor-pointer after:content-[""] after:block after:h-0.5 after:bg-muted selected:ring-2 selected:ring-primary selected:ring-offset-2 selected:user-select-none',
indent: "[--lexical-indent-base-value:40px]",
mark: "",
markOverlap: "",
}

View File

@ -0,0 +1,91 @@
"use client";
import { usePathname } from "next/navigation";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "../ui/pagination";
interface Props {
page: number;
totalPages: number;
}
export default function HomePagination({ page, totalPages }: Props) {
const pagesToShow = 3; // hiển thị tối đa 5 nút
let startPage = Math.max(1, page - Math.floor(pagesToShow / 2));
let endPage = startPage + pagesToShow - 1;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - pagesToShow + 1);
}
const pages = [];
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
const pathname = usePathname();
const getPageLink = (p: number) => `${pathname}?page=${p}`;
const prevPage = page > 1 ? page - 1 : 1;
const nextPage = page < totalPages ? page + 1 : totalPages;
return (
<div className="sticky z-50 bottom-0 bg-white w-full flex items-center justify-center py-2 col-span-12 border-t mt-10">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href={getPageLink(prevPage)} />
</PaginationItem>
{startPage > 1 && (
<>
<PaginationItem>
<PaginationLink href={getPageLink(1)}>1</PaginationLink>
</PaginationItem>
{startPage > 2 && (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}
</>
)}
{pages.map((p) => (
<PaginationItem key={p}>
<PaginationLink href={getPageLink(p)} isActive={p === page}>
{p}
</PaginationLink>
</PaginationItem>
))}
{endPage < totalPages && (
<>
{endPage < totalPages - 1 && (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}
<PaginationItem>
<PaginationLink href={getPageLink(totalPages)}>
{totalPages}
</PaginationLink>
</PaginationItem>
</>
)}
<PaginationItem>
<PaginationNext href={getPageLink(nextPage)} />
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
);
}

View File

@ -0,0 +1,201 @@
"use client";
import { Sku } from "@/entities/Sku";
import axios from "@/lib/axios";
import { cn } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { Label } from "@radix-ui/react-label";
import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../ui/card";
import { FormField, FormMessage } from "../ui/form";
import { Input } from "../ui/input";
import { Spinner } from "../ui/spinner";
import { Textarea } from "../ui/textarea";
import UncontrolledJoditEditor from "./uncontrolled-jodit-editor";
export interface IItemProps {
data: Pick<
Sku,
| "id"
| "normalized_title"
| "normalized_short_description"
| "normalized_html"
| "sku"
| "status"
>;
}
const ItemSchema = z.object({
normalized_title: z
.string()
.min(1, "Title must be at least 1 character long"),
normalized_short_description: z.string().optional(),
status: z.string(),
normalized_html: z.string(),
});
type ItemFormValues = z.infer<typeof ItemSchema>;
export default function Item({ data }: IItemProps) {
const [loading, setLoading] = useState(false);
const form = useForm<ItemFormValues>({
resolver: zodResolver(ItemSchema),
defaultValues: {
normalized_title: data.normalized_title || "",
normalized_short_description: data.normalized_short_description || "",
status: data.status,
normalized_html: data.normalized_html,
},
});
const getNewInfo = async () => {
try {
const res = await axios.get(`skus/${data.id}`);
if (res?.data) {
form.reset(res.data);
}
} catch (error) {
console.log(error);
toast.error("Failed to refresh SKU data");
}
};
const handlePass = async () => {
setLoading(true);
try {
const res = await axios.put("skus", {
...data,
status: "pass",
});
if (res?.data) {
toast.success(res.data?.message || "Marked as Pass");
await getNewInfo();
}
} catch (error) {
console.log(error);
toast.error("Failed to update status");
} finally {
setLoading(false);
}
};
const onSubmit = async (values: ItemFormValues) => {
setLoading(true);
try {
const res = await axios.put("skus", { ...data, ...values });
if (res?.data) {
toast.success(res.data?.message || "Updated successfully");
await getNewInfo();
}
} catch (error) {
console.log(error);
toast.error("Failed to save data");
} finally {
setLoading(false);
}
};
return (
<FormProvider {...form}>
<form
className="relative"
id={data.sku}
onSubmit={form.handleSubmit(onSubmit)}
>
<Card
className={cn("w-full h-fit", {
["border-green-600"]: form.getValues()?.status === "pass",
["border-red-600"]: form.getValues()?.status === "error",
})}
>
<CardHeader>
<CardTitle>{data.sku}</CardTitle>
<CardDescription>
Status:{" "}
<Badge className="capitalize" variant={"outline"}>
{form.getValues()?.status}
</Badge>
</CardDescription>
<CardAction>
<Button
disabled={form.getValues()?.status === "pass"}
onClick={handlePass}
type="button"
variant="outline"
>
Pass
</Button>
<Button className="ml-2" type="submit">
Save
</Button>
</CardAction>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-6">
<FormField
control={form.control}
name="normalized_title"
render={({ field, fieldState }) => (
<div className="grid gap-2">
<Label htmlFor={field.name}>Title</Label>
<Input {...field} placeholder="Some text..." />
<FormMessage>{fieldState.error?.message}</FormMessage>
</div>
)}
/>
<FormField
control={form.control}
name="normalized_short_description"
render={({ field, fieldState }) => (
<div className="grid gap-2">
<Label htmlFor={field.name}>Short Description</Label>
<Textarea
{...field}
placeholder="Description..."
className="resize-none"
/>
<FormMessage>{fieldState.error?.message}</FormMessage>
</div>
)}
/>
<FormField
control={form.control}
name="normalized_html"
render={({ field, fieldState }) => (
<div className="grid gap-2 overflow-x-auto">
<Label htmlFor={field.name}>Description</Label>
<UncontrolledJoditEditor field={field} />
<FormMessage>{fieldState.error?.message}</FormMessage>
</div>
)}
/>
</div>
</CardContent>
</Card>
{/* Overlay spinner khi loading */}
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 z-50 rounded-xl">
<Spinner className="h-12 w-12 text-white animate-spin" />
</div>
)}
</form>
</FormProvider>
);
}

View File

@ -0,0 +1,150 @@
"use client";
import { Input } from "@/components/ui/input";
import { Sku } from "@/entities/Sku";
import { ChevronRight, Search, X } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { Spinner } from "../ui/spinner";
export default function SKUListSidebar({
data,
}: {
data: Pick<
Sku,
| "id"
| "normalized_title"
| "normalized_short_description"
| "sku"
| "status"
>[];
}) {
const searchParams = useSearchParams();
const [searchQuery, setSearchQuery] = useState("");
const [selectedSKU, setSelectedSKU] = useState<string | null>(null);
const router = useRouter();
const [isPending, startTransition] = useTransition();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
startTransition(() => {
if (!searchQuery) {
router.push(`/`);
} else {
router.push(`/?search=${encodeURIComponent(searchQuery)}`);
}
});
};
useEffect(() => {
// Nếu URL search thay đổi, update state
setSearchQuery(searchParams.get("search") || "");
}, [searchParams]);
return (
<>
<aside className="w-full border-r border-border bg-card flex flex-col h-screen">
{/* Header */}
<div className="border-b border-border p-4">
<h2 className="text-lg font-semibold text-foreground mb-4">
Products
</h2>
{/* Search Input */}
<form onSubmit={handleSubmit} className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
name="search"
placeholder="Search products..."
className="pl-10 bg-background border-border"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery?.length > 0 && (
<X
onClick={() => {
setSearchQuery("");
router.push(`/`);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground cursor-pointer"
/>
)}
</form>
</div>
{/* SKU List */}
<div className="flex-1 overflow-y-auto">
<nav className="p-2 space-y-1">
{data.length > 0 ? (
data.map((item) => (
<div
key={item.sku}
onClick={(e) => {
e.preventDefault(); // chặn default jump
setSelectedSKU(item.sku);
const el = document.getElementById(item.sku);
if (el) {
el.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
}}
className={`group flex items-start gap-3 px-3 py-3 rounded-lg transition-colors cursor-pointer ${
selectedSKU === item.sku
? "bg-primary/10 border border-primary/20"
: "hover:bg-muted border border-transparent"
}`}
>
{/* SKU Badge */}
<div className="inline-flex items-center justify-center px-3 py-1 rounded-md bg-primary/20">
<span className="text-xs font-semibold text-primary truncate">
{item.sku}
</span>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-foreground truncate">
{item.normalized_title}
</p>
<span
className={`text-xs px-2 py-1 rounded-full whitespace-nowrap capitalize ${
item.status === "pass"
? "bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-400"
: "bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-400"
}`}
>
{item.status}
</span>
</div>
<p className="text-xs text-muted-foreground truncate mt-1">
{item.normalized_short_description}
</p>
</div>
{/* Chevron */}
<ChevronRight className="w-4 h-4 text-muted-foreground group-hover:text-foreground mt-1 transition-colors" />
</div>
))
) : (
<div className="px-3 py-8 text-center">
<p className="text-sm text-muted-foreground">
No products found
</p>
</div>
)}
</nav>
</div>
</aside>
{isPending && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<Spinner className="h-6 w-6 text-white" />
</div>
)}
</>
);
}

View File

@ -0,0 +1,43 @@
"use client";
/* eslint-disable @typescript-eslint/no-explicit-any */
import dynamic from "next/dynamic";
import { useEffect, useRef, useState, useMemo } from "react";
const JoditEditor = dynamic(() => import("jodit-react"), {
ssr: false,
});
export default function UncontrolledJoditEditor({ field }: { field: any }) {
const editor = useRef(null);
const [content, setContent] = useState(field.value || "");
// Sync lại khi form reset dữ liệu
useEffect(() => {
setContent(field.value || "");
}, [field.value]);
const config = useMemo(
() => ({
readonly: false,
height: 400,
toolbarButtonSize: "middle",
uploader: { insertImageAsBase64URI: true },
toolbarAdaptive: false,
askBeforePasteHTML: false,
askBeforePasteFromWord: false,
defaultActionOnPaste: "insert_clear_html",
}),
[]
);
return (
<JoditEditor
ref={editor}
value={content}
config={config as any}
onBlur={(newContent) => {
setContent(newContent);
field.onChange(newContent);
}}
/>
);
}

View File

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

167
src/components/ui/form.tsx Normal file
View File

@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@ -0,0 +1,40 @@
"use client"
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@ -0,0 +1,16 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

52
src/entities/Sku.ts Normal file
View File

@ -0,0 +1,52 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
@Entity({ name: "skus" })
export class Sku {
@PrimaryGeneratedColumn("increment")
id!: number;
@Column({ type: "varchar", length: 255, unique: true })
sku!: string;
@Column({
type: "enum",
enum: ["pass", "done", "error", "pending", "processing"],
})
status!: string;
@Column({ type: "json", nullable: true })
source_data?: string;
@Column({ type: "longtext", nullable: true })
normalized_html?: string;
@Column({ type: "varchar", length: 255, nullable: true })
normalized_title?: string;
@Column({ type: "varchar", length: 255, nullable: true })
normalized_short_description?: string;
@Column({ type: "text", nullable: true })
error_message?: string;
@CreateDateColumn({
type: "timestamp",
precision: 0,
default: () => "CURRENT_TIMESTAMP",
})
created_at!: Date;
@UpdateDateColumn({
type: "timestamp",
precision: 0,
default: () => "CURRENT_TIMESTAMP",
onUpdate: "CURRENT_TIMESTAMP",
})
updated_at!: Date;
}

10
src/lib/axios.ts Normal file
View File

@ -0,0 +1,10 @@
import ax from "axios";
const axios = ax.create({
baseURL: `${process.env.NEXT_PUBLIC_API_URL}/api` || "api",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
});
export default axios;

6
src/lib/utils.ts Normal file
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))
}

15
src/utils/data-source.ts Normal file
View File

@ -0,0 +1,15 @@
import "reflect-metadata";
import { DataSource } from "typeorm";
import { Sku } from "../entities/Sku";
export const AppDataSource = new DataSource({
type: "mysql",
host: process.env.MYSQL_HOST,
port: Number(process.env.MYSQL_PORT),
username: process.env.MYSQL_USER,
password: process.env.MYSQL_PASSWORD,
database: process.env.MYSQL_DATABASE,
synchronize: true, // chỉ dev, tự tạo bảng
logging: false,
entities: [Sku],
});

8
src/utils/init-db.ts Normal file
View File

@ -0,0 +1,8 @@
import { AppDataSource } from "./data-source";
export const initDB = async () => {
if (!AppDataSource.isInitialized) {
await AppDataSource.initialize();
console.log("Database connected");
}
};

View File

@ -13,6 +13,8 @@
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"plugins": [
{
"name": "next"