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:
AmirReza Jamali
2026-04-23 20:53:23 +03:30
parent bb1af8b31c
commit f195bba03d
50 changed files with 1245 additions and 319 deletions
+276
View File
@@ -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)
+4 -2
View File
@@ -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
View File
@@ -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>
+1 -1
View File
@@ -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>
+12 -4
View File
@@ -4,13 +4,21 @@ import SigninWithPassword from "../SigninWithPassword";
export default function Signin() {
return (
<>
<div>
<div className="flex items-center justify-center my-10">
<Logo />
<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>
</>
);
}
+17 -12
View File
@@ -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
<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(
"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",
"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",
)}
>
{isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
"Sign in"
)}
</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>
);
+8 -5
View File
@@ -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>
+2 -2
View File
@@ -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();
+4 -2
View File
@@ -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>
)}
+8 -6
View File
@@ -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" />
+5 -5
View File
@@ -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,
)}
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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>
);
};
+6 -5
View File
@@ -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>
);
};
+3 -4
View File
@@ -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>
);
};
+3 -2
View File
@@ -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>
);
};
+11 -6
View File
@@ -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>
);
};
+3 -2
View File
@@ -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>
);
+3 -2
View File
@@ -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>
);
};
+48 -50
View File
@@ -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,58 +16,55 @@ 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,
)}
>
<h2 className="mb-4 text-body-2xlg font-bold text-dark dark:text-white">
Top Channels
</h2>
<div className={cn("grid", className)}>
<ListWrapper>
<h2 className="mb-4 text-body-2xlg font-bold text-dark dark:text-white">
Top Channels
</h2>
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-center">
<TableHead className="min-w-[120px] !text-left">Source</TableHead>
<TableHead>Visitors</TableHead>
<TableHead className="!text-right">Revenues</TableHead>
<TableHead>Sales</TableHead>
<TableHead>Conversion</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((channel, i) => (
<TableRow
className="text-center text-base font-medium text-dark dark:text-white"
key={channel.name + i}
>
<TableCell className="flex min-w-fit items-center gap-3">
<Image
src={channel.logo}
className="size-8 rounded-full object-cover"
width={40}
height={40}
alt={channel.name + " Logo"}
role="presentation"
/>
<div className="">{channel.name}</div>
</TableCell>
<TableCell>{compactFormat(channel.visitors)}</TableCell>
<TableCell className="!text-right text-green-light-1">
${standardFormat(channel.revenues)}
</TableCell>
<TableCell>{channel.sales}</TableCell>
<TableCell>{channel.conversion}%</TableCell>
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-center">
<TableHead className="min-w-[120px] !text-left">Source</TableHead>
<TableHead>Visitors</TableHead>
<TableHead className="!text-right">Revenues</TableHead>
<TableHead>Sales</TableHead>
<TableHead>Conversion</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{data.map((channel, i) => (
<TableRow
className="text-center text-base font-medium text-dark dark:text-white"
key={channel.name + i}
>
<TableCell className="flex min-w-fit items-center gap-3">
<Image
src={channel.logo}
className="size-8 rounded-full object-cover"
width={40}
height={40}
alt={channel.name + " Logo"}
role="presentation"
/>
<div className="">{channel.name}</div>
</TableCell>
<TableCell>{compactFormat(channel.visitors)}</TableCell>
<TableCell className="!text-right text-green-light-1">
${standardFormat(channel.revenues)}
</TableCell>
<TableCell>{channel.sales}</TableCell>
<TableCell>{channel.conversion}%</TableCell>
</TableRow>
))}
</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 -7
View File
@@ -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">
Top Products
</h2>
</div>
<ListWrapper>
<h2 className="mb-4 text-2xl font-bold text-dark dark:text-white">
Top Products
</h2>
<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>
);
}
+26 -15
View File
@@ -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"
/>
{errors.profile_image && (
<p className="mt-1 text-sm text-red-500">
{errors.profile_image.message as string}
</p>
)}
</div>
<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}
/>
<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,
};
};
+57 -8
View File
@@ -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}
>
{icon && <span>{icon}</span>}
{label}
<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>
);
}
+147
View File
@@ -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;
+21 -14
View File
@@ -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
<Button
label={
isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
"Submit"
)
}
variant="primary"
shape="rounded"
size="small"
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 ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
"Submit"
)}
</button>
<button
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>
);
};
+24 -14
View File
@@ -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
<Button
label={filterButtonText}
icon={
<span className="text-primary">
<FilterIcon />
</span>
}
variant="outlineDark"
shape="rounded"
size="small"
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>
<FilterIcon />
</span>
{filterButtonText}
</button>
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>
);
+15
View File
@@ -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;
+8 -3
View File
@@ -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>
+11 -2
View File
@@ -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
View File
@@ -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>
+7 -7
View File
@@ -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>
);
+2 -1
View File
@@ -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}
+18 -7
View File
@@ -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}
+4
View File
@@ -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",
+40
View File
@@ -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;
}
},
};
+35
View File
@@ -125,4 +125,39 @@ export const getPaginatedRowNumber = ({
const safeLimit = limit > 0 ? limit : 10;
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`;
};