refactor(auth, layouts): enhance UI components for improved aesthetics and usability
- Updated SignIn and Profile components with new background styles and layout adjustments for a more modern look. - Refined button styles in GoogleSigninButton and SigninWithPassword for better user interaction feedback. - Enhanced InputGroup and Select components with improved styling and error handling for a more consistent user experience. - Adjusted Sidebar and Header components for better responsiveness and visual appeal. - Implemented spotlight effect in UserInfo dropdown for a more engaging user interface.
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
# Frontend Guide: File Upload + Profile Image APIs
|
||||
|
||||
This is a simple reference for frontend integration with file upload, file retrieval, and setting profile image.
|
||||
|
||||
## Base URLs
|
||||
|
||||
- Admin APIs: `/admin/v1`
|
||||
- Customer/Public APIs: `/api/v1`
|
||||
|
||||
All protected endpoints require:
|
||||
|
||||
- `Authorization: Bearer <access_token>`
|
||||
|
||||
---
|
||||
|
||||
## 1) Login (Admin)
|
||||
|
||||
Use this when you want to update admin user profile image.
|
||||
|
||||
### Request
|
||||
|
||||
- `POST /admin/v1/profile/login`
|
||||
- `Content-Type: application/json`
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "Admin1234"
|
||||
}
|
||||
```
|
||||
|
||||
### Response (success)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Login successful",
|
||||
"data": {
|
||||
"user": {
|
||||
"id": "65bc1234567890abcdef1234",
|
||||
"profile_image": null
|
||||
},
|
||||
"access_token": "<jwt>",
|
||||
"refresh_token": "<jwt>",
|
||||
"expires_at": 1776783486
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2) Upload File (GridFS)
|
||||
|
||||
Use multipart form upload.
|
||||
|
||||
### Admin route
|
||||
|
||||
- `POST /admin/v1/files/upload`
|
||||
|
||||
### Customer route
|
||||
|
||||
- `POST /api/v1/files/upload`
|
||||
|
||||
### Form fields
|
||||
|
||||
- `file` (required, File)
|
||||
- `category` (optional, string), example: `profile_images`
|
||||
- `tags` (optional, repeatable), example: `user`, `profile`
|
||||
- `description` (optional, string)
|
||||
|
||||
### Response (success)
|
||||
|
||||
```json
|
||||
{
|
||||
"file_id": "69e7899c2053abd3fa5433e0",
|
||||
"filename": "avatar.png",
|
||||
"content_type": "image/png",
|
||||
"size": 123456,
|
||||
"message": "File uploaded successfully"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3) Get File Info
|
||||
|
||||
- `GET /admin/v1/files/{file_id}/info`
|
||||
- `GET /api/v1/files/{file_id}/info`
|
||||
|
||||
### Response (success)
|
||||
|
||||
```json
|
||||
{
|
||||
"file_id": "69e7899c2053abd3fa5433e0",
|
||||
"filename": "avatar.png",
|
||||
"content_type": "image/png",
|
||||
"size": 123456,
|
||||
"uploaded_by": "65bc1234567890abcdef1234",
|
||||
"uploaded_at": "2026-04-21T14:28:44.787Z",
|
||||
"category": "profile_images",
|
||||
"tags": ["user", "profile"],
|
||||
"description": "sample profile photo"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4) Download File
|
||||
|
||||
- `GET /admin/v1/files/{file_id}/download`
|
||||
- `GET /api/v1/files/{file_id}/download`
|
||||
|
||||
Returns file binary stream.
|
||||
|
||||
Useful for image URL:
|
||||
|
||||
- `http://localhost:8080/admin/v1/files/<file_id>/download`
|
||||
- or `http://localhost:8080/api/v1/files/<file_id>/download`
|
||||
|
||||
---
|
||||
|
||||
## 5) Set Admin User Profile Image
|
||||
|
||||
Upload does not update user profile automatically.
|
||||
After upload, call profile update endpoint with the download URL.
|
||||
|
||||
### Request
|
||||
|
||||
- `PUT /admin/v1/profile`
|
||||
- `Content-Type: application/json`
|
||||
|
||||
```json
|
||||
{
|
||||
"profile_image": "http://localhost:8080/admin/v1/files/69e7899c2053abd3fa5433e0/download"
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
- Success: standard success envelope with updated user data
|
||||
- Failure example:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "Bad Request",
|
||||
"error": {
|
||||
"code": "BAD_REQUEST",
|
||||
"message": "failed to update user"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6) Verify Profile
|
||||
|
||||
- `GET /admin/v1/profile`
|
||||
|
||||
Check:
|
||||
|
||||
- `data.profile_image` is now populated with file URL.
|
||||
|
||||
---
|
||||
|
||||
## Common Errors
|
||||
|
||||
- `401 Unauthorized`: missing/expired token
|
||||
- `413 Request Entity Too Large`: file too big
|
||||
- `400 Unsupported file type` or validation error
|
||||
- `404 File not found`: wrong `file_id`
|
||||
|
||||
---
|
||||
|
||||
## Suggested Frontend Flow (Admin)
|
||||
|
||||
1. Login -> save `access_token`
|
||||
2. Upload image -> get `file_id`
|
||||
3. Build URL `.../admin/v1/files/{file_id}/download`
|
||||
4. Update profile with `profile_image` URL
|
||||
5. Refresh profile screen via `GET /admin/v1/profile`
|
||||
|
||||
---
|
||||
|
||||
## Customer: Company Documents Upload
|
||||
|
||||
Customers can upload company documents using customer auth token.
|
||||
|
||||
### Upload endpoint
|
||||
|
||||
- `POST /api/v1/files/upload`
|
||||
|
||||
### Recommended form-data for company docs
|
||||
|
||||
- `file`: actual document file
|
||||
- `category`: `company_documents`
|
||||
- `tags`: repeatable. Suggested:
|
||||
- `company`
|
||||
- `document`
|
||||
- `<doc_type>` (example: `tax_certificate`, `registration`, `license`)
|
||||
- `description`: human-readable note, example: `Company tax certificate 2026`
|
||||
|
||||
### Example curl
|
||||
|
||||
```bash
|
||||
CUSTOMER_TOKEN="<customer_access_token>"
|
||||
|
||||
curl -X POST "http://localhost:8080/api/v1/files/upload" \
|
||||
-H "Authorization: Bearer ${CUSTOMER_TOKEN}" \
|
||||
-F "file=@/absolute/path/to/company-doc.pdf" \
|
||||
-F "category=company_documents" \
|
||||
-F "tags=company" \
|
||||
-F "tags=document" \
|
||||
-F "tags=tax_certificate" \
|
||||
-F "description=Company tax certificate 2026"
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"file_id": "69e8abcd2053abd3fa5433e1",
|
||||
"filename": "company-doc.pdf",
|
||||
"content_type": "application/pdf",
|
||||
"size": 245632,
|
||||
"message": "File uploaded successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### Fetch company docs list
|
||||
|
||||
Use list with category filter:
|
||||
|
||||
- `GET /api/v1/files?category=company_documents&limit=50&skip=0`
|
||||
|
||||
You can also filter by tags:
|
||||
|
||||
- `GET /api/v1/files?category=company_documents&tags=tax_certificate`
|
||||
|
||||
### Download company doc
|
||||
|
||||
- `GET /api/v1/files/{file_id}/download`
|
||||
|
||||
### Important current behavior
|
||||
|
||||
- Upload works for customers and stores `uploaded_by` as `customer_id`.
|
||||
- Company now supports `document_file_ids` for mapping GridFS files.
|
||||
|
||||
### Attach uploaded documents to company
|
||||
|
||||
Use admin company update endpoint after collecting uploaded `file_id` values:
|
||||
|
||||
- `PUT /admin/v1/companies/{company_id}`
|
||||
|
||||
Request body includes regular company fields plus `document_file_ids`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Opplens",
|
||||
"type": "private",
|
||||
"registration_number": "1234567890",
|
||||
"tax_id": "1234567890",
|
||||
"industry": "Technology",
|
||||
"document_file_ids": [
|
||||
"69e8abcd2053abd3fa5433e1",
|
||||
"69e8abcd2053abd3fa5433e2"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`document_file_ids` is also returned in:
|
||||
|
||||
- `GET /admin/v1/companies/{id}`
|
||||
- `GET /api/v1/companies` (customer "my company" profile)
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function SignIn() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full flex-col items-center justify-center px-4 py-8 sm:px-6 sm:py-10">
|
||||
<div className="shadow-3xl m-auto flex w-full max-w-[26rem] flex-col items-center justify-center rounded-2xl bg-white px-4 dark:bg-gray-dark dark:shadow-card sm:max-w-md sm:px-8 md:max-w-lg lg:max-w-xl xl:w-4/12 xl:max-w-none">
|
||||
<div className="relative flex min-h-svh w-full flex-col items-center justify-center overflow-hidden bg-gray-1/40 px-4 py-8 dark:bg-dark sm:px-6 sm:py-10">
|
||||
<div className="pointer-events-none absolute -top-24 left-1/2 h-64 w-64 -translate-x-1/2 rounded-full bg-primary/10 blur-3xl dark:bg-primary/20" />
|
||||
<div className="pointer-events-none absolute -bottom-24 right-10 h-64 w-64 rounded-full bg-purple-500/10 blur-3xl dark:bg-purple-500/20" />
|
||||
<div className="m-auto flex w-full max-w-[26rem] flex-col items-center justify-center rounded-2xl border border-stroke/70 bg-white/95 px-4 shadow-theme-xs backdrop-blur-sm dark:border-dark-3 dark:bg-gray-dark/95 sm:max-w-md sm:px-8 md:max-w-lg lg:max-w-xl xl:w-4/12 xl:max-w-none">
|
||||
<div className="relative flex w-full flex-wrap items-center justify-center">
|
||||
<div className="w-full">
|
||||
<div className="w-full p-4 sm:p-12.5 xl:p-15">
|
||||
|
||||
+56
-33
@@ -14,17 +14,20 @@ export default function Page() {
|
||||
const hasProfileImage = data?.profile_image && !imageError;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl rounded-lg bg-white p-6 shadow-default dark:bg-gray-dark">
|
||||
<div className="relative h-48 w-full rounded-t-lg bg-gray-200 dark:bg-gray-700">
|
||||
<div className="relative mx-auto w-full max-w-4xl overflow-hidden rounded-2xl border border-stroke/70 bg-gradient-to-br from-white via-white to-primary/[0.03] p-6 dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-primary/[0.08]">
|
||||
<div className="pointer-events-none absolute -right-16 -top-16 h-40 w-40 rounded-full bg-primary/10 blur-3xl dark:bg-primary/20" />
|
||||
<div className="pointer-events-none absolute -bottom-16 -left-16 h-40 w-40 rounded-full bg-purple-500/10 blur-3xl dark:bg-purple-500/20" />
|
||||
<div className="relative h-52 w-full rounded-2xl border border-stroke/70 bg-gray-200 dark:border-dark-3 dark:bg-gray-700">
|
||||
<Image
|
||||
src={"/images/cover/cover-01.png"}
|
||||
alt="Cover"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
className="rounded-t-lg"
|
||||
className="rounded-2xl"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/25 via-black/10 to-transparent" />
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 transform">
|
||||
<div className="relative flex h-32 w-32 items-center justify-center overflow-hidden rounded-full border-4 border-white bg-gray-200 dark:border-gray-dark dark:bg-gray-700">
|
||||
<div className="relative flex h-32 w-32 items-center justify-center overflow-hidden rounded-full border-4 border-white bg-gray-200 shadow-theme-xs dark:border-gray-dark dark:bg-gray-700">
|
||||
{hasProfileImage ? (
|
||||
<Image
|
||||
onError={() => setImageError(true)}
|
||||
@@ -42,47 +45,63 @@ export default function Page() {
|
||||
</div>
|
||||
|
||||
<div className="mt-16 text-center">
|
||||
<h1 className="text-2xl font-bold text-dark dark:text-white">
|
||||
<h1 className="text-2xl font-semibold text-dark dark:text-white">
|
||||
{data?.full_name}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
@{data?.username}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-stroke pt-6 dark:border-stroke-dark">
|
||||
<div className="mt-8 rounded-xl border border-stroke/70 bg-white/70 p-5 backdrop-blur-sm dark:border-dark-3 dark:bg-dark-2/40">
|
||||
<h2 className="mb-4 text-lg font-semibold text-dark dark:text-white">
|
||||
User Information
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="font-medium text-dark dark:text-white">Email</p>
|
||||
<p className="text-gray-600 dark:text-gray-300">{data?.email}</p>
|
||||
<div className="rounded-lg bg-gray-1/70 px-4 py-3 dark:bg-dark-3/40">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-dark-5">
|
||||
Email
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{data?.email}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-dark dark:text-white">Phone</p>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
<div className="rounded-lg bg-gray-1/70 px-4 py-3 dark:bg-dark-3/40">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-dark-5">
|
||||
Phone
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{data?.phone ? formatPhoneNumber(data?.phone) : "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-dark dark:text-white">Department</p>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
<div className="rounded-lg bg-gray-1/70 px-4 py-3 dark:bg-dark-3/40">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-dark-5">
|
||||
Department
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{data?.department}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-dark dark:text-white">Position</p>
|
||||
<p className="text-gray-600 dark:text-gray-300">{data?.position}</p>
|
||||
<div className="rounded-lg bg-gray-1/70 px-4 py-3 dark:bg-dark-3/40">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-dark-5">
|
||||
Position
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{data?.position}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-dark dark:text-white">Role</p>
|
||||
<p className="capitalize text-gray-600 dark:text-gray-300">
|
||||
<div className="rounded-lg bg-gray-1/70 px-4 py-3 dark:bg-dark-3/40">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-dark-5">
|
||||
Role
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium capitalize text-gray-700 dark:text-gray-300">
|
||||
{data?.role}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-dark dark:text-white">Status</p>
|
||||
<div className="rounded-lg bg-gray-1/70 px-4 py-3 dark:bg-dark-3/40">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-dark-5">
|
||||
Status
|
||||
</p>
|
||||
<div className="w-fit">
|
||||
<Status status={data?.status ?? ""}>{data?.status ?? "-"}</Status>
|
||||
</div>
|
||||
@@ -90,13 +109,15 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-stroke pt-6 dark:border-stroke-dark">
|
||||
<div className="mt-6 rounded-xl border border-stroke/70 bg-white/70 p-5 backdrop-blur-sm dark:border-dark-3 dark:bg-dark-2/40">
|
||||
<h2 className="mb-4 text-lg font-semibold text-dark dark:text-white">
|
||||
Account Details
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="font-medium text-dark dark:text-white">Verified</p>
|
||||
<div className="rounded-lg bg-gray-1/70 px-4 py-3 dark:bg-dark-3/40">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-dark-5">
|
||||
Verified
|
||||
</p>
|
||||
<div className="w-fit">
|
||||
<Status status={data?.is_verified ? "success" : "warning"}>
|
||||
{data?.is_verified ? (
|
||||
@@ -107,17 +128,19 @@ export default function Page() {
|
||||
</Status>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-dark dark:text-white">Last Login</p>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
<div className="rounded-lg bg-gray-1/70 px-4 py-3 dark:bg-dark-3/40">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-dark-5">
|
||||
Last Login
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{unixToDate({ unix: data?.last_login_at ?? 0, hasTime: true })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-dark dark:text-white">
|
||||
<div className="rounded-lg bg-gray-1/70 px-4 py-3 dark:bg-dark-3/40">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-dark-5">
|
||||
Member Since
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
<p className="mt-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{unixToDate({ unix: data?.created_at ?? 0, hasTime: true })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { GoogleIcon } from "@/assets/icons";
|
||||
|
||||
export default function GoogleSigninButton({ text }: { text: string }) {
|
||||
return (
|
||||
<button className="flex w-full items-center justify-center gap-3.5 rounded-lg border border-stroke bg-gray-2 p-[15px] font-medium hover:bg-opacity-50 dark:border-dark-3 dark:bg-dark-2 dark:hover:bg-opacity-50">
|
||||
<button className="flex w-full items-center justify-center gap-3.5 rounded-xl border border-white/40 bg-white/70 px-4 py-[15px] text-sm font-semibold text-dark shadow-theme-xs backdrop-blur-md transition-all duration-300 hover:border-primary/35 hover:bg-white/80 dark:border-white/15 dark:bg-dark-2/70 dark:text-white dark:hover:bg-white/[0.1]">
|
||||
<GoogleIcon />
|
||||
{text} with Google
|
||||
</button>
|
||||
|
||||
@@ -4,13 +4,21 @@ import SigninWithPassword from "../SigninWithPassword";
|
||||
|
||||
export default function Signin() {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="flex items-center justify-center my-10">
|
||||
<div className="mb-8 mt-4 flex flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="rounded-2xl border border-stroke/70 bg-white/80 p-3 dark:border-dark-3 dark:bg-dark-2/70">
|
||||
<Logo />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-dark dark:text-white">
|
||||
Welcome back
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-dark-5">
|
||||
Sign in to continue to your dashboard
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<SigninWithPassword />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useState } from "react";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import InputGroup from "../FormElements/InputGroup";
|
||||
import { ThemeToggleSwitch } from "../Layouts/header/theme-toggle";
|
||||
import { Button } from "../ui-elements/button";
|
||||
|
||||
export default function SigninWithPassword() {
|
||||
const isMutating = useIsMutating();
|
||||
@@ -29,7 +30,7 @@ export default function SigninWithPassword() {
|
||||
};
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="mt-5">
|
||||
<div className="absolute top-7 right-0">
|
||||
<div className="absolute right-0 top-7">
|
||||
<ThemeToggleSwitch />
|
||||
</div>
|
||||
<InputGroup
|
||||
@@ -63,7 +64,7 @@ export default function SigninWithPassword() {
|
||||
name="password"
|
||||
icon={
|
||||
<PasswordIcon
|
||||
className="cursor-pointer"
|
||||
className="cursor-pointer text-dark-5 transition hover:text-primary dark:text-white/75"
|
||||
onClick={() => {
|
||||
setPasswordFieldInputType(
|
||||
passwordFieldInputType === "password" ? "text" : "password",
|
||||
@@ -74,20 +75,24 @@ export default function SigninWithPassword() {
|
||||
/>
|
||||
|
||||
<div className="mb-4.5">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!!isMutating}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-primary p-4 font-medium text-white transition hover:bg-opacity-90",
|
||||
isMutating && "cursor-not-allowed bg-primary/50",
|
||||
)}
|
||||
>
|
||||
{isMutating ? (
|
||||
<Button
|
||||
label={
|
||||
isMutating ? (
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
|
||||
) : (
|
||||
"Sign in"
|
||||
)
|
||||
}
|
||||
variant="primary"
|
||||
shape="rounded"
|
||||
size="small"
|
||||
type="submit"
|
||||
disabled={!!isMutating}
|
||||
className={cn(
|
||||
"w-full rounded-xl bg-gradient-to-r from-primary to-primary/85 p-4 font-semibold shadow-theme-xs transition-all duration-300 hover:-translate-y-0.5 hover:opacity-95",
|
||||
isMutating && "cursor-not-allowed opacity-70 hover:translate-y-0",
|
||||
)}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ type InputGroupProps<T extends FieldValues> = {
|
||||
name: Path<T>;
|
||||
label: string;
|
||||
type: HTMLInputTypeAttribute;
|
||||
accept?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
fileStyleVariant?: "style1" | "style2";
|
||||
@@ -38,6 +39,7 @@ const InputGroup = <T extends FieldValues>({
|
||||
label,
|
||||
type,
|
||||
placeholder,
|
||||
accept,
|
||||
required,
|
||||
disabled,
|
||||
active,
|
||||
@@ -60,7 +62,7 @@ const InputGroup = <T extends FieldValues>({
|
||||
<div className={className}>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-body-sm font-medium capitalize text-dark dark:text-white"
|
||||
className="text-body-sm font-semibold capitalize text-dark dark:text-white"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 select-none text-error">*</span>}
|
||||
@@ -68,7 +70,7 @@ const InputGroup = <T extends FieldValues>({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative mt-3 [&_svg]:absolute [&_svg]:top-1/2 [&_svg]:-translate-y-1/2",
|
||||
"relative mt-3 [&_svg]:pointer-events-none [&_svg]:absolute [&_svg]:top-1/2 [&_svg]:-translate-y-1/2 [&_svg]:text-dark-5 dark:[&_svg]:text-white/75",
|
||||
props.iconPosition === "left"
|
||||
? "[&_svg]:left-4.5"
|
||||
: "[&_svg]:right-4.5",
|
||||
@@ -77,6 +79,7 @@ const InputGroup = <T extends FieldValues>({
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
accept={accept}
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
ref={ref}
|
||||
@@ -93,9 +96,10 @@ const InputGroup = <T extends FieldValues>({
|
||||
defaultValue: props.defaultValue,
|
||||
})}
|
||||
className={cn(
|
||||
"w-full rounded-lg border-[1.5px] border-stroke bg-transparent outline-none transition focus:border-primary disabled:cursor-default disabled:bg-gray-2 data-[active=true]:border-primary dark:border-dark-3 dark:bg-dark-2 dark:focus:border-primary dark:disabled:bg-dark dark:data-[active=true]:border-primary",
|
||||
"w-full rounded-xl border border-stroke/70 bg-white/90 shadow-[0_1px_2px_rgba(16,24,40,0.04),0_8px_20px_rgba(16,24,40,0.04)] outline-none backdrop-blur-[1px] transition-all duration-200 focus:border-primary/70 focus:bg-white focus:shadow-[0_0_0_2px_rgba(59,130,246,0.14),0_10px_24px_rgba(59,130,246,0.08)] disabled:cursor-default disabled:bg-gray-2/70 data-[active=true]:border-primary/70 dark:border-dark-3/80 dark:bg-dark-2/85 dark:focus:border-primary/80 dark:focus:shadow-[0_0_0_2px_rgba(59,130,246,0.2),0_10px_24px_rgba(15,23,42,0.45)] dark:disabled:bg-dark dark:data-[active=true]:border-primary/80",
|
||||
// Add error styles conditionally
|
||||
error && "border-red focus:border-red dark:border-red",
|
||||
error &&
|
||||
"border-red/80 focus:border-red focus:shadow-[0_0_0_2px_rgba(239,68,68,0.16),0_10px_24px_rgba(239,68,68,0.1)] dark:border-red/80 dark:focus:shadow-[0_0_0_2px_rgba(239,68,68,0.24),0_10px_24px_rgba(127,29,29,0.45)]",
|
||||
type === "file"
|
||||
? getFileStyles(props.fileStyleVariant!)
|
||||
: "px-5.5 py-3 text-dark placeholder:text-dark-6 dark:text-white",
|
||||
@@ -110,7 +114,9 @@ const InputGroup = <T extends FieldValues>({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-red mt-1.5 text-sm">{error.message as string}</p>
|
||||
<p className="text-red mt-1.5 text-sm font-medium">
|
||||
{error.message as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -112,7 +112,7 @@ const TagInput = <T extends FieldValues>({
|
||||
<div className={className}>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-body-sm font-medium text-dark dark:text-white"
|
||||
className="text-body-sm font-semibold text-dark dark:text-white"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 select-none text-error">*</span>}
|
||||
@@ -120,7 +120,7 @@ const TagInput = <T extends FieldValues>({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative mt-3 [&_svg]:absolute [&_svg]:top-1/2 [&_svg]:-translate-y-1/2",
|
||||
"relative mt-3 [&_svg]:pointer-events-none [&_svg]:absolute [&_svg]:top-1/2 [&_svg]:-translate-y-1/2 [&_svg]:text-dark-5 dark:[&_svg]:text-white/75",
|
||||
props.iconPosition === "left"
|
||||
? "[&_svg]:left-4.5"
|
||||
: "[&_svg]:right-4.5",
|
||||
@@ -128,8 +128,9 @@ const TagInput = <T extends FieldValues>({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full rounded-lg border-[1.5px] border-stroke bg-transparent outline-none transition focus-within:border-primary disabled:cursor-default disabled:bg-gray-2 data-[active=true]:border-primary dark:border-dark-3 dark:bg-dark-2 dark:focus-within:border-primary dark:disabled:bg-dark dark:data-[active=true]:border-primary",
|
||||
error && "border-red focus-within:border-red dark:border-red",
|
||||
"w-full rounded-xl border border-stroke/70 bg-white/90 shadow-[0_1px_2px_rgba(16,24,40,0.04),0_8px_20px_rgba(16,24,40,0.04)] outline-none backdrop-blur-[1px] transition-all duration-200 focus-within:border-primary/70 focus-within:bg-white focus-within:shadow-[0_0_0_2px_rgba(59,130,246,0.14),0_10px_24px_rgba(59,130,246,0.08)] disabled:cursor-default disabled:bg-gray-2/70 data-[active=true]:border-primary/70 dark:border-dark-3/80 dark:bg-dark-2/85 dark:focus-within:border-primary/80 dark:focus-within:shadow-[0_0_0_2px_rgba(59,130,246,0.2),0_10px_24px_rgba(15,23,42,0.45)] dark:disabled:bg-dark dark:data-[active=true]:border-primary/80",
|
||||
error &&
|
||||
"border-red/80 focus-within:border-red focus-within:shadow-[0_0_0_2px_rgba(239,68,68,0.16),0_10px_24px_rgba(239,68,68,0.1)] dark:border-red/80 dark:focus-within:shadow-[0_0_0_2px_rgba(239,68,68,0.24),0_10px_24px_rgba(127,29,29,0.45)]",
|
||||
"flex min-h-[50px] flex-wrap items-center gap-2 px-3 py-2",
|
||||
props.iconPosition === "left" && "pl-12.5",
|
||||
props.height === "sm" && "min-h-[42px]",
|
||||
@@ -140,14 +141,14 @@ const TagInput = <T extends FieldValues>({
|
||||
{tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2.5 py-1 text-sm font-medium text-primary dark:bg-primary/20"
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-primary/20 bg-primary/10 px-2.5 py-1 text-sm font-semibold text-primary dark:border-primary/30 dark:bg-primary/20"
|
||||
>
|
||||
{tag}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(index)}
|
||||
className="ml-1 flex h-4 w-4 items-center justify-center rounded-full text-xs hover:bg-primary/20"
|
||||
className="ml-1 flex h-4 w-4 items-center justify-center rounded-full text-xs transition hover:bg-primary/20"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -179,7 +180,9 @@ const TagInput = <T extends FieldValues>({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-red mt-1.5 text-sm">{error.message as string}</p>
|
||||
<p className="text-red mt-1.5 text-sm font-medium">
|
||||
{error.message as string}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{maxTags && (
|
||||
|
||||
@@ -49,13 +49,13 @@ export function TextAreaGroup<T extends FieldValues>({
|
||||
<div className={cn(className)}>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="mb-3 block text-body-sm font-medium text-dark dark:text-white"
|
||||
className="mb-3 block text-body-sm font-semibold text-dark dark:text-white"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 select-none text-error">*</span>}
|
||||
</label>
|
||||
|
||||
<div className="relative mt-3 [&_svg]:pointer-events-none [&_svg]:absolute [&_svg]:left-5.5 [&_svg]:top-5.5">
|
||||
<div className="relative mt-3 [&_svg]:pointer-events-none [&_svg]:absolute [&_svg]:left-5.5 [&_svg]:top-5.5 [&_svg]:text-dark-5 dark:[&_svg]:text-white/75">
|
||||
<textarea
|
||||
id={id}
|
||||
name={name}
|
||||
@@ -69,8 +69,9 @@ export function TextAreaGroup<T extends FieldValues>({
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
className={cn(
|
||||
"w-full rounded-lg border-[1.5px] border-stroke bg-transparent px-5.5 py-3 text-dark outline-none transition focus:border-primary disabled:cursor-default disabled:bg-gray-2 data-[active=true]:border-primary dark:border-dark-3 dark:bg-dark-2 dark:text-white dark:focus:border-primary dark:disabled:bg-dark dark:data-[active=true]:border-primary",
|
||||
error && "border-red focus:border-red dark:border-red",
|
||||
"w-full rounded-xl border border-stroke/70 bg-white/90 px-5.5 py-3 text-dark shadow-[0_1px_2px_rgba(16,24,40,0.04),0_8px_20px_rgba(16,24,40,0.04)] outline-none backdrop-blur-[1px] transition-all duration-200 focus:border-primary/70 focus:bg-white focus:shadow-[0_0_0_2px_rgba(59,130,246,0.14),0_10px_24px_rgba(59,130,246,0.08)] disabled:cursor-default disabled:bg-gray-2/70 data-[active=true]:border-primary/70 dark:border-dark-3/80 dark:bg-dark-2/85 dark:text-white dark:focus:border-primary/80 dark:focus:shadow-[0_0_0_2px_rgba(59,130,246,0.2),0_10px_24px_rgba(15,23,42,0.45)] dark:disabled:bg-dark dark:data-[active=true]:border-primary/80",
|
||||
error &&
|
||||
"border-red/80 focus:border-red focus:shadow-[0_0_0_2px_rgba(239,68,68,0.16),0_10px_24px_rgba(239,68,68,0.1)] dark:border-red/80 dark:focus:shadow-[0_0_0_2px_rgba(239,68,68,0.24),0_10px_24px_rgba(127,29,29,0.45)]",
|
||||
icon && "py-5 pl-13 pr-5",
|
||||
)}
|
||||
required={required}
|
||||
@@ -82,7 +83,9 @@ export function TextAreaGroup<T extends FieldValues>({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-red mt-1.5 text-sm">{error.message as string}</p>
|
||||
<p className="text-red mt-1.5 text-sm font-medium">
|
||||
{error.message as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -81,7 +81,7 @@ export function Select<T extends FieldValues>({
|
||||
<div className={cn("space-y-3", className)}>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="block text-body-sm font-medium capitalize text-dark dark:text-white"
|
||||
className="block text-body-sm font-semibold capitalize text-dark dark:text-white"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 select-none text-error">*</span>}
|
||||
@@ -108,9 +108,10 @@ export function Select<T extends FieldValues>({
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
className={cn(
|
||||
"w-full appearance-none rounded-lg border border-stroke bg-transparent px-5.5 py-3 outline-none transition focus:border-primary active:border-primary dark:border-dark-3 dark:bg-dark-2 dark:focus:border-primary [&>option]:text-dark-5 dark:[&>option]:text-dark-6",
|
||||
"w-full appearance-none rounded-xl border border-stroke/70 bg-white/90 px-5.5 py-3 shadow-[0_1px_2px_rgba(16,24,40,0.04),0_8px_20px_rgba(16,24,40,0.04)] outline-none backdrop-blur-[1px] transition-all duration-200 focus:border-primary/70 focus:bg-white focus:shadow-[0_0_0_2px_rgba(59,130,246,0.14),0_10px_24px_rgba(59,130,246,0.08)] active:border-primary/70 dark:border-dark-3/80 dark:bg-dark-2/85 dark:focus:border-primary/80 dark:focus:shadow-[0_0_0_2px_rgba(59,130,246,0.2),0_10px_24px_rgba(15,23,42,0.45)] [&>option]:text-dark-5 dark:[&>option]:text-dark-6",
|
||||
isOptionSelected && "text-dark dark:text-white",
|
||||
error && "border-red focus:border-red dark:border-red",
|
||||
error &&
|
||||
"border-red/80 focus:border-red focus:shadow-[0_0_0_2px_rgba(239,68,68,0.16),0_10px_24px_rgba(239,68,68,0.1)] dark:border-red/80 dark:focus:shadow-[0_0_0_2px_rgba(239,68,68,0.24),0_10px_24px_rgba(127,29,29,0.45)]",
|
||||
prefixIcon && "pl-11.5",
|
||||
clearable && value && "pr-20",
|
||||
)}
|
||||
@@ -132,7 +133,7 @@ export function Select<T extends FieldValues>({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute right-10 top-1/2 -translate-y-1/2 text-dark-5 hover:text-dark dark:text-dark-6 dark:hover:text-white"
|
||||
className="absolute right-10 top-1/2 -translate-y-1/2 text-dark-5 transition hover:scale-110 hover:text-dark dark:text-dark-6 dark:hover:text-white"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
@@ -156,7 +157,9 @@ export function Select<T extends FieldValues>({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-red mt-1.5 text-sm">{error.message as string}</p>
|
||||
<p className="text-red mt-1.5 text-sm font-medium">
|
||||
{error.message as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ function MainLayout({ children }: PropsWithChildren) {
|
||||
{isMobile && <div className="w-[70px] shrink-0" />}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-gray-2 dark:bg-[#020d1a]">
|
||||
<Header />
|
||||
<main className="isolate flex min-h-0 flex-1 flex-col mx-auto w-full max-w-screen-2xl overflow-hidden p-4 md:p-6 2xl:px-10 2xl:py-4">
|
||||
<main className="mx-auto isolate flex min-h-0 w-full max-w-screen-2xl flex-1 flex-col overflow-hidden px-3 py-4 md:px-4 md:py-6 2xl:px-6 2xl:py-4">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -6,9 +6,9 @@ import { UserInfo } from "./user-info";
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-30 flex shrink-0 items-center justify-between border-b border-stroke bg-white px-4 py-2.5 shadow-1 dark:border-stroke-dark dark:bg-gray-dark md:px-5 2xl:px-10">
|
||||
<header className="sticky top-0 z-30 flex shrink-0 items-center justify-between border-b border-stroke/70 bg-white/90 px-4 py-2.5 backdrop-blur-md dark:border-dark-3 dark:bg-gray-dark/85 md:px-5 2xl:px-10">
|
||||
<div className="max-xl:hidden">
|
||||
<h1 className="text-heading-6 font-bold leading-tight text-dark dark:text-white">
|
||||
<h1 className="text-heading-6 font-semibold leading-tight text-dark dark:text-white">
|
||||
Opportunity Lens
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -49,7 +49,7 @@ export function Notification() {
|
||||
}}
|
||||
>
|
||||
<DropdownTrigger
|
||||
className="grid size-12 place-items-center rounded-full border bg-gray-2 text-dark outline-none hover:text-primary focus-visible:border-primary focus-visible:text-primary dark:border-dark-4 dark:bg-dark-3 dark:text-white dark:focus-visible:border-primary"
|
||||
className="grid size-12 place-items-center rounded-full border border-stroke/70 bg-white/75 text-dark shadow-theme-xs backdrop-blur-md outline-none transition-colors hover:text-primary focus-visible:border-primary focus-visible:text-primary dark:border-dark-3 dark:bg-dark-2/75 dark:text-white dark:focus-visible:border-primary"
|
||||
aria-label="View Notifications"
|
||||
>
|
||||
<span className="relative">
|
||||
@@ -68,13 +68,13 @@ export function Notification() {
|
||||
|
||||
<DropdownContent
|
||||
align={isMobile ? "end" : "center"}
|
||||
className="w-[15rem] max-w-[calc(100vw-1rem)] min-[360px]:w-[16rem] min-[390px]:w-[17rem] min-[430px]:w-[18rem] sm:w-[22rem] border border-stroke bg-white px-2.5 py-3 shadow-md sm:px-3.5 dark:border-dark-3 dark:bg-gray-dark"
|
||||
className="w-[15rem] max-w-[calc(100vw-1rem)] min-[360px]:w-[16rem] min-[390px]:w-[17rem] min-[430px]:w-[18rem] rounded-2xl border border-stroke/70 bg-white/95 px-2.5 py-3 shadow-theme-xs backdrop-blur-md sm:w-[22rem] sm:px-3.5 dark:border-dark-3 dark:bg-gray-dark/95"
|
||||
>
|
||||
<div className="mb-1 flex flex-wrap items-center justify-between gap-2 px-1 py-1.5 sm:px-2">
|
||||
<span className="text-base font-medium text-dark sm:text-lg dark:text-white">
|
||||
<span className="text-base font-semibold text-dark sm:text-lg dark:text-white">
|
||||
Notifications
|
||||
</span>
|
||||
<span className="shrink-0 rounded-md bg-primary px-[9px] py-0.5 text-xs font-medium text-white">
|
||||
<span className="shrink-0 rounded-full border border-white/35 bg-gradient-to-br from-primary/85 to-primary/70 px-[9px] py-0.5 text-xs font-semibold text-white shadow-theme-xs dark:border-white/20">
|
||||
{unreadNotificationCount} new
|
||||
</span>
|
||||
</div>
|
||||
@@ -87,7 +87,7 @@ export function Notification() {
|
||||
<Link
|
||||
href="/notification-history"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="block rounded-lg border border-primary p-2 text-center text-sm font-medium tracking-wide text-primary outline-none transition-colors hover:bg-blue-light-5 focus:bg-blue-light-5 focus:text-primary focus-visible:border-primary dark:border-dark-3 dark:text-dark-6 dark:hover:border-dark-5 dark:hover:bg-dark-3 dark:hover:text-dark-7 dark:focus-visible:border-dark-5 dark:focus-visible:bg-dark-3 dark:focus-visible:text-dark-7"
|
||||
className="block rounded-xl border border-primary/40 bg-white/70 p-2 text-center text-sm font-semibold tracking-wide text-primary outline-none transition-colors hover:bg-primary/10 focus:bg-primary/10 focus:text-primary focus-visible:border-primary dark:border-dark-3 dark:bg-dark-2/70 dark:text-dark-6 dark:hover:border-dark-5 dark:hover:bg-dark-3 dark:hover:text-dark-7 dark:focus-visible:border-dark-5 dark:focus-visible:bg-dark-3 dark:focus-visible:text-dark-7"
|
||||
>
|
||||
See all notifications
|
||||
</Link>
|
||||
|
||||
@@ -17,38 +17,87 @@ const THEMES = [
|
||||
export function ThemeToggleSwitch() {
|
||||
const { setTheme, theme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const isDarkTheme = theme === "dark";
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTransitioning) return;
|
||||
|
||||
const transitionTimer = window.setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
}, 650);
|
||||
|
||||
return () => window.clearTimeout(transitionTimer);
|
||||
}, [isTransitioning]);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
|
||||
className="group rounded-full bg-gray-3 p-[5px] text-[#111928] outline-1 outline-primary focus-visible:outline dark:bg-[#020D1A] dark:text-current"
|
||||
onClick={() => {
|
||||
setIsTransitioning(true);
|
||||
setTheme(isDarkTheme ? "light" : "dark");
|
||||
}}
|
||||
className="group relative overflow-hidden rounded-full border border-stroke/70 bg-white/75 p-[5px] text-[#111928] shadow-theme-xs backdrop-blur-md outline-1 outline-primary transition-[color,border-color,background-color,box-shadow] duration-500 ease-out focus-visible:outline dark:border-dark-3 dark:bg-[#020D1A]/75 dark:text-current"
|
||||
type="button"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Switch to {theme === "light" ? "dark" : "light"} mode
|
||||
Switch to {isDarkTheme ? "light" : "dark"} mode
|
||||
</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 rounded-full bg-[radial-gradient(circle_at_20%_20%,rgba(99,102,241,0.24),transparent_50%),radial-gradient(circle_at_80%_80%,rgba(20,184,166,0.2),transparent_45%)] opacity-0 transition-opacity duration-500",
|
||||
isTransitioning && "opacity-100",
|
||||
isDarkTheme &&
|
||||
"bg-[radial-gradient(circle_at_20%_20%,rgba(59,130,246,0.2),transparent_50%),radial-gradient(circle_at_80%_80%,rgba(129,140,248,0.25),transparent_45%)]",
|
||||
)}
|
||||
/>
|
||||
|
||||
<span aria-hidden className="relative flex gap-2.5">
|
||||
{/* Indicator */}
|
||||
<span className="absolute size-[38px] rounded-full border border-gray-200 bg-white transition-all dark:translate-x-[48px] dark:border-none dark:bg-dark-2 dark:group-hover:bg-dark-3" />
|
||||
<span
|
||||
className={cn(
|
||||
"absolute size-[38px] rounded-full border border-gray-200 bg-white transition-[transform,background-color,border-color,box-shadow] duration-500 ease-[cubic-bezier(0.2,0.8,0.2,1)] motion-reduce:transition-none",
|
||||
"shadow-[0_8px_24px_rgba(15,23,42,0.1)] dark:border-dark-3 dark:bg-dark-2 dark:group-hover:bg-dark-3",
|
||||
isDarkTheme
|
||||
? "translate-x-[48px] shadow-[0_0_0_1px_rgba(99,102,241,0.25),0_10px_24px_rgba(30,41,59,0.45)]"
|
||||
: "translate-x-0",
|
||||
isTransitioning && "scale-[1.06]",
|
||||
)}
|
||||
/>
|
||||
|
||||
{THEMES.map(({ name, Icon }) => (
|
||||
<span
|
||||
key={name}
|
||||
className={cn(
|
||||
"relative grid size-[38px] place-items-center rounded-full",
|
||||
"relative grid size-[38px] place-items-center rounded-full transition-transform duration-500 ease-out motion-reduce:transition-none",
|
||||
name === "dark" && "dark:text-white",
|
||||
name === "light"
|
||||
? isDarkTheme
|
||||
? "scale-75 -rotate-45 opacity-60"
|
||||
: "scale-100 rotate-0 opacity-100"
|
||||
: isDarkTheme
|
||||
? "scale-100 rotate-0 opacity-100"
|
||||
: "scale-75 rotate-45 opacity-60",
|
||||
)}
|
||||
>
|
||||
<Icon />
|
||||
<Icon
|
||||
className={cn(
|
||||
"transition-all duration-500 ease-out motion-reduce:transition-none",
|
||||
name === "light"
|
||||
? "text-amber-500 drop-shadow-[0_0_10px_rgba(245,158,11,0.35)]"
|
||||
: "text-indigo-300 drop-shadow-[0_0_10px_rgba(129,140,248,0.35)]",
|
||||
isTransitioning && "animate-[spin_700ms_cubic-bezier(0.2,0.8,0.2,1)]",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
|
||||
@@ -12,19 +12,52 @@ import { truncateString } from "@/utils/shared";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { LogOutIcon, SettingsIcon, UserIcon } from "./icons";
|
||||
|
||||
export function UserInfo() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const { logout, user } = useUser();
|
||||
|
||||
const spotlightStyle = {
|
||||
"--spotlight-x": `${mousePosition.x}px`,
|
||||
"--spotlight-y": `${mousePosition.y}px`,
|
||||
background:
|
||||
"radial-gradient(130px circle at var(--spotlight-x) var(--spotlight-y), rgba(255, 255, 255, 0.95), transparent 70%)",
|
||||
WebkitMask:
|
||||
"linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
|
||||
WebkitMaskComposite: "xor",
|
||||
maskComposite: "exclude",
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<Dropdown isOpen={isOpen} setIsOpen={setIsOpen}>
|
||||
<DropdownTrigger className="rounded align-middle outline-none ring-primary ring-offset-2 focus-visible:ring-1 dark:ring-offset-gray-dark">
|
||||
<DropdownTrigger
|
||||
className="relative isolate overflow-hidden rounded-xl border border-stroke/70 bg-white/70 px-2.5 py-1.5 align-middle shadow-theme-xs backdrop-blur-md outline-none ring-primary ring-offset-2 transition-colors focus-visible:ring-1 dark:border-dark-3 dark:bg-dark-2/70 dark:ring-offset-gray-dark"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onMouseMove={(event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setMousePosition({
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-0 rounded-[inherit] p-px transition-opacity duration-300"
|
||||
style={{
|
||||
...spotlightStyle,
|
||||
opacity: isHovered ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
<span className="sr-only">My Account</span>
|
||||
|
||||
<figure className="flex items-center gap-3">
|
||||
<div className="rounded-full bg-gray-3 p-3.5 dark:bg-dark-3">
|
||||
<figure className="relative z-10 flex items-center gap-3">
|
||||
<div className="rounded-full border border-stroke/70 bg-gray-3/80 p-3.5 dark:border-dark-3 dark:bg-dark-3">
|
||||
<UserIcon />
|
||||
</div>
|
||||
|
||||
@@ -44,7 +77,7 @@ export function UserInfo() {
|
||||
</DropdownTrigger>
|
||||
|
||||
<DropdownContent
|
||||
className="border border-stroke bg-white shadow-md dark:border-dark-3 dark:bg-gray-dark min-[230px]:min-w-[17.5rem]"
|
||||
className="min-[230px]:min-w-[17.5rem] rounded-2xl border border-stroke/70 bg-white/95 shadow-theme-xs backdrop-blur-md dark:border-dark-3 dark:bg-gray-dark/95"
|
||||
align="end"
|
||||
>
|
||||
<h2 className="sr-only">User information</h2>
|
||||
@@ -53,7 +86,7 @@ export function UserInfo() {
|
||||
{user?.profile_image ? (
|
||||
<Image
|
||||
src={user.profile_image}
|
||||
className="size-12"
|
||||
className="size-12 rounded-full border border-stroke/70 dark:border-dark-3"
|
||||
unoptimized
|
||||
alt={`Avatar for ${user.full_name}`}
|
||||
role="presentation"
|
||||
@@ -61,7 +94,7 @@ export function UserInfo() {
|
||||
height={200}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-full bg-gray-3 p-4 dark:bg-gray-7">
|
||||
<div className="rounded-full border border-stroke/70 bg-gray-3 p-4 dark:border-dark-3 dark:bg-gray-7">
|
||||
<UserIcon width={30} height={30} />
|
||||
</div>
|
||||
)}
|
||||
@@ -83,7 +116,7 @@ export function UserInfo() {
|
||||
<Link
|
||||
href={"/profile"}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-[9px] hover:bg-gray-2 hover:text-dark dark:hover:bg-dark-3 dark:hover:text-white"
|
||||
className="flex w-full items-center gap-2.5 rounded-xl px-2.5 py-[9px] transition-colors hover:bg-gray-2/80 hover:text-dark dark:hover:bg-dark-3 dark:hover:text-white"
|
||||
>
|
||||
<UserIcon />
|
||||
|
||||
@@ -93,7 +126,7 @@ export function UserInfo() {
|
||||
<Link
|
||||
href={"/pages/settings"}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-[9px] hover:bg-gray-2 hover:text-dark dark:hover:bg-dark-3 dark:hover:text-white"
|
||||
className="flex w-full items-center gap-2.5 rounded-xl px-2.5 py-[9px] transition-colors hover:bg-gray-2/80 hover:text-dark dark:hover:bg-dark-3 dark:hover:text-white"
|
||||
>
|
||||
<SettingsIcon />
|
||||
|
||||
@@ -107,7 +140,7 @@ export function UserInfo() {
|
||||
|
||||
<div className="p-2 text-base text-[#4B5563] dark:text-dark-6">
|
||||
<button
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-[9px] hover:bg-gray-2 hover:text-dark dark:hover:bg-dark-3 dark:hover:text-white"
|
||||
className="flex w-full items-center gap-2.5 rounded-xl px-2.5 py-[9px] transition-colors hover:bg-gray-2/80 hover:text-dark dark:hover:bg-dark-3 dark:hover:text-white"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
logout();
|
||||
|
||||
@@ -9,9 +9,11 @@ type PropsType = {
|
||||
|
||||
export function ShowcaseSection({ title, children, className }: PropsType) {
|
||||
return (
|
||||
<div className="rounded-[10px] bg-white shadow-1 dark:bg-gray-dark dark:shadow-card">
|
||||
<div className="relative overflow-hidden rounded-2xl border border-stroke/70 bg-gradient-to-br from-white via-white to-primary/[0.03] shadow-theme-xs dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-primary/[0.08]">
|
||||
<div className="pointer-events-none absolute -right-12 -top-12 h-32 w-32 rounded-full bg-primary/10 blur-3xl dark:bg-primary/20" />
|
||||
<div className="pointer-events-none absolute -bottom-12 -left-12 h-32 w-32 rounded-full bg-purple-500/10 blur-3xl dark:bg-purple-500/20" />
|
||||
{title && (
|
||||
<h2 className="border-b border-stroke px-4 py-4 font-medium text-dark dark:border-dark-3 dark:text-white sm:px-6 xl:px-7.5">
|
||||
<h2 className="border-b border-stroke/70 bg-white/70 px-4 py-4 text-base font-semibold text-dark backdrop-blur-sm dark:border-dark-3 dark:bg-dark-2/40 dark:text-white sm:px-6 xl:px-7.5">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
@@ -59,7 +59,7 @@ export function Sidebar() {
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"shrink-0 overflow-hidden border-r border-gray-200 bg-white transition-[width] duration-200 ease-linear dark:border-gray-800 dark:bg-gray-dark",
|
||||
"shrink-0 overflow-hidden border-r border-stroke/70 bg-gradient-to-b from-white via-white to-primary/[0.03] shadow-[8px_0_30px_-24px_rgba(16,24,40,0.35)] transition-[width] duration-200 ease-linear dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-primary/[0.08]",
|
||||
isMobile
|
||||
? cn("fixed bottom-0 top-0", isOpen ? "z-50" : "z-20")
|
||||
: "sticky top-0 h-screen",
|
||||
@@ -73,15 +73,17 @@ export function Sidebar() {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col py-10",
|
||||
"relative flex h-full flex-col py-10",
|
||||
isCollapsed ? "items-center px-2" : "pl-[25px] pr-[7px]",
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-none absolute -left-16 top-16 h-36 w-36 rounded-full bg-primary/10 blur-3xl dark:bg-primary/20" />
|
||||
<div className="pointer-events-none absolute -right-16 bottom-10 h-36 w-36 rounded-full bg-purple-500/10 blur-3xl dark:bg-purple-500/20" />
|
||||
<div className={cn("relative", isCollapsed ? "px-0" : "pr-4.5")}>
|
||||
<Link
|
||||
href={"/"}
|
||||
onClick={() => isMobile && isOpen && toggleSidebar()}
|
||||
className="px-0 py-2.5 min-[850px]:py-0"
|
||||
className="inline-flex rounded-xl px-1 py-2.5 transition hover:bg-gray-2/70 min-[850px]:py-0 dark:hover:bg-white/[0.06]"
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<Image
|
||||
@@ -98,7 +100,7 @@ export function Sidebar() {
|
||||
{isMobile && isOpen && (
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="absolute left-3/4 right-4.5 top-1/2 -translate-y-1/2 text-right"
|
||||
className="absolute left-3/4 right-4.5 top-1/2 -translate-y-1/2 rounded-lg p-1 text-right text-dark-4 transition hover:bg-gray-2/70 hover:text-dark dark:text-dark-6 dark:hover:bg-white/[0.08] dark:hover:text-white"
|
||||
>
|
||||
<span className="sr-only">Close Menu</span>
|
||||
|
||||
@@ -116,7 +118,7 @@ export function Sidebar() {
|
||||
{NAV_DATA.map((section) => (
|
||||
<div key={section.label} className="mb-6">
|
||||
{!isCollapsed && (
|
||||
<h2 className="mb-5 text-sm font-medium text-dark-4 dark:text-dark-6">
|
||||
<h2 className="mb-4 px-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-dark-5 dark:text-dark-6">
|
||||
{section.label}
|
||||
</h2>
|
||||
)}
|
||||
@@ -241,7 +243,7 @@ export function Sidebar() {
|
||||
{isCollapsed && (
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="mt-4 rounded-lg p-2 text-dark-4 transition-colors hover:bg-gray-100 dark:text-dark-6 dark:hover:bg-[#FFFFFF1A]"
|
||||
className="mt-4 rounded-xl border border-stroke/70 bg-white/80 p-2 text-dark-4 shadow-theme-xs transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:text-primary dark:border-dark-3 dark:bg-dark-2 dark:text-dark-6 dark:hover:bg-white/[0.08] dark:hover:text-white"
|
||||
aria-label="Expand sidebar"
|
||||
>
|
||||
<MenuExpandIcon className="size-6" />
|
||||
|
||||
@@ -4,16 +4,16 @@ import Link from "next/link";
|
||||
import { useSidebarContext } from "./sidebar-context";
|
||||
|
||||
const menuItemBaseStyles = cva(
|
||||
"rounded-lg px-3.5 font-medium text-dark-4 transition-all duration-200 dark:text-dark-6",
|
||||
"rounded-xl border border-transparent px-3.5 font-semibold text-dark-4 shadow-[inset_0_0_0_1px_transparent] transition-all duration-200 dark:text-dark-6",
|
||||
{
|
||||
variants: {
|
||||
isActive: {
|
||||
true: "bg-[rgba(87,80,241,0.07)] text-primary hover:bg-[rgba(87,80,241,0.07)] dark:bg-[#FFFFFF1A] dark:text-white",
|
||||
true: "border-primary/20 bg-gradient-to-r from-primary/[0.14] to-primary/[0.05] text-primary shadow-theme-xs hover:from-primary/[0.16] hover:to-primary/[0.08] dark:border-primary/30 dark:from-primary/[0.28] dark:to-primary/[0.12] dark:text-white",
|
||||
false:
|
||||
"hover:bg-gray-100 hover:text-dark hover:dark:bg-[#FFFFFF1A] hover:dark:text-white",
|
||||
"hover:-translate-y-0.5 hover:border-stroke/80 hover:bg-white/80 hover:text-dark hover:shadow-theme-xs dark:hover:border-dark-3 dark:hover:bg-white/[0.08] dark:hover:text-white",
|
||||
},
|
||||
isCollapsed: {
|
||||
true: "px-2 justify-center",
|
||||
true: "justify-center px-2",
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
@@ -45,7 +45,7 @@ export function MenuItem(
|
||||
menuItemBaseStyles({
|
||||
isActive: props.isActive,
|
||||
isCollapsed: props.isCollapsed,
|
||||
className: "relative block py-2",
|
||||
className: "relative block py-2.5",
|
||||
}),
|
||||
props.className,
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PencilSquareIcon, TrashIcon, UserSettingIcon } from "@/assets/icons";
|
||||
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
||||
import ListHeader from "@/components/ui/ListHeader";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
import Modal from "@/components/ui/modal";
|
||||
import Status from "@/components/ui/Status";
|
||||
import {
|
||||
@@ -50,7 +51,7 @@ const AdminsTable = () => {
|
||||
} = useAdminsPresenter();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 shadow-1 dark:bg-gray-dark dark:shadow-card">
|
||||
<ListWrapper>
|
||||
<ListHeader
|
||||
onFilter={() => setIsFilterModalOpen(true)}
|
||||
createButtonText="Create Admin"
|
||||
@@ -188,7 +189,7 @@ const AdminsTable = () => {
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ListWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -88,7 +88,6 @@ export const useAdminsPresenter = () => {
|
||||
"row",
|
||||
"full name",
|
||||
"email",
|
||||
|
||||
"username",
|
||||
"status",
|
||||
"actions",
|
||||
|
||||
@@ -18,6 +18,7 @@ import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import CmsListFilters from "./CmsListFilters";
|
||||
import useCmsTablePresenter from "./useCmsTablePresenter";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
|
||||
const CmsTable = () => {
|
||||
const {
|
||||
@@ -41,7 +42,7 @@ const CmsTable = () => {
|
||||
isMutating,
|
||||
} = useCmsTablePresenter();
|
||||
return (
|
||||
<div className="flex flex-col rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 shadow-1 dark:bg-gray-dark dark:shadow-card">
|
||||
<ListWrapper>
|
||||
<ListHeader onFilter={() => setIsFilterModalOpen(true)} />
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -143,7 +144,7 @@ const CmsTable = () => {
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ListWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Switch } from "@/components/FormElements/switch";
|
||||
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
||||
import ListHeader from "@/components/ui/ListHeader";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
import Modal from "@/components/ui/modal";
|
||||
import {
|
||||
Table,
|
||||
@@ -46,7 +47,7 @@ const CompanyCategoriesTable = () => {
|
||||
} = useCompanyCategoriesPresenter();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 shadow-1 dark:bg-gray-dark dark:shadow-card">
|
||||
<ListWrapper>
|
||||
<ListHeader
|
||||
onFilter={() => setIsFilterModalOpen(true)}
|
||||
createButtonText="Create Category"
|
||||
@@ -166,7 +167,7 @@ const CompanyCategoriesTable = () => {
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ListWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
} from "@/assets/icons";
|
||||
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
||||
import ListHeader from "@/components/ui/ListHeader";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
import Modal from "@/components/ui/modal";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -19,10 +22,8 @@ import TableSkeleton from "@/components/ui/TableSkeleton";
|
||||
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
||||
import { formatPhoneNumber, getPaginatedRowNumber } from "@/utils/shared";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import { useCompanyListPresenter } from "./useCompanyListPresenter";
|
||||
import ListHeader from "@/components/ui/ListHeader";
|
||||
import Modal from "@/components/ui/modal";
|
||||
import CompanyListFilters from "./CompanyListFilters";
|
||||
import { useCompanyListPresenter } from "./useCompanyListPresenter";
|
||||
|
||||
const CompaniesTable = () => {
|
||||
const {
|
||||
@@ -49,7 +50,7 @@ const CompaniesTable = () => {
|
||||
(c): c is NonNullable<typeof c> => c != null,
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 shadow-1 dark:bg-gray-dark dark:shadow-card">
|
||||
<ListWrapper>
|
||||
<ListHeader
|
||||
onFilter={() => setIsFilterModalOpen(true)}
|
||||
createButtonText="Create Company"
|
||||
@@ -197,7 +198,7 @@ const CompaniesTable = () => {
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ListWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { TrashIcon } from "@/assets/icons";
|
||||
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -19,8 +20,6 @@ import { useContactUsListPresenter } from "./useContactUsListPresenter";
|
||||
const ContactUsTable = () => {
|
||||
const {
|
||||
columns,
|
||||
pathname,
|
||||
router,
|
||||
isPending,
|
||||
contacts,
|
||||
onConfirmDelete,
|
||||
@@ -30,7 +29,7 @@ const ContactUsTable = () => {
|
||||
currentContact,
|
||||
} = useContactUsListPresenter();
|
||||
return (
|
||||
<div className="flex flex-col rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 shadow-1 dark:bg-gray-dark dark:shadow-card">
|
||||
<ListWrapper>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -113,7 +112,7 @@ const ContactUsTable = () => {
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ListWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Tooltip } from "react-tooltip";
|
||||
import AssignToCompanyModalContent from "./AssignToCompanyModalContent";
|
||||
import CustomerListFilters from "./CustomerListFilters";
|
||||
import useCustomerListPresenter from "./useCustomerListPresenter";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
|
||||
const CustomersTable = () => {
|
||||
const {
|
||||
@@ -57,7 +58,7 @@ const CustomersTable = () => {
|
||||
} = useCustomerListPresenter();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 shadow-1 dark:bg-gray-dark dark:shadow-card">
|
||||
<ListWrapper>
|
||||
<ListHeader
|
||||
onFilter={() => setIsFilterModalOpen(true)}
|
||||
createButtonText="Create Customer"
|
||||
@@ -251,7 +252,7 @@ const CustomersTable = () => {
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ListWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
||||
import IsVisible from "@/components/ui/IsVisible";
|
||||
import ListHeader from "@/components/ui/ListHeader";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
import Modal from "@/components/ui/modal";
|
||||
import Pagination from "@/components/ui/pagination";
|
||||
import Status from "@/components/ui/Status";
|
||||
@@ -17,7 +18,12 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import TableSkeleton from "@/components/ui/TableSkeleton";
|
||||
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
||||
import { formatPhoneNumber, getPaginatedRowNumber, unixToDate } from "@/utils/shared";
|
||||
import { TInquiries } from "@/lib/api";
|
||||
import {
|
||||
formatPhoneNumber,
|
||||
getPaginatedRowNumber,
|
||||
unixToDate,
|
||||
} from "@/utils/shared";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import ChangeStatusForm from "./ChangeStatusForm";
|
||||
import InquiriesListFilters from "./InquiriesListFilters";
|
||||
@@ -50,7 +56,7 @@ const InquiriesTable = () => {
|
||||
} = useInquiriesListPresenter();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 shadow-1 dark:bg-gray-dark dark:shadow-card">
|
||||
<ListWrapper>
|
||||
<ListHeader
|
||||
onFilter={() => setIsFilterModalOpen(true)}
|
||||
hasCreate={false}
|
||||
@@ -58,7 +64,7 @@ const InquiriesTable = () => {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-none uppercase [&>th]:text-start">
|
||||
{columns.map((column) => (
|
||||
{columns.map((column: string) => (
|
||||
<TableHead key={column} colSpan={100}>
|
||||
{column}
|
||||
</TableHead>
|
||||
@@ -73,7 +79,7 @@ const InquiriesTable = () => {
|
||||
emptyMessage="No Inquiries were found"
|
||||
isLoading={isPending}
|
||||
>
|
||||
{inquiries?.map((inquiry, index) => (
|
||||
{inquiries?.map((inquiry: TInquiries, index: number) => (
|
||||
<TableRow key={inquiry?.id ?? index}>
|
||||
<TableCell className="text-start" colSpan={100}>
|
||||
{getPaginatedRowNumber({
|
||||
@@ -184,7 +190,6 @@ const InquiriesTable = () => {
|
||||
onSubmit={handleChangeStatus}
|
||||
onCancel={() => setIsChangeStatusModalOpen(false)}
|
||||
currentStatus={currentInquiry?.status ?? "pending"}
|
||||
|
||||
/>
|
||||
</Modal>
|
||||
{isDeleteModalOpen && (
|
||||
@@ -202,7 +207,7 @@ const InquiriesTable = () => {
|
||||
onCancel={() => setIsDeleteModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ListWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
import { cn } from "@/lib/utils";
|
||||
import dayjs from "dayjs";
|
||||
import { getInvoiceTableData } from "./fetch";
|
||||
@@ -16,7 +17,7 @@ export async function InvoiceTable() {
|
||||
const data = await getInvoiceTableData();
|
||||
|
||||
return (
|
||||
<div className="rounded-[10px] border border-stroke bg-white p-4 shadow-1 dark:border-dark-3 dark:bg-gray-dark dark:shadow-card sm:p-7.5">
|
||||
<ListWrapper>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-none bg-[#F7F9FC] dark:bg-dark-2 [&>th]:py-4 [&>th]:text-base [&>th]:text-dark [&>th]:dark:text-white">
|
||||
@@ -83,6 +84,6 @@ export async function InvoiceTable() {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</ListWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
import { EyeIcon } from "@/assets/icons";
|
||||
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||
import IsVisible from "@/components/ui/IsVisible";
|
||||
import Pagination from "@/components/ui/pagination";
|
||||
import Status from "@/components/ui/Status";
|
||||
@@ -15,11 +14,16 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import TableSkeleton from "@/components/ui/TableSkeleton";
|
||||
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
||||
import { getPaginatedRowNumber, truncateString, unixToDate } from "@/utils/shared";
|
||||
import {
|
||||
getPaginatedRowNumber,
|
||||
truncateString,
|
||||
unixToDate,
|
||||
} from "@/utils/shared";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
|
||||
import Boolean from "@/components/ui/Boolean";
|
||||
import ListHeader from "@/components/ui/ListHeader";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
import Modal from "@/components/ui/modal";
|
||||
import NotificationHistoryListFilters from "./NotificationHistoryListFilters";
|
||||
import useNotificationHistoryTablePresenter from "./useNotificationHistoryTablePresenter";
|
||||
@@ -46,7 +50,7 @@ const NotificationHistoryTable = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ShowcaseSection className="flex flex-col">
|
||||
<ListWrapper>
|
||||
<ListHeader
|
||||
createButtonText="Create notification"
|
||||
onFilter={() => setIsFilterModalOpen(true)}
|
||||
@@ -55,56 +59,57 @@ const NotificationHistoryTable = () => {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableHead colSpan={100} key={column}>
|
||||
<TableHead key={column}>
|
||||
{column}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
{isLoading && <TableSkeleton column={columns.length} />}
|
||||
{isLoading && (
|
||||
<TableSkeleton column={columns.length} cellColSpan={1} />
|
||||
)}
|
||||
<TableBody>
|
||||
<EmptyListWrapper
|
||||
list={notificationHistory ?? []}
|
||||
columns={columns}
|
||||
emptyMessage="No notifications were found"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{(notificationHistory ?? []).map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell colSpan={100}>
|
||||
<TableCell>
|
||||
{getPaginatedRowNumber({
|
||||
index,
|
||||
page: metadata?.page,
|
||||
limit: metadata?.limit,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell colSpan={100}>{item.title}</TableCell>
|
||||
<TableCell colSpan={100}>{item.event_type}</TableCell>
|
||||
<TableCell colSpan={100}>
|
||||
<TableCell>{item.title}</TableCell>
|
||||
<TableCell>{item.event_type}</TableCell>
|
||||
<TableCell>
|
||||
{unixToDate({
|
||||
hasTime: true,
|
||||
unix: item.created_at,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell colSpan={100}>
|
||||
<TableCell>
|
||||
{item.recipient?.full_name}
|
||||
</TableCell>
|
||||
<TableCell colSpan={100} className="truncate">
|
||||
<TableCell className="truncate">
|
||||
{truncateString(item.message, 20, 20)}
|
||||
</TableCell>
|
||||
<TableCell className="capitalize" colSpan={100}>
|
||||
<TableCell className="capitalize">
|
||||
{item.priority}
|
||||
</TableCell>
|
||||
<TableCell className="capitalize" colSpan={100}>
|
||||
<TableCell className="capitalize">
|
||||
{item.type}
|
||||
</TableCell>
|
||||
<TableCell className="capitalize" colSpan={100}>
|
||||
<TableCell className="capitalize">
|
||||
<Boolean value={item.seen} />
|
||||
</TableCell>
|
||||
<TableCell colSpan={100}>
|
||||
<TableCell>
|
||||
<Status status={item.status}>{item.status}</Status>
|
||||
</TableCell>
|
||||
<TableCell colSpan={100}>
|
||||
<TableCell>
|
||||
<button
|
||||
data-tooltip-id="details"
|
||||
data-tooltip-content="Details"
|
||||
@@ -131,7 +136,7 @@ const NotificationHistoryTable = () => {
|
||||
currentPage={metadata?.page ? metadata?.page - 1 : 0}
|
||||
totalPages={metadata?.pages ?? 1}
|
||||
onPageChange={(e) => {
|
||||
setParams((currentParams) => ({
|
||||
setParams((currentParams: Record<string, any>) => ({
|
||||
...currentParams,
|
||||
offset: e.selected * (metadata?.limit ?? 10),
|
||||
limit: metadata?.limit ?? 10,
|
||||
@@ -139,7 +144,7 @@ const NotificationHistoryTable = () => {
|
||||
}}
|
||||
/>
|
||||
</IsVisible>
|
||||
</ShowcaseSection>
|
||||
</ListWrapper>
|
||||
<Modal
|
||||
isOpen={isFilterModalOpen}
|
||||
onClose={() => setIsFilterModalOpen(false)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import InputGroup from "@/components/FormElements/InputGroup";
|
||||
import { Select } from "@/components/FormElements/select";
|
||||
import { Button } from "@/components/ui-elements/button";
|
||||
import { Countries } from "@/constants/countries";
|
||||
import { TenderStatus } from "@/constants/enums";
|
||||
import { getEnumAsArray } from "@/utils/shared";
|
||||
@@ -63,24 +64,29 @@ const TenderListFilters = ({
|
||||
onClear={() => setValue("country_code", undefined)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-4 col-span-2">
|
||||
<button
|
||||
<div className="col-span-2 flex gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
||||
>
|
||||
{isMutating ? (
|
||||
size="small"
|
||||
shape="rounded"
|
||||
className="mt-6 w-fit capitalize"
|
||||
label={
|
||||
isMutating ? (
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
|
||||
) : (
|
||||
<span>Search</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
||||
variant="error"
|
||||
size="small"
|
||||
shape="rounded"
|
||||
className="mt-6 w-fit capitalize"
|
||||
onClick={() => setIsFilterModalOpen(false)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
label="Close"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ExclamationIcon, EyeIcon } from "@/assets/icons";
|
||||
|
||||
import IsVisible from "@/components/ui/IsVisible";
|
||||
import ListHeader from "@/components/ui/ListHeader";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
import Modal from "@/components/ui/modal";
|
||||
import Pagination from "@/components/ui/pagination";
|
||||
import TableSkeleton from "@/components/ui/TableSkeleton";
|
||||
@@ -45,7 +46,7 @@ const TendersTable = () => {
|
||||
} = useTenderListPresenter();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 shadow-1 dark:bg-gray-dark dark:shadow-card">
|
||||
<ListWrapper>
|
||||
<ListHeader
|
||||
onFilter={() => setIsFilterModalOpen(true)}
|
||||
createButtonText="Create Tender"
|
||||
@@ -166,7 +167,7 @@ const TendersTable = () => {
|
||||
}}
|
||||
/>
|
||||
</IsVisible>
|
||||
</div>
|
||||
</ListWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
import { compactFormat, standardFormat } from "@/lib/format-number";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
@@ -15,12 +16,8 @@ export async function TopChannels({ className }: { className?: string }) {
|
||||
const data = await getTopChannels();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 shadow-1 dark:bg-gray-dark dark:shadow-card",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn("grid", className)}>
|
||||
<ListWrapper>
|
||||
<h2 className="mb-4 text-body-2xlg font-bold text-dark dark:text-white">
|
||||
Top Channels
|
||||
</h2>
|
||||
@@ -67,6 +64,7 @@ export async function TopChannels({ className }: { className?: string }) {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ListWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
|
||||
export function TopChannelsSkeleton() {
|
||||
return (
|
||||
<div className="rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 shadow-1 dark:bg-gray-dark dark:shadow-card">
|
||||
<ListWrapper>
|
||||
<h2 className="mb-5.5 text-body-2xlg font-bold text-dark dark:text-white">
|
||||
Top Channels
|
||||
</h2>
|
||||
@@ -36,6 +37,6 @@ export function TopChannelsSkeleton() {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</ListWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
import Image from "next/image";
|
||||
import { getTopProducts } from "../fetch";
|
||||
|
||||
@@ -13,12 +14,10 @@ export async function TopProducts() {
|
||||
const data = await getTopProducts();
|
||||
|
||||
return (
|
||||
<div className="rounded-[10px] bg-white shadow-1 dark:bg-gray-dark dark:shadow-card">
|
||||
<div className="px-6 py-4 sm:px-7 sm:py-5 xl:px-8.5">
|
||||
<h2 className="text-2xl font-bold text-dark dark:text-white">
|
||||
<ListWrapper>
|
||||
<h2 className="mb-4 text-2xl font-bold text-dark dark:text-white">
|
||||
Top Products
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -66,6 +65,6 @@ export async function TopProducts() {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</ListWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
|
||||
export function TopProductsSkeleton() {
|
||||
return (
|
||||
<div className="rounded-[10px] bg-white shadow-1 dark:bg-gray-dark dark:shadow-card">
|
||||
<h2 className="px-4 py-6 text-2xl font-bold text-dark dark:text-white md:px-6 xl:px-9">
|
||||
<ListWrapper>
|
||||
<h2 className="mb-4 text-2xl font-bold text-dark dark:text-white">
|
||||
Top Products
|
||||
</h2>
|
||||
|
||||
@@ -36,6 +37,6 @@ export function TopProductsSkeleton() {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</ListWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import InputGroup from "@/components/FormElements/InputGroup";
|
||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||
import FileImageUploadCard from "@/components/ui/FileImageUploadCard";
|
||||
import FormFooter from "@/components/ui/FormFooter";
|
||||
import { REGEX } from "@/constants/regex";
|
||||
import { FormErrorMessages } from "@/constants/Texts";
|
||||
@@ -13,7 +14,19 @@ interface IProps {
|
||||
id?: string;
|
||||
}
|
||||
const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
|
||||
const { errors, handleSubmit, register, onSubmit } = useCreateAdminPresenter({
|
||||
const {
|
||||
errors,
|
||||
handleSubmit,
|
||||
register,
|
||||
onSubmit,
|
||||
isUploadingImage,
|
||||
uploadProgress,
|
||||
selectedFileName,
|
||||
selectedFileSize,
|
||||
previewUrl,
|
||||
clearSelectedProfileImage,
|
||||
profileImageRegister,
|
||||
} = useCreateAdminPresenter({
|
||||
editMode,
|
||||
defaultValues,
|
||||
id,
|
||||
@@ -221,20 +234,18 @@ const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<InputGroup
|
||||
type="file"
|
||||
fileStyleVariant="style1"
|
||||
label="Attach file"
|
||||
register={register}
|
||||
name="profile_image"
|
||||
<FileImageUploadCard
|
||||
label="Profile image"
|
||||
inputId="profile-image-upload"
|
||||
fileRegister={profileImageRegister}
|
||||
isLoading={isUploadingImage}
|
||||
uploadProgress={uploadProgress}
|
||||
previewUrl={previewUrl}
|
||||
selectedFileName={selectedFileName}
|
||||
selectedFileSize={selectedFileSize}
|
||||
onClear={clearSelectedProfileImage}
|
||||
errorMessage={errors.profile_image?.message as string | undefined}
|
||||
/>
|
||||
{errors.profile_image && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.profile_image.message as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<FormFooter />
|
||||
|
||||
@@ -1,37 +1,171 @@
|
||||
import { useCreateAdmin, useUpdateAdmin } from "@/hooks/queries";
|
||||
import { ICreateAdminCredentials } from "@/lib/api";
|
||||
import { ICreateAdminCredentials, userService } from "@/lib/api";
|
||||
import { validateImageFile } from "@/utils/shared";
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
interface IProps {
|
||||
editMode?: boolean;
|
||||
defaultValues?: ICreateAdminCredentials;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
type CreateAdminFormValues = Omit<ICreateAdminCredentials, "profile_image"> & {
|
||||
profile_image?: FileList;
|
||||
};
|
||||
|
||||
const useCreateAdminPresenter = ({
|
||||
editMode = false,
|
||||
defaultValues,
|
||||
id,
|
||||
}: IProps) => {
|
||||
const isMutating = useIsMutating();
|
||||
const [selectedFileName, setSelectedFileName] = useState<string>("");
|
||||
const [selectedFileSize, setSelectedFileSize] = useState<number | null>(null);
|
||||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<number>(0);
|
||||
const [uploadedProfileImageUrl, setUploadedProfileImageUrl] = useState<
|
||||
string | undefined
|
||||
>(defaultValues?.profile_image);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(
|
||||
defaultValues?.profile_image ?? null,
|
||||
);
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
handleSubmit,
|
||||
watch,
|
||||
} = useForm<ICreateAdminCredentials>({
|
||||
setError,
|
||||
clearErrors,
|
||||
setValue,
|
||||
} = useForm<CreateAdminFormValues>({
|
||||
mode: "onChange",
|
||||
defaultValues,
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
profile_image: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: createAdmin } = useCreateAdmin();
|
||||
const { mutate: updateAdmin } = useUpdateAdmin(id as string);
|
||||
const onSubmit: SubmitHandler<ICreateAdminCredentials> = (data) => {
|
||||
|
||||
const resetToDefaultProfileImage = () => {
|
||||
setSelectedFileName("");
|
||||
setSelectedFileSize(null);
|
||||
setUploadProgress(0);
|
||||
setUploadedProfileImageUrl(defaultValues?.profile_image);
|
||||
setPreviewUrl(defaultValues?.profile_image ?? null);
|
||||
};
|
||||
|
||||
const validateProfileImage = (file: File): string | null => {
|
||||
return validateImageFile({ file });
|
||||
};
|
||||
|
||||
const prepareUploadState = (file: File) => {
|
||||
clearErrors("profile_image");
|
||||
setSelectedFileName(file.name);
|
||||
setSelectedFileSize(file.size);
|
||||
setIsUploadingImage(true);
|
||||
setUploadProgress(0);
|
||||
};
|
||||
|
||||
const handleUploadSuccess = (downloadUrl: string) => {
|
||||
setUploadedProfileImageUrl(downloadUrl);
|
||||
setPreviewUrl(downloadUrl);
|
||||
setUploadProgress(100);
|
||||
};
|
||||
|
||||
const handleUploadFailure = (input: HTMLInputElement) => {
|
||||
setUploadedProfileImageUrl(undefined);
|
||||
resetToDefaultProfileImage();
|
||||
setValue("profile_image", undefined);
|
||||
setError("profile_image", {
|
||||
type: "validate",
|
||||
message: "Failed to upload profile image",
|
||||
});
|
||||
input.value = "";
|
||||
};
|
||||
|
||||
const uploadProfileImage = async (file: File) => {
|
||||
return userService.uploadProfileImage({
|
||||
file,
|
||||
onProgress: (percent) => {
|
||||
setUploadProgress(percent);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onProfileImageChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = event.target.files?.[0];
|
||||
const inputElement = event.target;
|
||||
|
||||
if (!selectedFile) {
|
||||
resetToDefaultProfileImage();
|
||||
clearErrors("profile_image");
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateProfileImage(selectedFile);
|
||||
if (validationError) {
|
||||
setError("profile_image", {
|
||||
type: "validate",
|
||||
message: validationError,
|
||||
});
|
||||
inputElement.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
prepareUploadState(selectedFile);
|
||||
|
||||
try {
|
||||
const downloadUrl = await uploadProfileImage(selectedFile);
|
||||
handleUploadSuccess(downloadUrl);
|
||||
} catch {
|
||||
handleUploadFailure(inputElement);
|
||||
} finally {
|
||||
setIsUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSelectedProfileImage = () => {
|
||||
setValue("profile_image", undefined);
|
||||
resetToDefaultProfileImage();
|
||||
clearErrors("profile_image");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl?.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
const profileImageRegister = register("profile_image", {
|
||||
onChange: onProfileImageChange,
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<CreateAdminFormValues> = async (data) => {
|
||||
const file = data.profile_image?.[0];
|
||||
const profileImageUrl = uploadedProfileImageUrl ?? defaultValues?.profile_image;
|
||||
|
||||
if (file && !profileImageUrl) {
|
||||
toast.error("Please wait for profile image upload to finish");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: ICreateAdminCredentials = {
|
||||
...data,
|
||||
profile_image: profileImageUrl,
|
||||
};
|
||||
|
||||
if (editMode) {
|
||||
updateAdmin({ id: id as string, credentials: data });
|
||||
updateAdmin({ id: id as string, credentials: payload });
|
||||
} else {
|
||||
createAdmin({ ...data, role: "admin" });
|
||||
createAdmin({ ...payload, role: "admin" });
|
||||
}
|
||||
};
|
||||
const router = useRouter();
|
||||
@@ -43,6 +177,13 @@ const useCreateAdminPresenter = ({
|
||||
isMutating,
|
||||
onSubmit,
|
||||
watchForm: watch,
|
||||
isUploadingImage,
|
||||
uploadProgress,
|
||||
selectedFileName,
|
||||
selectedFileSize,
|
||||
previewUrl,
|
||||
clearSelectedProfileImage,
|
||||
profileImageRegister,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { useState } from "react";
|
||||
import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from "react";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2.5 text-center font-medium hover:bg-opacity-90 font-medium transition focus:outline-none",
|
||||
"relative isolate inline-flex items-center justify-center gap-2.5 overflow-hidden text-center font-medium hover:bg-opacity-90 transition focus:outline-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: "bg-primary text-white",
|
||||
green: "bg-green text-white",
|
||||
error: "bg-error text-white",
|
||||
dark: "bg-dark text-white dark:bg-white/10",
|
||||
outlinePrimary:
|
||||
"border border-primary hover:bg-primary/10 text-primary",
|
||||
@@ -33,11 +37,10 @@ const buttonVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
type ButtonProps = HTMLAttributes<HTMLButtonElement> &
|
||||
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
type?: "button" | "submit" | "reset";
|
||||
label: ReactNode;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
export function Button({
|
||||
@@ -49,13 +52,59 @@ export function Button({
|
||||
className,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const spotlightStyle = {
|
||||
"--spotlight-x": `${mousePosition.x}px`,
|
||||
"--spotlight-y": `${mousePosition.y}px`,
|
||||
background:
|
||||
"radial-gradient(130px circle at var(--spotlight-x) var(--spotlight-y), rgba(255, 255, 255, 0.95), transparent 70%)",
|
||||
WebkitMask:
|
||||
"linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
|
||||
WebkitMaskComposite: "xor",
|
||||
maskComposite: "exclude",
|
||||
} as CSSProperties;
|
||||
|
||||
const handleMouseMove = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setMousePosition({
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
});
|
||||
props.onMouseMove?.(event);
|
||||
};
|
||||
|
||||
const handleMouseEnter = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setIsHovered(true);
|
||||
props.onMouseEnter?.(event);
|
||||
};
|
||||
|
||||
const handleMouseLeave = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setIsHovered(false);
|
||||
props.onMouseLeave?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={buttonVariants({ variant, shape, size, className })}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onMouseMove={handleMouseMove}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-0 rounded-[inherit] p-px transition-opacity duration-300"
|
||||
style={{
|
||||
...spotlightStyle,
|
||||
opacity: isHovered ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
<span className="relative z-10 inline-flex items-center gap-2.5">
|
||||
{icon && <span>{icon}</span>}
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { formatFileSize } from "@/utils/shared";
|
||||
import Image from "next/image";
|
||||
import { UseFormRegisterReturn } from "react-hook-form";
|
||||
|
||||
interface FileImageUploadCardProps {
|
||||
label: string;
|
||||
inputId: string;
|
||||
accept?: string;
|
||||
previewUrl?: string | null;
|
||||
selectedFileName?: string;
|
||||
selectedFileSize?: number | null;
|
||||
isLoading?: boolean;
|
||||
uploadProgress?: number;
|
||||
errorMessage?: string;
|
||||
onClear: () => void;
|
||||
fileRegister: UseFormRegisterReturn;
|
||||
}
|
||||
|
||||
const FileImageUploadCard = ({
|
||||
label,
|
||||
inputId,
|
||||
accept = "image/png,image/jpeg,image/jpg,image/webp",
|
||||
previewUrl,
|
||||
selectedFileName,
|
||||
selectedFileSize,
|
||||
isLoading = false,
|
||||
uploadProgress = 0,
|
||||
errorMessage,
|
||||
onClear,
|
||||
fileRegister,
|
||||
}: FileImageUploadCardProps) => {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-body-sm font-semibold capitalize text-dark dark:text-white">
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative mt-3 overflow-hidden rounded-2xl border border-stroke/70 bg-gradient-to-br from-white via-white to-primary/[0.04] p-4 shadow-theme-xs backdrop-blur-sm dark:border-dark-3 dark:from-dark-2 dark:via-dark-2 dark:to-primary/[0.12]">
|
||||
<div className="pointer-events-none absolute -right-16 -top-16 h-40 w-40 rounded-full bg-primary/10 blur-3xl dark:bg-primary/20" />
|
||||
<div className="pointer-events-none absolute -bottom-16 -left-16 h-36 w-36 rounded-full bg-purple-500/10 blur-3xl dark:bg-purple-500/20" />
|
||||
<input
|
||||
id={inputId}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="sr-only"
|
||||
{...fileRegister}
|
||||
/>
|
||||
|
||||
{!previewUrl ? (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="group relative flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-stroke/80 bg-white/70 px-4 py-9 text-center transition-all duration-300 hover:-translate-y-0.5 hover:border-primary hover:bg-primary/[0.03] dark:border-dark-3 dark:bg-dark/20 dark:hover:bg-primary/[0.08]"
|
||||
>
|
||||
<div className="mb-3 rounded-2xl bg-primary/[0.12] p-3 text-primary shadow-theme-xs transition-transform duration-300 group-hover:scale-105 group-hover:rotate-3 dark:bg-primary/[0.2]">
|
||||
<svg
|
||||
className="h-6 w-6 text-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-dark dark:text-white">
|
||||
Click to upload your profile image
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-dark-5">PNG, JPG, WEBP up to 5MB</p>
|
||||
<div className="mt-4 inline-flex items-center rounded-full border border-primary/20 bg-primary/[0.08] px-3 py-1 text-[11px] font-medium text-primary dark:border-primary/40 dark:bg-primary/[0.18]">
|
||||
Recommended size: 400 x 400
|
||||
</div>
|
||||
</label>
|
||||
) : (
|
||||
<div className="flex items-center gap-4 rounded-xl border border-stroke/70 bg-white/70 p-3.5 shadow-theme-xs backdrop-blur-sm dark:border-dark-3 dark:bg-dark/30">
|
||||
<div className="relative h-20 w-20 overflow-hidden rounded-xl bg-gray-2 ring-1 ring-stroke/80 dark:bg-dark-3 dark:ring-dark-3">
|
||||
<Image
|
||||
src={previewUrl}
|
||||
alt="Profile preview"
|
||||
fill
|
||||
unoptimized
|
||||
sizes="80px"
|
||||
className="object-cover transition-transform duration-500 hover:scale-105"
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 inline-flex items-center rounded-full border border-success/25 bg-success/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-success dark:border-success/30 dark:bg-success/15">
|
||||
Ready
|
||||
</div>
|
||||
<p className="truncate text-sm font-semibold text-dark dark:text-white">
|
||||
{selectedFileName || "Current profile image"}
|
||||
</p>
|
||||
{selectedFileSize ? (
|
||||
<p className="mt-0.5 text-xs text-dark-5">
|
||||
{formatFileSize(selectedFileSize)}
|
||||
</p>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="mt-1">
|
||||
<p className="text-xs font-medium text-primary">
|
||||
Uploading image... {uploadProgress}%
|
||||
</p>
|
||||
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-gray-2 dark:bg-dark-3">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-primary to-purple-500 transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="cursor-pointer rounded-lg border border-stroke/80 bg-white px-3 py-1.5 text-xs font-semibold text-dark shadow-theme-xs transition-all hover:-translate-y-0.5 hover:border-primary hover:text-primary dark:border-dark-3 dark:bg-dark-2 dark:text-white"
|
||||
>
|
||||
Change
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="rounded-lg border border-error/35 bg-error/5 px-3 py-1.5 text-xs font-semibold text-error transition-all hover:-translate-y-0.5 hover:bg-error/10"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="mt-1 text-sm font-medium text-red-500">{errorMessage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileImageUploadCard;
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { Button } from "@/components/ui-elements/button";
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -11,24 +12,30 @@ const FormFooter = ({ onCancel }: IProps) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="col-span-1 flex gap-6 md:col-span-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isMutating > 0}
|
||||
className="mt-6 flex w-fit items-center justify-center rounded-lg bg-primary p-[13px] font-medium text-white hover:bg-opacity-90"
|
||||
>
|
||||
{isMutating ? (
|
||||
<Button
|
||||
label={
|
||||
isMutating ? (
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
|
||||
) : (
|
||||
"Submit"
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
)
|
||||
}
|
||||
variant="primary"
|
||||
shape="rounded"
|
||||
size="small"
|
||||
type="submit"
|
||||
disabled={isMutating > 0}
|
||||
className="mt-6 w-fit rounded-lg bg-primary p-[13px] font-medium text-white hover:bg-opacity-90"
|
||||
/>
|
||||
<Button
|
||||
label="Opt out"
|
||||
shape="rounded"
|
||||
size="small"
|
||||
type="button"
|
||||
variant="error"
|
||||
onClick={onCancel ?? router.back}
|
||||
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium text-white hover:bg-opacity-90"
|
||||
>
|
||||
Opt out
|
||||
</button>
|
||||
className="mt-6 w-fit rounded-lg bg-error p-[13px] font-medium text-white hover:bg-opacity-90"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { FilterIcon } from "@/assets/icons";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Button } from "@/components/ui-elements/button";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import IsVisible from "./IsVisible";
|
||||
|
||||
interface IProps {
|
||||
@@ -20,23 +20,33 @@ const ListHeader = ({
|
||||
onFilter,
|
||||
}: IProps) => {
|
||||
const pathName = usePathname();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="flex flex-col-reverse items-center justify-between md:flex-row">
|
||||
<div className="mb-1 flex flex-col-reverse items-center justify-between gap-3 md:flex-row">
|
||||
<IsVisible condition={hasFilter}>
|
||||
<button
|
||||
onClick={onFilter}
|
||||
className="mb-5 flex w-fit justify-center self-end rounded-2xl border bg-gray-5 bg-opacity-20 px-6 py-2 font-medium transition duration-300 hover:bg-opacity-30 dark:border-gray-7 dark:bg-black dark:bg-opacity-40 dark:text-white dark:hover:bg-opacity-90"
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
label={filterButtonText}
|
||||
icon={
|
||||
<span className="text-primary">
|
||||
<FilterIcon />
|
||||
</span>
|
||||
{filterButtonText}
|
||||
</button>
|
||||
}
|
||||
variant="outlineDark"
|
||||
shape="rounded"
|
||||
size="small"
|
||||
onClick={onFilter}
|
||||
className="shadow-theme-xs mb-5 self-end rounded-xl border-stroke/70 bg-white/80 px-5 text-sm font-semibold text-dark transition-all duration-300 hover:border-primary/35 hover:text-primary dark:border-dark-3 dark:bg-dark-2 dark:text-white dark:hover:bg-white/[0.08]"
|
||||
/>
|
||||
</IsVisible>
|
||||
<IsVisible condition={hasCreate}>
|
||||
<button className="mb-5 flex w-fit justify-center self-end rounded-2xl bg-primary p-3 font-medium text-white hover:bg-opacity-90">
|
||||
<Link href={`${pathName}/create`}>{createButtonText}</Link>
|
||||
</button>
|
||||
<Button
|
||||
label={createButtonText}
|
||||
variant="primary"
|
||||
shape="rounded"
|
||||
size="small"
|
||||
onClick={() => router.push(`${pathName}/create`)}
|
||||
className="shadow-theme-xs mb-5 self-end rounded-xl bg-gradient-to-r from-primary to-primary/85 text-sm font-semibold transition-all duration-300 hover:opacity-95"
|
||||
/>
|
||||
</IsVisible>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type ListWrapperProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const ListWrapper = ({ children }: ListWrapperProps) => {
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-stroke/70 bg-white bg-[linear-gradient(225deg,rgba(87,80,241,0.18)_0%,rgba(87,80,241,0)_10%),linear-gradient(45deg,rgba(87,80,241,0.18)_0%,rgba(87,80,241,0)_10%)] px-7.5 pb-4 pt-7.5 dark:border-dark-3 dark:bg-gray-dark dark:bg-[linear-gradient(225deg,rgba(87,80,241,0.26)_0%,rgba(87,80,241,0)_10%),linear-gradient(45deg,rgba(87,80,241,0.26)_0%,rgba(87,80,241,0)_10%)]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListWrapper;
|
||||
@@ -4,16 +4,21 @@ import { TableBody, TableCell, TableRow } from "./table";
|
||||
interface IProps {
|
||||
length?: number;
|
||||
column: number;
|
||||
cellColSpan?: number;
|
||||
}
|
||||
|
||||
const TableSkeleton = ({ column, length = 20 }: IProps) => {
|
||||
const TableSkeleton = ({
|
||||
column,
|
||||
length = 20,
|
||||
cellColSpan = 100,
|
||||
}: IProps) => {
|
||||
return (
|
||||
<TableBody>
|
||||
{Array.from({ length }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{Array.from({ length: column }).map((_, j) => (
|
||||
<TableCell key={j} colSpan={100}>
|
||||
<Skeleton className="h-8" />
|
||||
<TableCell key={j} colSpan={cellColSpan}>
|
||||
<Skeleton className="h-8 rounded-lg" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
|
||||
@@ -116,16 +116,25 @@ type DropdownTriggerProps = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function DropdownTrigger({ children, className }: DropdownTriggerProps) {
|
||||
export function DropdownTrigger({
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: DropdownTriggerProps) {
|
||||
const { handleOpen, isOpen } = useDropdownContext();
|
||||
|
||||
return (
|
||||
<button
|
||||
className={className}
|
||||
onClick={handleOpen}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
handleOpen();
|
||||
}}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="menu"
|
||||
data-state={isOpen ? "open" : "closed"}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
||||
+17
-11
@@ -2,6 +2,7 @@ import { CrossIcon } from "@/assets/icons";
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "../ui-elements/button";
|
||||
import IsVisible from "./IsVisible";
|
||||
|
||||
interface ModalProps {
|
||||
@@ -63,24 +64,29 @@ const Modal: React.FC<ModalProps> = ({
|
||||
<div className="clear-both">{children}</div>
|
||||
<div className="flex gap-5">
|
||||
<IsVisible condition={showButtons}>
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
||||
size="small"
|
||||
shape="rounded"
|
||||
className="mt-6 w-fit capitalize"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{isMutating ? (
|
||||
label={
|
||||
isMutating ? (
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
|
||||
) : (
|
||||
<span>{confirmText}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
||||
variant="error"
|
||||
size="small"
|
||||
shape="rounded"
|
||||
className="mt-6 w-fit capitalize"
|
||||
onClick={onClose}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
label={cancelText}
|
||||
/>
|
||||
</IsVisible>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,19 +11,19 @@ const Pagination: FC<IProps> = ({ totalPages, currentPage, onPageChange }) => {
|
||||
<ReactPaginate
|
||||
pageCount={totalPages}
|
||||
initialPage={currentPage}
|
||||
nextLabel=">"
|
||||
previousLabel="<"
|
||||
nextLabel="›"
|
||||
previousLabel="‹"
|
||||
pageRangeDisplayed={5}
|
||||
activeClassName="flex justify-center rounded-md bg-primary p-1 font-medium !text-white hover:bg-opacity-90"
|
||||
activeClassName="flex h-9 min-w-9 items-center justify-center rounded-xl border border-white/35 bg-gradient-to-br from-primary/85 via-primary/75 to-primary/65 px-3 text-sm font-semibold !text-white shadow-theme-xs shadow-primary/20 backdrop-blur-md transition-all duration-300 before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:h-1/2 before:rounded-t-xl before:bg-gradient-to-b before:from-white/30 before:to-transparent dark:border-white/20 dark:before:from-white/20"
|
||||
onPageChange={(e) => {
|
||||
if (!onPageChange) return;
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
onPageChange(e);
|
||||
}}
|
||||
className="flex w-full items-center justify-center text-sm text-gray-dark dark:text-white"
|
||||
nextClassName="flex justify-center rounded-md p-1 font-medium text-gray-dark dark:text-white hover:bg-opacity-90"
|
||||
previousClassName="flex justify-center rounded-md p-1 font-medium text-gray-dark dark:text-white hover:bg-opacity-90"
|
||||
pageClassName="flex justify-center h-fit rounded-md mx-2 p-1 text-gray-dark dark:text-white hover:bg-opacity-90"
|
||||
className="mt-2 flex w-full items-center justify-center gap-2 text-sm text-gray-dark dark:text-white"
|
||||
nextClassName="flex h-9 min-w-9 items-center justify-center rounded-xl border border-stroke/80 bg-white/70 px-2 text-sm font-semibold text-dark shadow-theme-xs backdrop-blur-md transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/35 hover:text-primary dark:border-dark-3 dark:bg-dark-2/70 dark:text-white dark:hover:bg-white/[0.1]"
|
||||
previousClassName="flex h-9 min-w-9 items-center justify-center rounded-xl border border-stroke/80 bg-white/70 px-2 text-sm font-semibold text-dark shadow-theme-xs backdrop-blur-md transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/35 hover:text-primary dark:border-dark-3 dark:bg-dark-2/70 dark:text-white dark:hover:bg-white/[0.1]"
|
||||
pageClassName="relative flex h-9 min-w-9 items-center justify-center rounded-xl border border-stroke/80 bg-white/70 px-3 text-sm font-semibold text-dark shadow-theme-xs backdrop-blur-md transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/35 hover:text-primary dark:border-dark-3 dark:bg-dark-2/70 dark:text-white dark:hover:bg-white/[0.1]"
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,8 @@ export function Skeleton({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"animate-pulse rounded-md bg-neutral-100 dark:bg-dark-2",
|
||||
"relative animate-pulse overflow-hidden rounded-lg bg-gradient-to-r from-gray-2 via-neutral-100 to-gray-2 dark:from-dark-3 dark:via-dark-2 dark:to-dark-3",
|
||||
"before:absolute before:inset-0 before:bg-gradient-to-r before:from-transparent before:via-white/60 before:to-transparent dark:before:via-white/10",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -6,7 +6,7 @@ export function Table({
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLTableElement>) {
|
||||
return (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<div className="relative w-full overflow-auto rounded-xl border border-stroke/70 bg-white dark:border-dark-3 dark:bg-dark-2">
|
||||
<table
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
@@ -19,7 +19,15 @@ export function TableHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLTableSectionElement>) {
|
||||
return <thead className={cn("[&_tr]:border-b", className)} {...props} />;
|
||||
return (
|
||||
<thead
|
||||
className={cn(
|
||||
"bg-gray-2/60 dark:bg-dark-3/40 [&_tr]:border-b [&_tr]:border-stroke/70 dark:[&_tr]:border-dark-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableBody({
|
||||
@@ -27,7 +35,10 @@ export function TableBody({
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLTableSectionElement>) {
|
||||
return (
|
||||
<tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
||||
<tbody
|
||||
className={cn("[&_tr:last-child]:border-0 [&_tr]:border-stroke/60 dark:[&_tr]:border-dark-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +49,7 @@ export function TableFooter({
|
||||
return (
|
||||
<tfoot
|
||||
className={cn(
|
||||
"border-t bg-neutral-100/50 font-medium dark:bg-neutral-800/50 [&>tr]:last:border-b-0",
|
||||
"border-t border-stroke/70 bg-gray-2/60 font-medium dark:border-dark-3 dark:bg-dark-3/40 [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -54,7 +65,7 @@ export const TableRow = React.forwardRef<
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-neutral-100/50 data-[state=selected]:bg-neutral-100 dark:border-dark-3 dark:hover:bg-dark-2 dark:data-[state=selected]:bg-neutral-800",
|
||||
"border-b transition-colors duration-150 hover:bg-gray-2/60 data-[state=selected]:bg-gray-2 dark:border-dark-3 dark:hover:bg-dark-3/60 dark:data-[state=selected]:bg-dark-3/70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -70,7 +81,7 @@ export function TableHead({
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium uppercase text-neutral-500 dark:text-neutral-400 [&:has([role=checkbox])]:pr-0",
|
||||
"h-13 px-4 text-left align-middle text-[11px] font-semibold uppercase tracking-[0.08em] text-dark-5 dark:text-dark-6 [&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -85,7 +96,7 @@ export function TableCell({
|
||||
return (
|
||||
<td
|
||||
className={cn(
|
||||
"p-4 align-middle [&:has([role=checkbox])]:pr-0",
|
||||
"p-4 align-middle text-dark dark:text-white [&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -14,6 +14,10 @@ export const API_ENDPOINTS = {
|
||||
ADMIN_DETAILS: (id: string) => `users/${id}`,
|
||||
CHANGE_ADMIN_STATUS: (id: string) => `users/${id}/status`,
|
||||
},
|
||||
FILES: {
|
||||
UPLOAD: "files/upload",
|
||||
DOWNLOAD: (fileId: string) => `files/${fileId}/download`,
|
||||
},
|
||||
PROFILE: {
|
||||
READ: "profile",
|
||||
UPDATE: "profile",
|
||||
|
||||
@@ -108,4 +108,44 @@ export const userService = {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
uploadProfileImage: async ({
|
||||
file,
|
||||
onProgress,
|
||||
}: {
|
||||
file: File;
|
||||
onProgress?: (percent: number) => void;
|
||||
}) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("category", "profile_images");
|
||||
formData.append("tags", "user");
|
||||
formData.append("tags", "profile");
|
||||
formData.append("description", "Admin profile image");
|
||||
|
||||
const response = await api.post(API_ENDPOINTS.FILES.UPLOAD, formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (!progressEvent.total) return;
|
||||
const percent = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total,
|
||||
);
|
||||
onProgress?.(percent);
|
||||
},
|
||||
});
|
||||
|
||||
const fileId = response?.data?.file_id as string | undefined;
|
||||
if (!fileId) {
|
||||
throw new Error("No file id returned from upload");
|
||||
}
|
||||
|
||||
// /api/proxy/* is rewritten to /admin/v1/* in next.config.
|
||||
return `/api/proxy/${API_ENDPOINTS.FILES.DOWNLOAD(fileId)}`;
|
||||
} catch (error) {
|
||||
console.error("ERROR caught in User Service Upload Profile Image", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -126,3 +126,38 @@ export const getPaginatedRowNumber = ({
|
||||
|
||||
return (safePage - 1) * safeLimit + index + 1;
|
||||
};
|
||||
|
||||
export const ALLOWED_IMAGE_FILE_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/jpg",
|
||||
];
|
||||
|
||||
export const MAX_IMAGE_FILE_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
export const validateImageFile = ({
|
||||
file,
|
||||
allowedTypes = ALLOWED_IMAGE_FILE_TYPES,
|
||||
maxSize = MAX_IMAGE_FILE_SIZE,
|
||||
}: {
|
||||
file: File;
|
||||
allowedTypes?: string[];
|
||||
maxSize?: number;
|
||||
}) => {
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
return "Only JPG, PNG, or WEBP images are allowed";
|
||||
}
|
||||
|
||||
if (file.size > maxSize) {
|
||||
return "Image size must be 5MB or less";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
Reference in New Issue
Block a user