first commit
1292
package-lock.json
generated
25
package.json
@@ -12,23 +12,44 @@
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@mui/material": "^7.3.4",
|
||||
"@pbe/react-yandex-maps": "^1.2.5",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@tanstack/react-query": "^5.77.1",
|
||||
"axios": "^1.9.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.18",
|
||||
"framer-motion": "^12.23.24",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"react": "^19.1.1",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-github-btn": "^1.4.0",
|
||||
"react-hook-form": "^7.65.0",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-quill-new": "^3.6.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.13"
|
||||
"tailwindcss": "^4.1.13",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
@@ -55,4 +76,4 @@
|
||||
"eslint src --fix"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/Logo_white.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/airplane-ticket.jpg
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
public/breakfast-buffet.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/burj-khalifa-inside.jpg
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
public/comfortable-hotel-room.png
Normal file
|
After Width: | Height: | Size: 793 KiB |
BIN
public/dinner-restaurant.jpg
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
public/diverse-tour-group.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/dubai-airport.jpg
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
public/dubai-burj-khalifa.png
Normal file
|
After Width: | Height: | Size: 877 KiB |
BIN
public/dubai-desert-safari.png
Normal file
|
After Width: | Height: | Size: 817 KiB |
BIN
public/dubai-marina.jpg
Normal file
|
After Width: | Height: | Size: 176 KiB |
BIN
public/dubai-palm-jumeirah.jpg
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
public/placeholder-logo.png
Normal file
|
After Width: | Height: | Size: 568 B |
1
public/placeholder-logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="215" height="48" fill="none"><path fill="#000" d="M57.588 9.6h6L73.828 38h-5.2l-2.36-6.88h-11.36L52.548 38h-5.2l10.24-28.4Zm7.16 17.16-4.16-12.16-4.16 12.16h8.32Zm23.694-2.24c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.486-7.72.12 3.4c.534-1.227 1.307-2.173 2.32-2.84 1.04-.693 2.267-1.04 3.68-1.04 1.494 0 2.76.387 3.8 1.16 1.067.747 1.827 1.813 2.28 3.2.507-1.44 1.294-2.52 2.36-3.24 1.094-.747 2.414-1.12 3.96-1.12 1.414 0 2.64.307 3.68.92s1.84 1.52 2.4 2.72c.56 1.2.84 2.667.84 4.4V38h-4.96V25.92c0-1.813-.293-3.187-.88-4.12-.56-.96-1.413-1.44-2.56-1.44-.906 0-1.68.213-2.32.64-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.84-.48 3.04V38h-4.56V25.92c0-1.2-.133-2.213-.4-3.04-.24-.827-.626-1.453-1.16-1.88-.506-.427-1.133-.64-1.88-.64-.906 0-1.68.227-2.32.68-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.827-.48 3V38h-4.96V16.8h4.48Zm26.723 10.6c0-2.24.427-4.187 1.28-5.84.854-1.68 2.067-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.84 0 3.494.413 4.96 1.24 1.467.827 2.64 2.08 3.52 3.76.88 1.653 1.347 3.693 1.4 6.12v1.32h-15.08c.107 1.813.614 3.227 1.52 4.24.907.987 2.134 1.48 3.68 1.48.987 0 1.88-.253 2.68-.76a4.803 4.803 0 0 0 1.84-2.2l5.08.36c-.64 2.027-1.84 3.64-3.6 4.84-1.733 1.173-3.733 1.76-6 1.76-2.08 0-3.906-.453-5.48-1.36-1.573-.907-2.786-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84Zm15.16-2.04c-.213-1.733-.76-3.013-1.64-3.84-.853-.827-1.893-1.24-3.12-1.24-1.44 0-2.6.453-3.48 1.36-.88.88-1.44 2.12-1.68 3.72h9.92ZM163.139 9.6V38h-5.04V9.6h5.04Zm8.322 7.2.24 5.88-.64-.36c.32-2.053 1.094-3.56 2.32-4.52 1.254-.987 2.787-1.48 4.6-1.48 2.32 0 4.107.733 5.36 2.2 1.254 1.44 1.88 3.387 1.88 5.84V38h-4.96V25.92c0-1.253-.12-2.28-.36-3.08-.24-.8-.64-1.413-1.2-1.84-.533-.427-1.253-.64-2.16-.64-1.44 0-2.573.48-3.4 1.44-.8.933-1.2 2.307-1.2 4.12V38h-4.96V16.8h4.48Zm30.003 7.72c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.443 8.16V38h-5.6v-5.32h5.6Z"/><path fill="#171717" fill-rule="evenodd" d="m7.839 40.783 16.03-28.054L20 6 0 40.783h7.839Zm8.214 0H40L27.99 19.894l-4.02 7.032 3.976 6.914H20.02l-3.967 6.943Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/placeholder-user.jpg
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/placeholder.jpg
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
1
public/placeholder.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/transfer-car.jpg
Normal file
|
After Width: | Height: | Size: 111 KiB |
77
src/App.tsx
@@ -1,11 +1,80 @@
|
||||
import MainProvider from '@/providers/main';
|
||||
import '@/shared/config/i18n';
|
||||
import Welcome from '@/widgets/welcome/ui/welcome';
|
||||
import Agencies from "@/pages/agencies/Agencies";
|
||||
import AgencyDetail from "@/pages/agencies/AgencyDetail";
|
||||
import Bookings from "@/pages/bookings/ui/Bookings";
|
||||
import Employees from "@/pages/employees/ui/Employees";
|
||||
import Faq from "@/pages/faq/ui/Faq";
|
||||
import FaqCategory from "@/pages/faq/ui/FaqCategory";
|
||||
import FinancePage from "@/pages/finance/ui/Finance";
|
||||
import FinanceDetailTour from "@/pages/finance/ui/FinanceDetailTour";
|
||||
import {
|
||||
default as FinanceDetailUsers,
|
||||
default as PurchaseDetailPage,
|
||||
} from "@/pages/finance/ui/FinanceDetailUsers";
|
||||
import AddNews from "@/pages/news/ui/AddNews";
|
||||
import News from "@/pages/news/ui/News";
|
||||
import NewsCategory from "@/pages/news/ui/NewsCategory";
|
||||
import Seo from "@/pages/seo/ui/Seo";
|
||||
import PolicyCrud from "@/pages/site-page/ui/PolicyCrud";
|
||||
import SitePage from "@/pages/site-page/ui/SitePage";
|
||||
import SupportAgency from "@/pages/support/ui/SupportAgency";
|
||||
import SupportTours from "@/pages/support/ui/SupportTours";
|
||||
import TourSettings from "@/pages/tour-settings/ui/TourSettings";
|
||||
import CreateEditTour from "@/pages/tours/ui/CreateEditTour";
|
||||
import TourDetail from "@/pages/tours/ui/TourDetail";
|
||||
import Tours from "@/pages/tours/ui/Tours";
|
||||
import ToursSetting from "@/pages/tours/ui/ToursSetting";
|
||||
import CreateUser from "@/pages/users/Create";
|
||||
import EditUser from "@/pages/users/Edit";
|
||||
import UserList from "@/pages/users/User";
|
||||
import UserDetail from "@/pages/users/UserDetail";
|
||||
import MainProvider from "@/providers/main";
|
||||
import "@/shared/config/i18n";
|
||||
import { Sidebar } from "@/widgets/sidebar/ui/Sidebar";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
|
||||
const App = () => {
|
||||
const userRole = "admin";
|
||||
|
||||
return (
|
||||
<MainProvider>
|
||||
<Welcome />
|
||||
<div className="flex max-lg:flex-col bg-gray-900">
|
||||
<Sidebar role={userRole} />
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/user" replace />} />
|
||||
|
||||
<Route path="/user" element={<UserList />} />
|
||||
<Route path="/users/create" element={<CreateUser />} />
|
||||
<Route path="/users/:id/edit" element={<EditUser />} />
|
||||
<Route path="/users/:id/" element={<UserDetail />} />
|
||||
<Route path="/agencies" element={<Agencies />} />
|
||||
<Route path="/agencies/:id" element={<AgencyDetail />} />
|
||||
<Route path="/tours/:id" element={<TourDetail />} />
|
||||
<Route path="/employees" element={<Employees />} />
|
||||
<Route path="/finance" element={<FinancePage />} />
|
||||
<Route path="/purchases/:id/" element={<PurchaseDetailPage />} />
|
||||
<Route path="/travel/booking/:id/" element={<FinanceDetailTour />} />
|
||||
<Route path="/bookings/:id/" element={<FinanceDetailUsers />} />
|
||||
<Route path="/tours" element={<Tours />} />
|
||||
<Route path="/tours/setting" element={<ToursSetting />} />
|
||||
<Route path="/tours/:id/edit" element={<CreateEditTour />} />
|
||||
<Route path="/tours/create" element={<CreateEditTour />} />
|
||||
<Route path="/bookings" element={<Bookings />} />
|
||||
<Route path="/news" element={<News />} />
|
||||
<Route path="/news/add" element={<AddNews />} />
|
||||
<Route path="/news/categories" element={<NewsCategory />} />
|
||||
<Route path="/faq" element={<Faq />} />
|
||||
<Route path="/faq/categories" element={<FaqCategory />} />
|
||||
<Route path="/support/tours" element={<SupportAgency />} />
|
||||
<Route path="/support/user" element={<SupportTours />} />
|
||||
<Route path="/site-seo" element={<Seo />} />
|
||||
<Route path="/site-pages/" element={<SitePage />} />
|
||||
<Route path="/site-help/" element={<PolicyCrud />} />
|
||||
<Route path="/site-settings/" element={<TourSettings />} />
|
||||
{/* <Route path="/site-settings" element={<SiteSettings />} />
|
||||
<Route path="/page-services" element={<PageServices />} />
|
||||
<Route path="/page-help" element={<PageHelp />} /> */}
|
||||
</Routes>
|
||||
</div>
|
||||
</MainProvider>
|
||||
);
|
||||
};
|
||||
|
||||
15
src/main.tsx
@@ -1,10 +1,13 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
339
src/pages/agencies/Agencies.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import {
|
||||
Building2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
Package,
|
||||
TrendingUp,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
type Agency = {
|
||||
id: number;
|
||||
name: string;
|
||||
owner: string;
|
||||
status: "faol" | "nofaol";
|
||||
profitPercent: number;
|
||||
totalTours: number;
|
||||
soldTours: number;
|
||||
totalProfit: number;
|
||||
};
|
||||
|
||||
export default function TourAgenciesPage() {
|
||||
const [agencies, setAgencies] = useState<Agency[]>([
|
||||
{
|
||||
id: 1,
|
||||
name: "Silk Road Travel",
|
||||
owner: "Ali Karimov",
|
||||
status: "faol",
|
||||
profitPercent: 15,
|
||||
totalTours: 12,
|
||||
soldTours: 56,
|
||||
totalProfit: 8900000,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "UzTour Plus",
|
||||
owner: "Madina Qodirova",
|
||||
status: "nofaol",
|
||||
profitPercent: 10,
|
||||
totalTours: 5,
|
||||
soldTours: 0,
|
||||
totalProfit: 0,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Orient Express",
|
||||
owner: "Sardor Rahimov",
|
||||
status: "faol",
|
||||
profitPercent: 12,
|
||||
totalTours: 8,
|
||||
soldTours: 34,
|
||||
totalProfit: 5600000,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Golden Voyage",
|
||||
owner: "Dilnoza Azimova",
|
||||
status: "faol",
|
||||
profitPercent: 18,
|
||||
totalTours: 15,
|
||||
soldTours: 89,
|
||||
totalProfit: 12400000,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "SkyLine Tours",
|
||||
owner: "Rustam Qobilov",
|
||||
status: "nofaol",
|
||||
profitPercent: 8,
|
||||
totalTours: 4,
|
||||
soldTours: 0,
|
||||
totalProfit: 0,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Desert Adventures",
|
||||
owner: "Kamola Saidova",
|
||||
status: "faol",
|
||||
profitPercent: 20,
|
||||
totalTours: 11,
|
||||
soldTours: 42,
|
||||
totalProfit: 6700000,
|
||||
},
|
||||
]);
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 4;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleStatusChange = (id: number, newStatus: "faol" | "nofaol") => {
|
||||
setAgencies((prev) =>
|
||||
prev.map((a) => (a.id === id ? { ...a, status: newStatus } : a)),
|
||||
);
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(agencies.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const paginatedAgencies = agencies.slice(
|
||||
startIndex,
|
||||
startIndex + itemsPerPage,
|
||||
);
|
||||
|
||||
const activeCount = agencies.filter((a) => a.status === "faol").length;
|
||||
const totalTours = agencies.reduce((sum, a) => sum + a.totalTours, 0);
|
||||
const totalRevenue = agencies.reduce((sum, a) => sum + a.totalProfit, 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950">
|
||||
<div className="container mx-auto px-4 py-12 max-w-[90%]">
|
||||
{/* Header */}
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-3 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-xl shadow-lg shadow-cyan-500/20">
|
||||
<Building2 className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-5xl font-bold bg-gradient-to-r from-blue-400 via-cyan-300 to-blue-400 bg-clip-text text-transparent">
|
||||
Tur firmalari
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-400 text-lg ml-14">
|
||||
Firmalarni karta ko'rinishida boshqaring va statistikani kuzating
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-12">
|
||||
<StatCard
|
||||
title="Jami firmalar"
|
||||
value={agencies.length.toString()}
|
||||
icon={<Package className="w-6 h-6" />}
|
||||
gradient="from-blue-600 to-blue-400"
|
||||
shadowColor="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="Faol firmalar"
|
||||
value={activeCount.toString()}
|
||||
icon={<TrendingUp className="w-6 h-6" />}
|
||||
gradient="from-green-600 to-emerald-400"
|
||||
shadowColor="green"
|
||||
/>
|
||||
<StatCard
|
||||
title="Jami turlar"
|
||||
value={totalTours.toString()}
|
||||
icon={<Package className="w-6 h-6" />}
|
||||
gradient="from-amber-600 to-yellow-400"
|
||||
shadowColor="amber"
|
||||
/>
|
||||
<StatCard
|
||||
title="Umumiy daromad"
|
||||
value={`${(totalRevenue / 1_000_000).toFixed(1)}M`}
|
||||
icon={<TrendingUp className="w-6 h-6" />}
|
||||
gradient="from-purple-600 to-pink-400"
|
||||
shadowColor="purple"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-10">
|
||||
{paginatedAgencies.map((agency) => (
|
||||
<div
|
||||
key={agency.id}
|
||||
className="group relative hover:scale-105 transition-transform duration-300"
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-0 bg-gradient-to-r ${
|
||||
agency.status === "faol"
|
||||
? "from-blue-600/20 to-cyan-600/20"
|
||||
: "from-slate-600/20 to-slate-500/20"
|
||||
} rounded-2xl blur-xl group-hover:blur-2xl transition-all opacity-0 group-hover:opacity-100`}
|
||||
/>
|
||||
<div
|
||||
className={`relative bg-gradient-to-br ${
|
||||
agency.status === "faol"
|
||||
? "from-slate-700 to-slate-800"
|
||||
: "from-slate-800 to-slate-900"
|
||||
} border border-slate-600/50 rounded-2xl p-6 shadow-2xl hover:shadow-2xl transition-all backdrop-blur-sm hover:border-slate-500/70`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-1">
|
||||
{agency.name}
|
||||
</h2>
|
||||
<div className="flex gap-2 items-center">
|
||||
<UserIcon className="text-slate-400 size-5" />
|
||||
<p className="text-slate-400">{agency.owner}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`px-3 py-1 rounded-full text-sm font-semibold whitespace-nowrap ${
|
||||
agency.status === "faol"
|
||||
? "bg-green-500/20 text-green-300 border border-green-500/50"
|
||||
: "bg-red-500/20 text-red-300 border border-red-500/50"
|
||||
}`}
|
||||
>
|
||||
{agency.status === "faol" ? "Faol" : "No-faol"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-5">
|
||||
<div className="bg-slate-600/30 rounded-lg p-3 border border-slate-500/30 hover:border-slate-500/50 transition-all">
|
||||
<p className="text-slate-400 text-xs uppercase tracking-wider mb-1">
|
||||
Komissiya
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-blue-300">
|
||||
{agency.profitPercent}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-600/30 rounded-lg p-3 border border-slate-500/30 hover:border-slate-500/50 transition-all">
|
||||
<p className="text-slate-400 text-xs uppercase tracking-wider mb-1">
|
||||
Jami tur
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-cyan-300">
|
||||
{agency.totalTours}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-600/30 rounded-lg p-3 border border-slate-500/30 hover:border-slate-500/50 transition-all">
|
||||
<p className="text-slate-400 text-xs uppercase tracking-wider mb-1">
|
||||
Sotilgan
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-yellow-300">
|
||||
{agency.soldTours}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-600/30 rounded-lg p-3 border border-slate-500/30 hover:border-slate-500/50 transition-all">
|
||||
<p className="text-slate-400 text-xs uppercase tracking-wider mb-1">
|
||||
Daromad
|
||||
</p>
|
||||
<p className="text-lg font-bold text-green-300">
|
||||
{(agency.totalProfit / 1_000_000).toFixed(1)}M
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-4 border-t border-slate-600/50">
|
||||
<select
|
||||
value={agency.status}
|
||||
onChange={(e) =>
|
||||
handleStatusChange(
|
||||
agency.id,
|
||||
e.target.value as "faol" | "nofaol",
|
||||
)
|
||||
}
|
||||
className="flex-1 bg-slate-700/50 border border-slate-600 rounded-lg px-3 py-2 text-slate-300 text-sm hover:bg-slate-600/50 transition-all cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="faol" className="bg-slate-800">
|
||||
Faol
|
||||
</option>
|
||||
<option value="nofaol" className="bg-slate-800">
|
||||
No-faol
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => navigate(`/agencies/${agency.id}`)}
|
||||
className="flex-1 cursor-pointer bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-500 hover:to-cyan-500 text-white rounded-lg px-4 py-2 font-medium transition-all flex items-center justify-center gap-2 shadow-lg hover:shadow-cyan-500/50"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Ko'rish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
|
||||
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:border-slate-500"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{[...Array(totalPages)].map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setCurrentPage(i + 1)}
|
||||
className={`px-4 py-2 rounded-lg border transition-all font-medium ${
|
||||
currentPage === i + 1
|
||||
? "bg-gradient-to-r from-blue-600 to-cyan-600 border-blue-500 text-white shadow-lg shadow-cyan-500/50"
|
||||
: "border-slate-600 text-slate-300 hover:bg-slate-700/50 hover:border-slate-500"
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
|
||||
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:border-slate-500"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
gradient,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
icon: React.ReactNode;
|
||||
gradient: string;
|
||||
shadowColor: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative hover:scale-105 transition-transform duration-300">
|
||||
<div
|
||||
className={`absolute inset-0 bg-gradient-to-r ${gradient} rounded-xl blur-lg opacity-20 group-hover:opacity-40 transition-all`}
|
||||
/>
|
||||
<div
|
||||
className={`relative bg-gradient-to-br ${gradient} bg-opacity-10 border border-white/10 backdrop-blur-sm rounded-xl p-6 shadow-xl hover:shadow-2xl transition-all hover:border-white/20`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<p className="text-slate-300 text-sm font-medium">{title}</p>
|
||||
<div
|
||||
className={`bg-gradient-to-br ${gradient} p-2 rounded-lg text-white shadow-lg`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-white">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
240
src/pages/agencies/AgencyDetail.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronRight,
|
||||
DollarSign,
|
||||
Package,
|
||||
Percent,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
type Tour = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
sold: number;
|
||||
profit: number;
|
||||
status: "faol" | "nofaol";
|
||||
};
|
||||
|
||||
type Agency = {
|
||||
id: number;
|
||||
name: string;
|
||||
owner: string;
|
||||
status: "faol" | "nofaol";
|
||||
profitPercent: number;
|
||||
totalTours: number;
|
||||
soldTours: number;
|
||||
totalProfit: number;
|
||||
tours: Tour[];
|
||||
};
|
||||
|
||||
export default function AgencyDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useNavigate();
|
||||
const [agency, setAgency] = useState<Agency | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Mock data - replace with actual API call
|
||||
setAgency({
|
||||
id: Number(params.id),
|
||||
name: "Silk Road Travel",
|
||||
owner: "Ali Karimov",
|
||||
status: "faol",
|
||||
profitPercent: 15,
|
||||
totalTours: 12,
|
||||
soldTours: 56,
|
||||
totalProfit: 8900000,
|
||||
tours: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Dubai Tour",
|
||||
description: "7 kunlik hashamatli sayohat",
|
||||
sold: 23,
|
||||
profit: 3450000,
|
||||
status: "faol",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Bali Adventure",
|
||||
description: "10 kunlik ekzotik sayohat",
|
||||
sold: 33,
|
||||
profit: 5450000,
|
||||
status: "faol",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Istanbul Express",
|
||||
description: "5 kunlik madaniy sayohat",
|
||||
sold: 0,
|
||||
profit: 0,
|
||||
status: "nofaol",
|
||||
},
|
||||
],
|
||||
});
|
||||
}, [params.id]);
|
||||
|
||||
if (!agency) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
|
||||
<p className="text-gray-400">Yuklanmoqda...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 w-full">
|
||||
<div className="container mx-auto px-4 py-8 max-w-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => router("/")}
|
||||
className="rounded-full border-gray-700 bg-gray-800 hover:bg-gray-700"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-gray-300" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-white">{agency.name}</h1>
|
||||
<p className="text-gray-400 mt-1">Egasi: {agency.owner}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={agency.status === "faol" ? "default" : "secondary"}
|
||||
className="text-base px-4 py-2"
|
||||
>
|
||||
{agency.status === "faol" ? "Faol" : "No-faol"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{/* Total Tours */}
|
||||
<Card className="border border-gray-700 shadow-lg hover:shadow-xl transition-shadow bg-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-400">
|
||||
Jami turlar
|
||||
</CardTitle>
|
||||
<Package className="w-5 h-5 text-blue-400" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-white">
|
||||
{agency.totalTours}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Qo'shilgan turlar soni
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sold Tours */}
|
||||
<Card className="border border-gray-700 shadow-lg hover:shadow-xl transition-shadow bg-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-400">
|
||||
Sotilgan turlar
|
||||
</CardTitle>
|
||||
<TrendingUp className="w-5 h-5 text-green-400" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-white">
|
||||
{agency.soldTours}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">Jami sotilgan turlar</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Profit Percent */}
|
||||
<Card className="border border-gray-700 shadow-lg hover:shadow-xl transition-shadow bg-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-400">
|
||||
Ulush foizi
|
||||
</CardTitle>
|
||||
<Percent className="w-5 h-5 text-purple-400" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-white">
|
||||
{agency.profitPercent}%
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">Har bir sotuvdan</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Total Profit */}
|
||||
<Card className="border border-gray-700 shadow-lg hover:shadow-xl transition-shadow bg-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-400">
|
||||
Umumiy daromad
|
||||
</CardTitle>
|
||||
<DollarSign className="w-5 h-5 text-yellow-400" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-white">
|
||||
{(agency.totalProfit / 1000).toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">so'm daromad</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tours List */}
|
||||
<Card className="border border-gray-700 shadow-lg bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl text-white">
|
||||
Qo'shilgan turlar
|
||||
</CardTitle>
|
||||
<p className="text-gray-400">
|
||||
Firma tomonidan qo'shilgan barcha turlar ro'yxati
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{agency.tours.map((tour) => (
|
||||
<Link key={tour.id} to={`/tours/${tour.id}`} className="block">
|
||||
<div className="p-5 border border-gray-700 rounded-xl hover:bg-gray-700 transition-all cursor-pointer group bg-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-blue-400 transition-colors">
|
||||
{tour.name}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mb-3">
|
||||
{tour.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-gray-400">Sotilgan:</span>
|
||||
<span className="font-semibold text-white">
|
||||
{tour.sold} ta
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-gray-400">Daromad:</span>
|
||||
<span className="font-semibold text-yellow-400">
|
||||
{(tour.profit / 1000).toLocaleString()} so'm
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-gray-400 group-hover:text-blue-400 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
src/pages/bookings/ui/Bookings.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Eye } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
type Booking = {
|
||||
id: number;
|
||||
userName: string;
|
||||
tourName: string;
|
||||
agentName: string;
|
||||
destination: string;
|
||||
totalAmount: number;
|
||||
paidAmount: number;
|
||||
status: "Paid" | "Partial" | "Pending";
|
||||
};
|
||||
|
||||
const initialBookings: Booking[] = [
|
||||
{
|
||||
id: 1,
|
||||
userName: "Alijon Saidov",
|
||||
tourName: "Ichan Qala - Xiva",
|
||||
agentName: "Xiva Tours",
|
||||
destination: "Xiva",
|
||||
totalAmount: 1200,
|
||||
paidAmount: 1200,
|
||||
status: "Paid",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
userName: "Shahnoza Karimova",
|
||||
tourName: "Samarqandning Qadimiy Go'zalligi",
|
||||
agentName: "Samarqand Travel",
|
||||
destination: "Samarqand",
|
||||
totalAmount: 1500,
|
||||
paidAmount: 800,
|
||||
status: "Partial",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
userName: "Javlon Tursunov",
|
||||
tourName: "Tog'li Chimyon Sayohati",
|
||||
agentName: "Toshkent Explorer",
|
||||
destination: "Toshkent V.",
|
||||
totalAmount: 1000,
|
||||
paidAmount: 0,
|
||||
status: "Pending",
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "Paid":
|
||||
return "bg-gray-600 text-green-500 border-gray-500";
|
||||
case "Partial":
|
||||
return "bg-gray-600 text-yellow-500 border-gray-600";
|
||||
case "Pending":
|
||||
return "bg-gray-600 text-red-500 border-gray-600";
|
||||
default:
|
||||
return "bg-gray-600 text-gray-100 border-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
const BookingsPanel = () => {
|
||||
const [bookings, setBookings] = useState<Booking[]>(initialBookings);
|
||||
|
||||
const handleStatusChange = (id: number, newStatus: Booking["status"]) => {
|
||||
setBookings((prev) =>
|
||||
prev.map((b) => (b.id === id ? { ...b, status: newStatus } : b)),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 p-4 sm:p-8 font-[Inter] text-gray-100 w-full">
|
||||
<div className="max-w-[90%] mx-auto">
|
||||
<h1 className="text-3xl sm:text-4xl font-extrabold text-blue-400 mb-6">
|
||||
Bronlar Paneli
|
||||
</h1>
|
||||
|
||||
<div className="bg-gray-800 shadow-2xl rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-700">
|
||||
<thead className="bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
|
||||
Tour (Agent)
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
|
||||
Destination
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
|
||||
Total / Paid
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-300 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-300 uppercase tracking-wider">
|
||||
Ko'rish
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-gray-800 divide-y divide-gray-700">
|
||||
{bookings.map((booking) => (
|
||||
<tr
|
||||
key={booking.id}
|
||||
className="hover:bg-gray-700 transition duration-150 ease-in-out"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-100">
|
||||
{booking.userName}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
|
||||
<span className="font-semibold">{booking.tourName}</span>{" "}
|
||||
<span className="text-gray-400">
|
||||
({booking.agentName})
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
|
||||
{booking.destination}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
|
||||
${booking.paidAmount} / ${booking.totalAmount}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||
<select
|
||||
className={`px-3 py-1 text-xs font-semibold rounded-full border ${getStatusColor(
|
||||
booking.status,
|
||||
)}`}
|
||||
value={booking.status}
|
||||
onChange={(e) =>
|
||||
handleStatusChange(
|
||||
booking.id,
|
||||
e.target.value as Booking["status"],
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="Paid">Paid</option>
|
||||
<option value="Partial">Partial</option>
|
||||
<option value="Pending">Pending</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||
<Link to={`/bookings/${booking.id}`}>
|
||||
<button className="bg-blue-600 text-white px-3 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-1 transition-colors">
|
||||
<Eye className="w-4 h-4" /> Details
|
||||
</button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingsPanel;
|
||||
360
src/pages/employees/ui/Employees.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import formatPhone from "@/shared/lib/formatPhone";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/shared/ui/form";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Edit, Phone, Plus, Trash2, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import z from "zod";
|
||||
|
||||
const roles = ["Operator", "Bugalter", "Manager"] as const;
|
||||
|
||||
type Employee = {
|
||||
id: number;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
phone: string;
|
||||
role: "Operator" | "Bugalter" | "Manager";
|
||||
};
|
||||
|
||||
const initialEmployees: Employee[] = [
|
||||
{
|
||||
id: 1,
|
||||
firstname: "Alisher",
|
||||
lastname: "Karimov",
|
||||
phone: "+998901234567",
|
||||
role: "Operator",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
firstname: "Nigora",
|
||||
lastname: "Rahimova",
|
||||
phone: "+998912345678",
|
||||
role: "Bugalter",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
firstname: "Nigora",
|
||||
lastname: "Rahimova",
|
||||
phone: "+998912345678",
|
||||
role: "Manager",
|
||||
},
|
||||
];
|
||||
|
||||
const employeeSchema = z.object({
|
||||
firstname: z.string().min(2, "Kamida 2 ta belgidan iborat bo'lishi kerak"),
|
||||
lastname: z.string().min(2, "Kamida 2 ta belgidan iborat bo'lishi kerak"),
|
||||
phone: z.string().min(9, "Telefon raqam noto'g'ri"),
|
||||
role: z.enum(roles),
|
||||
});
|
||||
|
||||
type EmployeeFormValues = z.infer<typeof employeeSchema>;
|
||||
|
||||
const EmployeesManagement = () => {
|
||||
const form = useForm<EmployeeFormValues>({
|
||||
resolver: zodResolver(employeeSchema),
|
||||
defaultValues: {
|
||||
firstname: "",
|
||||
lastname: "",
|
||||
phone: "+998",
|
||||
role: "Bugalter",
|
||||
},
|
||||
});
|
||||
const [employees, setEmployees] = useState<Employee[]>(initialEmployees);
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [modalMode, setModalMode] = useState("add");
|
||||
const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedEmployee) {
|
||||
form.setValue("firstname", selectedEmployee.firstname);
|
||||
form.setValue("lastname", selectedEmployee.lastname);
|
||||
form.setValue("phone", selectedEmployee.phone);
|
||||
form.setValue("role", selectedEmployee.role);
|
||||
}
|
||||
}, [selectedEmployee, form]);
|
||||
|
||||
const itemsPerPage = 6;
|
||||
const totalPages = Math.ceil(employees.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const currentEmployees = employees.slice(
|
||||
startIndex,
|
||||
startIndex + itemsPerPage,
|
||||
);
|
||||
|
||||
const handleAdd = () => {
|
||||
setModalMode("add");
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (employee: Employee) => {
|
||||
setSelectedEmployee(employee);
|
||||
setShowModal(true);
|
||||
setModalMode("edit");
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (window.confirm("Ushbu xodimni o'chirishni xohlaysizmi?")) {
|
||||
setEmployees(employees.filter((emp) => emp.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = (values: EmployeeFormValues) => {
|
||||
if (modalMode === "add") {
|
||||
const newEmp: Employee = { ...values, id: Date.now() };
|
||||
setEmployees([...employees, newEmp]);
|
||||
} else if (selectedEmployee) {
|
||||
setEmployees(
|
||||
employees.map((emp) =>
|
||||
emp.id === selectedEmployee.id ? { ...emp, ...values } : emp,
|
||||
),
|
||||
);
|
||||
}
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 text-gray-100 p-6">
|
||||
<div className="w-[90%] mx-auto">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
|
||||
Xodimlar
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-2">
|
||||
Jami {employees.length} ta xodim
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="flex cursor-pointer items-center gap-2 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white px-6 py-3 rounded-lg font-semibold transition-all shadow-lg hover:shadow-xl transform hover:scale-105"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Xodim qo'shish
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
{currentEmployees.map((employee) => (
|
||||
<div
|
||||
key={employee.id}
|
||||
className="bg-gray-800/50 backdrop-blur-sm rounded-xl p-6 border border-gray-700 hover:border-blue-500/50 transition-all shadow-lg hover:shadow-2xl transform hover:-translate-y-1"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-white">
|
||||
{employee.firstname} {employee.lastname}
|
||||
</h3>
|
||||
<p className="text-blue-400 text-sm">{employee.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-4">
|
||||
<div className="flex items-center gap-2 text-gray-300">
|
||||
<Phone size={16} className="text-gray-500" />
|
||||
<span className="text-sm">{formatPhone(employee.phone)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4 border-t border-gray-700">
|
||||
<button
|
||||
onClick={() => handleEdit(employee)}
|
||||
className="flex-1 cursor-pointer flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-all font-medium"
|
||||
>
|
||||
<Edit size={16} />
|
||||
Tahrirlash
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(employee.id)}
|
||||
className="flex-1 cursor-pointer flex items-center justify-center gap-2 bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition-all font-medium"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
O'chirish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 disabled:bg-gray-800/50 disabled:cursor-not-allowed text-white rounded-lg transition-all border border-gray-700"
|
||||
>
|
||||
Oldingi
|
||||
</button>
|
||||
|
||||
{[...Array(totalPages)].map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setCurrentPage(i + 1)}
|
||||
className={`px-4 py-2 rounded-lg transition-all border ${
|
||||
currentPage === i + 1
|
||||
? "bg-gradient-to-r from-blue-500 to-purple-600 text-white border-transparent"
|
||||
: "bg-gray-800 hover:bg-gray-700 text-white border-gray-700"
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 disabled:bg-gray-800/50 disabled:cursor-not-allowed text-white rounded-lg transition-all border border-gray-700"
|
||||
>
|
||||
Keyingi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-gray-800 rounded-2xl p-8 max-w-md w-full border border-gray-700 shadow-2xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
{modalMode === "add" ? "Xodim qo'shish" : "Xodimni tahrirlash"}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="firstname"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label>First Name</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="First Name"
|
||||
{...field}
|
||||
className="h-12"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lastname"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label>Last Name</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Last Name"
|
||||
{...field}
|
||||
className="h-12"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label>Phone</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="+998 90 123 45 67"
|
||||
{...field}
|
||||
value={formatPhone(field.value)}
|
||||
className="h-12"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<Label>Role</Label>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(val) => field.onChange(val)}
|
||||
>
|
||||
<SelectTrigger className="w-full !h-12 cursor-pointer">
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="cursor-pointer">
|
||||
{roles.map((r) => (
|
||||
<SelectItem
|
||||
key={r}
|
||||
value={r}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{r}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 cursor-pointer bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white px-6 py-3 rounded-lg font-semibold transition-all shadow-lg"
|
||||
>
|
||||
{modalMode === "add" ? "Qo'shish" : "Saqlash"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
form.reset();
|
||||
}}
|
||||
className="flex-1 bg-gray-700 cursor-pointer hover:bg-gray-600 text-white px-6 py-3 rounded-lg font-semibold transition-all"
|
||||
>
|
||||
Bekor qilish
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmployeesManagement;
|
||||
346
src/pages/faq/ui/Faq.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/shared/ui/form";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Pencil, PlusCircle, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import z from "zod";
|
||||
|
||||
type FaqType = {
|
||||
id: number;
|
||||
category: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
};
|
||||
|
||||
const categories = [
|
||||
{ value: "umumiy", label: "Umumiy" },
|
||||
{ value: "tolov", label: "To‘lov" },
|
||||
{ value: "hujjatlar", label: "Hujjatlar" },
|
||||
{ value: "sugurta", label: "Sug‘urta" },
|
||||
];
|
||||
|
||||
const initialFaqs: FaqType[] = [
|
||||
{
|
||||
id: 1,
|
||||
category: "umumiy",
|
||||
question: "Sayohatni bron qilish uchun qanday to‘lov usullari mavjud?",
|
||||
answer:
|
||||
"Biz kredit karta, Payme, Click, va naqd to‘lovni qabul qilamiz. To‘lovlar xavfsiz va ishonchli tizim orqali amalga oshiriladi.",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category: "umumiy",
|
||||
question: "Sayohatni bekor qilsam, pul qaytariladimi?",
|
||||
answer:
|
||||
"Ha, ammo bu bron qilingan turdagi sayohatga bog‘liq. Ba’zi sayohatlar uchun 24 soat oldin bekor qilsangiz, to‘liq qaytariladi.",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
category: "hujjatlar",
|
||||
question: "Pasport muddati tugasa sayohat qilish mumkinmi?",
|
||||
answer:
|
||||
"Yo‘q, pasport muddati kamida 6 oy amal qilishi kerak. Aks holda, mamlakatga kirish rad etiladi.",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
category: "sugurta",
|
||||
question: "Sayohat davomida sug‘urta kerakmi?",
|
||||
answer:
|
||||
"Ha, biz barcha mijozlarga sayohat sug‘urtasini tavsiya qilamiz. Bu favqulodda holatlarda yordam beradi.",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
category: "tolov",
|
||||
question: "To‘lovni bosqichma-bosqich amalga oshirish mumkinmi?",
|
||||
answer: "Ha, ayrim yo‘nalishlar uchun bosqichli to‘lov mavjud.",
|
||||
},
|
||||
];
|
||||
|
||||
const faqForm = z.object({
|
||||
categories: z.string().min(1, { message: "Majburiy maydon" }),
|
||||
title: z.string().min(1, { message: "Majburiy maydon" }),
|
||||
answer: z.string().min(1, { message: "Majburiy maydon" }),
|
||||
});
|
||||
|
||||
const Faq = () => {
|
||||
const [faqs, setFaqs] = useState<FaqType[]>(initialFaqs);
|
||||
const [activeTab, setActiveTab] = useState("umumiy");
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
const [editFaq, setEditFaq] = useState<FaqType | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
|
||||
const filteredFaqs = faqs.filter((faq) => faq.category === activeTab);
|
||||
|
||||
const form = useForm<z.infer<typeof faqForm>>({
|
||||
resolver: zodResolver(faqForm),
|
||||
defaultValues: {
|
||||
answer: "",
|
||||
categories: "",
|
||||
title: "",
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(value: z.infer<typeof faqForm>) {
|
||||
console.log(value);
|
||||
}
|
||||
|
||||
const handleEdit = (faq: FaqType) => {
|
||||
setEditFaq(faq);
|
||||
setOpenModal(true);
|
||||
form.setValue("answer", faq.answer);
|
||||
form.setValue("title", faq.question);
|
||||
form.setValue("categories", faq.category);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (deleteId) {
|
||||
setFaqs((prev) => prev.filter((faq) => faq.id !== deleteId));
|
||||
setDeleteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!openModal) {
|
||||
form.reset();
|
||||
setEditFaq(null);
|
||||
}
|
||||
}, [openModal, form]);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 w-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">FAQ (Savol va javoblar)</h1>
|
||||
<Button
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
setEditFaq(null);
|
||||
setOpenModal(true);
|
||||
}}
|
||||
>
|
||||
<PlusCircle className="w-4 h-4" /> Yangi qo‘shish
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="flex flex-wrap gap-2">
|
||||
{categories.map((cat) => (
|
||||
<TabsTrigger key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={activeTab} className="mt-4">
|
||||
{filteredFaqs.length > 0 ? (
|
||||
<div className="border rounded-md overflow-hidden shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||
<TableHead>Savol</TableHead>
|
||||
<TableHead>Javob</TableHead>
|
||||
<TableHead className="w-[120px] text-center">
|
||||
Amallar
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredFaqs.map((faq, index) => (
|
||||
<TableRow key={faq.id}>
|
||||
<TableCell className="text-center font-medium">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{faq.question}
|
||||
</TableCell>
|
||||
<TableCell className="text-foreground">
|
||||
{faq.answer.length > 80
|
||||
? faq.answer.slice(0, 80) + "..."
|
||||
: faq.answer}
|
||||
</TableCell>
|
||||
<TableCell className="flex justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(faq)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={() => setDeleteId(faq.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm mt-4">
|
||||
Bu bo‘limda savollar yo‘q.
|
||||
</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Dialog open={openModal} onOpenChange={setOpenModal}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-gray-900">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editFaq ? "FAQni tahrirlash" : "Yangi FAQ qo‘shish"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="categories"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Kategoriya</Label>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger className="w-full !h-12 border-gray-700 text-white">
|
||||
<SelectValue placeholder="Kategoriya tanlang" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-gray-700 text-white">
|
||||
<SelectGroup>
|
||||
<SelectLabel>Kategoriyalar</SelectLabel>
|
||||
{categories.map((cat) => (
|
||||
<SelectItem key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Savol</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Savol"
|
||||
{...field}
|
||||
className="h-12 !text-md bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="answer"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Javob</Label>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Javob"
|
||||
{...field}
|
||||
className="min-h-48 max-h-56 !text-md bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpenModal(false);
|
||||
form.reset();
|
||||
}}
|
||||
className="bg-gray-600 px-5 py-5 hover:bg-gray-700 text-white mt-4 cursor-pointer"
|
||||
>
|
||||
Bekor qilish
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-blue-600 px-5 py-5 hover:bg-blue-700 text-white mt-4 cursor-pointer"
|
||||
>
|
||||
{/* {isEditMode ? "Yangilikni saqlash" : "Keyingisi"} */}
|
||||
Qo'shish
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Haqiqatan ham o‘chirmoqchimisiz?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||
Bekor qilish
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
O‘chirish
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Faq;
|
||||
232
src/pages/faq/ui/FaqCategory.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/shared/ui/form";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/ui/table";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Pencil, PlusCircle, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import z from "zod";
|
||||
|
||||
type FaqCategoryType = {
|
||||
id: number;
|
||||
name: string;
|
||||
faqCount: number;
|
||||
};
|
||||
|
||||
// fakeData: kategoriya + savol soni
|
||||
const initialCategories: FaqCategoryType[] = [
|
||||
{ id: 1, name: "umumiy", faqCount: 8 },
|
||||
{ id: 2, name: "to‘lov", faqCount: 5 },
|
||||
{ id: 3, name: "hujjatlar", faqCount: 6 },
|
||||
{ id: 4, name: "sug‘urta", faqCount: 3 },
|
||||
];
|
||||
|
||||
const categoryFormSchema = z.object({
|
||||
name: z.string().min(1, { message: "Kategoriya nomi majburiy" }),
|
||||
});
|
||||
|
||||
const FaqCategory = () => {
|
||||
const [categories, setCategories] =
|
||||
useState<FaqCategoryType[]>(initialCategories);
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
const [editCategory, setEditCategory] = useState<FaqCategoryType | null>(
|
||||
null,
|
||||
);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
|
||||
const form = useForm<z.infer<typeof categoryFormSchema>>({
|
||||
resolver: zodResolver(categoryFormSchema),
|
||||
defaultValues: { name: "" },
|
||||
});
|
||||
|
||||
const onSubmit = (values: z.infer<typeof categoryFormSchema>) => {
|
||||
if (editCategory) {
|
||||
setCategories((prev) =>
|
||||
prev.map((cat) =>
|
||||
cat.id === editCategory.id ? { ...cat, name: values.name } : cat,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const newCategory = {
|
||||
id: Date.now(),
|
||||
name: values.name,
|
||||
faqCount: 0, // yangi kategoriya bo‘lsa 0 ta savol
|
||||
};
|
||||
setCategories((prev) => [...prev, newCategory]);
|
||||
}
|
||||
|
||||
setOpenModal(false);
|
||||
};
|
||||
|
||||
const handleEdit = (cat: FaqCategoryType) => {
|
||||
setEditCategory(cat);
|
||||
form.setValue("name", cat.name);
|
||||
setOpenModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (deleteId) {
|
||||
setCategories((prev) => prev.filter((cat) => cat.id !== deleteId));
|
||||
setDeleteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!openModal) {
|
||||
form.reset();
|
||||
setEditCategory(null);
|
||||
}
|
||||
}, [openModal, form]);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">FAQ Kategoriyalar</h1>
|
||||
<Button
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
setEditCategory(null);
|
||||
setOpenModal(true);
|
||||
}}
|
||||
>
|
||||
<PlusCircle className="w-4 h-4" /> Yangi kategoriya
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Jadval */}
|
||||
<div className="border rounded-md overflow-hidden shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||
<TableHead>Kategoriya nomi</TableHead>
|
||||
<TableHead className="text-center">Savollar soni</TableHead>
|
||||
<TableHead className="w-[120px] text-center">Amallar</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{categories.map((cat, index) => (
|
||||
<TableRow key={cat.id}>
|
||||
<TableCell className="text-center font-medium">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell className="capitalize font-medium">
|
||||
{cat.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{cat.faqCount}</TableCell>
|
||||
<TableCell className="flex justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(cat)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={() => setDeleteId(cat.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
<Dialog open={openModal} onOpenChange={setOpenModal}>
|
||||
<DialogContent className="sm:max-w-[400px] bg-gray-900">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editCategory ? "Kategoriyani tahrirlash" : "Yangi kategoriya"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Kategoriya nomi</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Masalan: umumiy"
|
||||
{...field}
|
||||
className="h-12 bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setOpenModal(false)}
|
||||
className="bg-gray-600 hover:bg-gray-700 text-white"
|
||||
>
|
||||
Bekor qilish
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
{editCategory ? "Saqlash" : "Qo‘shish"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* O‘chirish tasdig‘i */}
|
||||
<Dialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Haqiqatan ham o‘chirmoqchimisiz?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||
Bekor qilish
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
O‘chirish
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FaqCategory;
|
||||
431
src/pages/finance/ui/Finance.tsx
Normal file
@@ -0,0 +1,431 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CreditCard,
|
||||
DollarSign,
|
||||
Eye,
|
||||
Hotel,
|
||||
MapPin,
|
||||
Plane,
|
||||
TrendingUp,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
type Purchase = {
|
||||
id: number;
|
||||
userName: string;
|
||||
userPhone: string;
|
||||
tourName: string;
|
||||
tourId: number;
|
||||
agencyName: string;
|
||||
agencyId: number;
|
||||
destination: string;
|
||||
travelDate: string;
|
||||
amount: number;
|
||||
paymentStatus: "paid" | "pending" | "cancelled" | "refunded";
|
||||
purchaseDate: string;
|
||||
};
|
||||
|
||||
const mockPurchases: Purchase[] = [
|
||||
{
|
||||
id: 1,
|
||||
userName: "Aziza Karimova",
|
||||
userPhone: "+998 90 123 45 67",
|
||||
tourName: "Dubai Luxury Tour",
|
||||
tourId: 1,
|
||||
agencyName: "Silk Road Travel",
|
||||
agencyId: 1,
|
||||
destination: "Dubai, UAE",
|
||||
travelDate: "2025-11-10",
|
||||
amount: 1500000,
|
||||
paymentStatus: "paid",
|
||||
purchaseDate: "2025-10-10",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
userName: "Sardor Rahimov",
|
||||
userPhone: "+998 91 234 56 78",
|
||||
tourName: "Bali Adventure Package",
|
||||
tourId: 2,
|
||||
agencyName: "Silk Road Travel",
|
||||
agencyId: 1,
|
||||
destination: "Bali, Indonesia",
|
||||
travelDate: "2025-11-15",
|
||||
amount: 1800000,
|
||||
paymentStatus: "paid",
|
||||
purchaseDate: "2025-10-12",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
userName: "Nilufar Toshmatova",
|
||||
userPhone: "+998 93 345 67 89",
|
||||
tourName: "Dubai Luxury Tour",
|
||||
tourId: 1,
|
||||
agencyName: "Silk Road Travel",
|
||||
agencyId: 1,
|
||||
destination: "Dubai, UAE",
|
||||
travelDate: "2025-11-20",
|
||||
amount: 1500000,
|
||||
paymentStatus: "pending",
|
||||
purchaseDate: "2025-10-14",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
userName: "Jamshid Alimov",
|
||||
userPhone: "+998 94 456 78 90",
|
||||
tourName: "Istanbul Express Tour",
|
||||
tourId: 3,
|
||||
agencyName: "Orient Express",
|
||||
agencyId: 3,
|
||||
destination: "Istanbul, Turkey",
|
||||
travelDate: "2025-11-05",
|
||||
amount: 1200000,
|
||||
paymentStatus: "cancelled",
|
||||
purchaseDate: "2025-10-08",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
userName: "Madina Yusupova",
|
||||
userPhone: "+998 97 567 89 01",
|
||||
tourName: "Paris Romantic Getaway",
|
||||
tourId: 4,
|
||||
agencyName: "Euro Travels",
|
||||
agencyId: 2,
|
||||
destination: "Paris, France",
|
||||
travelDate: "2025-12-01",
|
||||
amount: 2200000,
|
||||
paymentStatus: "paid",
|
||||
purchaseDate: "2025-10-16",
|
||||
},
|
||||
];
|
||||
|
||||
export default function FinancePage() {
|
||||
const [tab, setTab] = useState<"bookings" | "agencies">("bookings");
|
||||
const [filterStatus, setFilterStatus] = useState<
|
||||
"all" | "paid" | "pending" | "cancelled" | "refunded"
|
||||
>("all");
|
||||
|
||||
const getStatusBadge = (status: Purchase["paymentStatus"]) => {
|
||||
const base =
|
||||
"px-3 py-1 rounded-full text-sm font-medium inline-flex items-center gap-2";
|
||||
switch (status) {
|
||||
case "paid":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-green-900 text-green-400 border border-green-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
Paid
|
||||
</span>
|
||||
);
|
||||
case "pending":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-yellow-900 text-yellow-400 border border-yellow-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-400"></div>
|
||||
Pending
|
||||
</span>
|
||||
);
|
||||
case "cancelled":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-red-900 text-red-400 border border-red-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-red-400"></div>
|
||||
Cancelled
|
||||
</span>
|
||||
);
|
||||
case "refunded":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-blue-900 text-blue-400 border border-blue-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-blue-400"></div>
|
||||
Refunded
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPurchases =
|
||||
filterStatus === "all"
|
||||
? mockPurchases
|
||||
: mockPurchases.filter((p) => p.paymentStatus === filterStatus);
|
||||
|
||||
const totalRevenue = filteredPurchases
|
||||
.filter((p) => p.paymentStatus === "paid")
|
||||
.reduce((sum, p) => sum + p.amount, 0);
|
||||
const pendingRevenue = filteredPurchases
|
||||
.filter((p) => p.paymentStatus === "pending")
|
||||
.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
const agencies = Array.from(
|
||||
new Set(mockPurchases.map((p) => p.agencyId)),
|
||||
).map((id) => {
|
||||
const agencyPurchases = mockPurchases.filter((p) => p.agencyId === id);
|
||||
return {
|
||||
id,
|
||||
name: agencyPurchases[0].agencyName,
|
||||
totalPaid: agencyPurchases
|
||||
.filter((p) => p.paymentStatus === "paid")
|
||||
.reduce((sum, p) => sum + p.amount, 0),
|
||||
pending: agencyPurchases
|
||||
.filter((p) => p.paymentStatus === "pending")
|
||||
.reduce((sum, p) => sum + p.amount, 0),
|
||||
purchaseCount: agencyPurchases.length,
|
||||
destinations: Array.from(
|
||||
new Set(agencyPurchases.map((p) => p.destination)),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-gray-900 text-gray-100">
|
||||
<div className="w-[90%] mx-auto py-6">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Travel Finance Dashboard</h1>
|
||||
<p className="text-gray-400 mt-2">
|
||||
Manage bookings, payments, and agency finances
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-800 rounded-lg p-3 flex items-center">
|
||||
<Plane className="text-blue-400 mr-2" size={20} />
|
||||
<span className="font-medium text-gray-100">Travel Pro</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Toggle */}
|
||||
<div className="flex gap-2 mb-8 bg-gray-800 rounded-xl p-1 w-fit">
|
||||
<button
|
||||
className={`px-6 py-3 rounded-lg flex items-center gap-2 transition-all ${
|
||||
tab === "bookings"
|
||||
? "bg-blue-600 text-white shadow-md"
|
||||
: "text-gray-400 hover:bg-gray-700"
|
||||
}`}
|
||||
onClick={() => setTab("bookings")}
|
||||
>
|
||||
<CreditCard size={18} />
|
||||
Bookings & Payments
|
||||
</button>
|
||||
<button
|
||||
className={`px-6 py-3 rounded-lg flex items-center gap-2 transition-all ${
|
||||
tab === "agencies"
|
||||
? "bg-blue-600 text-white shadow-md"
|
||||
: "text-gray-400 hover:bg-gray-700"
|
||||
}`}
|
||||
onClick={() => setTab("agencies")}
|
||||
>
|
||||
<Users size={18} />
|
||||
Agency Reports
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "bookings" && (
|
||||
<>
|
||||
{/* Filter */}
|
||||
<div className="flex gap-2 mb-6 flex-wrap">
|
||||
{["all", "paid", "pending", "cancelled", "refunded"].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
className={`px-4 py-2 rounded-lg transition-all ${
|
||||
filterStatus === s
|
||||
? "bg-blue-600 text-white shadow-md"
|
||||
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
|
||||
}`}
|
||||
onClick={() =>
|
||||
setFilterStatus(
|
||||
s as
|
||||
| "all"
|
||||
| "paid"
|
||||
| "pending"
|
||||
| "cancelled"
|
||||
| "refunded",
|
||||
)
|
||||
}
|
||||
>
|
||||
{s === "all"
|
||||
? "All Bookings"
|
||||
: s === "paid"
|
||||
? "Paid"
|
||||
: s === "pending"
|
||||
? "Pending"
|
||||
: s === "cancelled"
|
||||
? "Cancelled"
|
||||
: "Refunded"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-gray-800 p-6 rounded-xl shadow flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-gray-400 font-medium">Total Revenue</p>
|
||||
<DollarSign className="text-green-400 w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-green-400 mt-3">
|
||||
${(totalRevenue / 1000000).toFixed(1)}M
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
From completed bookings
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-800 p-6 rounded-xl shadow flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-gray-400 font-medium">Pending Payments</p>
|
||||
<TrendingUp className="text-yellow-400 w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-yellow-400 mt-3">
|
||||
${(pendingRevenue / 1000000).toFixed(1)}M
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Awaiting confirmation
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-800 p-6 rounded-xl shadow flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-gray-400 font-medium">
|
||||
Confirmed Bookings
|
||||
</p>
|
||||
<CreditCard className="text-blue-400 w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-blue-400 mt-3">
|
||||
{
|
||||
filteredPurchases.filter((p) => p.paymentStatus === "paid")
|
||||
.length
|
||||
}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Paid and confirmed</p>
|
||||
</div>
|
||||
<div className="bg-gray-800 p-6 rounded-xl shadow flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-gray-400 font-medium">Pending Bookings</p>
|
||||
<Hotel className="text-purple-400 w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-purple-400 mt-3">
|
||||
{
|
||||
filteredPurchases.filter(
|
||||
(p) => p.paymentStatus === "pending",
|
||||
).length
|
||||
}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Awaiting payment</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking Cards */}
|
||||
<h2 className="text-xl font-bold mb-4">Recent Bookings</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredPurchases.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="bg-gray-800 p-5 rounded-xl shadow hover:shadow-md transition-all flex flex-col justify-between"
|
||||
>
|
||||
<div>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<h2 className="text-lg font-bold text-gray-100">
|
||||
{p.userName}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">{p.userPhone}</p>
|
||||
<p className="mt-3 font-semibold text-gray-200">
|
||||
{p.tourName}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 mt-1 text-gray-400">
|
||||
<MapPin size={14} />
|
||||
<p className="text-sm">{p.destination}</p>
|
||||
</div>
|
||||
<div className="flex justify-between mt-3">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">Travel Date</p>
|
||||
<p className="text-gray-100 font-medium">
|
||||
{p.travelDate}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-gray-500 text-sm">Amount</p>
|
||||
<p className="text-green-400 font-bold">
|
||||
${(p.amount / 1000000).toFixed(1)}M
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-between items-center">
|
||||
{getStatusBadge(p.paymentStatus)}
|
||||
<Link to={`/bookings/${p.id}`}>
|
||||
<button className="bg-blue-600 text-white px-3 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-1 transition-colors">
|
||||
<Eye className="w-4 h-4" /> Details
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "agencies" && (
|
||||
<>
|
||||
<h2 className="text-xl font-bold mb-6">Partner Agencies</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{agencies.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="bg-gray-800 p-6 rounded-xl shadow hover:shadow-md transition-all"
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-3 flex items-center gap-2 text-gray-100">
|
||||
<Users className="text-blue-400" size={20} />
|
||||
{a.name}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="bg-green-900 p-3 rounded-lg">
|
||||
<p className="text-gray-400 text-sm">Paid</p>
|
||||
<p className="text-green-400 font-bold text-lg">
|
||||
${(a.totalPaid / 1000000).toFixed(1)}M
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-yellow-900 p-3 rounded-lg">
|
||||
<p className="text-gray-400 text-sm">Pending</p>
|
||||
<p className="text-yellow-400 font-bold text-lg">
|
||||
${(a.pending / 1000000).toFixed(1)}M
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 text-gray-400">
|
||||
<p className="text-sm mb-1">
|
||||
Bookings:{" "}
|
||||
<span className="font-medium text-gray-100">
|
||||
{a.purchaseCount}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Destinations:{" "}
|
||||
<span className="font-medium text-gray-100">
|
||||
{a.destinations.length}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
to={`/travel/booking/${a.id}`}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-1 transition-colors flex-1 justify-center"
|
||||
>
|
||||
<Eye className="w-4 h-4" /> View Details
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
467
src/pages/finance/ui/FinanceDetailTour.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Download,
|
||||
Eye,
|
||||
Hotel,
|
||||
MapPin,
|
||||
Plane,
|
||||
Share2,
|
||||
Star,
|
||||
TrendingUp,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
type TourPurchase = {
|
||||
id: number;
|
||||
userName: string;
|
||||
userPhone: string;
|
||||
tourName: string;
|
||||
tourId: number;
|
||||
agencyName: string;
|
||||
agencyId: number;
|
||||
destination: string;
|
||||
travelDate: string;
|
||||
amount: number;
|
||||
paymentStatus: "paid" | "pending" | "cancelled" | "refunded";
|
||||
purchaseDate: string;
|
||||
rating: number;
|
||||
review: string;
|
||||
};
|
||||
|
||||
const mockTourData = {
|
||||
id: 1,
|
||||
name: "Dubai Luxury Tour",
|
||||
destination: "Dubai, UAE",
|
||||
duration: "7 days",
|
||||
price: 1500000,
|
||||
totalBookings: 45,
|
||||
totalRevenue: 67500000,
|
||||
averageRating: 4.8,
|
||||
agency: "Silk Road Travel",
|
||||
description:
|
||||
"Experience the ultimate luxury in Dubai with 5-star accommodations, private tours, and exclusive experiences.",
|
||||
inclusions: [
|
||||
"5-star hotel accommodation",
|
||||
"Private city tours",
|
||||
"Desert safari experience",
|
||||
"Burj Khalifa tickets",
|
||||
"Airport transfers",
|
||||
],
|
||||
};
|
||||
|
||||
const mockTourPurchases: TourPurchase[] = [
|
||||
{
|
||||
id: 1,
|
||||
userName: "Aziza Karimova",
|
||||
userPhone: "+998 90 123 45 67",
|
||||
tourName: "Dubai Luxury Tour",
|
||||
tourId: 1,
|
||||
agencyName: "Silk Road Travel",
|
||||
agencyId: 1,
|
||||
destination: "Dubai, UAE",
|
||||
travelDate: "2025-11-10",
|
||||
amount: 1500000,
|
||||
paymentStatus: "paid",
|
||||
purchaseDate: "2025-10-10",
|
||||
rating: 5,
|
||||
review:
|
||||
"Amazing experience! The hotel was luxurious and the tours were well organized.",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
userName: "Sardor Rahimov",
|
||||
userPhone: "+998 91 234 56 78",
|
||||
tourName: "Dubai Luxury Tour",
|
||||
tourId: 1,
|
||||
agencyName: "Silk Road Travel",
|
||||
agencyId: 1,
|
||||
destination: "Dubai, UAE",
|
||||
travelDate: "2025-11-15",
|
||||
amount: 1500000,
|
||||
paymentStatus: "paid",
|
||||
purchaseDate: "2025-10-12",
|
||||
rating: 4,
|
||||
review:
|
||||
"Great tour overall. The desert safari was the highlight of our trip.",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
userName: "Nilufar Toshmatova",
|
||||
userPhone: "+998 93 345 67 89",
|
||||
tourName: "Dubai Luxury Tour",
|
||||
tourId: 1,
|
||||
agencyName: "Silk Road Travel",
|
||||
agencyId: 1,
|
||||
destination: "Dubai, UAE",
|
||||
travelDate: "2025-11-20",
|
||||
amount: 1500000,
|
||||
paymentStatus: "pending",
|
||||
purchaseDate: "2025-10-14",
|
||||
rating: 0,
|
||||
review: "",
|
||||
},
|
||||
];
|
||||
|
||||
export default function FinanceDetailTour() {
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"overview" | "bookings" | "reviews"
|
||||
>("overview");
|
||||
|
||||
const getStatusBadge = (status: TourPurchase["paymentStatus"]) => {
|
||||
const base =
|
||||
"px-3 py-1 rounded-full text-sm font-medium inline-flex items-center gap-2";
|
||||
switch (status) {
|
||||
case "paid":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-green-900 text-green-400 border border-green-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
Paid
|
||||
</span>
|
||||
);
|
||||
case "pending":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-yellow-900 text-yellow-400 border border-yellow-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-400"></div>
|
||||
Pending
|
||||
</span>
|
||||
);
|
||||
case "cancelled":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-red-900 text-red-400 border border-red-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-red-400"></div>
|
||||
Cancelled
|
||||
</span>
|
||||
);
|
||||
case "refunded":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-blue-900 text-blue-400 border border-blue-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-blue-400"></div>
|
||||
Refunded
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStars = (rating: number) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className={`w-4 h-4 ${
|
||||
star <= rating
|
||||
? "text-yellow-400 fill-yellow-400"
|
||||
: "text-gray-600"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const paidBookings = mockTourPurchases.filter(
|
||||
(p) => p.paymentStatus === "paid",
|
||||
);
|
||||
const totalRevenue = paidBookings.reduce((sum, p) => sum + p.amount, 0);
|
||||
const pendingRevenue = mockTourPurchases
|
||||
.filter((p) => p.paymentStatus === "pending")
|
||||
.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-gray-900 text-gray-100">
|
||||
<div className="w-[90%] mx-auto py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/finance"
|
||||
className="bg-gray-800 p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Tour Financial Details</h1>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Financial performance for {mockTourData.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button className="bg-gray-800 px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
Export Report
|
||||
</button>
|
||||
<button className="bg-blue-600 px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2">
|
||||
<Share2 className="w-4 h-4" />
|
||||
Share
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tour Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-gray-800 p-6 rounded-xl shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-gray-400 font-medium">Total Revenue</p>
|
||||
<DollarSign className="text-green-400 w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-green-400 mt-3">
|
||||
${(totalRevenue / 1000000).toFixed(1)}M
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
From completed bookings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 p-6 rounded-xl shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-gray-400 font-medium">Pending Revenue</p>
|
||||
<TrendingUp className="text-yellow-400 w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-yellow-400 mt-3">
|
||||
${(pendingRevenue / 1000000).toFixed(1)}M
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Awaiting payment</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 p-6 rounded-xl shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-gray-400 font-medium">Total Bookings</p>
|
||||
<Users className="text-blue-400 w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-blue-400 mt-3">
|
||||
{mockTourPurchases.length}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">All bookings</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 p-6 rounded-xl shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-gray-400 font-medium">Average Rating</p>
|
||||
<Star className="text-yellow-400 w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-yellow-400 mt-3">
|
||||
{mockTourData.averageRating}/5
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Customer satisfaction</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="bg-gray-800 rounded-xl shadow">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-700">
|
||||
<button
|
||||
className={`px-6 py-4 font-medium flex items-center gap-2 transition-colors ${
|
||||
activeTab === "overview"
|
||||
? "text-blue-400 border-b-2 border-blue-400"
|
||||
: "text-gray-400 hover:text-gray-300"
|
||||
}`}
|
||||
onClick={() => setActiveTab("overview")}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Tour Overview
|
||||
</button>
|
||||
<button
|
||||
className={`px-6 py-4 font-medium flex items-center gap-2 transition-colors ${
|
||||
activeTab === "bookings"
|
||||
? "text-blue-400 border-b-2 border-blue-400"
|
||||
: "text-gray-400 hover:text-gray-300"
|
||||
}`}
|
||||
onClick={() => setActiveTab("bookings")}
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
Bookings ({mockTourPurchases.length})
|
||||
</button>
|
||||
<button
|
||||
className={`px-6 py-4 font-medium flex items-center gap-2 transition-colors ${
|
||||
activeTab === "reviews"
|
||||
? "text-blue-400 border-b-2 border-blue-400"
|
||||
: "text-gray-400 hover:text-gray-300"
|
||||
}`}
|
||||
onClick={() => setActiveTab("reviews")}
|
||||
>
|
||||
<Star className="w-4 h-4" />
|
||||
Reviews
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{activeTab === "overview" && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Tour Information */}
|
||||
<div className="lg:col-span-2">
|
||||
<h3 className="text-lg font-bold mb-4">Tour Information</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
||||
<Plane className="w-5 h-5 text-blue-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Tour Name</p>
|
||||
<p className="text-gray-100 font-medium">
|
||||
{mockTourData.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
||||
<MapPin className="w-5 h-5 text-green-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Destination</p>
|
||||
<p className="text-gray-100">
|
||||
{mockTourData.destination}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
||||
<Calendar className="w-5 h-5 text-purple-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Duration</p>
|
||||
<p className="text-gray-100">{mockTourData.duration}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
||||
<Hotel className="w-5 h-5 text-yellow-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Agency</p>
|
||||
<p className="text-gray-100">{mockTourData.agency}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-700 rounded-lg">
|
||||
<p className="text-sm text-gray-400 mb-2">Description</p>
|
||||
<p className="text-gray-100">
|
||||
{mockTourData.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold mb-4">Tour Inclusions</h3>
|
||||
<div className="mt-6 p-4 bg-gray-700 rounded-lg">
|
||||
<p className="text-sm text-gray-400 mb-2">Base Price</p>
|
||||
<p className="text-2xl font-bold text-green-400">
|
||||
${(mockTourData.price / 1000000).toFixed(1)}M
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 mt-1">per person</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "bookings" && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-bold mb-4">Recent Bookings</h2>
|
||||
{mockTourPurchases.map((purchase) => (
|
||||
<div
|
||||
key={purchase.id}
|
||||
className="bg-gray-700 p-6 rounded-lg hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-100">
|
||||
{purchase.userName}
|
||||
</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{purchase.userPhone}
|
||||
</p>
|
||||
</div>
|
||||
{getStatusBadge(purchase.paymentStatus)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Travel Date</p>
|
||||
<p className="text-gray-100 font-medium">
|
||||
{purchase.travelDate}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Booking Date</p>
|
||||
<p className="text-gray-100">{purchase.purchaseDate}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Amount</p>
|
||||
<p className="text-green-400 font-bold">
|
||||
${(purchase.amount / 1000000).toFixed(1)}M
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-4 border-t border-gray-600">
|
||||
<div className="text-sm text-gray-400">
|
||||
Agency: {purchase.agencyName}
|
||||
</div>
|
||||
<Link
|
||||
to={`/bookings/${purchase.id}`}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
View Details
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "reviews" && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-bold mb-4">Customer Reviews</h2>
|
||||
{mockTourPurchases
|
||||
.filter((purchase) => purchase.rating > 0)
|
||||
.map((purchase) => (
|
||||
<div
|
||||
key={purchase.id}
|
||||
className="bg-gray-700 p-6 rounded-lg hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-100">
|
||||
{purchase.userName}
|
||||
</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{purchase.travelDate}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{renderStars(purchase.rating)}
|
||||
<span className="text-gray-300">
|
||||
{purchase.rating}.0
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-100 mb-4">{purchase.review}</p>
|
||||
|
||||
<div className="flex justify-between items-center pt-4 border-t border-gray-600">
|
||||
<div className="text-sm text-gray-400">
|
||||
Booked on {purchase.purchaseDate}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
452
src/pages/finance/ui/FinanceDetailUsers.tsx
Normal file
@@ -0,0 +1,452 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
CreditCard,
|
||||
DollarSign,
|
||||
Download,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
Share2,
|
||||
TrendingUp,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
type UserPurchase = {
|
||||
id: number;
|
||||
userName: string;
|
||||
userPhone: string;
|
||||
userEmail: string;
|
||||
tourName: string;
|
||||
tourId: number;
|
||||
agencyName: string;
|
||||
agencyId: number;
|
||||
destination: string;
|
||||
travelDate: string;
|
||||
returnDate: string;
|
||||
amount: number;
|
||||
paymentStatus: "paid" | "pending" | "cancelled" | "refunded";
|
||||
paymentMethod: "credit_card" | "paypal" | "bank_transfer" | "crypto";
|
||||
purchaseDate: string;
|
||||
travelers: number;
|
||||
bookingReference: string;
|
||||
};
|
||||
|
||||
const mockUserData = {
|
||||
id: 1,
|
||||
userName: "Aziza Karimova",
|
||||
userPhone: "+998 90 123 45 67",
|
||||
userEmail: "aziza.karimova@example.com",
|
||||
joinDate: "2024-01-15",
|
||||
totalSpent: 4500000,
|
||||
totalBookings: 3,
|
||||
memberLevel: "Gold",
|
||||
};
|
||||
|
||||
const mockUserPurchases: UserPurchase[] = [
|
||||
{
|
||||
id: 1,
|
||||
userName: "Aziza Karimova",
|
||||
userPhone: "+998 90 123 45 67",
|
||||
userEmail: "aziza.karimova@example.com",
|
||||
tourName: "Dubai Luxury Tour",
|
||||
tourId: 1,
|
||||
agencyName: "Silk Road Travel",
|
||||
agencyId: 1,
|
||||
destination: "Dubai, UAE",
|
||||
travelDate: "2025-11-10",
|
||||
returnDate: "2025-11-17",
|
||||
amount: 1500000,
|
||||
paymentStatus: "paid",
|
||||
paymentMethod: "credit_card",
|
||||
purchaseDate: "2025-10-10",
|
||||
travelers: 2,
|
||||
bookingReference: "TRV-DXB-001",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
userName: "Aziza Karimova",
|
||||
userPhone: "+998 90 123 45 67",
|
||||
userEmail: "aziza.karimova@example.com",
|
||||
tourName: "Paris Romantic Getaway",
|
||||
tourId: 4,
|
||||
agencyName: "Euro Travels",
|
||||
agencyId: 2,
|
||||
destination: "Paris, France",
|
||||
travelDate: "2025-12-01",
|
||||
returnDate: "2025-12-08",
|
||||
amount: 2200000,
|
||||
paymentStatus: "paid",
|
||||
paymentMethod: "paypal",
|
||||
purchaseDate: "2025-10-16",
|
||||
travelers: 2,
|
||||
bookingReference: "TRV-PAR-002",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
userName: "Aziza Karimova",
|
||||
userPhone: "+998 90 123 45 67",
|
||||
userEmail: "aziza.karimova@example.com",
|
||||
tourName: "Bali Adventure Package",
|
||||
tourId: 2,
|
||||
agencyName: "Silk Road Travel",
|
||||
agencyId: 1,
|
||||
destination: "Bali, Indonesia",
|
||||
travelDate: "2025-11-15",
|
||||
returnDate: "2025-11-22",
|
||||
amount: 1800000,
|
||||
paymentStatus: "pending",
|
||||
paymentMethod: "bank_transfer",
|
||||
purchaseDate: "2025-10-12",
|
||||
travelers: 1,
|
||||
bookingReference: "TRV-BAL-003",
|
||||
},
|
||||
];
|
||||
|
||||
export default function FinanceDetailUser() {
|
||||
const [activeTab, setActiveTab] = useState<"bookings" | "details">(
|
||||
"bookings",
|
||||
);
|
||||
|
||||
const getStatusBadge = (status: UserPurchase["paymentStatus"]) => {
|
||||
const base =
|
||||
"px-3 py-1 rounded-full text-sm font-medium inline-flex items-center gap-2";
|
||||
switch (status) {
|
||||
case "paid":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-green-900 text-green-400 border border-green-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
Paid
|
||||
</span>
|
||||
);
|
||||
case "pending":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-yellow-900 text-yellow-400 border border-yellow-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-400"></div>
|
||||
Pending
|
||||
</span>
|
||||
);
|
||||
case "cancelled":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-red-900 text-red-400 border border-red-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-red-400"></div>
|
||||
Cancelled
|
||||
</span>
|
||||
);
|
||||
case "refunded":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-blue-900 text-blue-400 border border-blue-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-blue-400"></div>
|
||||
Refunded
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getPaymentMethod = (method: UserPurchase["paymentMethod"]) => {
|
||||
switch (method) {
|
||||
case "credit_card":
|
||||
return "Credit Card";
|
||||
case "paypal":
|
||||
return "PayPal";
|
||||
case "bank_transfer":
|
||||
return "Bank Transfer";
|
||||
case "crypto":
|
||||
return "Cryptocurrency";
|
||||
}
|
||||
};
|
||||
|
||||
const totalSpent = mockUserPurchases
|
||||
.filter((p) => p.paymentStatus === "paid")
|
||||
.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
const pendingAmount = mockUserPurchases
|
||||
.filter((p) => p.paymentStatus === "pending")
|
||||
.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-gray-900 text-gray-100">
|
||||
<div className="w-[90%] mx-auto py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/finance"
|
||||
className="bg-gray-800 p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">User Financial Details</h1>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Detailed financial overview for {mockUserData.userName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button className="bg-gray-800 px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
Export
|
||||
</button>
|
||||
<button className="bg-blue-600 px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2">
|
||||
<Share2 className="w-4 h-4" />
|
||||
Share Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-gray-800 p-6 rounded-xl shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-gray-400 font-medium">Total Spent</p>
|
||||
<DollarSign className="text-green-400 w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-green-400 mt-3">
|
||||
${(totalSpent / 1000000).toFixed(1)}M
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">All completed bookings</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 p-6 rounded-xl shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-gray-400 font-medium">Pending Payments</p>
|
||||
<TrendingUp className="text-yellow-400 w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-yellow-400 mt-3">
|
||||
${(pendingAmount / 1000000).toFixed(1)}M
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Awaiting confirmation</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 p-6 rounded-xl shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-gray-400 font-medium">Total Bookings</p>
|
||||
<CreditCard className="text-blue-400 w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-blue-400 mt-3">
|
||||
{mockUserData.totalBookings}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">All time bookings</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 p-6 rounded-xl shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-gray-400 font-medium">Member Level</p>
|
||||
<User className="text-purple-400 w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-purple-400 mt-3">
|
||||
{mockUserData.memberLevel}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Loyalty status</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="bg-gray-800 rounded-xl shadow">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-700">
|
||||
<button
|
||||
className={`px-6 py-4 font-medium flex items-center gap-2 transition-colors ${
|
||||
activeTab === "bookings"
|
||||
? "text-blue-400 border-b-2 border-blue-400"
|
||||
: "text-gray-400 hover:text-gray-300"
|
||||
}`}
|
||||
onClick={() => setActiveTab("bookings")}
|
||||
>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
Booking History
|
||||
</button>
|
||||
<button
|
||||
className={`px-6 py-4 font-medium flex items-center gap-2 transition-colors ${
|
||||
activeTab === "details"
|
||||
? "text-blue-400 border-b-2 border-blue-400"
|
||||
: "text-gray-400 hover:text-gray-300"
|
||||
}`}
|
||||
onClick={() => setActiveTab("details")}
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
User Details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{activeTab === "bookings" && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-bold mb-4">Booking History</h2>
|
||||
{mockUserPurchases.map((purchase) => (
|
||||
<div
|
||||
key={purchase.id}
|
||||
className="bg-gray-700 p-6 rounded-lg hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-100">
|
||||
{purchase.tourName}
|
||||
</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Booking Ref: {purchase.bookingReference}
|
||||
</p>
|
||||
</div>
|
||||
{getStatusBadge(purchase.paymentStatus)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Destination</p>
|
||||
<p className="text-gray-100">
|
||||
{purchase.destination}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Travel Dates</p>
|
||||
<p className="text-gray-100">
|
||||
{purchase.travelDate} - {purchase.returnDate}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Travelers</p>
|
||||
<p className="text-gray-100">
|
||||
{purchase.travelers} person(s)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Amount</p>
|
||||
<p className="text-green-400 font-bold">
|
||||
${(purchase.amount / 1000000).toFixed(1)}M
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-4 border-t border-gray-600">
|
||||
<div className="text-sm text-gray-400">
|
||||
Booked on {purchase.purchaseDate} •{" "}
|
||||
{getPaymentMethod(purchase.paymentMethod)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "details" && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Personal Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold mb-4">
|
||||
Personal Information
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
||||
<User className="w-5 h-5 text-blue-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Full Name</p>
|
||||
<p className="text-gray-100">{mockUserData.userName}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
||||
<Phone className="w-5 h-5 text-green-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Phone Number</p>
|
||||
<p className="text-gray-100">
|
||||
{mockUserData.userPhone}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
||||
<Mail className="w-5 h-5 text-yellow-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Email Address</p>
|
||||
<p className="text-gray-100">
|
||||
{mockUserData.userEmail}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
||||
<Calendar className="w-5 h-5 text-purple-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Member Since</p>
|
||||
<p className="text-gray-100">{mockUserData.joinDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Travel Preferences */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold mb-4">Travel Statistics</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-gray-700 rounded-lg">
|
||||
<p className="text-sm text-gray-400 mb-2">
|
||||
Favorite Destination
|
||||
</p>
|
||||
<p className="text-gray-100 font-medium">Dubai, UAE</p>
|
||||
<p className="text-sm text-gray-400 mt-1">2 bookings</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-700 rounded-lg">
|
||||
<p className="text-sm text-gray-400 mb-2">
|
||||
Preferred Agency
|
||||
</p>
|
||||
<p className="text-gray-100 font-medium">
|
||||
Silk Road Travel
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
2 out of 3 bookings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-700 rounded-lg">
|
||||
<p className="text-sm text-gray-400 mb-2">
|
||||
Average Booking Value
|
||||
</p>
|
||||
<p className="text-green-400 font-bold">
|
||||
$
|
||||
{(
|
||||
totalSpent /
|
||||
mockUserData.totalBookings /
|
||||
1000000
|
||||
).toFixed(1)}
|
||||
M
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
src/pages/news/lib/api.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { NewsType } from "@/pages/news/lib/type";
|
||||
|
||||
const STORAGE_KEY = "news_data";
|
||||
|
||||
export const getAllNews = (): NewsType[] => {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
return data ? JSON.parse(data) : [];
|
||||
};
|
||||
|
||||
export const addNews = (news: Omit<NewsType, "id" | "createdAt">) => {
|
||||
const all = getAllNews();
|
||||
const newNews: NewsType = {
|
||||
id: "1",
|
||||
createdAt: new Date().toISOString(),
|
||||
...news,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([newNews, ...all]));
|
||||
};
|
||||
|
||||
export const updateNews = (id: string, updated: Partial<NewsType>) => {
|
||||
const all = getAllNews().map((n) => (n.id === id ? { ...n, ...updated } : n));
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
||||
};
|
||||
|
||||
export const deleteNews = (id: string) => {
|
||||
const filtered = getAllNews().filter((n) => n.id !== id);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
|
||||
};
|
||||
|
||||
export const getNewsById = (id: string) => {
|
||||
return getAllNews().find((n) => n.id === id);
|
||||
};
|
||||
54
src/pages/news/lib/data.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { NewsAll } from "./type";
|
||||
|
||||
export const fakeNewsData: NewsAll[] = [
|
||||
{
|
||||
id: 1,
|
||||
short_title: "Yangi sayohat yo‘nalishlari ochildi",
|
||||
slug: "yangi-sayohat-yonalishlari",
|
||||
image: "https://images.unsplash.com/photo-1507525428034-b723cf961d3e",
|
||||
category: { id: 1, name: "Turlar" },
|
||||
short_text:
|
||||
"Bu yozda yangi xalqaro yo‘nalishlar ochilmoqda — Turkiya, Dubay, Malayziya va yana ko‘plab manzillar.",
|
||||
created: "2025-10-15T08:45:00Z",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
short_title: "Tur firmalar uchun yangi litsenziya tizimi",
|
||||
slug: "litsenziya-tizimi",
|
||||
image: "https://images.unsplash.com/photo-1488646953014-85cb44e25828",
|
||||
category: { id: 2, name: "Yangiliklar" },
|
||||
short_text:
|
||||
"Turizm agentliklari uchun raqamli litsenziya olish tizimi ishga tushirildi. Endi barcha jarayon onlayn bo‘ladi.",
|
||||
created: "2025-09-22T12:30:00Z",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
short_title: "Sayohat bozorida narxlar pasaymoqda",
|
||||
slug: "narxlar-pasaymoqda",
|
||||
image: "https://images.unsplash.com/photo-1473625247510-8ceb1760943f",
|
||||
category: { id: 3, name: "Blog" },
|
||||
short_text:
|
||||
"So‘nggi haftalarda xalqaro aviabiletlar narxi 15% gacha arzonlashgani kuzatildi. Mutaxassislar bunga tahlil beradi.",
|
||||
created: "2025-10-10T09:15:00Z",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
short_title: "Yangi mehmonxonalar tarmog‘i ish boshladi",
|
||||
slug: "yangi-mehmonxonalar",
|
||||
image: "https://images.unsplash.com/photo-1566073771259-6a8506099945",
|
||||
category: { id: 4, name: "Yangiliklar" },
|
||||
short_text:
|
||||
"O‘zbekistonda 5 ta yangi premium mehmonxona ochildi. Bu turizm industriyasi uchun muhim qadam.",
|
||||
created: "2025-10-05T15:10:00Z",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
short_title: "Sayyohlar uchun foydali maslahatlar",
|
||||
slug: "sayyohlar-maslahatlar",
|
||||
image: "https://images.unsplash.com/photo-1526778548025-fa2f459cd5c1",
|
||||
category: { id: 5, name: "Blog" },
|
||||
short_text:
|
||||
"Chet elga chiqayotganlar uchun xavfsizlik, valyuta va mobil aloqa haqida 10 ta foydali maslahat.",
|
||||
created: "2025-09-30T10:00:00Z",
|
||||
},
|
||||
];
|
||||
21
src/pages/news/lib/form.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import z from "zod";
|
||||
|
||||
export const newsForm = z.object({
|
||||
title: z.string().min(2, {
|
||||
message: "Username must be at least 2 characters.",
|
||||
}),
|
||||
desc: z.string().min(2, {
|
||||
message: "Username must be at least 2 characters.",
|
||||
}),
|
||||
category: z.string().min(1, {
|
||||
message: "Majburiy maydon",
|
||||
}),
|
||||
banner: z.string().min(1, { message: "Banner rasmi majburiy" }),
|
||||
});
|
||||
|
||||
export const newsPostForm = z.object({
|
||||
desc: z.string().min(2, {
|
||||
message: "Username must be at least 2 characters.",
|
||||
}),
|
||||
banner: z.string().min(1, { message: "Banner rasmi majburiy" }),
|
||||
});
|
||||
33
src/pages/news/lib/type.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// types.ts
|
||||
export interface NewsCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface NewsTag {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface NewsType {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
image?: string;
|
||||
category?: NewsCategory;
|
||||
tags?: NewsTag[];
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface NewsAll {
|
||||
id: number;
|
||||
short_title: string;
|
||||
slug: string;
|
||||
image: string;
|
||||
category: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
short_text: string;
|
||||
created: string;
|
||||
}
|
||||
37
src/pages/news/ui/AddNews.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
import StepOne from "@/pages/news/ui/StepOne";
|
||||
import StepTwo from "@/pages/news/ui/StepTwo";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
const AddNews = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEditMode = useMemo(() => !!id, [id]);
|
||||
const [step, setStep] = useState(1);
|
||||
|
||||
return (
|
||||
<div className="p-8 w-full mx-auto bg-gray-900 text-white rounded-2xl shadow-lg">
|
||||
<h1 className="text-3xl font-bold mb-6">
|
||||
{isEditMode ? "Yangilikni tahrirlash" : "Yangi yangilik qo‘shish"}
|
||||
</h1>
|
||||
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div
|
||||
className={`flex-1 text-center py-2 rounded-l-lg ${step === 1 ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground border"}`}
|
||||
>
|
||||
1. Yangilik sarlavhasi
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 text-center py-2 rounded-r-lg ${step === 2 ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground border"}`}
|
||||
>
|
||||
2. Yangilik ma'lumotlari
|
||||
</div>
|
||||
</div>
|
||||
{step === 1 && <StepOne isEditMode={isEditMode} setStep={setStep} />}
|
||||
{step === 2 && <StepTwo isEditMode={isEditMode} setStep={setStep} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddNews;
|
||||
210
src/pages/news/ui/News.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
import { fakeNewsData } from "@/pages/news/lib/data";
|
||||
import type { NewsAll } from "@/pages/news/lib/type";
|
||||
import formatDate from "@/shared/lib/formatDate";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card } from "@/shared/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import clsx from "clsx";
|
||||
import { Calendar, Edit, FolderOpen, PlusCircle, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const News = () => {
|
||||
const [newsList, setNewsList] = useState<NewsAll[]>(fakeNewsData);
|
||||
const loading = false;
|
||||
const error = null;
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteId !== null) {
|
||||
setNewsList((prev) => prev.filter((t) => t.id !== deleteId));
|
||||
setDeleteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 w-full text-white flex justify-center items-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white mx-auto mb-4"></div>
|
||||
<p className="text-lg">Yuklanmoqda...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 w-full text-white flex justify-center items-center">
|
||||
<div className="text-center">
|
||||
<p className="text-xl text-red-400">{error}</p>
|
||||
<Button className="mt-4">Qayta urinish</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 w-full text-white p-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-8 w-[90%] mx-auto">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">Yangiliklar</h1>
|
||||
<p className="text-gray-400">
|
||||
Jami {newsList.length} ta yangilik mavjud
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate("/news/add")}
|
||||
className="flex items-center gap-2 cursor-pointer bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<PlusCircle size={18} />
|
||||
Yangilik qo'shish
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* News Grid */}
|
||||
<div
|
||||
className={clsx(
|
||||
"gap-6 w-[90%] mx-auto",
|
||||
newsList.length === 0
|
||||
? "flex justify-center items-center min-h-[60vh]"
|
||||
: "grid md:grid-cols-2 lg:grid-cols-3",
|
||||
)}
|
||||
>
|
||||
{newsList.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="mb-6">
|
||||
<div className="w-24 h-24 bg-neutral-800 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<FolderOpen size={48} className="text-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl text-gray-400 mb-2 font-semibold">
|
||||
Hozircha yangilik yo'q
|
||||
</p>
|
||||
<p className="text-gray-500 mb-6">
|
||||
Birinchi yangilikni qo'shib boshlang
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => navigate("/news/add")}
|
||||
className="flex items-center gap-2 mx-auto bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<PlusCircle size={18} /> Yangilik qo'shish
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
newsList.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
className="overflow-hidden bg-neutral-900 hover:bg-neutral-800 transition-all duration-300 border border-neutral-800 hover:border-neutral-700 group"
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="relative h-48 overflow-hidden">
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.short_title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src =
|
||||
"https://images.unsplash.com/photo-1507525428034-b723cf961d3e";
|
||||
}}
|
||||
/>
|
||||
{/* Category Badge */}
|
||||
{item.category && (
|
||||
<Badge className="absolute top-3 left-3 bg-blue-600 hover:bg-blue-700 text-white border-0">
|
||||
<FolderOpen size={12} className="mr-1" />
|
||||
{item.category.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Title */}
|
||||
<h2 className="text-xl font-bold line-clamp-2 group-hover:text-blue-400 transition-colors">
|
||||
{item.short_title}
|
||||
</h2>
|
||||
|
||||
{/* Short Text */}
|
||||
<p className="text-sm text-gray-400 line-clamp-3 leading-relaxed">
|
||||
{item.short_text}
|
||||
</p>
|
||||
|
||||
{/* Date */}
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<Calendar size={14} />
|
||||
<span>{formatDate.format(item.created, "DD.MM.YYYY")}</span>
|
||||
</div>
|
||||
|
||||
{/* Slug */}
|
||||
<div className="pt-2 border-t border-neutral-800">
|
||||
<code className="text-xs text-gray-500 bg-neutral-800 px-2 py-1 rounded">
|
||||
/{item.slug}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-3">
|
||||
<Button
|
||||
onClick={() => navigate(`/news/add`)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="hover:bg-neutral-700 hover:text-blue-400"
|
||||
>
|
||||
<Edit size={16} className="mr-1" />
|
||||
Tahrirlash
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteId(item.id)}
|
||||
className="hover:bg-red-700"
|
||||
>
|
||||
<Trash2 size={16} className="mr-1" />
|
||||
O'chirish
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>
|
||||
<DialogContent className="sm:max-w-[425px] bg-gray-900">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">
|
||||
Yangilikni o'chirishni tasdiqlang
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-muted-foreground">
|
||||
Haqiqatan ham bu turni o'chirib tashlamoqchimisiz? Bu amalni ortga
|
||||
qaytarib bo'lmaydi.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="gap-4 flex">
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||
Bekor qilish
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
O'chirish
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default News;
|
||||
232
src/pages/news/ui/NewsCategory.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/shared/ui/form";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/ui/table";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Edit, PlusCircle, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
type NewsCategoryType = {
|
||||
id: number;
|
||||
name: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
const fakeCategories: NewsCategoryType[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Blog",
|
||||
count: 12,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "News",
|
||||
count: 8,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Tours",
|
||||
count: 5,
|
||||
},
|
||||
];
|
||||
|
||||
const NewsCategory = () => {
|
||||
const newsForm = z.object({
|
||||
title: z.string().min(2, {
|
||||
message: "Username must be at least 2 characters.",
|
||||
}),
|
||||
});
|
||||
const [categories, setCategories] =
|
||||
useState<NewsCategoryType[]>(fakeCategories);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editItem, setEditItem] = useState<NewsCategoryType | null>(null);
|
||||
const form = useForm<z.infer<typeof newsForm>>({
|
||||
resolver: zodResolver(newsForm),
|
||||
defaultValues: {
|
||||
title: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editItem) {
|
||||
form.setValue("title", editItem.name);
|
||||
}
|
||||
}, [editItem, form]);
|
||||
|
||||
const openDialog = () => {
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
function onSubmit() {
|
||||
setIsDialogOpen(false);
|
||||
}
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
setCategories((prev) => prev.filter((c) => c.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 w-full text-gray-100 p-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
News Categories
|
||||
</h1>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
openDialog();
|
||||
form.reset();
|
||||
}}
|
||||
className="flex items-center bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<PlusCircle className="w-4 h-4 mr-2" /> Yangi qo‘shish
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-800 overflow-hidden bg-gray-900">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-800/50">
|
||||
<TableRow>
|
||||
<TableHead className="text-gray-300 w-[60px]">#</TableHead>
|
||||
<TableHead className="text-gray-300">Kategoriya nomi</TableHead>
|
||||
<TableHead className="text-gray-300 text-center">
|
||||
Yangiliklar soni
|
||||
</TableHead>
|
||||
<TableHead className="text-gray-300 text-right">
|
||||
Harakatlar
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{categories.length > 0 ? (
|
||||
categories.map((cat, index) => (
|
||||
<TableRow
|
||||
key={cat.id}
|
||||
className="border-b border-gray-800 hover:bg-gray-800/40 transition-colors"
|
||||
>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<p className="font-medium">{cat.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="secondary" className="bg-gray-700">
|
||||
{cat.count} ta
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-gray-700 text-gray-200 hover:bg-gray-800"
|
||||
onClick={() => {
|
||||
openDialog();
|
||||
setEditItem(cat);
|
||||
}}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-1" /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDelete(cat.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1" /> O‘chirish
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center py-8 text-gray-400"
|
||||
>
|
||||
Hech qanday kategoriya topilmadi
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="bg-gray-900 border border-gray-700 text-gray-100">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editItem ? "Kategoriya tahrirlash" : "Yangi kategoriya qo‘shish"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6 bg-gray-900"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Yangilik nomi</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Masalan: Yangi turistik joylar ochildi"
|
||||
{...field}
|
||||
className="h-12 !text-md bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-blue-600 px-5 py-5 hover:bg-blue-700 text-white mt-4 cursor-pointer"
|
||||
>
|
||||
Saqlash
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsCategory;
|
||||
190
src/pages/news/ui/StepOne.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { newsForm } from "@/pages/news/lib/form";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/shared/ui/form";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type z from "zod";
|
||||
|
||||
const StepOne = ({
|
||||
setStep,
|
||||
isEditMode,
|
||||
}: {
|
||||
setStep: Dispatch<SetStateAction<number>>;
|
||||
isEditMode: boolean;
|
||||
}) => {
|
||||
const categories = [
|
||||
{ name: "Blog", id: "1" },
|
||||
{ name: "Tours", id: "2" },
|
||||
{ name: "News", id: "3" },
|
||||
];
|
||||
|
||||
const form = useForm<z.infer<typeof newsForm>>({
|
||||
resolver: zodResolver(newsForm),
|
||||
defaultValues: {
|
||||
title: "",
|
||||
category: "",
|
||||
banner: "",
|
||||
desc: "",
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit() {
|
||||
setStep(2);
|
||||
}
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6 bg-gray-900"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Yangilik nomi</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Masalan: Yangi turistik joylar ochildi"
|
||||
{...field}
|
||||
className="h-12 !text-md bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="desc"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Yangilik haqida</Label>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Yangilik haqida"
|
||||
{...field}
|
||||
className="min-h-48 max-h-56 !text-md bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="category"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Kategoriya</Label>
|
||||
<FormControl>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<SelectTrigger className="w-full !h-12 bg-gray-800 border-gray-700 text-white">
|
||||
<SelectValue placeholder="Kategoriya tanlang" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-800 border-gray-700 text-white">
|
||||
<SelectGroup>
|
||||
<SelectLabel>Kategoriyalar</SelectLabel>
|
||||
{categories.map((cat) => (
|
||||
<SelectItem key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="banner"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Banner rasmi</Label>
|
||||
<FormControl>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<Input
|
||||
type="file"
|
||||
id="license-files"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
form.setValue("banner", url);
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="license-files"
|
||||
className="w-full border-2 border-dashed h-40 border-[#D3D3D3] flex flex-col items-center gap-2 justify-center py-4 rounded-2xl cursor-pointer"
|
||||
>
|
||||
<p className="font-semibold text-xl text-[#FFFF]">
|
||||
Drag or select files
|
||||
</p>
|
||||
<p className="text-[#FFFF] text-sm">
|
||||
Drop files here or click to browse
|
||||
</p>
|
||||
</label>
|
||||
|
||||
{form.watch("banner") && (
|
||||
<div className="relative size-24 rounded-md overflow-hidden border">
|
||||
<img
|
||||
src={form.watch("banner")}
|
||||
alt={`Nanner`}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => form.setValue("banner", "")}
|
||||
className="absolute top-1 right-1 bg-white/80 rounded-full p-1 shadow hover:bg-white"
|
||||
>
|
||||
<XIcon className="size-4 text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="w-full flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-blue-600 px-5 py-5 hover:bg-blue-700 text-white mt-4 cursor-pointer"
|
||||
>
|
||||
{isEditMode ? "Yangilikni saqlash" : "Keyingisi"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepOne;
|
||||
195
src/pages/news/ui/StepTwo.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/shared/ui/form";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusCircle, Trash2, XIcon } from "lucide-react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import z from "zod";
|
||||
|
||||
const newsItemSchema = z.object({
|
||||
desc: z.string().min(2, {
|
||||
message: "Yangilik matni kamida 2 belgidan iborat bo‘lishi kerak",
|
||||
}),
|
||||
banner: z.string().min(1, { message: "Banner rasmi majburiy" }),
|
||||
});
|
||||
|
||||
const newsListSchema = z.object({
|
||||
items: z
|
||||
.array(newsItemSchema)
|
||||
.min(1, { message: "Kamida 1 ta yangilik kerak" }),
|
||||
});
|
||||
|
||||
type NewsFormType = z.infer<typeof newsListSchema>;
|
||||
|
||||
const StepTwo = ({
|
||||
setStep,
|
||||
isEditMode,
|
||||
}: {
|
||||
setStep: Dispatch<SetStateAction<number>>;
|
||||
isEditMode: boolean;
|
||||
}) => {
|
||||
const form = useForm<NewsFormType>({
|
||||
resolver: zodResolver(newsListSchema),
|
||||
defaultValues: {
|
||||
items: [{ desc: "", banner: "" }],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "items",
|
||||
});
|
||||
const navigator = useNavigate();
|
||||
|
||||
function onSubmit() {
|
||||
navigator("/news");
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-8 bg-gray-900 p-6 rounded-2xl text-white"
|
||||
>
|
||||
<h2 className="text-2xl font-semibold">Yangiliklar ro‘yxati</h2>
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="relative border border-gray-700 bg-gray-800 rounded-xl p-4 space-y-4"
|
||||
>
|
||||
{/* O'chirish tugmasi */}
|
||||
{fields.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
className="absolute top-3 right-3 text-red-400 hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* DESC FIELD */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`items.${index}.desc`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Yangilik haqida</Label>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Yangilik haqida"
|
||||
{...field}
|
||||
className="min-h-48 max-h-56 !text-md bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* BANNER FIELD */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`items.${index}.banner`}
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Banner rasmi</Label>
|
||||
<FormControl>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<Input
|
||||
type="file"
|
||||
id={`file-${index}`}
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
form.setValue(`items.${index}.banner`, url);
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor={`file-${index}`}
|
||||
className="w-full border-2 border-dashed h-40 border-gray-600 hover:border-gray-500 transition-all flex flex-col items-center gap-2 justify-center py-4 rounded-2xl cursor-pointer"
|
||||
>
|
||||
<p className="font-semibold text-xl text-white">
|
||||
Drag or select files
|
||||
</p>
|
||||
<p className="text-gray-300 text-sm">
|
||||
Drop files here or click to browse
|
||||
</p>
|
||||
</label>
|
||||
|
||||
{form.watch(`items.${index}.banner`) && (
|
||||
<div className="relative size-24 rounded-md overflow-hidden border border-gray-700">
|
||||
<img
|
||||
src={form.watch(`items.${index}.banner`)}
|
||||
alt="Banner preview"
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
form.setValue(`items.${index}.banner`, "")
|
||||
}
|
||||
className="absolute top-1 right-1 bg-white/80 rounded-full p-1 shadow hover:bg-white"
|
||||
>
|
||||
<XIcon className="size-4 text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => append({ desc: "", banner: "" })}
|
||||
className="flex items-center px-6 py-5 text-lg gap-2 bg-gray-600 hover:bg-gray-700 text-white cursor-pointer"
|
||||
>
|
||||
<PlusCircle className="size-5" />
|
||||
Qo‘shish
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Navigatsiya tugmalari */}
|
||||
<div className="w-full flex justify-between pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setStep(1)}
|
||||
className="bg-gray-600 hover:bg-gray-700 text-white"
|
||||
>
|
||||
Orqaga
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
{isEditMode ? "Yangiliklarni saqlash" : "Saqlash"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepTwo;
|
||||
269
src/pages/seo/ui/Seo.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { useState, type ChangeEvent } from "react";
|
||||
|
||||
type SeoData = {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords: string;
|
||||
ogTitle: string;
|
||||
ogDescription: string;
|
||||
ogImage: string;
|
||||
};
|
||||
|
||||
export default function Seo() {
|
||||
const [formData, setFormData] = useState<SeoData>({
|
||||
title: "",
|
||||
description: "",
|
||||
keywords: "",
|
||||
ogTitle: "",
|
||||
ogDescription: "",
|
||||
ogImage: "",
|
||||
});
|
||||
|
||||
const [savedSeo, setSavedSeo] = useState<SeoData | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
|
||||
const handleChange = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleImageUpload = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const result = event.target?.result as string;
|
||||
setImagePreview(result);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
ogImage: result,
|
||||
}));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setSavedSeo(formData);
|
||||
setFormData({
|
||||
description: "",
|
||||
keywords: "",
|
||||
ogDescription: "",
|
||||
ogImage: "",
|
||||
ogTitle: "",
|
||||
title: "",
|
||||
});
|
||||
};
|
||||
|
||||
const getTitleLength = () => formData.title.length;
|
||||
const getDescriptionLength = () => formData.description.length;
|
||||
|
||||
const isValidTitle = getTitleLength() > 30 && getTitleLength() <= 60;
|
||||
const isValidDescription =
|
||||
getDescriptionLength() > 120 && getDescriptionLength() <= 160;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 p-8 w-full">
|
||||
<div className="max-w-[90%] mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<TrendingUp className="w-8 h-8 text-blue-400" />
|
||||
<h1 className="text-4xl font-bold text-white">SEO Manager</h1>
|
||||
</div>
|
||||
<p className="text-slate-400">
|
||||
Saytingizni qidiruv tizimida yaxshi pozitsiyaga keltiring
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
<div className="bg-slate-800 rounded-lg p-6 space-y-6">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-white mb-2">
|
||||
<FileText className="inline w-4 h-4 mr-1" /> Page Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
placeholder="Sahifa sarlavhasi (30–60 belgi)"
|
||||
className="w-full bg-slate-700 text-white px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-sm text-slate-400">
|
||||
{getTitleLength()} / 60
|
||||
</span>
|
||||
{isValidTitle && (
|
||||
<CheckCircle className="w-5 h-5 text-green-400" />
|
||||
)}
|
||||
{getTitleLength() > 0 && !isValidTitle && (
|
||||
<AlertCircle className="w-5 h-5 text-yellow-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-white mb-2">
|
||||
Meta Description
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
placeholder="Sahifa tavsifi (120–160 belgi)"
|
||||
className="w-full bg-slate-700 text-white px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-sm text-slate-400">
|
||||
{getDescriptionLength()} / 160
|
||||
</span>
|
||||
{isValidDescription && (
|
||||
<CheckCircle className="w-5 h-5 text-green-400" />
|
||||
)}
|
||||
{getDescriptionLength() > 0 && !isValidDescription && (
|
||||
<AlertCircle className="w-5 h-5 text-yellow-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keywords */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-white mb-2">
|
||||
Keywords
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="keywords"
|
||||
value={formData.keywords}
|
||||
onChange={handleChange}
|
||||
placeholder="Kalit so'zlar (vergul bilan ajratilgan)"
|
||||
className="w-full bg-slate-700 text-white px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Masalan: Python, Web Development, Coding
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* OG Tags */}
|
||||
<div className="border-t border-slate-700 pt-6">
|
||||
<h3 className="text-sm font-semibold text-white mb-4">
|
||||
Open Graph (Ijtimoiy Tarmoqlar)
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-2">
|
||||
OG Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="ogTitle"
|
||||
value={formData.ogTitle}
|
||||
onChange={handleChange}
|
||||
placeholder="Ijtimoiy tarmoqdagi sarlavha"
|
||||
className="w-full bg-slate-700 text-white px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-2">
|
||||
OG Description
|
||||
</label>
|
||||
<textarea
|
||||
name="ogDescription"
|
||||
value={formData.ogDescription}
|
||||
onChange={handleChange}
|
||||
placeholder="Ijtimoiy tarmoqdagi tavsif"
|
||||
className="w-full bg-slate-700 text-white px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-2">
|
||||
<ImageIcon className="inline w-4 h-4 mr-1" /> OG Image
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="w-full bg-slate-700 text-white px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 file:bg-blue-600 file:text-white file:px-3 file:py-1 file:rounded file:border-0 file:cursor-pointer"
|
||||
/>
|
||||
{imagePreview && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Preview"
|
||||
className="w-full h-40 object-cover rounded-lg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setImagePreview(null);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
ogImage: "",
|
||||
}));
|
||||
}}
|
||||
className="mt-2 text-xs bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded"
|
||||
>
|
||||
O‘chirish
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 rounded-lg transition-colors"
|
||||
>
|
||||
Saqlash
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saved SEO Data (Preview) */}
|
||||
{savedSeo && (
|
||||
<div className="mt-8 bg-slate-700 rounded-lg p-6 text-slate-200">
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
Saqlangan SEO Ma’lumotlari
|
||||
</h3>
|
||||
<pre className="bg-slate-800 p-4 rounded text-xs overflow-auto">
|
||||
{JSON.stringify(
|
||||
{
|
||||
...savedSeo,
|
||||
ogImage: savedSeo.ogImage
|
||||
? savedSeo.ogImage.substring(0, 100) + "..."
|
||||
: "",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
350
src/pages/site-page/ui/PolicyCrud.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||
import { Checkbox } from "@/shared/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import { Edit2, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ReactQuill from "react-quill-new";
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
|
||||
type Offer = {
|
||||
id: string;
|
||||
title: string;
|
||||
audience: "Foydalanuvchi qo‘llanmasi" | "Maxfiylik siyosati";
|
||||
content: string;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const FAKE_DATA: Offer[] = [
|
||||
{
|
||||
id: "of-1",
|
||||
title: "Ommaviy oferta - Standart shartlar",
|
||||
audience: "Foydalanuvchi qo‘llanmasi",
|
||||
content:
|
||||
"Bu hujjat kompaniya va xizmatlardan foydalanish bo'yicha umumiy shartlarni o'z ichiga oladi.",
|
||||
active: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "of-2",
|
||||
title: "Foydalanuvchi qo‘llanmasi uchun oferta",
|
||||
audience: "Foydalanuvchi qo‘llanmasi",
|
||||
content: "Foydalanuvchi qo‘llanmasi uchun maxsus shartlar va kafolatlar.",
|
||||
active: false,
|
||||
createdAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
const STORAGE_KEY = "ommaviy_oferta_v1";
|
||||
|
||||
export default function PolicyCrud() {
|
||||
const [items, setItems] = useState<Offer[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [editing, setEditing] = useState<Offer | null>(null);
|
||||
const [form, setForm] = useState<Partial<Offer>>({
|
||||
title: "",
|
||||
audience: "Foydalanuvchi qo‘llanmasi",
|
||||
content: "",
|
||||
active: true,
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Offer[];
|
||||
setItems(parsed);
|
||||
} catch {
|
||||
setItems(FAKE_DATA);
|
||||
}
|
||||
} else {
|
||||
setItems(FAKE_DATA);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
}, [items]);
|
||||
|
||||
function resetForm() {
|
||||
setForm({
|
||||
title: "",
|
||||
audience: "Foydalanuvchi qo‘llanmasi",
|
||||
content: "",
|
||||
active: true,
|
||||
});
|
||||
setErrors({});
|
||||
setEditing(null);
|
||||
}
|
||||
|
||||
function validate(f: Partial<Offer>) {
|
||||
const e: Record<string, string> = {};
|
||||
if (!f.title || f.title.trim().length < 3)
|
||||
e.title = "Sarlavha kamida 3 ta belgidan iborat bo'lishi kerak";
|
||||
if (!f.content || f.content.trim().length < 10)
|
||||
e.content = "Kontent kamida 10 ta belgidan iborat bo'lishi kerak";
|
||||
return e;
|
||||
}
|
||||
|
||||
function handleCreateOrUpdate() {
|
||||
const validation = validate(form);
|
||||
if (Object.keys(validation).length) {
|
||||
setErrors(validation);
|
||||
return;
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.id === editing.id ? { ...it, ...(form as Offer) } : it,
|
||||
),
|
||||
);
|
||||
resetForm();
|
||||
} else {
|
||||
const newItem: Offer = {
|
||||
id: `of-${Date.now()}`,
|
||||
title: (form.title || "Untitled").trim(),
|
||||
audience: (form.audience as Offer["audience"]) || "Barcha",
|
||||
content: (form.content || "").trim(),
|
||||
active: form.active ?? true,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setItems((prev) => [newItem, ...prev]);
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(item: Offer) {
|
||||
setEditing(item);
|
||||
setForm({ ...item });
|
||||
setErrors({});
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
setItems((prev) => prev.filter((p) => p.id !== id));
|
||||
}
|
||||
|
||||
function toggleActive(id: string) {
|
||||
setItems((prev) =>
|
||||
prev.map((p) => (p.id === id ? { ...p, active: !p.active } : p)),
|
||||
);
|
||||
}
|
||||
|
||||
const filtered = items.filter((it) => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return (
|
||||
it.title.toLowerCase().includes(q) ||
|
||||
it.content.toLowerCase().includes(q) ||
|
||||
it.audience.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full p-6 bg-gray-900">
|
||||
<div className="max-w-[90%] mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold">Ommaviy oferta</h1>
|
||||
</div>
|
||||
|
||||
<Card className="bg-gray-900">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{editing ? "Tahrirish" : "Yangi oferta yaratish"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Sarlavha</label>
|
||||
<Input
|
||||
value={form.title || ""}
|
||||
onChange={(e) =>
|
||||
setForm((s) => ({ ...s, title: e.target.value }))
|
||||
}
|
||||
placeholder="Ommaviy oferta sarlavhasi"
|
||||
className="mt-1"
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="text-destructive text-sm mt-1">{errors.title}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-full w-[100%]">
|
||||
<label className="text-sm font-medium">Kontent</label>
|
||||
<div className="mt-1">
|
||||
<ReactQuill
|
||||
value={form.content || ""}
|
||||
onChange={(value) =>
|
||||
setForm((s) => ({ ...s, content: value }))
|
||||
}
|
||||
className="bg-gray-900 h-48"
|
||||
placeholder="Oferta matnini kiriting..."
|
||||
/>
|
||||
</div>
|
||||
{errors.content && (
|
||||
<p className="text-destructive text-sm mt-1">
|
||||
{errors.content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 mt-24">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Kimlar uchun</label>
|
||||
<Select
|
||||
value={form.audience || "Barcha"}
|
||||
onValueChange={(value) =>
|
||||
setForm((s) => ({
|
||||
...s,
|
||||
audience: value as Offer["audience"],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 w-full !h-12">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Foydalanuvchi qo‘llanmasi">
|
||||
Foydalanuvchi qo‘llanmasi
|
||||
</SelectItem>
|
||||
<SelectItem value="Maxfiylik siyosati">
|
||||
Maxfiylik siyosati
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={!!form.active}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((s) => ({ ...s, active: checked ? true : false }))
|
||||
}
|
||||
/>
|
||||
<span>Faol</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
onClick={handleCreateOrUpdate}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{editing ? "Saqlash" : "Yaratish"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={resetForm}>
|
||||
Bekor qilish
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Qidirish sarlavha, kontent yoki auditoriya bo'yicha..."
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<div className="flex gap-4 text-sm text-muted-foreground">
|
||||
<span>Natija: {filtered.length}</span>
|
||||
<span>Barcha: {items.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filtered.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-muted-foreground text-center">
|
||||
Natija topilmadi.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{filtered.map((it) => (
|
||||
<Card key={it.id} className="overflow-hidden">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg">{it.title}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{it.audience} • {new Date(it.createdAt).toLocaleString()}
|
||||
</p>
|
||||
<p className="mt-3 text-sm line-clamp-3">{it.content}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center flex-wrap md:flex-col md:flex-nowrap">
|
||||
<Button
|
||||
onClick={() => startEdit(it)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 mr-1" />
|
||||
Tahrirlash
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => toggleActive(it.id)}
|
||||
variant={it.active ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={
|
||||
it.active ? "bg-green-600 hover:bg-green-700" : ""
|
||||
}
|
||||
>
|
||||
{it.active ? "Faol" : "Faol emas"}
|
||||
</Button>
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm">
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
O'chirish
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>O'chirish tasdiqlash</DialogTitle>
|
||||
<DialogDescription>
|
||||
Haqiqatan ham bu ofertani o'chirmoqchimisiz? Bu amalni
|
||||
bekor qilib bo'lmaydi.
|
||||
</DialogDescription>
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<Button>Bekor qilish</Button>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
onClick={() => removeItem(it.id)}
|
||||
>
|
||||
O'chirish
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
351
src/pages/site-page/ui/SitePage.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||
import { Checkbox } from "@/shared/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import { Edit2, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ReactQuill from "react-quill-new";
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
|
||||
type Offer = {
|
||||
id: string;
|
||||
title: string;
|
||||
audience: "Jismoniy shaxslar" | "Yuridik shaxslar";
|
||||
content: string;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const FAKE_DATA: Offer[] = [
|
||||
{
|
||||
id: "of-1",
|
||||
title: "Ommaviy oferta - Standart shartlar",
|
||||
audience: "Jismoniy shaxslar",
|
||||
content:
|
||||
"Bu hujjat kompaniya va xizmatlardan foydalanish bo'yicha umumiy shartlarni o'z ichiga oladi.",
|
||||
active: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "of-2",
|
||||
title: "Yuridik shaxslar uchun oferta",
|
||||
audience: "Yuridik shaxslar",
|
||||
content: "Yuridik shaxslar uchun maxsus shartlar va kafolatlar.",
|
||||
active: false,
|
||||
createdAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
const STORAGE_KEY = "ommaviy_oferta_v1";
|
||||
|
||||
export default function OmmaviyOfertaCRUD() {
|
||||
const [items, setItems] = useState<Offer[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [editing, setEditing] = useState<Offer | null>(null);
|
||||
const [form, setForm] = useState<Partial<Offer>>({
|
||||
title: "",
|
||||
audience: "Jismoniy shaxslar",
|
||||
content: "",
|
||||
active: true,
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Offer[];
|
||||
setItems(parsed);
|
||||
} catch {
|
||||
setItems(FAKE_DATA);
|
||||
}
|
||||
} else {
|
||||
setItems(FAKE_DATA);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
}, [items]);
|
||||
|
||||
function resetForm() {
|
||||
setForm({
|
||||
title: "",
|
||||
audience: "Jismoniy shaxslar",
|
||||
content: "",
|
||||
active: true,
|
||||
});
|
||||
setErrors({});
|
||||
setEditing(null);
|
||||
}
|
||||
|
||||
function validate(f: Partial<Offer>) {
|
||||
const e: Record<string, string> = {};
|
||||
if (!f.title || f.title.trim().length < 3)
|
||||
e.title = "Sarlavha kamida 3 ta belgidan iborat bo'lishi kerak";
|
||||
if (!f.content || f.content.trim().length < 10)
|
||||
e.content = "Kontent kamida 10 ta belgidan iborat bo'lishi kerak";
|
||||
return e;
|
||||
}
|
||||
|
||||
function handleCreateOrUpdate() {
|
||||
const validation = validate(form);
|
||||
if (Object.keys(validation).length) {
|
||||
setErrors(validation);
|
||||
return;
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.id === editing.id ? { ...it, ...(form as Offer) } : it,
|
||||
),
|
||||
);
|
||||
resetForm();
|
||||
} else {
|
||||
const newItem: Offer = {
|
||||
id: `of-${Date.now()}`,
|
||||
title: (form.title || "Untitled").trim(),
|
||||
audience: (form.audience as Offer["audience"]) || "Barcha",
|
||||
content: (form.content || "").trim(),
|
||||
active: form.active ?? true,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setItems((prev) => [newItem, ...prev]);
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(item: Offer) {
|
||||
setEditing(item);
|
||||
setForm({ ...item });
|
||||
setErrors({});
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
setItems((prev) => prev.filter((p) => p.id !== id));
|
||||
}
|
||||
|
||||
function toggleActive(id: string) {
|
||||
setItems((prev) =>
|
||||
prev.map((p) => (p.id === id ? { ...p, active: !p.active } : p)),
|
||||
);
|
||||
}
|
||||
|
||||
const filtered = items.filter((it) => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return (
|
||||
it.title.toLowerCase().includes(q) ||
|
||||
it.content.toLowerCase().includes(q) ||
|
||||
it.audience.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full p-6 bg-gray-900">
|
||||
<div className="max-w-[90%] mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold">Ommaviy oferta</h1>
|
||||
</div>
|
||||
|
||||
<Card className="bg-gray-900">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{editing ? "Tahrirish" : "Yangi oferta yaratish"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Sarlavha</label>
|
||||
<Input
|
||||
value={form.title || ""}
|
||||
onChange={(e) =>
|
||||
setForm((s) => ({ ...s, title: e.target.value }))
|
||||
}
|
||||
placeholder="Ommaviy oferta sarlavhasi"
|
||||
className="mt-1"
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="text-destructive text-sm mt-1">{errors.title}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-full w-[100%]">
|
||||
<label className="text-sm font-medium">Kontent</label>
|
||||
<div className="mt-1">
|
||||
<ReactQuill
|
||||
value={form.content || ""}
|
||||
onChange={(value) =>
|
||||
setForm((s) => ({ ...s, content: value }))
|
||||
}
|
||||
className="bg-gray-900 h-48"
|
||||
placeholder="Oferta matnini kiriting..."
|
||||
/>
|
||||
</div>
|
||||
{errors.content && (
|
||||
<p className="text-destructive text-sm mt-1">
|
||||
{errors.content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 mt-24">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Kimlar uchun</label>
|
||||
<Select
|
||||
value={form.audience || "Barcha"}
|
||||
onValueChange={(value) =>
|
||||
setForm((s) => ({
|
||||
...s,
|
||||
audience: value as Offer["audience"],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 w-full !h-12">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Barcha">Barcha</SelectItem>
|
||||
<SelectItem value="Jismoniy shaxslar">
|
||||
Jismoniy shaxslar uchun
|
||||
</SelectItem>
|
||||
<SelectItem value="Yuridik shaxslar">
|
||||
Yuridik shaxslar uchun
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={!!form.active}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((s) => ({ ...s, active: checked ? true : false }))
|
||||
}
|
||||
/>
|
||||
<span>Faol</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
onClick={handleCreateOrUpdate}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{editing ? "Saqlash" : "Yaratish"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={resetForm}>
|
||||
Bekor qilish
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Qidirish sarlavha, kontent yoki auditoriya bo'yicha..."
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<div className="flex gap-4 text-sm text-muted-foreground">
|
||||
<span>Natija: {filtered.length}</span>
|
||||
<span>Barcha: {items.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filtered.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-muted-foreground text-center">
|
||||
Natija topilmadi.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{filtered.map((it) => (
|
||||
<Card key={it.id} className="overflow-hidden">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg">{it.title}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{it.audience} • {new Date(it.createdAt).toLocaleString()}
|
||||
</p>
|
||||
<p className="mt-3 text-sm line-clamp-3">{it.content}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center flex-wrap md:flex-col md:flex-nowrap">
|
||||
<Button
|
||||
onClick={() => startEdit(it)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 mr-1" />
|
||||
Tahrirlash
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => toggleActive(it.id)}
|
||||
variant={it.active ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={
|
||||
it.active ? "bg-green-600 hover:bg-green-700" : ""
|
||||
}
|
||||
>
|
||||
{it.active ? "Faol" : "Faol emas"}
|
||||
</Button>
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm">
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
O'chirish
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>O'chirish tasdiqlash</DialogTitle>
|
||||
<DialogDescription>
|
||||
Haqiqatan ham bu ofertani o'chirmoqchimisiz? Bu amalni
|
||||
bekor qilib bo'lmaydi.
|
||||
</DialogDescription>
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<Button>Bekor qilish</Button>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
onClick={() => removeItem(it.id)}
|
||||
>
|
||||
O'chirish
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
280
src/pages/support/ui/SupportAgency.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
interface Data {
|
||||
id: number;
|
||||
name: string;
|
||||
address: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
instagram: string;
|
||||
web_site: string;
|
||||
documents: string[];
|
||||
}
|
||||
|
||||
const sampleData: Data[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Alpha Travel",
|
||||
address: "Tashkent, Mustaqillik ko'chasi 12",
|
||||
email: "alpha@example.com",
|
||||
phone: "+998901234567",
|
||||
instagram: "https://instagram.com/alphatravel",
|
||||
web_site: "https://alphatravel.uz",
|
||||
documents: [
|
||||
"https://turan-travel.com/uploads/files/license/sertificate.jpg",
|
||||
"https://turan-travel.com/uploads/files/license/sertificate.jpg",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Samarkand Tours",
|
||||
address: "Samarkand, Registon 5",
|
||||
email: "info@samarktours.uz",
|
||||
phone: "+998903334455",
|
||||
instagram: "https://instagram.com/samarktours",
|
||||
web_site: "https://samarktours.uz",
|
||||
documents: [
|
||||
"https://turan-travel.com/uploads/files/license/sertificate.jpg",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const SupportAgency = ({ requests = sampleData }) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<Data | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return requests;
|
||||
const q = query.toLowerCase();
|
||||
return requests.filter(
|
||||
(r) =>
|
||||
(r.name && r.name.toLowerCase().includes(q)) ||
|
||||
(r.email && r.email.toLowerCase().includes(q)) ||
|
||||
(r.phone && r.phone.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [requests, query]);
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full mx-auto">
|
||||
<h2 className="text-2xl font-semibold mb-4">Agentlik soʻrovlari</h2>
|
||||
|
||||
<div className="flex gap-3 mb-6">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Qidiruv (ism, email yoki telefon)..."
|
||||
className="flex-1 p-2 border rounded-md focus:outline-none focus:ring"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setQuery("")}
|
||||
className="px-4 py-2 bg-gray-500 rounded-md hover:bg-gray-300 transition"
|
||||
>
|
||||
Tozalash
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center text-gray-500">Soʻrov topilmadi.</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="border rounded-lg p-4 shadow-sm hover:shadow-md transition bg-gray-800 text-white"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">{r.name}</h3>
|
||||
<p className="text-md">{r.address}</p>
|
||||
</div>
|
||||
<div className="text-md">{r.phone}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-white">
|
||||
<div>
|
||||
<strong>Email:</strong>{" "}
|
||||
<Link to={`mailto:${r.email}`} className="text-white">
|
||||
{r.email}
|
||||
</Link>
|
||||
</div>
|
||||
{r.instagram && (
|
||||
<div>
|
||||
<strong>Instagram:</strong>{" "}
|
||||
<a
|
||||
href={r.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-white hover:underline"
|
||||
>
|
||||
@
|
||||
{r.instagram.replace(
|
||||
/^https?:\/\/(www\.)?instagram\.com\/?/,
|
||||
"",
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{r.web_site && (
|
||||
<div>
|
||||
<strong>Website:</strong>{" "}
|
||||
<a
|
||||
href={r.web_site}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-white hover:underline"
|
||||
>
|
||||
{r.web_site.replace(/^https?:\/\//, "")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => setSelected(r)}
|
||||
className="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition"
|
||||
>
|
||||
Tafsilotlar
|
||||
</button>
|
||||
<Link
|
||||
to={`mailto:${r.email}`}
|
||||
className="px-3 py-1 rounded border text-sm transition"
|
||||
>
|
||||
Javob yozish
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
<div
|
||||
className="bg-gray-900 rounded-lg max-w-2xl w-full p-6 relative max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelected(null)}
|
||||
className="absolute top-3 right-3 text-white cursor-pointer text-2xl"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-2">{selected.name}</h3>
|
||||
<p className="text-md text-white mb-4">{selected.address}</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-md text-white">Email</div>
|
||||
<a
|
||||
href={`mailto:${selected.email}`}
|
||||
className="block text-white hover:underline"
|
||||
>
|
||||
{selected.email}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-md text-white">Telefon</div>
|
||||
<div>{selected.phone}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-md text-white">Instagram</div>
|
||||
{selected.instagram ? (
|
||||
<a
|
||||
href={selected.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-white hover:underline"
|
||||
>
|
||||
{selected.instagram}
|
||||
</a>
|
||||
) : (
|
||||
<div className="text-white">—</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-white">Website</div>
|
||||
{selected.web_site ? (
|
||||
<a
|
||||
href={selected.web_site}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-white hover:underline"
|
||||
>
|
||||
{selected.web_site}
|
||||
</a>
|
||||
) : (
|
||||
<div className="text-gray-400">—</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="text-sm font-medium mb-2">Hujjatlar</div>
|
||||
{selected.documents && selected.documents.length > 0 ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{selected.documents.map((doc, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={doc}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group relative aspect-square border-2 border-gray-200 rounded-lg overflow-hidden hover:border-blue-500 transition"
|
||||
>
|
||||
<img
|
||||
src={doc}
|
||||
alt={`Hujjat ${i + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition flex items-end">
|
||||
<div className="w-full bg-gradient-to-t from-black/70 to-transparent p-2">
|
||||
<span className="text-white text-xs font-medium">
|
||||
Hujjat {i + 1}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-500">Hujjat topilmadi</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
alert("Qabul qilindi (ishlab chiqishingiz kerak)");
|
||||
setSelected(null);
|
||||
}}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 transition"
|
||||
>
|
||||
Qabul qilish
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
alert("Rad etildi (ishlab chiqishingiz kerak)");
|
||||
setSelected(null);
|
||||
}}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition"
|
||||
>
|
||||
Rad etish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupportAgency;
|
||||
199
src/pages/support/ui/SupportTours.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { MessageCircle, Phone, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
type SupportRequest = {
|
||||
id: number;
|
||||
name: string;
|
||||
phone: string;
|
||||
message: string;
|
||||
status: "Pending" | "Resolved";
|
||||
};
|
||||
|
||||
const initialRequests: SupportRequest[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Alisher Karimov",
|
||||
phone: "+998 90 123 45 67",
|
||||
message: "Sayohat uchun viza hujjatlarini tayyorlashda yordam kerak.",
|
||||
status: "Pending",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Dilnoza Tursunova",
|
||||
phone: "+998 91 765 43 21",
|
||||
message: "To‘lov muvaffaqiyatli o‘tmadi, yordam bera olasizmi?",
|
||||
status: "Resolved",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Jamshid Abdullayev",
|
||||
phone: "+998 93 555 22 11",
|
||||
message: "Sayohat sanasini o‘zgartirishni istayman.",
|
||||
status: "Pending",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Jamshid Abdullayev",
|
||||
phone: "+998 93 555 22 11",
|
||||
message: "Sayohat sanasini o‘zgartirishni istayman.",
|
||||
status: "Pending",
|
||||
},
|
||||
];
|
||||
|
||||
const SupportTours = () => {
|
||||
const [requests, setRequests] = useState<SupportRequest[]>(initialRequests);
|
||||
const [selected, setSelected] = useState<SupportRequest | null>(null);
|
||||
|
||||
const handleToggleStatus = (id: number) => {
|
||||
setRequests((prev) =>
|
||||
prev.map((req) =>
|
||||
req.id === id
|
||||
? {
|
||||
...req,
|
||||
status: req.status === "Pending" ? "Resolved" : "Pending",
|
||||
}
|
||||
: req,
|
||||
),
|
||||
);
|
||||
setSelected((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
status: prev.status === "Pending" ? "Resolved" : "Pending",
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-gray-100 p-6 space-y-6 w-full">
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-4 text-white">
|
||||
Yordam so‘rovlari
|
||||
</h1>
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-3 lg:grid-cols-3">
|
||||
{requests.map((req) => (
|
||||
<Card
|
||||
key={req.id}
|
||||
className="bg-gray-800/70 border border-gray-700 shadow-md hover:shadow-lg hover:bg-gray-800 transition-all duration-200"
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-lg font-semibold text-white">
|
||||
<User className="w-5 h-5 text-blue-400" />
|
||||
{req.name}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`px-2 py-1 rounded-md text-xs font-medium ${
|
||||
req.status === "Pending"
|
||||
? "bg-red-500/10 text-red-400 border-red-400/40"
|
||||
: "bg-green-500/10 text-green-400 border-green-400/40"
|
||||
}`}
|
||||
>
|
||||
{req.status === "Pending" ? "Kutilmoqda" : "Yakunlangan"}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3 mt-1">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Phone className="w-4 h-4 text-gray-400" />
|
||||
{req.phone}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 text-gray-300">
|
||||
<MessageCircle className="w-4 h-4 text-gray-400 mt-1" />
|
||||
<p className="text-sm leading-relaxed">{req.message}</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3 w-full border-gray-600 text-gray-200 hover:bg-gray-700 hover:text-white"
|
||||
onClick={() => setSelected(req)}
|
||||
>
|
||||
Batafsil ko‘rish
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Modal (Dialog) */}
|
||||
<Dialog open={!!selected} onOpenChange={() => setSelected(null)}>
|
||||
<DialogContent className="bg-gray-800 border border-gray-700 text-gray-100 sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-semibold text-white flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-blue-400" />
|
||||
{selected?.name}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 mt-2">
|
||||
<div className="flex items-center gap-2 text-gray-400">
|
||||
<Phone className="w-4 h-4" />
|
||||
{selected?.phone}
|
||||
</div>
|
||||
<div className="flex items-start gap-2 text-gray-300">
|
||||
<MessageCircle className="w-4 h-4 mt-1" />
|
||||
<p className="text-sm leading-relaxed">{selected?.message}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-400">Status:</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`px-2 py-1 rounded-md text-xs font-medium ${
|
||||
selected?.status === "Pending"
|
||||
? "bg-red-500/10 text-red-400 border-red-400/40"
|
||||
: "bg-green-500/10 text-green-400 border-green-400/40"
|
||||
}`}
|
||||
>
|
||||
{selected?.status === "Pending" ? "Kutilmoqda" : "Yakunlangan"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex justify-end mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-gray-600 text-gray-200 hover:bg-gray-700 hover:text-white"
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
Yopish
|
||||
</Button>
|
||||
{selected && (
|
||||
<Button
|
||||
onClick={() => handleToggleStatus(selected.id)}
|
||||
className={`${
|
||||
selected.status === "Pending"
|
||||
? "bg-green-600 hover:bg-green-700"
|
||||
: "bg-red-600 hover:bg-red-700"
|
||||
} text-white`}
|
||||
>
|
||||
{selected.status === "Pending"
|
||||
? "Yakunlandi deb belgilash"
|
||||
: "Kutilmoqda deb belgilash"}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupportTours;
|
||||
316
src/pages/tour-settings/ui/TourSettings.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
import { Map, Placemark, YMaps } from "@pbe/react-yandex-maps";
|
||||
import { Edit, Plus, Trash } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type MapClickEvent = {
|
||||
get: (key: "coords") => [number, number];
|
||||
};
|
||||
|
||||
type ContactInfo = {
|
||||
telegram?: string;
|
||||
instagram?: string;
|
||||
facebook?: string;
|
||||
twiter?: string;
|
||||
linkedin?: string;
|
||||
address?: string;
|
||||
email?: string;
|
||||
phonePrimary?: string;
|
||||
phoneSecondary?: string;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "site_contact_info";
|
||||
|
||||
async function getAddressFromCoords(lat: number, lon: number) {
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json`,
|
||||
);
|
||||
const data = await response.json();
|
||||
return data.display_name || "";
|
||||
}
|
||||
|
||||
export default function ContactSettings() {
|
||||
const [contact, setContact] = useState<ContactInfo | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState<ContactInfo>({});
|
||||
const [coords, setCoords] = useState({
|
||||
latitude: 41.311081,
|
||||
longitude: 69.240562,
|
||||
}); // Toshkent default
|
||||
|
||||
// Load saved contact
|
||||
useEffect(() => {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) setContact(JSON.parse(raw));
|
||||
}, []);
|
||||
|
||||
// Populate form when editing
|
||||
useEffect(() => {
|
||||
if (open && editing && contact) setForm(contact);
|
||||
if (!open && !editing) setForm({});
|
||||
}, [open, editing, contact]);
|
||||
|
||||
const handleChange = <K extends keyof ContactInfo>(
|
||||
key: K,
|
||||
value: ContactInfo[K],
|
||||
) => {
|
||||
setForm((s) => ({ ...s, [key]: value }));
|
||||
};
|
||||
|
||||
const handleMapClick = async (e: MapClickEvent) => {
|
||||
const lat = e.get("coords")[0];
|
||||
const lon = e.get("coords")[1];
|
||||
setCoords({ latitude: lat, longitude: lon });
|
||||
|
||||
const addressName = await getAddressFromCoords(lat, lon);
|
||||
setForm((s) => ({ ...s, address: addressName }));
|
||||
};
|
||||
|
||||
const saveContact = () => {
|
||||
if (!form.email && !form.phonePrimary) {
|
||||
alert("Iltimos email yoki telefon kiriting");
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||
setContact(form);
|
||||
setOpen(false);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const startAdd = () => {
|
||||
setForm({});
|
||||
setEditing(false);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const startEdit = () => {
|
||||
setEditing(true);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const removeContact = () => {
|
||||
if (!confirm("Contact ma'lumotlarini o'chirishni xohlaysizmi?")) return;
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
setContact(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto p-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-100">
|
||||
Contact settings
|
||||
</h2>
|
||||
{!contact && (
|
||||
<Button onClick={startAdd} className="flex items-center gap-2">
|
||||
<Plus size={16} /> Qo'shish
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!contact ? (
|
||||
<Card className="bg-gray-900">
|
||||
<CardHeader>
|
||||
<CardTitle>Hozircha kontakt ma'lumotlari qo'shilmagan</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sayt uchun telegram, instagram, manzil, email va telefonni bu
|
||||
yerda saqlang. Siz faqat bir marta qo'sha olasiz — keyin
|
||||
tahrirlash mumkin.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button onClick={startAdd} className="flex items-center gap-2">
|
||||
<Plus size={14} /> Qo'shish
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="bg-gray-900">
|
||||
<CardHeader className="flex items-center justify-between">
|
||||
<CardTitle>Kontakt ma'lumotlari</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={startEdit}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Edit size={14} /> Tahrirlash
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={removeContact}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Trash size={14} /> O'chirish
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Telegram</div>
|
||||
<div className="text-sm">{contact.telegram || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Instagram</div>
|
||||
<div className="text-sm">{contact.instagram || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Facebook</div>
|
||||
<div className="text-sm">{contact.facebook || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">LinkedIn</div>
|
||||
<div className="text-sm">{contact.linkedin || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Twitter</div>
|
||||
<div className="text-sm">{contact.twiter || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Manzil</div>
|
||||
<div className="text-sm">{contact.address || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Email</div>
|
||||
<div className="text-sm">{contact.email || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Telefonlar</div>
|
||||
<div className="text-sm">
|
||||
{contact.phonePrimary || "—"}
|
||||
{contact.phoneSecondary ? ` • ${contact.phoneSecondary}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Dialog */}
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
setOpen(v);
|
||||
if (!v) setEditing(false);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="h-[90%] overflow-y-scroll">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editing ? "Kontaktni tahrirlash" : "Kontakt qo'shish"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Map */}
|
||||
<YMaps query={{ lang: "en_RU" }}>
|
||||
<Map
|
||||
defaultState={{
|
||||
center: [coords.latitude, coords.longitude],
|
||||
zoom: 13,
|
||||
}}
|
||||
width="100%"
|
||||
height="400px"
|
||||
onClick={handleMapClick}
|
||||
>
|
||||
<Placemark geometry={[coords.latitude, coords.longitude]} />
|
||||
</Map>
|
||||
</YMaps>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 mt-4">
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label>Manzil</Label>
|
||||
<Input
|
||||
value={form.address || ""}
|
||||
onChange={(e) => handleChange("address", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Telegram</Label>
|
||||
<Input
|
||||
value={form.telegram || ""}
|
||||
onChange={(e) => handleChange("telegram", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Instagram</Label>
|
||||
<Input
|
||||
value={form.instagram || ""}
|
||||
onChange={(e) => handleChange("instagram", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Linkedin</Label>
|
||||
<Input
|
||||
value={form.linkedin || ""}
|
||||
onChange={(e) => handleChange("linkedin", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Facebook</Label>
|
||||
<Input
|
||||
value={form.facebook || ""}
|
||||
onChange={(e) => handleChange("facebook", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Twitter</Label>
|
||||
<Input
|
||||
value={form.twiter || ""}
|
||||
onChange={(e) => handleChange("twiter", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
value={form.email || ""}
|
||||
onChange={(e) => handleChange("email", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Asosiy telefon</Label>
|
||||
<Input
|
||||
value={form.phonePrimary || ""}
|
||||
onChange={(e) => handleChange("phonePrimary", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<Label>Qo'shimcha telefon</Label>
|
||||
<Input
|
||||
value={form.phoneSecondary || ""}
|
||||
onChange={(e) => handleChange("phoneSecondary", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
Bekor qilish
|
||||
</Button>
|
||||
<Button onClick={saveContact}>Saqlash</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
src/pages/tours/lib/useTourForm.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
// src/models/tour.ts
|
||||
|
||||
// Ilovada foydalaniladigan barcha modellar
|
||||
export type TicketAmenity = {
|
||||
name: string;
|
||||
icon_name: string; // Misol uchun: "wifi", "pool"
|
||||
};
|
||||
|
||||
export type IncludedService = {
|
||||
image: string;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type HotelMeal = {
|
||||
image: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type HotelData = {
|
||||
name: string; // Joylashadigan manzil nomi
|
||||
rating: number; // Yulduz
|
||||
meal_plan: string; // Ovqatlanish rejasi
|
||||
// Bu malumotlar API orqali keladi deb faraz qilinadi
|
||||
hotel_type_id: number;
|
||||
hotel_amenity_ids: number[];
|
||||
hotel_feature_ids: number[];
|
||||
};
|
||||
|
||||
export type TourFormData = {
|
||||
// Etap 1: Asosiy Tur Ma'lumotlari
|
||||
title: string;
|
||||
price: number;
|
||||
departure: string;
|
||||
destination: string;
|
||||
departure_time: string;
|
||||
travel_time: string;
|
||||
passenger_count: number;
|
||||
languages: string;
|
||||
duration_days: number;
|
||||
hotel_meals_option: string; // 'nonushta', 'yarmpansion'
|
||||
tariff: string; // 'econom', 'business'
|
||||
badge: string; // 'hot', 'new'
|
||||
visa_required: boolean;
|
||||
banner_image: File[];
|
||||
transport: string; // 'airplane', 'bus'
|
||||
|
||||
// Qo'shimcha Modellar
|
||||
tickets_images: string[]; // Bilet rasmlari URL massivi
|
||||
tickets_amenities: TicketAmenity[];
|
||||
tickets_included_services: IncludedService[];
|
||||
tickets_hotel_meals: HotelMeal[];
|
||||
|
||||
// Etap 2: Mehmonxona Ma'lumotlari (yoki uni qismi)
|
||||
hotel_info: string; // Asosiy matn
|
||||
hotel_data: HotelData;
|
||||
};
|
||||
|
||||
// Forma holatini boshqarish uchun boshlang'ich qiymat
|
||||
export const initialTourData: TourFormData = {
|
||||
title: "",
|
||||
price: 0,
|
||||
departure: "",
|
||||
destination: "",
|
||||
departure_time: "",
|
||||
travel_time: "",
|
||||
passenger_count: 1,
|
||||
languages: "O'zbek, Rus",
|
||||
duration_days: 1,
|
||||
hotel_meals_option: "Nonushta",
|
||||
tariff: "Econom",
|
||||
badge: "New",
|
||||
visa_required: false,
|
||||
banner_image: [],
|
||||
transport: "Samolyot",
|
||||
tickets_images: [],
|
||||
tickets_amenities: [{ name: "Wi-Fi", icon_name: "Wifi" }],
|
||||
tickets_included_services: [],
|
||||
tickets_hotel_meals: [],
|
||||
hotel_info: "",
|
||||
hotel_data: {
|
||||
name: "",
|
||||
rating: 5,
|
||||
meal_plan: "Nonushta",
|
||||
hotel_type_id: 1,
|
||||
hotel_amenity_ids: [],
|
||||
hotel_feature_ids: [],
|
||||
},
|
||||
};
|
||||
38
src/pages/tours/ui/CreateEditTour.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import StepOne from "@/pages/tours/ui/StepOne";
|
||||
import StepTwo from "@/pages/tours/ui/StepTwo";
|
||||
import { Hotel, Plane } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
const CreateEditTour = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEditMode = useMemo(() => !!id, [id]);
|
||||
const [step, setStep] = useState(1);
|
||||
|
||||
return (
|
||||
<div className="p-8 w-full mx-auto bg-gray-900">
|
||||
<h1 className="text-3xl font-bold mb-6 text-center">
|
||||
{isEditMode ? "Turni Tahrirlash" : "Yangi Tur Qo'shish"}
|
||||
</h1>
|
||||
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div
|
||||
className={`flex-1 text-center py-2 rounded-l-lg ${step === 1 ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground border"}`}
|
||||
>
|
||||
1. Tur ma'lumotlari <Plane className="w-5 h-5 inline ml-2" />
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 text-center py-2 rounded-r-lg ${step === 2 ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground border"}`}
|
||||
>
|
||||
2. Mehmonxona <Hotel className="w-5 h-5 inline ml-2" />
|
||||
</div>
|
||||
</div>
|
||||
{step === 1 && <StepOne setStep={setStep} />}
|
||||
{step === 2 && <StepTwo setStep={setStep} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateEditTour;
|
||||
1103
src/pages/tours/ui/StepOne.tsx
Normal file
287
src/pages/tours/ui/StepTwo.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/shared/ui/form";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import z from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string().min(2, {
|
||||
message: "Sarlavha kamida 2 ta belgidan iborat bo‘lishi kerak.",
|
||||
}),
|
||||
rating: z.number(),
|
||||
mealPlan: z.string().min(1, { message: "Taom rejasi tanlanishi majburiy" }),
|
||||
hotelType: z
|
||||
.string()
|
||||
.min(1, { message: "Mehmonxona turi tanlanishi majburiy" }),
|
||||
hotelFeatures: z
|
||||
.array(z.string())
|
||||
.min(1, { message: "Kamida 1 ta xususiyat tanlanishi kerak" }),
|
||||
});
|
||||
|
||||
const StepTwo = ({
|
||||
setStep,
|
||||
}: {
|
||||
setStep: Dispatch<SetStateAction<number>>;
|
||||
}) => {
|
||||
const navigator = useNavigate();
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
title: "",
|
||||
rating: 3.0,
|
||||
mealPlan: "",
|
||||
hotelType: "",
|
||||
hotelFeatures: [],
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit() {
|
||||
navigator("tours");
|
||||
}
|
||||
|
||||
const mealPlans = [
|
||||
"Breakfast Only",
|
||||
"Half Board",
|
||||
"Full Board",
|
||||
"All Inclusive",
|
||||
];
|
||||
const hotelTypes = ["Hotel", "Resort", "Guest House", "Apartment"];
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1 items-start">
|
||||
{/* Mehmonxona nomi */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Mehmonxona nomi</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Toshkent - Dubay"
|
||||
{...field}
|
||||
className="h-12 !text-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Mehmonxona rating */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rating"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Mehmonxona raytingi</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="3.0"
|
||||
{...field}
|
||||
className="h-12 !text-md"
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (/^\d*\.?\d*$/.test(val)) {
|
||||
field.onChange(val);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Meal Plan */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mealPlan"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Meal Plan</Label>
|
||||
<FormControl>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="mealPlan"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger className="!h-12 w-full">
|
||||
<SelectValue placeholder="Taom rejasini tanlang" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{mealPlans.map((plan) => (
|
||||
<SelectItem key={plan} value={plan}>
|
||||
{plan}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Hotel Type */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hotelType"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Mehmonxona turi</Label>
|
||||
<FormControl>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="hotelType"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger className="!h-12 w-full">
|
||||
<SelectValue placeholder="Mehmonxona turini tanlang" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{hotelTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* <FormField
|
||||
control={form.control}
|
||||
name="hotelFeatures"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Mehmonxona qulayliklar</Label>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{form.watch("amenities").map((item, idx) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const Icon =
|
||||
(LucideIcons as any)[item.icon_name] || XIcon;
|
||||
return (
|
||||
<Badge
|
||||
key={idx}
|
||||
variant="secondary"
|
||||
className="px-3 py-1 text-sm flex items-center gap-2"
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{item.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = form.getValues("amenities");
|
||||
form.setValue(
|
||||
"amenities",
|
||||
current.filter((_, i: number) => i !== idx),
|
||||
);
|
||||
}}
|
||||
className="ml-1 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<IconSelect
|
||||
setSelectedIcon={setSelectedIcon}
|
||||
selectedIcon={selectedIcon}
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="amenity_name"
|
||||
placeholder="Qulaylik nomi (masalan: Wi-Fi)"
|
||||
className="h-12 !text-md flex-1"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nameInput = document.getElementById(
|
||||
"amenity_name",
|
||||
) as HTMLInputElement;
|
||||
|
||||
if (selectedIcon && nameInput.value) {
|
||||
const current = form.getValues("amenities");
|
||||
form.setValue("amenities", [
|
||||
...current,
|
||||
{
|
||||
icon_name: selectedIcon,
|
||||
name: nameInput.value,
|
||||
},
|
||||
]);
|
||||
nameInput.value = "";
|
||||
setSelectedIcon("");
|
||||
}
|
||||
}}
|
||||
className="h-12"
|
||||
>
|
||||
Qo‘shish
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/> */}
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(1)}
|
||||
className="mt-6 px-6 py-3 bg-gray-600 text-white rounded-md"
|
||||
>
|
||||
Ortga
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-6 px-6 py-3 bg-blue-600 text-white rounded-md"
|
||||
>
|
||||
Saqlash
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepTwo;
|
||||
110
src/pages/tours/ui/TicketsImagesModel.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/shared/ui/form";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
import { ImagePlus, XIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface TicketsImagesModelProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form: any;
|
||||
name: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export default function TicketsImagesModel({
|
||||
form,
|
||||
name,
|
||||
label = "Rasmlar",
|
||||
}: TicketsImagesModelProps) {
|
||||
const [previews, setPreviews] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={name}
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<Label className="text-md">{label}</Label>
|
||||
<FormControl>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
id="ticket-images"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const newFiles = e.target.files
|
||||
? Array.from(e.target.files)
|
||||
: [];
|
||||
const existingFiles = form.getValues(name) || [];
|
||||
const allFiles = [...existingFiles, ...newFiles];
|
||||
|
||||
form.setValue(name, allFiles);
|
||||
const urls = allFiles.map((file) =>
|
||||
URL.createObjectURL(file),
|
||||
);
|
||||
setPreviews(urls);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Upload Zone */}
|
||||
<label
|
||||
htmlFor="ticket-images"
|
||||
className="border-2 border-dashed border-gray-300 h-40 rounded-2xl flex flex-col justify-center items-center cursor-pointer hover:bg-muted/20 transition"
|
||||
>
|
||||
<ImagePlus className="size-8 text-muted-foreground mb-2" />
|
||||
<p className="font-semibold text-white">Rasmlarni tanlang</p>
|
||||
<p className="text-sm text-white">
|
||||
Bir nechta rasm yuklashingiz mumkin
|
||||
</p>
|
||||
</label>
|
||||
|
||||
{/* Preview Images */}
|
||||
{previews.length > 0 && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{previews.map((src, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative size-24 rounded-md overflow-hidden border"
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={`preview-${i}`}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newFiles = form
|
||||
.getValues(name)
|
||||
.filter((_: File, idx: number) => idx !== i);
|
||||
const newPreviews = previews.filter(
|
||||
(_: string, idx: number) => idx !== i,
|
||||
);
|
||||
form.setValue(name, newFiles);
|
||||
setPreviews(newPreviews);
|
||||
}}
|
||||
className="absolute top-1 right-1 bg-white/80 rounded-full p-1 shadow hover:bg-white"
|
||||
>
|
||||
<XIcon className="size-4 text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
642
src/pages/tours/ui/TourDetail.tsx
Normal file
@@ -0,0 +1,642 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Globe,
|
||||
Heart,
|
||||
Hotel,
|
||||
MapPin,
|
||||
Star,
|
||||
Users,
|
||||
Utensils,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
type TourDetail = {
|
||||
id: number;
|
||||
title: string;
|
||||
price: number;
|
||||
departure_date: string;
|
||||
departure: string;
|
||||
destination: string;
|
||||
passenger_count: number;
|
||||
languages: string;
|
||||
rating: number;
|
||||
hotel_info: string;
|
||||
duration_days: number;
|
||||
hotel_meals: string;
|
||||
ticket_images: Array<{ image: string }>;
|
||||
ticket_amenities: Array<{ name: string; icon_name: string }>;
|
||||
ticket_included_services: Array<{
|
||||
image: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
}>;
|
||||
ticket_itinerary: Array<{
|
||||
title: string;
|
||||
duration: number;
|
||||
ticket_itinerary_image: Array<{ image: string }>;
|
||||
ticket_itinerary_destinations: Array<{ name: string }>;
|
||||
}>;
|
||||
ticket_hotel_meals: Array<{ image: string; name: string; desc: string }>;
|
||||
travel_agency_id: string;
|
||||
ticket_comments: Array<{
|
||||
user: { id: number; username: string };
|
||||
text: string;
|
||||
rating: number;
|
||||
}>;
|
||||
tariff: Array<{ name: string }>;
|
||||
is_liked: string;
|
||||
};
|
||||
|
||||
export default function TourDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useNavigate();
|
||||
const [tour, setTour] = useState<TourDetail | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTour({
|
||||
id: Number(params.id),
|
||||
title: "Dubai Hashamatli Sayohati",
|
||||
price: 1500000,
|
||||
departure_date: "2025-11-15",
|
||||
departure: "Toshkent, O'zbekiston",
|
||||
destination: "Dubai, BAA",
|
||||
passenger_count: 30,
|
||||
languages: "O'zbek, Rus, Ingliz",
|
||||
rating: 4.8,
|
||||
hotel_info: "5 yulduzli Atlantis The Palm mehmonxonasi",
|
||||
duration_days: 7,
|
||||
hotel_meals: "Nonushta va kechki ovqat kiritilgan",
|
||||
ticket_images: [
|
||||
{ image: "/dubai-burj-khalifa.png" },
|
||||
{ image: "/dubai-palm-jumeirah.jpg" },
|
||||
{ image: "/dubai-marina.jpg" },
|
||||
{ image: "/dubai-desert-safari.png" },
|
||||
],
|
||||
ticket_amenities: [
|
||||
{ name: "Wi-Fi", icon_name: "wifi" },
|
||||
{ name: "Konditsioner", icon_name: "air-vent" },
|
||||
{ name: "Basseyn", icon_name: "waves" },
|
||||
{ name: "Fitnes zal", icon_name: "dumbbell" },
|
||||
{ name: "Spa markaz", icon_name: "sparkles" },
|
||||
{ name: "Restoran", icon_name: "utensils" },
|
||||
],
|
||||
ticket_included_services: [
|
||||
{
|
||||
image: "/airplane-ticket.jpg",
|
||||
title: "Aviachiptalar",
|
||||
desc: "Toshkent-Dubai-Toshkent yo'nalishi bo'yicha qatnov chiptalar",
|
||||
},
|
||||
{
|
||||
image: "/comfortable-hotel-room.png",
|
||||
title: "Mehmonxona",
|
||||
desc: "5 yulduzli mehmonxonada 6 kecha turar joy",
|
||||
},
|
||||
{
|
||||
image: "/diverse-tour-group.png",
|
||||
title: "Gid xizmati",
|
||||
desc: "Professional gid bilan barcha ekskursiyalar",
|
||||
},
|
||||
{
|
||||
image: "/transfer-car.jpg",
|
||||
title: "Transfer",
|
||||
desc: "Aeroport-mehmonxona-aeroport transferi",
|
||||
},
|
||||
],
|
||||
ticket_itinerary: [
|
||||
{
|
||||
title: "Dubayga kelish va mehmonxonaga joylashish",
|
||||
duration: 1,
|
||||
ticket_itinerary_image: [{ image: "/dubai-airport.jpg" }],
|
||||
ticket_itinerary_destinations: [
|
||||
{ name: "Dubai Xalqaro Aeroporti" },
|
||||
{ name: "Atlantis The Palm" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Burj Khalifa va Dubai Mall sayohati",
|
||||
duration: 1,
|
||||
ticket_itinerary_image: [{ image: "/burj-khalifa-inside.jpg" }],
|
||||
ticket_itinerary_destinations: [
|
||||
{ name: "Burj Khalifa" },
|
||||
{ name: "Dubai Mall" },
|
||||
{ name: "Dubai Fountain" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Sahro safari va beduinlar lageri",
|
||||
duration: 1,
|
||||
ticket_itinerary_image: [{ image: "/dubai-desert-safari.png" }],
|
||||
ticket_itinerary_destinations: [
|
||||
{ name: "Dubai sahro" },
|
||||
{ name: "Beduinlar lageri" },
|
||||
],
|
||||
},
|
||||
],
|
||||
ticket_hotel_meals: [
|
||||
{
|
||||
image: "/breakfast-buffet.png",
|
||||
name: "Nonushta",
|
||||
desc: "Xalqaro bufet nonushtasi har kuni ertalab",
|
||||
},
|
||||
{
|
||||
image: "/dinner-restaurant.jpg",
|
||||
name: "Kechki ovqat",
|
||||
desc: "Mehmonxona restoranida 3 xil menyu tanlovli kechki ovqat",
|
||||
},
|
||||
],
|
||||
travel_agency_id: "1",
|
||||
ticket_comments: [
|
||||
{
|
||||
user: { id: 1, username: "Aziza Karimova" },
|
||||
text: "Ajoyib sayohat bo'ldi! Barcha xizmatlar yuqori darajada. Gid juda professional va mehribon edi.",
|
||||
rating: 5,
|
||||
},
|
||||
{
|
||||
user: { id: 2, username: "Sardor Rahimov" },
|
||||
text: "Mehmonxona va ovqatlar juda yaxshi. Faqat transfer biroz kechikdi, lekin umuman olganda juda yoqdi.",
|
||||
rating: 4,
|
||||
},
|
||||
{
|
||||
user: { id: 3, username: "Nilufar Toshmatova" },
|
||||
text: "Hayotimning eng yaxshi sayohati! Barcha narsani juda yaxshi tashkil qilishgan. Rahmat!",
|
||||
rating: 5,
|
||||
},
|
||||
],
|
||||
tariff: [{ name: "standart" }],
|
||||
is_liked: "true",
|
||||
});
|
||||
}, [params.id]);
|
||||
|
||||
if (!tour) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
|
||||
<p className="text-gray-400">Yuklanmoqda...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderStars = (rating: number) => {
|
||||
return Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-4 h-4 ${i < rating ? "fill-yellow-400 text-yellow-400" : "text-gray-600"}`}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 w-full">
|
||||
<div className="container mx-auto px-4 py-8 max-w-full">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => router(-1)}
|
||||
className="rounded-full border-gray-700 bg-gray-800 hover:bg-gray-700"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-gray-300" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-4xl font-bold text-white">{tour.title}</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full hover:bg-gray-800"
|
||||
>
|
||||
<Heart
|
||||
className={`w-6 h-6 ${tour.is_liked === "true" ? "fill-red-500 text-red-500" : "text-gray-400"}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
{renderStars(Math.floor(tour.rating))}
|
||||
<span className="ml-2 font-semibold">{tour.rating}</span>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<span>{tour.ticket_comments.length} sharh</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
{tour.ticket_images.map((img, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="relative aspect-video rounded-lg overflow-hidden group"
|
||||
>
|
||||
<img
|
||||
src={img.image || "/placeholder.svg"}
|
||||
alt={`${tour.title} ${idx + 1}`}
|
||||
className="w-full h-full object-cover transition-transform group-hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<DollarSign className="w-5 h-5 text-green-400" />
|
||||
<p className="text-sm text-gray-400">Narxi</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{tour.price.toLocaleString()} so'm
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Clock className="w-5 h-5 text-blue-400" />
|
||||
<p className="text-sm text-gray-400">Davomiyligi</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{tour.duration_days} kun
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Users className="w-5 h-5 text-purple-400" />
|
||||
<p className="text-sm text-gray-400">Yo'lovchilar</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{tour.passenger_count} kishi
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Calendar className="w-5 h-5 text-yellow-400" />
|
||||
<p className="text-sm text-gray-400">Jo'nash sanasi</p>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{new Date(tour.departure_date).toLocaleDateString("uz-UZ")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2 md:grid-cols-5 lg:w-auto bg-gray-800 border-gray-700">
|
||||
<TabsTrigger
|
||||
value="overview"
|
||||
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
|
||||
>
|
||||
Umumiy
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="itinerary"
|
||||
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
|
||||
>
|
||||
Marshshrut
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="services"
|
||||
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
|
||||
>
|
||||
Xizmatlar
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="hotel"
|
||||
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
|
||||
>
|
||||
Mehmonxona
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="reviews"
|
||||
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
|
||||
>
|
||||
Sharhlar
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl text-white">
|
||||
Tur haqida ma'lumot
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<MapPin className="w-5 h-5 text-green-400 mt-1" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Jo'nash joyi</p>
|
||||
<p className="font-semibold text-white">
|
||||
{tour.departure}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<MapPin className="w-5 h-5 text-blue-400 mt-1" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Yo'nalish</p>
|
||||
<p className="font-semibold text-white">
|
||||
{tour.destination}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<Globe className="w-5 h-5 text-purple-400 mt-1" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Tillar</p>
|
||||
<p className="font-semibold text-white">
|
||||
{tour.languages}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Hotel className="w-5 h-5 text-yellow-400 mt-1" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Mehmonxona</p>
|
||||
<p className="font-semibold text-white">
|
||||
{tour.hotel_info}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<Utensils className="w-5 h-5 text-green-400 mt-1" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Ovqatlanish</p>
|
||||
<p className="font-semibold text-white">
|
||||
{tour.hotel_meals}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-blue-400 mt-1" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Tarif</p>
|
||||
<p className="font-semibold text-white capitalize">
|
||||
{tour.tariff[0]?.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-gray-700">
|
||||
<h3 className="text-lg font-semibold mb-4 text-white">
|
||||
Qulayliklar
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{tour.ticket_amenities.map((amenity, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex flex-col items-center gap-2 p-4 bg-gray-700/50 rounded-lg"
|
||||
>
|
||||
<CheckCircle2 className="w-6 h-6 text-green-400" />
|
||||
<p className="text-sm text-center font-medium text-white">
|
||||
{amenity.name}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="itinerary" className="space-y-6">
|
||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl text-white">
|
||||
Sayohat marshshruti
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{tour.ticket_itinerary.map((day, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="border-l-4 border-green-400 pl-6 pb-6 last:pb-0"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-base border-gray-600 text-gray-300"
|
||||
>
|
||||
{day.duration}-kun
|
||||
</Badge>
|
||||
<h3 className="text-xl font-semibold text-white">
|
||||
{day.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{day.ticket_itinerary_image.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{day.ticket_itinerary_image.map((img, imgIdx) => (
|
||||
<div
|
||||
key={imgIdx}
|
||||
className="relative aspect-video rounded-lg overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src={img.image || "/placeholder.svg"}
|
||||
alt={day.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{day.ticket_itinerary_destinations.map(
|
||||
(dest, destIdx) => (
|
||||
<Badge
|
||||
key={destIdx}
|
||||
variant="secondary"
|
||||
className="text-sm bg-gray-700 text-gray-300"
|
||||
>
|
||||
<MapPin className="w-3 h-3 mr-1" />
|
||||
{dest.name}
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="services" className="space-y-6">
|
||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl text-white">
|
||||
Narxga kiritilgan xizmatlar
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{tour.ticket_included_services.map((service, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="border border-gray-700 rounded-lg overflow-hidden hover:shadow-xl transition-shadow bg-gray-800"
|
||||
>
|
||||
<div className="relative aspect-video">
|
||||
<img
|
||||
src={service.image || "/placeholder.svg"}
|
||||
alt={service.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold mb-2 text-white">
|
||||
{service.title}
|
||||
</h3>
|
||||
<p className="text-gray-400 text-sm">{service.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="hotel" className="space-y-6">
|
||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl text-white">
|
||||
Mehmonxona va ovqatlanish
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="p-6 bg-gray-700/50 rounded-lg">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Hotel className="w-6 h-6 text-yellow-400" />
|
||||
<h3 className="text-xl font-semibold text-white">
|
||||
{tour.hotel_info}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-gray-400">{tour.hotel_meals}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 text-white">
|
||||
Ovqatlanish tafsilotlari
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{tour.ticket_hotel_meals.map((meal, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="border border-gray-700 rounded-lg overflow-hidden bg-gray-800"
|
||||
>
|
||||
<div className="relative aspect-video">
|
||||
<img
|
||||
src={meal.image || "/placeholder.svg"}
|
||||
alt={meal.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h4 className="text-lg font-semibold mb-2 text-white">
|
||||
{meal.name}
|
||||
</h4>
|
||||
<p className="text-gray-400 text-sm">{meal.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="reviews" className="space-y-6">
|
||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-2xl text-white">
|
||||
Mijozlar sharhlari
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{renderStars(Math.floor(tour.rating))}
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-white">
|
||||
{tour.rating}
|
||||
</span>
|
||||
<span className="text-gray-400">
|
||||
({tour.ticket_comments.length} sharh)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{tour.ticket_comments.map((comment, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="border-b border-gray-700 pb-6 last:border-0 last:pb-0"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<p className="font-semibold text-lg text-white">
|
||||
{comment.user.username}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{renderStars(comment.rating)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-300 leading-relaxed">
|
||||
{comment.text}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Card className="border-gray-700 shadow-lg mt-8 bg-gray-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-1">Tur firmasi</p>
|
||||
<p className="text-xl font-semibold text-white">
|
||||
Firma ID: {tour.travel_agency_id}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router(`/agencies/${tour.travel_agency_id}`)}
|
||||
className="border-gray-700 bg-gray-800 hover:bg-gray-700 text-gray-300"
|
||||
>
|
||||
Firma sahifasiga o'tish
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
src/pages/tours/ui/Tours.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/ui/table";
|
||||
import { Edit, Plane, PlusCircle, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
type Tour = {
|
||||
id: number;
|
||||
image?: string;
|
||||
tickets: string;
|
||||
min_price: string;
|
||||
max_price: string;
|
||||
top_duration: string;
|
||||
top_destinations: string;
|
||||
hotel_features_by_type: string;
|
||||
hotel_types: string;
|
||||
hotel_amenities: string;
|
||||
};
|
||||
|
||||
const Tours = () => {
|
||||
const [tours, setTours] = useState<Tour[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(3);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const mockData: Tour[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
image: `/dubai-marina.jpg`,
|
||||
tickets: `Bilet turi ${i + 1}`,
|
||||
min_price: `${200 + i * 50}$`,
|
||||
max_price: `${400 + i * 70}$`,
|
||||
top_duration: `${3 + i} kun`,
|
||||
top_destinations: `Shahar ${i + 1}`,
|
||||
hotel_features_by_type: "Spa, Wi-Fi, Pool",
|
||||
hotel_types: "5 yulduzli mehmonxona",
|
||||
hotel_amenities: "Nonushta, Parking, Bar",
|
||||
}));
|
||||
|
||||
const itemsPerPage = 6;
|
||||
const start = (page - 1) * itemsPerPage;
|
||||
const end = start + itemsPerPage;
|
||||
|
||||
setTotalPages(Math.ceil(mockData.length / itemsPerPage));
|
||||
setTours(mockData.slice(start, end));
|
||||
}, [page]);
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteId !== null) {
|
||||
setTours((prev) => prev.filter((t) => t.id !== deleteId));
|
||||
setDeleteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-foreground py-10 px-5 w-full">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-semibold">Turlar ro'yxati</h1>
|
||||
<Button onClick={() => navigate("/tours/create")} variant="default">
|
||||
<PlusCircle className="w-5 h-5 mr-2" /> Yangi tur qo'shish
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||
<TableHead className="min-w-[150px]">Manzil</TableHead>
|
||||
<TableHead className="min-w-[120px]">Davomiyligi</TableHead>
|
||||
<TableHead className="min-w-[180px]">Mehmonxona</TableHead>
|
||||
<TableHead className="min-w-[200px]">Narx Oralig'i</TableHead>
|
||||
<TableHead className="min-w-[200px]">Imkoniyatlar</TableHead>
|
||||
<TableHead className="min-w-[150px] text-center">
|
||||
Amallar
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tours.map((tour, idx) => (
|
||||
<TableRow key={tour.id}>
|
||||
<TableCell className="font-medium text-center">
|
||||
{(page - 1) * 6 + idx + 1}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<Plane className="w-4 h-4 text-primary" />
|
||||
{tour.top_destinations}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-primary font-medium">
|
||||
{tour.top_duration}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{tour.hotel_types}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tour.tickets}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-bold text-base text-green-600">
|
||||
{tour.min_price} – {tour.max_price}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{tour.hotel_amenities}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-center">
|
||||
<div className="flex gap-2 justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/tours/${tour.id}/edit`)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={() => setDeleteId(tour.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tours/${tour.id}`)}
|
||||
>
|
||||
Batafsil
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog - Faqat bitta */}
|
||||
<Dialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>
|
||||
<DialogContent className="sm:max-w-[425px] bg-gray-900">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">
|
||||
Turni o'chirishni tasdiqlang
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-muted-foreground">
|
||||
Haqiqatan ham bu turni o'chirib tashlamoqchimisiz? Bu amalni ortga
|
||||
qaytarib bo'lmaydi.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="gap-4 flex">
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||
Bekor qilish
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
O'chirish
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="flex justify-center mt-10 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
Oldingi
|
||||
</Button>
|
||||
<span className="text-sm flex items-center">
|
||||
Sahifa {page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
Keyingi
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tours;
|
||||
461
src/pages/tours/ui/ToursSetting.tsx
Normal file
@@ -0,0 +1,461 @@
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card, CardContent } from "@/shared/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { Edit2, Plus, Search, Trash2 } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
interface Badge {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Tariff {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
interface Transport {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
interface MealPlan {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface HotelType {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
type TabId = "badges" | "tariffs" | "transports" | "mealPlans" | "hotelTypes";
|
||||
|
||||
type DataItem = Badge | Tariff | Transport | MealPlan | HotelType;
|
||||
|
||||
interface FormField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: "text" | "number" | "color" | "textarea" | "select";
|
||||
required: boolean;
|
||||
options?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const ToursSetting: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<TabId>("badges");
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const [modalMode, setModalMode] = useState<"add" | "edit">("add");
|
||||
const [currentItem, setCurrentItem] = useState<DataItem | null>(null);
|
||||
|
||||
const [badges, setBadges] = useState<Badge[]>([
|
||||
{ id: 1, name: "Bestseller", color: "#FFD700" },
|
||||
{ id: 2, name: "Yangi", color: "#4CAF50" },
|
||||
]);
|
||||
|
||||
const [tariffs, setTariffs] = useState<Tariff[]>([
|
||||
{ id: 1, name: "Standart", price: 500 },
|
||||
{ id: 2, name: "Premium", price: 1000 },
|
||||
]);
|
||||
|
||||
const [transports, setTransports] = useState<Transport[]>([
|
||||
{ id: 1, name: "Avtobus", price: 200 },
|
||||
{ id: 2, name: "Minivan", price: 500 },
|
||||
]);
|
||||
|
||||
const [mealPlans, setMealPlans] = useState<MealPlan[]>([
|
||||
{ id: 1, name: "BB (Bed & Breakfast)" },
|
||||
{ id: 2, name: "HB (Half Board)" },
|
||||
{ id: 3, name: "FB (Full Board)" },
|
||||
]);
|
||||
|
||||
const [hotelTypes, setHotelTypes] = useState<HotelType[]>([
|
||||
{ id: 1, name: "3 Yulduz" },
|
||||
{ id: 2, name: "5 Yulduz" },
|
||||
]);
|
||||
|
||||
const [formData, setFormData] = useState<Partial<DataItem>>({});
|
||||
|
||||
const getCurrentData = (): DataItem[] => {
|
||||
switch (activeTab) {
|
||||
case "badges":
|
||||
return badges;
|
||||
case "tariffs":
|
||||
return tariffs;
|
||||
case "transports":
|
||||
return transports;
|
||||
case "mealPlans":
|
||||
return mealPlans;
|
||||
case "hotelTypes":
|
||||
return hotelTypes;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const getSetterFunction = (): React.Dispatch<
|
||||
React.SetStateAction<DataItem[]>
|
||||
> => {
|
||||
switch (activeTab) {
|
||||
case "badges":
|
||||
return setBadges as React.Dispatch<React.SetStateAction<DataItem[]>>;
|
||||
case "tariffs":
|
||||
return setTariffs as React.Dispatch<React.SetStateAction<DataItem[]>>;
|
||||
case "transports":
|
||||
return setTransports as React.Dispatch<
|
||||
React.SetStateAction<DataItem[]>
|
||||
>;
|
||||
case "mealPlans":
|
||||
return setMealPlans as React.Dispatch<React.SetStateAction<DataItem[]>>;
|
||||
case "hotelTypes":
|
||||
return setHotelTypes as React.Dispatch<
|
||||
React.SetStateAction<DataItem[]>
|
||||
>;
|
||||
default:
|
||||
return (() => {}) as React.Dispatch<React.SetStateAction<DataItem[]>>;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredData = getCurrentData().filter((item) =>
|
||||
Object.values(item).some((val) =>
|
||||
val?.toString().toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
const getFormFields = (): FormField[] => {
|
||||
switch (activeTab) {
|
||||
case "badges":
|
||||
return [
|
||||
{ name: "name", label: "Nomi", type: "text", required: true },
|
||||
{ name: "color", label: "Rang", type: "color", required: true },
|
||||
];
|
||||
case "tariffs":
|
||||
return [
|
||||
{ name: "name", label: "Tarif nomi", type: "text", required: true },
|
||||
{ name: "price", label: "Narx", type: "number", required: true },
|
||||
];
|
||||
case "transports":
|
||||
return [
|
||||
{
|
||||
name: "name",
|
||||
label: "Transport nomi",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{ name: "capacity", label: "Sig'im", type: "number", required: true },
|
||||
];
|
||||
case "mealPlans":
|
||||
return [{ name: "name", label: "Nomi", type: "text", required: true }];
|
||||
case "hotelTypes":
|
||||
return [
|
||||
{ name: "name", label: "Tur nomi", type: "text", required: true },
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const openModal = (
|
||||
mode: "add" | "edit",
|
||||
item: DataItem | null = null,
|
||||
): void => {
|
||||
setModalMode(mode);
|
||||
setCurrentItem(item);
|
||||
if (mode === "edit" && item) {
|
||||
setFormData(item);
|
||||
} else {
|
||||
setFormData({});
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = (): void => {
|
||||
setIsModalOpen(false);
|
||||
setFormData({});
|
||||
setCurrentItem(null);
|
||||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
const setter = getSetterFunction();
|
||||
|
||||
if (modalMode === "add") {
|
||||
const newId = Math.max(...getCurrentData().map((i) => i.id), 0) + 1;
|
||||
setter([...getCurrentData(), { ...formData, id: newId } as DataItem]);
|
||||
} else {
|
||||
setter(
|
||||
getCurrentData().map((item) =>
|
||||
item.id === currentItem?.id ? { ...item, ...formData } : item,
|
||||
),
|
||||
);
|
||||
}
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const handleDelete = (id: number): void => {
|
||||
if (window.confirm("Rostdan ham o'chirmoqchimisiz?")) {
|
||||
const setter = getSetterFunction();
|
||||
setter(getCurrentData().filter((item) => item.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ id: "badges", label: "Belgilar" },
|
||||
{ id: "tariffs", label: "Tariflar" },
|
||||
{ id: "transports", label: "Transportlar" },
|
||||
{ id: "mealPlans", label: "Ovqatlanish" },
|
||||
{ id: "hotelTypes", label: "Otel turlari" },
|
||||
];
|
||||
|
||||
const getFieldValue = (fieldName: string): string | number => {
|
||||
return (formData as Record<string, string | number>)[fieldName] || "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 p-6 w-full">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
<h1 className="text-3xl font-bold">Tur Sozlamalari</h1>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => {
|
||||
setActiveTab(v as TabId);
|
||||
setSearchTerm("");
|
||||
}}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<Card className="bg-gray-900">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-between items-center">
|
||||
<div className="relative w-full sm:w-96">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground"
|
||||
size={20}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Qidirish..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => openModal("add")}
|
||||
className="w-full sm:w-auto cursor-pointer"
|
||||
>
|
||||
<Plus size={20} className="mr-2" />
|
||||
Yangi qo'shish
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-full">
|
||||
<div className="border-b">
|
||||
<div className="flex">
|
||||
<div className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider w-20">
|
||||
ID
|
||||
</div>
|
||||
<div className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider flex-1">
|
||||
Nomi
|
||||
</div>
|
||||
{activeTab === "badges" && (
|
||||
<div className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider w-48">
|
||||
Rang
|
||||
</div>
|
||||
)}
|
||||
{(activeTab === "tariffs" || activeTab === "transports") && (
|
||||
<div className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider w-32">
|
||||
Narx
|
||||
</div>
|
||||
)}
|
||||
<div className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase tracking-wider w-32">
|
||||
Amallar
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{filteredData.length === 0 ? (
|
||||
<div className="px-6 py-8 text-center text-muted-foreground">
|
||||
Ma'lumot topilmadi
|
||||
</div>
|
||||
) : (
|
||||
filteredData.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="px-6 py-4 w-20">{item.id}</div>
|
||||
<div className="px-6 py-4 font-medium flex-1">
|
||||
{item.name}
|
||||
</div>
|
||||
{activeTab === "badges" && (
|
||||
<div className="px-6 py-4 w-48">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded border"
|
||||
style={{ backgroundColor: (item as Badge).color }}
|
||||
/>
|
||||
<span>{(item as Badge).color}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(activeTab === "tariffs" ||
|
||||
activeTab === "transports") && (
|
||||
<div className="px-6 py-4 w-32">
|
||||
{(item as Tariff).price}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-6 py-4 w-32">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openModal("edit", item)}
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
>
|
||||
<Trash2 size={18} className="text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-gray-900">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{modalMode === "add" ? "Yangi qo'shish" : "Tahrirlash"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{getFormFields().map((field) => (
|
||||
<div key={field.name} className="space-y-2">
|
||||
<Label htmlFor={field.name}>
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<span className="text-destructive">*</span>
|
||||
)}
|
||||
</Label>
|
||||
{field.type === "textarea" ? (
|
||||
<Textarea
|
||||
id={field.name}
|
||||
value={getFieldValue(field.name) as string}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
[field.name]: e.target.value,
|
||||
})
|
||||
}
|
||||
required={field.required}
|
||||
rows={3}
|
||||
/>
|
||||
) : field.type === "select" ? (
|
||||
<Select
|
||||
value={getFieldValue(field.name) as string}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, [field.name]: value })
|
||||
}
|
||||
required={field.required}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Tanlang" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id={field.name}
|
||||
type={field.type}
|
||||
value={getFieldValue(field.name)}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
[field.name]:
|
||||
field.type === "number"
|
||||
? Number(e.target.value)
|
||||
: e.target.value,
|
||||
})
|
||||
}
|
||||
required={field.required}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeModal}
|
||||
className="flex-1"
|
||||
>
|
||||
Bekor qilish
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSubmit} className="flex-1">
|
||||
Saqlash
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToursSetting;
|
||||
354
src/pages/users/Create.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import formatPhone from "@/shared/lib/formatPhone";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Lock,
|
||||
Mail,
|
||||
Phone,
|
||||
Sparkles,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function CreateUser() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
email: "",
|
||||
phone: "+998",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.username.trim()) {
|
||||
newErrors.username = "Username majburiy";
|
||||
} else if (formData.username.length < 3) {
|
||||
newErrors.username =
|
||||
"Username kamida 3 ta belgidan iborat bo'lishi kerak";
|
||||
}
|
||||
|
||||
if (!formData.email.trim() && !formData.phone.trim()) {
|
||||
newErrors.contact =
|
||||
"Email yoki telefon raqamlardan kamida bittasi kiritilishi kerak";
|
||||
}
|
||||
|
||||
if (
|
||||
formData.email.trim() &&
|
||||
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)
|
||||
) {
|
||||
newErrors.email = "Email formati noto'g'ri";
|
||||
}
|
||||
|
||||
if (
|
||||
formData.phone.trim() &&
|
||||
!/^\+998\d{9}$/.test(formData.phone.replace(/\s/g, ""))
|
||||
) {
|
||||
newErrors.phone = "Telefon raqami formati: +998901234567";
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = "Parol majburiy";
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = "Parol kamida 6 ta belgidan iborat bo'lishi kerak";
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = "Parollar mos kelmaydi";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (validateForm()) {
|
||||
// const payload = {
|
||||
// username: formData.username,
|
||||
// email: formData.email || null,
|
||||
// phone: formData.phone || null,
|
||||
// password: formData.password,
|
||||
// };
|
||||
// navigate("/");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br w-full from-slate-950 via-slate-900 to-slate-950 p-4 md:p-8">
|
||||
<div className="w-full mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate("/")}
|
||||
className="mb-4 -ml-2 text-slate-400 hover:text-slate-100 hover:bg-slate-800/50 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Orqaga
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-700 flex items-center justify-center shadow-lg shadow-blue-500/20">
|
||||
<Sparkles className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-100">
|
||||
Yangi foydalanuvchi
|
||||
</h1>
|
||||
<p className="text-slate-400 mt-1">
|
||||
Ma'lumotlarni to'ldiring va saqlang
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Card */}
|
||||
<div className="bg-slate-900/50 backdrop-blur-sm rounded-3xl shadow-2xl border border-slate-800/50 overflow-hidden">
|
||||
<div className="p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Username */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="username"
|
||||
className="text-slate-300 font-medium"
|
||||
>
|
||||
Username <span className="text-red-400">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<Input
|
||||
id="username"
|
||||
value={formData.username}
|
||||
onChange={(e) => {
|
||||
setFormData((p) => ({ ...p, username: e.target.value }));
|
||||
setErrors((p) => ({ ...p, username: "" }));
|
||||
}}
|
||||
placeholder="john_doe"
|
||||
className={clsx(
|
||||
"h-14 pl-12 pr-4 rounded-xl border-2 transition-all duration-200 bg-slate-800/50 text-slate-200 placeholder:text-slate-500",
|
||||
errors.username
|
||||
? "border-red-500/50 focus:border-red-500 bg-red-500/10"
|
||||
: "border-slate-700/50 focus:border-blue-500 hover:border-slate-600/50",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{errors.username && (
|
||||
<p className="text-sm text-red-400 flex items-center gap-1">
|
||||
<span className="w-1 h-1 rounded-full bg-red-400"></span>
|
||||
{errors.username}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-slate-300 font-medium">
|
||||
Email
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => {
|
||||
setFormData((p) => ({ ...p, email: e.target.value }));
|
||||
setErrors((p) => ({ ...p, email: "", contact: "" }));
|
||||
}}
|
||||
placeholder="email@example.com"
|
||||
className={clsx(
|
||||
"h-14 pl-12 pr-4 rounded-xl border-2 transition-all duration-200 bg-slate-800/50 text-slate-200 placeholder:text-slate-500",
|
||||
errors.email
|
||||
? "border-red-500/50 focus:border-red-500 bg-red-500/10"
|
||||
: "border-slate-700/50 focus:border-blue-500 hover:border-slate-600/50",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-red-400 flex items-center gap-1">
|
||||
<span className="w-1 h-1 rounded-full bg-red-400"></span>
|
||||
{errors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone" className="text-slate-300 font-medium">
|
||||
Telefon raqami
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={formatPhone(formData.phone)}
|
||||
onChange={(e) => {
|
||||
setFormData((p) => ({ ...p, phone: e.target.value }));
|
||||
setErrors((p) => ({ ...p, phone: "", contact: "" }));
|
||||
}}
|
||||
placeholder="+998 90 123 45 67"
|
||||
className={clsx(
|
||||
"h-14 pl-12 pr-4 rounded-xl border-2 transition-all duration-200 bg-slate-800/50 text-slate-200 placeholder:text-slate-500",
|
||||
errors.phone
|
||||
? "border-red-500/50 focus:border-red-500 bg-red-500/10"
|
||||
: "border-slate-700/50 focus:border-blue-500 hover:border-slate-600/50",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{errors.phone && (
|
||||
<p className="text-sm text-red-400 flex items-center gap-1">
|
||||
<span className="w-1 h-1 rounded-full bg-red-400"></span>
|
||||
{errors.phone}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact Error */}
|
||||
{errors.contact && (
|
||||
<div className="bg-amber-500/10 border-2 border-amber-500/30 rounded-xl p-4">
|
||||
<p className="text-sm text-amber-400 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-400"></span>
|
||||
{errors.contact}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="password"
|
||||
className="text-slate-300 font-medium"
|
||||
>
|
||||
Parol <span className="text-red-400">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.password}
|
||||
onChange={(e) => {
|
||||
setFormData((p) => ({ ...p, password: e.target.value }));
|
||||
setErrors((p) => ({ ...p, password: "" }));
|
||||
}}
|
||||
placeholder="••••••••"
|
||||
className={clsx(
|
||||
"h-14 pl-12 pr-12 rounded-xl border-2 transition-all duration-200 bg-slate-800/50 text-slate-200 placeholder:text-slate-500",
|
||||
errors.password
|
||||
? "border-red-500/50 focus:border-red-500 bg-red-500/10"
|
||||
: "border-slate-700/50 focus:border-blue-500 hover:border-slate-600/50",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 h-10 w-10 hover:bg-slate-700/50 rounded-lg"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-slate-500" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-slate-500" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-red-400 flex items-center gap-1">
|
||||
<span className="w-1 h-1 rounded-full bg-red-400"></span>
|
||||
{errors.password}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="confirmPassword"
|
||||
className="text-slate-300 font-medium"
|
||||
>
|
||||
Parolni tasdiqlang <span className="text-red-400">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => {
|
||||
setFormData((p) => ({
|
||||
...p,
|
||||
confirmPassword: e.target.value,
|
||||
}));
|
||||
setErrors((p) => ({ ...p, confirmPassword: "" }));
|
||||
}}
|
||||
placeholder="••••••••"
|
||||
className={clsx(
|
||||
"h-14 pl-12 pr-12 rounded-xl border-2 transition-all duration-200 bg-slate-800/50 text-slate-200 placeholder:text-slate-500",
|
||||
errors.confirmPassword
|
||||
? "border-red-500/50 focus:border-red-500 bg-red-500/10"
|
||||
: "border-slate-700/50 focus:border-blue-500 hover:border-slate-600/50",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 h-10 w-10 hover:bg-slate-700/50 rounded-lg"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-slate-500" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-slate-500" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-sm text-red-400 flex items-center gap-1">
|
||||
<span className="w-1 h-1 rounded-full bg-red-400"></span>
|
||||
{errors.confirmPassword}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/")}
|
||||
className="flex-1 h-14 rounded-xl cursor-pointer border-2 border-slate-700/50 bg-slate-800/50 hover:bg-slate-700/50 text-slate-300 hover:text-slate-100 hover:border-slate-600/50 font-medium transition-all duration-200"
|
||||
>
|
||||
Bekor qilish
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1 h-14 rounded-xl cursor-pointer bg-gradient-to-r from-blue-600 to-indigo-700 hover:from-blue-700 hover:to-indigo-800 text-white font-medium shadow-lg shadow-blue-500/20 hover:shadow-xl hover:shadow-blue-500/30 transition-all duration-200"
|
||||
>
|
||||
Saqlash
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
220
src/pages/users/Edit.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import formatPhone from "@/shared/lib/formatPhone";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
import clsx from "clsx";
|
||||
import { ArrowLeft, Mail, Phone, Save, User } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
export default function EditUser() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
email: "",
|
||||
phone: "+998",
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
setFormData({
|
||||
username: "john_doe",
|
||||
email: "john@example.com",
|
||||
phone: "+998901234567",
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.username.trim()) {
|
||||
newErrors.username = "Username majburiy";
|
||||
} else if (formData.username.length < 3) {
|
||||
newErrors.username =
|
||||
"Username kamida 3 ta belgidan iborat bo'lishi kerak";
|
||||
}
|
||||
|
||||
if (!formData.email.trim() && !formData.phone.trim()) {
|
||||
newErrors.contact = "Email yoki telefon raqami kiritilishi shart";
|
||||
}
|
||||
|
||||
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = "Email formati noto'g'ri";
|
||||
}
|
||||
|
||||
if (
|
||||
formData.phone &&
|
||||
!/^\+998\d{9}$/.test(formData.phone.replace(/\s/g, ""))
|
||||
) {
|
||||
newErrors.phone = "Telefon raqami formati: +998901234567";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (validateForm()) {
|
||||
navigate("/");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 p-4 md:p-8">
|
||||
<div className="w-full mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate(-1)}
|
||||
className="mb-4 -ml-2 text-slate-400 cursor-pointer hover:text-slate-100 hover:bg-slate-800/50"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Orqaga
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-600 to-teal-700 flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
||||
<User className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">Tahrirlash</h1>
|
||||
<p className="text-slate-400 text-sm">Ma'lumotlarni yangilang</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Card */}
|
||||
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6 md:p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Username */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="username"
|
||||
className="text-slate-300 font-medium text-sm"
|
||||
>
|
||||
Username <span className="text-red-400">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<Input
|
||||
id="username"
|
||||
value={formData.username}
|
||||
onChange={(e) => {
|
||||
setFormData((p) => ({ ...p, username: e.target.value }));
|
||||
setErrors((p) => ({ ...p, username: "" }));
|
||||
}}
|
||||
placeholder="john_doe"
|
||||
className={clsx(
|
||||
"h-11 pl-10 rounded-lg border transition-colors bg-slate-800/50 text-slate-200 placeholder:text-slate-500",
|
||||
errors.username
|
||||
? "border-red-500/50 focus:border-red-500 bg-red-500/10"
|
||||
: "border-slate-700/50 focus:border-emerald-500 hover:border-slate-600/50",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{errors.username && (
|
||||
<p className="text-xs text-red-400">{errors.username}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="email"
|
||||
className="text-slate-300 font-medium text-sm"
|
||||
>
|
||||
Email
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => {
|
||||
setFormData((p) => ({ ...p, email: e.target.value }));
|
||||
setErrors((p) => ({ ...p, email: "", contact: "" }));
|
||||
}}
|
||||
placeholder="email@example.com"
|
||||
className={clsx(
|
||||
"h-11 pl-10 rounded-lg border transition-colors bg-slate-800/50 text-slate-200 placeholder:text-slate-500",
|
||||
errors.email
|
||||
? "border-red-500/50 focus:border-red-500 bg-red-500/10"
|
||||
: "border-slate-700/50 focus:border-emerald-500 hover:border-slate-600/50",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="text-xs text-red-400">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="phone"
|
||||
className="text-slate-300 font-medium text-sm"
|
||||
>
|
||||
Telefon raqami
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={formatPhone(formData.phone)}
|
||||
onChange={(e) => {
|
||||
setFormData((p) => ({ ...p, phone: e.target.value }));
|
||||
setErrors((p) => ({ ...p, phone: "", contact: "" }));
|
||||
}}
|
||||
placeholder="+998 90 123 45 67"
|
||||
className={clsx(
|
||||
"h-11 pl-10 rounded-lg border transition-colors bg-slate-800/50 text-slate-200 placeholder:text-slate-500",
|
||||
errors.phone
|
||||
? "border-red-500/50 focus:border-red-500 bg-red-500/10"
|
||||
: "border-slate-700/50 focus:border-emerald-500 hover:border-slate-600/50",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{errors.phone && (
|
||||
<p className="text-xs text-red-400">{errors.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact Error */}
|
||||
{errors.contact && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3">
|
||||
<p className="text-xs text-amber-400">{errors.contact}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex-1 h-11 rounded-lg border-slate-700/50 bg-slate-800/50 hover:bg-slate-700/50 text-slate-300 hover:text-slate-100 font-medium cursor-pointer"
|
||||
>
|
||||
Bekor qilish
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1 h-11 rounded-lg bg-gradient-to-r cursor-pointer text-md from-emerald-600 to-teal-700 hover:from-emerald-700 hover:to-teal-800 text-white font-medium shadow-lg shadow-emerald-500/20 hover:shadow-emerald-500/30 transition-all"
|
||||
>
|
||||
<Save className="!w-5 !h-5 mr-2" />
|
||||
Yangilash
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
398
src/pages/users/User.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
Mail,
|
||||
Pencil,
|
||||
Phone,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
type User = {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export default function UserList() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const usersPerPage = 6;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [users, setUsers] = useState<User[]>([
|
||||
{
|
||||
id: 1,
|
||||
username: "john_doe",
|
||||
email: "john@example.com",
|
||||
createdAt: "2024-01-15",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: "jane_smith",
|
||||
phone: "+998907654321",
|
||||
createdAt: "2024-01-20",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
username: "ali_karimov",
|
||||
phone: "+998909876543",
|
||||
createdAt: "2024-02-01",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
username: "sara_johnson",
|
||||
email: "sara@example.com",
|
||||
phone: "+998901234567",
|
||||
createdAt: "2024-02-10",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
username: "murod_toshev",
|
||||
email: "murod@example.com",
|
||||
createdAt: "2024-02-15",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
username: "aziza_sobirova",
|
||||
email: "aziza@example.com",
|
||||
createdAt: "2024-03-01",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
username: "timur_ergashev",
|
||||
phone: "+998907777777",
|
||||
createdAt: "2024-03-10",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
username: "odil_akbarov",
|
||||
email: "odil@example.com",
|
||||
createdAt: "2024-03-12",
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
username: "lola_nazarova",
|
||||
phone: "+998909111222",
|
||||
createdAt: "2024-04-05",
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
username: "bahrom_tursunov",
|
||||
email: "bahrom@example.com",
|
||||
createdAt: "2024-04-10",
|
||||
},
|
||||
]);
|
||||
|
||||
const [confirmDelete, setConfirmDelete] = useState<User | null>(null);
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
setUsers((prev) => prev.filter((u) => u.id !== id));
|
||||
setConfirmDelete(null);
|
||||
};
|
||||
|
||||
const formatPhone = (phone: string) => {
|
||||
if (phone.startsWith("+998")) {
|
||||
return phone.replace(
|
||||
/(\+998)(\d{2})(\d{3})(\d{2})(\d{2})/,
|
||||
"$1 $2 $3 $4 $5",
|
||||
);
|
||||
}
|
||||
return phone;
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(
|
||||
(user) =>
|
||||
user.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
user.email?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
user.phone?.includes(searchQuery),
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(filteredUsers.length / usersPerPage);
|
||||
const startIndex = (currentPage - 1) * usersPerPage;
|
||||
const paginatedUsers = filteredUsers.slice(
|
||||
startIndex,
|
||||
startIndex + usersPerPage,
|
||||
);
|
||||
|
||||
const getInitials = (username: string) => username.slice(0, 2).toUpperCase();
|
||||
|
||||
const getAvatarGradient = (id: number) => {
|
||||
const gradients = ["from-blue-600 to-cyan-500"];
|
||||
return gradients[id % gradients.length];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 py-12 px-4 w-full">
|
||||
<div className="max-w-[90%] mx-auto">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-3 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-xl shadow-lg shadow-cyan-500/20">
|
||||
<Users className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-5xl font-bold bg-gradient-to-r from-blue-400 via-cyan-300 to-blue-400 bg-clip-text text-transparent">
|
||||
Foydalanuvchilar
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-400 text-lg ml-14">
|
||||
Jami {users.length} ta foydalanuvchini boshqaring
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate("/users/create")}
|
||||
className="py-5 px-4 bg-gradient-to-br text-white cursor-pointer from-blue-500 to-cyan-500 rounded-xl shadow-lg shadow-cyan-500/20"
|
||||
>
|
||||
<Plus />
|
||||
<p>Foydalanuvchi Qo'shish</p>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-12">
|
||||
<StatCard
|
||||
title="Jami foydalanuvchilar"
|
||||
value={users.length.toString()}
|
||||
icon={<Users className="w-6 h-6" />}
|
||||
gradient="from-blue-600 to-blue-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="Email bilan ro'yxatlangan"
|
||||
value={users.filter((u) => u.email).length.toString()}
|
||||
icon={<Mail className="w-6 h-6" />}
|
||||
gradient="from-cyan-600 to-cyan-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="Telefon bilan ro'yxatlangan"
|
||||
value={users.filter((u) => u.phone).length.toString()}
|
||||
icon={<Phone className="w-6 h-6" />}
|
||||
gradient="from-purple-600 to-pink-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-10 relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-600/10 to-cyan-600/10 rounded-2xl blur-xl" />
|
||||
<div className="relative bg-slate-800/50 border border-slate-700/50 rounded-2xl p-6 backdrop-blur-sm">
|
||||
<Search className="absolute left-8 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Username, email yoki telefon raqami bo'yicha qidirish..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full pl-14 pr-4 py-3 bg-slate-700/30 border border-slate-600/50 text-white placeholder-slate-400 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-10">
|
||||
{paginatedUsers.map((user) => (
|
||||
<div className="group relative hover:scale-105 transition-transform duration-300">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-600/20 to-cyan-600/20 rounded-2xl blur-xl group-hover:blur-2xl transition-all opacity-0 group-hover:opacity-100" />
|
||||
<div className="relative h-full flex flex-col justify-between bg-gradient-to-br from-slate-800 to-slate-900 border border-slate-700/50 rounded-2xl p-6 shadow-2xl hover:border-slate-600/70 transition-all backdrop-blur-sm">
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div
|
||||
className={`w-16 h-16 rounded-xl bg-gradient-to-br ${getAvatarGradient(
|
||||
user.id,
|
||||
)} flex items-center justify-center text-white text-xl font-bold shadow-lg`}
|
||||
>
|
||||
{getInitials(user.username)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-bold text-white truncate">
|
||||
{user.username}
|
||||
</h3>
|
||||
<span className="inline-block mt-1 px-3 py-1 bg-green-500/20 text-green-300 text-xs font-semibold rounded-full border border-green-500/50">
|
||||
Faol
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 pt-4 border-t border-slate-700/50">
|
||||
{user.email && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail className="w-5 h-5 text-cyan-400" />
|
||||
<span className="text-slate-300 text-sm truncate">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{user.phone && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Phone className="w-5 h-5 text-cyan-400" />
|
||||
<span className="text-slate-300 text-sm">
|
||||
{formatPhone(user.phone)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<Calendar className="w-5 h-5 text-cyan-400" />
|
||||
<span className="text-slate-300 text-sm">
|
||||
{user.createdAt}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4 mt-4 border-t border-slate-700/50">
|
||||
<button
|
||||
onClick={() => navigate(`/users/${user.id}/`)}
|
||||
className="flex-1 flex cursor-pointer items-center justify-center gap-2 px-4 py-2.5 bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 font-medium rounded-lg transition-all border border-blue-500/30 hover:border-blue-500/50"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Ko'rish
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/users/${user.id}/edit`)}
|
||||
className="flex-1 flex items-center cursor-pointer justify-center gap-2 px-4 py-2.5 bg-green-500/20 hover:bg-green-500/30 text-green-300 font-medium rounded-lg transition-all border border-green-500/30 hover:border-green-500/50"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
Tahrirlash
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(user)}
|
||||
className="flex-1 flex items-center cursor-pointer justify-center gap-2 px-4 py-2.5 bg-red-500/20 hover:bg-red-500/30 text-red-300 font-medium rounded-lg transition-all border border-red-500/30 hover:border-red-500/50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
O'chirish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
|
||||
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{[...Array(totalPages)].map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setCurrentPage(i + 1)}
|
||||
className={`px-4 py-2 rounded-lg border transition-all font-medium ${
|
||||
currentPage === i + 1
|
||||
? "bg-gradient-to-r from-blue-600 to-cyan-600 border-blue-500 text-white shadow-lg shadow-cyan-500/50"
|
||||
: "border-slate-600 text-slate-300 hover:bg-slate-700/50 hover:border-slate-500"
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
|
||||
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirmDelete && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 border border-slate-700/50 rounded-2xl shadow-2xl max-w-md w-full p-6 space-y-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-red-500/20 rounded-xl border border-red-500/30">
|
||||
<Trash2 className="w-6 h-6 text-red-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
Foydalanuvchini o'chirish
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="p-2 hover:bg-slate-700/50 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-slate-300">
|
||||
Siz{" "}
|
||||
<span className="font-semibold text-white">
|
||||
{confirmDelete.username}
|
||||
</span>{" "}
|
||||
foydalanuvchini o'chirmoqchimisiz?
|
||||
</p>
|
||||
<div className="mt-3 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<p className="text-sm text-red-300 font-medium flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span>Ushbu amalni qaytarib bo'lmaydi.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="flex-1 px-4 py-3 border border-slate-600 hover:bg-slate-700/50 text-slate-300 font-medium rounded-lg transition-all"
|
||||
>
|
||||
Bekor qilish
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(confirmDelete.id)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-red-600 to-red-700 hover:from-red-500 hover:to-red-600 text-white font-medium rounded-lg transition-all shadow-lg"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>O'chirish</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
gradient,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
icon: React.ReactNode;
|
||||
gradient: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative hover:scale-105 transition-transform duration-300">
|
||||
<div
|
||||
className={`absolute inset-0 bg-gradient-to-r ${gradient} rounded-xl blur-lg opacity-20 group-hover:opacity-40 transition-all`}
|
||||
/>
|
||||
<div
|
||||
className={`relative bg-gradient-to-br ${gradient} bg-opacity-10 border border-white/10 backdrop-blur-sm rounded-xl p-6 shadow-xl hover:shadow-2xl transition-all hover:border-white/20`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<p className="text-slate-300 text-sm font-medium">{title}</p>
|
||||
<div
|
||||
className={`bg-gradient-to-br ${gradient} p-2 rounded-lg text-white shadow-lg`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-white">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
689
src/pages/users/UserDetail.tsx
Normal file
@@ -0,0 +1,689 @@
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bus,
|
||||
Calendar,
|
||||
Clock,
|
||||
CreditCard,
|
||||
DollarSign,
|
||||
Download,
|
||||
Edit,
|
||||
Mail,
|
||||
MapPin,
|
||||
Package,
|
||||
Phone,
|
||||
Shield,
|
||||
Ticket,
|
||||
User,
|
||||
Users as UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
type PassportImage = {
|
||||
id: number;
|
||||
image: string;
|
||||
};
|
||||
|
||||
type Companion = {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
birth_date: string;
|
||||
phone_number: string;
|
||||
gender: "male" | "female";
|
||||
participant_pasport_image: PassportImage[];
|
||||
};
|
||||
|
||||
type Participant = {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
gender: "male" | "female";
|
||||
};
|
||||
|
||||
type TicketInfo = {
|
||||
id: number;
|
||||
title: string;
|
||||
service_name: string;
|
||||
location_name: string;
|
||||
};
|
||||
|
||||
type ExtraService = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type ExtraPaidService = {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
};
|
||||
|
||||
type BookingTicket = {
|
||||
id: number;
|
||||
departure: string;
|
||||
destination: string;
|
||||
departure_date: string;
|
||||
arrival_time: string;
|
||||
participant: Participant[];
|
||||
ticket: TicketInfo;
|
||||
tariff: string;
|
||||
transport: string;
|
||||
extra_service: ExtraService[];
|
||||
extra_paid_service: ExtraPaidService[];
|
||||
total_price: number;
|
||||
order_status: "pending_payment" | "confirmed" | "cancelled";
|
||||
};
|
||||
|
||||
type UserData = {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
createdAt: string;
|
||||
status: "active" | "inactive";
|
||||
companions: Companion[];
|
||||
bookings: BookingTicket[];
|
||||
};
|
||||
|
||||
const UserDetail = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const [user, setUser] = useState<UserData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Backend'dan ma'lumot olish
|
||||
setUser({
|
||||
id: Number(id),
|
||||
username: "john_doe",
|
||||
email: "john@example.com",
|
||||
phone: "+998901234567",
|
||||
createdAt: "2024-01-15",
|
||||
status: "active",
|
||||
companions: [
|
||||
{
|
||||
id: 1,
|
||||
first_name: "Aziza",
|
||||
last_name: "Karimova",
|
||||
birth_date: "1995-05-20",
|
||||
phone_number: "+998901111111",
|
||||
gender: "female",
|
||||
participant_pasport_image: [
|
||||
{ id: 1, image: "/images/passport1.jpg" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
first_name: "Sardor",
|
||||
last_name: "Toshev",
|
||||
birth_date: "1990-08-15",
|
||||
phone_number: "+998902222222",
|
||||
gender: "male",
|
||||
participant_pasport_image: [
|
||||
{ id: 2, image: "/images/passport2.jpg" },
|
||||
],
|
||||
},
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
id: 1,
|
||||
departure: "Toshkent",
|
||||
destination: "Samarqand",
|
||||
departure_date: "2024-06-20",
|
||||
arrival_time: "2024-06-20T18:30:00",
|
||||
participant: [
|
||||
{ id: 1, first_name: "John", last_name: "Doe", gender: "male" },
|
||||
],
|
||||
ticket: {
|
||||
id: 1,
|
||||
title: "Premium Class",
|
||||
service_name: "Express Service",
|
||||
location_name: "Central Station",
|
||||
},
|
||||
tariff: "Standard",
|
||||
transport: "Bus",
|
||||
extra_service: [
|
||||
{ id: 1, name: "Wi-Fi" },
|
||||
{ id: 2, name: "Refreshments" },
|
||||
],
|
||||
extra_paid_service: [{ id: 1, name: "Extra Luggage", price: 50000 }],
|
||||
total_price: 150000,
|
||||
order_status: "confirmed",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
departure: "Samarqand",
|
||||
destination: "Buxoro",
|
||||
departure_date: "2024-06-25",
|
||||
arrival_time: "2024-06-25T15:00:00",
|
||||
participant: [
|
||||
{ id: 2, first_name: "Jane", last_name: "Smith", gender: "female" },
|
||||
],
|
||||
ticket: {
|
||||
id: 2,
|
||||
title: "Economy Class",
|
||||
service_name: "Standard Service",
|
||||
location_name: "Main Terminal",
|
||||
},
|
||||
tariff: "Economy",
|
||||
transport: "Train",
|
||||
extra_service: [{ id: 3, name: "AC" }],
|
||||
extra_paid_service: [],
|
||||
total_price: 120000,
|
||||
order_status: "confirmed",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
departure: "Samarqand",
|
||||
destination: "Buxoro",
|
||||
departure_date: "2024-06-25",
|
||||
arrival_time: "2024-06-25T15:00:00",
|
||||
participant: [
|
||||
{ id: 2, first_name: "Jane", last_name: "Smith", gender: "female" },
|
||||
],
|
||||
ticket: {
|
||||
id: 2,
|
||||
title: "Economy Class",
|
||||
service_name: "Standard Service",
|
||||
location_name: "Main Terminal",
|
||||
},
|
||||
tariff: "Economy",
|
||||
transport: "Train",
|
||||
extra_service: [{ id: 3, name: "AC" }],
|
||||
extra_paid_service: [],
|
||||
total_price: 120000,
|
||||
order_status: "confirmed",
|
||||
},
|
||||
],
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
const formatPhone = (phone: string) => {
|
||||
if (phone.startsWith("+998")) {
|
||||
return phone.replace(
|
||||
/(\+998)(\d{2})(\d{3})(\d{2})(\d{2})/,
|
||||
"$1 $2 $3 $4 $5",
|
||||
);
|
||||
}
|
||||
return phone;
|
||||
};
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
return price.toLocaleString("uz-UZ") + " so'm";
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges = {
|
||||
confirmed:
|
||||
"bg-emerald-500/20 text-emerald-400 border border-emerald-500/30",
|
||||
pending_payment:
|
||||
"bg-amber-500/20 text-amber-400 border border-amber-500/30",
|
||||
cancelled: "bg-red-500/20 text-red-400 border border-red-500/30",
|
||||
};
|
||||
const labels = {
|
||||
confirmed: "Tasdiqlangan",
|
||||
pending_payment: "To'lov kutilmoqda",
|
||||
cancelled: "Bekor qilingan",
|
||||
};
|
||||
return {
|
||||
class: badges[status as keyof typeof badges],
|
||||
label: labels[status as keyof typeof labels],
|
||||
};
|
||||
};
|
||||
|
||||
const handleDownloadPDF = (bookingId: number) => {
|
||||
console.log("Downloading PDF for booking:", bookingId);
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto"></div>
|
||||
<p className="mt-4 text-slate-400">Yuklanmoqda...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 p-4 md:p-8">
|
||||
<div className="w-full mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate("/")}
|
||||
className="mb-4 -ml-2 text-slate-400 hover:text-slate-100 hover:bg-slate-800/50 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Orqaga
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-700 flex items-center justify-center shadow-lg shadow-blue-500/20">
|
||||
<User className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-100">
|
||||
{user.username}
|
||||
</h1>
|
||||
<span
|
||||
className={`inline-block mt-2 px-3 py-1 text-xs font-medium rounded-full border ${
|
||||
user.status === "active"
|
||||
? "bg-emerald-500/20 text-emerald-400 border-emerald-500/30"
|
||||
: "bg-slate-700/50 text-slate-400 border-slate-600/30"
|
||||
}`}
|
||||
>
|
||||
{user.status === "active" ? "Faol" : "Nofaol"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => navigate(`/users/${id}/edit`)}
|
||||
className="bg-gradient-to-r cursor-pointer from-blue-600 to-indigo-700 hover:from-blue-700 hover:to-indigo-800 text-white shadow-lg shadow-blue-500/20"
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Tahrirlash
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column - Main Info */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Contact Information */}
|
||||
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
|
||||
<Mail className="w-5 h-5 text-blue-400" />
|
||||
Aloqa ma'lumotlari
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{user.email && (
|
||||
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-xl border border-slate-700/50">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center flex-shrink-0 border border-blue-500/30">
|
||||
<Mail className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 font-medium">
|
||||
Email
|
||||
</p>
|
||||
<p className="text-slate-200 font-medium">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user.phone && (
|
||||
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-xl border border-slate-700/50">
|
||||
<div className="w-10 h-10 rounded-lg bg-emerald-500/20 flex items-center justify-center flex-shrink-0 border border-emerald-500/30">
|
||||
<Phone className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 font-medium">
|
||||
Telefon
|
||||
</p>
|
||||
<p className="text-slate-200 font-medium">
|
||||
{formatPhone(user.phone)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Information */}
|
||||
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-indigo-400" />
|
||||
Hisob ma'lumotlari
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-xl border border-slate-700/50">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center flex-shrink-0 border border-purple-500/30">
|
||||
<User className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 font-medium">
|
||||
Username
|
||||
</p>
|
||||
<p className="text-slate-200 font-medium">
|
||||
{user.username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-xl border border-slate-700/50">
|
||||
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center flex-shrink-0 border border-orange-500/30">
|
||||
<Calendar className="w-5 h-5 text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 font-medium">
|
||||
Yaratilgan sana
|
||||
</p>
|
||||
<p className="text-slate-200 font-medium">
|
||||
{user.createdAt}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking Tickets */}
|
||||
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
|
||||
<Ticket className="w-5 h-5 text-emerald-400" />
|
||||
Sotib olingan chiptalar
|
||||
<span className="ml-auto text-sm font-normal text-slate-500">
|
||||
{user.bookings.length} ta
|
||||
</span>
|
||||
</h2>
|
||||
<div className="space-y-4 max-h-[600px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-slate-700 hover:scrollbar-thumb-slate-600 scrollbar-track-transparent">
|
||||
{user.bookings.length > 0 ? (
|
||||
user.bookings.map((booking) => {
|
||||
const statusBadge = getStatusBadge(booking.order_status);
|
||||
return (
|
||||
<div
|
||||
key={booking.id}
|
||||
className="p-5 bg-slate-800/50 rounded-xl border border-slate-700/50 hover:border-blue-500/50 transition-colors space-y-4"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-5 h-5 text-blue-400" />
|
||||
<span className="font-semibold text-slate-100 text-lg">
|
||||
{booking.departure} → {booking.destination}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full ${statusBadge.class}`}
|
||||
>
|
||||
{statusBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Ticket Info */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm bg-slate-900/50 p-3 rounded-lg border border-slate-700/30">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">
|
||||
Chipta turi
|
||||
</p>
|
||||
<p className="text-slate-200 font-medium">
|
||||
{booking.ticket.title}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Xizmat</p>
|
||||
<p className="text-slate-200 font-medium">
|
||||
{booking.ticket.service_name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Manzil</p>
|
||||
<p className="text-slate-200 font-medium">
|
||||
{booking.ticket.location_name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Transport</p>
|
||||
<p className="text-slate-200 font-medium flex items-center gap-1">
|
||||
<Bus className="w-3 h-3" />
|
||||
{booking.transport}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-slate-500" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Jo'nash</p>
|
||||
<p className="text-slate-200 font-medium">
|
||||
{booking.departure_date}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-slate-500" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Yetish</p>
|
||||
<p className="text-slate-200 font-medium">
|
||||
{booking.arrival_time.split("T")[1].slice(0, 5)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Participants */}
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-2">
|
||||
Yo'lovchilar:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{booking.participant.map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
className="px-3 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-full border border-blue-500/30"
|
||||
>
|
||||
{p.first_name} {p.last_name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
{booking.extra_service.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-2">
|
||||
Qo'shimcha xizmatlar:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{booking.extra_service.map((service) => (
|
||||
<span
|
||||
key={service.id}
|
||||
className="px-2 py-1 bg-emerald-500/20 text-emerald-400 text-xs rounded-lg flex items-center gap-1 border border-emerald-500/30"
|
||||
>
|
||||
<Package className="w-3 h-3" />
|
||||
{service.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paid Services */}
|
||||
{booking.extra_paid_service.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-2">
|
||||
Pullik xizmatlar:
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{booking.extra_paid_service.map((service) => (
|
||||
<div
|
||||
key={service.id}
|
||||
className="flex items-center justify-between text-xs bg-amber-500/10 px-3 py-2 rounded-lg border border-amber-500/20"
|
||||
>
|
||||
<span className="text-slate-300">
|
||||
{service.name}
|
||||
</span>
|
||||
<span className="font-medium text-amber-400">
|
||||
{formatPrice(service.price)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Total & Actions */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-slate-700/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="w-5 h-5 text-emerald-400" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">
|
||||
Jami narx
|
||||
</p>
|
||||
<p className="text-lg font-bold text-emerald-400">
|
||||
{formatPrice(booking.total_price)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleDownloadPDF(booking.id)}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 bg-slate-800/50 hover:bg-slate-700/50 text-slate-300 border-slate-700/50 hover:border-slate-600/50"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
PDF yuklab olish
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="text-sm text-slate-500 text-center py-4">
|
||||
Hozircha chiptalar mavjud emas
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Companions */}
|
||||
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
|
||||
<UsersIcon className="w-5 h-5 text-purple-400" />
|
||||
Hamrohlar
|
||||
<span className="ml-auto text-sm font-normal text-slate-500">
|
||||
{user.companions.length} ta
|
||||
</span>
|
||||
</h2>
|
||||
<div className="space-y-4 max-h-[300px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-slate-700 hover:scrollbar-thumb-slate-600 scrollbar-track-transparent">
|
||||
{user.companions.length > 0 ? (
|
||||
user.companions.map((companion) => (
|
||||
<div
|
||||
key={companion.id}
|
||||
className="p-4 bg-slate-800/50 rounded-xl border border-slate-700/50"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-14 h-14 rounded-lg bg-gradient-to-br from-purple-600 to-pink-600 flex items-center justify-center flex-shrink-0 shadow-lg shadow-purple-500/20">
|
||||
<User className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-100 text-lg">
|
||||
{companion.first_name} {companion.last_name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded-full border ${
|
||||
companion.gender === "male"
|
||||
? "bg-blue-500/20 text-blue-400 border-blue-500/30"
|
||||
: "bg-pink-500/20 text-pink-400 border-pink-500/30"
|
||||
}`}
|
||||
>
|
||||
{companion.gender === "male" ? "Erkak" : "Ayol"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">
|
||||
Tug'ilgan sana
|
||||
</p>
|
||||
<p className="text-slate-200 font-medium">
|
||||
{companion.birth_date}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Telefon</p>
|
||||
<p className="text-slate-200 font-medium">
|
||||
{formatPhone(companion.phone_number)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{companion.participant_pasport_image.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-2">
|
||||
Passport rasmlari:
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{companion.participant_pasport_image.map(
|
||||
(img) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="w-20 h-20 rounded-lg bg-slate-700/50 flex items-center justify-center overflow-hidden border border-slate-600/50"
|
||||
>
|
||||
<CreditCard className="w-8 h-8 text-slate-500" />
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-slate-500 text-center py-4">
|
||||
Hozircha hamrohlar qo'shilmagan
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Stats */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-emerald-400" />
|
||||
Statistika
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 bg-emerald-500/10 rounded-lg border border-emerald-500/20">
|
||||
<span className="text-sm text-slate-300">Chiptalar</span>
|
||||
<span className="text-sm font-semibold text-emerald-400">
|
||||
{user.bookings.length} ta
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-purple-500/10 rounded-lg border border-purple-500/20">
|
||||
<span className="text-sm text-slate-300">Hamrohlar</span>
|
||||
<span className="text-sm font-semibold text-purple-400">
|
||||
{user.companions.length} ta
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-blue-500/10 rounded-lg border border-blue-500/20">
|
||||
<span className="text-sm text-slate-300">Status</span>
|
||||
<span className="text-sm font-semibold text-blue-400">
|
||||
{user.status === "active" ? "Faol" : "Nofaol"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-orange-500/10 rounded-lg border border-orange-500/20">
|
||||
<span className="text-sm text-slate-300">ID</span>
|
||||
<span className="text-sm font-semibold text-orange-400">
|
||||
#{user.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-blue-600 to-indigo-700 rounded-2xl shadow-xl shadow-blue-500/20 p-6 text-white border border-blue-500/20">
|
||||
<h3 className="font-semibold mb-2">Qo'shimcha ma'lumot</h3>
|
||||
<p className="text-sm text-blue-100">
|
||||
Bu foydalanuvchi hozirda tizimda faol holatda. Barcha
|
||||
ma'lumotlar to'liq va tasdiqlangan.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserDetail;
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import QueryProvider from './react-query/QueryProvider';
|
||||
import { ThemeProvider } from '@/providers/theme/ThemeProvider';
|
||||
import { ThemeProvider } from "@/providers/theme/ThemeProvider";
|
||||
import type { ReactNode } from "react";
|
||||
import QueryProvider from "./react-query/QueryProvider";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -9,7 +9,7 @@ interface Props {
|
||||
const MainProvider = ({ children }: Props) => {
|
||||
return (
|
||||
<QueryProvider>
|
||||
<ThemeProvider defaultTheme="light">{children}</ThemeProvider>
|
||||
<ThemeProvider defaultTheme="dark">{children}</ThemeProvider>
|
||||
</QueryProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system';
|
||||
type Theme = "dark" | "light" | "system";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -14,7 +14,7 @@ type ThemeProviderState = {
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: 'system',
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
@@ -22,8 +22,8 @@ const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'system',
|
||||
storageKey = 'vite-ui-theme',
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
@@ -33,13 +33,13 @@ export function ThemeProvider({
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
@@ -67,7 +67,7 @@ export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import { BASE_URL } from './URLs';
|
||||
import i18n from '@/shared/config/i18n';
|
||||
import i18n from "@/shared/config/i18n";
|
||||
import axios from "axios";
|
||||
import { BASE_URL } from "./URLs";
|
||||
|
||||
const httpClient = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
@@ -9,11 +9,9 @@ const httpClient = axios.create({
|
||||
|
||||
httpClient.interceptors.request.use(
|
||||
async (config) => {
|
||||
console.log(`API REQUEST to ${config.url}`, config);
|
||||
|
||||
// Language configs
|
||||
const language = i18n.language;
|
||||
config.headers['Accept-Language'] = language;
|
||||
config.headers["Accept-Language"] = language;
|
||||
// const accessToken = localStorage.getItem('accessToken');
|
||||
// if (accessToken) {
|
||||
// config.headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
@@ -27,7 +25,7 @@ httpClient.interceptors.request.use(
|
||||
httpClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
console.error('API error:', error);
|
||||
console.error("API error:", error);
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"welcome": "Rus. Bizning saytga xush kelibsiz",
|
||||
"language": "Til"
|
||||
"welcome": "Добро пожаловать на наш сайт",
|
||||
"language": "Язык",
|
||||
"Foydalanuvchilar": "Пользователи",
|
||||
"Tur firmalar": "Турфирмы",
|
||||
"Xodimlar": "Сотрудники",
|
||||
"Byudjet": "Бюджет",
|
||||
"Turlar": "Туры",
|
||||
"Bronlar": "Бронирования",
|
||||
"Yangiliklar": "Новости",
|
||||
"Yordam Arizalar": "Заявки на помощь",
|
||||
"Tur sozlamalari": "Настройки тура"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"welcome": "Uzbek. Bizning saytga xush kelibsiz",
|
||||
"language": "Til"
|
||||
"language": "Til",
|
||||
"Foydalanuvchilar": "Foydalanuvchilar",
|
||||
"Tur firmalar": "Tur firmalar",
|
||||
"Xodimlar": "Xodimlar",
|
||||
"Byudjet": "Byudjet",
|
||||
"Turlar": "Turlar",
|
||||
"Bronlar": "Bronlar",
|
||||
"Yangiliklar": "Yangiliklar",
|
||||
"Yordam Arizalar": "Yordam Arizalar",
|
||||
"Tur sozlamalari": "Tur sozlamalari"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export enum LanguageRoutes {
|
||||
UZ = 'uz', // o'zbekcha
|
||||
RU = 'ru', // ruscha
|
||||
KI = 'ki', // kirilcha
|
||||
UZ = "uz", // o'zbekcha
|
||||
RU = "ru", // ruscha
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Hook for closing some items when they are unnecessary to the user
|
||||
@@ -27,13 +27,13 @@ const useCloser = (
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('scroll', handleScroll);
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("scroll", handleScroll);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('scroll', handleScroll);
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, [ref, closeFunction]);
|
||||
}, [ref, closeFunction, scrollClose]);
|
||||
};
|
||||
|
||||
export default useCloser;
|
||||
|
||||
@@ -1,32 +1,38 @@
|
||||
import i18n from '@/shared/config/i18n';
|
||||
import { LanguageRoutes } from '@/shared/config/i18n/type';
|
||||
import i18n from "@/shared/config/i18n";
|
||||
import { LanguageRoutes } from "@/shared/config/i18n/type";
|
||||
|
||||
/**
|
||||
* Format price. With label.
|
||||
* @param amount Price
|
||||
* @param withLabel Show label. Default false
|
||||
* @returns string. Ex. X XXX XXX sum
|
||||
* @returns string. Ex. 1 000 000 so‘m
|
||||
*/
|
||||
const formatPrice = (amount: number | string, withLabel?: boolean) => {
|
||||
const formatPrice = (amount: number | string, withLabel = false): string => {
|
||||
const locale = i18n.language;
|
||||
const label = withLabel
|
||||
? locale == LanguageRoutes.RU
|
||||
? ' сум'
|
||||
: locale == LanguageRoutes.KI
|
||||
? ' сўм'
|
||||
: ' so‘m'
|
||||
: '';
|
||||
const parts = String(amount).split('.');
|
||||
const dollars = parts[0];
|
||||
const cents = parts.length > 1 ? parts[1] : '00';
|
||||
? locale === LanguageRoutes.RU
|
||||
? " сум"
|
||||
: locale === LanguageRoutes.UZ
|
||||
? " сўм"
|
||||
: " so‘m"
|
||||
: "";
|
||||
|
||||
const formattedDollars = dollars.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||
// Agar qiymat bo‘sh yoki 0 bo‘lsa — hech narsa ko‘rsatmaymiz
|
||||
if (
|
||||
amount === "" ||
|
||||
amount === null ||
|
||||
amount === undefined ||
|
||||
Number(amount) === 0
|
||||
)
|
||||
return "";
|
||||
|
||||
if (String(amount).length == 0) {
|
||||
return formattedDollars + '.' + cents + label;
|
||||
} else {
|
||||
return formattedDollars + label;
|
||||
}
|
||||
// Faqat raqamlarni qoldiramiz
|
||||
const numeric = String(amount).replace(/\D/g, "");
|
||||
|
||||
// Raqamni 3 xonadan ajratamiz
|
||||
const formatted = numeric.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||
|
||||
return formatted + label;
|
||||
};
|
||||
|
||||
export default formatPrice;
|
||||
|
||||
46
src/shared/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
@@ -10,27 +10,27 @@ const buttonVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -41,11 +41,11 @@ function Button({
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
|
||||
211
src/shared/ui/calendar.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react";
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { Button, buttonVariants } from "@/shared/ui/button";
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className,
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"flex gap-4 flex-col md:flex-row relative",
|
||||
defaultClassNames.months,
|
||||
),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav,
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous,
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next,
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption,
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns,
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root,
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute bg-popover inset-0 opacity-0",
|
||||
defaultClassNames.dropdown,
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label,
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday,
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"select-none w-(--cell-size)",
|
||||
defaultClassNames.week_number_header,
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] select-none text-muted-foreground",
|
||||
defaultClassNames.week_number,
|
||||
),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
defaultClassNames.day,
|
||||
),
|
||||
range_start: cn(
|
||||
"rounded-l-md bg-accent",
|
||||
defaultClassNames.range_start,
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today,
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside,
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled,
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
);
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton };
|
||||
92
src/shared/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
30
src/shared/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
184
src/shared/ui/command.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
143
src/shared/ui/dialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
168
src/shared/ui/form.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue,
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState } = useFormContext();
|
||||
const formState = useFormState({ name: fieldContext.name });
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue,
|
||||
);
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message ?? "") : props.children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
||||
33
src/shared/ui/icon.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { type LucideProps, icons } from "lucide-react";
|
||||
|
||||
type IconComponentName = keyof typeof icons;
|
||||
|
||||
interface IconProps extends LucideProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
function isValidIconComponent(
|
||||
componentName: string,
|
||||
): componentName is IconComponentName {
|
||||
return componentName in icons;
|
||||
}
|
||||
|
||||
function Icon({ name, ...props }: IconProps) {
|
||||
const kebabToPascal = (str: string) =>
|
||||
str
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join("");
|
||||
|
||||
const componentName = kebabToPascal(name);
|
||||
|
||||
if (!isValidIconComponent(componentName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const IconName = icons[componentName];
|
||||
|
||||
return <IconName {...props} />;
|
||||
}
|
||||
|
||||
export default Icon;
|
||||
20
src/shared/ui/input.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
163
src/shared/ui/iocnSelect.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import Icon from "@/shared/ui/icon";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import { HelpCircle, Search } from "lucide-react";
|
||||
import React, {
|
||||
Suspense,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type LazyExoticComponent,
|
||||
} from "react";
|
||||
|
||||
// 🔹 Lazy icon faqat tanlangan icon uchun
|
||||
const LazyIcon: React.FC<{ name: string }> = ({ name }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const IconComp: LazyExoticComponent<ComponentType<any>> = React.lazy(
|
||||
async () => {
|
||||
const icons = await import("lucide-react");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { default: (icons as any)[name] || HelpCircle };
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<div className="w-4 h-4" />}>
|
||||
<IconComp className="w-4 h-4" />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
interface IconSelectProps {
|
||||
selectedIcon?: string;
|
||||
setSelectedIcon: (value: string) => void;
|
||||
}
|
||||
|
||||
const IconSelect: React.FC<IconSelectProps> = ({
|
||||
selectedIcon,
|
||||
setSelectedIcon,
|
||||
}) => {
|
||||
const [icons, setIcons] = useState<string[]>([]);
|
||||
const [visibleIcons, setVisibleIcons] = useState<string[]>([]);
|
||||
const [chunkSize] = useState(100);
|
||||
const [index, setIndex] = useState(1);
|
||||
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const loaderRef = useRef<HTMLDivElement | null>(null);
|
||||
const deferredSearch = useDeferredValue(searchTerm);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const loadIcons = async () => {
|
||||
const mod = await import("lucide-react");
|
||||
const allIcons = Object.keys(mod).filter((k) => /^[A-Z]/.test(k));
|
||||
setIcons(allIcons);
|
||||
setVisibleIcons(allIcons.slice(0, chunkSize));
|
||||
setIndex(1);
|
||||
};
|
||||
loadIcons();
|
||||
}, [isOpen, chunkSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerEl || !loaderRef.current || !isOpen) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
const start = index * chunkSize;
|
||||
const end = start + chunkSize;
|
||||
const next = icons.slice(start, end);
|
||||
if (next.length > 0) {
|
||||
setVisibleIcons((p) => [...p, ...next]);
|
||||
setIndex((p) => p + 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ root: containerEl, threshold: 1.0 },
|
||||
);
|
||||
|
||||
observer.observe(loaderRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [containerEl, icons, index, chunkSize, isOpen]);
|
||||
|
||||
const filteredIcons = useMemo(() => {
|
||||
const term = deferredSearch.trim().toLowerCase();
|
||||
if (!term) return visibleIcons;
|
||||
return icons.filter((n) => n.toLowerCase().includes(term));
|
||||
}, [icons, visibleIcons, deferredSearch]);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
setVisibleIcons([]);
|
||||
setIcons([]);
|
||||
setIndex(1);
|
||||
setSearchTerm("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selectedIcon}
|
||||
onValueChange={setSelectedIcon}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<SelectTrigger className="!h-12 w-[220px] text-md">
|
||||
<SelectValue placeholder="Ikonka tanlang">
|
||||
{selectedIcon ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<LazyIcon name={selectedIcon} />
|
||||
{selectedIcon}
|
||||
</div>
|
||||
) : (
|
||||
"Ikonka tanlang"
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent ref={setContainerEl} className="max-h-80 overflow-y-auto">
|
||||
<div className="sticky top-0 bg-white dark:bg-neutral-900 z-10 p-2 border-b flex items-center gap-2">
|
||||
<Search className="w-4 h-4 text-gray-500" />
|
||||
<Input
|
||||
placeholder="Qidiruv..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filteredIcons.map((iconName) => (
|
||||
<SelectItem key={iconName} value={iconName}>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Icon name={iconName} />
|
||||
{iconName}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
{!searchTerm && isOpen && (
|
||||
<div ref={loaderRef} className="h-6 flex justify-center items-center">
|
||||
{visibleIcons.length < icons.length && (
|
||||
<span className="text-xs text-gray-400">Yuklanmoqda...</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconSelect;
|
||||
22
src/shared/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
46
src/shared/ui/popover.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
185
src/shared/ui/select.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
26
src/shared/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
139
src/shared/ui/sheet.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
726
src/shared/ui/sidebar.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
"use client";
|
||||
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { PanelLeftIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import useIsMobile from "@/shared/hooks/use-mobile";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/shared/ui/sheet";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/ui/tooltip";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open],
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("size-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean;
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
};
|
||||
13
src/shared/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
114
src/shared/ui/table.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
64
src/shared/ui/tabs.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
18
src/shared/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
59
src/shared/ui/tooltip.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LanguageRoutes } from '@/shared/config/i18n/type';
|
||||
import { LanguageRoutes } from "@/shared/config/i18n/type";
|
||||
|
||||
const languages: { name: string; key: LanguageRoutes }[] = [
|
||||
{
|
||||
@@ -6,11 +6,7 @@ const languages: { name: string; key: LanguageRoutes }[] = [
|
||||
key: LanguageRoutes.UZ,
|
||||
},
|
||||
{
|
||||
name: 'Ўзбекча',
|
||||
key: LanguageRoutes.KI,
|
||||
},
|
||||
{
|
||||
name: 'Русский',
|
||||
name: "Русский",
|
||||
key: LanguageRoutes.RU,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { LanguageRoutes } from '@/shared/config/i18n/type';
|
||||
import { Button } from '@/shared/ui/button';
|
||||
import { LanguageRoutes } from "@/shared/config/i18n/type";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/shared/ui/dropdown-menu';
|
||||
import { languages } from '@/widgets/lang-toggle/lib/data';
|
||||
import { GlobeIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { languages } from "@/widgets/lang-toggle/lib/data";
|
||||
import { GlobeIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const LangToggle = () => {
|
||||
const { i18n } = useTranslation();
|
||||
@@ -21,7 +21,7 @@ const LangToggle = () => {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<GlobeIcon />
|
||||
<span>{languages.find((e) => e.key == i18n.language)?.name}</span>
|
||||
<span>{languages.find((e) => e.key === i18n.language)?.name}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
|
||||
238
src/widgets/sidebar/ui/Sidebar.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/shared/ui/sheet";
|
||||
import LangToggle from "@/widgets/lang-toggle/ui/lang-toggle";
|
||||
import {
|
||||
Briefcase,
|
||||
Building2,
|
||||
CalendarCheck2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
HelpCircle,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Newspaper,
|
||||
Plane,
|
||||
Settings,
|
||||
Users,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
type Role = "admin" | "manager" | "user";
|
||||
|
||||
interface SidebarProps {
|
||||
role: Role;
|
||||
}
|
||||
|
||||
/** --- MENYU TUZILMASI --- **/
|
||||
const MENU_ITEMS = [
|
||||
{ label: "Foydalanuvchilar", icon: Users, path: "/user", roles: ["admin"] },
|
||||
{
|
||||
label: "Tur firmalar",
|
||||
icon: Building2,
|
||||
path: "/agencies",
|
||||
roles: ["admin", "manager"],
|
||||
},
|
||||
{ label: "Xodimlar", icon: Briefcase, path: "/employees", roles: ["admin"] },
|
||||
{ label: "Byudjet", icon: Wallet, path: "/finance", roles: ["admin"] },
|
||||
{
|
||||
label: "Turlar",
|
||||
icon: Plane,
|
||||
path: "/tours",
|
||||
roles: ["admin", "manager"],
|
||||
children: [
|
||||
{ label: "Turlar", path: "/tours" },
|
||||
{ label: "Tur sozlamalari", path: "/tours/setting" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Bronlar",
|
||||
icon: CalendarCheck2,
|
||||
path: "/bookings",
|
||||
roles: ["admin", "manager", "user"],
|
||||
},
|
||||
{
|
||||
label: "Yangiliklar",
|
||||
icon: Newspaper,
|
||||
path: "/news",
|
||||
roles: ["admin", "manager"],
|
||||
children: [
|
||||
{ label: "Yangiliklar", path: "/news" },
|
||||
{ label: "Kategoriya", path: "/news/categories" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "FAQ",
|
||||
icon: MessageSquare,
|
||||
path: "/faq",
|
||||
roles: ["admin"],
|
||||
children: [
|
||||
{ label: "Savollar ro‘yxati", path: "/faq" },
|
||||
{ label: "Savollar kategoriyasi", path: "/faq/categories" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Arizalar",
|
||||
icon: HelpCircle,
|
||||
path: "/support",
|
||||
roles: ["admin", "manager"],
|
||||
children: [
|
||||
{ label: "Agentlik arizalari", path: "/support/tours", roles: ["admin"] },
|
||||
{ label: "Yordam arizalari", path: "/support/user", roles: ["admin"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Tur sozlamalari",
|
||||
icon: Settings,
|
||||
path: "/tour-settings",
|
||||
roles: ["admin"],
|
||||
children: [
|
||||
{ label: "Sayt SEOsi", path: "/site-seo/" },
|
||||
{ label: "Offerta", path: "/site-pages/" },
|
||||
{ label: "Yordam pagelari", path: "/site-help/" },
|
||||
{ label: "Sayt sozlamalari", path: "/site-settings/" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function Sidebar({ role }: SidebarProps) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
|
||||
const visibleMenu = useMemo(
|
||||
() => MENU_ITEMS.filter((item) => item.roles.includes(role)),
|
||||
[role],
|
||||
);
|
||||
|
||||
const [active, setActive] = useState<string>(location.pathname);
|
||||
const [openMenus, setOpenMenus] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(location.pathname);
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleClick = (path: string) => {
|
||||
setActive(path);
|
||||
navigate(path);
|
||||
setIsSheetOpen(false);
|
||||
};
|
||||
|
||||
const toggleSubMenu = (label: string) => {
|
||||
setOpenMenus((prev) =>
|
||||
prev.includes(label) ? prev.filter((m) => m !== label) : [...prev, label],
|
||||
);
|
||||
};
|
||||
|
||||
const MenuList = (
|
||||
<ul className="p-2 space-y-1">
|
||||
{visibleMenu.map(({ label, icon: Icon, path, children }) => {
|
||||
const isActive = active.startsWith(path);
|
||||
const isOpen = openMenus.includes(label);
|
||||
|
||||
return (
|
||||
<li key={path}>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between gap-2 px-2 py-3 rounded-md cursor-pointer transition text-md font-medium",
|
||||
isActive
|
||||
? "bg-gray-600 text-white"
|
||||
: "text-gray-400 hover:bg-gray-700 hover:text-white",
|
||||
)}
|
||||
onClick={() =>
|
||||
children ? toggleSubMenu(label) : handleClick(path)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-5 h-5" />
|
||||
{t(label)}
|
||||
</div>
|
||||
{children && (
|
||||
<span className="transition-transform">
|
||||
{isOpen ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{children && isOpen && (
|
||||
<ul className="ml-6 mt-1 space-y-1 border-l border-gray-700 pl-3">
|
||||
{children.map((sub) => (
|
||||
<li key={sub.path}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleClick(sub.path)}
|
||||
className={cn(
|
||||
"w-full justify-start gap-2 text-sm !px-2 !py-2 cursor-pointer",
|
||||
active === sub.path
|
||||
? "bg-gray-700 text-white"
|
||||
: "text-gray-400 hover:text-white hover:bg-gray-800",
|
||||
)}
|
||||
>
|
||||
{t(sub.label)}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
<LangToggle />
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="lg:border">
|
||||
<div className="lg:hidden flex items-center justify-between bg-gray-900 p-4 sticky top-0 z-50">
|
||||
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||
<div className="flex gap-4">
|
||||
<LangToggle />
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Menu className="w-5 h-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
</div>
|
||||
|
||||
<SheetContent side="left" className="p-0 w-64 bg-gray-900">
|
||||
<SheetHeader className="p-4 border-b">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<img
|
||||
src="/Logo_white.png"
|
||||
width={120}
|
||||
height={120}
|
||||
alt="logo"
|
||||
/>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="overflow-y-auto">{MenuList}</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
<aside className="hidden bg-gray-900 lg:flex w-64 flex-col h-screen">
|
||||
<div className="flex items-center gap-2 p-4 border-b">
|
||||
<img src="/Logo_white.png" width={120} height={120} alt="logo" />
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto">{MenuList}</nav>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,8 @@
|
||||
import { getPosts } from '@/shared/config/api/test/test.request';
|
||||
import LangToggle from '@/widgets/lang-toggle/ui/lang-toggle';
|
||||
import ModeToggle from '@/widgets/theme-toggle/ui/theme-toggle';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import GitHubButton from 'react-github-btn';
|
||||
import LangToggle from "@/widgets/lang-toggle/ui/lang-toggle";
|
||||
import ModeToggle from "@/widgets/theme-toggle/ui/theme-toggle";
|
||||
import GitHubButton from "react-github-btn";
|
||||
|
||||
const Welcome = () => {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: () => getPosts(),
|
||||
});
|
||||
|
||||
console.log('data', data);
|
||||
|
||||
return (
|
||||
<div className="custom-container h-screen rounded-2xl flex items-center justify-center">
|
||||
<div className="flex flex-col gap-2 items-center">
|
||||
|
||||