Initial commit
This commit is contained in:
commit
74b27f9341
|
|
@ -0,0 +1,41 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
"use client";
|
||||||
|
import { AddIcon, CloudUploadIcon, TimesIcon } from "@/components/icons";
|
||||||
|
import AdvertiseTable from "@/components/table/advertise/advertise-table";
|
||||||
|
import * as z from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { useState } from "react";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
import withReactContent from "sweetalert2-react-content";
|
||||||
|
import { useDropzone } from "react-dropzone";
|
||||||
|
import { close, error, loading } from "@/config/swal";
|
||||||
|
import useDisclosure from "@/components/useDisclosure";
|
||||||
|
import { createAdvertise } from "@/service/advertisement";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
const createArticleSchema = z.object({
|
||||||
|
title: z.string().min(2, {
|
||||||
|
message: "Judul harus diisi",
|
||||||
|
}),
|
||||||
|
url: z.string().min(1, {
|
||||||
|
message: "Link harus diisi",
|
||||||
|
}),
|
||||||
|
description: z.string().min(2, {
|
||||||
|
message: "Deskripsi harus diisi",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function AdvertisePage() {
|
||||||
|
const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure();
|
||||||
|
const MySwal = withReactContent(Swal);
|
||||||
|
|
||||||
|
const [refresh, setRefresh] = useState(false);
|
||||||
|
const [placement, setPlacement] = useState("banner");
|
||||||
|
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
|
||||||
|
const formOptions = {
|
||||||
|
resolver: zodResolver(createArticleSchema),
|
||||||
|
defaultValues: { title: "", description: "", url: "" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps } = useDropzone({
|
||||||
|
onDrop: (acceptedFiles) => {
|
||||||
|
setFiles(acceptedFiles.map((file) => Object.assign(file)));
|
||||||
|
},
|
||||||
|
maxFiles: 1,
|
||||||
|
accept:
|
||||||
|
placement === "banner"
|
||||||
|
? {
|
||||||
|
"image/*": [],
|
||||||
|
"video/*": [],
|
||||||
|
}
|
||||||
|
: { "image/*": [] },
|
||||||
|
});
|
||||||
|
type UserSettingSchema = z.infer<typeof createArticleSchema>;
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<UserSettingSchema>(formOptions);
|
||||||
|
|
||||||
|
const onSubmit = async (values: z.infer<typeof createArticleSchema>) => {
|
||||||
|
loading();
|
||||||
|
const formData = {
|
||||||
|
title: values.title,
|
||||||
|
description: values.description,
|
||||||
|
placement: placement,
|
||||||
|
redirectLink: values.url,
|
||||||
|
};
|
||||||
|
const res = await createAdvertise(formData);
|
||||||
|
if (res?.error) {
|
||||||
|
error(res?.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// const idNow = res?.data?.data?.id;
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
const formFiles = new FormData();
|
||||||
|
formFiles.append("file", files[0]);
|
||||||
|
// const resFile = await createMediaFileAdvertise(idNow, formFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
close();
|
||||||
|
MySwal.fire({
|
||||||
|
title: "Sukses",
|
||||||
|
icon: "success",
|
||||||
|
confirmButtonColor: "#3085d6",
|
||||||
|
confirmButtonText: "OK",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
setRefresh(!refresh);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFile = (file: File) => {
|
||||||
|
const uploadedFiles = files;
|
||||||
|
const filtered = uploadedFiles.filter((i) => i.name !== file.name);
|
||||||
|
setFiles([...filtered]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-hidden overflow-y-scroll">
|
||||||
|
<div className="px-2 md:px-4 md:py-4 w-full">
|
||||||
|
<div className="bg-white shadow-lg dark:bg-[#18181b] rounded-xl p-3">
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
className="bg-[#F07C00] text-white w-full lg:w-fit"
|
||||||
|
onClick={onOpen}
|
||||||
|
>
|
||||||
|
Buat Baru
|
||||||
|
<AddIcon />
|
||||||
|
</Button>
|
||||||
|
<AdvertiseTable triggerRefresh={refresh} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Advertise</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm">Judul</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="title"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full border rounded-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors?.title && (
|
||||||
|
<p className="text-red-400 text-sm">{errors.title?.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm">Deskripsi</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="description"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full border rounded-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors?.description && (
|
||||||
|
<p className="text-red-400 text-sm">
|
||||||
|
{errors.description?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm">Link</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="url"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Input
|
||||||
|
id="url"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full border rounded-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors?.url && (
|
||||||
|
<p className="text-red-400 text-sm">{errors.url?.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm mt-3">Penempatan</p>
|
||||||
|
<RadioGroup
|
||||||
|
value={placement}
|
||||||
|
onValueChange={setPlacement}
|
||||||
|
className="flex flex-row gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="banner" id="banner" />
|
||||||
|
<Label htmlFor="banner">Banner</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="jumbotron" id="jumbotron" />
|
||||||
|
<Label htmlFor="jumbotron">Jumbotron</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm mt-3">Thumbnail</p>
|
||||||
|
{files.length < 1 && (
|
||||||
|
<div {...getRootProps({ className: "dropzone" })}>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<div className="w-full text-center border-dashed border rounded-md py-[52px] flex items-center flex-col">
|
||||||
|
<CloudUploadIcon />
|
||||||
|
<h4 className="text-2xl font-medium mb-1 mt-3 text-card-foreground/80">
|
||||||
|
Tarik file disini atau klik untuk upload.
|
||||||
|
</h4>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
(Upload file dengan format .jpg, .jpeg, atau .png. Ukuran
|
||||||
|
maksimal 100mb.)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(files[0])}
|
||||||
|
className="w-[30%]"
|
||||||
|
alt="thumbnail"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleRemoveFile(files[0])}
|
||||||
|
>
|
||||||
|
<TimesIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="pt-4">
|
||||||
|
<Button type="submit">Simpan</Button>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Tutup
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import CreateArticleForm from "@/components/form/article/create-article-form";
|
||||||
|
|
||||||
|
export default function CreateArticle() {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-100 p-3 lg:p-8 dark:!bg-black overflow-y-auto">
|
||||||
|
<CreateArticleForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import EditArticleForm from "@/components/form/article/edit-article-form";
|
||||||
|
|
||||||
|
export default function DetailArticlePage() {
|
||||||
|
return (
|
||||||
|
<div className="">
|
||||||
|
{/* <div className="flex flex-row justify-between border-b-2 px-4 bg-white shadow-md">
|
||||||
|
<div className="flex flex-col gap-1 py-2">
|
||||||
|
<h1 className="font-bold text-[25px]">Article</h1>
|
||||||
|
<p className="text-[14px]">Article</p>
|
||||||
|
</div>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 20 20">
|
||||||
|
<path fill="currentColor" d="M5 1a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2zm0 3h5v1H5zm0 2h5v1H5zm0 2h5v1H5zm10 7H5v-1h10zm0-2H5v-1h10zm0-2H5v-1h10zm0-2h-4V4h4z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div> */}
|
||||||
|
<div className="h-[96vh] p-3 lg:p-8 bg-slate-100 dark:!bg-black overflow-y-auto">
|
||||||
|
<EditArticleForm isDetail={true} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import EditArticleForm from "@/components/form/article/edit-article-form";
|
||||||
|
|
||||||
|
export default function UpdateArticlePage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* <div className="flex flex-row justify-between border-b-2 px-4 bg-white shadow-md">
|
||||||
|
<div className="flex flex-col gap-1 py-2">
|
||||||
|
<h1 className="font-bold text-[25px]">Article</h1>
|
||||||
|
<p className="text-[14px]">Article</p>
|
||||||
|
</div>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 20 20">
|
||||||
|
<path fill="currentColor" d="M5 1a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2zm0 3h5v1H5zm0 2h5v1H5zm0 2h5v1H5zm10 7H5v-1h10zm0-2H5v-1h10zm0-2H5v-1h10zm0-2h-4V4h4z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div> */}
|
||||||
|
<div className="h-[96vh] p-3 lg:p-8 bg-slate-100 dark:!bg-black overflow-y-auto">
|
||||||
|
<EditArticleForm isDetail={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
"use client";
|
||||||
|
import ArticleTable from "@/components/table/article-table";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function BasicPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* <div className="flex flex-row justify-between border-b-2 mb-4 px-4 ">
|
||||||
|
<div className="flex flex-col gap-1 py-2">
|
||||||
|
<h1 className="font-bold text-[25px]">Article</h1>
|
||||||
|
<p className="text-[14px]">Article</p>
|
||||||
|
</div>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 20 20">
|
||||||
|
<path fill="currentColor" d="M5 1a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2zm0 3h5v1H5zm0 2h5v1H5zm0 2h5v1H5zm10 7H5v-1h10zm0-2H5v-1h10zm0-2H5v-1h10zm0-2h-4V4h4z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div> */}
|
||||||
|
<div className="overflow-x-hidden overflow-y-scroll w-full">
|
||||||
|
<div className="px-2 md:px-4 md:py-4 w-full">
|
||||||
|
<div className="bg-white shadow-lg dark:bg-[#18181b] rounded-xl p-3">
|
||||||
|
<Link href="/admin/article/create">
|
||||||
|
<Button className="bg-[#F07C00] text-white w-full lg:w-fit hover:bg-[#d96e00]">
|
||||||
|
Tambah Artikel
|
||||||
|
<Plus className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<ArticleTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import DashboardContainer from "@/components/main/dashboard/dashboard-container";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto bg-gradient-to-br from-slate-50/50 via-white to-slate-50/50 flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="h-full overflow-auto bg-gradient-to-br from-slate-50/50 via-white to-slate-50/50"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<DashboardContainer />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AdminLayout } from "@/components/layout/admin-layout";
|
||||||
|
|
||||||
|
export default function AdminPageLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <AdminLayout>{children}</AdminLayout>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import MagazineTable from "@/components/table/magazine/magazine-table";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function MagazineTablePage() {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-hidden overflow-y-scroll">
|
||||||
|
<div className="px-2 md:px-4 md:py-4 w-full">
|
||||||
|
<div className="bg-white shadow-lg dark:bg-[#18181b] rounded-xl p-3 w-full">
|
||||||
|
<Link href="/admin/magazine/create">
|
||||||
|
<Button className="bg-[#F07C00] text-white hover:bg-[#d96e00] w-full lg:w-auto">
|
||||||
|
<Plus className="ml-2 h-4 w-4" />
|
||||||
|
Tambah Majalah
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<MagazineTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,341 @@
|
||||||
|
"use client";
|
||||||
|
import { AddIcon, CloudUploadIcon, TimesIcon } from "@/components/icons";
|
||||||
|
import CategoriesTable from "@/components/table/master-categories/categories-table";
|
||||||
|
import * as z from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import ReactSelect from "react-select";
|
||||||
|
import makeAnimated from "react-select/animated";
|
||||||
|
import { useDropzone } from "react-dropzone";
|
||||||
|
import { close, error, loading } from "@/config/swal";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
import withReactContent from "sweetalert2-react-content";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
import { getArticleByCategory } from "@/service/article";
|
||||||
|
import {
|
||||||
|
createCategory,
|
||||||
|
uploadCategoryThumbnail,
|
||||||
|
} from "@/service/master-categories";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import useDisclosure from "@/components/useDisclosure";
|
||||||
|
|
||||||
|
const createArticleSchema = z.object({
|
||||||
|
title: z.string().min(2, {
|
||||||
|
message: "Judul harus diisi",
|
||||||
|
}),
|
||||||
|
description: z.string().min(2, {
|
||||||
|
message: "Deskripsi harus diisi",
|
||||||
|
}),
|
||||||
|
tags: z.array(z.string()),
|
||||||
|
// parent: z.array(categorySchema).nonempty({
|
||||||
|
// message: "Kategori harus memiliki setidaknya satu item",
|
||||||
|
// }),
|
||||||
|
// tags: z.array(z.string()).nonempty({
|
||||||
|
// message: "Minimal 1 tag",
|
||||||
|
// }),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface CategoryType {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MasterCategoryTable() {
|
||||||
|
const MySwal = withReactContent(Swal);
|
||||||
|
const animatedComponents = makeAnimated();
|
||||||
|
const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure();
|
||||||
|
const [listCategory, setListCategory] = useState<CategoryType[]>([]);
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [refresh, setRefresh] = useState(false);
|
||||||
|
const [tag, setTag] = useState("");
|
||||||
|
const [selectedParent, setSelectedParent] = useState<any>();
|
||||||
|
const [isDetail] = useState<any>();
|
||||||
|
|
||||||
|
const formOptions = {
|
||||||
|
resolver: zodResolver(createArticleSchema),
|
||||||
|
defaultValues: { title: "", description: "", tags: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps } = useDropzone({
|
||||||
|
onDrop: (acceptedFiles) => {
|
||||||
|
setFiles(acceptedFiles.map((file) => Object.assign(file)));
|
||||||
|
},
|
||||||
|
maxFiles: 1,
|
||||||
|
});
|
||||||
|
type UserSettingSchema = z.infer<typeof createArticleSchema>;
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
setValue,
|
||||||
|
getValues,
|
||||||
|
setError,
|
||||||
|
clearErrors,
|
||||||
|
} = useForm<UserSettingSchema>(formOptions);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCategory();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchCategory = async () => {
|
||||||
|
const res = await getArticleByCategory();
|
||||||
|
if (res?.data?.data) {
|
||||||
|
setupCategory(res?.data?.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupCategory = (data: any) => {
|
||||||
|
const temp = [];
|
||||||
|
for (const element of data) {
|
||||||
|
temp.push({
|
||||||
|
id: element.id,
|
||||||
|
label: element.title,
|
||||||
|
value: element.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setListCategory(temp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (values: z.infer<typeof createArticleSchema>) => {
|
||||||
|
console.log("values,", values);
|
||||||
|
loading();
|
||||||
|
const formData = {
|
||||||
|
title: values.title,
|
||||||
|
statusId: 1,
|
||||||
|
parentId: selectedParent ? selectedParent.id : 0,
|
||||||
|
tags: values.tags.join(","),
|
||||||
|
description: values.description,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await createCategory(formData);
|
||||||
|
|
||||||
|
if (response?.error) {
|
||||||
|
error(response.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const categoryId = response?.data?.data?.id;
|
||||||
|
const formFiles = new FormData();
|
||||||
|
|
||||||
|
formFiles.append("files", files[0]);
|
||||||
|
const resFile = await uploadCategoryThumbnail(categoryId, formFiles);
|
||||||
|
if (resFile?.error) {
|
||||||
|
error(resFile.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
setRefresh(!refresh);
|
||||||
|
MySwal.fire({
|
||||||
|
title: "Sukses",
|
||||||
|
icon: "success",
|
||||||
|
confirmButtonColor: "#3085d6",
|
||||||
|
confirmButtonText: "OK",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFile = (file: File) => {
|
||||||
|
const uploadedFiles = files;
|
||||||
|
const filtered = uploadedFiles.filter((i) => i.name !== file.name);
|
||||||
|
setFiles([...filtered]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-hidden overflow-y-scroll">
|
||||||
|
<div className="px-2 md:px-4 md:py-4 w-full">
|
||||||
|
<div className="bg-white shadow-lg dark:bg-[#18181b] rounded-xl p-3">
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
className="bg-[#F07C00] text-white w-full lg:w-fit flex items-center gap-2"
|
||||||
|
onClick={onOpen}
|
||||||
|
>
|
||||||
|
Tambah Kategori
|
||||||
|
<AddIcon />
|
||||||
|
</Button>
|
||||||
|
<CategoriesTable triggerRefresh={refresh} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-6xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Kategori Baru</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm">Nama Kategori</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="title"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
readOnly={isDetail}
|
||||||
|
className="w-full border rounded-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors?.title && (
|
||||||
|
<p className="text-red-400 text-sm">{errors.title?.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm">Deskripsi</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="description"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
readOnly={isDetail}
|
||||||
|
className="w-full border rounded-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors?.description && (
|
||||||
|
<p className="text-red-400 text-sm">
|
||||||
|
{errors.description?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm mt-3">Parent</p>
|
||||||
|
<ReactSelect
|
||||||
|
className="basic-single text-black z-50"
|
||||||
|
classNames={{
|
||||||
|
control: () =>
|
||||||
|
"!rounded-lg bg-white !border !border-gray-200 dark:!border-stone-500",
|
||||||
|
}}
|
||||||
|
classNamePrefix="select"
|
||||||
|
value={selectedParent}
|
||||||
|
isDisabled={isDetail}
|
||||||
|
onChange={setSelectedParent}
|
||||||
|
closeMenuOnSelect={false}
|
||||||
|
components={animatedComponents}
|
||||||
|
isClearable
|
||||||
|
isSearchable
|
||||||
|
isMulti={false}
|
||||||
|
placeholder="Kategori..."
|
||||||
|
name="sub-module"
|
||||||
|
options={listCategory}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm mt-3">Tag Terkait</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="tags"
|
||||||
|
render={({ field: { value } }) => (
|
||||||
|
<Input
|
||||||
|
id="tags"
|
||||||
|
value={tag}
|
||||||
|
onChange={(e) => setTag(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
if (tag.trim() !== "") {
|
||||||
|
setValue("tags", [...value, tag.trim()]);
|
||||||
|
setTag("");
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full border rounded-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{getValues("tags")?.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="bg-blue-500 text-white px-2 py-1 rounded-full text-sm flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const filteredTags = getValues("tags").filter(
|
||||||
|
(tag) => tag !== item
|
||||||
|
);
|
||||||
|
if (filteredTags.length === 0) {
|
||||||
|
setError("tags", {
|
||||||
|
type: "manual",
|
||||||
|
message: "Tags tidak boleh kosong",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
clearErrors("tags");
|
||||||
|
setValue("tags", filteredTags);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm mt-3">Thumbnail</p>
|
||||||
|
{files.length < 1 && (
|
||||||
|
<div {...getRootProps({ className: "dropzone" })}>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<div className="w-full text-center border-dashed border rounded-md py-[52px] flex flex-col items-center">
|
||||||
|
<CloudUploadIcon />
|
||||||
|
<h4 className="text-2xl font-medium mb-1 mt-3 text-muted-foreground">
|
||||||
|
Tarik file disini atau klik untuk upload.
|
||||||
|
</h4>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
( Upload file dengan format .jpg, .jpeg, atau .png. Ukuran
|
||||||
|
maksimal 100mb.)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
<Image
|
||||||
|
src={URL.createObjectURL(files[0])}
|
||||||
|
className="w-[30%]"
|
||||||
|
alt="thumbnail"
|
||||||
|
width={480}
|
||||||
|
height={480}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleRemoveFile(files[0])}
|
||||||
|
>
|
||||||
|
<TimesIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="self-end">
|
||||||
|
{!isDetail && <Button type="submit">Simpan</Button>}
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Tutup
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import FormMasterUser from "@/components/form/form-master-user";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default function CreateMasterUserPage() {
|
||||||
|
return (
|
||||||
|
<Card className="h-[96vh] rounded-md bg-transparent">
|
||||||
|
<FormMasterUser />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import FormMasterUserEdit from "@/components/form/form-master-user-edit";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default function CreateMasterUserPage() {
|
||||||
|
return (
|
||||||
|
<Card className="h-[96vh] rounded-md bg-transparent">
|
||||||
|
<FormMasterUserEdit />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
"use client";
|
||||||
|
import { AddIcon } from "@/components/icons";
|
||||||
|
import MasterUserTable from "@/components/table/master-user-table";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function MasterUserPage() {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-hidden overflow-y-scroll">
|
||||||
|
<div className="px-2 md:px-4 md:py-4 w-full">
|
||||||
|
<div className="bg-white shadow-lg dark:bg-[#18181b] rounded-xl py-3">
|
||||||
|
<Link href="/admin/master-user/create" className="mx-3">
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
color="primary"
|
||||||
|
className="bg-[#F07C00] text-white"
|
||||||
|
>
|
||||||
|
Pengguna Baru
|
||||||
|
<AddIcon />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<MasterUserTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import StaticPageBuilder from "@/components/main/static-page/static-page-main";
|
||||||
|
|
||||||
|
export default function StaticPageGenerator() {
|
||||||
|
return (
|
||||||
|
<div className="bg-transparent p-4 overflow-auto">
|
||||||
|
<StaticPageBuilder />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import StaticPageBuilderEdit from "@/components/form/static-page/static-page-edit-form";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default function StaticPageEdit() {
|
||||||
|
return (
|
||||||
|
<Card className="rounded-md bg-transparent p-4 overflow-auto">
|
||||||
|
<StaticPageBuilderEdit />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { AddIcon } from "@/components/icons";
|
||||||
|
import StaticPageTable from "@/components/table/static-page-table";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function StaticPageGeneratorList() {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-hidden overflow-y-scroll rounded-lg">
|
||||||
|
<div className="px-2 md:px-4 md:py-4 w-full">
|
||||||
|
<div className="bg-white shadow-lg dark:bg-[#18181b] rounded-xl p-3">
|
||||||
|
<Link href="/admin/static-page/create">
|
||||||
|
<Button size="default" color="primary" className="bg-[#F07C00] text-white w-full lg:w-fit">
|
||||||
|
Tambah Halaman
|
||||||
|
<AddIcon />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<StaticPageTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import Agent from "@/components/landing-page/agent";
|
||||||
|
import Footer from "@/components/landing-page/footer";
|
||||||
|
import Galeri from "@/components/landing-page/galeri";
|
||||||
|
import HeaderAbout from "@/components/landing-page/header-about";
|
||||||
|
import HeaderItems from "@/components/landing-page/header-item";
|
||||||
|
import Help from "@/components/landing-page/help";
|
||||||
|
import FormJaecoo from "@/components/landing-page/jaecoo-form";
|
||||||
|
import Navbar from "@/components/landing-page/navbar";
|
||||||
|
import NearestLocation from "@/components/landing-page/nearest-location";
|
||||||
|
|
||||||
|
import Service from "@/components/landing-page/service";
|
||||||
|
|
||||||
|
export default function AboutPage() {
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-screen font-[family-name:var(--font-geist-sans)]">
|
||||||
|
<div className="relative z-10 bg-white w-full mx-auto">
|
||||||
|
<Navbar />
|
||||||
|
<HeaderAbout />
|
||||||
|
<Agent />
|
||||||
|
<Galeri />
|
||||||
|
<FormJaecoo />
|
||||||
|
<Help />
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export default function AuthLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <> {children}</>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import Login from "@/components/form/login";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function AuthPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Login />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
|
|
@ -0,0 +1,122 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
|
||||||
|
: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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
// import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
// const geistSans = Geist({
|
||||||
|
// variable: "--font-geist-sans",
|
||||||
|
// subsets: ["latin"],
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const geistMono = Geist_Mono({
|
||||||
|
// variable: "--font-geist-mono",
|
||||||
|
// subsets: ["latin"],
|
||||||
|
// });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Jaecoo",
|
||||||
|
description: "Generated by create next app",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import Footer from "@/components/landing-page/footer";
|
||||||
|
import Header from "@/components/landing-page/header";
|
||||||
|
import Items from "@/components/landing-page/items";
|
||||||
|
import Location from "@/components/landing-page/location";
|
||||||
|
import Navbar from "@/components/landing-page/navbar";
|
||||||
|
import Video from "@/components/landing-page/video";
|
||||||
|
import Agent from "@/components/landing-page/agent";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-screen font-[family-name:var(--font-geist-sans)]">
|
||||||
|
<div className="relative z-10 bg-white w-full mx-auto">
|
||||||
|
<Navbar />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Header />
|
||||||
|
</div>
|
||||||
|
<Items />
|
||||||
|
<Video />
|
||||||
|
<Agent />
|
||||||
|
<Location />
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import Exterior from "@/components/landing-page/exterior";
|
||||||
|
import FeaturesAndSpecifications from "@/components/landing-page/features-and-specifications";
|
||||||
|
import Footer from "@/components/landing-page/footer";
|
||||||
|
import HeaderProduct from "@/components/landing-page/header-product";
|
||||||
|
import Interior from "@/components/landing-page/interior";
|
||||||
|
import Navbar from "@/components/landing-page/navbar";
|
||||||
|
|
||||||
|
export default function ProductPage() {
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-screen font-[family-name:var(--font-geist-sans)]">
|
||||||
|
<div className="relative z-10 bg-white w-full mx-auto">
|
||||||
|
<Navbar />
|
||||||
|
<HeaderProduct />
|
||||||
|
<Exterior />
|
||||||
|
<Interior />
|
||||||
|
<FeaturesAndSpecifications />
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import Footer from "@/components/landing-page/footer";
|
||||||
|
import HeaderItems from "@/components/landing-page/header-item";
|
||||||
|
import Navbar from "@/components/landing-page/navbar";
|
||||||
|
import NearestLocation from "@/components/landing-page/nearest-location";
|
||||||
|
|
||||||
|
import Service from "@/components/landing-page/service";
|
||||||
|
|
||||||
|
export default function ServicePage() {
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-screen font-[family-name:var(--font-geist-sans)]">
|
||||||
|
<div className="relative z-10 bg-white w-full mx-auto">
|
||||||
|
<Navbar />
|
||||||
|
<HeaderItems />
|
||||||
|
<Service />
|
||||||
|
<NearestLocation />
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
// components/custom-editor.js
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { CKEditor } from "@ckeditor/ckeditor5-react";
|
||||||
|
import Editor from "@/vendor/ckeditor5/build/ckeditor";
|
||||||
|
|
||||||
|
function CustomEditor(props) {
|
||||||
|
return (
|
||||||
|
<CKEditor
|
||||||
|
editor={Editor}
|
||||||
|
data={props.initialData}
|
||||||
|
onChange={(event, editor) => {
|
||||||
|
const data = editor.getData();
|
||||||
|
console.log({ event, editor, data });
|
||||||
|
props.onChange(data);
|
||||||
|
}}
|
||||||
|
config={{
|
||||||
|
toolbar: [
|
||||||
|
"heading",
|
||||||
|
"fontsize",
|
||||||
|
"bold",
|
||||||
|
"italic",
|
||||||
|
"link",
|
||||||
|
"numberedList",
|
||||||
|
"bulletedList",
|
||||||
|
"undo",
|
||||||
|
"redo",
|
||||||
|
"alignment",
|
||||||
|
"outdent",
|
||||||
|
"indent",
|
||||||
|
"blockQuote",
|
||||||
|
"insertTable",
|
||||||
|
"codeBlock",
|
||||||
|
"sourceEditing",
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CustomEditor;
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import React from "react";
|
||||||
|
import { CKEditor } from "@ckeditor/ckeditor5-react";
|
||||||
|
import Editor from "@/vendor/ckeditor5/build/ckeditor";
|
||||||
|
|
||||||
|
function ViewEditor(props) {
|
||||||
|
return (
|
||||||
|
<CKEditor
|
||||||
|
editor={Editor}
|
||||||
|
data={props.initialData}
|
||||||
|
disabled={true}
|
||||||
|
config={{
|
||||||
|
// toolbar: [],
|
||||||
|
isReadOnly: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ViewEditor;
|
||||||
|
|
@ -0,0 +1,875 @@
|
||||||
|
"use client";
|
||||||
|
import { Fragment, useEffect, useState } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
import withReactContent from "sweetalert2-react-content";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { useDropzone } from "react-dropzone";
|
||||||
|
import { CloudUploadIcon, TimesIcon } from "@/components/icons";
|
||||||
|
import Image from "next/image";
|
||||||
|
import ReactSelect from "react-select";
|
||||||
|
import makeAnimated from "react-select/animated";
|
||||||
|
import { htmlToString } from "@/utils/global";
|
||||||
|
import { close, error, loading, successToast } from "@/config/swal";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
createArticle,
|
||||||
|
createArticleSchedule,
|
||||||
|
getArticleByCategory,
|
||||||
|
uploadArticleFile,
|
||||||
|
uploadArticleThumbnail,
|
||||||
|
} from "@/service/article";
|
||||||
|
import {
|
||||||
|
saveManualContext,
|
||||||
|
updateManualArticle,
|
||||||
|
} from "@/service/generate-article";
|
||||||
|
import { getUserLevels } from "@/service/user-levels-service";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { getCategoryById } from "@/service/master-categories";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import GenerateSingleArticleForm from "./generate-ai-single-form";
|
||||||
|
import GenerateContentRewriteForm from "./generate-ai-content-rewrite-form";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
const CustomEditor = dynamic(
|
||||||
|
() => {
|
||||||
|
return import("@/components/editor/custom-editor");
|
||||||
|
},
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
interface FileWithPreview extends File {
|
||||||
|
preview: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryType {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
const categorySchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
label: z.string(),
|
||||||
|
value: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface DiseData {
|
||||||
|
id: number;
|
||||||
|
articleBody: string;
|
||||||
|
title: string;
|
||||||
|
metaTitle: string;
|
||||||
|
description: string;
|
||||||
|
metaDescription: string;
|
||||||
|
mainKeyword: string;
|
||||||
|
additionalKeywords: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createArticleSchema = z.object({
|
||||||
|
title: z.string().min(2, {
|
||||||
|
message: "Judul harus diisi",
|
||||||
|
}),
|
||||||
|
slug: z.string().min(2, {
|
||||||
|
message: "Slug harus diisi",
|
||||||
|
}),
|
||||||
|
description: z.string().min(2, {
|
||||||
|
message: "Deskripsi harus diisi",
|
||||||
|
}),
|
||||||
|
category: z.array(categorySchema).nonempty({
|
||||||
|
message: "Kategori harus memiliki setidaknya satu item",
|
||||||
|
}),
|
||||||
|
tags: z.array(z.string()).nonempty({
|
||||||
|
message: "Minimal 1 tag",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function CreateArticleForm() {
|
||||||
|
const userLevel = Cookies.get("ulne");
|
||||||
|
const animatedComponents = makeAnimated();
|
||||||
|
const MySwal = withReactContent(Swal);
|
||||||
|
const router = useRouter();
|
||||||
|
const [files, setFiles] = useState<FileWithPreview[]>([]);
|
||||||
|
const [useAi, setUseAI] = useState(false);
|
||||||
|
const [listCategory, setListCategory] = useState<CategoryType[]>([]);
|
||||||
|
const [tag, setTag] = useState("");
|
||||||
|
const [thumbnailImg, setThumbnailImg] = useState<File[]>([]);
|
||||||
|
const [selectedMainImage, setSelectedMainImage] = useState<number | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [thumbnailValidation, setThumbnailValidation] = useState("");
|
||||||
|
const [filesValidation, setFileValidation] = useState("");
|
||||||
|
const [diseData, setDiseData] = useState<DiseData>();
|
||||||
|
const [selectedWritingType, setSelectedWritingType] = useState("single");
|
||||||
|
const [status, setStatus] = useState<"publish" | "draft" | "scheduled">(
|
||||||
|
"publish"
|
||||||
|
);
|
||||||
|
const [isScheduled, setIsScheduled] = useState(false);
|
||||||
|
|
||||||
|
const [startDateValue, setStartDateValue] = useState<any>(null);
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps } = useDropzone({
|
||||||
|
onDrop: (acceptedFiles) => {
|
||||||
|
setFiles((prevFiles) => [
|
||||||
|
...prevFiles,
|
||||||
|
...acceptedFiles.map((file) => Object.assign(file)),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
multiple: true,
|
||||||
|
accept: {
|
||||||
|
"image/*": [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const formOptions = {
|
||||||
|
resolver: zodResolver(createArticleSchema),
|
||||||
|
defaultValues: { title: "", description: "", category: [], tags: [] },
|
||||||
|
};
|
||||||
|
type UserSettingSchema = z.infer<typeof createArticleSchema>;
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
setValue,
|
||||||
|
getValues,
|
||||||
|
watch,
|
||||||
|
setError,
|
||||||
|
clearErrors,
|
||||||
|
} = useForm<UserSettingSchema>(formOptions);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCategory();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchCategory = async () => {
|
||||||
|
const res = await getArticleByCategory();
|
||||||
|
if (res?.data?.data) {
|
||||||
|
setupCategory(res?.data?.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupCategory = (data: any) => {
|
||||||
|
const temp = [];
|
||||||
|
for (const element of data) {
|
||||||
|
temp.push({
|
||||||
|
id: element.id,
|
||||||
|
label: element.title,
|
||||||
|
value: element.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setListCategory(temp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (values: z.infer<typeof createArticleSchema>) => {
|
||||||
|
if ((thumbnailImg.length < 1 && !selectedMainImage) || files.length < 1) {
|
||||||
|
if (files.length < 1) {
|
||||||
|
setFileValidation("Required");
|
||||||
|
} else {
|
||||||
|
setFileValidation("");
|
||||||
|
}
|
||||||
|
if (thumbnailImg.length < 1 && !selectedMainImage) {
|
||||||
|
setThumbnailValidation("Required");
|
||||||
|
} else {
|
||||||
|
setThumbnailValidation("");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setThumbnailValidation("");
|
||||||
|
setFileValidation("");
|
||||||
|
MySwal.fire({
|
||||||
|
title: "Simpan Data",
|
||||||
|
text: "",
|
||||||
|
icon: "warning",
|
||||||
|
showCancelButton: true,
|
||||||
|
cancelButtonColor: "#d33",
|
||||||
|
confirmButtonColor: "#3085d6",
|
||||||
|
confirmButtonText: "Simpan",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
save(values);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (useAi === false) {
|
||||||
|
setValue("description", "");
|
||||||
|
}
|
||||||
|
}, [useAi]);
|
||||||
|
|
||||||
|
function removeImgTags(htmlString: string) {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(String(htmlString), "text/html");
|
||||||
|
|
||||||
|
const images = doc.querySelectorAll("img");
|
||||||
|
images.forEach((img) => img.remove());
|
||||||
|
return doc.body.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveArticleToDise = async (
|
||||||
|
values: z.infer<typeof createArticleSchema>
|
||||||
|
) => {
|
||||||
|
if (useAi) {
|
||||||
|
const request = {
|
||||||
|
id: diseData?.id,
|
||||||
|
title: values.title,
|
||||||
|
articleBody: removeImgTags(values.description),
|
||||||
|
metaDescription: diseData?.metaDescription,
|
||||||
|
metaTitle: diseData?.metaTitle,
|
||||||
|
mainKeyword: diseData?.mainKeyword,
|
||||||
|
additionalKeywords: diseData?.additionalKeywords,
|
||||||
|
createdBy: "345",
|
||||||
|
style: "Informational",
|
||||||
|
projectId: 2,
|
||||||
|
clientId: "humasClientIdtest",
|
||||||
|
lang: "id",
|
||||||
|
};
|
||||||
|
const res = await updateManualArticle(request);
|
||||||
|
if (res.error) {
|
||||||
|
error(res.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return diseData?.id;
|
||||||
|
} else {
|
||||||
|
const request = {
|
||||||
|
title: values.title,
|
||||||
|
articleBody: removeImgTags(values.description),
|
||||||
|
metaDescription: values.title,
|
||||||
|
metaTitle: values.title,
|
||||||
|
mainKeyword: values.title,
|
||||||
|
additionalKeywords: values.title,
|
||||||
|
createdBy: "345",
|
||||||
|
style: "Informational",
|
||||||
|
projectId: 2,
|
||||||
|
clientId: "humasClientIdtest",
|
||||||
|
lang: "id",
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await saveManualContext(request);
|
||||||
|
if (res.error) {
|
||||||
|
res.message;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return res?.data?.data?.id;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserLevelApprovalStatus = async () => {
|
||||||
|
const res = await getUserLevels(String(userLevel));
|
||||||
|
return res?.data?.data?.isApprovalActive;
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async (values: z.infer<typeof createArticleSchema>) => {
|
||||||
|
loading();
|
||||||
|
|
||||||
|
const userLevelStatus = await getUserLevelApprovalStatus();
|
||||||
|
const formData = {
|
||||||
|
title: values.title,
|
||||||
|
typeId: 1,
|
||||||
|
slug: values.slug,
|
||||||
|
categoryIds: values.category.map((a) => a.id).join(","),
|
||||||
|
tags: values.tags.join(","),
|
||||||
|
description: htmlToString(removeImgTags(values.description)),
|
||||||
|
htmlDescription: removeImgTags(values.description),
|
||||||
|
aiArticleId: await saveArticleToDise(values),
|
||||||
|
// isDraft: userLevelStatus ? true : status === "draft",
|
||||||
|
// isPublish: userLevelStatus ? false : status === "publish",
|
||||||
|
isDraft: status === "draft",
|
||||||
|
isPublish: status === "publish",
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await createArticle(formData);
|
||||||
|
|
||||||
|
if (response?.error) {
|
||||||
|
error(response.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const articleId = response?.data?.data?.id;
|
||||||
|
|
||||||
|
if (files?.length > 0) {
|
||||||
|
const formFiles = new FormData();
|
||||||
|
|
||||||
|
for (const element of files) {
|
||||||
|
formFiles.append("file", element);
|
||||||
|
const resFile = await uploadArticleFile(articleId, formFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (thumbnailImg?.length > 0 || files?.length > 0) {
|
||||||
|
if (thumbnailImg?.length > 0) {
|
||||||
|
const formFiles = new FormData();
|
||||||
|
|
||||||
|
formFiles.append("files", thumbnailImg[0]);
|
||||||
|
const resFile = await uploadArticleThumbnail(articleId, formFiles);
|
||||||
|
} else {
|
||||||
|
const formFiles = new FormData();
|
||||||
|
|
||||||
|
if (selectedMainImage) {
|
||||||
|
formFiles.append("files", files[selectedMainImage - 1]);
|
||||||
|
|
||||||
|
const resFile = await uploadArticleThumbnail(articleId, formFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "scheduled") {
|
||||||
|
const request = {
|
||||||
|
id: articleId,
|
||||||
|
date: `${startDateValue?.year}-${startDateValue?.month}-${startDateValue?.day}`,
|
||||||
|
};
|
||||||
|
const res = await createArticleSchedule(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
close();
|
||||||
|
successSubmit("/admin/article", articleId, values.slug);
|
||||||
|
};
|
||||||
|
|
||||||
|
function successSubmit(redirect: string, id: number, slug: string) {
|
||||||
|
const url =
|
||||||
|
`${window.location.protocol}//${window.location.host}` +
|
||||||
|
"/news/detail/" +
|
||||||
|
`${id}-${slug}`;
|
||||||
|
MySwal.fire({
|
||||||
|
title: "Sukses",
|
||||||
|
icon: "success",
|
||||||
|
confirmButtonColor: "#3085d6",
|
||||||
|
confirmButtonText: "OK",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
router.push(redirect);
|
||||||
|
successToast("Article Url", url);
|
||||||
|
} else {
|
||||||
|
router.push(redirect);
|
||||||
|
successToast("Article Url", url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchTitle = watch("title");
|
||||||
|
const generateSlug = (title: string) => {
|
||||||
|
return title
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^\w\s-]/g, "")
|
||||||
|
.replace(/\s+/g, "-");
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValue("slug", generateSlug(watchTitle));
|
||||||
|
}, [watchTitle]);
|
||||||
|
|
||||||
|
const renderFilePreview = (file: FileWithPreview) => {
|
||||||
|
if (file.type.startsWith("image")) {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
alt={file.name}
|
||||||
|
src={URL.createObjectURL(file)}
|
||||||
|
className=" rounded border p-0.5"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return "Not Found";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFile = (file: FileWithPreview) => {
|
||||||
|
const uploadedFiles = files;
|
||||||
|
const filtered = uploadedFiles.filter((i) => i.name !== file.name);
|
||||||
|
setFiles([...filtered]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileList = files.map((file, index) => (
|
||||||
|
<div
|
||||||
|
key={file.name}
|
||||||
|
className=" flex justify-between border px-3.5 py-3 my-6 rounded-md"
|
||||||
|
>
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<div className="file-preview">{renderFilePreview(file)}</div>
|
||||||
|
<div>
|
||||||
|
<div className=" text-sm text-card-foreground">{file.name}</div>
|
||||||
|
<div className=" text-xs font-light text-muted-foreground">
|
||||||
|
{Math.round(file.size / 100) / 10 > 1000 ? (
|
||||||
|
<>{(Math.round(file.size / 100) / 10000).toFixed(1)}</>
|
||||||
|
) : (
|
||||||
|
<>{(Math.round(file.size / 100) / 10).toFixed(1)}</>
|
||||||
|
)}
|
||||||
|
{" kb"}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={String(index)}
|
||||||
|
value={String(index)}
|
||||||
|
checked={selectedMainImage === index + 1}
|
||||||
|
onCheckedChange={() => setSelectedMainImage(index + 1)}
|
||||||
|
/>
|
||||||
|
<label htmlFor={String(index)} className="text-black text-xs">
|
||||||
|
Jadikan Thumbnail
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="rounded-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleRemoveFile(file)}
|
||||||
|
>
|
||||||
|
<TimesIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selectedFiles = event.target.files;
|
||||||
|
if (selectedFiles) {
|
||||||
|
setThumbnailImg(Array.from(selectedFiles));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedCategory = watch("category");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getDetailCategory();
|
||||||
|
}, [selectedCategory]);
|
||||||
|
|
||||||
|
const getDetailCategory = async () => {
|
||||||
|
let temp = getValues("tags");
|
||||||
|
for (const element of selectedCategory) {
|
||||||
|
const res = await getCategoryById(element?.id);
|
||||||
|
const tagList = res?.data?.data?.tags;
|
||||||
|
if (tagList) {
|
||||||
|
temp = [...temp, ...res?.data?.data?.tags];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const uniqueArray = temp.filter(
|
||||||
|
(item, index) => temp.indexOf(item) === index
|
||||||
|
);
|
||||||
|
|
||||||
|
setValue("tags", uniqueArray as [string, ...string[]]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="flex flex-col lg:flex-row gap-8 text-black"
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<div className="w-full lg:w-[65%] bg-white rounded-lg p-8 flex flex-col gap-1">
|
||||||
|
<p className="text-sm">Judul</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan judul artikel"
|
||||||
|
className="w-full border rounded-lg dark:border-gray-400"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errors?.title && (
|
||||||
|
<p className="text-red-400 text-sm mb-3">{errors.title?.message}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm mt-3">Slug</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="slug"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
placeholder=""
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={field.onChange}
|
||||||
|
className="w-full border rounded-lg dark:border-gray-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errors?.slug && (
|
||||||
|
<p className="text-red-400 text-sm mb-3">{errors.slug?.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-3">
|
||||||
|
<Switch checked={useAi} onCheckedChange={setUseAI} />
|
||||||
|
<p className="text-sm text-black">Bantuan AI</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{useAi && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Select
|
||||||
|
value={selectedWritingType ?? ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value !== "") setSelectedWritingType(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
|
||||||
|
<SelectValue placeholder="Writing Style" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="single">Single Article</SelectItem>
|
||||||
|
<SelectItem value="rewrite">Content Rewrite</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{selectedWritingType === "single" ? (
|
||||||
|
<GenerateSingleArticleForm
|
||||||
|
content={(data) => {
|
||||||
|
setDiseData(data);
|
||||||
|
setValue(
|
||||||
|
"description",
|
||||||
|
data?.articleBody ? data?.articleBody : ""
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GenerateContentRewriteForm
|
||||||
|
content={(data) => {
|
||||||
|
setDiseData(data);
|
||||||
|
setValue(
|
||||||
|
"description",
|
||||||
|
data?.articleBody ? data?.articleBody : ""
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-sm mt-3">Deskripsi</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="description"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<CustomEditor onChange={onChange} initialData={value} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors?.description && (
|
||||||
|
<p className="text-red-400 text-sm mb-3">
|
||||||
|
{errors.description?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-sm mt-3">File Media</p>
|
||||||
|
<Fragment>
|
||||||
|
<div {...getRootProps({ className: "dropzone" })}>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<div className=" w-full text-center border-dashed border border-default-200 dark:border-default-300 rounded-md py-[52px] flex items-center flex-col">
|
||||||
|
<CloudUploadIcon size={50} className="text-gray-300" />
|
||||||
|
<h4 className=" text-2xl font-medium mb-1 mt-3 text-card-foreground/80">
|
||||||
|
Tarik file disini atau klik untuk upload.
|
||||||
|
</h4>
|
||||||
|
<div className=" text-xs text-muted-foreground">
|
||||||
|
( Upload file dengan format .jpg, .jpeg, atau .png. Ukuran
|
||||||
|
maksimal 100mb.)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{files.length ? (
|
||||||
|
<Fragment>
|
||||||
|
<div>{fileList}</div>
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
|
<Button onClick={() => setFiles([])} size="sm">
|
||||||
|
Hapus Semua
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
) : null}
|
||||||
|
</Fragment>
|
||||||
|
{filesValidation !== "" && files.length < 1 && (
|
||||||
|
<p className="text-red-400 text-sm mb-3">Upload File Media</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-full lg:w-[35%] flex flex-col gap-8">
|
||||||
|
<div className="h-fit bg-white rounded-lg p-8 flex flex-col gap-1">
|
||||||
|
<p className="text-sm">Thubmnail</p>
|
||||||
|
|
||||||
|
{selectedMainImage && files.length >= selectedMainImage ? (
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(files[selectedMainImage - 1])}
|
||||||
|
className="w-[30%]"
|
||||||
|
alt="thumbnail"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="border-none rounded-full"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedMainImage(null)}
|
||||||
|
>
|
||||||
|
<TimesIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : thumbnailImg.length > 0 ? (
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(thumbnailImg[0])}
|
||||||
|
className="w-[30%]"
|
||||||
|
alt="thumbnail"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="border-none rounded-full"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setThumbnailImg([])}
|
||||||
|
>
|
||||||
|
<TimesIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* <label htmlFor="file-upload">
|
||||||
|
<button>Upload Thumbnail</button>
|
||||||
|
</label>{" "} */}
|
||||||
|
<input
|
||||||
|
id="file-upload"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="w-fit h-fit"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
{thumbnailValidation !== "" && (
|
||||||
|
<p className="text-red-400 text-sm mb-3">
|
||||||
|
Upload thumbnail atau pilih dari File Media
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-sm mt-3">Kategori</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="category"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<ReactSelect
|
||||||
|
className="basic-single text-black z-50"
|
||||||
|
classNames={{
|
||||||
|
control: (state: any) =>
|
||||||
|
"!rounded-lg bg-white !border-1 !border-gray-200 dark:!border-stone-500",
|
||||||
|
}}
|
||||||
|
classNamePrefix="select"
|
||||||
|
onChange={onChange}
|
||||||
|
closeMenuOnSelect={false}
|
||||||
|
components={animatedComponents}
|
||||||
|
isClearable={true}
|
||||||
|
isSearchable={true}
|
||||||
|
isMulti={true}
|
||||||
|
placeholder="Kategori..."
|
||||||
|
name="sub-module"
|
||||||
|
options={listCategory}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors?.category && (
|
||||||
|
<p className="text-red-400 text-sm mb-3">
|
||||||
|
{errors.category?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-sm">Tags</p>
|
||||||
|
{/* <Controller
|
||||||
|
control={control}
|
||||||
|
name="tags"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Textarea
|
||||||
|
type="text"
|
||||||
|
id="tags"
|
||||||
|
placeholder=""
|
||||||
|
label=""
|
||||||
|
value={tag}
|
||||||
|
onValueChange={setTag}
|
||||||
|
startContent={
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{value.map((item, index) => (
|
||||||
|
<Chip
|
||||||
|
color="primary"
|
||||||
|
key={index}
|
||||||
|
className=""
|
||||||
|
onClose={() => {
|
||||||
|
const filteredTags = value.filter((tag) => tag !== item);
|
||||||
|
if (filteredTags.length === 0) {
|
||||||
|
setError("tags", {
|
||||||
|
type: "manual",
|
||||||
|
message: "Tags tidak boleh kosong",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
clearErrors("tags");
|
||||||
|
setValue("tags", filteredTags as [string, ...string[]]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
if (tag.trim() !== "") {
|
||||||
|
setValue("tags", [...value, tag.trim()]);
|
||||||
|
setTag("");
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
labelPlacement="outside"
|
||||||
|
className="w-full h-fit"
|
||||||
|
classNames={{
|
||||||
|
inputWrapper: ["border-1 rounded-lg", "dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400"],
|
||||||
|
}}
|
||||||
|
variant="bordered"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/> */}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="tags"
|
||||||
|
render={({ field: { value } }) => (
|
||||||
|
<div className="w-full">
|
||||||
|
{/* Menampilkan tags */}
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
|
{value.map((item: string, index: number) => (
|
||||||
|
<Badge
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-sm"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const filteredTags = value.filter(
|
||||||
|
(tag: string) => tag !== item
|
||||||
|
);
|
||||||
|
if (filteredTags.length === 0) {
|
||||||
|
setError("tags", {
|
||||||
|
type: "manual",
|
||||||
|
message: "Tags tidak boleh kosong",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
clearErrors("tags");
|
||||||
|
setValue(
|
||||||
|
"tags",
|
||||||
|
filteredTags as [string, ...string[]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-red-500 text-xs ml-1"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Textarea input */}
|
||||||
|
<Textarea
|
||||||
|
id="tags"
|
||||||
|
placeholder="Tekan Enter untuk menambahkan tag"
|
||||||
|
value={tag ?? ""}
|
||||||
|
onChange={(e) => setTag(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (tag.trim() !== "") {
|
||||||
|
setValue("tags", [...value, tag.trim()]);
|
||||||
|
setTag("");
|
||||||
|
clearErrors("tags");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="border rounded-lg"
|
||||||
|
aria-label="Tags Input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors?.tags && (
|
||||||
|
<p className="text-red-400 text-sm mb-3">{errors.tags?.message}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-2 mt-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="schedule-switch"
|
||||||
|
checked={isScheduled}
|
||||||
|
onCheckedChange={setIsScheduled}
|
||||||
|
/>
|
||||||
|
<label htmlFor="schedule-switch" className="text-black text-sm">
|
||||||
|
Publish dengan Jadwal
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* {isScheduled && (
|
||||||
|
<div className="flex flex-col lg:flex-row gap-3">
|
||||||
|
<div className="w-full lg:w-[140px] flex flex-col gal-2 ">
|
||||||
|
<p className="text-sm">Tanggal</p>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full !h-[30px] lg:h-[40px] border-1 rounded-lg text-black"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{startDateValue
|
||||||
|
? convertDateFormatNoTime(startDateValue)
|
||||||
|
: "-"}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="bg-transparent p-0">
|
||||||
|
<Calendar
|
||||||
|
selected={startDateValue}
|
||||||
|
onSelect={setStartDateValue}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)} */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={isScheduled && startDateValue == null}
|
||||||
|
onClick={() =>
|
||||||
|
isScheduled ? setStatus("scheduled") : setStatus("publish")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Publish
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="success"
|
||||||
|
type="submit"
|
||||||
|
onClick={() => setStatus("draft")}
|
||||||
|
>
|
||||||
|
<p className="text-white">Draft</p>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Link href="/admin/article">
|
||||||
|
<Button variant="outline" type="button">
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,342 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { close, error, loading } from "@/config/swal";
|
||||||
|
import { delay } from "@/utils/global";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import GetSeoScore from "./get-seo-score-form";
|
||||||
|
import {
|
||||||
|
getDetailArticle,
|
||||||
|
getGenerateRewriter,
|
||||||
|
} from "@/service/generate-article";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const CustomEditor = dynamic(
|
||||||
|
() => {
|
||||||
|
return import("@/components/editor/custom-editor");
|
||||||
|
},
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const writingStyle = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Friendly",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Professional",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "Informational",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: "Neutral",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: "Witty",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const articleSize = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "News (300 - 900 words)",
|
||||||
|
value: "News",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "Info (900 - 2000 words)",
|
||||||
|
value: "Info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "Detail (2000 - 5000 words)",
|
||||||
|
value: "Detail",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface DiseData {
|
||||||
|
id: number;
|
||||||
|
articleBody: string;
|
||||||
|
title: string;
|
||||||
|
metaTitle: string;
|
||||||
|
description: string;
|
||||||
|
metaDescription: string;
|
||||||
|
mainKeyword: string;
|
||||||
|
additionalKeywords: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GenerateContentRewriteForm(props: {
|
||||||
|
content: (data: DiseData) => void;
|
||||||
|
}) {
|
||||||
|
const [selectedWritingSyle, setSelectedWritingStyle] =
|
||||||
|
useState("Informational");
|
||||||
|
const [selectedArticleSize, setSelectedArticleSize] = useState("News");
|
||||||
|
const [selectedLanguage, setSelectedLanguage] = useState("id");
|
||||||
|
const [mainKeyword, setMainKeyword] = useState("");
|
||||||
|
const [articleIds, setArticleIds] = useState<number[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<number>();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
loading();
|
||||||
|
const request = {
|
||||||
|
advConfig: "",
|
||||||
|
context: mainKeyword,
|
||||||
|
style: selectedWritingSyle,
|
||||||
|
sentiment: "Informational",
|
||||||
|
urlContext: null,
|
||||||
|
contextType: "article",
|
||||||
|
lang: selectedLanguage,
|
||||||
|
createdBy: "123123",
|
||||||
|
clientId: "humasClientIdtest",
|
||||||
|
};
|
||||||
|
const res = await getGenerateRewriter(request);
|
||||||
|
close();
|
||||||
|
if (res?.error) {
|
||||||
|
error("Error");
|
||||||
|
}
|
||||||
|
setArticleIds([...articleIds, res?.data?.data?.id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getArticleDetail();
|
||||||
|
}, [selectedId]);
|
||||||
|
|
||||||
|
const checkArticleStatus = async (data: string | null) => {
|
||||||
|
if (data === null) {
|
||||||
|
delay(7000).then(() => {
|
||||||
|
getArticleDetail();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getArticleDetail = async () => {
|
||||||
|
if (selectedId) {
|
||||||
|
const res = await getDetailArticle(selectedId);
|
||||||
|
const data = res?.data?.data;
|
||||||
|
checkArticleStatus(data?.articleBody);
|
||||||
|
if (data?.articleBody !== null) {
|
||||||
|
setIsLoading(false);
|
||||||
|
props.content(data);
|
||||||
|
} else {
|
||||||
|
setIsLoading(true);
|
||||||
|
props.content({
|
||||||
|
id: data?.id,
|
||||||
|
articleBody: "",
|
||||||
|
title: "",
|
||||||
|
metaTitle: "",
|
||||||
|
description: "",
|
||||||
|
metaDescription: "",
|
||||||
|
additionalKeywords: "",
|
||||||
|
mainKeyword: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset>
|
||||||
|
<form className="flex flex-col w-full mt-3">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 w-full">
|
||||||
|
{/* <Select
|
||||||
|
label="Writing Style"
|
||||||
|
variant="bordered"
|
||||||
|
labelPlacement="outside"
|
||||||
|
placeholder=""
|
||||||
|
selectedKeys={[selectedWritingSyle]}
|
||||||
|
onChange={(e) =>
|
||||||
|
e.target.value !== ""
|
||||||
|
? setSelectedWritingStyle(e.target.value)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
classNames={{
|
||||||
|
label: "!text-black",
|
||||||
|
value: "!text-black",
|
||||||
|
trigger: [
|
||||||
|
"border-1 rounded-lg",
|
||||||
|
"dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400",
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectSection>
|
||||||
|
{writingStyle.map((style) => (
|
||||||
|
<SelectItem key={style.name}>{style.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectSection>
|
||||||
|
</Select> */}
|
||||||
|
<div className="w-full space-y-1">
|
||||||
|
<label className="text-sm font-medium text-black dark:text-white">
|
||||||
|
Writing Style
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={selectedWritingSyle}
|
||||||
|
onValueChange={(value) => setSelectedWritingStyle(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
|
||||||
|
<SelectValue placeholder="Writing Style" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{writingStyle.map((style) => (
|
||||||
|
<SelectItem key={style.name} value={style.name}>
|
||||||
|
{style.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* <Select
|
||||||
|
label="Article Size"
|
||||||
|
variant="bordered"
|
||||||
|
labelPlacement="outside"
|
||||||
|
placeholder=""
|
||||||
|
selectedKeys={[selectedArticleSize]}
|
||||||
|
onChange={(e) => (e.target.value !== "" ? setSelectedArticleSize(e.target.value) : "")}
|
||||||
|
className="w-full"
|
||||||
|
classNames={{
|
||||||
|
label: "!text-black",
|
||||||
|
value: "!text-black",
|
||||||
|
trigger: ["border-1 rounded-lg", "dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400"],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectSection>
|
||||||
|
{articleSize.map((size) => (
|
||||||
|
<SelectItem key={size.value}>{size.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectSection>
|
||||||
|
</Select> */}
|
||||||
|
<div className="w-full space-y-1">
|
||||||
|
<label className="text-sm font-medium text-black dark:text-white">
|
||||||
|
Article Size
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={selectedArticleSize}
|
||||||
|
onValueChange={(value) => setSelectedArticleSize(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
|
||||||
|
<SelectValue placeholder="Writing Style" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{articleSize.map((size) => (
|
||||||
|
<SelectItem key={size.name} value={size.name}>
|
||||||
|
{size.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{/* <Select
|
||||||
|
label="Bahasa"
|
||||||
|
variant="bordered"
|
||||||
|
labelPlacement="outside"
|
||||||
|
placeholder=""
|
||||||
|
selectedKeys={[selectedLanguage]}
|
||||||
|
onChange={(e) => (e.target.value !== "" ? setSelectedLanguage(e.target.value) : "")}
|
||||||
|
className="w-full"
|
||||||
|
classNames={{
|
||||||
|
label: "!text-black",
|
||||||
|
value: "!text-black",
|
||||||
|
trigger: ["border-1 rounded-lg", "dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400"],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectSection>
|
||||||
|
<SelectItem key="id">Indonesia</SelectItem>
|
||||||
|
<SelectItem key="en">English</SelectItem>
|
||||||
|
</SelectSection>
|
||||||
|
</Select> */}
|
||||||
|
<div className="w-full space-y-1">
|
||||||
|
<label className="text-sm font-medium text-black dark:text-white">
|
||||||
|
Language
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={selectedLanguage}
|
||||||
|
onValueChange={(value) => setSelectedLanguage(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
|
||||||
|
<SelectValue placeholder="Writing Style" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="id">Indonesia</SelectItem>
|
||||||
|
<SelectItem value="en">English</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col mt-3">
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<p className="text-sm">Text</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-[78vw] lg:w-full">
|
||||||
|
<CustomEditor onChange={setMainKeyword} initialData={mainKeyword} />
|
||||||
|
</div>
|
||||||
|
{mainKeyword == "" && (
|
||||||
|
<p className="text-red-400 text-sm">Required</p>
|
||||||
|
)}
|
||||||
|
{/* {articleIds.length < 3 && (
|
||||||
|
<Button color="primary" className="my-5 w-full py-5 text-xs md:text-base" type="button" onPress={onSubmit} isDisabled={mainKeyword == ""}>
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
)} */}
|
||||||
|
{articleIds.length < 3 && (
|
||||||
|
<Button
|
||||||
|
onClick={onSubmit}
|
||||||
|
type="button"
|
||||||
|
disabled={mainKeyword === ""}
|
||||||
|
className="my-5 w-full py-5 text-xs md:text-base"
|
||||||
|
>
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{articleIds.length > 0 && (
|
||||||
|
<div className="flex flex-row gap-1 mt-2">
|
||||||
|
{/* {articleIds?.map((id, index) => (
|
||||||
|
<Button onPress={() => setSelectedId(id)} key={id} isLoading={isLoading && selectedId == id} color={selectedId == id && isLoading ? "warning" : selectedId == id ? "success" : "default"}>
|
||||||
|
<p className={selectedId == id ? "text-white" : "text-black"}>Article {index + 1}</p>
|
||||||
|
</Button>
|
||||||
|
))} */}
|
||||||
|
{articleIds?.map((id, index) => (
|
||||||
|
<Button
|
||||||
|
key={id}
|
||||||
|
onClick={() => setSelectedId(id)}
|
||||||
|
disabled={isLoading && selectedId === id}
|
||||||
|
variant={
|
||||||
|
selectedId === id
|
||||||
|
? isLoading
|
||||||
|
? "secondary"
|
||||||
|
: "default"
|
||||||
|
: "outline"
|
||||||
|
}
|
||||||
|
className={
|
||||||
|
selectedId === id ? "bg-green-600 text-white" : "text-black"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isLoading && selectedId === id
|
||||||
|
? "Loading..."
|
||||||
|
: `Article ${index + 1}`}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && (
|
||||||
|
<div>
|
||||||
|
<GetSeoScore id={String(selectedId)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,287 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { close, error, loading } from "@/config/swal";
|
||||||
|
import { delay } from "@/utils/global";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { getDetailArticle, getGenerateRewriter } from "@/service/generate-article";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import GetSeoScore from "./get-seo-score-form";
|
||||||
|
|
||||||
|
const CustomEditor = dynamic(
|
||||||
|
() => {
|
||||||
|
return import("@/components/editor/custom-editor");
|
||||||
|
},
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const writingStyle = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Friendly",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Professional",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "Informational",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: "Neutral",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: "Witty",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const articleSize = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "News (300 - 900 words)",
|
||||||
|
value: "News",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "Info (900 - 2000 words)",
|
||||||
|
value: "Info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "Detail (2000 - 5000 words)",
|
||||||
|
value: "Detail",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface DiseData {
|
||||||
|
id: number;
|
||||||
|
articleBody: string;
|
||||||
|
title: string;
|
||||||
|
metaTitle: string;
|
||||||
|
description: string;
|
||||||
|
metaDescription: string;
|
||||||
|
mainKeyword: string;
|
||||||
|
additionalKeywords: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GenerateContentRewriteForm(props: { content: (data: DiseData) => void }) {
|
||||||
|
const [selectedWritingSyle, setSelectedWritingStyle] = useState("Informational");
|
||||||
|
const [selectedArticleSize, setSelectedArticleSize] = useState("News");
|
||||||
|
const [selectedLanguage, setSelectedLanguage] = useState("id");
|
||||||
|
const [mainKeyword, setMainKeyword] = useState("");
|
||||||
|
const [articleIds, setArticleIds] = useState<number[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<number>();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
loading();
|
||||||
|
const request = {
|
||||||
|
advConfig: "",
|
||||||
|
context: mainKeyword,
|
||||||
|
style: selectedWritingSyle,
|
||||||
|
sentiment: "Informational",
|
||||||
|
urlContext: null,
|
||||||
|
contextType: "article",
|
||||||
|
lang: selectedLanguage,
|
||||||
|
createdBy: "123123",
|
||||||
|
clientId: "humasClientIdtest",
|
||||||
|
};
|
||||||
|
const res = await getGenerateRewriter(request);
|
||||||
|
close();
|
||||||
|
if (res?.error) {
|
||||||
|
error("Error");
|
||||||
|
}
|
||||||
|
setArticleIds([...articleIds, res?.data?.data?.id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getArticleDetail();
|
||||||
|
}, [selectedId]);
|
||||||
|
|
||||||
|
const checkArticleStatus = async (data: string | null) => {
|
||||||
|
if (data === null) {
|
||||||
|
delay(7000).then(() => {
|
||||||
|
getArticleDetail();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getArticleDetail = async () => {
|
||||||
|
if (selectedId) {
|
||||||
|
const res = await getDetailArticle(selectedId);
|
||||||
|
const data = res?.data?.data;
|
||||||
|
checkArticleStatus(data?.articleBody);
|
||||||
|
if (data?.articleBody !== null) {
|
||||||
|
setIsLoading(false);
|
||||||
|
props.content(data);
|
||||||
|
} else {
|
||||||
|
setIsLoading(true);
|
||||||
|
props.content({
|
||||||
|
id: data?.id,
|
||||||
|
articleBody: "",
|
||||||
|
title: "",
|
||||||
|
metaTitle: "",
|
||||||
|
description: "",
|
||||||
|
metaDescription: "",
|
||||||
|
additionalKeywords: "",
|
||||||
|
mainKeyword: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset>
|
||||||
|
<form className="flex flex-col w-full mt-3">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 w-full">
|
||||||
|
{/* <Select
|
||||||
|
label="Writing Style"
|
||||||
|
variant="bordered"
|
||||||
|
labelPlacement="outside"
|
||||||
|
placeholder=""
|
||||||
|
selectedKeys={[selectedWritingSyle]}
|
||||||
|
onChange={(e) =>
|
||||||
|
e.target.value !== ""
|
||||||
|
? setSelectedWritingStyle(e.target.value)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
classNames={{
|
||||||
|
label: "!text-black",
|
||||||
|
value: "!text-black",
|
||||||
|
trigger: [
|
||||||
|
"border-1 rounded-lg",
|
||||||
|
"dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400",
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectSection>
|
||||||
|
{writingStyle.map((style) => (
|
||||||
|
<SelectItem key={style.name}>{style.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectSection>
|
||||||
|
</Select> */}
|
||||||
|
<Select value={selectedWritingSyle} onValueChange={(value) => setSelectedWritingStyle(value)}>
|
||||||
|
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
|
||||||
|
<SelectValue placeholder="Writing Style" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{writingStyle.map((style) => (
|
||||||
|
<SelectItem key={style.name} value={style.name}>
|
||||||
|
{style.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{/* <Select
|
||||||
|
label="Article Size"
|
||||||
|
variant="bordered"
|
||||||
|
labelPlacement="outside"
|
||||||
|
placeholder=""
|
||||||
|
selectedKeys={[selectedArticleSize]}
|
||||||
|
onChange={(e) => (e.target.value !== "" ? setSelectedArticleSize(e.target.value) : "")}
|
||||||
|
className="w-full"
|
||||||
|
classNames={{
|
||||||
|
label: "!text-black",
|
||||||
|
value: "!text-black",
|
||||||
|
trigger: ["border-1 rounded-lg", "dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400"],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectSection>
|
||||||
|
{articleSize.map((size) => (
|
||||||
|
<SelectItem key={size.value}>{size.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectSection>
|
||||||
|
</Select> */}
|
||||||
|
<Select value={selectedArticleSize} onValueChange={(value) => setSelectedArticleSize(value)}>
|
||||||
|
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
|
||||||
|
<SelectValue placeholder="Writing Style" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{articleSize.map((style) => (
|
||||||
|
<SelectItem key={style.name} value={style.name}>
|
||||||
|
{style.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{/* <Select
|
||||||
|
label="Bahasa"
|
||||||
|
variant="bordered"
|
||||||
|
labelPlacement="outside"
|
||||||
|
placeholder=""
|
||||||
|
selectedKeys={[selectedLanguage]}
|
||||||
|
onChange={(e) => (e.target.value !== "" ? setSelectedLanguage(e.target.value) : "")}
|
||||||
|
className="w-full"
|
||||||
|
classNames={{
|
||||||
|
label: "!text-black",
|
||||||
|
value: "!text-black",
|
||||||
|
trigger: ["border-1 rounded-lg", "dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400"],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectSection>
|
||||||
|
<SelectItem key="id">Indonesia</SelectItem>
|
||||||
|
<SelectItem key="en">English</SelectItem>
|
||||||
|
</SelectSection>
|
||||||
|
</Select> */}
|
||||||
|
<Select value={selectedLanguage} onValueChange={(value) => setSelectedLanguage(value)}>
|
||||||
|
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
|
||||||
|
<SelectValue placeholder="Writing Style" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="id">Indonesia</SelectItem>
|
||||||
|
<SelectItem value="en">English</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col mt-3">
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<p className="text-sm">Text</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-[78vw] lg:w-full">
|
||||||
|
<CustomEditor onChange={setMainKeyword} initialData={mainKeyword} />
|
||||||
|
</div>
|
||||||
|
{mainKeyword == "" && <p className="text-red-400 text-sm">Required</p>}
|
||||||
|
{articleIds.length < 3 && (
|
||||||
|
<Button onClick={onSubmit} type="button" disabled={mainKeyword === "" || isLoading} className="my-5 w-full py-5 text-xs md:text-base">
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Generate"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{articleIds.length > 0 && (
|
||||||
|
<div className="flex flex-row gap-1 mt-2">
|
||||||
|
{articleIds?.map((id, index) => (
|
||||||
|
<Button key={id} onClick={() => setSelectedId(id)} disabled={isLoading && selectedId === id} variant={selectedId === id ? "default" : "outline"} className="flex items-center gap-2">
|
||||||
|
{isLoading && selectedId === id ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
`Article ${index + 1}`
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && (
|
||||||
|
<div>
|
||||||
|
<GetSeoScore id={String(selectedId)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,448 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { close, error, loading } from "@/config/swal";
|
||||||
|
import { delay } from "@/utils/global";
|
||||||
|
import GetSeoScore from "./get-seo-score-form";
|
||||||
|
import {
|
||||||
|
generateDataArticle,
|
||||||
|
getDetailArticle,
|
||||||
|
getGenerateKeywords,
|
||||||
|
getGenerateTitle,
|
||||||
|
} from "@/service/generate-article";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
const writingStyle = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Friendly",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Professional",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "Informational",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: "Neutral",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: "Witty",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const articleSize = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "News (300 - 900 words)",
|
||||||
|
value: "News",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "Info (900 - 2000 words)",
|
||||||
|
value: "Info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "Detail (2000 - 5000 words)",
|
||||||
|
value: "Detail",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface DiseData {
|
||||||
|
id: number;
|
||||||
|
articleBody: string;
|
||||||
|
title: string;
|
||||||
|
metaTitle: string;
|
||||||
|
description: string;
|
||||||
|
metaDescription: string;
|
||||||
|
mainKeyword: string;
|
||||||
|
additionalKeywords: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GenerateSingleArticleForm(props: {
|
||||||
|
content: (data: DiseData) => void;
|
||||||
|
}) {
|
||||||
|
const [selectedWritingSyle, setSelectedWritingStyle] =
|
||||||
|
useState("Informational");
|
||||||
|
const [selectedArticleSize, setSelectedArticleSize] = useState("News");
|
||||||
|
const [selectedLanguage, setSelectedLanguage] = useState("id");
|
||||||
|
const [mainKeyword, setMainKeyword] = useState("");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [additionalKeyword, setAdditionalKeyword] = useState("");
|
||||||
|
const [articleIds, setArticleIds] = useState<number[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<number>();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const generateAll = async (keyword: string | undefined) => {
|
||||||
|
if (keyword) {
|
||||||
|
generateTitle(keyword);
|
||||||
|
generateKeywords(keyword);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateTitle = async (keyword: string | undefined) => {
|
||||||
|
if (keyword) {
|
||||||
|
loading();
|
||||||
|
const req = {
|
||||||
|
keyword: keyword,
|
||||||
|
style: selectedWritingSyle,
|
||||||
|
website: "None",
|
||||||
|
connectToWeb: true,
|
||||||
|
lang: selectedLanguage,
|
||||||
|
pointOfView: "None",
|
||||||
|
clientId: "",
|
||||||
|
};
|
||||||
|
const res = await getGenerateTitle(req);
|
||||||
|
const data = res?.data?.data;
|
||||||
|
setTitle(data);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateKeywords = async (keyword: string | undefined) => {
|
||||||
|
if (keyword) {
|
||||||
|
const req = {
|
||||||
|
keyword: keyword,
|
||||||
|
style: selectedWritingSyle,
|
||||||
|
website: "None",
|
||||||
|
connectToWeb: true,
|
||||||
|
lang: selectedLanguage,
|
||||||
|
pointOfView: "0",
|
||||||
|
clientId: "",
|
||||||
|
};
|
||||||
|
loading();
|
||||||
|
const res = await getGenerateKeywords(req);
|
||||||
|
const data = res?.data?.data;
|
||||||
|
setAdditionalKeyword(data);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
loading();
|
||||||
|
const request = {
|
||||||
|
advConfig: "",
|
||||||
|
style: selectedWritingSyle,
|
||||||
|
website: "None",
|
||||||
|
connectToWeb: true,
|
||||||
|
lang: selectedLanguage,
|
||||||
|
pointOfView: "None",
|
||||||
|
title: title,
|
||||||
|
imageSource: "Web",
|
||||||
|
mainKeyword: mainKeyword,
|
||||||
|
additionalKeywords: additionalKeyword,
|
||||||
|
targetCountry: null,
|
||||||
|
articleSize: selectedArticleSize,
|
||||||
|
projectId: 2,
|
||||||
|
createdBy: "123123",
|
||||||
|
clientId: "humasClientIdtest",
|
||||||
|
};
|
||||||
|
const res = await generateDataArticle(request);
|
||||||
|
close();
|
||||||
|
if (res?.error) {
|
||||||
|
error("Error");
|
||||||
|
}
|
||||||
|
setArticleIds([...articleIds, res?.data?.data?.id]);
|
||||||
|
// props.articleId(res?.data?.data?.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getArticleDetail();
|
||||||
|
}, [selectedId]);
|
||||||
|
|
||||||
|
const checkArticleStatus = async (data: string | null) => {
|
||||||
|
if (data === null) {
|
||||||
|
delay(7000).then(() => {
|
||||||
|
getArticleDetail();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getArticleDetail = async () => {
|
||||||
|
if (selectedId) {
|
||||||
|
const res = await getDetailArticle(selectedId);
|
||||||
|
const data = res?.data?.data;
|
||||||
|
checkArticleStatus(data?.articleBody);
|
||||||
|
if (data?.articleBody !== null) {
|
||||||
|
setIsLoading(false);
|
||||||
|
props.content(data);
|
||||||
|
} else {
|
||||||
|
setIsLoading(true);
|
||||||
|
props.content({
|
||||||
|
id: data?.id,
|
||||||
|
articleBody: "",
|
||||||
|
title: "",
|
||||||
|
metaTitle: "",
|
||||||
|
description: "",
|
||||||
|
metaDescription: "",
|
||||||
|
additionalKeywords: "",
|
||||||
|
mainKeyword: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset>
|
||||||
|
<form className="flex flex-col w-full mt-3">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 w-full">
|
||||||
|
{/* <Select
|
||||||
|
label="Writing Style"
|
||||||
|
variant="bordered"
|
||||||
|
labelPlacement="outside"
|
||||||
|
placeholder=""
|
||||||
|
selectedKeys={[selectedWritingSyle]}
|
||||||
|
onChange={(e) =>
|
||||||
|
e.target.value !== ""
|
||||||
|
? setSelectedWritingStyle(e.target.value)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
classNames={{
|
||||||
|
label: "!text-black",
|
||||||
|
value: "!text-black",
|
||||||
|
trigger: [
|
||||||
|
"border-1 rounded-lg",
|
||||||
|
"dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400",
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectSection>
|
||||||
|
{writingStyle.map((style) => (
|
||||||
|
<SelectItem key={style.name}>{style.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectSection>
|
||||||
|
</Select> */}
|
||||||
|
<Select
|
||||||
|
value={selectedWritingSyle}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value !== "") setSelectedWritingStyle(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full border border-gray-300 dark:border-gray-400 rounded-lg text-black">
|
||||||
|
<SelectValue placeholder="Writing Style" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{writingStyle.map((style) => (
|
||||||
|
<SelectItem key={style.name} value={style.name}>
|
||||||
|
{style.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{/* <Select
|
||||||
|
label="Article Size"
|
||||||
|
variant="bordered"
|
||||||
|
labelPlacement="outside"
|
||||||
|
placeholder=""
|
||||||
|
selectedKeys={[selectedArticleSize]}
|
||||||
|
onChange={(e) => (e.target.value !== "" ? setSelectedArticleSize(e.target.value) : "")}
|
||||||
|
className="w-full"
|
||||||
|
classNames={{
|
||||||
|
label: "!text-black",
|
||||||
|
value: "!text-black",
|
||||||
|
trigger: ["border-1 rounded-lg", "dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400"],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectSection>
|
||||||
|
{articleSize.map((size) => (
|
||||||
|
<SelectItem key={size.value}>{size.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectSection>
|
||||||
|
</Select> */}
|
||||||
|
<Select
|
||||||
|
value={selectedArticleSize}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value !== "") setSelectedArticleSize(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full border border-gray-300 dark:border-gray-400 rounded-lg text-black">
|
||||||
|
<SelectValue placeholder="Writing Style" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{articleSize.map((style) => (
|
||||||
|
<SelectItem key={style.name} value={style.name}>
|
||||||
|
{style.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{/* <Select
|
||||||
|
label="Bahasa"
|
||||||
|
variant="bordered"
|
||||||
|
labelPlacement="outside"
|
||||||
|
placeholder=""
|
||||||
|
selectedKeys={[selectedLanguage]}
|
||||||
|
onChange={(e) => (e.target.value !== "" ? setSelectedLanguage(e.target.value) : "")}
|
||||||
|
className="w-full"
|
||||||
|
classNames={{
|
||||||
|
label: "!text-black",
|
||||||
|
value: "!text-black",
|
||||||
|
trigger: ["border-1 rounded-lg", "dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400"],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectSection>
|
||||||
|
<SelectItem key="id">Indonesia</SelectItem>
|
||||||
|
<SelectItem key="en">English</SelectItem>
|
||||||
|
</SelectSection>
|
||||||
|
</Select> */}
|
||||||
|
<Select
|
||||||
|
value={selectedLanguage}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value !== "") setSelectedLanguage(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full border border-gray-300 dark:border-gray-400 rounded-lg text-black">
|
||||||
|
<SelectValue placeholder="Bahasa" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="id">Indonesia</SelectItem>
|
||||||
|
<SelectItem value="en">English</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col mt-3">
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<p className="text-sm">Main Keyword</p>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => generateAll(mainKeyword)}
|
||||||
|
disabled={isLoading} // tambahkan state kontrol loading
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Process"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="mainKeyword"
|
||||||
|
placeholder="Masukkan keyword utama"
|
||||||
|
value={mainKeyword}
|
||||||
|
onChange={(e) => setMainKeyword(e.target.value)}
|
||||||
|
className="w-full mt-1 border border-gray-300 rounded-lg dark:border-gray-400"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{mainKeyword == "" && (
|
||||||
|
<p className="text-red-400 text-sm">Required</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-row gap-2 items-center mt-3">
|
||||||
|
<p className="text-sm">Title</p>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => generateTitle(mainKeyword)}
|
||||||
|
disabled={mainKeyword === ""}
|
||||||
|
>
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
placeholder=""
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="w-full mt-1 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" // Custom styling using className
|
||||||
|
aria-label="Title"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* {title == "" && <p className="text-red-400 text-sm">Required</p>} */}
|
||||||
|
<div className="flex flex-row gap-2 items-center mt-2">
|
||||||
|
<p className="text-sm">Additional Keyword</p>
|
||||||
|
<Button
|
||||||
|
className="text-sm"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => generateKeywords(mainKeyword)}
|
||||||
|
disabled={mainKeyword === ""}
|
||||||
|
>
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="additionalKeyword"
|
||||||
|
placeholder=""
|
||||||
|
value={additionalKeyword}
|
||||||
|
onChange={(e) => setAdditionalKeyword(e.target.value)}
|
||||||
|
className="mt-1 border rounded-lg dark:bg-transparent dark:border-gray-400"
|
||||||
|
aria-label="Additional Keyword"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* {additionalKeyword == "" && (
|
||||||
|
<p className="text-red-400 text-sm">Required</p>
|
||||||
|
)} */}
|
||||||
|
{/* {articleIds.length < 3 && (
|
||||||
|
<Button color="primary" className="my-5 w-full py-5 text-xs md:text-base" type="button" onPress={onSubmit} isDisabled={mainKeyword == "" || title == "" || additionalKeyword == ""}>
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
)} */}
|
||||||
|
{articleIds.length < 3 && (
|
||||||
|
<Button
|
||||||
|
className="my-5 w-full py-5 text-xs md:text-base"
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={
|
||||||
|
mainKeyword === "" || title === "" || additionalKeyword === ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{articleIds.length > 0 && (
|
||||||
|
<div className="flex flex-row gap-1 mt-2">
|
||||||
|
{articleIds.map((id, index) => (
|
||||||
|
<Button
|
||||||
|
key={id}
|
||||||
|
onClick={() => setSelectedId(id)}
|
||||||
|
disabled={isLoading && selectedId === id}
|
||||||
|
className={`
|
||||||
|
${
|
||||||
|
selectedId === id
|
||||||
|
? isLoading
|
||||||
|
? "bg-yellow-500"
|
||||||
|
: "bg-green-600"
|
||||||
|
: "bg-gray-200"
|
||||||
|
}
|
||||||
|
text-sm px-4 py-2 rounded text-white transition-colors
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
Article {index + 1}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && (
|
||||||
|
<div>
|
||||||
|
<GetSeoScore id={String(selectedId)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { CustomCircularProgress } from "@/components/layout/costum-circular-progress";
|
||||||
|
import { getSeoScore } from "@/service/generate-article";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function GetSeoScore(props: { id: string }) {
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSeoScore();
|
||||||
|
}, [props.id]);
|
||||||
|
|
||||||
|
const [totalScoreSEO, setTotalScoreSEO] = useState();
|
||||||
|
const [errorSEO, setErrorSEO] = useState<any>([]);
|
||||||
|
const [warningSEO, setWarningSEO] = useState<any>([]);
|
||||||
|
const [optimizedSEO, setOptimizedSEO] = useState<any>([]);
|
||||||
|
|
||||||
|
const fetchSeoScore = async () => {
|
||||||
|
const res = await getSeoScore(props?.id);
|
||||||
|
if (res.error) {
|
||||||
|
// error(res.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setTotalScoreSEO(res.data.data?.seo_analysis?.score || 0);
|
||||||
|
const errorList: any[] = [
|
||||||
|
...res.data.data?.seo_analysis?.analysis?.keyword_optimization?.error,
|
||||||
|
...res.data.data?.seo_analysis?.analysis?.content_quality?.error,
|
||||||
|
];
|
||||||
|
setErrorSEO(errorList);
|
||||||
|
const warningList: any[] = [
|
||||||
|
...res.data.data?.seo_analysis?.analysis?.keyword_optimization?.warning,
|
||||||
|
...res.data.data?.seo_analysis?.analysis?.content_quality?.warning,
|
||||||
|
];
|
||||||
|
setWarningSEO(warningList);
|
||||||
|
const optimizedList: any[] = [
|
||||||
|
...res.data.data?.seo_analysis?.analysis?.keyword_optimization?.optimized,
|
||||||
|
...res.data.data?.seo_analysis?.analysis?.content_quality?.optimized,
|
||||||
|
];
|
||||||
|
setOptimizedSEO(optimizedList);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="overflow-y-auto my-2">
|
||||||
|
<div className="text-black flex flex-col rounded-md gap-3">
|
||||||
|
<p className="font-semibold text-lg"> SEO Score</p>
|
||||||
|
{totalScoreSEO ? (
|
||||||
|
<div className="flex flex-row gap-5 w-full">
|
||||||
|
{/* <CircularProgress
|
||||||
|
aria-label=""
|
||||||
|
color="warning"
|
||||||
|
showValueLabel={true}
|
||||||
|
size="lg"
|
||||||
|
value={Number(totalScoreSEO) * 100}
|
||||||
|
/> */}
|
||||||
|
<CustomCircularProgress value={Number(totalScoreSEO) * 100} />
|
||||||
|
<div>
|
||||||
|
{/* <ApexChartDonut value={Number(totalScoreSEO) * 100} /> */}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row gap-5">
|
||||||
|
<div className="px-2 py-1 border radius-md flex flex-row gap-2 items-center border-red-500 rounded-lg">
|
||||||
|
{/* <TimesIcon size={15} className="text-danger" /> */}
|
||||||
|
Error : {errorSEO.length || 0}
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-1 border radius-md flex flex-row gap-2 items-center border-yellow-500 rounded-lg">
|
||||||
|
{/* <p className="text-warning w-[15px] h-[15px] text-center mt-[-10px]">
|
||||||
|
!
|
||||||
|
</p> */}
|
||||||
|
Warning : {warningSEO.length || 0}
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-1 border radius-md flex flex-row gap-2 items-center border-green-500 rounded-lg">
|
||||||
|
{/* <CheckIcon size={15} className="text-success" /> */}
|
||||||
|
Optimize : {optimizedSEO.length || 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"Belum ada Data"
|
||||||
|
)}
|
||||||
|
{totalScoreSEO && (
|
||||||
|
// <Accordion
|
||||||
|
// variant="splitted"
|
||||||
|
// itemClasses={{
|
||||||
|
// base: "!bg-transparent",
|
||||||
|
// title: "text-black",
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// <AccordionItem
|
||||||
|
// key="1"
|
||||||
|
// aria-label="Error"
|
||||||
|
// // startContent={<TimesIcon size={20} className="text-danger" />}
|
||||||
|
// title={`${errorSEO?.length || 0} Errors`}
|
||||||
|
// >
|
||||||
|
// <div className="flex flex-col gap-2">
|
||||||
|
// {errorSEO?.map((item: any) => (
|
||||||
|
// <p key={item} className="w-full border border-red-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3">
|
||||||
|
// {item}
|
||||||
|
// </p>
|
||||||
|
// ))}
|
||||||
|
// </div>
|
||||||
|
// </AccordionItem>
|
||||||
|
// <AccordionItem
|
||||||
|
// key="2"
|
||||||
|
// aria-label="Warning"
|
||||||
|
// // startContent={
|
||||||
|
// // <p className="text-warning w-[20px] h-[20px] text-center mt-[-10px]">
|
||||||
|
// // !
|
||||||
|
// // </p>
|
||||||
|
// // }
|
||||||
|
// title={`${warningSEO?.length || 0} Warnings`}
|
||||||
|
// >
|
||||||
|
// <div className="flex flex-col gap-2">
|
||||||
|
// {warningSEO?.map((item: any) => (
|
||||||
|
// <p key={item} className="w-full border border-yellow-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3">
|
||||||
|
// {item}
|
||||||
|
// </p>
|
||||||
|
// ))}
|
||||||
|
// </div>
|
||||||
|
// </AccordionItem>
|
||||||
|
// <AccordionItem
|
||||||
|
// key="3"
|
||||||
|
// aria-label="Optimized"
|
||||||
|
// // startContent={<CheckIcon size={20} className="text-success" />}
|
||||||
|
// title={`${optimizedSEO?.length || 0} Optimized`}
|
||||||
|
// >
|
||||||
|
// <div className="flex flex-col gap-2">
|
||||||
|
// {optimizedSEO?.map((item: any) => (
|
||||||
|
// <p key={item} className="w-full border border-green-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3">
|
||||||
|
// {item}
|
||||||
|
// </p>
|
||||||
|
// ))}
|
||||||
|
// </div>
|
||||||
|
// </AccordionItem>
|
||||||
|
// </Accordion>
|
||||||
|
<Accordion type="multiple" className="w-full">
|
||||||
|
<AccordionItem value="error">
|
||||||
|
<AccordionTrigger className="text-black">{`${
|
||||||
|
errorSEO?.length || 0
|
||||||
|
} Errors`}</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{errorSEO?.map((item: any) => (
|
||||||
|
<p
|
||||||
|
key={item}
|
||||||
|
className="w-full border border-red-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem value="warning">
|
||||||
|
<AccordionTrigger className="text-black">{`${
|
||||||
|
warningSEO?.length || 0
|
||||||
|
} Warnings`}</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{warningSEO?.map((item: any) => (
|
||||||
|
<p
|
||||||
|
key={item}
|
||||||
|
className="w-full border border-yellow-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem value="optimized">
|
||||||
|
<AccordionTrigger className="text-black">{`${
|
||||||
|
optimizedSEO?.length || 0
|
||||||
|
} Optimized`}</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{optimizedSEO?.map((item: any) => (
|
||||||
|
<p
|
||||||
|
key={item}
|
||||||
|
className="w-full border border-green-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,442 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { close, error, loading } from "@/config/swal";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
import withReactContent from "sweetalert2-react-content";
|
||||||
|
import { z } from "zod";
|
||||||
|
import ReactSelect from "react-select";
|
||||||
|
import makeAnimated from "react-select/animated";
|
||||||
|
import { editMasterUsers, getDetailMasterUsers } from "@/service/master-user";
|
||||||
|
import { getAllUserLevels } from "@/service/user-levels-service";
|
||||||
|
import { listUserRole } from "@/service/master-user-role";
|
||||||
|
import { Card } from "../ui/card";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Textarea } from "../ui/textarea";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
|
const userSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
label: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const masterUserSchema = z.object({
|
||||||
|
fullname: z.string().min(1, { message: "Required" }),
|
||||||
|
username: z.string().min(1, { message: "Required" }),
|
||||||
|
email: z.string().min(1, { message: "Required" }),
|
||||||
|
identityNumber: z.string().min(1, { message: "Required" }),
|
||||||
|
genderType: z.string().min(1, { message: "Required" }),
|
||||||
|
phoneNumber: z.string().min(1, { message: "Required" }),
|
||||||
|
address: z.string().min(1, { message: "Required" }),
|
||||||
|
userLevelType: userSchema,
|
||||||
|
userRoleType: userSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function FormMasterUserEdit() {
|
||||||
|
const router = useRouter();
|
||||||
|
const MySwal = withReactContent(Swal);
|
||||||
|
const animatedComponents = makeAnimated();
|
||||||
|
const params = useParams();
|
||||||
|
const id = params?.id;
|
||||||
|
const [parentList, setParentList] = useState<any>([]);
|
||||||
|
const [listRole, setListRole] = useState<any>([]);
|
||||||
|
|
||||||
|
const formOptions = {
|
||||||
|
resolver: zodResolver(masterUserSchema),
|
||||||
|
};
|
||||||
|
type MicroIssueSchema = z.infer<typeof masterUserSchema>;
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
|
||||||
|
setValue,
|
||||||
|
} = useForm<MicroIssueSchema>(formOptions);
|
||||||
|
|
||||||
|
async function save(data: z.infer<typeof masterUserSchema>) {
|
||||||
|
const formData = {
|
||||||
|
address: data.address,
|
||||||
|
email: data.email,
|
||||||
|
fullname: data.fullname,
|
||||||
|
genderType: data.genderType,
|
||||||
|
identityNumber: data.identityNumber,
|
||||||
|
phoneNumber: data.phoneNumber,
|
||||||
|
userLevelId: data.userLevelType.id,
|
||||||
|
userRoleId: data.userRoleType.id,
|
||||||
|
username: data.username,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await editMasterUsers(formData, String(id));
|
||||||
|
|
||||||
|
if (response?.error) {
|
||||||
|
error(response.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
successSubmit("/admin/master-user");
|
||||||
|
}
|
||||||
|
|
||||||
|
function successSubmit(redirect: any) {
|
||||||
|
MySwal.fire({
|
||||||
|
title: "Sukses",
|
||||||
|
icon: "success",
|
||||||
|
confirmButtonColor: "#3085d6",
|
||||||
|
confirmButtonText: "OK",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
router.push(redirect);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(data: z.infer<typeof masterUserSchema>) {
|
||||||
|
MySwal.fire({
|
||||||
|
title: "Simpan Data",
|
||||||
|
text: "",
|
||||||
|
icon: "warning",
|
||||||
|
showCancelButton: true,
|
||||||
|
cancelButtonColor: "#d33",
|
||||||
|
confirmButtonColor: "#3085d6",
|
||||||
|
confirmButtonText: "Simpan",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
save(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initFetch();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const initFetch = async () => {
|
||||||
|
loading();
|
||||||
|
const res = await getDetailMasterUsers(String(id));
|
||||||
|
const profile = res?.data?.data;
|
||||||
|
const listLevel = await fetchUserLevel();
|
||||||
|
const listRole = await fetchUserRole();
|
||||||
|
const findLevel = listLevel?.find((a) => a.id === profile.userLevelId);
|
||||||
|
const findRole = listRole?.find((a) => a.id === profile.userRoleId);
|
||||||
|
setValue("fullname", profile?.fullname);
|
||||||
|
setValue("username", profile?.username);
|
||||||
|
setValue("email", profile?.email);
|
||||||
|
setValue("address", profile?.address);
|
||||||
|
setValue("identityNumber", profile?.identityNumber);
|
||||||
|
setValue("genderType", profile?.genderType);
|
||||||
|
setValue("phoneNumber", profile?.phoneNumber);
|
||||||
|
if (findLevel) {
|
||||||
|
setValue("userLevelType", findLevel);
|
||||||
|
}
|
||||||
|
if (findRole) {
|
||||||
|
setValue("userRoleType", findRole);
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchUserLevel = async () => {
|
||||||
|
const res = await getAllUserLevels();
|
||||||
|
if (res?.data?.data) {
|
||||||
|
return setupParent(res?.data?.data, "level");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const fetchUserRole = async () => {
|
||||||
|
const request = {
|
||||||
|
limit: 100,
|
||||||
|
page: 1,
|
||||||
|
};
|
||||||
|
const res = await listUserRole(request);
|
||||||
|
if (res?.data?.data) {
|
||||||
|
return setupParent(res?.data?.data, "role");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupParent = (data: any, type: "level" | "role") => {
|
||||||
|
const temp = [];
|
||||||
|
for (const element of data) {
|
||||||
|
temp.push({
|
||||||
|
id: element.id,
|
||||||
|
label: element.name,
|
||||||
|
value: element.aliasName || element.code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type === "level") {
|
||||||
|
setParentList(temp);
|
||||||
|
} else {
|
||||||
|
setListRole(temp);
|
||||||
|
}
|
||||||
|
return temp;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-5 my-5 overflow-y-auto">
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="w-full lg:w-1/2 lg:ml-4"
|
||||||
|
>
|
||||||
|
<Card className="rounded-md p-5 flex flex-col gap-3">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="fullname"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<Label htmlFor="title">Nama Lengkap</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
placeholder="Nama Lengkap..."
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg dark:bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.fullname?.message && (
|
||||||
|
<p className="text-red-400 text-sm">{errors.fullname?.message}</p>
|
||||||
|
)}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="username"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
placeholder="Username..."
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg dark:bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.username?.message && (
|
||||||
|
<p className="text-red-400 text-sm">{errors.username?.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="email"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
placeholder="Email..."
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg dark:bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.email?.message && (
|
||||||
|
<p className="text-red-400 text-sm">{errors.email?.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* <Controller
|
||||||
|
control={control}
|
||||||
|
name="identityType"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Select
|
||||||
|
variant="bordered"
|
||||||
|
labelPlacement="outside"
|
||||||
|
label="Identity Type"
|
||||||
|
placeholder="Select"
|
||||||
|
className="max-w-xs"
|
||||||
|
selectedKeys={[value]}
|
||||||
|
onChange={onChange}
|
||||||
|
>
|
||||||
|
{typeIdentity.map((type) => (
|
||||||
|
<SelectItem key={type.value}>{type.value}</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.identityType?.message && (
|
||||||
|
<p className="text-red-400 text-sm">
|
||||||
|
{errors.identityType?.message}
|
||||||
|
</p>
|
||||||
|
)} */}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="identityNumber"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<Label htmlFor="identityNumber">NRP</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
id="identityNumber"
|
||||||
|
placeholder="NRP..."
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg dark:bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.identityNumber?.message && (
|
||||||
|
<p className="text-red-400 text-sm">
|
||||||
|
{errors.identityNumber?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="address"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<Label htmlFor="alamat">Alamat</Label>
|
||||||
|
<Textarea
|
||||||
|
id="alamat"
|
||||||
|
placeholder="Alamat..."
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => onChange(e)}
|
||||||
|
className="border border-gray-300 dark:border-gray-400 rounded-lg dark:bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.address?.message && (
|
||||||
|
<p className="text-red-400 text-sm">{errors.address?.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="genderType"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="gender">Gender</Label>
|
||||||
|
<RadioGroup
|
||||||
|
id="gender"
|
||||||
|
value={value}
|
||||||
|
onValueChange={onChange}
|
||||||
|
className="flex flex-row gap-6"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="Male" id="male" />
|
||||||
|
<Label htmlFor="male">Laki-laki</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="Female" id="female" />
|
||||||
|
<Label htmlFor="female">Perempuan</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.genderType?.message && (
|
||||||
|
<p className="text-red-400 text-sm">{errors.genderType?.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="userLevelType"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<>
|
||||||
|
<p className="text-sm mt-3">Level Pengguna</p>
|
||||||
|
<ReactSelect
|
||||||
|
className="basic-single text-black z-50"
|
||||||
|
classNames={{
|
||||||
|
control: (state: any) =>
|
||||||
|
"!rounded-lg bg-white !border-1 !border-gray-200 dark:!border-stone-500",
|
||||||
|
}}
|
||||||
|
classNamePrefix="select"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
closeMenuOnSelect={false}
|
||||||
|
components={animatedComponents}
|
||||||
|
isClearable={true}
|
||||||
|
isSearchable={true}
|
||||||
|
isMulti={false}
|
||||||
|
placeholder=""
|
||||||
|
name="sub-module"
|
||||||
|
options={parentList}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.userLevelType?.message && (
|
||||||
|
<p className="text-red-400 text-sm">
|
||||||
|
{errors.userLevelType?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="userRoleType"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<>
|
||||||
|
<p className="text-sm mt-3">Peran Pengguna</p>
|
||||||
|
<ReactSelect
|
||||||
|
className="basic-single text-black z-49"
|
||||||
|
classNames={{
|
||||||
|
control: (state: any) =>
|
||||||
|
"!rounded-lg bg-white !border-1 !border-gray-200 dark:!border-stone-500",
|
||||||
|
}}
|
||||||
|
classNamePrefix="select"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
closeMenuOnSelect={false}
|
||||||
|
components={animatedComponents}
|
||||||
|
isClearable={true}
|
||||||
|
isSearchable={true}
|
||||||
|
isMulti={false}
|
||||||
|
placeholder=""
|
||||||
|
name="sub-module"
|
||||||
|
options={listRole}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.userRoleType?.message && (
|
||||||
|
<p className="text-red-400 text-sm">
|
||||||
|
{errors.userRoleType?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="phoneNumber"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="w-full z-0 space-y-2">
|
||||||
|
<Label htmlFor="identityNumber">No. Handphone</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
id="identityNumber"
|
||||||
|
placeholder="08*********"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.phoneNumber?.message && (
|
||||||
|
<p className="text-red-400 text-sm">
|
||||||
|
{errors.phoneNumber?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Link href={`/admin/master-user`}>
|
||||||
|
<Button color="danger" variant="ghost">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button type="submit" color="primary" variant="default">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,577 @@
|
||||||
|
"use client";
|
||||||
|
import { close, error, loading } from "@/config/swal";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
import withReactContent from "sweetalert2-react-content";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { EyeFilledIcon, EyeSlashFilledIcon } from "../icons";
|
||||||
|
import ReactPasswordChecklist from "react-password-checklist";
|
||||||
|
import ReactSelect from "react-select";
|
||||||
|
import makeAnimated from "react-select/animated";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { createMasterUser } from "@/service/master-user";
|
||||||
|
import { getAllUserLevels } from "@/service/user-levels-service";
|
||||||
|
import { listUserRole } from "@/service/master-user-role";
|
||||||
|
import { Card } from "../ui/card";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import { Textarea } from "../ui/textarea";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
|
const userSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
label: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const masterUserSchema = z.object({
|
||||||
|
fullname: z.string().min(1, { message: "Required" }),
|
||||||
|
username: z.string().min(1, { message: "Required" }),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(8, "Password harus memiliki minimal 8 karakter.")
|
||||||
|
.refine((password) => /[A-Z]/.test(password), {
|
||||||
|
message: "Password harus memiliki minimal satu huruf kapital.",
|
||||||
|
})
|
||||||
|
.refine((password) => /[0-9]/.test(password), {
|
||||||
|
message: "Password harus memiliki minimal satu angka.",
|
||||||
|
})
|
||||||
|
.refine((password) => /[!@#$%^&*(),.?":{}|<>]/.test(password), {
|
||||||
|
message: "Password harus memiliki minimal satu simbol.",
|
||||||
|
}),
|
||||||
|
passwordValidate: z.string().min(1, { message: "Required" }),
|
||||||
|
email: z.string().min(1, { message: "Required" }),
|
||||||
|
identityNumber: z.string().min(1, { message: "Required" }),
|
||||||
|
genderType: z.string().min(1, { message: "Required" }),
|
||||||
|
phoneNumber: z.string().min(1, { message: "Required" }),
|
||||||
|
address: z.string().min(1, { message: "Required" }),
|
||||||
|
userLevelType: userSchema,
|
||||||
|
userRoleType: userSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function FormMasterUser() {
|
||||||
|
const router = useRouter();
|
||||||
|
const animatedComponents = makeAnimated();
|
||||||
|
|
||||||
|
const MySwal = withReactContent(Swal);
|
||||||
|
const [isVisible, setIsVisible] = useState([false, false]);
|
||||||
|
const [isValidPassword, setIsValidPassword] = useState(false);
|
||||||
|
const [parentList, setParentList] = useState<any>([]);
|
||||||
|
const [listRole, setListRole] = useState<any>([]);
|
||||||
|
|
||||||
|
const toggleVisibility = (type: number) => {
|
||||||
|
setIsVisible(
|
||||||
|
type === 0 ? [!isVisible[0], isVisible[1]] : [isVisible[0], !isVisible[1]]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formOptions = {
|
||||||
|
resolver: zodResolver(masterUserSchema),
|
||||||
|
defaultValues: { password: "", passwordValidate: "" },
|
||||||
|
};
|
||||||
|
type MicroIssueSchema = z.infer<typeof masterUserSchema>;
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
setError,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
} = useForm<MicroIssueSchema>(formOptions);
|
||||||
|
|
||||||
|
const passwordVal = watch("password");
|
||||||
|
const passwordConfVal = watch("passwordValidate");
|
||||||
|
|
||||||
|
async function save(data: z.infer<typeof masterUserSchema>) {
|
||||||
|
const formData = {
|
||||||
|
address: data.address,
|
||||||
|
password: data.password,
|
||||||
|
email: data.email,
|
||||||
|
fullname: data.fullname,
|
||||||
|
genderType: data.genderType,
|
||||||
|
identityNumber: data.identityNumber,
|
||||||
|
identityType: "nrp",
|
||||||
|
phoneNumber: data.phoneNumber,
|
||||||
|
userLevelId: data.userLevelType.id,
|
||||||
|
userRoleId: data.userRoleType.id,
|
||||||
|
username: data.username,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await createMasterUser(formData);
|
||||||
|
|
||||||
|
if (response?.error) {
|
||||||
|
error(response.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
successSubmit("/admin/master-user");
|
||||||
|
}
|
||||||
|
|
||||||
|
function successSubmit(redirect: any) {
|
||||||
|
MySwal.fire({
|
||||||
|
title: "Sukses",
|
||||||
|
icon: "success",
|
||||||
|
confirmButtonColor: "#3085d6",
|
||||||
|
confirmButtonText: "OK",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
router.push(redirect);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(data: z.infer<typeof masterUserSchema>) {
|
||||||
|
if (data.password === data.passwordValidate) {
|
||||||
|
MySwal.fire({
|
||||||
|
title: "Simpan Data",
|
||||||
|
text: "",
|
||||||
|
icon: "warning",
|
||||||
|
showCancelButton: true,
|
||||||
|
cancelButtonColor: "#d33",
|
||||||
|
confirmButtonColor: "#3085d6",
|
||||||
|
confirmButtonText: "Simpan",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
save(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setError("passwordValidate", {
|
||||||
|
type: "manual",
|
||||||
|
message: "Password harus sama.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatePassword = () => {
|
||||||
|
const length = Math.floor(Math.random() * 9) + 8;
|
||||||
|
|
||||||
|
const upperCaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
|
const lowerCaseChars = "abcdefghijklmnopqrstuvwxyz";
|
||||||
|
const numberChars = "0123456789";
|
||||||
|
const specialChars = "!@#$%^&*";
|
||||||
|
const allChars =
|
||||||
|
upperCaseChars + lowerCaseChars + numberChars + specialChars;
|
||||||
|
|
||||||
|
let generatedPassword = "";
|
||||||
|
|
||||||
|
generatedPassword +=
|
||||||
|
upperCaseChars[Math.floor(Math.random() * upperCaseChars.length)];
|
||||||
|
generatedPassword +=
|
||||||
|
specialChars[Math.floor(Math.random() * specialChars.length)];
|
||||||
|
generatedPassword +=
|
||||||
|
numberChars[Math.floor(Math.random() * numberChars.length)];
|
||||||
|
generatedPassword +=
|
||||||
|
lowerCaseChars[Math.floor(Math.random() * lowerCaseChars.length)];
|
||||||
|
|
||||||
|
for (let i = generatedPassword.length; i < length; i++) {
|
||||||
|
generatedPassword +=
|
||||||
|
allChars[Math.floor(Math.random() * allChars.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
generatedPassword = generatedPassword
|
||||||
|
.split("")
|
||||||
|
.sort(() => 0.5 - Math.random())
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
setValue("password", generatedPassword);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUserLevel();
|
||||||
|
fetchUserRole();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchUserLevel = async () => {
|
||||||
|
loading();
|
||||||
|
const res = await getAllUserLevels();
|
||||||
|
close();
|
||||||
|
if (res?.data?.data) {
|
||||||
|
setupParent(res?.data?.data, "level");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const fetchUserRole = async () => {
|
||||||
|
loading();
|
||||||
|
const request = {
|
||||||
|
limit: 100,
|
||||||
|
page: 1,
|
||||||
|
};
|
||||||
|
const res = await listUserRole(request);
|
||||||
|
close();
|
||||||
|
if (res?.data?.data) {
|
||||||
|
setupParent(res?.data?.data, "role");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupParent = (data: any, type: "level" | "role") => {
|
||||||
|
const temp = [];
|
||||||
|
for (const element of data) {
|
||||||
|
temp.push({
|
||||||
|
id: element.id,
|
||||||
|
label: element.name,
|
||||||
|
value: element.aliasName || element.code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type === "level") {
|
||||||
|
setParentList(temp);
|
||||||
|
} else {
|
||||||
|
setListRole(temp);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-5 my-5 overflow-y-auto">
|
||||||
|
<form method="POST" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Card className="rounded-md p-5 flex flex-col gap-3">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="fullname"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="w-full">
|
||||||
|
<Label
|
||||||
|
htmlFor="title"
|
||||||
|
className="mb-1 block text-sm font-medium"
|
||||||
|
>
|
||||||
|
Nama Lengkap
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
placeholder="Nama Lengkap..."
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg bg-white dark:bg-transparent text-black dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.fullname?.message && (
|
||||||
|
<p className="text-red-400 text-sm">{errors.fullname?.message}</p>
|
||||||
|
)}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="username"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="w-full">
|
||||||
|
<Label
|
||||||
|
htmlFor="username"
|
||||||
|
className="mb-1 block text-sm font-medium"
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
placeholder="Username..."
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg bg-white dark:bg-transparent text-black dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.username?.message && (
|
||||||
|
<p className="text-red-400 text-sm">{errors.username?.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="email"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="w-full">
|
||||||
|
<Label
|
||||||
|
htmlFor="email"
|
||||||
|
className="mb-1 block text-sm font-medium"
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
placeholder="Email..."
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg bg-white dark:bg-transparent text-black dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.email?.message && (
|
||||||
|
<p className="text-red-400 text-sm">{errors.email?.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="identityNumber"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="w-full">
|
||||||
|
<Label
|
||||||
|
htmlFor="identityNumber"
|
||||||
|
className="mb-1 block text-sm font-medium"
|
||||||
|
>
|
||||||
|
NRP
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
id="identityNumber"
|
||||||
|
placeholder="NRP..."
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg bg-white dark:bg-transparent text-black dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.identityNumber?.message && (
|
||||||
|
<p className="text-red-400 text-sm">
|
||||||
|
{errors.identityNumber?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="address"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="w-full">
|
||||||
|
<Label
|
||||||
|
htmlFor="alamat"
|
||||||
|
className="mb-1 block text-sm font-medium"
|
||||||
|
>
|
||||||
|
Alamat
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="alamat"
|
||||||
|
placeholder="Alamat..."
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="border border-gray-300 dark:border-gray-400 rounded-lg bg-white dark:bg-transparent text-black dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.address?.message && (
|
||||||
|
<p className="text-red-400 text-sm">{errors.address?.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="genderType"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="w-full">
|
||||||
|
<Label className="mb-2 block text-sm font-medium">Gender</Label>
|
||||||
|
<RadioGroup
|
||||||
|
className="flex flex-row gap-4"
|
||||||
|
value={value}
|
||||||
|
onValueChange={onChange}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="Male" id="male" />
|
||||||
|
<Label htmlFor="male">Laki-laki</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="Female" id="female" />
|
||||||
|
<Label htmlFor="female">Perempuan</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.genderType?.message && (
|
||||||
|
<p className="text-red-400 text-sm">{errors.genderType?.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="phoneNumber"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<Label htmlFor="identityNumber">No. Handphone</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
id="identityNumber"
|
||||||
|
placeholder="08*********"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.phoneNumber?.message && (
|
||||||
|
<p className="text-red-400 text-sm">
|
||||||
|
{errors.phoneNumber?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="userLevelType"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<>
|
||||||
|
<p className="text-sm mt-3">Level Pengguna</p>
|
||||||
|
<ReactSelect
|
||||||
|
className="basic-single text-black z-50"
|
||||||
|
classNames={{
|
||||||
|
control: (state: any) =>
|
||||||
|
"!rounded-lg bg-white !border-1 !border-gray-200 dark:!border-stone-500",
|
||||||
|
}}
|
||||||
|
classNamePrefix="select"
|
||||||
|
onChange={onChange}
|
||||||
|
closeMenuOnSelect={false}
|
||||||
|
components={animatedComponents}
|
||||||
|
isClearable={true}
|
||||||
|
isSearchable={true}
|
||||||
|
isMulti={false}
|
||||||
|
placeholder=""
|
||||||
|
name="sub-module"
|
||||||
|
options={parentList}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.userLevelType?.message && (
|
||||||
|
<p className="text-red-400 text-sm">
|
||||||
|
{errors.userLevelType?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="userRoleType"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<>
|
||||||
|
<p className="text-sm mt-3">Peran Pengguna</p>
|
||||||
|
<ReactSelect
|
||||||
|
className="basic-single text-black z-49"
|
||||||
|
classNames={{
|
||||||
|
control: (state: any) =>
|
||||||
|
"!rounded-lg bg-white !border-1 !border-gray-200 dark:!border-stone-500",
|
||||||
|
}}
|
||||||
|
classNamePrefix="select"
|
||||||
|
onChange={onChange}
|
||||||
|
closeMenuOnSelect={false}
|
||||||
|
components={animatedComponents}
|
||||||
|
isClearable={true}
|
||||||
|
isSearchable={true}
|
||||||
|
isMulti={false}
|
||||||
|
placeholder=""
|
||||||
|
name="sub-module"
|
||||||
|
options={listRole}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.userRoleType?.message && (
|
||||||
|
<p className="text-red-400 text-sm">
|
||||||
|
{errors.userRoleType?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="password"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="w-full space-y-2 relative z-0">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type={isVisible[0] ? "text" : "password"}
|
||||||
|
id="password"
|
||||||
|
placeholder="Password..."
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-2xl text-muted-foreground focus:outline-none"
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleVisibility(0)}
|
||||||
|
>
|
||||||
|
{isVisible[0] ? (
|
||||||
|
<EyeSlashFilledIcon className="pointer-events-none" />
|
||||||
|
) : (
|
||||||
|
<EyeFilledIcon className="pointer-events-none" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.password?.message && (
|
||||||
|
<p className="text-red-400 text-sm">{errors.password?.message}</p>
|
||||||
|
)}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="passwordValidate"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="w-full space-y-2 relative z-0">
|
||||||
|
<Label htmlFor="passwordValidate">Konfirmasi Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type={isVisible[1] ? "text" : "password"}
|
||||||
|
id="passwordValidate"
|
||||||
|
placeholder="Konfirmasi Password..."
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-2xl text-muted-foreground focus:outline-none"
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleVisibility(1)}
|
||||||
|
>
|
||||||
|
{isVisible[1] ? (
|
||||||
|
<EyeSlashFilledIcon className="pointer-events-none" />
|
||||||
|
) : (
|
||||||
|
<EyeFilledIcon className="pointer-events-none" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.passwordValidate?.message && (
|
||||||
|
<p className="text-red-400 text-sm">
|
||||||
|
{errors.passwordValidate?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<a
|
||||||
|
className="cursor-pointer text-[#DD8306]"
|
||||||
|
onClick={generatePassword}
|
||||||
|
>
|
||||||
|
Generate Password
|
||||||
|
</a>
|
||||||
|
<ReactPasswordChecklist
|
||||||
|
rules={["minLength", "specialChar", "number", "capital", "match"]}
|
||||||
|
minLength={8}
|
||||||
|
value={passwordVal}
|
||||||
|
valueAgain={passwordConfVal}
|
||||||
|
onChange={(isValid) => {
|
||||||
|
setIsValidPassword(isValid);
|
||||||
|
}}
|
||||||
|
className="text-black dark:text-white text-sm my-3"
|
||||||
|
messages={{
|
||||||
|
minLength: "Password must be more than 8 characters",
|
||||||
|
specialChar: "Password must include a special character",
|
||||||
|
number: "Password must include a number",
|
||||||
|
capital: "Password must include an uppercase letter",
|
||||||
|
match: "Passwords match",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Link href={`/admin/master-user`}>
|
||||||
|
<Button color="danger" variant="ghost">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button type="submit" color="primary" variant="default">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,526 @@
|
||||||
|
"use client";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { close, error, loading } from "@/config/swal";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import withReactContent from "sweetalert2-react-content";
|
||||||
|
import {
|
||||||
|
checkUsernames,
|
||||||
|
emailValidation,
|
||||||
|
getProfile,
|
||||||
|
postSignIn,
|
||||||
|
setupEmail,
|
||||||
|
} from "@/service/master-user";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
|
||||||
|
import { saveActivity } from "@/service/activity-log";
|
||||||
|
import { EyeFilledIcon, EyeSlashFilledIcon } from "../icons";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [isVisibleSetup, setIsVisibleSetup] = useState([false, false]);
|
||||||
|
const [oldEmail, setOldEmail] = useState("");
|
||||||
|
const [newEmail, setNewEmail] = useState("");
|
||||||
|
const [passwordSetup, setPasswordSetup] = useState("");
|
||||||
|
const [confPasswordSetup, setConfPasswordSetup] = useState("");
|
||||||
|
|
||||||
|
const toggleVisibility = () => setIsVisible(!isVisible);
|
||||||
|
const [needOtp, setNeedOtp] = useState(false);
|
||||||
|
const [isFirstLogin, setFirstLogin] = useState(false);
|
||||||
|
const [otpValue, setOtpValue] = useState("");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [accessData, setAccessData] = useState<any>();
|
||||||
|
const [profile, setProfile] = useState<any>();
|
||||||
|
const [isValidEmail, setIsValidEmail] = useState(false);
|
||||||
|
const [isResetPassword, setIsResetPassword] = useState(false);
|
||||||
|
const [checkUsernameValue, setCheckUsernameValue] = useState("");
|
||||||
|
const MySwal = withReactContent(Swal);
|
||||||
|
|
||||||
|
const setValUsername = (e: any) => {
|
||||||
|
const uname = e.replaceAll(/[^\w.-]/g, "");
|
||||||
|
setUsername(uname.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
const data = {
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
error("Username & Password Wajib Diisi !");
|
||||||
|
} else {
|
||||||
|
loading();
|
||||||
|
const response = await postSignIn(data);
|
||||||
|
if (response?.error) {
|
||||||
|
error("Username / Password Tidak Sesuai");
|
||||||
|
} else {
|
||||||
|
const profile = await getProfile(response?.data?.data?.access_token);
|
||||||
|
const dateTime: any = new Date();
|
||||||
|
|
||||||
|
const newTime: any = dateTime.getTime() + 10 * 60 * 1000;
|
||||||
|
|
||||||
|
Cookies.set("access_token", response?.data?.data?.access_token, {
|
||||||
|
expires: 1,
|
||||||
|
});
|
||||||
|
Cookies.set("refresh_token", response?.data?.data?.refresh_token, {
|
||||||
|
expires: 1,
|
||||||
|
});
|
||||||
|
Cookies.set("time_refresh", newTime, {
|
||||||
|
expires: 1,
|
||||||
|
});
|
||||||
|
Cookies.set("is_first_login", "true", {
|
||||||
|
secure: true,
|
||||||
|
sameSite: "strict",
|
||||||
|
});
|
||||||
|
await saveActivity(
|
||||||
|
{
|
||||||
|
activityTypeId: 1,
|
||||||
|
url: "https://dev.mikulnews.com/auth",
|
||||||
|
userId: profile?.data?.data?.id,
|
||||||
|
},
|
||||||
|
response?.data?.data?.access_token
|
||||||
|
);
|
||||||
|
Cookies.set("profile_picture", profile?.data?.data?.profilePictureUrl, {
|
||||||
|
expires: 1,
|
||||||
|
});
|
||||||
|
Cookies.set("uie", profile?.data?.data?.id, {
|
||||||
|
expires: 1,
|
||||||
|
});
|
||||||
|
Cookies.set("ufne", profile?.data?.data?.fullname, {
|
||||||
|
expires: 1,
|
||||||
|
});
|
||||||
|
Cookies.set("ulie", profile?.data?.data?.userLevelGroup, {
|
||||||
|
expires: 1,
|
||||||
|
});
|
||||||
|
Cookies.set("username", profile?.data?.data?.username, {
|
||||||
|
expires: 1,
|
||||||
|
});
|
||||||
|
Cookies.set("urie", profile?.data?.data?.roleId, {
|
||||||
|
expires: 1,
|
||||||
|
});
|
||||||
|
Cookies.set("roleName", profile?.data?.data?.roleName, {
|
||||||
|
expires: 1,
|
||||||
|
});
|
||||||
|
Cookies.set("masterPoldaId", profile?.data?.data?.masterPoldaId, {
|
||||||
|
expires: 1,
|
||||||
|
});
|
||||||
|
Cookies.set("ulne", profile?.data?.data?.userLevelId, {
|
||||||
|
expires: 1,
|
||||||
|
});
|
||||||
|
Cookies.set("urce", profile?.data?.data?.roleCode, {
|
||||||
|
expires: 1,
|
||||||
|
});
|
||||||
|
Cookies.set("email", profile?.data?.data?.email, {
|
||||||
|
expires: 1,
|
||||||
|
});
|
||||||
|
router.push("/admin/dashboard");
|
||||||
|
Cookies.set("status", "login", {
|
||||||
|
expires: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkUsername = async () => {
|
||||||
|
const res = await checkUsernames(checkUsernameValue);
|
||||||
|
if (res?.error) {
|
||||||
|
error("Username tidak ditemukan");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
MySwal.fire({
|
||||||
|
title: "",
|
||||||
|
text: "",
|
||||||
|
html: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Kami telah mengirimkan tautan untuk mengatur ulang kata sandi ke
|
||||||
|
email Anda
|
||||||
|
</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
Apakah Anda sudah menerima emailnya? Jika belum, periksa folder spam
|
||||||
|
Anda
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
icon: "info",
|
||||||
|
cancelButtonColor: "#d33",
|
||||||
|
confirmButtonColor: "#3085d6",
|
||||||
|
confirmButtonText: "Oke",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitCheckEmail = async () => {
|
||||||
|
const req = {
|
||||||
|
oldEmail: oldEmail,
|
||||||
|
newEmail: newEmail,
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await setupEmail(req);
|
||||||
|
if (res?.error) {
|
||||||
|
if (res.message?.messages[0]) {
|
||||||
|
error(res.message?.messages[0]);
|
||||||
|
} else {
|
||||||
|
error(res?.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
setNeedOtp(true);
|
||||||
|
setFirstLogin(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex">
|
||||||
|
{/* Left Side - Logo Section */}
|
||||||
|
<div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-gray-600 via-gray-700 to-gray-800 relative overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-black/20"></div>
|
||||||
|
<div className="relative z-10 flex items-center justify-center w-full p-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<Link href={"/"}>
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-8 shadow-2xl border border-white/20">
|
||||||
|
<img
|
||||||
|
src="/masjaecoo.png"
|
||||||
|
alt="Mikul News Logo"
|
||||||
|
className="max-w-xs h-auto drop-shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<div className="mt-8 text-white/90">
|
||||||
|
<h2 className="text-2xl font-bold mb-2">Portal Jaecoo</h2>
|
||||||
|
<p className="text-sm opacity-80">Platform beyond classic</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Decorative elements */}
|
||||||
|
<div className="absolute top-10 left-10 w-20 h-20 bg-white/10 rounded-full blur-xl"></div>
|
||||||
|
<div className="absolute bottom-20 right-20 w-32 h-32 bg-white/5 rounded-full blur-2xl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side - Login Form */}
|
||||||
|
<div className="w-full lg:w-1/2 flex items-center justify-center p-8 bg-gray-50">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Mobile Logo */}
|
||||||
|
<div className="lg:hidden text-center mb-8">
|
||||||
|
<Link href={"/"}>
|
||||||
|
<img
|
||||||
|
src="/masjaecoonav.png"
|
||||||
|
alt="Mikul News Logo"
|
||||||
|
className="w-64 h-10 mx-auto"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isFirstLogin ? (
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-orange-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
|
Setup Akun
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600">Lengkapi informasi email Anda</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
htmlFor="old-email"
|
||||||
|
className="text-sm font-medium text-gray-700 mb-2 block"
|
||||||
|
>
|
||||||
|
Email Lama
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="old-email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
placeholder="Masukkan email lama"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500 transition-colors"
|
||||||
|
value={oldEmail}
|
||||||
|
onChange={(e) => setOldEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
htmlFor="new-email"
|
||||||
|
className="text-sm font-medium text-gray-700 mb-2 block"
|
||||||
|
>
|
||||||
|
Email Baru
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="new-email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
placeholder="Masukkan email baru"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500 transition-colors"
|
||||||
|
value={newEmail}
|
||||||
|
onChange={(e) => setNewEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="w-full bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white font-semibold py-3 rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||||
|
onClick={submitCheckEmail}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : needOtp ? (
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-blue-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
|
Verifikasi OTP
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Masukkan kode OTP yang telah dikirim
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isResetPassword ? (
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-red-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
|
Reset Password
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Masukkan username untuk reset password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
htmlFor="reset-username"
|
||||||
|
className="text-sm font-medium text-gray-700 mb-2 block"
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="reset-username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Masukkan username"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-colors"
|
||||||
|
value={checkUsernameValue}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCheckUsernameValue(e.target.value.trim())
|
||||||
|
}
|
||||||
|
onPaste={(e) =>
|
||||||
|
setCheckUsernameValue(e.currentTarget.value.trim())
|
||||||
|
}
|
||||||
|
onCopy={(e) =>
|
||||||
|
setCheckUsernameValue(e.currentTarget.value.trim())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="w-full bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 text-white font-semibold py-3 rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
onClick={checkUsername}
|
||||||
|
disabled={checkUsernameValue === ""}
|
||||||
|
>
|
||||||
|
Check Username
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center pt-4 border-t border-gray-100">
|
||||||
|
<Link
|
||||||
|
href={`/`}
|
||||||
|
className="text-sm text-gray-600 hover:text-gray-900 transition-colors lg:hidden"
|
||||||
|
>
|
||||||
|
Beranda
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="text-sm text-red-600 hover:text-red-700 font-medium transition-colors"
|
||||||
|
onClick={() => setIsResetPassword(false)}
|
||||||
|
>
|
||||||
|
Kembali ke Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-gray-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
|
Selamat Datang
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Portal Jaecoo - Platform beyond classic
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
htmlFor="username"
|
||||||
|
className="text-sm font-medium text-gray-700 mb-2 block"
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan username"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-colors"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setValUsername(e.target.value.trim())}
|
||||||
|
onPaste={(e) =>
|
||||||
|
setValUsername(e.currentTarget.value.trim())
|
||||||
|
}
|
||||||
|
onCopy={(e) => setValUsername(e.currentTarget.value.trim())}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
htmlFor="password"
|
||||||
|
className="text-sm font-medium text-gray-700 mb-2 block"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
required
|
||||||
|
type={isVisible ? "text" : "password"}
|
||||||
|
placeholder="Masukkan password"
|
||||||
|
className="w-full px-4 py-3 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-colors"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleVisibility}
|
||||||
|
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-gray-600 focus:outline-none transition-colors"
|
||||||
|
>
|
||||||
|
{isVisible ? (
|
||||||
|
<EyeSlashFilledIcon className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<EyeFilledIcon className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="w-full bg-gradient-to-r from-gray-500 to-gray-600 hover:from-gray-600 hover:to-gray-700 text-white font-semibold py-3 rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||||
|
onClick={onSubmit}
|
||||||
|
>
|
||||||
|
Masuk ke Portal
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center pt-4 border-t border-gray-100">
|
||||||
|
<Link
|
||||||
|
href={`/`}
|
||||||
|
className="text-sm text-gray-600 hover:text-gray-900 transition-colors lg:hidden"
|
||||||
|
>
|
||||||
|
Beranda
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="text-sm text-red-600 hover:text-red-700 font-medium transition-colors"
|
||||||
|
onClick={() => setIsResetPassword(true)}
|
||||||
|
>
|
||||||
|
Lupa Password?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* <div className="mt-8 p-4 bg-blue-50 rounded-lg border border-blue-100">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<svg className="w-5 h-5 text-blue-600 mt-0.5 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<p className="font-medium mb-1">Informasi Portal</p>
|
||||||
|
<p className="text-blue-700">Akses informasi terkini dan status permintaan informasi yang telah diajukan.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
import withReactContent from "sweetalert2-react-content";
|
||||||
|
import PasswordChecklist from "react-password-checklist";
|
||||||
|
import { close, error, loading } from "@/config/swal";
|
||||||
|
import { savePassword } from "@/service/master-user";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function PasswordForm(props: { doFetch: () => void }) {
|
||||||
|
const MySwal = withReactContent(Swal);
|
||||||
|
const [isVisible, setIsVisible] = useState([false, false]);
|
||||||
|
|
||||||
|
const [passwordConf, setPasswordConf] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [isValidPassword, setIsValidPassword] = useState(false);
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
loading();
|
||||||
|
const data = {
|
||||||
|
password: password,
|
||||||
|
confirmPassword: passwordConf,
|
||||||
|
};
|
||||||
|
const res = await savePassword(data);
|
||||||
|
if (res?.error) {
|
||||||
|
error(res.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
props.doFetch();
|
||||||
|
MySwal.fire({
|
||||||
|
title: "Sukses",
|
||||||
|
icon: "success",
|
||||||
|
confirmButtonColor: "#3085d6",
|
||||||
|
confirmButtonText: "OK",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const generatePassword = () => {
|
||||||
|
const length = Math.floor(Math.random() * 9) + 8;
|
||||||
|
|
||||||
|
const upperCaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
|
const lowerCaseChars = "abcdefghijklmnopqrstuvwxyz";
|
||||||
|
const numberChars = "0123456789";
|
||||||
|
const specialChars = "!@#$%^&*";
|
||||||
|
const allChars =
|
||||||
|
upperCaseChars + lowerCaseChars + numberChars + specialChars;
|
||||||
|
|
||||||
|
let generatedPassword = "";
|
||||||
|
|
||||||
|
generatedPassword +=
|
||||||
|
upperCaseChars[Math.floor(Math.random() * upperCaseChars.length)];
|
||||||
|
generatedPassword +=
|
||||||
|
specialChars[Math.floor(Math.random() * specialChars.length)];
|
||||||
|
generatedPassword +=
|
||||||
|
numberChars[Math.floor(Math.random() * numberChars.length)];
|
||||||
|
generatedPassword +=
|
||||||
|
lowerCaseChars[Math.floor(Math.random() * lowerCaseChars.length)];
|
||||||
|
|
||||||
|
for (let i = generatedPassword.length; i < length; i++) {
|
||||||
|
generatedPassword +=
|
||||||
|
allChars[Math.floor(Math.random() * allChars.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
generatedPassword = generatedPassword
|
||||||
|
.split("")
|
||||||
|
.sort(() => 0.5 - Math.random())
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
setPassword(generatedPassword);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm">Password</p>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
type={isVisible[0] ? "text" : "password"}
|
||||||
|
placeholder=""
|
||||||
|
className="w-full pr-10 border border-gray-300 rounded-lg dark:border-gray-400"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
setPassword(target.value.trim());
|
||||||
|
}}
|
||||||
|
onPaste={(e) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
setPassword(target.value.trim());
|
||||||
|
}}
|
||||||
|
onCopy={(e) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
setPassword(target.value.trim());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsVisible([!isVisible[0], isVisible[1]])}
|
||||||
|
className="absolute inset-y-0 right-2 flex items-center text-gray-400 focus:outline-none"
|
||||||
|
>
|
||||||
|
{isVisible[0] ? (
|
||||||
|
<EyeOff className="w-5 h-5 pointer-events-none" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-5 h-5 pointer-events-none" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm">Konfirmasi Password</p>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
type={isVisible[1] ? "text" : "password"}
|
||||||
|
placeholder=""
|
||||||
|
className="w-full pr-10 border border-gray-300 rounded-lg dark:border-gray-400"
|
||||||
|
value={passwordConf}
|
||||||
|
onChange={(e) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
setPasswordConf(target.value.trim());
|
||||||
|
}}
|
||||||
|
onPaste={(e) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
setPasswordConf(target.value.trim());
|
||||||
|
}}
|
||||||
|
onCopy={(e) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
setPasswordConf(target.value.trim());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsVisible([isVisible[0], !isVisible[1]])}
|
||||||
|
className="absolute inset-y-0 right-2 flex items-center text-gray-400 focus:outline-none"
|
||||||
|
>
|
||||||
|
{isVisible[1] ? (
|
||||||
|
<EyeOff className="w-5 h-5 pointer-events-none" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-5 h-5 pointer-events-none" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a className="cursor-pointer text-[#DD8306]" onClick={generatePassword}>
|
||||||
|
Generate Password
|
||||||
|
</a>
|
||||||
|
<PasswordChecklist
|
||||||
|
rules={["minLength", "specialChar", "number", "capital", "match"]}
|
||||||
|
minLength={8}
|
||||||
|
value={password}
|
||||||
|
valueAgain={passwordConf}
|
||||||
|
onChange={(isValid) => {
|
||||||
|
setIsValidPassword(isValid);
|
||||||
|
}}
|
||||||
|
className="text-black dark:text-white text-sm my-3"
|
||||||
|
messages={{
|
||||||
|
minLength: "Password must be more than 8 characters",
|
||||||
|
specialChar: "Password must include a special character",
|
||||||
|
number: "Password must include a number",
|
||||||
|
capital: "Password must include an uppercase letter",
|
||||||
|
match: "Passwords match",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="w-fit mt-4 bg-blue-600 text-white hover:bg-blue-700"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={!isValidPassword}
|
||||||
|
>
|
||||||
|
Simpan
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,290 @@
|
||||||
|
"use client";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import * as z from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
import withReactContent from "sweetalert2-react-content";
|
||||||
|
import { close, error, loading } from "@/config/swal";
|
||||||
|
import { updateProfile } from "@/service/master-user";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
fullname: z.string().min(1, {
|
||||||
|
message: "Harus diisi",
|
||||||
|
}),
|
||||||
|
username: z.string().min(1, {
|
||||||
|
message: "Harus diisi",
|
||||||
|
}),
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.email({
|
||||||
|
message: "Email tidak valid",
|
||||||
|
})
|
||||||
|
.min(2, {
|
||||||
|
message: "Harus diisi",
|
||||||
|
}),
|
||||||
|
nrp: z.string().min(1, {
|
||||||
|
message: "Harus diisi",
|
||||||
|
}),
|
||||||
|
address: z.string().min(1, {
|
||||||
|
message: "Harus diisi",
|
||||||
|
}),
|
||||||
|
gender: z.string().min(1, {
|
||||||
|
message: "Harus diisi",
|
||||||
|
}),
|
||||||
|
phoneNumber: z.string().min(1, {
|
||||||
|
message: "Harus diisi",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function ProfileForm(props: {
|
||||||
|
profile: any;
|
||||||
|
doFetch: () => void;
|
||||||
|
}) {
|
||||||
|
const MySwal = withReactContent(Swal);
|
||||||
|
const { profile } = props;
|
||||||
|
const formOptions = {
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
};
|
||||||
|
type UserSettingSchema = z.infer<typeof formSchema>;
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
setValue,
|
||||||
|
} = useForm<UserSettingSchema>(formOptions);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValue("fullname", profile?.fullname);
|
||||||
|
setValue("username", profile?.username);
|
||||||
|
setValue("email", profile?.email);
|
||||||
|
setValue("address", profile?.address);
|
||||||
|
setValue("nrp", profile?.identityNumber);
|
||||||
|
setValue("gender", profile?.genderType);
|
||||||
|
setValue("phoneNumber", profile?.phoneNumber);
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||||
|
loading();
|
||||||
|
const req = {
|
||||||
|
address: values.address,
|
||||||
|
fullname: values.fullname,
|
||||||
|
username: values.username,
|
||||||
|
email: values.email,
|
||||||
|
identityNumber: values.nrp,
|
||||||
|
phoneNumber: values.phoneNumber,
|
||||||
|
genderType: values.gender,
|
||||||
|
userLevelId: profile.userLevelId,
|
||||||
|
userRoleId: profile.userRoleId,
|
||||||
|
};
|
||||||
|
const res = await updateProfile(req);
|
||||||
|
close();
|
||||||
|
if (res?.error) {
|
||||||
|
error(res.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
props.doFetch();
|
||||||
|
MySwal.fire({
|
||||||
|
title: "Sukses",
|
||||||
|
icon: "success",
|
||||||
|
confirmButtonColor: "#3085d6",
|
||||||
|
confirmButtonText: "OK",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="flex flex-col gap-3 " onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm">Username</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="username"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
placeholder=""
|
||||||
|
readOnly
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full border border-gray-300 rounded-lg dark:border-gray-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors?.username && (
|
||||||
|
<p className="text-red-400 text-sm mb-3">
|
||||||
|
{errors.username?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="fullname"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="fullname" className="text-sm">
|
||||||
|
Nama Lengkap
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="fullname"
|
||||||
|
placeholder=""
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full border border-gray-300 rounded-lg dark:border-gray-400 dark:bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors?.fullname && (
|
||||||
|
<p className="text-red-400 text-sm mb-3">
|
||||||
|
{errors.fullname?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="email"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="email" className="text-sm">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
placeholder=""
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full border border-gray-300 rounded-lg dark:border-gray-400 dark:bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors?.email && (
|
||||||
|
<p className="text-red-400 text-sm mb-3">{errors.email?.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="nrp"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="nrp" className="text-sm">
|
||||||
|
NRP
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
id="nrp"
|
||||||
|
placeholder=""
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full border border-gray-300 rounded-lg dark:border-gray-400 dark:bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors?.nrp && (
|
||||||
|
<p className="text-red-400 text-sm mb-3">{errors.nrp?.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="address"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="address" className="text-sm">
|
||||||
|
Alamat
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="address"
|
||||||
|
placeholder=""
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full border border-gray-300 rounded-lg dark:border-gray-400 dark:bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors?.address && (
|
||||||
|
<p className="text-red-400 text-sm mb-3">{errors.address?.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm">Gender</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="gender"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<RadioGroup
|
||||||
|
value={value}
|
||||||
|
onValueChange={onChange}
|
||||||
|
className="flex flex-row gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="male" id="male" />
|
||||||
|
<label htmlFor="male" className="text-sm">
|
||||||
|
Laki-laki
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="female" id="female" />
|
||||||
|
<label htmlFor="female" className="text-sm">
|
||||||
|
Perempuan
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors?.gender && (
|
||||||
|
<p className="text-red-400 text-sm mb-3">{errors.gender?.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="phoneNumber"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="phoneNumber" className="text-sm">
|
||||||
|
Nomor Handphone
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
id="phoneNumber"
|
||||||
|
placeholder=""
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full border border-gray-300 rounded-lg dark:border-gray-400 dark:bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors?.phoneNumber && (
|
||||||
|
<p className="text-red-400 text-sm mb-3">
|
||||||
|
{errors.phoneNumber?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button color="primary" type="submit" className="w-fit mt-4">
|
||||||
|
Simpan
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import * as z from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { close, error, loading } from "@/config/swal";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
import withReactContent from "sweetalert2-react-content";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { editCustomStaticPage, getCustomStaticDetail } from "@/service/static-page-service";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const CustomEditor = dynamic(
|
||||||
|
() => {
|
||||||
|
return import("@/components/editor/custom-editor");
|
||||||
|
},
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
slug: z.string().min(2, {
|
||||||
|
message: "Slug must be at least 2 characters.",
|
||||||
|
}),
|
||||||
|
title: z.string().min(2, {
|
||||||
|
message: "Title must be at least 2 characters.",
|
||||||
|
}),
|
||||||
|
description: z.string().min(2, {
|
||||||
|
message: "Main Keyword must be at least 2 characters.",
|
||||||
|
}),
|
||||||
|
htmlBody: z.string().min(2, {
|
||||||
|
message: "Main Keyword must be at least 2 characters.",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function StaticPageBuilderEdit() {
|
||||||
|
const MySwal = withReactContent(Swal);
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const id = params.id;
|
||||||
|
const formOptions = {
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
};
|
||||||
|
type UserSettingSchema = z.infer<typeof formSchema>;
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
getValues,
|
||||||
|
} = useForm<UserSettingSchema>(formOptions);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initFetch();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const initFetch = async () => {
|
||||||
|
const res = await getCustomStaticDetail(id ? String(id) : "");
|
||||||
|
const data = res?.data?.data;
|
||||||
|
console.log("res", data);
|
||||||
|
setValue("title", data?.title);
|
||||||
|
setValue("slug", data?.slug);
|
||||||
|
setValue("description", data?.description);
|
||||||
|
setValue("htmlBody", addPreCode(data?.htmlBody));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPreCode = (htmlString: string): string => {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(htmlString, "text/html");
|
||||||
|
|
||||||
|
const bodyContent = doc.body.innerHTML.trim();
|
||||||
|
|
||||||
|
return `<pre><code class="language-html">${bodyContent.replace(/</g, "<").replace(/>/g, ">")}</code></pre>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = watch("htmlBody");
|
||||||
|
const generatedPage = useCallback(() => {
|
||||||
|
const sanitizedContent = DOMPurify.sanitize(content);
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.innerHTML = sanitizedContent;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="rounded-md border p-4">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: textArea.value }} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
function createSlug(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, "")
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/-+/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||||
|
const request = {
|
||||||
|
id: Number(id),
|
||||||
|
title: values.title,
|
||||||
|
slug: values.slug,
|
||||||
|
description: values.description,
|
||||||
|
htmlBody: values.htmlBody,
|
||||||
|
};
|
||||||
|
loading();
|
||||||
|
const res = await editCustomStaticPage(request);
|
||||||
|
|
||||||
|
if (res?.error) {
|
||||||
|
error(res.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
|
||||||
|
successSubmit("/admin/static-page");
|
||||||
|
};
|
||||||
|
|
||||||
|
function successSubmit(redirect: any) {
|
||||||
|
MySwal.fire({
|
||||||
|
title: "Sukses",
|
||||||
|
icon: "success",
|
||||||
|
confirmButtonColor: "#3085d6",
|
||||||
|
confirmButtonText: "OK",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
router.push(redirect);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// const title = watch("title");
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (getValues("title")) {
|
||||||
|
// setValue("slug", createSlug(getValues("title")));
|
||||||
|
// }
|
||||||
|
// }, [title]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="flex flex-col gap-3 px-4" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="title"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<Label htmlFor="title" className="text-sm font-medium">
|
||||||
|
Title
|
||||||
|
</Label>
|
||||||
|
<Input type="text" id="title" placeholder="Title" value={value ?? ""} onChange={onChange} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.title?.message && <p className="text-red-400 text-sm">{errors.title?.message}</p>}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="slug"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<Label htmlFor="slug" className="text-sm font-medium">
|
||||||
|
Slug
|
||||||
|
</Label>
|
||||||
|
<Input type="text" id="slug" placeholder="Slug" value={value ?? ""} onChange={onChange} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="description"
|
||||||
|
render={({ field: { onChange, value } }) => <div className="space-y-2">
|
||||||
|
<Label htmlFor="description" className="text-sm font-medium">
|
||||||
|
Description
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
placeholder="Description"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>}
|
||||||
|
/>
|
||||||
|
{errors.description?.message && <p className="text-red-400 text-sm">{errors.description?.message}</p>}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
Editor
|
||||||
|
<Controller control={control} name="htmlBody" render={({ field: { onChange, value } }) => <CustomEditor onChange={onChange} initialData={value} />} />
|
||||||
|
</div>
|
||||||
|
<div className="px-4 flex flex-col gap-2">
|
||||||
|
Preview
|
||||||
|
{generatedPage()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end w-full">
|
||||||
|
<Button type="submit" color="primary" className="w-fit">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,214 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { IconSvgProps } from "@/types/globals";
|
||||||
|
|
||||||
|
export const DashboardUserIcon = ({
|
||||||
|
size,
|
||||||
|
height = 48,
|
||||||
|
width = 48,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size || width}
|
||||||
|
height={size || height}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 3c2.21 0 4 1.79 4 4s-1.79 4-4 4s-4-1.79-4-4s1.79-4 4-4m4 10.54c0 1.06-.28 3.53-2.19 6.29L13 15l.94-1.88c-.62-.07-1.27-.12-1.94-.12s-1.32.05-1.94.12L11 15l-.81 4.83C8.28 17.07 8 14.6 8 13.54c-2.39.7-4 1.96-4 3.46v4h16v-4c0-1.5-1.6-2.76-4-3.46"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DashboardBriefcaseIcon = ({
|
||||||
|
size,
|
||||||
|
height = 48,
|
||||||
|
width = 48,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height={size || height}
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
width={size || width}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path fill="#f5bc00" d="M44,41H4V10h40V41z" />
|
||||||
|
<polygon fill="#eb7900" points="44,26 24,26 4,26 4,10 44,10" />
|
||||||
|
<path fill="#eb7900" d="M17,26h-6v3h6V26z" />
|
||||||
|
<path fill="#eb7900" d="M37,26h-6v3h6V26z" />
|
||||||
|
<rect width="14" height="3" x="17" y="7" fill="#f5bc00" />
|
||||||
|
<path fill="#eb0000" d="M17,23h-6v3h6V23z" />
|
||||||
|
<path fill="#eb0000" d="M37,23h-6v3h6V23z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const DashboardMailboxIcon = ({
|
||||||
|
size,
|
||||||
|
height = 48,
|
||||||
|
width = 48,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height={size || height}
|
||||||
|
width={size || width}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
>
|
||||||
|
<path fill="#3dd9eb" d="M43,36H13V11h22c4.418,0,8,3.582,8,8V36z" />
|
||||||
|
<path
|
||||||
|
fill="#7debf5"
|
||||||
|
d="M21,36H5V19c0-4.418,3.582-8,8-8l0,0c4.418,0,8,3.582,8,8V36z"
|
||||||
|
/>
|
||||||
|
<path fill="#6c19ff" d="M21,36h5v8h-5V36z" />
|
||||||
|
<polygon fill="#eb0000" points="27,16 27,20 35,20 35,24 39,24 39,16" />
|
||||||
|
<rect width="8" height="3" x="9" y="20" fill="#3dd9eb" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const DashboardShareIcon = ({
|
||||||
|
size,
|
||||||
|
height = 48,
|
||||||
|
width = 48,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
height={size || height}
|
||||||
|
width={size || width}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M503.691 189.836L327.687 37.851C312.281 24.546 288 35.347 288 56.015v80.053C127.371 137.907 0 170.1 0 322.326c0 61.441 39.581 122.309 83.333 154.132c13.653 9.931 33.111-2.533 28.077-18.631C66.066 312.814 132.917 274.316 288 272.085V360c0 20.7 24.3 31.453 39.687 18.164l176.004-152c11.071-9.562 11.086-26.753 0-36.328"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const DashboardSpeecIcon = ({
|
||||||
|
size,
|
||||||
|
height = 48,
|
||||||
|
width = 48,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
height={size || height}
|
||||||
|
width={size || width}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M7 0a2 2 0 0 0-2 2h9a2 2 0 0 1 2 2v12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M13 20a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2zM9 5h4v5H9zM4 5h4v1H4zm0 2h4v1H4zm0 2h4v1H4zm0 2h9v1H4zm0 2h9v1H4zm0 2h9v1H4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DashboardConnectIcon = ({
|
||||||
|
size,
|
||||||
|
height = 48,
|
||||||
|
width = 48,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
height={size || height}
|
||||||
|
width={size || width}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M2 22V4q0-.825.588-1.412T4 2h16q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18H6zm7.075-7.75L12 12.475l2.925 1.775l-.775-3.325l2.6-2.25l-3.425-.275L12 5.25L10.675 8.4l-3.425.275l2.6 2.25z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DashboardTopLeftPointIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height={size || height}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size || width}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
d="M18 18L6 6m0 0h9M6 6v9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DashboardRightDownPointIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height={size || height}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size || width}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M5.47 5.47a.75.75 0 0 1 1.06 0l10.72 10.72V9a.75.75 0 0 1 1.5 0v9a.75.75 0 0 1-.75.75H9a.75.75 0 0 1 0-1.5h7.19L5.47 6.53a.75.75 0 0 1 0-1.06"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const DashboardCommentIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
height={size || height}
|
||||||
|
width={size || width}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<mask id="ipSComment0">
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="4"
|
||||||
|
>
|
||||||
|
<path fill="#fff" stroke="#fff" d="M44 6H4v30h9v5l10-5h21z" />
|
||||||
|
<path stroke="#000" d="M14 19.5v3m10-3v3m10-3v3" />
|
||||||
|
</g>
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<path fill="currentColor" d="M0 0h48v48H0z" mask="url(#ipSComment0)" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { IconSvgProps } from "@/types/globals";
|
||||||
|
|
||||||
|
export const PdfIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size || width}
|
||||||
|
height={size || height}
|
||||||
|
viewBox="0 0 15 15"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M3.5 8H3V7h.5a.5.5 0 0 1 0 1M7 10V7h.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M1 1.5A1.5 1.5 0 0 1 2.5 0h8.207L14 3.293V13.5a1.5 1.5 0 0 1-1.5 1.5h-10A1.5 1.5 0 0 1 1 13.5zM3.5 6H2v5h1V9h.5a1.5 1.5 0 1 0 0-3m4 0H6v5h1.5A1.5 1.5 0 0 0 9 9.5v-2A1.5 1.5 0 0 0 7.5 6m2.5 5V6h3v1h-2v1h1v1h-1v2z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const CsvIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size || width}
|
||||||
|
height={size || height}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 15 15"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M1 1.5A1.5 1.5 0 0 1 2.5 0h8.207L14 3.293V13.5a1.5 1.5 0 0 1-1.5 1.5h-10A1.5 1.5 0 0 1 1 13.5zM2 6h3v1H3v3h2v1H2zm7 0H6v3h2v1H6v1h3V8H7V7h2zm2 0h-1v3.707l1.5 1.5l1.5-1.5V6h-1v3.293l-.5.5l-.5-.5z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const ExcelIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 15 15"
|
||||||
|
width={size || width}
|
||||||
|
height={size || height}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M3.793 7.5L2.146 5.854l.708-.708L4.5 6.793l1.646-1.647l.708.708L5.207 7.5l1.647 1.646l-.708.708L4.5 8.207L2.854 9.854l-.708-.708z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M3.5 0A1.5 1.5 0 0 0 2 1.5V3h-.5A1.5 1.5 0 0 0 0 4.5v6A1.5 1.5 0 0 0 1.5 12H2v1.5A1.5 1.5 0 0 0 3.5 15h10a1.5 1.5 0 0 0 1.5-1.5v-12A1.5 1.5 0 0 0 13.5 0zm-2 4a.5.5 0 0 0-.5.5v6a.5.5 0 0 0 .5.5h6a.5.5 0 0 0 .5-.5v-6a.5.5 0 0 0-.5-.5z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const WordIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size || width}
|
||||||
|
height={size || height}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 15 15"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="m2.015 5.621l1 4a.5.5 0 0 0 .901.156l.584-.876l.584.876a.5.5 0 0 0 .901-.156l1-4l-.97-.242l-.726 2.903l-.373-.56a.5.5 0 0 0-.832 0l-.373.56l-.726-2.903z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M3.5 0A1.5 1.5 0 0 0 2 1.5V3h-.5A1.5 1.5 0 0 0 0 4.5v6A1.5 1.5 0 0 0 1.5 12H2v1.5A1.5 1.5 0 0 0 3.5 15h10a1.5 1.5 0 0 0 1.5-1.5v-12A1.5 1.5 0 0 0 13.5 0zm-2 4a.5.5 0 0 0-.5.5v6a.5.5 0 0 0 .5.5h6a.5.5 0 0 0 .5-.5v-6a.5.5 0 0 0-.5-.5z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PptIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size || width}
|
||||||
|
height={size || height}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 15 15"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M3 8h.5a.5.5 0 0 0 0-1H3zm4 0h.5a.5.5 0 0 0 0-1H7z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M1 1.5A1.5 1.5 0 0 1 2.5 0h8.207L14 3.293V13.5a1.5 1.5 0 0 1-1.5 1.5h-10A1.5 1.5 0 0 1 1 13.5zM2 6h1.5a1.5 1.5 0 1 1 0 3H3v2H2zm4 0h1.5a1.5 1.5 0 1 1 0 3H7v2H6zm5 5h1V7h1V6h-3v1h1z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const FileIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size || width}
|
||||||
|
height={size || height}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 15 15"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="m10.5.5l.354-.354L10.707 0H10.5zm3 3h.5v-.207l-.146-.147zm-1 10.5h-10v1h10zM2 13.5v-12H1v12zM2.5 1h8V0h-8zM13 3.5v10h1v-10zM10.146.854l3 3l.708-.708l-3-3zM2.5 14a.5.5 0 0 1-.5-.5H1A1.5 1.5 0 0 0 2.5 15zm10 1a1.5 1.5 0 0 0 1.5-1.5h-1a.5.5 0 0 1-.5.5zM2 1.5a.5.5 0 0 1 .5-.5V0A1.5 1.5 0 0 0 1 1.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const UserProfileIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size || width}
|
||||||
|
height={size || height}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M11 7c0 1.66-1.34 3-3 3S5 8.66 5 7s1.34-3 3-3s3 1.34 3 3"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16 8c0 4.42-3.58 8-8 8s-8-3.58-8-8s3.58-8 8-8s8 3.58 8 8M4 13.75C4.16 13.484 5.71 11 7.99 11c2.27 0 3.83 2.49 3.99 2.75A6.98 6.98 0 0 0 14.99 8c0-3.87-3.13-7-7-7s-7 3.13-7 7c0 2.38 1.19 4.49 3.01 5.75"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const SettingsIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size || width}
|
||||||
|
height={size || height}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="m9.25 22l-.4-3.2q-.325-.125-.612-.3t-.563-.375L4.7 19.375l-2.75-4.75l2.575-1.95Q4.5 12.5 4.5 12.338v-.675q0-.163.025-.338L1.95 9.375l2.75-4.75l2.975 1.25q.275-.2.575-.375t.6-.3l.4-3.2h5.5l.4 3.2q.325.125.613.3t.562.375l2.975-1.25l2.75 4.75l-2.575 1.95q.025.175.025.338v.674q0 .163-.05.338l2.575 1.95l-2.75 4.75l-2.95-1.25q-.275.2-.575.375t-.6.3l-.4 3.2zm2.8-6.5q1.45 0 2.475-1.025T15.55 12t-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12t1.013 2.475T12.05 15.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,487 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { IconSvgProps } from "@/types/globals";
|
||||||
|
|
||||||
|
export const MenuBurgerIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height={size || height}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size || width}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
d="M3 6h18M3 12h18M3 18h18"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DashboardIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height={size || height}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size || width}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M13 9V3h8v6zM3 13V3h8v10zm10 8V11h8v10zM3 21v-6h8v6z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const HomeIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height={size || height}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size || width}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<g fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M2 12.204c0-2.289 0-3.433.52-4.381c.518-.949 1.467-1.537 3.364-2.715l2-1.241C9.889 2.622 10.892 2 12 2c1.108 0 2.11.622 4.116 1.867l2 1.241c1.897 1.178 2.846 1.766 3.365 2.715c.519.948.519 2.092.519 4.38v1.522c0 3.9 0 5.851-1.172 7.063C19.657 22 17.771 22 14 22h-4c-3.771 0-5.657 0-6.828-1.212C2 19.576 2 17.626 2 13.725z" />
|
||||||
|
<path strokeLinecap="round" d="M12 15v3" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Submenu1Icon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height={size || height}
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
width={size || width}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<mask id="ipTData0">
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke="#fff"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="4"
|
||||||
|
>
|
||||||
|
<path d="M44 11v27c0 3.314-8.954 6-20 6S4 41.314 4 38V11" />
|
||||||
|
<path d="M44 29c0 3.314-8.954 6-20 6S4 32.314 4 29m40-9c0 3.314-8.954 6-20 6S4 23.314 4 20" />
|
||||||
|
<ellipse cx="24" cy="10" fill="#555" rx="20" ry="6" />
|
||||||
|
</g>
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<path fill="currentColor" d="M0 0h48v48H0z" mask="url(#ipTData0)" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Submenu2Icon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height={size || height}
|
||||||
|
viewBox="0 0 256 256"
|
||||||
|
width={size || width}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M230.93 220a8 8 0 0 1-6.93 4H32a8 8 0 0 1-6.92-12c15.23-26.33 38.7-45.21 66.09-54.16a72 72 0 1 1 73.66 0c27.39 8.95 50.86 27.83 66.09 54.16a8 8 0 0 1 .01 8"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const InfoCircleIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height={size || height}
|
||||||
|
viewBox="0 0 1024 1024"
|
||||||
|
width={size || width}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448s448-200.6 448-448S759.4 64 512 64m0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372s372 166.6 372 372s-166.6 372-372 372"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M464 336a48 48 0 1 0 96 0a48 48 0 1 0-96 0m72 112h-48c-4.4 0-8 3.6-8 8v272c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V456c0-4.4-3.6-8-8-8"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MinusCircleIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height={size || height}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
width={size || width}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<g fill="currentColor">
|
||||||
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16" />
|
||||||
|
<path d="M4 8a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7A.5.5 0 0 1 4 8" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TableIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 22,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height={size || height}
|
||||||
|
viewBox="0 0 24 22"
|
||||||
|
width={size || width}
|
||||||
|
strokeWidth="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0 1 12 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M13.125 12h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125M20.625 12c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5M12 14.625v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 14.625c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 1.5v-1.5m0 0c0-.621.504-1.125 1.125-1.125m0 0h7.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const ArticleIcon = ({
|
||||||
|
size,
|
||||||
|
height = 20,
|
||||||
|
width = 20,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
height={size || height}
|
||||||
|
width={size || width}
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M5 1a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2zm0 3h5v1H5zm0 2h5v1H5zm0 2h5v1H5zm10 7H5v-1h10zm0-2H5v-1h10zm0-2H5v-1h10zm0-2h-4V4h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const MagazineIcon = ({
|
||||||
|
size,
|
||||||
|
height = 20,
|
||||||
|
width = 20,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
height={size || height}
|
||||||
|
width={size || width}
|
||||||
|
viewBox="0 0 128 128"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#bdbdbd"
|
||||||
|
d="M-125.7 124.54V11.79c29.36 1.85 58.81 1.91 88.18.19c1.77-.1 3.21 1.08 3.21 2.66v107.04c0 1.58-1.44 2.94-3.21 3.05a727 727 0 0 1-88.18-.19"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#e0e0e0"
|
||||||
|
d="M-125.7 124.54V11.79c27.11-5.31 54.34-8.57 81.45-9.76c1.64-.07 2.96 1.15 2.96 2.73V111.8c0 1.58-1.33 2.9-2.96 2.98c-27.11 1.19-54.34 4.45-81.45 9.76"
|
||||||
|
/>
|
||||||
|
<g fill="#757575">
|
||||||
|
<path d="M-92.84 42.86c-7.46 1-14.91 2.16-22.36 3.47v-3.22c7.45-1.31 14.9-2.47 22.36-3.47zm-12.76-15.72c-3.2.51-6.4 1.04-9.6 1.6v-8.47c3.2-.56 6.4-1.1 9.6-1.6zm12.17-1.78c-3.2.43-6.4.9-9.6 1.39v-8.47c3.2-.49 6.4-.95 9.6-1.39zm12.17-1.52q-4.8.54-9.6 1.17v-8.47q4.8-.63 9.6-1.17zm12.17-1.23c-3.2.29-6.4.61-9.6.95v-8.47c3.2-.35 6.4-.66 9.6-.95zm17.21 5.12a548 548 0 0 0-63.31 7.42v-.81c21.09-3.72 42.23-6.19 63.31-7.42zm-32.67 14.08c-2.21.26-4.41.54-6.62.83v-3.22c2.21-.29 4.41-.57 6.62-.83z" />
|
||||||
|
<path d="M-75.06 40.77c-3.6.37-7.21.77-10.81 1.2v-3.22c3.6-.44 7.21-.84 10.81-1.2zm12.29-1.11l-3.66.3v-3.22l3.66-.3z" />
|
||||||
|
<path d="M-65.38 39.87c-2.47.21-4.95.43-7.42.67v-3.22c2.47-.24 4.95-.46 7.42-.67zm10.32-.76c-2.02.13-4.05.27-6.07.42v-3.22c2.02-.15 4.05-.29 6.07-.42zm-49.89 14.57c-3.42.54-6.83 1.11-10.25 1.71v-3.22c3.41-.6 6.83-1.17 10.25-1.71zm9.51-1.4c-2.63.37-5.26.75-7.89 1.16v-3.22c2.63-.41 5.26-.79 7.89-1.16z" />
|
||||||
|
<path d="M-84.55 50.87c-3.95.47-7.89.98-11.84 1.54v-3.22c3.95-.56 7.89-1.07 11.84-1.54z" />
|
||||||
|
<path d="M-75.06 49.82c-3.6.37-7.21.77-10.81 1.2V47.8c3.6-.44 7.21-.84 10.81-1.2zm12.29-1.1l-3.66.3V45.8l3.66-.3z" />
|
||||||
|
<path d="M-61.13 48.59c-3.89.29-7.78.63-11.67 1.01v-3.22c3.89-.38 7.78-.71 11.67-1.01z" />
|
||||||
|
<path d="M-51.89 47.97c-3.08.18-6.16.39-9.25.62v-3.22c3.08-.23 6.17-.44 9.25-.62zm-49.5 14.22q-6.9 1.035-13.8 2.25v-3.22q6.9-1.215 13.8-2.25z" />
|
||||||
|
<path d="M-95.44 61.33c-2.63.37-5.26.75-7.89 1.16v-3.22c2.63-.41 5.26-.79 7.89-1.16zm10.89-1.4c-2.76.33-5.53.68-8.29 1.05v-3.22c2.76-.37 5.53-.72 8.29-1.05z" />
|
||||||
|
<path d="M-78.26 59.21c-2.54.27-5.07.56-7.61.87v-3.22c2.54-.31 5.07-.6 7.61-.87zm26.37 24.99q-12.075.705-24.18 1.95V55.76q12.09-1.245 24.18-1.95zm-38.55-14.48c-8.25 1.07-16.51 2.34-24.75 3.79v-3.22c8.24-1.45 16.5-2.72 24.75-3.79z" />
|
||||||
|
<path d="M-95.44 70.39c-1.31.18-2.63.37-3.94.56v-3.22c1.31-.19 2.63-.38 3.94-.56zm10.89-1.41c-2.21.26-4.41.54-6.62.83v-3.22c2.21-.29 4.41-.57 6.62-.83z" />
|
||||||
|
<path d="M-78.32 68.28c-2.51.27-5.03.56-7.54.86v-3.22c2.51-.31 5.03-.59 7.54-.86zm-23.07 12.03q-6.9 1.035-13.8 2.25v-3.22q6.9-1.215 13.8-2.25z" />
|
||||||
|
<path d="M-98.16 79.83c-1.72.25-3.44.51-5.17.77v-3.22c1.72-.27 3.44-.52 5.17-.77zm13.61-1.79q-5.445.645-10.89 1.41v-3.22c3.63-.51 7.26-.97 10.89-1.41z" />
|
||||||
|
<path d="M-80.46 77.57c-1.8.2-3.6.41-5.41.63v-3.22c1.8-.22 3.6-.43 5.41-.63zm-16.95 11.21c-5.93.85-11.86 1.79-17.79 2.84V88.4c5.92-1.04 11.85-1.99 17.79-2.84z" />
|
||||||
|
<path d="M-92.54 88.1c-2.28.31-4.56.62-6.84.96v-3.22q3.42-.495 6.84-.96zm7.99-1.01c-1.75.21-3.5.43-5.25.65v-3.22c1.75-.23 3.5-.44 5.25-.65z" />
|
||||||
|
<path d="M-78.32 86.39c-2.51.27-5.03.56-7.54.86v-3.22c2.51-.31 5.03-.59 7.54-.86zm-23.07 12.03q-6.9 1.035-13.8 2.25v-3.22q6.9-1.215 13.8-2.25zm14.22-1.95q-6.105.75-12.21 1.65V94.9q6.105-.9 12.21-1.65z" />
|
||||||
|
<path d="M-84.55 96.15c-2.21.26-4.41.54-6.62.83v-3.22c2.21-.29 4.41-.57 6.62-.83z" />
|
||||||
|
<path d="M-75.06 95.1c-3.6.37-7.21.77-10.81 1.2v-3.22c3.6-.44 7.21-.84 10.81-1.2zm12.29-1.1l-3.66.3v-3.22l3.66-.3zm-5.64.47c-1.46.13-2.93.27-4.39.41v-3.22c1.46-.14 2.93-.28 4.39-.41z" />
|
||||||
|
<path d="M-51.89 93.25c-6 .35-12.01.8-18.01 1.35v-3.22c6.01-.55 12.01-1 18.01-1.35zm-43.56 13.36c-6.59.92-13.17 1.96-19.75 3.11v-3.22c6.58-1.16 13.16-2.2 19.75-3.11zm22.09-2.62c-3.48.34-6.96.72-10.44 1.13v-3.22c3.48-.41 6.96-.78 10.44-1.13zm-13.53 1.5c-2.18.27-4.36.55-6.55.85v-3.22c2.18-.3 4.36-.58 6.55-.85z" />
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
fill="#eee"
|
||||||
|
d="M15.71 280.41V170.86h76.08a2.77 2.77 0 0 1 2.77 2.77v104.01a2.77 2.77 0 0 1-2.77 2.77z"
|
||||||
|
/>
|
||||||
|
<g fill="#757575">
|
||||||
|
<path d="M25.53 203.19h20.88v3.13H25.53zm0-22.19h8.96v8.23h-8.96zm11.36 0h8.96v8.23h-8.96zm11.36 0h8.96v8.23h-8.96zm11.36 0h8.96v8.23h-8.96zm-34.08 13.66h59.12v.79H25.53zm22.44 8.53h6.18v3.13h-6.18z" />
|
||||||
|
<path d="M52.92 203.19h10.09v3.13H52.92zm18.14 0h3.42v3.13h-3.42z" />
|
||||||
|
<path d="M65.11 203.19h6.93v3.13h-6.93zm10.9 0h5.67v3.13h-5.67zm-50.48 8.8h9.57v3.13h-9.57zm11.08 0h7.37v3.13h-7.37z" />
|
||||||
|
<path d="M43.1 211.99h11.05v3.13H43.1z" />
|
||||||
|
<path d="M52.92 211.99h10.09v3.13H52.92zm18.14 0h3.42v3.13h-3.42z" />
|
||||||
|
<path d="M65.11 211.99H76v3.13H65.11zm10.9 0h8.64v3.13h-8.64zm-50.48 8.8h12.89v3.13H25.53z" />
|
||||||
|
<path d="M36.61 220.79h7.37v3.13h-7.37zm9.8 0h7.74v3.13h-7.74z" />
|
||||||
|
<path d="M52.92 220.79h7.1v3.13h-7.1zm9.15 0h22.58v29.53H62.07zm-36.54 8.8h23.11v3.13H25.53z" />
|
||||||
|
<path d="M40.3 229.59h3.68v3.13H40.3zm7.67 0h6.18v3.13h-6.18z" />
|
||||||
|
<path d="M52.92 229.59h7.04v3.13h-7.04zm-27.39 8.8h12.89v3.13H25.53z" />
|
||||||
|
<path d="M36.61 238.39h4.82v3.13h-4.82zm7.37 0h10.17v3.13H43.98z" />
|
||||||
|
<path d="M52.92 238.39h5.04v3.13h-5.04zm-27.39 8.79h16.61v3.13H25.53z" />
|
||||||
|
<path d="M40.3 247.18h6.38v3.13H40.3zm8.95 0h4.9v3.13h-4.9z" />
|
||||||
|
<path d="M52.92 247.18h7.04v3.13h-7.04zm-27.39 8.8h12.89v3.13H25.53zm14.77 0h11.39v3.13H40.3z" />
|
||||||
|
<path d="M47.97 255.98h6.18v3.13h-6.18z" />
|
||||||
|
<path d="M52.92 255.98h10.09v3.13H52.92zm18.14 0h3.42v3.13h-3.42zm-5.95 0h4.1v3.13h-4.1z" />
|
||||||
|
<path d="M67.82 255.98h16.82v3.13H67.82zm-42.29 8.8h18.44v3.13H25.53zm29.32 0h9.74v3.13h-9.74zm-9 0h6.11v3.13h-6.11z" />
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
fill="#bdbdbd"
|
||||||
|
d="M16.62 124.27V14.04c30.52 2.2 61.18 2.27 91.71.21c1.68-.11 3.05 1.04 3.05 2.58v104.65c0 1.54-1.36 2.89-3.05 3a659 659 0 0 1-91.71-.21"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#e0e0e0"
|
||||||
|
d="M16.62 124.25V14.02C44.36 7.91 72.21 3.9 99.95 2.03c1.53-.1 2.77 1.07 2.77 2.61v104.65c0 1.54-1.24 2.87-2.77 2.97c-27.74 1.87-55.59 5.88-83.33 11.99"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="#616161"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeMiterlimit="10"
|
||||||
|
strokeWidth="5"
|
||||||
|
d="M28.75 49.34c20.6-4.08 41.25-7 61.84-8.74M28.75 63.23a565 565 0 0 1 26.45-4.6M28.75 77.11a565 565 0 0 1 26.45-4.6m-26.45 33.06c20.6-4.08 41.25-7 61.84-8.74M28.75 91a565 565 0 0 1 26.45-4.6"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#616161"
|
||||||
|
d="M64.86 87.55a560 560 0 0 1 24.67-2.69c1.49-.13 2.69-1.44 2.69-2.94V54.54c0-1.5-1.21-2.61-2.69-2.48c-8.22.71-16.44 1.61-24.67 2.69c-1.49.2-2.7 1.58-2.7 3.07V85.2c.01 1.5 1.21 2.55 2.7 2.35m-34.4-52.14c2.03-.4 4.05-.78 6.08-1.15c1.49-.27 2.69-1.7 2.69-3.2v-7.02c0-1.5-1.21-2.49-2.69-2.22c-2.03.37-4.05.76-6.08 1.15c-1.49.29-2.69 1.75-2.69 3.24v7.02c-.01 1.5 1.2 2.47 2.69 2.18m15.96-2.88c2.03-.34 4.05-.66 6.08-.97c1.49-.23 2.7-1.62 2.7-3.12v-7.02c0-1.5-1.21-2.53-2.7-2.3c-2.03.31-4.06.64-6.08.97c-1.49.25-2.69 1.67-2.69 3.16v7.02c0 1.5 1.2 2.51 2.69 2.26m15.97-2.41c2.03-.28 4.06-.54 6.08-.8c1.49-.19 2.7-1.54 2.7-3.04v-7.02c0-1.5-1.21-2.57-2.7-2.38c-2.03.25-4.06.52-6.08.8c-1.49.2-2.7 1.59-2.7 3.08v7.02c.01 1.5 1.22 2.54 2.7 2.34"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#e0e0e0"
|
||||||
|
d="M374.07 165.73V44.63h92.1a3.06 3.06 0 0 1 3.06 3.06v114.98a3.06 3.06 0 0 1-3.06 3.06z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="#616161"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeMiterlimit="10"
|
||||||
|
strokeWidth="5"
|
||||||
|
d="M387.48 86.21h68.34m-68.34 15.26h29.23m-29.23 15.26h29.23M387.48 148h68.34m-68.34-16.01h29.23"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#616161"
|
||||||
|
d="M427.38 134.75h27.26c1.64 0 2.98-1.33 2.98-2.98v-30.08c0-1.64-1.33-2.98-2.98-2.98h-27.26c-1.64 0-2.98 1.33-2.98 2.98v30.08a2.987 2.987 0 0 0 2.98 2.98m-38.01-63.47h6.72c1.64 0 2.98-1.33 2.98-2.98v-7.71c0-1.64-1.33-2.98-2.98-2.98h-6.72c-1.64 0-2.98 1.33-2.98 2.98v7.71c0 1.65 1.33 2.98 2.98 2.98m17.64 0h6.72c1.64 0 2.98-1.33 2.98-2.98v-7.71c0-1.64-1.33-2.98-2.98-2.98h-6.72c-1.64 0-2.98 1.33-2.98 2.98v7.71a2.987 2.987 0 0 0 2.98 2.98m17.65 0h6.72c1.64 0 2.98-1.33 2.98-2.98v-7.71c0-1.64-1.33-2.98-2.98-2.98h-6.72c-1.64 0-2.98 1.33-2.98 2.98v7.71c0 1.65 1.33 2.98 2.98 2.98"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#bdbdbd"
|
||||||
|
d="M479.86 165.73V44.63h92.1a3.06 3.06 0 0 1 3.06 3.06v114.98a3.06 3.06 0 0 1-3.06 3.06z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const StaticPageIcon = ({
|
||||||
|
size,
|
||||||
|
height = 20,
|
||||||
|
width = 20,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size || width}
|
||||||
|
height={size || height}
|
||||||
|
viewBox="0 0 2048 2048"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M1755 512h-475V37zm37 128v1408H128V0h1024v640z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const MasterUsersIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size || width}
|
||||||
|
height={size || height}
|
||||||
|
viewBox="0 0 640 512"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M144 0a80 80 0 1 1 0 160a80 80 0 1 1 0-160m368 0a80 80 0 1 1 0 160a80 80 0 1 1 0-160M0 298.7C0 239.8 47.8 192 106.7 192h42.7c15.9 0 31 3.5 44.6 9.7c-1.3 7.2-1.9 14.7-1.9 22.3c0 38.2 16.8 72.5 43.3 96H21.3C9.6 320 0 310.4 0 298.7M405.3 320h-.7c26.6-23.5 43.3-57.8 43.3-96c0-7.6-.7-15-1.9-22.3c13.6-6.3 28.7-9.7 44.6-9.7h42.7c58.9 0 106.7 47.8 106.7 106.7c0 11.8-9.6 21.3-21.3 21.3H405.4zM224 224a96 96 0 1 1 192 0a96 96 0 1 1-192 0m-96 261.3c0-73.6 59.7-133.3 133.3-133.3h117.3c73.7 0 133.4 59.7 133.4 133.3c0 14.7-11.9 26.7-26.7 26.7H154.6c-14.7 0-26.7-11.9-26.7-26.7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const MasterRoleIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size || width}
|
||||||
|
height={size || height}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M15 21h-2a2 2 0 0 1 0-4h2v-2h-2a4 4 0 0 0 0 8h2Zm8-2a4 4 0 0 1-4 4h-2v-2h2a2 2 0 0 0 0-4h-2v-2h2a4 4 0 0 1 4 4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M14 18h4v2h-4zm-7 1a6 6 0 0 1 .09-1H3v-1.4c0-2 4-3.1 6-3.1a8.6 8.6 0 0 1 1.35.125A5.95 5.95 0 0 1 13 13h5V4a2.006 2.006 0 0 0-2-2h-4.18a2.988 2.988 0 0 0-5.64 0H2a2.006 2.006 0 0 0-2 2v14a2.006 2.006 0 0 0 2 2h5.09A6 6 0 0 1 7 19M9 2a1 1 0 1 1-1 1a1.003 1.003 0 0 1 1-1m0 4a3 3 0 1 1-3 3a2.996 2.996 0 0 1 3-3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const MasterUserLevelIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size || width}
|
||||||
|
height={size || height}
|
||||||
|
viewBox="0 0 640 512"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M192 256c61.9 0 112-50.1 112-112S253.9 32 192 32S80 82.1 80 144s50.1 112 112 112m76.8 32h-8.3c-20.8 10-43.9 16-68.5 16s-47.6-6-68.5-16h-8.3C51.6 288 0 339.6 0 403.2V432c0 26.5 21.5 48 48 48h288c26.5 0 48-21.5 48-48v-28.8c0-63.6-51.6-115.2-115.2-115.2M480 256c53 0 96-43 96-96s-43-96-96-96s-96 43-96 96s43 96 96 96m48 32h-3.8c-13.9 4.8-28.6 8-44.2 8s-30.3-3.2-44.2-8H432c-20.4 0-39.2 5.9-55.7 15.4c24.4 26.3 39.7 61.2 39.7 99.8v38.4c0 2.2-.5 4.3-.6 6.4H592c26.5 0 48-21.5 48-48c0-61.9-50.1-112-112-112"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const MasterCategoryIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size || width}
|
||||||
|
height={size || height}
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M14 25h14v2H14zm-6.83 1l-2.58 2.58L6 30l4-4l-4-4l-1.42 1.41zM14 15h14v2H14zm-6.83 1l-2.58 2.58L6 20l4-4l-4-4l-1.42 1.41zM14 5h14v2H14zM7.17 6L4.59 8.58L6 10l4-4l-4-4l-1.42 1.41z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const AddvertiseIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size || width}
|
||||||
|
height={size || height}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 8q-1.65 0-2.825 1.175T8 12q0 1.125.563 2.075t1.562 1.475q.4.2.563.587t-.013.788q-.175.35-.525.525t-.7 0q-1.575-.75-2.512-2.225T6 12q0-2.5 1.75-4.25T12 6q1.775 0 3.263.938T17.475 9.5q.15.35-.012.7t-.513.5q-.4.175-.8 0t-.6-.575q-.525-1-1.475-1.562T12 8m0-4Q8.65 4 6.325 6.325T4 12q0 3.15 2.075 5.4t5.2 2.55q.425.05.737.375t.288.75t-.313.7t-.712.25q-1.95-.125-3.638-.975t-2.95-2.213t-1.975-3.125T2 12q0-2.075.788-3.9t2.137-3.175T8.1 2.788T12 2q3.925 0 6.838 2.675t3.187 6.6q.05.4-.237.688t-.713.312t-.762-.275t-.388-.725q-.375-3-2.612-5.137T12 4m7.55 17.5l-3.3-3.275l-.75 2.275q-.125.35-.475.338t-.475-.363L12.275 12.9q-.1-.275.125-.5t.5-.125l7.575 2.275q.35.125.363.475t-.338.475l-2.275.75l3.3 3.3q.425.425.425.975t-.425.975t-.987.425t-.988-.425"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const SuggestionsIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size || width}
|
||||||
|
height={size || height}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M17.175 14H7.5q-1.875 0-3.187-1.312T3 9.5t1.313-3.187T7.5 5q.425 0 .713.288T8.5 6t-.288.713T7.5 7q-1.05 0-1.775.725T5 9.5t.725 1.775T7.5 12h9.675L14.3 9.1q-.275-.275-.288-.687T14.3 7.7q.275-.275.7-.275t.7.275l4.6 4.6q.3.3.3.7t-.3.7l-4.6 4.6q-.3.3-.7.288t-.7-.313q-.275-.3-.288-.7t.288-.7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const CommentIcon = ({
|
||||||
|
size,
|
||||||
|
height = 24,
|
||||||
|
width = 24,
|
||||||
|
fill = "currentColor",
|
||||||
|
...props
|
||||||
|
}: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size || width}
|
||||||
|
height={size || height}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M6.5 13.5h11v-1h-11zm0-3h11v-1h-11zm0-3h11v-1h-11zM4.616 17q-.691 0-1.153-.462T3 15.385V4.615q0-.69.463-1.153T4.615 3h14.77q.69 0 1.152.462T21 4.615v15.462L17.923 17z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
const agents = [
|
||||||
|
{
|
||||||
|
name: "Zamroni",
|
||||||
|
title: "Spv Jaecoo Kelapa Gading",
|
||||||
|
image: "/zamroni.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Murtiyono",
|
||||||
|
title: "Spv Jaecoo Kelapa Gading",
|
||||||
|
image: "/murtiyono.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sutino",
|
||||||
|
title: "Spv Jaecoo Kelapa Gading",
|
||||||
|
image: "/sutino.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Amendra Ismail",
|
||||||
|
title: "Spv Jaecoo Kelapa Gading",
|
||||||
|
image: "/amendra.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Henny",
|
||||||
|
title: "Branch Manager Jaecoo Kelapa Gading",
|
||||||
|
image: "/henny.png",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Agent() {
|
||||||
|
return (
|
||||||
|
<section className="py-16 px-6 md:px-12 bg-[#f9f9f9] text-center mt-0">
|
||||||
|
<motion.h2
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="text-3xl md:text-6xl font-semibold text-gray-900 mb-2"
|
||||||
|
>
|
||||||
|
Agen Unggulan Kami
|
||||||
|
</motion.h2>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="text-gray-600 mb-10 text-lg"
|
||||||
|
>
|
||||||
|
Temui anggota tim kami yang luar biasa
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6 justify-center">
|
||||||
|
{agents.map((agent, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
initial={{ opacity: 0, y: 40 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.6,
|
||||||
|
delay: index * 0.2 + 0.3,
|
||||||
|
ease: "easeOut",
|
||||||
|
}}
|
||||||
|
viewport={{ once: true, amount: 0.3 }}
|
||||||
|
className="bg-white shadow-md px-4 py-10 flex flex-col items-center h-[317px]"
|
||||||
|
>
|
||||||
|
<div className="relative w-36 h-40 mb-14">
|
||||||
|
<Image
|
||||||
|
src={agent.image}
|
||||||
|
alt={agent.name}
|
||||||
|
fill
|
||||||
|
className="rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl text-gray-900">{agent.name}</h3>
|
||||||
|
<p className="text-xs text-gray-600 text-center mt-1">
|
||||||
|
{agent.title}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useInView } from "react-intersection-observer";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
title: "REAR BUMPER DESIGN",
|
||||||
|
description:
|
||||||
|
"Featuring refined lines and bold contours, the rear bumper enhances the vehicle’s sporty and stylish character.",
|
||||||
|
image: "/pj7-1.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "PANORAMIC SUNROOF",
|
||||||
|
description:
|
||||||
|
"The Panoramic Sunroof transforms your journey, creating a brighter and more spacious atmosphere.",
|
||||||
|
image: "/pj7-2.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "REAR BUMPER DESIGN",
|
||||||
|
description:
|
||||||
|
"Featuring refined lines and bold contours, the rear bumper enhances the vehicle’s sporty and stylish character.",
|
||||||
|
image: "/pj7-3.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "NATURAL DAYLIGHT LED LIGHTING SYSTEM",
|
||||||
|
description:
|
||||||
|
"Illuminate your journey with the Natural Daylight LED Lighting System, designed to mimic the clarity and warmth of sunlight.",
|
||||||
|
image: "/pj7-4.png",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Exterior() {
|
||||||
|
const [ref, inView] = useInView({ triggerOnce: true, threshold: 0.2 });
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inView) {
|
||||||
|
setShow(true);
|
||||||
|
}
|
||||||
|
}, [inView]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
ref={ref}
|
||||||
|
className="py-10 px-4 sm:px-6 md:px-20 bg-white overflow-hidden"
|
||||||
|
>
|
||||||
|
<motion.h2
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={show ? { opacity: 1, y: 0 } : {}}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
className="text-2xl mt-5 mb-8"
|
||||||
|
>
|
||||||
|
<span className="text-[#1F6779] font-semibold">Jaecoo 7 AWD</span>{" "}
|
||||||
|
Teknologi dan Exterior
|
||||||
|
</motion.h2>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={show ? { opacity: 1, scale: 1 } : {}}
|
||||||
|
transition={{ duration: 0.8 }}
|
||||||
|
className="relative w-full h-[300px] sm:h-[400px] md:h-[600px]"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/jj7-awd.png"
|
||||||
|
alt="Aluminium Chassis"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="100vw"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-6 left-3 sm:left-3 md:left-6 max-w-5xl">
|
||||||
|
<h2 className="text-xl sm:text-xl font-semibold text-white">
|
||||||
|
ALUMINIUM CHASSIS
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs sm:text-xs mt-2 text-gray-200">
|
||||||
|
Built with a lightweight aluminum chassis, offering enhanced
|
||||||
|
strength, durability, and improved performance for a superior
|
||||||
|
driving experience.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 50 }}
|
||||||
|
animate={show ? { opacity: 1, y: 0 } : {}}
|
||||||
|
transition={{ delay: 0.2, duration: 0.8 }}
|
||||||
|
className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-2 sm:gap-4 mt-5"
|
||||||
|
>
|
||||||
|
{features.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
className="relative aspect-[4/3] overflow-hidden group"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={item.image}
|
||||||
|
alt={item.title}
|
||||||
|
fill
|
||||||
|
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
sizes="(max-width: 768px) 100vw, 25vw"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
||||||
|
<h3 className="text-sm sm:text-base font-bold text-white">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs sm:text-sm text-gray-300 mt-1">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
export default function FeaturesAndSpecifications() {
|
||||||
|
return (
|
||||||
|
<section className="pt-10 px-4 sm:px-6 md:px-20 bg-white">
|
||||||
|
<h2 className="text-2xl mt-5 mb-8">
|
||||||
|
<span className="text-[#1F6779] font-semibold">Jaecoo 7 AWD</span> Fitur
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="relative w-full h-[300px] sm:h-[400px] md:h-[600px]">
|
||||||
|
<Image
|
||||||
|
src="/fitur1.png"
|
||||||
|
alt="Aluminium Chassis"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="100vw"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-26 left-3 sm:left-10 md:left-26 max-w-xs bg-white/60 rounded-lg p-4">
|
||||||
|
<h2 className="text-xl sm:text-sm font-semibold text-black">
|
||||||
|
Lane Changing Assistance
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs sm:text-xs mt-2 text-black">
|
||||||
|
Advanced safety feature that monitors surrounding traffic and
|
||||||
|
provides alerts or steering support to help ensure sasfe and
|
||||||
|
confident lane changes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-full h-[300px] sm:h-[400px] md:h-[600px] my-20">
|
||||||
|
<Image
|
||||||
|
src="/fitur2.png"
|
||||||
|
alt="Aluminium Chassis"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="100vw"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl mt-5 mb-8">
|
||||||
|
<span className="text-[#1F6779] font-semibold">Jaecoo 7 AWD</span>{" "}
|
||||||
|
Spesifikasi
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-x-32 gap-y-6 text-sm sm:text-base text-start my-10">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Max Power</p>
|
||||||
|
<p className="font-bold">136,5kw/183hp</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Power Train</p>
|
||||||
|
<p className="font-bold">1.6T+7DHT</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Torque</p>
|
||||||
|
<p className="font-bold">275N.m</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Sensor</p>
|
||||||
|
<p className="font-bold">8 Sensor</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Max Speed</p>
|
||||||
|
<p className="font-bold">180km/h</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">0-100km-h</p>
|
||||||
|
<p className="font-bold">9,2s</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Airbag</p>
|
||||||
|
<p className="font-bold">8 airbags</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">ADAS</p>
|
||||||
|
<p className="font-bold">19 adas</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 pt-10 ">
|
||||||
|
<div className="text-start px-4 border-l border-gray-300">
|
||||||
|
<div className="mb-14 ml-3">
|
||||||
|
<div className="text-4xl mb-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="30"
|
||||||
|
height="30"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<g fill="none">
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
// stroke-linejoin="round"
|
||||||
|
// stroke-width="2"
|
||||||
|
d="M12 13v7.098c0 .399 0 .598-.129.67c-.129.071-.298-.035-.636-.246L4.94 16.588c-.46-.288-.69-.431-.815-.658C4 15.705 4 15.434 4 14.893V8m8 5L4 8m8 5l5.286-3.304c1.218-.761 1.827-1.142 1.827-1.696s-.609-.935-1.827-1.696L13.06 3.663c-.516-.323-.773-.484-1.06-.484s-.544.161-1.06.484L4 8"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M19 12a1 1 0 1 0 2 0zm.875-3.93L19 8.553zM19 9.107V12h2V9.108zm.59-2.544l-3.06-1.912l-1.06 1.696l3.06 1.913zM21 9.109c0-.252.001-.51-.02-.733a2 2 0 0 0-.23-.79l-1.75.97c-.027-.05-.02-.073-.011.01c.01.106.011.254.011.543zm-2.47-.848c.246.154.37.233.454.298c.067.05.043.045.016-.004l1.75-.97a2 2 0 0 0-.549-.614c-.177-.136-.397-.272-.611-.405z"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="17.5"
|
||||||
|
cy="16.5"
|
||||||
|
r="2.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
// stroke-width="2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
// stroke-linecap="round"
|
||||||
|
// stroke-width="2"
|
||||||
|
d="m21 20l-1.5-1.5"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M14.53 20.598a1 1 0 0 0-1.06-1.696zM11 20.375l-.53.848zm.937.444l-.063.998zm.126 0L12 19.82zm-.533-1.292l-3-1.875l-1.06 1.696l3 1.875zm1.94-.625l-.5.313l1.06 1.695l.5-.312zm-.5.313l-.5.312l1.06 1.696l.5-.312zm-2.5 2.008c.213.133.429.27.625.368c.214.108.47.206.779.226L12 19.82c.056.003.072.022-.005-.016a7 7 0 0 1-.465-.278zm2-1.696a7 7 0 0 1-.465.278c-.077.038-.061.02-.005.016l.126 1.996c.31-.02.565-.118.779-.226c.196-.099.412-.235.625-.368zm-.596 2.29q.126.008.252 0L12 19.82z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="font-semibold mb-1"> {">"}TERSEDIA DALAM STOK</p>
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
Jelajahi pilihan hebat kami dari mobil Jaecoo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-start px-4 border-l border-r border-gray-300">
|
||||||
|
<div className="mb-14 ml-3">
|
||||||
|
<div className="text-2xl mb-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="30"
|
||||||
|
height="30"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20zm8-7L4 8v10h16V8zm0-2l8-5H4zM4 8V6v12z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="font-semibold mb-1">{">"}BERITAHU SAYA</p>
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
Daftar untuk semua berita terbaru dari Jaecoo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-start px-4 border-r border-gray-300">
|
||||||
|
<div className="mb-14">
|
||||||
|
<div className="text-2xl mb-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="30"
|
||||||
|
height="30"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
// fill-rule="evenodd"
|
||||||
|
d="M256 42.667c117.821 0 213.334 95.513 213.334 213.333c0 117.821-95.513 213.334-213.334 213.334c-117.82 0-213.333-95.513-213.333-213.334C42.667 138.18 138.18 42.667 256 42.667M85.334 256c0 87.032 65.145 158.848 149.332 169.346V316.358c-21.87-7.73-38.283-27.01-41.913-50.51L85.636 245.762q-.301 5.081-.302 10.238m341.031-10.238l-107.118 20.086c-3.629 23.5-20.043 42.78-41.913 50.51v108.988C361.523 414.848 426.668 343.032 426.668 256q-.001-5.156-.302-10.238M256 85.334c-76.056 0-140.493 49.75-162.541 118.484l107.16 20.085C211.699 204.827 232.352 192 256 192c23.65 0 44.302 12.827 55.382 31.903l107.16-20.085C396.493 135.084 332.057 85.334 256 85.334"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="font-semibold mb-1">{">"}PESAN TEST DRIVE</p>
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
Atur test drive di jalan melalui Dealer terdekat kami
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="bg-black text-[#c7dbe3] px-6 md:px-20 py-16">
|
||||||
|
<div className="flex flex-col md:flex-row gap-10">
|
||||||
|
<div className="w-full md:w-4/12">
|
||||||
|
<Image
|
||||||
|
src="/masjaecoo.png"
|
||||||
|
alt="Jaecoo"
|
||||||
|
width={300}
|
||||||
|
height={200}
|
||||||
|
className="ml-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-4 mt-6 ml-24 md:ml-20 text-xl text-[#c7dbe3]">
|
||||||
|
<div className="hover:text-white cursor-pointer">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
// stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
// stroke-linecap="round"
|
||||||
|
// stroke-linejoin="round"
|
||||||
|
d="M12 16a4 4 0 1 0 0-8a4 4 0 0 0 0 8"
|
||||||
|
/>
|
||||||
|
<path d="M3 16V8a5 5 0 0 1 5-5h8a5 5 0 0 1 5 5v8a5 5 0 0 1-5 5H8a5 5 0 0 1-5-5Z" />
|
||||||
|
<path
|
||||||
|
// stroke-linecap="round"
|
||||||
|
// stroke-linejoin="round"
|
||||||
|
d="m17.5 6.51l.01-.011"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="hover:text-white cursor-pointer">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M14 13.5h2.5l1-4H14v-2c0-1.03 0-2 2-2h1.5V2.14c-.326-.043-1.557-.14-2.857-.14C11.928 2 10 3.657 10 6.7v2.8H7v4h3V22h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="hover:text-white cursor-pointer">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M16 8.245V15.5a6.5 6.5 0 1 1-5-6.326v3.163a3.5 3.5 0 1 0 2 3.163V2h3a5 5 0 0 0 5 5v3a7.97 7.97 0 0 1-5-1.755"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="hover:text-white cursor-pointer">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12.244 4c.534.003 1.87.016 3.29.073l.504.022c1.429.067 2.857.183 3.566.38c.945.266 1.687 1.04 1.938 2.022c.4 1.56.45 4.602.456 5.339l.001.152v.174c-.007.737-.057 3.78-.457 5.339c-.254.985-.997 1.76-1.938 2.022c-.709.197-2.137.313-3.566.38l-.504.023c-1.42.056-2.756.07-3.29.072l-.235.001h-.255c-1.13-.007-5.856-.058-7.36-.476c-.944-.266-1.687-1.04-1.938-2.022c-.4-1.56-.45-4.602-.456-5.339v-.326c.006-.737.056-3.78.456-5.339c.254-.985.997-1.76 1.939-2.021c1.503-.419 6.23-.47 7.36-.476zM9.999 8.5v7l6-3.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="md:w-8/12 ">
|
||||||
|
<div className="flex flex-wrap md:flex-row gap-10 md:gap-28">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-white mb-4">ABOUT</h4>
|
||||||
|
<ul className="space-y-4 text-sm">
|
||||||
|
<li>
|
||||||
|
<a href="#">Partnership</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#">Terms of Use</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#">Privacy</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-white mb-4">PRODUCT</h4>
|
||||||
|
<ul className="space-y-4 text-sm">
|
||||||
|
<li>
|
||||||
|
<a href="#">About</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#">Features</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#">Support</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-white mb-4">RESOURCES</h4>
|
||||||
|
<ul className="space-y-4 text-sm">
|
||||||
|
<li>
|
||||||
|
<a href="#">Career</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#">Blog</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#">Legal</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-white mb-4">CONTACT</h4>
|
||||||
|
<ul className="space-y-4 text-sm">
|
||||||
|
<li>
|
||||||
|
<a href="https://jaecoo.com" target="_blank" rel="noreferrer">
|
||||||
|
jaecoo.com
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>0851-1234-567</li>
|
||||||
|
<li>
|
||||||
|
<p className="font-semibold text-white">
|
||||||
|
Jaecoo Kelapa Gading
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Jl. Boulevard Raya Blok LA 6 No. 34-35,
|
||||||
|
<br />
|
||||||
|
Kelapa Gading, Jakarta Utara 14240
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
const images = ["/g1.png", "/g2.png", "/g3.png", "/g4.png"];
|
||||||
|
|
||||||
|
export default function Galeri() {
|
||||||
|
const [consent, setConsent] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="px-4 py-12 md:px-20 bg-white">
|
||||||
|
<motion.h2
|
||||||
|
className="text-3xl font-bold mb-8 text-black"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
>
|
||||||
|
Galeri Kami
|
||||||
|
</motion.h2>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-8 mb-16">
|
||||||
|
<div className="flex justify-start gap-8 flex-wrap">
|
||||||
|
{[images[0], images[1]].map((src, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
className="relative w-[400px] h-[250px] overflow-hidden"
|
||||||
|
initial={{ opacity: 0, x: -40 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: index * 0.2 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={`Galeri ${index + 1}`}
|
||||||
|
fill
|
||||||
|
className="object-cover object-center"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-8 flex-wrap">
|
||||||
|
{[images[2], images[3]].map((src, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index + 2}
|
||||||
|
className="relative w-[400px] h-[250px] overflow-hidden"
|
||||||
|
initial={{ opacity: 0, x: 40 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: index * 0.2 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={`Galeri ${index + 3}`}
|
||||||
|
fill
|
||||||
|
className="object-cover object-center"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
export default function HeaderAbout() {
|
||||||
|
return (
|
||||||
|
<section className="py-10 px-4 sm:px-6 md:px-10 bg-white">
|
||||||
|
<div className="flex flex-col items-center gap-6">
|
||||||
|
<motion.div
|
||||||
|
className="relative w-full h-[300px] sm:h-[400px] md:h-[640px] overflow-hidden "
|
||||||
|
initial={{ opacity: 0, y: 40 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8, ease: [0.25, 0.1, 0.25, 1] }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/about-i.png"
|
||||||
|
alt="about-header"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(max-width: 768px) 100vw, 640px"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-col lg:flex-row gap-10 mt-10">
|
||||||
|
<motion.div
|
||||||
|
className="w-full lg:w-6/12 space-y-6 sm:ml-15"
|
||||||
|
initial={{ opacity: 0, x: -40 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.8, ease: [0.25, 0.1, 0.25, 1] }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold text-black">
|
||||||
|
Mengenal Lebih Dekat Dealer Resmi Jaecoo
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg sm:text-xl font-semibold text-gray-800 mb-2">
|
||||||
|
Deskripsi Singkat
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 leading-relaxed text-base">
|
||||||
|
Dealer resmi Jaecoo sejak 2023, berlokasi di pusat Bandung. Kami
|
||||||
|
melayani penjualan, servis, serta test drive dengan fasilitas
|
||||||
|
showroom modern dan teknisi bersertifikat.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-gray-700 text-sm space-y-2">
|
||||||
|
<p>
|
||||||
|
<strong>Alamat:</strong> Jaecoo Cihampelas Bandung, Jl. Cihampelas
|
||||||
|
No. 264-268, Bandung, Jawa Barat 40131
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Telepon:</strong> 021-12345678
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Email:</strong> info@dealerjaecoo.id
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="relative w-full lg:w-[536px] h-[300px] sm:h-[400px] lg:h-[576px] overflow-hidden"
|
||||||
|
initial={{ opacity: 0, x: 40 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.8, ease: [0.25, 0.1, 0.25, 1] }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/dealer.png"
|
||||||
|
alt="Dealer Jaecoo"
|
||||||
|
fill
|
||||||
|
className="object-cover object-center"
|
||||||
|
sizes="(max-width: 768px) 100vw, 536px"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
"use client";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
export default function HeaderItems() {
|
||||||
|
return (
|
||||||
|
<section className="py-10 px-6 md:px-10 bg-white">
|
||||||
|
<motion.div
|
||||||
|
className="flex flex-col items-center gap-10"
|
||||||
|
initial={{ opacity: 0, y: 50 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8 }}
|
||||||
|
>
|
||||||
|
<div className="relative w-full h-[640px] overflow-hidden p-5">
|
||||||
|
<Image
|
||||||
|
src={"/service.png"}
|
||||||
|
alt="Service Header"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="100vw"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function HeaderProduct() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="py-10 px-4 sm:px-6 md:px-10 bg-white">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 50 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8 }}
|
||||||
|
className="flex flex-col items-center gap-6"
|
||||||
|
>
|
||||||
|
<div className="relative w-full h-[300px] sm:h-[400px] md:h-[640px] overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src="/product1.jpg"
|
||||||
|
alt="about-header"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(max-width: 768px) 100vw, 640px"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3, duration: 0.6 }}
|
||||||
|
className="flex flex-col md:flex-row justify-between items-center my-8"
|
||||||
|
>
|
||||||
|
<p className="text-lg md:text-5xl font-bold">JAECOO 7 AWD</p>
|
||||||
|
<div className="flex items-center mt-2 md:mt-0 gap-3">
|
||||||
|
<a
|
||||||
|
href="https://drive.google.com/file/d/1Nici3bdjUG524sUYQgHfbeO63xW6f1_o/view"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Button className="bg-white text-black border w-[100px] h-[30px] md:w-[200px] md:h-[40px] rounded-xl hover:bg-amber-50 hover:cursor-pointer">
|
||||||
|
BROSUR
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<Link href={"/about"}>
|
||||||
|
<Button className="bg-[#1F6779] h-[30px] md:w-[200px] md:h-[40px] rounded-xl hover:cursor-pointer">
|
||||||
|
TEST DRIVE
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.5, duration: 0.8 }}
|
||||||
|
className="relative w-full h-[300px] sm:h-[400px] md:h-[640px] overflow-hidden"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/color.png"
|
||||||
|
alt="about-header"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(max-width: 768px) 100vw, 640px"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -40 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.8, duration: 0.6 }}
|
||||||
|
className="absolute top-1/2 left-5 md:left-56 transform -translate-y-1/2 flex flex-col gap-4 z-10"
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
"linear-gradient(to bottom, #C9CCD3, #FFFFFF)",
|
||||||
|
"linear-gradient(to bottom, #788662, #C9CCD3)",
|
||||||
|
"linear-gradient(to bottom, #1A1A1A, #3A3A3A)",
|
||||||
|
"linear-gradient(to bottom, #E1ECF4, #FFFFFF)",
|
||||||
|
].map((bg, index) => (
|
||||||
|
<motion.button
|
||||||
|
key={index}
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
className="w-6 h-6 rounded-full border border-white shadow-md hover:cursor-pointer"
|
||||||
|
style={{ background: bg }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import {
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselNext,
|
||||||
|
CarouselPrevious,
|
||||||
|
} from "@/components/ui/carousel";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const heroImages = ["/Hero.png", "/hero2.png", "/hero3.png"];
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
return (
|
||||||
|
<section className="relative w-full overflow-hidden bg-white">
|
||||||
|
<Carousel className="w-full relative">
|
||||||
|
<CarouselContent>
|
||||||
|
{heroImages.map((img, index) => (
|
||||||
|
<CarouselItem key={index}>
|
||||||
|
<div className="relative w-full h-[810px]">
|
||||||
|
<Image
|
||||||
|
src={img}
|
||||||
|
alt={`JAECOO Image ${index + 1}`}
|
||||||
|
width={1400}
|
||||||
|
height={810}
|
||||||
|
className="object-cover w-full h-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{index === 0 && (
|
||||||
|
<div className="absolute inset-0 flex flex-col justify-center items-start px-6 md:px-28 z-10">
|
||||||
|
<motion.h1
|
||||||
|
initial={{ opacity: 0, y: 40 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||||
|
className="text-3xl sm:text-4xl md:text-5xl font-bold text-black mb-4"
|
||||||
|
>
|
||||||
|
JAECOO J7 AWD
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 40 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.9,
|
||||||
|
ease: "easeOut",
|
||||||
|
delay: 0.2,
|
||||||
|
}}
|
||||||
|
className="text-lg text-black mb-6"
|
||||||
|
>
|
||||||
|
DELICATE OFF-ROAD SUV
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="flex items-center gap-4"
|
||||||
|
initial={{ opacity: 0, y: 40 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 1, ease: "easeOut", delay: 0.4 }}
|
||||||
|
>
|
||||||
|
<Link href={"/about"}>
|
||||||
|
<Button className="rounded-full bg-[#155B6E] px-6 py-2 text-white hover:cursor-pointer">
|
||||||
|
TEST DRIVE
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href={"/product"}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full border-black text-black px-6 py-2 hover:cursor-pointer hover:bg-amber-50"
|
||||||
|
>
|
||||||
|
JELAJAHI
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CarouselPrevious className="absolute left-6 top-1/2 -translate-y-1/2 z-20 border border-[#155B6E] text-[#155B6E] bg-white" />
|
||||||
|
<CarouselNext className="absolute right-6 top-1/2 -translate-y-1/2 z-20 border border-[#155B6E] text-[#155B6E] bg-white" />
|
||||||
|
</div>
|
||||||
|
</CarouselItem>
|
||||||
|
))}
|
||||||
|
</CarouselContent>
|
||||||
|
</Carousel>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
export default function Help() {
|
||||||
|
return (
|
||||||
|
<section className="max-w-[1400px] mx-auto bg-white pt-16 px-4 sm:px-6 lg:px-10">
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold text-center mb-6">
|
||||||
|
Need More Help ?
|
||||||
|
</h2>
|
||||||
|
<h2 className="text-2xl sm:text-xl text-center mb-6 text-[#007BAC]">
|
||||||
|
Just Call Tiara
|
||||||
|
</h2>
|
||||||
|
<div className="w-full mb-10 flex justify-center">
|
||||||
|
<Image
|
||||||
|
src="/tiara.png"
|
||||||
|
alt="Lokasi Servis Terdekat"
|
||||||
|
width={500}
|
||||||
|
height={500}
|
||||||
|
className="w-[600px] object-cover rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 pt-10">
|
||||||
|
<div className="text-start px-4 border-l border-gray-300">
|
||||||
|
<div className="mb-14 ml-3">
|
||||||
|
<div className="text-4xl mb-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="30"
|
||||||
|
height="30"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<g fill="none">
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
// stroke-linejoin="round"
|
||||||
|
// stroke-width="2"
|
||||||
|
d="M12 13v7.098c0 .399 0 .598-.129.67c-.129.071-.298-.035-.636-.246L4.94 16.588c-.46-.288-.69-.431-.815-.658C4 15.705 4 15.434 4 14.893V8m8 5L4 8m8 5l5.286-3.304c1.218-.761 1.827-1.142 1.827-1.696s-.609-.935-1.827-1.696L13.06 3.663c-.516-.323-.773-.484-1.06-.484s-.544.161-1.06.484L4 8"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M19 12a1 1 0 1 0 2 0zm.875-3.93L19 8.553zM19 9.107V12h2V9.108zm.59-2.544l-3.06-1.912l-1.06 1.696l3.06 1.913zM21 9.109c0-.252.001-.51-.02-.733a2 2 0 0 0-.23-.79l-1.75.97c-.027-.05-.02-.073-.011.01c.01.106.011.254.011.543zm-2.47-.848c.246.154.37.233.454.298c.067.05.043.045.016-.004l1.75-.97a2 2 0 0 0-.549-.614c-.177-.136-.397-.272-.611-.405z"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="17.5"
|
||||||
|
cy="16.5"
|
||||||
|
r="2.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
// stroke-width="2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
// stroke-linecap="round"
|
||||||
|
// stroke-width="2"
|
||||||
|
d="m21 20l-1.5-1.5"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M14.53 20.598a1 1 0 0 0-1.06-1.696zM11 20.375l-.53.848zm.937.444l-.063.998zm.126 0L12 19.82zm-.533-1.292l-3-1.875l-1.06 1.696l3 1.875zm1.94-.625l-.5.313l1.06 1.695l.5-.312zm-.5.313l-.5.312l1.06 1.696l.5-.312zm-2.5 2.008c.213.133.429.27.625.368c.214.108.47.206.779.226L12 19.82c.056.003.072.022-.005-.016a7 7 0 0 1-.465-.278zm2-1.696a7 7 0 0 1-.465.278c-.077.038-.061.02-.005.016l.126 1.996c.31-.02.565-.118.779-.226c.196-.099.412-.235.625-.368zm-.596 2.29q.126.008.252 0L12 19.82z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="font-semibold mb-1"> {">"}TERSEDIA DALAM STOK</p>
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
Jelajahi pilihan hebat kami dari mobil Jaecoo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-start px-4 border-l border-r border-gray-300">
|
||||||
|
<div className="mb-14 ml-3">
|
||||||
|
<div className="text-2xl mb-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="30"
|
||||||
|
height="30"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20zm8-7L4 8v10h16V8zm0-2l8-5H4zM4 8V6v12z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="font-semibold mb-1">{">"}BERITAHU SAYA</p>
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
Daftar untuk semua berita terbaru dari Jaecoo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-start px-4 border-r border-gray-300">
|
||||||
|
<div className="mb-14">
|
||||||
|
<div className="text-2xl mb-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="30"
|
||||||
|
height="30"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
// fill-rule="evenodd"
|
||||||
|
d="M256 42.667c117.821 0 213.334 95.513 213.334 213.333c0 117.821-95.513 213.334-213.334 213.334c-117.82 0-213.333-95.513-213.333-213.334C42.667 138.18 138.18 42.667 256 42.667M85.334 256c0 87.032 65.145 158.848 149.332 169.346V316.358c-21.87-7.73-38.283-27.01-41.913-50.51L85.636 245.762q-.301 5.081-.302 10.238m341.031-10.238l-107.118 20.086c-3.629 23.5-20.043 42.78-41.913 50.51v108.988C361.523 414.848 426.668 343.032 426.668 256q-.001-5.156-.302-10.238M256 85.334c-76.056 0-140.493 49.75-162.541 118.484l107.16 20.085C211.699 204.827 232.352 192 256 192c23.65 0 44.302 12.827 55.382 31.903l107.16-20.085C396.493 135.084 332.057 85.334 256 85.334"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="font-semibold mb-1">{">"}PESAN TEST DRIVE</p>
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
Atur test drive di jalan melalui Dealer terdekat kami
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
"use client";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useInView } from "react-intersection-observer";
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
title: "14.8 Screen with APPLE Carplay & Android Auto",
|
||||||
|
description:
|
||||||
|
"Stay connected and informed with a 14.8 display offering clear visuals and advanced functionality for a seamless driving experience.",
|
||||||
|
image: "/interior-2.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Windshield Heads-Up Display (W-HUD)",
|
||||||
|
description:
|
||||||
|
"Stay informed and focused on the road with the adjustable W-HUD, tailored to align perfectly with your unique sitting position for optimal driving comfort and safety.",
|
||||||
|
image: "/interior-3.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "540 degree HD video",
|
||||||
|
description:
|
||||||
|
"The 540-degree HD video system provides comprehensive coverage, ensuring nothing escapes your view.",
|
||||||
|
image: "/interior-4.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Wireless Charging",
|
||||||
|
description:
|
||||||
|
"Stay powered up on the go with Wireless Charging, ensuring your devices are always ready when you are.",
|
||||||
|
image: "/interior-5.png",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const interior = [
|
||||||
|
{
|
||||||
|
title: "Aircraft-style Gear Shift",
|
||||||
|
description:
|
||||||
|
"A seamless blend of style and performance with the Avantgrade Fighter-Inspired Transmission Shifter.",
|
||||||
|
image: "/interior-6.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Heated/Ventilated Seats",
|
||||||
|
description:
|
||||||
|
"Enjoy ultimate comfort with synthetic leather seats featuring heating and ventilation, designed for a luxurious and adaptable driving experience.",
|
||||||
|
image: "/interior-7.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Sony 8-Speaker Audio system",
|
||||||
|
description:
|
||||||
|
"Immerse yourself in rich, high-quality sound with the Sony 8-speaker audio system, delivering an exceptional listening experience on every journey.",
|
||||||
|
image: "/interior-8.png",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
export default function Interior() {
|
||||||
|
const { ref, inView } = useInView({ triggerOnce: true, threshold: 0.2 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-10 px-4 sm:px-6 md:px-20 bg-white" ref={ref}>
|
||||||
|
<motion.h2
|
||||||
|
className="text-2xl mt-5 mb-8"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={inView ? { opacity: 1, y: 0 } : {}}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<span className="text-[#1F6779] font-semibold">Jaecoo 7 AWD</span>{" "}
|
||||||
|
Interior
|
||||||
|
</motion.h2>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="relative w-full h-[300px] sm:h-[400px] md:h-[600px]"
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={inView ? { opacity: 1, scale: 1 } : {}}
|
||||||
|
transition={{ duration: 0.7 }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/interior-1.png"
|
||||||
|
alt="Interior Hero"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="100vw"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-2 sm:gap-4 mt-5">
|
||||||
|
{features.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
className="relative aspect-[4/3] overflow-hidden group"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={inView ? { opacity: 1, y: 0 } : {}}
|
||||||
|
transition={{ duration: 0.4, delay: index * 0.1 }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={item.image}
|
||||||
|
alt={item.title}
|
||||||
|
fill
|
||||||
|
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
sizes="(max-width: 768px) 100vw, 25vw"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
||||||
|
<h3 className="text-sm sm:text-base font-bold text-white">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs sm:text-sm text-gray-300 mt-1">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2 sm:gap-4 mt-5">
|
||||||
|
{interior.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
className="relative aspect-[2/1] overflow-hidden group"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={inView ? { opacity: 1, y: 0 } : {}}
|
||||||
|
transition={{ duration: 0.4, delay: index * 0.1 }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={item.image}
|
||||||
|
alt={item.title}
|
||||||
|
fill
|
||||||
|
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
sizes="(max-width: 768px) 100vw, 25vw"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
||||||
|
<h3 className="text-sm sm:text-base font-bold text-white">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs sm:text-sm text-gray-300 mt-1">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
image: "/new-car2.png",
|
||||||
|
title: "JAECOO J7 AWD",
|
||||||
|
description: "DELICATE OFF-ROAD SUV",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: "/new-car1.png",
|
||||||
|
title: "JAECOO J7 SHS",
|
||||||
|
description: "SUPER HYBRID SYSTEM = SUPER HEV + EV",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: "/new-car3.png",
|
||||||
|
title: "JAECOO J8 AWD",
|
||||||
|
description: "FIRST CLASS OFF-ROAD",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Items() {
|
||||||
|
return (
|
||||||
|
<section className="py-10 px-4 sm:px-6 md:px-10 bg-white">
|
||||||
|
<div className="flex flex-col items-center gap-10">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
initial={{ opacity: 0, y: 50 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.7,
|
||||||
|
delay: index * 0.2,
|
||||||
|
ease: "easeOut",
|
||||||
|
}}
|
||||||
|
className="relative w-full min-h-[400px] sm:min-h-[500px] md:min-h-[600px] overflow-hidden"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={item.image}
|
||||||
|
alt={item.title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(max-width: 768px) 100vw, 768px"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="absolute inset-0 z-10 flex flex-col justify-between">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: index * 0.25 + 0.3, duration: 0.5 }}
|
||||||
|
className="mt-3 ml-3 font-semibold text-white text-sm sm:text-xl px-4 py-2 rounded-lg max-w-[80%]"
|
||||||
|
>
|
||||||
|
{item.description}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center pb-8 bg-gradient-to-t to-transparent">
|
||||||
|
<motion.h1
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.25 + 0.4, duration: 0.6 }}
|
||||||
|
className="text-2xl sm:text-3xl md:text-2xl font-semibold text-white mb-4 text-center"
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</motion.h1>
|
||||||
|
<motion.div
|
||||||
|
className="flex items-center gap-4"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.25 + 0.6, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<Link href="/about">
|
||||||
|
<Button className="rounded-full bg-[#155B6E] px-6 py-2 text-white hover:cursor-pointer">
|
||||||
|
TEST DRIVE
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/product">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full border-white text-black px-6 py-2 hover:text-black hover:bg-amber-50 hover:border-white hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
EXPLORE
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function FormJaecoo() {
|
||||||
|
const [consent, setConsent] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="px-4 py-12 md:px-20 bg-white">
|
||||||
|
<div className="max-w-full mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold mb-8 text-black">
|
||||||
|
Get In Touch With Us
|
||||||
|
</h2>
|
||||||
|
<form className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="name" className="mb-2">
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="Type your name here…"
|
||||||
|
className="w-full h-12 text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email" className="mb-2">
|
||||||
|
Email
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Rachel@domain.com"
|
||||||
|
className="w-full h-12 text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="phone" className="mb-2">
|
||||||
|
Mobile Number
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
type="tel"
|
||||||
|
placeholder="+62 8xxxx"
|
||||||
|
className="w-full h-12 text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="city" className="mb-2 block">
|
||||||
|
City
|
||||||
|
</Label>
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger className="w-full h-12 text-base">
|
||||||
|
<SelectValue placeholder="Select city" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="jakarta">Jakarta</SelectItem>
|
||||||
|
<SelectItem value="bandung">Bandung</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="categories" className="mb-2">
|
||||||
|
Categories
|
||||||
|
</Label>
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger className="w-full h-12 text-base">
|
||||||
|
<SelectValue placeholder="Select categories" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ev">Electric Vehicle</SelectItem>
|
||||||
|
<SelectItem value="suv">SUV</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="series" className="mb-2">
|
||||||
|
Product Series
|
||||||
|
</Label>
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger className="w-full h-12 text-base">
|
||||||
|
<SelectValue placeholder="Select product series" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="e100">E100</SelectItem>
|
||||||
|
<SelectItem value="e200">E200</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="showroom" className="mb-2">
|
||||||
|
Showroom
|
||||||
|
</Label>
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger className="w-full h-12 text-base">
|
||||||
|
<SelectValue placeholder="Select showroom" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="jakarta">Jakarta</SelectItem>
|
||||||
|
<SelectItem value="bandung">Bandung</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="subject" className="mb-2">
|
||||||
|
Subject
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="subject"
|
||||||
|
placeholder="Type your subject here…"
|
||||||
|
className="w-full h-12 text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="message" className="mb-2">
|
||||||
|
Message
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="message"
|
||||||
|
placeholder="Type your query here…"
|
||||||
|
className="w-full h-32 resize-none text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="consent"
|
||||||
|
checked={consent}
|
||||||
|
onCheckedChange={() => setConsent(!consent)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="consent" className="text-sm text-gray-700">
|
||||||
|
By providing your information, you consent to the collection, use
|
||||||
|
and disclosure of your personal data by PT. Inchcape Indomobil
|
||||||
|
Energi Baru and our trusted third parties (our related
|
||||||
|
corporations and affiliates, selected partners, service providers,
|
||||||
|
agents and other Inchcape Indomobil Energi Baru companies) in
|
||||||
|
accordance with the purposes set out in our privacy policy.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
type="submit"
|
||||||
|
className="bg-[#008bcf] hover:bg-[#0072a8] w-[250px] h-[50px] rounded-full text-white"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
const locations = [
|
||||||
|
{
|
||||||
|
name: "Jaecoo 1S Kelapa Gading",
|
||||||
|
address:
|
||||||
|
"Jl. Boulevard Raya Blok LA 6 No. 34-35, Kelapa Gading, Jakarta Utara 14240",
|
||||||
|
region: "Kelapa Gading, Jakarta",
|
||||||
|
image: "/loc1.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Jaecoo Cihampelas Bandung",
|
||||||
|
address: "Jl. Cihampelas No. 264-268, Bandung, Jawa Barat 40131",
|
||||||
|
region: "Cihampelas, Bandung",
|
||||||
|
image: "/loc2.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Jaecoo 2S Kelapa Gading",
|
||||||
|
address: "Jl. Pegangsaan Dua No.17 B, Kelapa Gading, Jakarta Utara 14250",
|
||||||
|
region: "Kelapa Gading, Jakarta",
|
||||||
|
image: "/loc3.png",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Location() {
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const perPage = 3;
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(locations.length / perPage);
|
||||||
|
const paginated = locations.slice(
|
||||||
|
(currentPage - 1) * perPage,
|
||||||
|
currentPage * perPage
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
if (currentPage > 1) setCurrentPage(currentPage - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (currentPage < totalPages) setCurrentPage(currentPage + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="max-w-7xl mx-auto py-16 px-6 md:px-12 bg-white">
|
||||||
|
<div className="flex flex-col md:flex-row mx-2 items-center justify-between mb-10">
|
||||||
|
<h2 className="text-2xl md:text-6xl font-bold text-gray-900 text-center mb-2 md:md-0">
|
||||||
|
Cari Store Lain
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-row gap-3">
|
||||||
|
<div className=" max-w-xl">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Cari lokasi..."
|
||||||
|
className="w-full py-3 pl-5 pr-20 border border-gray-300 rounded-full text-gray-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>{" "}
|
||||||
|
<button className=" bg-[#00696e] text-white px-6 py-2 rounded-full font-medium">
|
||||||
|
Cari
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{paginated.map((store, index) => (
|
||||||
|
<div key={index} className="bg-white shadow-md overflow-hidden">
|
||||||
|
<div className="relative w-full h-64">
|
||||||
|
<Image
|
||||||
|
src={store.image}
|
||||||
|
alt={store.name}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="font-semibold text-black">
|
||||||
|
{store.name}, {store.address}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 mt-1">{store.region}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-4 mt-10">
|
||||||
|
<button
|
||||||
|
onClick={handlePrev}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="p-2 rounded-full bg-gray-100 hover:bg-gray-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
{[...Array(totalPages)].map((_, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => setCurrentPage(idx + 1)}
|
||||||
|
className={`w-8 h-8 rounded-full text-sm font-medium ${
|
||||||
|
currentPage === idx + 1
|
||||||
|
? "bg-[#00696e] text-white"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{idx + 1}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="p-2 rounded-full bg-gray-100 hover:bg-gray-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
"use client";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ChevronDown, Lock, Menu, X } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [showProdukMenu, setShowProdukMenu] = useState(false);
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const isActive = (path: string) =>
|
||||||
|
pathname === path || pathname.startsWith(path + "/");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = () => {
|
||||||
|
setShowProdukMenu(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("click", handleClickOutside);
|
||||||
|
return () => window.removeEventListener("click", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const produkList = [
|
||||||
|
{
|
||||||
|
name: "JAECOO J7 AWD",
|
||||||
|
img: "/j7awd.png",
|
||||||
|
link: "/produk/jaecoo7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "JAECOO J7 SHS",
|
||||||
|
img: "/j7shs.png",
|
||||||
|
link: "/produk/jaecoo7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "JAECOO J8 AWD",
|
||||||
|
img: "/j8awd.png",
|
||||||
|
link: "/produk/jaecoo8",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="relative w-full flex items-center justify-between py-4 px-6 sm:px-10 bg-white z-50">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/" className="flex items-center space-x-2">
|
||||||
|
<Image
|
||||||
|
src="/masjaecoonav.png"
|
||||||
|
alt="MAS JAECOO Logo"
|
||||||
|
width={300}
|
||||||
|
height={30}
|
||||||
|
className=" object-fill"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="sm:hidden absolute right-6 text-[#1F3D4A]"
|
||||||
|
onClick={() => setIsMobileMenuOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{isMobileMenuOpen ? (
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
) : (
|
||||||
|
<Menu className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul className="hidden sm:flex mx-auto items-center gap-4 sm:gap-6 text-sm font-medium">
|
||||||
|
<li>
|
||||||
|
<Link href="/">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={`hover:cursor-pointer rounded-full px-5 ${
|
||||||
|
isActive("/") ? "bg-[#C2D8E2] text-[#1F3D4A]" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
BERANDA
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowProdukMenu((prev) => !prev);
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-1 font-bold text-sm focus:outline-none hover:cursor-pointer rounded-full px-5 py-2 ${
|
||||||
|
isActive("/product") || isActive("/produk")
|
||||||
|
? "bg-[#C2D8E2] text-[#1F3D4A]"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
PRODUK <ChevronDown className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showProdukMenu && (
|
||||||
|
<motion.div
|
||||||
|
key="produk-dropdown"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="absolute left-0 right-0 top-[calc(100%+1rem)] z-50 bg-white shadow-xl px-6 sm:px-10 py-6 rounded-xl w-full"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="max-w-screen-xl mx-auto w-full flex flex-col sm:flex-row items-center sm:items-start justify-between gap-y-10 sm:gap-y-0 sm:gap-x-6 text-center sm:text-left">
|
||||||
|
{produkList.map((car, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 30 }}
|
||||||
|
transition={{ delay: 0.2 + i * 0.2, duration: 0.5 }}
|
||||||
|
className="flex flex-col items-center text-center w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={car.img}
|
||||||
|
alt={car.name}
|
||||||
|
width={250}
|
||||||
|
height={150}
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
<p className="font-bold mt-4 text-center">{car.name}</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 mt-2 items-center">
|
||||||
|
<Link href="/about" className="w-[200px]">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className="bg-[#0C5B71] text-white px-4 rounded-full w-full hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
TEST DRIVE
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/product" className="w-[200px]">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full px-4 w-full hover:cursor-pointer hover:bg-amber-50"
|
||||||
|
>
|
||||||
|
JELAJAHI
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<Link href="/service">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={`hover:cursor-pointer rounded-full px-5 ${
|
||||||
|
isActive("/service") ? "bg-[#C2D8E2] text-[#1F3D4A]" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
SERVIS
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<Link href="/about">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={`hover:cursor-pointer rounded-full px-5 ${
|
||||||
|
isActive("/about") ? "bg-[#C2D8E2] text-[#1F3D4A]" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
TENTANG DEALER JAECOO
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<Link href="/auth">
|
||||||
|
<Button className="bg-[#1F6779]">
|
||||||
|
<Lock className="w-3 h-3 mr-1" />
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMobileMenuOpen && (
|
||||||
|
<div className="absolute top-full left-0 right-0 bg-white px-6 py-4 shadow-md flex flex-col gap-4 text-sm font-medium sm:hidden z-40">
|
||||||
|
<Link href="/" onClick={() => setIsMobileMenuOpen(false)}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={`w-full justify-start ${
|
||||||
|
isActive("/") ? "bg-[#C2D8E2] text-[#1F3D4A]" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
BERANDA
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/product" onClick={() => setIsMobileMenuOpen(false)}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={`flex items-center gap-1 font-bold text-sm focus:outline-none rounded-full px-5 py-2 ${
|
||||||
|
isActive("/product") || isActive("/produk")
|
||||||
|
? "bg-[#C2D8E2] text-[#1F3D4A]"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
PRODUK
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/service" onClick={() => setIsMobileMenuOpen(false)}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={`w-full justify-start ${
|
||||||
|
isActive("/service") ? "bg-[#C2D8E2] text-[#1F3D4A]" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
SERVIS
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/about" onClick={() => setIsMobileMenuOpen(false)}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={`w-full justify-start ${
|
||||||
|
isActive("/about") ? "bg-[#C2D8E2] text-[#1F3D4A]" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
TENTANG DEALER JAECOO
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/auth" onClick={() => setIsMobileMenuOpen(false)}>
|
||||||
|
<Button className="bg-[#1F6779] w-full justify-start">
|
||||||
|
<Lock className="w-3 h-3 mr-1" />
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
export default function NearestLocation() {
|
||||||
|
return (
|
||||||
|
<section className="max-w-[1400px] mx-auto bg-white py-16 px-4 sm:px-6 lg:px-10">
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold text-center mb-6">
|
||||||
|
Lokasi Servis Terdekat
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="w-full mb-10">
|
||||||
|
<Image
|
||||||
|
src="/map-service.png"
|
||||||
|
alt="Lokasi Servis Terdekat"
|
||||||
|
width={1200}
|
||||||
|
height={600}
|
||||||
|
className="w-full object-cover rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 pt-10">
|
||||||
|
<div className="text-start px-4 border-l border-gray-300">
|
||||||
|
<div className="text-4xl mb-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="30"
|
||||||
|
height="30"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<g fill="none">
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
// stroke-linejoin="round"
|
||||||
|
// stroke-width="2"
|
||||||
|
d="M12 13v7.098c0 .399 0 .598-.129.67c-.129.071-.298-.035-.636-.246L4.94 16.588c-.46-.288-.69-.431-.815-.658C4 15.705 4 15.434 4 14.893V8m8 5L4 8m8 5l5.286-3.304c1.218-.761 1.827-1.142 1.827-1.696s-.609-.935-1.827-1.696L13.06 3.663c-.516-.323-.773-.484-1.06-.484s-.544.161-1.06.484L4 8"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M19 12a1 1 0 1 0 2 0zm.875-3.93L19 8.553zM19 9.107V12h2V9.108zm.59-2.544l-3.06-1.912l-1.06 1.696l3.06 1.913zM21 9.109c0-.252.001-.51-.02-.733a2 2 0 0 0-.23-.79l-1.75.97c-.027-.05-.02-.073-.011.01c.01.106.011.254.011.543zm-2.47-.848c.246.154.37.233.454.298c.067.05.043.045.016-.004l1.75-.97a2 2 0 0 0-.549-.614c-.177-.136-.397-.272-.611-.405z"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="17.5"
|
||||||
|
cy="16.5"
|
||||||
|
r="2.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
// stroke-width="2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
// stroke-linecap="round"
|
||||||
|
// stroke-width="2"
|
||||||
|
d="m21 20l-1.5-1.5"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M14.53 20.598a1 1 0 0 0-1.06-1.696zM11 20.375l-.53.848zm.937.444l-.063.998zm.126 0L12 19.82zm-.533-1.292l-3-1.875l-1.06 1.696l3 1.875zm1.94-.625l-.5.313l1.06 1.695l.5-.312zm-.5.313l-.5.312l1.06 1.696l.5-.312zm-2.5 2.008c.213.133.429.27.625.368c.214.108.47.206.779.226L12 19.82c.056.003.072.022-.005-.016a7 7 0 0 1-.465-.278zm2-1.696a7 7 0 0 1-.465.278c-.077.038-.061.02-.005.016l.126 1.996c.31-.02.565-.118.779-.226c.196-.099.412-.235.625-.368zm-.596 2.29q.126.008.252 0L12 19.82z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="font-semibold mb-1"> {">"}TERSEDIA DALAM STOK</p>
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
Jelajahi pilihan hebat kami dari mobil Jaecoo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-start px-4 border-l border-r border-gray-300">
|
||||||
|
<div className="text-2xl mb-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="30"
|
||||||
|
height="30"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20zm8-7L4 8v10h16V8zm0-2l8-5H4zM4 8V6v12z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="font-semibold mb-1">{">"}BERITAHU SAYA</p>
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
Daftar untuk semua berita terbaru dari Jaecoo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-start px-4 border-r border-gray-300">
|
||||||
|
<div className="text-2xl mb-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="30"
|
||||||
|
height="30"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
// fill-rule="evenodd"
|
||||||
|
d="M256 42.667c117.821 0 213.334 95.513 213.334 213.333c0 117.821-95.513 213.334-213.334 213.334c-117.82 0-213.333-95.513-213.333-213.334C42.667 138.18 138.18 42.667 256 42.667M85.334 256c0 87.032 65.145 158.848 149.332 169.346V316.358c-21.87-7.73-38.283-27.01-41.913-50.51L85.636 245.762q-.301 5.081-.302 10.238m341.031-10.238l-107.118 20.086c-3.629 23.5-20.043 42.78-41.913 50.51v108.988C361.523 414.848 426.668 343.032 426.668 256q-.001-5.156-.302-10.238M256 85.334c-76.056 0-140.493 49.75-162.541 118.484l107.16 20.085C211.699 204.827 232.352 192 256 192c23.65 0 44.302 12.827 55.382 31.903l107.16-20.085C396.493 135.084 332.057 85.334 256 85.334"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="font-semibold mb-1">{">"}PESAN TEST DRIVE</p>
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
Atur test drive di jalan melalui Dealer terdekat kami
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useState, Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
|
export type OptionProps = {
|
||||||
|
Icon: any;
|
||||||
|
title: string;
|
||||||
|
selected?: string;
|
||||||
|
setSelected?: Dispatch<SetStateAction<string>>;
|
||||||
|
open: boolean;
|
||||||
|
notifs?: number;
|
||||||
|
active?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Option = ({ Icon, title, selected, setSelected, open, notifs, active }: OptionProps) => {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const isActive = active ?? selected === title;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
layout
|
||||||
|
onClick={() => setSelected?.(title)}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
className={`relative flex h-12 w-full px-3 items-center rounded-xl transition-all duration-200 cursor-pointer group ${
|
||||||
|
isActive
|
||||||
|
? "bg-gradient-to-r from-emerald-500 to-green-500 text-white shadow-lg shadow-emerald-500/25"
|
||||||
|
: "text-slate-600 hover:bg-gradient-to-r hover:from-slate-100 hover:to-slate-200/50 hover:text-slate-800"
|
||||||
|
}`}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
{/* Active indicator */}
|
||||||
|
{isActive && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="activeIndicator"
|
||||||
|
className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-white rounded-r-full shadow-sm"
|
||||||
|
initial={{ opacity: 0, scaleY: 0 }}
|
||||||
|
animate={{ opacity: 1, scaleY: 1 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
className={`h-full flex items-center justify-center ${
|
||||||
|
open ? "w-12" : "w-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`text-lg transition-all duration-200 ${
|
||||||
|
isActive
|
||||||
|
? "text-white"
|
||||||
|
: "text-slate-500 group-hover:text-slate-700"
|
||||||
|
}`}>
|
||||||
|
<Icon />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<motion.span
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.1, duration: 0.2 }}
|
||||||
|
className={`text-sm font-medium transition-colors duration-200 ${
|
||||||
|
isActive ? "text-white" : "text-slate-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tooltip for collapsed state */}
|
||||||
|
{!open && hovered && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 8, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, x: 8, scale: 0.8 }}
|
||||||
|
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
||||||
|
className="absolute left-full ml-3 whitespace-nowrap rounded-lg bg-slate-800 px-3 py-2 text-sm text-white shadow-xl z-50"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
{title}
|
||||||
|
{/* Tooltip arrow */}
|
||||||
|
<div className="absolute -left-1 top-1/2 -translate-y-1/2 w-2 h-2 bg-slate-800 rotate-45"></div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notification badge */}
|
||||||
|
{notifs && open && (
|
||||||
|
<motion.span
|
||||||
|
initial={{ scale: 0, opacity: 0 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.3, type: "spring" }}
|
||||||
|
className={`absolute right-3 top-1/2 -translate-y-1/2 size-5 rounded-full text-xs font-semibold flex items-center justify-center ${
|
||||||
|
isActive
|
||||||
|
? "bg-white text-emerald-500"
|
||||||
|
: "bg-red-500 text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{notifs}
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hover effect overlay */}
|
||||||
|
{hovered && !isActive && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="hoverOverlay"
|
||||||
|
className="absolute inset-0 bg-gradient-to-r from-slate-100/50 to-slate-200/50 rounded-xl"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Option;
|
||||||
|
|
@ -0,0 +1,605 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { Dispatch, SetStateAction, useState, useEffect } from "react";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import DashboardContainer from "../main/dashboard/dashboard-container";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { useTheme } from "../layout/theme-context";
|
||||||
|
import Option from "./option";
|
||||||
|
|
||||||
|
interface RetractingSidebarProps {
|
||||||
|
sidebarData: boolean;
|
||||||
|
updateSidebarData: (newData: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarSections = [
|
||||||
|
{
|
||||||
|
title: "Dashboard",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Dashboard",
|
||||||
|
icon: () => (
|
||||||
|
<Icon icon="material-symbols:dashboard" className="text-lg" />
|
||||||
|
),
|
||||||
|
link: "/admin/dashboard",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Content Management",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Articles",
|
||||||
|
icon: () => <Icon icon="ri:article-line" className="text-lg" />,
|
||||||
|
link: "/admin/article",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Categories",
|
||||||
|
icon: () => <Icon icon="famicons:list-outline" className="text-lg" />,
|
||||||
|
link: "/admin/master-category",
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// title: "Majalah",
|
||||||
|
// icon: () => <Icon icon="emojione-monotone:newspaper" className="text-lg" />,
|
||||||
|
// link: "/admin/magazine",
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
title: "Advertisements",
|
||||||
|
icon: () => <Icon icon="ic:round-ads-click" className="text-lg" />,
|
||||||
|
link: "/admin/advertise",
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// title: "Komentar",
|
||||||
|
// icon: () => <Icon icon="material-symbols:comment-outline-rounded" className="text-lg" />,
|
||||||
|
// link: "/admin/komentar",
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "System",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Static Pages",
|
||||||
|
icon: () => <Icon icon="fluent-mdl2:page-solid" className="text-lg" />,
|
||||||
|
link: "/admin/static-page",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "User Management",
|
||||||
|
icon: () => <Icon icon="ph:users-three-fill" className="text-lg" />,
|
||||||
|
link: "/admin/master-user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const RetractingSidebar = ({
|
||||||
|
sidebarData,
|
||||||
|
updateSidebarData,
|
||||||
|
}: RetractingSidebarProps) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* DESKTOP SIDEBAR */}
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.nav
|
||||||
|
key="desktop-sidebar"
|
||||||
|
layout
|
||||||
|
className="hidden md:flex sticky top-0 h-screen shrink-0 bg-gradient-to-b from-slate-50 to-white dark:from-slate-800 dark:to-slate-900 border-r border-slate-200/60 dark:border-slate-700/60 shadow-lg backdrop-blur-sm flex-col justify-between"
|
||||||
|
style={{
|
||||||
|
width: sidebarData ? "280px" : "80px",
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<SidebarContent
|
||||||
|
open={sidebarData}
|
||||||
|
pathname={pathname}
|
||||||
|
updateSidebarData={updateSidebarData}
|
||||||
|
/>
|
||||||
|
</motion.nav>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Desktop Toggle Button - appears when sidebar is collapsed */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{!sidebarData && (
|
||||||
|
<motion.button
|
||||||
|
key="desktop-toggle"
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
className="hidden md:flex fixed top-4 left-20 z-40 p-3 bg-white rounded-xl shadow-lg border border-slate-200/60 hover:shadow-xl transition-all duration-200 hover:bg-slate-50"
|
||||||
|
onClick={() => updateSidebarData(true)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="heroicons:chevron-right"
|
||||||
|
className="w-5 h-5 text-slate-600"
|
||||||
|
/>
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Mobile Toggle Button */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{!sidebarData && (
|
||||||
|
<motion.button
|
||||||
|
key="mobile-toggle"
|
||||||
|
initial={{ opacity: 0, scale: 0 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0 }}
|
||||||
|
className="md:hidden fixed top-4 left-4 z-50 p-3 bg-white dark:bg-slate-800 rounded-xl shadow-lg border border-slate-200/60 dark:border-slate-700/60 hover:shadow-xl transition-all duration-200"
|
||||||
|
onClick={() => updateSidebarData(true)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="heroicons:chevron-right"
|
||||||
|
className="w-6 h-6 text-slate-600"
|
||||||
|
/>
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* MOBILE SIDEBAR */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{sidebarData && (
|
||||||
|
<motion.div
|
||||||
|
key="mobile-sidebar"
|
||||||
|
initial={{ x: "-100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "-100%" }}
|
||||||
|
transition={{ type: "tween", duration: 0.3 }}
|
||||||
|
className="fixed top-0 left-0 z-50 w-[280px] h-full bg-gradient-to-b from-slate-50 to-white dark:from-slate-800 dark:to-slate-900 p-4 flex flex-col md:hidden shadow-2xl backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
{/* <button onClick={() => updateSidebarData(false)} className="mb-4 self-end text-zinc-500">
|
||||||
|
✕
|
||||||
|
</button> */}
|
||||||
|
<SidebarContent
|
||||||
|
open={true}
|
||||||
|
pathname={pathname}
|
||||||
|
updateSidebarData={updateSidebarData}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SidebarContent = ({
|
||||||
|
open,
|
||||||
|
pathname,
|
||||||
|
updateSidebarData,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
pathname: string;
|
||||||
|
updateSidebarData: (newData: boolean) => void;
|
||||||
|
}) => {
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* SCROLLABLE TOP SECTION */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{/* HEADER SECTION */}
|
||||||
|
<div className="flex flex-col space-y-6">
|
||||||
|
{/* Logo and Toggle */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-6">
|
||||||
|
<Link href="/" className="flex items-center space-x-3">
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src="/masjaecoo.png"
|
||||||
|
className="w-28 h-10 bg-black p-1 dark:bg-transparent"
|
||||||
|
/>
|
||||||
|
<div className="absolute opacity-20"></div>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
className="flex flex-col"
|
||||||
|
>
|
||||||
|
<span className="text-lg font-bold bg-gradient-to-r from-slate-800 to-slate-600 bg-clip-text text-black dark:text-white">
|
||||||
|
Jaecoo
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">Admin Panel</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
className="p-2 rounded-lg hover:bg-slate-100 transition-colors duration-200 group"
|
||||||
|
onClick={() => updateSidebarData(false)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="heroicons:chevron-left"
|
||||||
|
className="w-5 h-5 text-slate-500 group-hover:text-slate-700 transition-colors"
|
||||||
|
/>
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Sections */}
|
||||||
|
<div className="space-y-3 px-3 pb-6">
|
||||||
|
{sidebarSections.map((section, sectionIndex) => (
|
||||||
|
<motion.div
|
||||||
|
key={section.title}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 + sectionIndex * 0.1 }}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
{open && (
|
||||||
|
<motion.h3
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.2 + sectionIndex * 0.1 }}
|
||||||
|
className="text-xs font-semibold text-slate-500 uppercase tracking-wider px-3"
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</motion.h3>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{section.items.map((item, itemIndex) => (
|
||||||
|
<Link href={item.link} key={item.title}>
|
||||||
|
<Option
|
||||||
|
Icon={item.icon}
|
||||||
|
title={item.title}
|
||||||
|
active={pathname === item.link}
|
||||||
|
open={open}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FIXED BOTTOM SECTION */}
|
||||||
|
<div className="flex-shrink-0 space-y-1 border-t border-slate-200/60 dark:border-slate-700/60 bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm">
|
||||||
|
{/* Divider */}
|
||||||
|
{/* <div className="px-3 pb-2">
|
||||||
|
<div className="h-px bg-gradient-to-r from-transparent via-slate-300 to-transparent"></div>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
{/* Theme Toggle */}
|
||||||
|
<div className="px-3 pt-1">
|
||||||
|
<motion.button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className={`relative flex h-12 w-full items-center rounded-xl transition-all duration-200 cursor-pointer group ${
|
||||||
|
open ? "px-3" : "justify-center"
|
||||||
|
} ${
|
||||||
|
theme === "dark"
|
||||||
|
? "bg-gradient-to-r from-emerald-500 to-green-500 text-white shadow-lg shadow-emerald-500/25"
|
||||||
|
: "text-slate-600 hover:bg-gradient-to-r hover:from-slate-100 hover:to-slate-200/50 hover:text-slate-800 dark:text-slate-300 dark:hover:bg-slate-700/50"
|
||||||
|
}`}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className={`h-full flex items-center justify-center ${
|
||||||
|
open ? "w-12" : "w-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`text-lg transition-all duration-200 ${
|
||||||
|
theme === "dark"
|
||||||
|
? "text-white"
|
||||||
|
: "text-slate-500 group-hover:text-slate-700 dark:text-slate-400 dark:group-hover:text-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{theme === "dark" ? (
|
||||||
|
<Icon icon="solar:sun-bold" className="text-lg" />
|
||||||
|
) : (
|
||||||
|
<Icon icon="solar:moon-bold" className="text-lg" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<motion.span
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.1, duration: 0.2 }}
|
||||||
|
className={`text-sm font-medium transition-colors duration-200 ${
|
||||||
|
theme === "dark"
|
||||||
|
? "text-white"
|
||||||
|
: "text-slate-700 dark:text-slate-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{theme === "dark" ? "Light Mode" : "Dark Mode"}
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings */}
|
||||||
|
<div className="px-3">
|
||||||
|
<Link href="/settings">
|
||||||
|
<Option
|
||||||
|
Icon={() => (
|
||||||
|
<Icon icon="lets-icons:setting-fill" className="text-lg" />
|
||||||
|
)}
|
||||||
|
title="Settings"
|
||||||
|
active={pathname === "/settings"}
|
||||||
|
open={open}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Profile */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.4 }}
|
||||||
|
className="px-3 py-3 border-t border-slate-200/60"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${
|
||||||
|
open
|
||||||
|
? "flex items-center space-x-3"
|
||||||
|
: "flex items-center justify-center"
|
||||||
|
} p-3 rounded-xl bg-gradient-to-r from-slate-50 to-slate-100/50 hover:from-slate-100 hover:to-slate-200/50 transition-all duration-200 cursor-pointer group`}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-blue-500 to-purple-500 flex items-center justify-center text-white font-semibold text-sm shadow-lg">
|
||||||
|
A
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-white"></div>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
className="flex-1 min-w-0"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-slate-800 truncate">
|
||||||
|
admin-mabes
|
||||||
|
</p>
|
||||||
|
<Link href="/auth">
|
||||||
|
<p className="text-xs text-slate-500 hover:text-blue-600 transition-colors duration-200">
|
||||||
|
Sign out
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Expand Button for Collapsed State */}
|
||||||
|
{/* {!open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.6 }}
|
||||||
|
className="px-3 pt-2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => updateSidebarData(true)}
|
||||||
|
className="w-full p-3 rounded-xl bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 text-white shadow-lg transition-all duration-200 hover:shadow-xl group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<Icon
|
||||||
|
icon="heroicons:chevron-right"
|
||||||
|
className="w-5 h-5 group-hover:scale-110 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)} */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Sidebar = () => {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.nav
|
||||||
|
layout
|
||||||
|
className="sticky top-0 h-screen shrink-0 border-r border-slate-300 bg-white p-1 hidden md:flex flex-col justify-between"
|
||||||
|
style={{
|
||||||
|
width: open ? "120px" : "90px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* BAGIAN ATAS */}
|
||||||
|
<div>
|
||||||
|
{!open && (
|
||||||
|
<div className="w-full flex justify-center items-center">
|
||||||
|
<button
|
||||||
|
className="w-5 h-5 text-zinc-400 border border-zinc-400 rounded-full flex justify-center items-center"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
d="m10 17l5-5m0 0l-5-5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex ${
|
||||||
|
open ? "justify-between" : "justify-center"
|
||||||
|
} w-full items-center px-2`}
|
||||||
|
>
|
||||||
|
<Link href="/" className="flex flex-row items-center gap-3 font-bold">
|
||||||
|
<img src="/assets/icon/Logo.png" className="w-20" />
|
||||||
|
</Link>
|
||||||
|
{open && (
|
||||||
|
<button
|
||||||
|
className="w-5 h-5 text-zinc-400 border border-zinc-400 rounded-full flex justify-center items-center"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
d="m14 7l-5 5m0 0l5 5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{sidebarSections.map((section) => (
|
||||||
|
<div key={section.title}>
|
||||||
|
<p className="font-bold text-[14px] py-2">{section.title}</p>
|
||||||
|
{section.items.map((item) => (
|
||||||
|
<Link href={item.link} key={item.title}>
|
||||||
|
<Option
|
||||||
|
Icon={item.icon}
|
||||||
|
title={item.title}
|
||||||
|
active={pathname === item.link}
|
||||||
|
open={open}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BAGIAN BAWAH */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Option
|
||||||
|
Icon={() => <Icon icon="solar:moon-bold" className="text-lg" />}
|
||||||
|
title="Theme"
|
||||||
|
active={false}
|
||||||
|
open={open}
|
||||||
|
/>
|
||||||
|
<Link href="/settings">
|
||||||
|
<Option
|
||||||
|
Icon={() => (
|
||||||
|
<Icon icon="lets-icons:setting-fill" className="text-lg" />
|
||||||
|
)}
|
||||||
|
title="Settings"
|
||||||
|
active={pathname === "/settings"}
|
||||||
|
open={open}
|
||||||
|
/>
|
||||||
|
</Link>{" "}
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="34"
|
||||||
|
height="34"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<g fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<circle cx="12" cy="6" r="4" />
|
||||||
|
<path d="M20 17.5c0 2.485 0 4.5-8 4.5s-8-2.015-8-4.5S7.582 13 12 13s8 2.015 8 4.5Z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<div className="flex flex-col gap-0.5 text-xs">
|
||||||
|
<p>admin-mabes</p>
|
||||||
|
<p className="underline">Logout</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
|
|
||||||
|
const TitleSection = ({ open }: { open: boolean }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex cursor-pointer items-center justify-between rounded-md transition-colors hover:bg-slate-100">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 12, scale: 0.5 }}
|
||||||
|
animate={
|
||||||
|
open
|
||||||
|
? { opacity: 1, y: 0, scale: 1 }
|
||||||
|
: { opacity: 1, y: 0, scale: 0.5 }
|
||||||
|
}
|
||||||
|
transition={{ delay: 0.125 }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/assets/icon/Logo.png"
|
||||||
|
alt="logo"
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
className="w-full h-fit"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
{/* {open && <FiChevronDown className="mr-2" />} */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ToggleClose = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<motion.button layout onClick={() => setOpen((pv) => !pv)}>
|
||||||
|
<div className="flex justify-center items-center pt-2">
|
||||||
|
<motion.div layout className="grid size-10 text-lg">
|
||||||
|
{/* <FiChevronsRight
|
||||||
|
className={`transition-transform ${open && "rotate-180"}`}
|
||||||
|
/> */}
|
||||||
|
</motion.div>
|
||||||
|
{/* {open && (
|
||||||
|
<motion.span layout initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.125 }} className="text-xs font-medium">
|
||||||
|
Hide
|
||||||
|
</motion.span>
|
||||||
|
)} */}
|
||||||
|
</div>
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ExampleContent = () => (
|
||||||
|
<div>
|
||||||
|
<DashboardContainer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
"use client";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { easeOut, motion } from "framer-motion";
|
||||||
|
|
||||||
|
const services = [
|
||||||
|
{ image: "/s1.png", title: "ELECTRONIC VEHICLE HEALTH CHECK" },
|
||||||
|
{ image: "/s2.png", title: "REQUEST A SERVICE" },
|
||||||
|
{ image: "/s3.png", title: "SERVICE PLANS" },
|
||||||
|
{ image: "/s4.png", title: "BODY AND PAINT" },
|
||||||
|
{ image: "/s5.png", title: "GENUINE PARTS" },
|
||||||
|
{ image: "/s6.png", title: "JAECOO REPAIRS" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: {},
|
||||||
|
visible: {
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.15,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { opacity: 0, y: 50 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.6, ease: easeOut },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Service() {
|
||||||
|
return (
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-10 bg-white">
|
||||||
|
<div className="w-full mx-auto text-start">
|
||||||
|
<motion.h2
|
||||||
|
className="text-2xl sm:text-3xl font-bold mb-2"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
>
|
||||||
|
Performa Hebat, Layanan Terjamin
|
||||||
|
</motion.h2>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
className="text-gray-700 mb-10 w-full md:w-6/12"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut", delay: 0.1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
>
|
||||||
|
Servis resmi Jaecoo untuk kendaraan Anda dikerjakan oleh teknisi
|
||||||
|
tersertifikasi dengan suku cadang asli dan sistem booking online.
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 justify-items-center"
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, amount: 0.2 }}
|
||||||
|
>
|
||||||
|
{services.map((service, index) => (
|
||||||
|
<motion.div key={index} variants={itemVariants} className="w-full">
|
||||||
|
<Image
|
||||||
|
src={service.image}
|
||||||
|
alt={service.title}
|
||||||
|
width={413}
|
||||||
|
height={170}
|
||||||
|
className="w-full object-contain"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
export default function Video() {
|
||||||
|
return (
|
||||||
|
<section className="pt-10 bg-white">
|
||||||
|
<div className="relative w-full h-[500px] overflow-hidden">
|
||||||
|
<iframe
|
||||||
|
className="w-full h-full"
|
||||||
|
src="https://www.youtube.com/embed/qEfjAK4gVhU?autoplay=1&mute=1&loop=1&playlist=qEfjAK4gVhU"
|
||||||
|
title="YouTube video player"
|
||||||
|
frameBorder="0"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import React, { ReactNode } from "react";
|
||||||
|
import { SidebarProvider } from "./sidebar-context";
|
||||||
|
import { ThemeProvider } from "./theme-context";
|
||||||
|
import { Breadcrumbs } from "./breadcrumbs";
|
||||||
|
import { BurgerButtonIcon } from "../icons";
|
||||||
|
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { RetractingSidebar } from "../landing-page/retracting-sidedar";
|
||||||
|
|
||||||
|
export const AdminLayout = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
|
const [hasMounted, setHasMounted] = useState(false);
|
||||||
|
|
||||||
|
const updateSidebarData = (newData: boolean) => {
|
||||||
|
setIsOpen(newData);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
useEffect(() => {
|
||||||
|
setHasMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Render loading state until mounted
|
||||||
|
if (!hasMounted) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<SidebarProvider>
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 dark:from-slate-500 dark:via-slate-800 dark:to-slate-900">
|
||||||
|
<div className="flex h-screen overflow-hidden">
|
||||||
|
<RetractingSidebar
|
||||||
|
sidebarData={isOpen}
|
||||||
|
updateSidebarData={updateSidebarData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key="main-content"
|
||||||
|
className="flex-1 flex flex-col overflow-hidden"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<motion.header
|
||||||
|
className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-b border-slate-200/60 dark:border-slate-700/60 shadow-sm"
|
||||||
|
initial={{ y: -20, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ delay: 0.2, duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<button
|
||||||
|
className="md:hidden p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors duration-200"
|
||||||
|
onClick={() => updateSidebarData(true)}
|
||||||
|
>
|
||||||
|
<BurgerButtonIcon />
|
||||||
|
</button>
|
||||||
|
<Breadcrumbs />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<motion.main
|
||||||
|
className="flex-1 overflow-auto"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3, duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<div className="h-full">{children}</div>
|
||||||
|
</motion.main>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
ArticleIcon,
|
||||||
|
DashboardIcon,
|
||||||
|
MagazineIcon,
|
||||||
|
MasterCategoryIcon,
|
||||||
|
MasterRoleIcon,
|
||||||
|
MasterUsersIcon,
|
||||||
|
StaticPageIcon,
|
||||||
|
} from "../icons/sidebar-icon";
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "../ui/breadcrumb";
|
||||||
|
import React from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
export const Breadcrumbs = () => {
|
||||||
|
const [currentPage, setCurrentPage] = useState<React.Key>("");
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const pathnameSplit = pathname.split("/");
|
||||||
|
|
||||||
|
pathnameSplit.shift();
|
||||||
|
const pathnameTransformed = pathnameSplit.map((item) => {
|
||||||
|
const words = item.split("-");
|
||||||
|
const capitalizedWords = words.map(
|
||||||
|
(word) => word.charAt(0).toUpperCase() + word.slice(1)
|
||||||
|
);
|
||||||
|
return capitalizedWords.join(" ");
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(pathnameSplit[pathnameSplit.length - 1]);
|
||||||
|
}, [pathnameSplit]);
|
||||||
|
|
||||||
|
const handleAction = (key: any) => {
|
||||||
|
const keyIndex = pathnameSplit.indexOf(key);
|
||||||
|
const combinedPath = pathnameSplit.slice(0, keyIndex + 1).join("/");
|
||||||
|
router.push("/" + combinedPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPageIcon = () => {
|
||||||
|
if (pathname.includes("dashboard")) return <DashboardIcon size={40} />;
|
||||||
|
if (pathname.includes("article")) return <ArticleIcon size={40} />;
|
||||||
|
if (pathname.includes("master-category"))
|
||||||
|
return <MasterCategoryIcon size={40} />;
|
||||||
|
if (pathname.includes("magazine")) return <MagazineIcon size={40} />;
|
||||||
|
if (pathname.includes("static-page")) return <StaticPageIcon size={40} />;
|
||||||
|
if (pathname.includes("master-user")) return <MasterUsersIcon size={40} />;
|
||||||
|
if (pathname.includes("master-role")) return <MasterRoleIcon size={40} />;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-6">
|
||||||
|
<div className="w-10 h-10 bg-slate-200 rounded-lg animate-pulse"></div>
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<div className="h-8 w-32 bg-slate-200 rounded animate-pulse"></div>
|
||||||
|
<div className="h-4 w-48 bg-slate-200 rounded animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="flex items-center space-x-6"
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
{/* Page Icon */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0, rotate: -180 }}
|
||||||
|
animate={{ scale: 1, rotate: 0 }}
|
||||||
|
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
{getPageIcon()}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Page Title and Breadcrumbs */}
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<motion.h1
|
||||||
|
className="text-2xl font-bold bg-gradient-to-r from-slate-800 to-slate-600 bg-clip-text text-black dark:text-white"
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3, duration: 0.3 }}
|
||||||
|
>
|
||||||
|
{pathnameTransformed[pathnameTransformed.length - 1]}
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.4, duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList className="flex items-center space-x-2">
|
||||||
|
{pathnameTransformed
|
||||||
|
?.filter((item) => item !== "Admin")
|
||||||
|
.map((item, index, array) => (
|
||||||
|
<React.Fragment key={pathnameSplit[index]}>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink
|
||||||
|
onClick={() => handleAction(pathnameSplit[index])}
|
||||||
|
className={`text-sm transition-all duration-200 hover:text-blue-600 ${
|
||||||
|
pathnameSplit[index] === currentPage
|
||||||
|
? "font-semibold text-blue-600"
|
||||||
|
: "text-slate-500 hover:text-slate-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
{index < array.length - 1 && (
|
||||||
|
<BreadcrumbSeparator className="text-slate-400">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</BreadcrumbSeparator>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChunkErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
// Check if it's a chunk loading error
|
||||||
|
if (error.name === 'ChunkLoadError' || error.message.includes('Loading chunk')) {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
return { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Chunk loading error:', error, errorInfo);
|
||||||
|
|
||||||
|
// If it's a chunk loading error, try to reload the page
|
||||||
|
if (error.name === 'ChunkLoadError' || error.message.includes('Loading chunk')) {
|
||||||
|
this.setState({ hasError: true, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRetry = () => {
|
||||||
|
// Clear the error state and reload the page
|
||||||
|
this.setState({ hasError: false, error: undefined });
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
||||||
|
<div className="text-center p-8 max-w-md">
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<RefreshCw className="w-8 h-8 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
|
Chunk Loading Error
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
There was an issue loading a part of the application. This usually happens when the application has been updated.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button
|
||||||
|
onClick={this.handleRetry}
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Reload Application
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.href = '/'}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Go to Homepage
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||||
|
<details className="mt-6 text-left">
|
||||||
|
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
|
||||||
|
Error Details (Development)
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto">
|
||||||
|
{this.state.error.message}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChunkErrorBoundary;
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface CircularProgressProps {
|
||||||
|
size?: number;
|
||||||
|
strokeWidth?: number;
|
||||||
|
value: number; // 0 to 100
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CircularProgress({ size = 48, strokeWidth = 4, value, className }: CircularProgressProps) {
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const offset = circumference - (value / 100) * circumference;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} className={className} viewBox={`0 0 ${size} ${size}`}>
|
||||||
|
<circle className="text-gray-200" stroke="currentColor" strokeWidth={strokeWidth} fill="transparent" r={radius} cx={size / 2} cy={size / 2} />
|
||||||
|
<circle
|
||||||
|
className="text-primary"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="transparent"
|
||||||
|
r={radius}
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
style={{ transition: "stroke-dashoffset 0.35s" }}
|
||||||
|
/>
|
||||||
|
<text x="50%" y="50%" dy=".3em" textAnchor="middle" className="text-xs fill-primary font-medium">
|
||||||
|
{Math.round(value)}%
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type CircularProgressProps = {
|
||||||
|
value: number; // antara 0 - 100
|
||||||
|
size?: number; // diameter lingkaran (px)
|
||||||
|
strokeWidth?: number;
|
||||||
|
color?: string;
|
||||||
|
bgColor?: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomCircularProgress = ({
|
||||||
|
value,
|
||||||
|
size = 80,
|
||||||
|
strokeWidth = 8,
|
||||||
|
color = "#f59e0b", // shadcn's warning color
|
||||||
|
bgColor = "#e5e7eb", // gray-200
|
||||||
|
label,
|
||||||
|
}: CircularProgressProps) => {
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const progress = Math.min(Math.max(value, 0), 100); // jaga antara 0 - 100
|
||||||
|
const offset = circumference - (progress / 100) * circumference;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center justify-center" style={{ width: size, height: size }}>
|
||||||
|
<svg width={size} height={size}>
|
||||||
|
<circle
|
||||||
|
stroke={bgColor}
|
||||||
|
fill="transparent"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
r={radius}
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
stroke={color}
|
||||||
|
fill="transparent"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
r={radius}
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
style={{ transition: "stroke-dashoffset 0.35s" }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="absolute text-sm font-semibold text-gray-800 dark:text-white">
|
||||||
|
{label ?? `${Math.round(progress)}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
"use client";
|
||||||
|
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function CustomPagination(props: { totalPage: number; onPageChange: (data: number) => void }) {
|
||||||
|
const { totalPage, onPageChange } = props;
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onPageChange(page);
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
const renderPageNumbers = () => {
|
||||||
|
const pageNumbers = [];
|
||||||
|
const halfWindow = Math.floor(5 / 2);
|
||||||
|
let startPage = Math.max(2, page - halfWindow);
|
||||||
|
let endPage = Math.min(totalPage - 1, page + halfWindow);
|
||||||
|
|
||||||
|
if (endPage - startPage + 1 < 5) {
|
||||||
|
if (page <= halfWindow) {
|
||||||
|
endPage = Math.min(totalPage, endPage + (5 - (endPage - startPage + 1)));
|
||||||
|
} else if (page + halfWindow >= totalPage) {
|
||||||
|
startPage = Math.max(1, startPage - (5 - (endPage - startPage + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
pageNumbers.push(
|
||||||
|
<PaginationItem key={i} onClick={() => setPage(i)}>
|
||||||
|
<PaginationLink className="cursor-pointer" isActive={page === i}>
|
||||||
|
{i}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageNumbers;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink className="cursor-pointer" onClick={() => (page > 10 ? setPage(page - 10) : "")}>
|
||||||
|
{/* <DoubleArrowLeftIcon /> */}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious className="cursor-pointer" onClick={() => (page > 1 ? setPage(page - 1) : "")} />
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink className="cursor-pointer" onClick={() => setPage(1)} isActive={page === 1}>
|
||||||
|
{1}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
{page > 4 && (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationEllipsis className="cursor-pointer" onClick={() => setPage(page - 1)} />
|
||||||
|
</PaginationItem>
|
||||||
|
)}
|
||||||
|
{renderPageNumbers()}
|
||||||
|
{page < totalPage - 3 && (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationEllipsis className="cursor-pointer" onClick={() => setPage(page + 1)} />
|
||||||
|
</PaginationItem>
|
||||||
|
)}
|
||||||
|
{totalPage > 1 && (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink className="cursor-pointer" onClick={() => setPage(totalPage)} isActive={page === totalPage}>
|
||||||
|
{totalPage}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext className="cursor-pointer" onClick={() => (page < totalPage ? setPage(page + 1) : "")} />
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink onClick={() => (page < totalPage - 10 ? setPage(page + 10) : "")}>{/* <DoubleArrowRightIcon /> */}</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
'use client'
|
||||||
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface SidebarContextType {
|
||||||
|
isOpen: boolean;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const storedValue = localStorage.getItem('sidebarOpen');
|
||||||
|
return storedValue ? JSON.parse(storedValue) : false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('sidebarOpen', JSON.stringify(isOpen));
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
setIsOpen(window.innerWidth > 768); // Ganti 768 dengan lebar yang sesuai dengan breakpoint Anda
|
||||||
|
};
|
||||||
|
|
||||||
|
handleResize(); // Pastikan untuk memanggil fungsi handleResize saat komponen dimuat
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider value={{ isOpen, toggleSidebar }}>
|
||||||
|
{children}
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSidebar = () => {
|
||||||
|
const context = useContext(SidebarContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSidebar must be used within a SidebarProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSidebarContext = () => {
|
||||||
|
return useContext(SidebarContext);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
theme: Theme;
|
||||||
|
toggleTheme: () => void;
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [theme, setThemeState] = useState<Theme>('light');
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
// Get theme from localStorage or default to 'light'
|
||||||
|
const savedTheme = localStorage.getItem('theme') as Theme;
|
||||||
|
if (savedTheme) {
|
||||||
|
setThemeState(savedTheme);
|
||||||
|
} else {
|
||||||
|
// Check system preference
|
||||||
|
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
setThemeState(systemTheme);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Update document class and localStorage
|
||||||
|
document.documentElement.classList.remove('light', 'dark');
|
||||||
|
document.documentElement.classList.add(theme);
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
}, [theme, mounted]);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setThemeState(prev => prev === 'light' ? 'dark' : 'light');
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTheme = (newTheme: Theme) => {
|
||||||
|
setThemeState(newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prevent hydration mismatch
|
||||||
|
if (!mounted) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTheme = () => {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useTheme must be used within a ThemeProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
"use client";
|
||||||
|
import { getStatisticMonthly } from "@/service/article";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { SafeReactApexChart } from "@/utils/dynamic-import";
|
||||||
|
|
||||||
|
type WeekData = {
|
||||||
|
week: number;
|
||||||
|
days: number[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RemainingDays = {
|
||||||
|
days: number[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function processMonthlyData(count: number[]): {
|
||||||
|
weeks: WeekData[];
|
||||||
|
remaining_days: RemainingDays;
|
||||||
|
} {
|
||||||
|
const weeks: WeekData[] = [];
|
||||||
|
let weekIndex = 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < count.length; i += 7) {
|
||||||
|
const weekData = count.slice(i, i + 7);
|
||||||
|
weeks.push({
|
||||||
|
week: weekIndex,
|
||||||
|
days: weekData,
|
||||||
|
total: weekData.reduce((sum, day) => sum + day, 0),
|
||||||
|
});
|
||||||
|
weekIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingDays: RemainingDays = {
|
||||||
|
days: count.length % 7 === 0 ? [] : count.slice(-count.length % 7),
|
||||||
|
total: count.slice(-count.length % 7).reduce((sum, day) => sum + day, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
weeks,
|
||||||
|
remaining_days: remainingDays,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApexChartColumn = (props: {
|
||||||
|
type: string;
|
||||||
|
date: string;
|
||||||
|
view: string[];
|
||||||
|
}) => {
|
||||||
|
const { date, type, view } = props;
|
||||||
|
const [categories, setCategories] = useState<string[]>([]);
|
||||||
|
const [series, setSeries] = useState<{ name: string; data: number[] }[]>([]);
|
||||||
|
const [seriesComment, setSeriesComment] = useState<number[]>([]);
|
||||||
|
const [seriesView, setSeriesView] = useState<number[]>([]);
|
||||||
|
const [seriesShare, setSeriesShare] = useState<number[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initFetch();
|
||||||
|
}, [date, type, view]);
|
||||||
|
|
||||||
|
const initFetch = async () => {
|
||||||
|
const splitDate = date.split(" ");
|
||||||
|
|
||||||
|
const res = await getStatisticMonthly(splitDate[1]);
|
||||||
|
const data = res?.data?.data;
|
||||||
|
const getDatas = data?.find(
|
||||||
|
(a: any) =>
|
||||||
|
a.month == Number(splitDate[0]) && a.year === Number(splitDate[1])
|
||||||
|
);
|
||||||
|
if (getDatas) {
|
||||||
|
const temp1 = processMonthlyData(getDatas?.comment);
|
||||||
|
const temp2 = processMonthlyData(getDatas?.view);
|
||||||
|
const temp3 = processMonthlyData(getDatas?.share);
|
||||||
|
|
||||||
|
if (type == "weekly") {
|
||||||
|
setSeriesComment(
|
||||||
|
temp1.weeks.map((list) => {
|
||||||
|
return list.total;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setSeriesView(
|
||||||
|
temp2.weeks.map((list) => {
|
||||||
|
return list.total;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setSeriesShare(
|
||||||
|
temp3.weeks.map((list) => {
|
||||||
|
return list.total;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSeriesComment(getDatas.comment);
|
||||||
|
setSeriesView(getDatas.view);
|
||||||
|
setSeriesShare(getDatas.share);
|
||||||
|
}
|
||||||
|
if (type === "weekly") {
|
||||||
|
const category = [];
|
||||||
|
for (let i = 1; i <= temp1.weeks.length; i++) {
|
||||||
|
category.push(`Week ${i}`);
|
||||||
|
}
|
||||||
|
setCategories(category);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSeriesComment([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const temp = [
|
||||||
|
{
|
||||||
|
name: "Comment",
|
||||||
|
data: view.includes("comment") ? seriesComment : [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "View",
|
||||||
|
data: view.includes("view") ? seriesView : [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Share",
|
||||||
|
data: view.includes("share") ? seriesShare : [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log("temp", temp);
|
||||||
|
|
||||||
|
setSeries(temp);
|
||||||
|
}, [view, seriesShare, seriesView, seriesComment]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full">
|
||||||
|
<div id="chart" className="h-full">
|
||||||
|
<SafeReactApexChart
|
||||||
|
options={{
|
||||||
|
chart: {
|
||||||
|
height: "100%",
|
||||||
|
type: "area",
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
curve: "smooth",
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: type == "weekly" ? categories : [],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
series={series}
|
||||||
|
type="area"
|
||||||
|
height="100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div id="html-dist"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApexChartColumn;
|
||||||
|
|
@ -0,0 +1,392 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DashboardCommentIcon,
|
||||||
|
DashboardConnectIcon,
|
||||||
|
DashboardShareIcon,
|
||||||
|
DashboardSpeecIcon,
|
||||||
|
DashboardUserIcon,
|
||||||
|
} from "@/components/icons/dashboard-icon";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Article } from "@/types/globals";
|
||||||
|
import {
|
||||||
|
getListArticle,
|
||||||
|
getStatisticSummary,
|
||||||
|
getTopArticles,
|
||||||
|
getUserLevelDataStat,
|
||||||
|
} from "@/service/article";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { convertDateFormat, convertDateFormatNoTime } from "@/utils/global";
|
||||||
|
import "react-datepicker/dist/react-datepicker.css";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import ApexChartColumn from "@/components/main/dashboard/chart/column-chart";
|
||||||
|
import CustomPagination from "@/components/layout/custom-pagination";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
type ArticleData = Article & {
|
||||||
|
no: number;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TopPages {
|
||||||
|
id: number;
|
||||||
|
no: number;
|
||||||
|
title: string;
|
||||||
|
viewCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PostCount {
|
||||||
|
userLevelId: number;
|
||||||
|
no: number;
|
||||||
|
userLevelName: string;
|
||||||
|
totalArticle: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardContainer() {
|
||||||
|
const username = Cookies.get("username");
|
||||||
|
const fullname = Cookies.get("ufne");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPage, setTotalPage] = useState(1);
|
||||||
|
const [topPagesTotalPage, setTopPagesTotalPage] = useState(1);
|
||||||
|
const [article, setArticle] = useState<ArticleData[]>([]);
|
||||||
|
// const [analyticsView, setAnalyticView] = useState<string[]>(["comment", "view", "share"]);
|
||||||
|
// const [startDateValue, setStartDateValue] = useState(parseDate(convertDateFormatNoTimeV2(new Date())));
|
||||||
|
// const [postContentDate, setPostContentDate] = useState({
|
||||||
|
// startDate: parseDate(convertDateFormatNoTimeV2(new Date(new Date().setDate(new Date().getDate() - 7)))),
|
||||||
|
// endDate: parseDate(convertDateFormatNoTimeV2(new Date())),
|
||||||
|
// });
|
||||||
|
|
||||||
|
const [startDateValue, setStartDateValue] = useState(new Date());
|
||||||
|
const [analyticsView, setAnalyticView] = useState<string[]>([]);
|
||||||
|
const options = [
|
||||||
|
{ label: "Comment", value: "comment" },
|
||||||
|
{ label: "View", value: "view" },
|
||||||
|
{ label: "Share", value: "share" },
|
||||||
|
];
|
||||||
|
const handleChange = (value: string, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setAnalyticView([...analyticsView, value]);
|
||||||
|
} else {
|
||||||
|
setAnalyticView(analyticsView.filter((v) => v !== value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const [postContentDate, setPostContentDate] = useState({
|
||||||
|
startDate: new Date(new Date().setDate(new Date().getDate() - 7)),
|
||||||
|
endDate: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [typeDate, setTypeDate] = useState("monthly");
|
||||||
|
const [summary, setSummary] = useState<any>();
|
||||||
|
|
||||||
|
const [topPages, setTopPages] = useState<TopPages[]>([]);
|
||||||
|
const [postCount, setPostCount] = useState<PostCount[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSummary();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initState();
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
async function initState() {
|
||||||
|
const req = {
|
||||||
|
limit: "4",
|
||||||
|
page: page,
|
||||||
|
search: "",
|
||||||
|
};
|
||||||
|
const res = await getListArticle(req);
|
||||||
|
setArticle(res.data?.data);
|
||||||
|
setTotalPage(res?.data?.meta?.totalPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSummary() {
|
||||||
|
const res = await getStatisticSummary();
|
||||||
|
setSummary(res?.data?.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTopPages();
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
async function fetchTopPages() {
|
||||||
|
const req = {
|
||||||
|
limit: "10",
|
||||||
|
page: page,
|
||||||
|
search: "",
|
||||||
|
};
|
||||||
|
const res = await getTopArticles(req);
|
||||||
|
setTopPages(getTableNumber(10, res.data?.data));
|
||||||
|
setTopPagesTotalPage(res?.data?.meta?.totalPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPostCount();
|
||||||
|
}, [postContentDate]);
|
||||||
|
async function fetchPostCount() {
|
||||||
|
const getDate = (data: any) => {
|
||||||
|
return `${data.year}-${data.month < 10 ? `0${data.month}` : data.month}-${
|
||||||
|
data.day < 10 ? `0${data.day}` : data.day
|
||||||
|
}`;
|
||||||
|
};
|
||||||
|
const res = await getUserLevelDataStat(
|
||||||
|
getDate(postContentDate.startDate),
|
||||||
|
getDate(postContentDate.endDate)
|
||||||
|
);
|
||||||
|
setPostCount(getTableNumber(10, res?.data?.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTableNumber = (limit: number, data: any) => {
|
||||||
|
if (data) {
|
||||||
|
const startIndex = limit * (page - 1);
|
||||||
|
let iterate = 0;
|
||||||
|
const newData = data.map((value: any) => {
|
||||||
|
iterate++;
|
||||||
|
value.no = startIndex + iterate;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
return newData;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMonthYear = (date: any) => {
|
||||||
|
return date.month + " " + date.year;
|
||||||
|
};
|
||||||
|
const getMonthYearName = (date: any) => {
|
||||||
|
const newDate = new Date(date);
|
||||||
|
|
||||||
|
const months = [
|
||||||
|
"Januari",
|
||||||
|
"Februari",
|
||||||
|
"Maret",
|
||||||
|
"April",
|
||||||
|
"Mei",
|
||||||
|
"Juni",
|
||||||
|
"Juli",
|
||||||
|
"Agustus",
|
||||||
|
"September",
|
||||||
|
"Oktober",
|
||||||
|
"November",
|
||||||
|
"Desember",
|
||||||
|
];
|
||||||
|
const year = newDate.getFullYear();
|
||||||
|
|
||||||
|
const month = months[newDate.getMonth()];
|
||||||
|
return month + " " + year;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-6">
|
||||||
|
{/* User Profile Card */}
|
||||||
|
<motion.div
|
||||||
|
className="col-span-1 md:col-span-2 bg-white rounded-2xl shadow-lg border border-slate-200/60 p-6 hover:shadow-xl transition-all duration-300"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-xl font-bold text-slate-800">{fullname}</h3>
|
||||||
|
<p className="text-slate-600">{username}</p>
|
||||||
|
<div className="flex space-x-6 pt-2">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-blue-600">
|
||||||
|
{summary?.totalToday}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-500">Today</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-purple-600">
|
||||||
|
{summary?.totalThisWeek}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-500">This Week</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gradient-to-br from-blue-50 to-purple-50 rounded-xl">
|
||||||
|
<DashboardUserIcon size={60} className="text-black" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Total Posts */}
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-2xl shadow-lg border border-slate-200/60 p-6 hover:shadow-xl transition-all duration-300"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center text-center space-y-3">
|
||||||
|
<div className="p-3 bg-gradient-to-br from-green-50 to-emerald-50 rounded-xl">
|
||||||
|
<DashboardSpeecIcon className="text-black" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-3xl font-bold text-slate-800">
|
||||||
|
{summary?.totalAll}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-500">Total Posts</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Total Views */}
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-2xl shadow-lg border border-slate-200/60 p-6 hover:shadow-xl transition-all duration-300"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center text-center space-y-3">
|
||||||
|
<div className="p-3 bg-gradient-to-br from-blue-50 to-cyan-50 rounded-xl">
|
||||||
|
<DashboardConnectIcon className="text-black" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-3xl font-bold text-slate-800">
|
||||||
|
{summary?.totalViews}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-500">Total Views</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Total Shares */}
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-2xl shadow-lg border border-slate-200/60 p-6 hover:shadow-xl transition-all duration-300"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.4 }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center text-center space-y-3">
|
||||||
|
<div className="p-3 bg-gradient-to-br from-purple-50 to-pink-50 rounded-xl">
|
||||||
|
<DashboardShareIcon className="text-black" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-3xl font-bold text-slate-800">
|
||||||
|
{summary?.totalShares}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-500">Total Shares</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Total Comments */}
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-2xl shadow-lg border border-slate-200/60 p-6 hover:shadow-xl transition-all duration-300"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center text-center space-y-3">
|
||||||
|
<div className="p-3 bg-gradient-to-br from-orange-50 to-red-50 rounded-xl">
|
||||||
|
<DashboardCommentIcon size={40} className="text-black" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-3xl font-bold text-slate-800">
|
||||||
|
{summary?.totalComments}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-500">Total Comments</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Section */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{/* Analytics Chart */}
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-2xl shadow-lg border border-slate-200/60 p-6"
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.6 }}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-800">
|
||||||
|
Analytics Overview
|
||||||
|
</h3>
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
{options.map((option) => (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={analyticsView.includes(option.value)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleChange(option.value, checked as boolean)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-600">{option.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-80">
|
||||||
|
<ApexChartColumn
|
||||||
|
type="monthly"
|
||||||
|
date={`${new Date().getMonth() + 1} ${new Date().getFullYear()}`}
|
||||||
|
view={analyticsView}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Recent Articles */}
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-2xl shadow-lg border border-slate-200/60 p-6"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.7 }}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-800">
|
||||||
|
Recent Articles
|
||||||
|
</h3>
|
||||||
|
<Link href="/admin/article/create">
|
||||||
|
<Button className="bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 text-white shadow-lg">
|
||||||
|
Create Article
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 max-h-96 overflow-y-auto scrollbar-thin">
|
||||||
|
{article?.map((list: any) => (
|
||||||
|
<motion.div
|
||||||
|
key={list?.id}
|
||||||
|
className="flex space-x-4 p-4 rounded-xl hover:bg-slate-50 transition-colors duration-200"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
transition={{ type: "spring", stiffness: 300 }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
alt="thumbnail"
|
||||||
|
src={list?.thumbnailUrl || `/no-image.jpg`}
|
||||||
|
width={80}
|
||||||
|
height={80}
|
||||||
|
className="h-20 w-20 object-cover rounded-lg shadow-sm flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="font-medium text-slate-800 line-clamp-2 mb-1">
|
||||||
|
{list?.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{convertDateFormat(list?.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-center">
|
||||||
|
<CustomPagination
|
||||||
|
totalPage={totalPage}
|
||||||
|
onPageChange={(data) => setPage(data)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import * as z from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { close, error, loading } from "@/config/swal";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
import withReactContent from "sweetalert2-react-content";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { createCustomStaticPage } from "@/service/static-page-service";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const CustomEditor = dynamic(
|
||||||
|
() => {
|
||||||
|
return import("@/components/editor/custom-editor");
|
||||||
|
},
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
slug: z.string().min(2, {
|
||||||
|
message: "Slug must be at least 2 characters.",
|
||||||
|
}),
|
||||||
|
title: z.string().min(2, {
|
||||||
|
message: "Title must be at least 2 characters.",
|
||||||
|
}),
|
||||||
|
description: z.string().min(2, {
|
||||||
|
message: "Main Keyword must be at least 2 characters.",
|
||||||
|
}),
|
||||||
|
htmlBody: z.string().min(2, {
|
||||||
|
message: "Main Keyword must be at least 2 characters.",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function StaticPageBuilder() {
|
||||||
|
const MySwal = withReactContent(Swal);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const formOptions = {
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
};
|
||||||
|
type UserSettingSchema = z.infer<typeof formSchema>;
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
watch,
|
||||||
|
} = useForm<UserSettingSchema>(formOptions);
|
||||||
|
|
||||||
|
const content = watch("htmlBody");
|
||||||
|
const generatedPage = useCallback(() => {
|
||||||
|
const sanitizedContent = DOMPurify.sanitize(content);
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.innerHTML = sanitizedContent;
|
||||||
|
return (
|
||||||
|
<Card className="rounded-md border p-4">
|
||||||
|
{/* <div dangerouslySetInnerHTML={{ __html: sanitizedContent }} /> */}
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: textArea.value }} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||||
|
const request = {
|
||||||
|
title: values.title,
|
||||||
|
slug: values.slug,
|
||||||
|
description: values.description,
|
||||||
|
htmlBody: values.htmlBody,
|
||||||
|
};
|
||||||
|
loading();
|
||||||
|
const res = await createCustomStaticPage(request);
|
||||||
|
|
||||||
|
if (res?.error) {
|
||||||
|
error(res.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
|
||||||
|
successSubmit("/admin/static-page");
|
||||||
|
};
|
||||||
|
|
||||||
|
function successSubmit(redirect: any) {
|
||||||
|
MySwal.fire({
|
||||||
|
title: "Sukses",
|
||||||
|
icon: "success",
|
||||||
|
confirmButtonColor: "#3085d6",
|
||||||
|
confirmButtonText: "OK",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
router.push(redirect);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// const title = watch("title");
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (getValues("title")) {
|
||||||
|
// setValue("slug", createSlug(getValues("title")));
|
||||||
|
// }
|
||||||
|
// }, [title]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-3 px-4"
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="title"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="w-full space-y-1">
|
||||||
|
<Label htmlFor="title">Title</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
placeholder="Title"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.title?.message && (
|
||||||
|
<p className="text-red-400 text-sm">{errors.title?.message}</p>
|
||||||
|
)}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="slug"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="w-full space-y-1">
|
||||||
|
<Label htmlFor="slug">Slug</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="slug"
|
||||||
|
placeholder="Slug"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="description"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="w-full space-y-1">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
placeholder="Description"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.description?.message && (
|
||||||
|
<p className="text-red-400 text-sm">{errors.description?.message}</p>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
Editor
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="htmlBody"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
// <Textarea
|
||||||
|
// variant="bordered"
|
||||||
|
// label=""
|
||||||
|
// labelPlacement="outside"
|
||||||
|
// placeholder=""
|
||||||
|
// className="max-h-[80vh]"
|
||||||
|
// classNames={{
|
||||||
|
// mainWrapper: "h-[80vh] overflow-hidden",
|
||||||
|
// innerWrapper: "h-[80vh] overflow-hidden",
|
||||||
|
// input: "min-h-full",
|
||||||
|
// inputWrapper: "h-full",
|
||||||
|
// }}
|
||||||
|
// value={value}
|
||||||
|
// onChange={onChange}
|
||||||
|
// disableAutosize={false}
|
||||||
|
// />
|
||||||
|
<CustomEditor onChange={onChange} initialData={value} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 flex flex-col gap-2">
|
||||||
|
Preview
|
||||||
|
{generatedPage()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end w-full">
|
||||||
|
<Button type="submit" color="primary" className="w-fit">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,606 @@
|
||||||
|
"use client";
|
||||||
|
import {
|
||||||
|
CloudUploadIcon,
|
||||||
|
CreateIconIon,
|
||||||
|
DeleteIcon,
|
||||||
|
DotsYIcon,
|
||||||
|
SearchIcon,
|
||||||
|
TimesIcon,
|
||||||
|
} from "@/components/icons";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { close, error, loading, success } from "@/config/swal";
|
||||||
|
import { Article } from "@/types/globals";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Fragment, Key, useCallback, useEffect, useState } from "react";
|
||||||
|
import * as z from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
import withReactContent from "sweetalert2-react-content";
|
||||||
|
import { useDropzone } from "react-dropzone";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import useDisclosure from "@/components/useDisclosure";
|
||||||
|
import {
|
||||||
|
createMediaFileAdvertise,
|
||||||
|
deleteAdvertise,
|
||||||
|
editAdvertise,
|
||||||
|
editAdvertiseIsActive,
|
||||||
|
getAdvertise,
|
||||||
|
getAdvertiseById,
|
||||||
|
} from "@/service/advertisement";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableHead,
|
||||||
|
TableCell,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import CustomPagination from "@/components/layout/custom-pagination";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ name: "No", uid: "no" },
|
||||||
|
{ name: "Judul", uid: "title" },
|
||||||
|
{ name: "Deskripsi", uid: "description" },
|
||||||
|
{ name: "Penempatan", uid: "placement" },
|
||||||
|
{ name: "Link", uid: "redirectLink" },
|
||||||
|
{ name: "Aktif", uid: "isActive" },
|
||||||
|
{ name: "Aksi", uid: "actions" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const createArticleSchema = z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
|
title: z.string().min(2, {
|
||||||
|
message: "Judul harus diisi",
|
||||||
|
}),
|
||||||
|
url: z.string().min(1, {
|
||||||
|
message: "Url harus diisi",
|
||||||
|
}),
|
||||||
|
description: z.string().min(2, {
|
||||||
|
message: "Deskripsi harus diisi",
|
||||||
|
}),
|
||||||
|
file: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function AdvertiseTable(props: { triggerRefresh: boolean }) {
|
||||||
|
const MySwal = withReactContent(Swal);
|
||||||
|
const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure();
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPage, setTotalPage] = useState(1);
|
||||||
|
const [article, setArticle] = useState<any[]>([]);
|
||||||
|
const [showData, setShowData] = useState("10");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const [placement, setPlacement] = useState("banner");
|
||||||
|
const [refresh, setRefresh] = useState(false);
|
||||||
|
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
|
||||||
|
const formOptions = {
|
||||||
|
resolver: zodResolver(createArticleSchema),
|
||||||
|
defaultValues: { title: "", description: "", url: "", file: "" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps } = useDropzone({
|
||||||
|
onDrop: (acceptedFiles) => {
|
||||||
|
setFiles(acceptedFiles.map((file) => Object.assign(file)));
|
||||||
|
},
|
||||||
|
maxFiles: 1,
|
||||||
|
accept: {
|
||||||
|
"image/*": [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
type UserSettingSchema = z.infer<typeof createArticleSchema>;
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<UserSettingSchema>(formOptions);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initState();
|
||||||
|
}, [page, showData, props.triggerRefresh, refresh]);
|
||||||
|
|
||||||
|
const handleRemoveFile = (file: File) => {
|
||||||
|
const uploadedFiles = files;
|
||||||
|
const filtered = uploadedFiles.filter((i) => i.name !== file.name);
|
||||||
|
setFiles([...filtered]);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function initState() {
|
||||||
|
const req = {
|
||||||
|
limit: showData,
|
||||||
|
page: page,
|
||||||
|
search: search,
|
||||||
|
|
||||||
|
sort: "desc",
|
||||||
|
sortBy: "created_at",
|
||||||
|
};
|
||||||
|
const res = await getAdvertise(req);
|
||||||
|
getTableNumber(parseInt(showData), res.data?.data);
|
||||||
|
setTotalPage(res?.data?.meta?.totalPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTableNumber = (limit: number, data: Article[]) => {
|
||||||
|
if (data) {
|
||||||
|
const startIndex = limit * (page - 1);
|
||||||
|
let iterate = 0;
|
||||||
|
const newData = data.map((value: any) => {
|
||||||
|
iterate++;
|
||||||
|
value.no = startIndex + iterate;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
setArticle(newData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function doDelete(id: any) {
|
||||||
|
loading();
|
||||||
|
const resDelete = await deleteAdvertise(id);
|
||||||
|
|
||||||
|
if (resDelete?.error) {
|
||||||
|
error(resDelete.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
success("Berhasil Hapus");
|
||||||
|
setRefresh(!refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id: any) => {
|
||||||
|
MySwal.fire({
|
||||||
|
title: "Hapus Data",
|
||||||
|
icon: "warning",
|
||||||
|
showCancelButton: true,
|
||||||
|
cancelButtonColor: "#3085d6",
|
||||||
|
confirmButtonColor: "#d33",
|
||||||
|
confirmButtonText: "Hapus",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
doDelete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (values: z.infer<typeof createArticleSchema>) => {
|
||||||
|
loading();
|
||||||
|
const formData = {
|
||||||
|
id: Number(values.id),
|
||||||
|
title: values.title,
|
||||||
|
description: values.description,
|
||||||
|
placement: placement,
|
||||||
|
redirectLink: values.url,
|
||||||
|
};
|
||||||
|
const res = await editAdvertise(formData);
|
||||||
|
if (res?.error) {
|
||||||
|
error(res?.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
const formFiles = new FormData();
|
||||||
|
formFiles.append("file", files[0]);
|
||||||
|
const resFile = await createMediaFileAdvertise(
|
||||||
|
Number(values.id),
|
||||||
|
formFiles
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
close();
|
||||||
|
MySwal.fire({
|
||||||
|
title: "Sukses",
|
||||||
|
icon: "success",
|
||||||
|
confirmButtonColor: "#3085d6",
|
||||||
|
confirmButtonText: "OK",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
setRefresh(!refresh);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openModal = async (id: number) => {
|
||||||
|
const res = await getAdvertiseById(Number(id));
|
||||||
|
const data = res?.data?.data;
|
||||||
|
setValue("id", String(data?.id));
|
||||||
|
setValue("title", data?.title);
|
||||||
|
setValue("description", data?.description);
|
||||||
|
setValue("url", data?.redirectLink);
|
||||||
|
setPlacement(data?.placement);
|
||||||
|
// setValue("file", data?.thumbnailUrl);
|
||||||
|
|
||||||
|
onOpen();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdvertise = async (e: boolean, id: number) => {
|
||||||
|
const res = await editAdvertiseIsActive({ id, isActive: e });
|
||||||
|
if (res?.error) {
|
||||||
|
error(res?.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setRefresh(!refresh);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCell = useCallback(
|
||||||
|
(advertise: any, columnKey: Key) => {
|
||||||
|
const cellValue = advertise[columnKey as keyof any];
|
||||||
|
|
||||||
|
switch (columnKey) {
|
||||||
|
case "redirectLink":
|
||||||
|
return cellValue.includes("https") ? (
|
||||||
|
<Link
|
||||||
|
href={cellValue}
|
||||||
|
target="_blank"
|
||||||
|
className="text-primary hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
{cellValue}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<p> {cellValue}</p>
|
||||||
|
);
|
||||||
|
case "placement":
|
||||||
|
return <p className="capitalize">{cellValue}</p>;
|
||||||
|
case "isActive":
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<Switch
|
||||||
|
checked={advertise?.isPublish}
|
||||||
|
onCheckedChange={(e) => handleAdvertise(e, advertise?.id)}
|
||||||
|
/>
|
||||||
|
{advertise?.isPublish ? "Ya" : "Tidak"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "actions":
|
||||||
|
return (
|
||||||
|
<div className="relative flex justify-start items-center gap-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<DotsYIcon className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent className="lg:min-w-[150px] bg-black text-white shadow border">
|
||||||
|
{/*
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/admin/advertise/detail/${article.id}`}>
|
||||||
|
<EyeIconMdi className="inline mr-2 mb-1" />
|
||||||
|
Detail
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
*/}
|
||||||
|
|
||||||
|
<DropdownMenuItem onClick={() => openModal(advertise.id)}>
|
||||||
|
<CreateIconIon className="inline mr-2 mb-1" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem onClick={() => handleDelete(advertise.id)}>
|
||||||
|
<DeleteIcon
|
||||||
|
color="red"
|
||||||
|
size={18}
|
||||||
|
className="inline ml-1 mr-2 mb-1"
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return cellValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[article, props.triggerRefresh, refresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
let typingTimer: NodeJS.Timeout;
|
||||||
|
const doneTypingInterval = 1500;
|
||||||
|
|
||||||
|
const handleKeyUp = () => {
|
||||||
|
clearTimeout(typingTimer);
|
||||||
|
typingTimer = setTimeout(doneTyping, doneTypingInterval);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = () => {
|
||||||
|
clearTimeout(typingTimer);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function doneTyping() {
|
||||||
|
initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="py-3">
|
||||||
|
<div className="flex flex-col items-start rounded-2xl gap-3">
|
||||||
|
<div className="flex flex-col md:flex-row gap-3 w-full">
|
||||||
|
<div className="flex flex-col gap-1 w-full lg:w-1/3">
|
||||||
|
<p className="font-semibold text-sm">Pencarian</p>
|
||||||
|
<div className="relative">
|
||||||
|
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-base text-muted-foreground pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
aria-label="Search"
|
||||||
|
type="text"
|
||||||
|
className="pl-10 text-sm bg-muted"
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 w-full lg:w-[72px]">
|
||||||
|
<p className="font-semibold text-sm">Data</p>
|
||||||
|
<Select
|
||||||
|
value={showData}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
value === "" ? "" : setShowData(value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full border">
|
||||||
|
<SelectValue placeholder="Select" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="5">5</SelectItem>
|
||||||
|
<SelectItem value="10">10</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Table className="rounded-3xl min-h-[50px] bg-white dark:bg-black border text-black dark:text-white">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableHead
|
||||||
|
key={column.uid}
|
||||||
|
className="bg-white dark:bg-black text-black dark:text-white border-b text-md"
|
||||||
|
>
|
||||||
|
{column.name}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{article.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="text-center py-4"
|
||||||
|
>
|
||||||
|
No data to display.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
article.map((item: any) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableCell key={column.uid}>
|
||||||
|
{renderCell(item, column.uid)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<div className="my-2 w-full flex justify-center">
|
||||||
|
{/* <Pagination
|
||||||
|
isCompact
|
||||||
|
showControls
|
||||||
|
showShadow
|
||||||
|
color="primary"
|
||||||
|
classNames={{
|
||||||
|
base: "bg-transparent",
|
||||||
|
wrapper: "bg-transparent",
|
||||||
|
}}
|
||||||
|
page={page}
|
||||||
|
total={totalPage}
|
||||||
|
onChange={(page) => setPage(page)}
|
||||||
|
/> */}
|
||||||
|
<CustomPagination
|
||||||
|
totalPage={totalPage}
|
||||||
|
onPageChange={(data) => setPage(data)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-5xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Advertise</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm">Judul</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="title"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors?.title && (
|
||||||
|
<p className="text-red-400 text-sm">{errors.title?.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm">Deskripsi</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="description"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors?.description && (
|
||||||
|
<p className="text-red-400 text-sm">
|
||||||
|
{errors.description?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm">Link</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="url"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="url"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors?.url && (
|
||||||
|
<p className="text-red-400 text-sm">{errors.url?.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm mt-3">Penempatan</p>
|
||||||
|
<RadioGroup
|
||||||
|
value={placement}
|
||||||
|
onValueChange={setPlacement}
|
||||||
|
className="flex flex-row gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="banner" id="banner" />
|
||||||
|
<label htmlFor="banner" className="text-sm">
|
||||||
|
Banner
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="jumbotron" id="jumbotron" />
|
||||||
|
<label htmlFor="jumbotron" className="text-sm">
|
||||||
|
Jumbotron
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="file"
|
||||||
|
render={({ field: { value } }) => (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm mt-3">Thumbnail</p>
|
||||||
|
|
||||||
|
{files.length < 1 && value === "" && (
|
||||||
|
<Fragment>
|
||||||
|
<div {...getRootProps({ className: "dropzone" })}>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<div className="w-full text-center border-dashed border border-gray-300 rounded-md py-[52px] flex items-center flex-col">
|
||||||
|
<CloudUploadIcon />
|
||||||
|
<h4 className="text-2xl font-medium mb-1 mt-3 text-gray-700">
|
||||||
|
Tarik file disini atau klik untuk upload.
|
||||||
|
</h4>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
( Upload file dengan format .jpg, .jpeg, atau .png.
|
||||||
|
Ukuran maksimal 100mb.)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{value !== "" && (
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
<Image
|
||||||
|
src={String(value)}
|
||||||
|
className="w-[30%]"
|
||||||
|
alt="thumbnail"
|
||||||
|
width={480}
|
||||||
|
height={480}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setValue("file", "")}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<TimesIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(files[0])}
|
||||||
|
className="w-[30%]"
|
||||||
|
alt="thumbnail"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveFile(files[0])}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<TimesIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4 flex justify-end gap-2">
|
||||||
|
<Button type="submit">Simpan</Button>
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose}>
|
||||||
|
Tutup
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,446 @@
|
||||||
|
"use client";
|
||||||
|
import {
|
||||||
|
BannerIcon,
|
||||||
|
CopyIcon,
|
||||||
|
CreateIconIon,
|
||||||
|
DeleteIcon,
|
||||||
|
DotsYIcon,
|
||||||
|
EyeIconMdi,
|
||||||
|
SearchIcon,
|
||||||
|
} from "@/components/icons";
|
||||||
|
import { close, error, loading, success, successToast } from "@/config/swal";
|
||||||
|
import { Article } from "@/types/globals";
|
||||||
|
import { convertDateFormat } from "@/utils/global";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Key, useCallback, useEffect, useState } from "react";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
import withReactContent from "sweetalert2-react-content";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import {
|
||||||
|
deleteArticle,
|
||||||
|
getArticleByCategory,
|
||||||
|
getArticlePagination,
|
||||||
|
updateIsBannerArticle,
|
||||||
|
} from "@/service/article";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "../ui/dropdown-menu";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableHead,
|
||||||
|
TableCell,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import CustomPagination from "../layout/custom-pagination";
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ name: "No", uid: "no" },
|
||||||
|
{ name: "Judul", uid: "title" },
|
||||||
|
{ name: "Banner", uid: "isBanner" },
|
||||||
|
{ name: "Kategori", uid: "category" },
|
||||||
|
{ name: "Tanggal Unggah", uid: "createdAt" },
|
||||||
|
{ name: "Kreator", uid: "createdByName" },
|
||||||
|
{ name: "Status", uid: "isPublish" },
|
||||||
|
{ name: "Aksi", uid: "actions" },
|
||||||
|
];
|
||||||
|
const columnsOtherRole = [
|
||||||
|
{ name: "No", uid: "no" },
|
||||||
|
{ name: "Judul", uid: "title" },
|
||||||
|
{ name: "Kategori", uid: "category" },
|
||||||
|
{ name: "Tanggal Unggah", uid: "createdAt" },
|
||||||
|
{ name: "Kreator", uid: "createdByName" },
|
||||||
|
{ name: "Status", uid: "isPublish" },
|
||||||
|
{ name: "Aksi", uid: "actions" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// interface Category {
|
||||||
|
// id: number;
|
||||||
|
// title: string;
|
||||||
|
// }
|
||||||
|
|
||||||
|
export default function ArticleTable() {
|
||||||
|
const MySwal = withReactContent(Swal);
|
||||||
|
const username = Cookies.get("username");
|
||||||
|
const userId = Cookies.get("uie");
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPage, setTotalPage] = useState(1);
|
||||||
|
const [article, setArticle] = useState<any[]>([]);
|
||||||
|
const [showData, setShowData] = useState("10");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [categories, setCategories] = useState<any>([]);
|
||||||
|
const [selectedCategories, setSelectedCategories] = useState<any>("");
|
||||||
|
const [startDateValue, setStartDateValue] = useState({
|
||||||
|
startDate: null,
|
||||||
|
endDate: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initState();
|
||||||
|
getCategories();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function getCategories() {
|
||||||
|
const res = await getArticleByCategory();
|
||||||
|
const data = res?.data?.data;
|
||||||
|
setCategories(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initState = useCallback(async () => {
|
||||||
|
loading();
|
||||||
|
const req = {
|
||||||
|
limit: showData,
|
||||||
|
page: page,
|
||||||
|
search: search,
|
||||||
|
// startDate:
|
||||||
|
// startDateValue.startDate === null ? "" : startDateValue.startDate,
|
||||||
|
// endDate: startDateValue.endDate === null ? "" : startDateValue.endDate,
|
||||||
|
categorySlug: Array.from(selectedCategories).join(","),
|
||||||
|
sort: "desc",
|
||||||
|
sortBy: "created_at",
|
||||||
|
};
|
||||||
|
const res = await getArticlePagination(req);
|
||||||
|
await getTableNumber(parseInt(showData), res.data?.data);
|
||||||
|
setTotalPage(res?.data?.meta?.totalPage);
|
||||||
|
close();
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
const getTableNumber = async (limit: number, data: Article[]) => {
|
||||||
|
if (data) {
|
||||||
|
const startIndex = limit * (page - 1);
|
||||||
|
let iterate = 0;
|
||||||
|
const newData = data.map((value: any) => {
|
||||||
|
iterate++;
|
||||||
|
value.no = startIndex + iterate;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
setArticle(newData);
|
||||||
|
} else {
|
||||||
|
setArticle([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function doDelete(id: any) {
|
||||||
|
// loading();
|
||||||
|
const resDelete = await deleteArticle(id);
|
||||||
|
|
||||||
|
if (resDelete?.error) {
|
||||||
|
error(resDelete.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
success("Berhasil Hapus");
|
||||||
|
initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id: any) => {
|
||||||
|
MySwal.fire({
|
||||||
|
title: "Hapus Data",
|
||||||
|
icon: "warning",
|
||||||
|
showCancelButton: true,
|
||||||
|
cancelButtonColor: "#3085d6",
|
||||||
|
confirmButtonColor: "#d33",
|
||||||
|
confirmButtonText: "Hapus",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
doDelete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBanner = async (id: number, status: boolean) => {
|
||||||
|
const res = await updateIsBannerArticle(id, status);
|
||||||
|
if (res?.error) {
|
||||||
|
error(res?.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
initState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyUrlArticle = async (id: number, slug: string) => {
|
||||||
|
const url =
|
||||||
|
`${window.location.protocol}//${window.location.host}` +
|
||||||
|
"/news/detail/" +
|
||||||
|
`${id}-${slug}`;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
successToast("Success", "Article Copy to Clipboard");
|
||||||
|
setTimeout(() => {}, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
("Failed to copy!");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCell = useCallback(
|
||||||
|
(article: any, columnKey: Key) => {
|
||||||
|
const cellValue = article[columnKey as keyof any];
|
||||||
|
|
||||||
|
switch (columnKey) {
|
||||||
|
case "isPublish":
|
||||||
|
return (
|
||||||
|
// <Chip
|
||||||
|
// className="capitalize "
|
||||||
|
// color={statusColorMap[article.status]}
|
||||||
|
// size="lg"
|
||||||
|
// variant="flat"
|
||||||
|
// >
|
||||||
|
// <div className="flex flex-row items-center gap-2 justify-center">
|
||||||
|
// {article.status}
|
||||||
|
// </div>
|
||||||
|
// </Chip>
|
||||||
|
<p>{article.isPublish ? "Publish" : "Draft"}</p>
|
||||||
|
);
|
||||||
|
case "isBanner":
|
||||||
|
return <p>{article.isBanner ? "Ya" : "Tidak"}</p>;
|
||||||
|
case "createdAt":
|
||||||
|
return <p>{convertDateFormat(article.createdAt)}</p>;
|
||||||
|
case "category":
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
{article?.categories?.map((list: any) => list.title).join(", ") +
|
||||||
|
" "}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "actions":
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center gap-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<DotsYIcon className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-56">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => copyUrlArticle(article.id, article.slug)}
|
||||||
|
>
|
||||||
|
<CopyIcon className="mr-2 h-4 w-4" />
|
||||||
|
Copy Url Article
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href={`/admin/article/detail/${article.id}`}
|
||||||
|
className="flex items-center"
|
||||||
|
>
|
||||||
|
<EyeIconMdi className="mr-2 h-4 w-4" />
|
||||||
|
Detail
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{(username === "admin-mabes" ||
|
||||||
|
Number(userId) === article.createdById) && (
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href={`/admin/article/edit/${article.id}`}
|
||||||
|
className="flex items-center"
|
||||||
|
>
|
||||||
|
<CreateIconIon className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{username === "admin-mabes" && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
handleBanner(article.id, !article.isBanner)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BannerIcon className="mr-2 h-4 w-4" />
|
||||||
|
{article.isBanner
|
||||||
|
? "Hapus dari Banner"
|
||||||
|
: "Jadikan Banner"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(username === "admin-mabes" ||
|
||||||
|
Number(userId) === article.createdById) && (
|
||||||
|
<DropdownMenuItem onClick={() => handleDelete(article.id)}>
|
||||||
|
<DeleteIcon className="mr-2 h-4 w-4 text-red-500" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return cellValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[article, page]
|
||||||
|
);
|
||||||
|
|
||||||
|
let typingTimer: NodeJS.Timeout;
|
||||||
|
const doneTypingInterval = 1500;
|
||||||
|
|
||||||
|
const handleKeyUp = () => {
|
||||||
|
clearTimeout(typingTimer);
|
||||||
|
typingTimer = setTimeout(doneTyping, doneTypingInterval);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = () => {
|
||||||
|
clearTimeout(typingTimer);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function doneTyping() {
|
||||||
|
setPage(1);
|
||||||
|
initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="py-3">
|
||||||
|
<div className="flex flex-col items-start rounded-2xl gap-3">
|
||||||
|
<div className="flex flex-col md:flex-row gap-3 w-full">
|
||||||
|
<div className="flex flex-col gap-1 w-full lg:w-1/3">
|
||||||
|
<p className="font-semibold text-sm">Pencarian</p>
|
||||||
|
<div className="relative">
|
||||||
|
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground h-4 w-4 pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Cari..."
|
||||||
|
className="pl-9 text-sm bg-muted"
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 w-full lg:w-[72px]">
|
||||||
|
<p className="font-semibold text-sm">Data</p>
|
||||||
|
<Select
|
||||||
|
value={showData}
|
||||||
|
onValueChange={(value) => setShowData(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full text-sm border">
|
||||||
|
<SelectValue placeholder="Select" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="5">5</SelectItem>
|
||||||
|
<SelectItem value="10">10</SelectItem>
|
||||||
|
<SelectItem value="25">25</SelectItem>
|
||||||
|
<SelectItem value="50">50</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 w-full lg:w-[230px]">
|
||||||
|
<p className="font-semibold text-sm">Kategori</p>
|
||||||
|
<Select
|
||||||
|
value={selectedCategories}
|
||||||
|
onValueChange={(value) => setSelectedCategories(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full text-sm border">
|
||||||
|
<SelectValue placeholder="Kategori" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categories
|
||||||
|
?.filter((category: any) => category.slug != null)
|
||||||
|
.map((category: any) => (
|
||||||
|
<SelectItem key={category.slug} value={category.slug}>
|
||||||
|
{category.title}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{/* <div className="flex flex-col gap-1 w-full lg:w-[240px]">
|
||||||
|
<p className="font-semibold text-sm">Tanggal</p>
|
||||||
|
<Datepicker
|
||||||
|
value={startDateValue}
|
||||||
|
displayFormat="DD/MM/YYYY"
|
||||||
|
onChange={(e: any) => setStartDateValue(e)}
|
||||||
|
inputClassName="z-50 w-full text-sm bg-transparent border-1 border-gray-200 px-2 py-[6px] rounded-xl h-[40px] text-gray-600 dark:text-gray-300"
|
||||||
|
/>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
<div className="w-full overflow-x-hidden">
|
||||||
|
<div className="w-full mx-auto overflow-x-hidden">
|
||||||
|
<Table className="w-full table-fixed border text-sm">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
{(username === "admin-mabes"
|
||||||
|
? columns
|
||||||
|
: columnsOtherRole
|
||||||
|
).map((column) => (
|
||||||
|
<TableHead
|
||||||
|
key={column.uid}
|
||||||
|
className="truncate bg-white dark:bg-black text-black dark:text-white border-b text-md"
|
||||||
|
>
|
||||||
|
{column.name}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{article.length > 0 ? (
|
||||||
|
article.map((item: any) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
{(username === "admin-mabes"
|
||||||
|
? columns
|
||||||
|
: columnsOtherRole
|
||||||
|
).map((column) => (
|
||||||
|
<TableCell
|
||||||
|
key={column.uid}
|
||||||
|
className="truncate text-black dark:text-white max-w-[200px]"
|
||||||
|
>
|
||||||
|
{renderCell(item, column.uid)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="text-center py-4"
|
||||||
|
>
|
||||||
|
No data to display.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="my-2 w-full flex justify-center">
|
||||||
|
{/* <Pagination
|
||||||
|
isCompact
|
||||||
|
showControls
|
||||||
|
showShadow
|
||||||
|
color="primary"
|
||||||
|
classNames={{
|
||||||
|
base: "bg-transparent",
|
||||||
|
wrapper: "bg-transparent",
|
||||||
|
}}
|
||||||
|
page={page}
|
||||||
|
total={totalPage}
|
||||||
|
onChange={(page) => setPage(page)}
|
||||||
|
/> */}
|
||||||
|
<CustomPagination
|
||||||
|
totalPage={totalPage}
|
||||||
|
onPageChange={(data) => setPage(data)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,363 @@
|
||||||
|
"use client";
|
||||||
|
import {
|
||||||
|
CreateIconIon,
|
||||||
|
DeleteIcon,
|
||||||
|
DotsYIcon,
|
||||||
|
EyeIconMdi,
|
||||||
|
SearchIcon,
|
||||||
|
} from "@/components/icons";
|
||||||
|
import { close, error, loading, success } from "@/config/swal";
|
||||||
|
import { getArticleByCategory } from "@/service/article";
|
||||||
|
import { deleteMagazine, getListMagazine } from "@/service/magazine";
|
||||||
|
import { Article } from "@/types/globals";
|
||||||
|
import { convertDateFormat } from "@/utils/global";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Key, useCallback, useEffect, useState } from "react";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
import withReactContent from "sweetalert2-react-content";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableHead,
|
||||||
|
TableCell,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import CustomPagination from "@/components/layout/custom-pagination";
|
||||||
|
import { Chip, ChipProps } from "@/components/ui/chip";
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ name: "No", uid: "no" },
|
||||||
|
{ name: "Judul", uid: "title" },
|
||||||
|
// { name: "Kategori", uid: "categoryName" },
|
||||||
|
{ name: "Tanggal Unggah", uid: "createdAt" },
|
||||||
|
{ name: "Kreator", uid: "createdByName" },
|
||||||
|
{ name: "Aksi", uid: "actions" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type ArticleData = Article & {
|
||||||
|
no: number;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MagazineTable() {
|
||||||
|
const MySwal = withReactContent(Swal);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPage, setTotalPage] = useState(1);
|
||||||
|
const [article, setArticle] = useState<ArticleData[]>([]);
|
||||||
|
const [showData, setShowData] = useState("10");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [, setCategoies] = useState<any>([]);
|
||||||
|
const [startDateValue] = useState({
|
||||||
|
startDate: null,
|
||||||
|
endDate: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initState();
|
||||||
|
}, [page, showData, startDateValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getCategories();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function getCategories() {
|
||||||
|
const res = await getArticleByCategory();
|
||||||
|
const data = res?.data?.data;
|
||||||
|
console.log("datass", res?.data?.data);
|
||||||
|
setCategoies(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initState() {
|
||||||
|
const req = {
|
||||||
|
limit: showData,
|
||||||
|
page: page,
|
||||||
|
search: search,
|
||||||
|
startDate:
|
||||||
|
startDateValue.startDate === null ? "" : startDateValue.startDate,
|
||||||
|
endDate: startDateValue.endDate === null ? "" : startDateValue.endDate,
|
||||||
|
};
|
||||||
|
const res = await getListMagazine(req);
|
||||||
|
getTableNumber(parseInt(showData), res.data?.data);
|
||||||
|
console.log("res.data?.data magz", res.data);
|
||||||
|
setTotalPage(res?.data?.meta?.totalPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTableNumber = (limit: number, data: Article[]) => {
|
||||||
|
if (data) {
|
||||||
|
const startIndex = limit * (page - 1);
|
||||||
|
let iterate = 0;
|
||||||
|
const newData = data.map((value: any) => {
|
||||||
|
iterate++;
|
||||||
|
value.no = startIndex + iterate;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
console.log("daata", data);
|
||||||
|
setArticle(newData);
|
||||||
|
} else {
|
||||||
|
setArticle([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function doDelete(id: any) {
|
||||||
|
loading();
|
||||||
|
const resDelete = await deleteMagazine(id);
|
||||||
|
|
||||||
|
if (resDelete?.error) {
|
||||||
|
error(resDelete.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
success("Berhasil Hapus");
|
||||||
|
initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id: any) => {
|
||||||
|
MySwal.fire({
|
||||||
|
title: "Hapus Data",
|
||||||
|
icon: "warning",
|
||||||
|
showCancelButton: true,
|
||||||
|
cancelButtonColor: "#3085d6",
|
||||||
|
confirmButtonColor: "#d33",
|
||||||
|
confirmButtonText: "Hapus",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
doDelete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCell = useCallback((article: ArticleData, columnKey: Key) => {
|
||||||
|
const cellValue = article[columnKey as keyof ArticleData];
|
||||||
|
const statusColorMap: Record<string, ChipProps["color"]> = {
|
||||||
|
active: "primary",
|
||||||
|
cancel: "danger",
|
||||||
|
pending: "success",
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (columnKey) {
|
||||||
|
case "status":
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
className="capitalize "
|
||||||
|
color={statusColorMap[article.status]}
|
||||||
|
size="lg"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
<div className="flex flex-row items-center gap-2 justify-center">
|
||||||
|
{cellValue}
|
||||||
|
</div>
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
case "createdAt":
|
||||||
|
return <p>{convertDateFormat(article.createdAt)}</p>;
|
||||||
|
|
||||||
|
case "actions":
|
||||||
|
return (
|
||||||
|
<div className="relative flex justify-start items-center gap-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<DotsYIcon className="text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent className="bg-black text-white border shadow-lg">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href={`/admin/magazine/detail/${article.id}`}
|
||||||
|
className="flex items-center"
|
||||||
|
>
|
||||||
|
<EyeIconMdi className="inline mr-2 mb-1" />
|
||||||
|
Detail
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href={`/admin/magazine/edit/${article.id}`}
|
||||||
|
className="flex items-center"
|
||||||
|
>
|
||||||
|
<CreateIconIon className="inline mr-2 mb-1" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDelete(article.id)}
|
||||||
|
className="text-red-500"
|
||||||
|
>
|
||||||
|
<DeleteIcon
|
||||||
|
width={20}
|
||||||
|
height={16}
|
||||||
|
className="inline mr-2 mb-1"
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return cellValue;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
let typingTimer: NodeJS.Timeout;
|
||||||
|
const doneTypingInterval = 1500;
|
||||||
|
|
||||||
|
const handleKeyUp = () => {
|
||||||
|
clearTimeout(typingTimer);
|
||||||
|
typingTimer = setTimeout(doneTyping, doneTypingInterval);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = () => {
|
||||||
|
clearTimeout(typingTimer);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function doneTyping() {
|
||||||
|
initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="py-3 w-full">
|
||||||
|
<div className="flex flex-col items-start rounded-2xl gap-3">
|
||||||
|
<div className="flex flex-col md:flex-row gap-3 w-full">
|
||||||
|
<div className="flex flex-col gap-1 w-full lg:w-1/3">
|
||||||
|
<p className="font-semibold text-sm">Pencarian</p>
|
||||||
|
<div className="relative">
|
||||||
|
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-base text-muted-foreground pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search"
|
||||||
|
className="pl-10 text-sm bg-muted"
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 w-full lg:w-[72px]">
|
||||||
|
<p className="font-semibold text-sm">Data</p>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => setShowData(value)}
|
||||||
|
value={showData}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full border">
|
||||||
|
<SelectValue placeholder="Select" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="5">5</SelectItem>
|
||||||
|
<SelectItem value="10">10</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{/* <div className="flex flex-col gap-1 w-[230px]">
|
||||||
|
<p className="font-semibold text-sm">Kategori</p>
|
||||||
|
<Select
|
||||||
|
label=""
|
||||||
|
variant="bordered"
|
||||||
|
labelPlacement="outside"
|
||||||
|
placeholder="Select"
|
||||||
|
selectionMode="multiple"
|
||||||
|
selectedKeys={[selectedCategories]}
|
||||||
|
className="w-full"
|
||||||
|
classNames={{ trigger: "border-1" }}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.target.value === ""
|
||||||
|
? ""
|
||||||
|
: setSelectedCategories(e.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{categories?.map((category: any) => (
|
||||||
|
<SelectItem key={category?.id} value={category?.id}>
|
||||||
|
{category?.title}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div> */}
|
||||||
|
{/* <div className="flex flex-col gap-1 w-full lg:w-[240px]">
|
||||||
|
<p className="font-semibold text-sm">Tanggal</p>
|
||||||
|
<Datepicker
|
||||||
|
value={startDateValue}
|
||||||
|
displayFormat="DD/MM/YYYY"
|
||||||
|
onChange={(e: any) => setStartDateValue(e)}
|
||||||
|
inputClassName="z-50 w-full text-sm bg-transparent border-1 border-gray-200 px-2 py-[6px] rounded-xl h-[40px] text-gray-600 dark:text-gray-300"
|
||||||
|
/>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-3xl border overflow-hidden w-full">
|
||||||
|
<Table>
|
||||||
|
<thead className="bg-white dark:bg-black text-black dark:text-white border-b">
|
||||||
|
<tr>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableHead key={column.uid} className="text-md">
|
||||||
|
{column.name}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<TableBody>
|
||||||
|
{article.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="text-center py-4"
|
||||||
|
>
|
||||||
|
No data to display.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
article.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableCell key={column.uid}>
|
||||||
|
{renderCell(item, column.uid)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="my-2 w-full flex justify-center">
|
||||||
|
{/* <Pagination
|
||||||
|
isCompact
|
||||||
|
showControls
|
||||||
|
showShadow
|
||||||
|
color="primary"
|
||||||
|
classNames={{
|
||||||
|
base: "bg-transparent",
|
||||||
|
wrapper: "bg-transparent",
|
||||||
|
}}
|
||||||
|
page={page}
|
||||||
|
total={totalPage}
|
||||||
|
onChange={(page) => setPage(page)}
|
||||||
|
/> */}
|
||||||
|
<CustomPagination
|
||||||
|
totalPage={totalPage}
|
||||||
|
onPageChange={(data) => setPage(data)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,712 @@
|
||||||
|
"use client";
|
||||||
|
import {
|
||||||
|
CloudUploadIcon,
|
||||||
|
CreateIconIon,
|
||||||
|
DeleteIcon,
|
||||||
|
DotsYIcon,
|
||||||
|
EyeIconMdi,
|
||||||
|
SearchIcon,
|
||||||
|
} from "@/components/icons";
|
||||||
|
import { Article } from "@/types/globals";
|
||||||
|
import { convertDateFormat } from "@/utils/global";
|
||||||
|
import { Key, useCallback, useEffect, useState } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import ReactSelect from "react-select";
|
||||||
|
import makeAnimated from "react-select/animated";
|
||||||
|
import { useDropzone } from "react-dropzone";
|
||||||
|
import { close, error, loading, success } from "@/config/swal";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
import withReactContent from "sweetalert2-react-content";
|
||||||
|
import * as z from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import Image from "next/image";
|
||||||
|
import useDisclosure from "@/components/useDisclosure";
|
||||||
|
import { getArticleByCategory, getCategoryPagination } from "@/service/article";
|
||||||
|
import {
|
||||||
|
deleteCategory,
|
||||||
|
getCategoryById,
|
||||||
|
updateCategory,
|
||||||
|
uploadCategoryThumbnail,
|
||||||
|
} from "@/service/master-categories";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
TableHead,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import CustomPagination from "@/components/layout/custom-pagination";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ name: "No", uid: "no" },
|
||||||
|
{ name: "Kategori", uid: "title" },
|
||||||
|
{ name: "Deskripsi", uid: "description" },
|
||||||
|
{ name: "Tag Terkait", uid: "tags" },
|
||||||
|
{ name: "Dibuat ", uid: "createdAt" },
|
||||||
|
|
||||||
|
{ name: "Aksi", uid: "actions" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CategoryType {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
type ArticleData = Article & {
|
||||||
|
no: number;
|
||||||
|
createdAt: string;
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// const categorySchema = z.object({
|
||||||
|
// id: z.number(),
|
||||||
|
// label: z.string(),
|
||||||
|
// value: z.number(),
|
||||||
|
// });
|
||||||
|
|
||||||
|
const createArticleSchema = z.object({
|
||||||
|
id: z.string().min(1, {
|
||||||
|
message: "Id harus valid",
|
||||||
|
}),
|
||||||
|
title: z.string().min(2, {
|
||||||
|
message: "Judul harus diisi",
|
||||||
|
}),
|
||||||
|
description: z.string().min(2, {
|
||||||
|
message: "Deskripsi harus diisi",
|
||||||
|
}),
|
||||||
|
tags: z.array(z.string()),
|
||||||
|
|
||||||
|
file: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function CategoriesTable(props: { triggerRefresh: boolean }) {
|
||||||
|
const MySwal = withReactContent(Swal);
|
||||||
|
const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure();
|
||||||
|
const animatedComponents = makeAnimated();
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPage, setTotalPage] = useState(1);
|
||||||
|
const [categories, setCategories] = useState<ArticleData[]>([]);
|
||||||
|
const [showData, setShowData] = useState("10");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [listCategory, setListCategory] = useState<CategoryType[]>([]);
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [isDetail, setIsDetail] = useState(false);
|
||||||
|
const [tag, setTag] = useState("");
|
||||||
|
const formOptions = {
|
||||||
|
resolver: zodResolver(createArticleSchema),
|
||||||
|
defaultValues: { title: "", description: "", category: [], tags: [] },
|
||||||
|
};
|
||||||
|
const [selectedParent, setSelectedParent] = useState<any>();
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps } = useDropzone({
|
||||||
|
onDrop: (acceptedFiles) => {
|
||||||
|
setFiles(acceptedFiles.map((file) => Object.assign(file)));
|
||||||
|
},
|
||||||
|
maxFiles: 1,
|
||||||
|
accept: {
|
||||||
|
"image/*": [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
type UserSettingSchema = z.infer<typeof createArticleSchema>;
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
setValue,
|
||||||
|
getValues,
|
||||||
|
setError,
|
||||||
|
clearErrors,
|
||||||
|
} = useForm<UserSettingSchema>(formOptions);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initState();
|
||||||
|
}, [page, showData, props.triggerRefresh]);
|
||||||
|
|
||||||
|
async function initState() {
|
||||||
|
const req = {
|
||||||
|
limit: showData,
|
||||||
|
page: page,
|
||||||
|
search: search,
|
||||||
|
};
|
||||||
|
const res = await getCategoryPagination(req);
|
||||||
|
getTableNumber(parseInt(showData), res.data?.data);
|
||||||
|
setTotalPage(res?.data?.meta?.totalPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTableNumber = (limit: number, data: Article[]) => {
|
||||||
|
if (data) {
|
||||||
|
const startIndex = limit * (page - 1);
|
||||||
|
let iterate = 0;
|
||||||
|
const newData = data.map((value: any) => {
|
||||||
|
iterate++;
|
||||||
|
value.no = startIndex + iterate;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
setCategories(newData);
|
||||||
|
} else {
|
||||||
|
setCategories([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function doDelete(id: number) {
|
||||||
|
// loading();
|
||||||
|
const resDelete = await deleteCategory(id);
|
||||||
|
|
||||||
|
if (resDelete?.error) {
|
||||||
|
error(resDelete.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
success("Berhasil Hapus");
|
||||||
|
initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id: number) => {
|
||||||
|
MySwal.fire({
|
||||||
|
title: "Hapus Data",
|
||||||
|
icon: "warning",
|
||||||
|
showCancelButton: true,
|
||||||
|
cancelButtonColor: "#3085d6",
|
||||||
|
confirmButtonColor: "#d33",
|
||||||
|
confirmButtonText: "Hapus",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
doDelete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCategory();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchCategory = async () => {
|
||||||
|
const res = await getArticleByCategory();
|
||||||
|
if (res?.data?.data) {
|
||||||
|
setupCategory(res?.data?.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupCategory = (data: any) => {
|
||||||
|
const temp = [];
|
||||||
|
for (const element of data) {
|
||||||
|
temp.push({
|
||||||
|
id: element.id,
|
||||||
|
label: element.title,
|
||||||
|
value: element.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setListCategory(temp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openModal = async (id: number | string, detail: boolean) => {
|
||||||
|
setIsDetail(detail);
|
||||||
|
const res = await getCategoryById(Number(id));
|
||||||
|
const data = res?.data?.data;
|
||||||
|
setValue("id", String(data?.id));
|
||||||
|
setValue("title", data?.title);
|
||||||
|
setValue("description", data?.description);
|
||||||
|
setValue("tags", data?.tags);
|
||||||
|
setValue("file", data?.thumbnailUrl);
|
||||||
|
findParent(data?.parentId);
|
||||||
|
|
||||||
|
onOpen();
|
||||||
|
};
|
||||||
|
|
||||||
|
const findParent = (parent: number | undefined) => {
|
||||||
|
const finded = listCategory?.find((a: any) => a.id === parent);
|
||||||
|
if (finded) {
|
||||||
|
setSelectedParent(finded);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCell = useCallback(
|
||||||
|
(category: ArticleData, columnKey: Key) => {
|
||||||
|
const cellValue = category[columnKey as keyof ArticleData];
|
||||||
|
// const statusColorMap: Record<string, ChipProps["color"]> = {
|
||||||
|
// active: "primary",
|
||||||
|
// cancel: "danger",
|
||||||
|
// pending: "success",
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const findRelated = (parent: number | string) => {
|
||||||
|
// const filter = listCategory?.filter((a) => a.id == parent);
|
||||||
|
// return filter[0]?.label;
|
||||||
|
// };
|
||||||
|
|
||||||
|
switch (columnKey) {
|
||||||
|
case "tags":
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row gap-1">
|
||||||
|
{category.tags
|
||||||
|
? category.tags.map((value) => value).join(", ")
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "createdAt":
|
||||||
|
return <p>{convertDateFormat(category.createdAt)}</p>;
|
||||||
|
|
||||||
|
case "actions":
|
||||||
|
return (
|
||||||
|
<div className="relative flex justify-star items-center gap-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<DotsYIcon className="text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="lg:min-w-[150px] bg-black text-white shadow border">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => openModal(category.id, true)}
|
||||||
|
>
|
||||||
|
<EyeIconMdi className="inline mr-2 mb-1" />
|
||||||
|
Detail
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => openModal(category.id, false)}
|
||||||
|
>
|
||||||
|
<CreateIconIon className="inline mr-2 mb-1" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleDelete(category.id)}>
|
||||||
|
<DeleteIcon
|
||||||
|
color="red"
|
||||||
|
size={20}
|
||||||
|
className="inline mr-3 mb-1"
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return cellValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[listCategory]
|
||||||
|
);
|
||||||
|
|
||||||
|
let typingTimer: NodeJS.Timeout;
|
||||||
|
const doneTypingInterval = 1500;
|
||||||
|
|
||||||
|
const handleKeyUp = () => {
|
||||||
|
clearTimeout(typingTimer);
|
||||||
|
typingTimer = setTimeout(doneTyping, doneTypingInterval);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = () => {
|
||||||
|
clearTimeout(typingTimer);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function doneTyping() {
|
||||||
|
initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async (values: z.infer<typeof createArticleSchema>) => {
|
||||||
|
loading();
|
||||||
|
const formData = {
|
||||||
|
id: Number(values.id),
|
||||||
|
title: values.title,
|
||||||
|
statusId: 1,
|
||||||
|
parentId: selectedParent ? selectedParent.id : 0,
|
||||||
|
tags: values.tags.join(","),
|
||||||
|
description: values.description,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await updateCategory(values.id, formData);
|
||||||
|
|
||||||
|
if (response?.error) {
|
||||||
|
error(response.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (files?.length > 0) {
|
||||||
|
const formFiles = new FormData();
|
||||||
|
|
||||||
|
formFiles.append("files", files[0]);
|
||||||
|
const resFile = await uploadCategoryThumbnail(values.id, formFiles);
|
||||||
|
if (resFile?.error) {
|
||||||
|
error(resFile.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFiles([]);
|
||||||
|
close();
|
||||||
|
initState();
|
||||||
|
MySwal.fire({
|
||||||
|
title: "Sukses",
|
||||||
|
icon: "success",
|
||||||
|
confirmButtonColor: "#3085d6",
|
||||||
|
confirmButtonText: "OK",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFile = (file: File) => {
|
||||||
|
const uploadedFiles = files;
|
||||||
|
const filtered = uploadedFiles.filter((i) => i.name !== file.name);
|
||||||
|
setFiles([...filtered]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="py-3 w-full">
|
||||||
|
<div className="flex flex-col items-start rounded-2xl gap-3 w-full">
|
||||||
|
<div className="flex flex-col md:flex-row gap-3 w-full">
|
||||||
|
<div className="flex flex-col gap-1 w-full lg:w-1/3">
|
||||||
|
<p className="font-semibold text-sm">Pencarian</p>
|
||||||
|
<div className="relative">
|
||||||
|
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-base pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Cari..."
|
||||||
|
aria-label="Search"
|
||||||
|
className="pl-10 text-sm bg-muted"
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 w-full lg:w-[72px]">
|
||||||
|
<p className="font-semibold text-sm">Data</p>
|
||||||
|
<Select
|
||||||
|
value={showData}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
value === "" ? "" : setShowData(value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full border">
|
||||||
|
<SelectValue placeholder="Select" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="5">5</SelectItem>
|
||||||
|
<SelectItem value="10">10</SelectItem>
|
||||||
|
<SelectItem value="25">25</SelectItem>
|
||||||
|
<SelectItem value="50">50</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* <Table
|
||||||
|
aria-label="micro issue table"
|
||||||
|
className="rounded-3xl"
|
||||||
|
classNames={{
|
||||||
|
th: "bg-white dark:bg-black text-black dark:text-white border-b-1 text-md",
|
||||||
|
base: "bg-white dark:bg-black border",
|
||||||
|
wrapper: "min-h-[50px] bg-transpararent text-black dark:text-white ",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableHeader columns={columns}>{(column) => <TableColumn key={column.uid}>{column.name}</TableColumn>}</TableHeader>
|
||||||
|
<TableBody items={categories} emptyContent={"No data to display."} loadingContent={<Spinner label="Loading..." />}>
|
||||||
|
{(item) => <TableRow key={item.id}>{(columnKey) => <TableCell>{renderCell(item, columnKey)}</TableCell>}</TableRow>}
|
||||||
|
</TableBody>
|
||||||
|
</Table> */}
|
||||||
|
<div className="rounded-3xl border bg-white dark:bg-black text-black dark:text-white w-full">
|
||||||
|
<Table className="min-h-[50px] w-full">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableHead
|
||||||
|
key={column.uid}
|
||||||
|
className="text-md border-b bg-white dark:bg-black text-black dark:text-white"
|
||||||
|
>
|
||||||
|
{column.name}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{categories.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="text-center py-4"
|
||||||
|
>
|
||||||
|
No data to display.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
categories.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableCell key={column.uid}>
|
||||||
|
{renderCell(item, column.uid)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="my-2 w-full flex justify-center">
|
||||||
|
{/* <Pagination
|
||||||
|
isCompact
|
||||||
|
showControls
|
||||||
|
showShadow
|
||||||
|
color="primary"
|
||||||
|
classNames={{
|
||||||
|
base: "bg-transparent",
|
||||||
|
wrapper: "bg-transparent",
|
||||||
|
}}
|
||||||
|
page={page}
|
||||||
|
total={totalPage}
|
||||||
|
onChange={(page) => setPage(page)}
|
||||||
|
/> */}
|
||||||
|
<CustomPagination
|
||||||
|
totalPage={totalPage}
|
||||||
|
onPageChange={(data) => setPage(data)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Kategori Baru</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<ScrollArea className="h-[70vh] pr-4">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm">Nama Kategori</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="title"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
readOnly={isDetail}
|
||||||
|
className="rounded-lg border dark:border-gray-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors?.title && (
|
||||||
|
<p className="text-red-400 text-sm">
|
||||||
|
{errors.title?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm">Deskripsi</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="description"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
readOnly={isDetail}
|
||||||
|
className="rounded-lg border dark:border-gray-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors?.description && (
|
||||||
|
<p className="text-red-400 text-sm">
|
||||||
|
{errors.description?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm mt-3">Parent</p>
|
||||||
|
<ReactSelect
|
||||||
|
className="text-black z-50"
|
||||||
|
classNames={{
|
||||||
|
control: () =>
|
||||||
|
"rounded-lg bg-white border border-gray-200 dark:border-stone-500",
|
||||||
|
}}
|
||||||
|
classNamePrefix="select"
|
||||||
|
value={selectedParent}
|
||||||
|
isDisabled={isDetail}
|
||||||
|
onChange={setSelectedParent}
|
||||||
|
closeMenuOnSelect={false}
|
||||||
|
components={animatedComponents}
|
||||||
|
isClearable
|
||||||
|
isSearchable
|
||||||
|
isMulti={false}
|
||||||
|
placeholder="Kategori..."
|
||||||
|
name="sub-module"
|
||||||
|
options={listCategory}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm mt-3">Tag Terkait</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="tags"
|
||||||
|
render={({ field: { value } }) => (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="tags"
|
||||||
|
type="text"
|
||||||
|
value={tag}
|
||||||
|
onChange={(e) => setTag(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
if (tag.trim() !== "") {
|
||||||
|
setValue("tags", [...value, tag.trim()]);
|
||||||
|
setTag("");
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
readOnly={isDetail}
|
||||||
|
className="rounded-lg border dark:border-gray-400 h-[45px]"
|
||||||
|
/>
|
||||||
|
<div className="absolute top-2 left-3 flex gap-1">
|
||||||
|
{value?.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="bg-blue-100 text-blue-700 px-2 py-1 text-xs rounded flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const filtered = value.filter(
|
||||||
|
(tag) => tag !== item
|
||||||
|
);
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
setError("tags", {
|
||||||
|
type: "manual",
|
||||||
|
message: "Tags tidak boleh kosong",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
clearErrors("tags");
|
||||||
|
setValue(
|
||||||
|
"tags",
|
||||||
|
filtered as [string, ...string[]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDetail ? (
|
||||||
|
<img
|
||||||
|
src={getValues("file")}
|
||||||
|
className="w-[30%]"
|
||||||
|
alt="thumbnail"
|
||||||
|
width={480}
|
||||||
|
height={480}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="file"
|
||||||
|
render={({ field: { value } }) => (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm mt-3">Thumbnail</p>
|
||||||
|
{files.length < 1 && value === "" && (
|
||||||
|
<div {...getRootProps({ className: "dropzone" })}>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<div className="w-full text-center border-dashed border rounded-md py-[52px] flex items-center flex-col">
|
||||||
|
<CloudUploadIcon />
|
||||||
|
<h4 className="text-2xl font-medium mb-1 mt-3">
|
||||||
|
Tarik file disini atau klik untuk upload.
|
||||||
|
</h4>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
( Upload file .jpg, .jpeg, .png. Maks 100mb )
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{value !== "" && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<img
|
||||||
|
src={value}
|
||||||
|
className="w-[30%]"
|
||||||
|
alt="thumbnail"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setValue("file", "")}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Image
|
||||||
|
src={URL.createObjectURL(files[0])}
|
||||||
|
className="w-[30%]"
|
||||||
|
alt="thumbnail"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveFile(files[0])}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 mt-4">
|
||||||
|
{!isDetail && <Button type="submit">Simpan</Button>}
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Tutup
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
"use client";
|
||||||
|
import { CreateIconIon, DeleteIcon, DotsYIcon } from "@/components/icons";
|
||||||
|
import { close, error } from "@/config/swal";
|
||||||
|
import { deleteMasterUser, listMasterUsers } from "@/service/master-user";
|
||||||
|
import { MasterUser } from "@/types/globals";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Key, useCallback, useEffect, useState } from "react";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
import withReactContent from "sweetalert2-react-content";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
TableHead,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import CustomPagination from "../layout/custom-pagination";
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ name: "No", uid: "no" },
|
||||||
|
{ name: "Username", uid: "username" },
|
||||||
|
{ name: "Fullname", uid: "fullname" },
|
||||||
|
{ name: "Email", uid: "email" },
|
||||||
|
{ name: "Identity Type", uid: "identityType" },
|
||||||
|
{ name: "Identity Number", uid: "identityNumber" },
|
||||||
|
// { name: "Users", uid: "users" },
|
||||||
|
// { name: "Status", uid: "status" },
|
||||||
|
{ name: "Aksi", uid: "actions" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MasterUserTable() {
|
||||||
|
const MySwal = withReactContent(Swal);
|
||||||
|
const [user, setUser] = useState<MasterUser[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPage, setTotalPage] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initState();
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
async function initState() {
|
||||||
|
const res = await listMasterUsers({ page: page, limit: 10 });
|
||||||
|
getTableNumber(10, res?.data?.data);
|
||||||
|
setTotalPage(res?.data?.meta?.totalPage);
|
||||||
|
}
|
||||||
|
const getTableNumber = (limit: number, data?: any) => {
|
||||||
|
if (data) {
|
||||||
|
const startIndex = limit * (page - 1);
|
||||||
|
let iterate = 0;
|
||||||
|
const newData = data.map((value: any) => {
|
||||||
|
iterate++;
|
||||||
|
value.no = startIndex + iterate;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
setUser(newData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function doDelete(id: string) {
|
||||||
|
// loading();
|
||||||
|
const resDelete = await deleteMasterUser(id);
|
||||||
|
|
||||||
|
if (resDelete?.error) {
|
||||||
|
error(resDelete.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
successSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id: any) => {
|
||||||
|
MySwal.fire({
|
||||||
|
title: "Hapus Data",
|
||||||
|
icon: "warning",
|
||||||
|
showCancelButton: true,
|
||||||
|
cancelButtonColor: "#3085d6",
|
||||||
|
confirmButtonColor: "#d33",
|
||||||
|
confirmButtonText: "Hapus",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
doDelete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function successSubmit() {
|
||||||
|
MySwal.fire({
|
||||||
|
title: "Sukses",
|
||||||
|
icon: "success",
|
||||||
|
confirmButtonColor: "#3085d6",
|
||||||
|
confirmButtonText: "OK",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
initState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderCell = useCallback((user: MasterUser, columnKey: Key) => {
|
||||||
|
const cellValue = user[columnKey as keyof MasterUser];
|
||||||
|
// const statusColorMap: Record<string, ChipProps["color"]> = {
|
||||||
|
// active: "primary",
|
||||||
|
// cancel: "danger",
|
||||||
|
// pending: "success",
|
||||||
|
// };
|
||||||
|
|
||||||
|
switch (columnKey) {
|
||||||
|
case "id":
|
||||||
|
return <div>{user.id}</div>;
|
||||||
|
|
||||||
|
case "status":
|
||||||
|
return (
|
||||||
|
<div></div>
|
||||||
|
// <Chip
|
||||||
|
// className="capitalize "
|
||||||
|
// // color={statusColorMap[user.status]}
|
||||||
|
// size="lg"
|
||||||
|
// variant="flat"
|
||||||
|
// >
|
||||||
|
// <div className="flex flex-row items-center gap-2 justify-center">{cellValue}</div>
|
||||||
|
// </Chip>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "actions":
|
||||||
|
return (
|
||||||
|
<div className="relative flex justify-start items-center gap-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<DotsYIcon className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href={`/admin/master-user/edit/${user.id}`}
|
||||||
|
className="flex items-center"
|
||||||
|
>
|
||||||
|
<CreateIconIon className="inline mr-2 mb-1" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDelete(user.id)}
|
||||||
|
className="text-red-600"
|
||||||
|
>
|
||||||
|
<DeleteIcon
|
||||||
|
width={20}
|
||||||
|
height={16}
|
||||||
|
className="inline mr-2 mb-1"
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return cellValue;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mx-3 my-5">
|
||||||
|
<div className="flex flex-col items-center rounded-2xl">
|
||||||
|
<Table className="rounded-2xl text-black dark:text-white bg-white dark:bg-black min-h-[50px]">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableHead
|
||||||
|
key={column.uid}
|
||||||
|
className="bg-white dark:bg-black text-black dark:text-white border-b text-md"
|
||||||
|
>
|
||||||
|
{column.name}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{user.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="text-center">
|
||||||
|
No data to display.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
user.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableCell key={column.uid}>
|
||||||
|
{renderCell(item, column.uid)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<div className="my-2 w-full flex justify-center">
|
||||||
|
{/* <Pagination
|
||||||
|
isCompact
|
||||||
|
showControls
|
||||||
|
showShadow
|
||||||
|
color="primary"
|
||||||
|
classNames={{
|
||||||
|
base: "bg-transparent",
|
||||||
|
wrapper: "bg-transparent",
|
||||||
|
}}
|
||||||
|
page={page}
|
||||||
|
total={totalPage}
|
||||||
|
onChange={(page) => setPage(page)}
|
||||||
|
/> */}
|
||||||
|
<CustomPagination
|
||||||
|
totalPage={totalPage}
|
||||||
|
onPageChange={(data) => setPage(data)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,321 @@
|
||||||
|
"use client";
|
||||||
|
import {
|
||||||
|
CreateIconIon,
|
||||||
|
DeleteIcon,
|
||||||
|
DotsYIcon,
|
||||||
|
SearchIcon,
|
||||||
|
} from "@/components/icons";
|
||||||
|
import { close, error, success } from "@/config/swal";
|
||||||
|
import { deleteArticle } from "@/service/article";
|
||||||
|
import { getCustomStaticPage } from "@/service/static-page-service";
|
||||||
|
import { Article } from "@/types/globals";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Key, useCallback, useEffect, useState } from "react";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
import withReactContent from "sweetalert2-react-content";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableHead,
|
||||||
|
TableCell,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import CustomPagination from "../layout/custom-pagination";
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ name: "No", uid: "no" },
|
||||||
|
{ name: "Judul", uid: "title" },
|
||||||
|
{ name: "Slug", uid: "slug" },
|
||||||
|
{ name: "Deskripsi", uid: "description" },
|
||||||
|
{ name: "Aksi", uid: "actions" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type ArticleData = Article & {
|
||||||
|
no: number;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StaticPageTable() {
|
||||||
|
const MySwal = withReactContent(Swal);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPage, setTotalPage] = useState(1);
|
||||||
|
const [article, setArticle] = useState<ArticleData[]>([]);
|
||||||
|
const [showData, setShowData] = useState("10");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [startDateValue] = useState({
|
||||||
|
startDate: null,
|
||||||
|
endDate: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initState();
|
||||||
|
}, [page, showData, startDateValue]);
|
||||||
|
|
||||||
|
async function initState() {
|
||||||
|
const req = {
|
||||||
|
limit: showData,
|
||||||
|
page: page,
|
||||||
|
search: search,
|
||||||
|
};
|
||||||
|
const res = await getCustomStaticPage(req);
|
||||||
|
getTableNumber(parseInt(showData), res.data?.data);
|
||||||
|
console.log("res.data?.data", res.data);
|
||||||
|
setTotalPage(res?.data?.meta?.totalPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTableNumber = (limit: number, data: Article[]) => {
|
||||||
|
if (data) {
|
||||||
|
const startIndex = limit * (page - 1);
|
||||||
|
let iterate = 0;
|
||||||
|
const newData = data.map((value: any) => {
|
||||||
|
iterate++;
|
||||||
|
value.no = startIndex + iterate;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
console.log("daata", data);
|
||||||
|
setArticle(newData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function doDelete(id: string) {
|
||||||
|
// loading();
|
||||||
|
const resDelete = await deleteArticle(id);
|
||||||
|
|
||||||
|
if (resDelete?.error) {
|
||||||
|
error(resDelete.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
success("Success Deleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
MySwal.fire({
|
||||||
|
title: "Hapus Data",
|
||||||
|
icon: "warning",
|
||||||
|
showCancelButton: true,
|
||||||
|
cancelButtonColor: "#3085d6",
|
||||||
|
confirmButtonColor: "#d33",
|
||||||
|
confirmButtonText: "Hapus",
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
doDelete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// function successSubmit() {
|
||||||
|
// MySwal.fire({
|
||||||
|
// title: "Sukses",
|
||||||
|
// icon: "success",
|
||||||
|
// confirmButtonColor: "#3085d6",
|
||||||
|
// confirmButtonText: "OK",
|
||||||
|
// }).then((result) => {
|
||||||
|
// if (result.isConfirmed) {
|
||||||
|
// // initStete();
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
const renderCell = useCallback((article: ArticleData, columnKey: Key) => {
|
||||||
|
const cellValue = article[columnKey as keyof ArticleData];
|
||||||
|
|
||||||
|
switch (columnKey) {
|
||||||
|
case "actions":
|
||||||
|
return (
|
||||||
|
<div className="relative flex justify-start items-center gap-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="p-0 h-auto w-auto"
|
||||||
|
>
|
||||||
|
<DotsYIcon className="text-black dark:text-white" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="bg-black text-white border">
|
||||||
|
{/* <DropdownMenuItem>
|
||||||
|
<Link href={`/admin/static-page/detail/${article.id}`} className="flex items-center">
|
||||||
|
<EyeIconMdi className="inline mr-2 mb-1" />
|
||||||
|
Detail
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem> */}
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href={`/admin/static-page/edit/${article.id}`}
|
||||||
|
className="flex items-center"
|
||||||
|
>
|
||||||
|
<CreateIconIon className="inline mr-2 mb-1" size={20} />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDelete(article.id)}
|
||||||
|
className="flex items-center text-red-500"
|
||||||
|
>
|
||||||
|
<DeleteIcon
|
||||||
|
color="red"
|
||||||
|
width={20}
|
||||||
|
height={16}
|
||||||
|
className="inline mr-2 mb-1"
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return cellValue;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
let typingTimer: NodeJS.Timeout;
|
||||||
|
const doneTypingInterval = 1500;
|
||||||
|
|
||||||
|
const handleKeyUp = () => {
|
||||||
|
clearTimeout(typingTimer);
|
||||||
|
typingTimer = setTimeout(doneTyping, doneTypingInterval);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = () => {
|
||||||
|
clearTimeout(typingTimer);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function doneTyping() {
|
||||||
|
initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="py-3">
|
||||||
|
<div className="flex flex-col items-start rounded-2xl gap-3">
|
||||||
|
<div className="flex flex-col md:flex-row gap-3 w-full">
|
||||||
|
<div className="flex flex-col gap-1 w-full lg:w-1/3">
|
||||||
|
<Label className="font-semibold text-sm">Pencarian</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<SearchIcon className="text-base text-muted-foreground" />
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
aria-label="Pencarian..."
|
||||||
|
placeholder="Pencarian..."
|
||||||
|
className="pl-10 text-sm bg-muted"
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 w-full lg:w-[72px]">
|
||||||
|
<Label className="font-semibold text-sm">Data</Label>
|
||||||
|
<Select
|
||||||
|
value={showData}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
value === "" ? "" : setShowData(value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="5">5</SelectItem>
|
||||||
|
<SelectItem value="10">10</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* <div className="flex flex-col gap-1 w-full lg:w-[340px]">
|
||||||
|
<p className="font-semibold text-sm">Tanggal</p>
|
||||||
|
<Datepicker
|
||||||
|
value={startDateValue}
|
||||||
|
displayFormat="DD/MM/YYYY"
|
||||||
|
onChange={(e: any) => setStartDateValue(e)}
|
||||||
|
inputClassName="z-50 w-full text-sm bg-transparent border-1 border-gray-200 px-2 py-[6px] rounded-xl h-[40px] text-gray-600 dark:text-gray-300"
|
||||||
|
/>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
<Table className="rounded-3xl overflow-hidden border border-gray-200 dark:border-gray-800 shadow-sm bg-white dark:bg-black text-black dark:text-white min-h-[50px]">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-white dark:bg-black border-b dark:border-gray-800">
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableHead
|
||||||
|
key={column.uid}
|
||||||
|
className="text-left font-semibold text-sm text-black dark:text-white px-4 py-3"
|
||||||
|
>
|
||||||
|
{column.name}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{article.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="text-center py-4 text-sm"
|
||||||
|
>
|
||||||
|
No data to display.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
article.map((item) => (
|
||||||
|
<TableRow key={item.id} className="transition-colors">
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableCell
|
||||||
|
key={column.uid}
|
||||||
|
className="px-4 py-3 text-sm border-none"
|
||||||
|
>
|
||||||
|
{renderCell(item, column.uid)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<div className="my-2 w-full flex justify-center">
|
||||||
|
{/* <Pagination
|
||||||
|
isCompact
|
||||||
|
showControls
|
||||||
|
showShadow
|
||||||
|
color="primary"
|
||||||
|
classNames={{
|
||||||
|
base: "bg-transparent",
|
||||||
|
wrapper: "bg-transparent",
|
||||||
|
}}
|
||||||
|
page={page}
|
||||||
|
total={totalPage}
|
||||||
|
onChange={(page) => setPage(page)}
|
||||||
|
/> */}
|
||||||
|
<CustomPagination
|
||||||
|
totalPage={totalPage}
|
||||||
|
onPageChange={(data) => setPage(data)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||||
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Accordion({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
|
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn("border-b last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||||
|
|
@ -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-md 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 }
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="breadcrumb-link"
|
||||||
|
className={cn("hover:text-foreground transition-colors", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("text-foreground font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
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 shadow-xs hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white shadow-xs 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 shadow-xs 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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 }
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = "label",
|
||||||
|
buttonVariant = "ghost",
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) =>
|
||||||
|
date.toLocaleString("default", { month: "short" }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
|
months: cn(
|
||||||
|
"flex gap-4 flex-col md:flex-row relative",
|
||||||
|
defaultClassNames.months
|
||||||
|
),
|
||||||
|
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||||
|
nav: cn(
|
||||||
|
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||||
|
defaultClassNames.nav
|
||||||
|
),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
|
defaultClassNames.button_previous
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
|
defaultClassNames.button_next
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||||
|
defaultClassNames.month_caption
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||||
|
defaultClassNames.dropdowns
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||||
|
defaultClassNames.dropdown_root
|
||||||
|
),
|
||||||
|
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
|
||||||
|
caption_label: cn(
|
||||||
|
"select-none font-medium",
|
||||||
|
captionLayout === "label"
|
||||||
|
? "text-sm"
|
||||||
|
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||||
|
defaultClassNames.caption_label
|
||||||
|
),
|
||||||
|
table: "w-full border-collapse",
|
||||||
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||||
|
defaultClassNames.weekday
|
||||||
|
),
|
||||||
|
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||||
|
week_number_header: cn(
|
||||||
|
"select-none w-(--cell-size)",
|
||||||
|
defaultClassNames.week_number_header
|
||||||
|
),
|
||||||
|
week_number: cn(
|
||||||
|
"text-[0.8rem] select-none text-muted-foreground",
|
||||||
|
defaultClassNames.week_number
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||||
|
defaultClassNames.day
|
||||||
|
),
|
||||||
|
range_start: cn(
|
||||||
|
"rounded-l-md bg-accent",
|
||||||
|
defaultClassNames.range_start
|
||||||
|
),
|
||||||
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
|
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||||
|
today: cn(
|
||||||
|
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||||
|
defaultClassNames.today
|
||||||
|
),
|
||||||
|
outside: cn(
|
||||||
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
defaultClassNames.outside
|
||||||
|
),
|
||||||
|
disabled: cn(
|
||||||
|
"text-muted-foreground opacity-50",
|
||||||
|
defaultClassNames.disabled
|
||||||
|
),
|
||||||
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="calendar"
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return (
|
||||||
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === "right") {
|
||||||
|
return (
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn("size-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DayButton: CalendarDayButton,
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton>) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus()
|
||||||
|
}, [modifiers.focused])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString()}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected &&
|
||||||
|
!modifiers.range_start &&
|
||||||
|
!modifiers.range_end &&
|
||||||
|
!modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
defaultClassNames.day,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton }
|
||||||
|
|
@ -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-1.5 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,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,241 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import useEmblaCarousel, {
|
||||||
|
type UseEmblaCarouselType,
|
||||||
|
} from "embla-carousel-react"
|
||||||
|
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
type CarouselApi = UseEmblaCarouselType[1]
|
||||||
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||||
|
type CarouselOptions = UseCarouselParameters[0]
|
||||||
|
type CarouselPlugin = UseCarouselParameters[1]
|
||||||
|
|
||||||
|
type CarouselProps = {
|
||||||
|
opts?: CarouselOptions
|
||||||
|
plugins?: CarouselPlugin
|
||||||
|
orientation?: "horizontal" | "vertical"
|
||||||
|
setApi?: (api: CarouselApi) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type CarouselContextProps = {
|
||||||
|
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||||
|
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||||
|
scrollPrev: () => void
|
||||||
|
scrollNext: () => void
|
||||||
|
canScrollPrev: boolean
|
||||||
|
canScrollNext: boolean
|
||||||
|
} & CarouselProps
|
||||||
|
|
||||||
|
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||||
|
|
||||||
|
function useCarousel() {
|
||||||
|
const context = React.useContext(CarouselContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useCarousel must be used within a <Carousel />")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function Carousel({
|
||||||
|
orientation = "horizontal",
|
||||||
|
opts,
|
||||||
|
setApi,
|
||||||
|
plugins,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||||
|
const [carouselRef, api] = useEmblaCarousel(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
axis: orientation === "horizontal" ? "x" : "y",
|
||||||
|
},
|
||||||
|
plugins
|
||||||
|
)
|
||||||
|
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||||
|
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||||
|
|
||||||
|
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||||
|
if (!api) return
|
||||||
|
setCanScrollPrev(api.canScrollPrev())
|
||||||
|
setCanScrollNext(api.canScrollNext())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scrollPrev = React.useCallback(() => {
|
||||||
|
api?.scrollPrev()
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const scrollNext = React.useCallback(() => {
|
||||||
|
api?.scrollNext()
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault()
|
||||||
|
scrollPrev()
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault()
|
||||||
|
scrollNext()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollPrev, scrollNext]
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api || !setApi) return
|
||||||
|
setApi(api)
|
||||||
|
}, [api, setApi])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api) return
|
||||||
|
onSelect(api)
|
||||||
|
api.on("reInit", onSelect)
|
||||||
|
api.on("select", onSelect)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
api?.off("select", onSelect)
|
||||||
|
}
|
||||||
|
}, [api, onSelect])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CarouselContext.Provider
|
||||||
|
value={{
|
||||||
|
carouselRef,
|
||||||
|
api: api,
|
||||||
|
opts,
|
||||||
|
orientation:
|
||||||
|
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||||
|
scrollPrev,
|
||||||
|
scrollNext,
|
||||||
|
canScrollPrev,
|
||||||
|
canScrollNext,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onKeyDownCapture={handleKeyDown}
|
||||||
|
className={cn("relative", className)}
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
data-slot="carousel"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</CarouselContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const { carouselRef, orientation } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={carouselRef}
|
||||||
|
className="overflow-hidden"
|
||||||
|
data-slot="carousel-content"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex",
|
||||||
|
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const { orientation } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
data-slot="carousel-item"
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 shrink-0 grow-0 basis-full",
|
||||||
|
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselPrevious({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-previous"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute size-8 rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "top-1/2 -left-12 -translate-y-1/2"
|
||||||
|
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollPrev}
|
||||||
|
onClick={scrollPrev}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowLeft />
|
||||||
|
<span className="sr-only">Previous slide</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselNext({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-next"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute size-8 rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "top-1/2 -right-12 -translate-y-1/2"
|
||||||
|
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollNext}
|
||||||
|
onClick={scrollNext}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowRight />
|
||||||
|
<span className="sr-only">Next slide</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
type CarouselApi,
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselPrevious,
|
||||||
|
CarouselNext,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="flex items-center justify-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
export type ChipColor = "default" | "primary" | "success" | "danger";
|
||||||
|
|
||||||
|
export interface ChipProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
color?: ChipColor;
|
||||||
|
className?: string;
|
||||||
|
size?: string;
|
||||||
|
variant?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorMap: Record<ChipColor, string> = {
|
||||||
|
default: "bg-gray-200 text-gray-800",
|
||||||
|
primary: "bg-blue-100 text-blue-800",
|
||||||
|
success: "bg-green-100 text-green-800",
|
||||||
|
danger: "bg-red-100 text-red-800",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Chip: React.FC<ChipProps> = ({ children, color = "default", className }) => {
|
||||||
|
return <span className={clsx("inline-flex items-center px-3 py-1 rounded-full text-sm font-medium", colorMap[color], className)}>{children}</span>;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,257 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
|
|
@ -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 flex 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 }
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||||
|
import { CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("grid gap-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"border-input text-primary 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 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="relative flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]: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 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "popper",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue