search, edit adt, admin panel
This commit is contained in:
parent
c30ac3e108
commit
9b56be6b03
@ -1,3 +1,3 @@
|
||||
import { Adt, Category, City, User } from "@prisma/client";
|
||||
import { Adt, Category, City, Country, User } from "@prisma/client";
|
||||
|
||||
export type AdtWithRelations = Adt & { category: Category; city: City; user: User };
|
||||
export type AdtWithRelations = Adt & { category: Category; city: City; country: Country; user: User };
|
||||
81
app/(admin)/admin/categories/columns.tsx
Normal file
81
app/(admin)/admin/categories/columns.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ArrowUpDown, MoreHorizontal } from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { deleteCategory } from "@/app/actions/categories"
|
||||
import CategoryForm from "@/components/admin/CategoryForm"
|
||||
|
||||
export type Category = {
|
||||
id: string
|
||||
nameEn: string
|
||||
nameAr: string
|
||||
slug: string
|
||||
icon?: string
|
||||
_count: {
|
||||
adts: number
|
||||
}
|
||||
}
|
||||
|
||||
export const categoryColumns: ColumnDef<Category>[] = [
|
||||
{
|
||||
accessorKey: "nameEn",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Название
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "icon",
|
||||
header: "Иконка",
|
||||
cell: ({ row }) => row.getValue("icon") || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "_count.adts",
|
||||
header: "Количество объявлений",
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const category = row.original
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<CategoryForm category={category} mode="edit" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-red-600"
|
||||
onClick={() => {
|
||||
if (confirm("Вы уверены, что хотите удалить эту категорию?")) {
|
||||
deleteCategory(category.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
19
app/(admin)/admin/categories/page.tsx
Normal file
19
app/(admin)/admin/categories/page.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
// import { getCategoriesWithCount } from "@/app/actions/categories";
|
||||
import CategoryForm from "@/components/admin/CategoryForm";
|
||||
import { DataTable } from "@/components/admin/DataTable";
|
||||
import { Category, categoryColumns } from "./columns";
|
||||
import { getCategoriesWithCount } from "@/app/actions/categories";
|
||||
|
||||
export default async function CategoriesPage() {
|
||||
const categories = await getCategoriesWithCount();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Управление категориями</h1>
|
||||
<CategoryForm />
|
||||
</div>
|
||||
<DataTable columns={categoryColumns} data={categories as Category[]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
app/(admin)/admin/page.tsx
Normal file
61
app/(admin)/admin/page.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { getStats } from "@/app/actions/stats"
|
||||
import { Card } from "@/components/ui/card"
|
||||
// import { getStats } from "@/app/actions/stats"
|
||||
|
||||
export default async function AdminPage() {
|
||||
const stats = await getStats()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Панель управления</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="text-sm text-gray-500">Всего объявлений</div>
|
||||
<div className="text-2xl font-bold">{stats.totalAds}</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="text-sm text-gray-500">Активные объявления</div>
|
||||
<div className="text-2xl font-bold">{stats.activeAds}</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="text-sm text-gray-500">Всего пользователей</div>
|
||||
<div className="text-2xl font-bold">{stats.totalUsers}</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="text-sm text-gray-500">Новые пользователи (30 дней)</div>
|
||||
<div className="text-2xl font-bold">{stats.newUsers}</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
|
||||
<Card className="p-4">
|
||||
<h2 className="text-lg font-semibold mb-4">Популярные категории</h2>
|
||||
<div className="space-y-2">
|
||||
{stats.topCategories.map((category) => (
|
||||
<div key={category.id} className="flex justify-between">
|
||||
<span>{category.name}</span>
|
||||
<span className="font-medium">{category.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<h2 className="text-lg font-semibold mb-4">Популярные города</h2>
|
||||
<div className="space-y-2">
|
||||
{stats.topCities.map((city) => (
|
||||
<div key={city.id} className="flex justify-between">
|
||||
<span>{city.name}</span>
|
||||
<span className="font-medium">{city.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
app/(admin)/layout.tsx
Normal file
30
app/(admin)/layout.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
// import { auth } from "@clerk/nextjs";
|
||||
import AdminSidebar from "@/components/admin/AdminSidebar";
|
||||
import { getUserSession } from "@/lib/get-user-session";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// const { userId } = auth();
|
||||
|
||||
const user = await getUserSession()
|
||||
|
||||
if (!user) {
|
||||
redirect('/sign-in');
|
||||
}
|
||||
|
||||
// Здесь можно добавить проверку на роль админа
|
||||
// TODO: Добавить проверку isAdmin из базы данных
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<AdminSidebar />
|
||||
<main className="flex-1 p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,13 +2,14 @@
|
||||
|
||||
import React from 'react';
|
||||
// import { useParams } from 'next/navigation';
|
||||
import { MapPin, Calendar, Phone, MessageCircle, Share2, Flag, Heart, Tag, Building, ChevronRight } from 'lucide-react';
|
||||
import { MapPin, Calendar, Phone, MessageCircle, Share2, Flag, Heart, Tag, Building, ChevronRight, Pencil } from 'lucide-react';
|
||||
import { prisma } from '@/prisma/prisma-client';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ShowNumberModal } from '@/components/shared/modals/show-number';
|
||||
import { getUserSession } from '@/lib/get-user-session';
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
|
||||
import { BreadcrumbsCategory } from '@/components/shared/breadcrumbs-category';
|
||||
import Link from 'next/link';
|
||||
|
||||
type Params = Promise<{ id: string }>
|
||||
|
||||
@ -37,6 +38,8 @@ export default async function AdtPage(props: { params: Params }) {
|
||||
|
||||
const user = adt.user
|
||||
|
||||
const isOwner = session?.id === String(adt.userId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
@ -74,6 +77,15 @@ export default async function AdtPage(props: { params: Params }) {
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{isOwner && (
|
||||
<Link
|
||||
href={`/adt/edit/${adt.id}`}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-200 hover:bg-gray-50"
|
||||
>
|
||||
<Pencil className="h-5 w-5" />
|
||||
<span>Редактировать</span>
|
||||
</Link>
|
||||
)}
|
||||
<button className="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-200 hover:bg-gray-50">
|
||||
<Share2 className="h-5 w-5" />
|
||||
<span>Поделиться</span>
|
||||
|
||||
69
app/(root)/adt/edit/[id]/page.tsx
Normal file
69
app/(root)/adt/edit/[id]/page.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { prisma } from '@/prisma/prisma-client';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import AdtEditForm from '@/components/shared/adt-edit/adt-edit-form';
|
||||
import { getUserSession } from '@/lib/get-user-session';
|
||||
import { Adt } from '@prisma/client';
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export default async function EditAdt({ params }: Props) {
|
||||
const session = await getUserSession();
|
||||
if (!session) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
try {
|
||||
const [adt, categories, countries, cities] = await Promise.all([
|
||||
prisma.adt.findFirst({
|
||||
where: {
|
||||
id: Number((await params).id),
|
||||
userId: Number(session.id)
|
||||
},
|
||||
include: {
|
||||
category: {
|
||||
include: {
|
||||
parent: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}).then(adt => adt ? {
|
||||
...adt,
|
||||
price: adt.price || ''
|
||||
} : null),
|
||||
prisma.category.findMany({
|
||||
include: {
|
||||
children: true,
|
||||
parent: true
|
||||
}
|
||||
}),
|
||||
prisma.country.findMany(),
|
||||
prisma.city.findMany()
|
||||
]);
|
||||
|
||||
if (!adt) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 className="text-2xl font-semibold mb-6">Редактировать объявление</h1>
|
||||
<AdtEditForm
|
||||
adt={adt}
|
||||
categories={categories}
|
||||
countries={countries}
|
||||
cities={cities}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке данных:', error);
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 className="text-2xl font-semibold mb-6 text-red-600">Произошла ошибка при загрузке данных</h1>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
45
app/(root)/search/page.tsx
Normal file
45
app/(root)/search/page.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { searchAdts } from "@/app/actions/search"
|
||||
import Categories from "@/components/Categories"
|
||||
import ListingCard from "@/components/ListingCard"
|
||||
import { AdtWithRelations } from "@/@types/prisma"
|
||||
|
||||
interface SearchPageProps {
|
||||
searchParams: Promise<{ q: string }>
|
||||
}
|
||||
|
||||
export default async function SearchPage({ searchParams }: SearchPageProps) {
|
||||
const params = await searchParams
|
||||
const results = await searchAdts(params.q)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<Categories />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 className="text-2xl font-semibold mb-6">
|
||||
Результаты поиска: {params.q}
|
||||
</h1>
|
||||
|
||||
{results.length === 0 ? (
|
||||
<div className="text-center py-10">
|
||||
<p className="text-gray-500 text-lg">По вашему запросу ничего не найдено</p>
|
||||
<p className="text-gray-400 mt-2">Попробуйте изменить параметры поиска</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{results.map((adt) => (
|
||||
<ListingCard
|
||||
key={adt.id}
|
||||
id={String(adt.id)}
|
||||
title={adt.title}
|
||||
price={adt.price?.toString() || 'Цена не указана'}
|
||||
location={`${adt.city.nameEn}, ${adt.country.nameEn}`}
|
||||
image={String(adt.image)}
|
||||
date={new Date(adt.createdAt).toLocaleDateString()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
app/actions/categories.ts
Normal file
66
app/actions/categories.ts
Normal file
@ -0,0 +1,66 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/prisma/prisma-client";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function getCategoriesWithCount() {
|
||||
const categories = await prisma.category.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { adts: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
export async function createCategory(data: {
|
||||
nameEn: string;
|
||||
nameAr: string;
|
||||
slug: string;
|
||||
icon?: string;
|
||||
}) {
|
||||
const category = await prisma.category.create({
|
||||
data: {
|
||||
nameEn: data.nameEn,
|
||||
nameAr: data.nameAr,
|
||||
slug: data.slug,
|
||||
icon: data.icon
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePath("/admin/categories");
|
||||
return category;
|
||||
}
|
||||
|
||||
export async function updateCategory(
|
||||
id: string,
|
||||
data: {
|
||||
nameEn: string;
|
||||
nameAr: string;
|
||||
slug: string;
|
||||
icon?: string;
|
||||
}
|
||||
) {
|
||||
const category = await prisma.category.update({
|
||||
where: { id },
|
||||
data: {
|
||||
nameEn: data.nameEn,
|
||||
nameAr: data.nameAr,
|
||||
slug: data.slug,
|
||||
icon: data.icon
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePath("/admin/categories");
|
||||
return category;
|
||||
}
|
||||
|
||||
export async function deleteCategory(id: string) {
|
||||
await prisma.category.delete({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
revalidatePath("/admin/categories");
|
||||
}
|
||||
48
app/actions/search.ts
Normal file
48
app/actions/search.ts
Normal file
@ -0,0 +1,48 @@
|
||||
"use server"
|
||||
|
||||
import { AdtWithRelations } from "@/@types/prisma"
|
||||
import { prisma } from "@/prisma/prisma-client"
|
||||
|
||||
export async function searchAdts(query: string): Promise<AdtWithRelations[]> {
|
||||
if (!query || query.length < 2) {
|
||||
return []
|
||||
}
|
||||
|
||||
const adts = await prisma.adt.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
title: {
|
||||
contains: query,
|
||||
mode: 'insensitive'
|
||||
}
|
||||
},
|
||||
{
|
||||
description: {
|
||||
contains: query,
|
||||
mode: 'insensitive'
|
||||
}
|
||||
}
|
||||
],
|
||||
status: 'PUBLISHED'
|
||||
},
|
||||
include: {
|
||||
images: {
|
||||
take: 1,
|
||||
orderBy: {
|
||||
order: 'asc'
|
||||
}
|
||||
},
|
||||
category: true,
|
||||
city: true,
|
||||
country: true,
|
||||
user: true
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
take: 5
|
||||
})
|
||||
|
||||
return adts as AdtWithRelations[]
|
||||
}
|
||||
93
app/actions/stats.ts
Normal file
93
app/actions/stats.ts
Normal file
@ -0,0 +1,93 @@
|
||||
"use server"
|
||||
|
||||
import { prisma } from "@/prisma/prisma-client"
|
||||
|
||||
|
||||
|
||||
export async function getStats() {
|
||||
const [
|
||||
totalAds,
|
||||
activeAds,
|
||||
totalUsers,
|
||||
newUsers,
|
||||
topCategories,
|
||||
topCities
|
||||
] = await Promise.all([
|
||||
// Общее количество объявлений
|
||||
prisma.adt.count(),
|
||||
|
||||
// Активные объявления
|
||||
prisma.adt.count({
|
||||
where: {
|
||||
status: "PUBLISHED"
|
||||
}
|
||||
}),
|
||||
|
||||
// Общее количество пользователей
|
||||
prisma.user.count(),
|
||||
|
||||
// Новые пользователи за последние 30 дней
|
||||
prisma.user.count({
|
||||
// where: {
|
||||
// createdAt: {
|
||||
// gte: addDays(new Date(), -30)
|
||||
// }
|
||||
// }
|
||||
}),
|
||||
|
||||
// Топ категорий
|
||||
prisma.category.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
nameEn: true,
|
||||
_count: {
|
||||
select: {
|
||||
adts: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
adts: {
|
||||
_count: 'desc'
|
||||
}
|
||||
},
|
||||
take: 5
|
||||
}),
|
||||
|
||||
// Топ городов
|
||||
prisma.city.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
nameEn: true,
|
||||
_count: {
|
||||
select: {
|
||||
adts: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
adts: {
|
||||
_count: 'desc'
|
||||
}
|
||||
},
|
||||
take: 5
|
||||
})
|
||||
])
|
||||
|
||||
return {
|
||||
totalAds,
|
||||
activeAds,
|
||||
totalUsers,
|
||||
newUsers,
|
||||
topCategories: topCategories.map(cat => ({
|
||||
id: cat.id,
|
||||
name: cat.nameEn,
|
||||
count: cat._count.adts
|
||||
})),
|
||||
topCities: topCities.map(city => ({
|
||||
id: city.id,
|
||||
name: city.nameEn,
|
||||
count: city._count.adts
|
||||
}))
|
||||
}
|
||||
}
|
||||
@ -5,29 +5,25 @@ import { getUserSession } from '@/lib/get-user-session';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
|
||||
// GET функция для получения списка объявлений с пагинацией
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
console.log("request",request)
|
||||
// Получаем параметры из URL
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// Параметры пагинации (по умолчанию: страница 1, 10 элементов на странице)
|
||||
const page = Number(searchParams.get('page')) || 1;
|
||||
const limit = Number(searchParams.get('limit')) || 10;
|
||||
const page = Math.max(1, Number(searchParams.get('page')) || 1);
|
||||
const limit = Math.min(50, Math.max(1, Number(searchParams.get('limit')) || 10));
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Получаем параметры фильтрации
|
||||
const category = searchParams.get('category');
|
||||
const cityId = searchParams.get('cityId');
|
||||
|
||||
|
||||
// Формируем условия фильтрации
|
||||
const where: Prisma.AdtWhereInput = {};
|
||||
|
||||
if (category) {
|
||||
// Сначала находим категорию и ее дочерние категории
|
||||
const categoryWithChildren = await prisma.category.findFirst({
|
||||
where: { slug: category },
|
||||
include: {
|
||||
@ -36,27 +32,21 @@ export async function GET(request: Request) {
|
||||
});
|
||||
|
||||
if (categoryWithChildren) {
|
||||
// Получаем ID текущей категории и всех дочерних категорий
|
||||
const categoryIds = [
|
||||
categoryWithChildren.id,
|
||||
...categoryWithChildren.children.map(child => child.id)
|
||||
...(categoryWithChildren.children?.map(child => child.id) || [])
|
||||
];
|
||||
|
||||
// Используем IN для поиска объявлений во всех категориях
|
||||
where.categoryId = {
|
||||
in: categoryIds
|
||||
};
|
||||
} else {
|
||||
where.category = {
|
||||
slug: category
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (cityId) {
|
||||
where.cityId = cityId;
|
||||
}
|
||||
|
||||
|
||||
// Получаем общее количество объявлений для пагинации с учетом фильтров
|
||||
const total = await prisma.adt.count({ where });
|
||||
|
||||
@ -83,11 +73,10 @@ export async function GET(request: Request) {
|
||||
country: true
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc' // Сортировка по дате создания (новые первыми)
|
||||
createdAt: 'desc'
|
||||
}
|
||||
});
|
||||
|
||||
// Формируем метаданные для пагинации
|
||||
const meta = {
|
||||
currentPage: page,
|
||||
itemsPerPage: limit,
|
||||
@ -109,12 +98,11 @@ export async function GET(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getUserSession();
|
||||
|
||||
if (!session) {
|
||||
if (!session?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Необходима авторизация' },
|
||||
{ status: 401 }
|
||||
@ -123,10 +111,8 @@ export async function POST(request: Request) {
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Валидация данных
|
||||
const validatedData = formAdtCreateSchema.parse(body);
|
||||
|
||||
// Получаем пользователя
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: Number(session.id) }
|
||||
});
|
||||
@ -137,13 +123,13 @@ export async function POST(request: Request) {
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
// Создание объявления
|
||||
|
||||
const adt = await prisma.adt.create({
|
||||
data: {
|
||||
title: validatedData.title,
|
||||
description: validatedData.description,
|
||||
price: validatedData.price,
|
||||
address: validatedData.address,
|
||||
address: validatedData.address || '',
|
||||
image: validatedData.image,
|
||||
userId: user.id,
|
||||
categoryId: validatedData.categoryId,
|
||||
@ -158,9 +144,86 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json(adt, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating adt:', error);
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ошибка валидации данных' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Ошибка при создании объявления' },
|
||||
{ status: 400 }
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
|
||||
const session = await getUserSession();
|
||||
|
||||
if (!session?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Необходима авторизация' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { id, ...updateData } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ID объявления не указан' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const validatedData = formAdtCreateSchema.parse(updateData);
|
||||
|
||||
const existingAdt = await prisma.adt.findFirst({
|
||||
where: {
|
||||
id: Number(id),
|
||||
userId: Number(session.id)
|
||||
}
|
||||
});
|
||||
|
||||
if (!existingAdt) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Объявление не найдено или у вас нет прав на его редактирование' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const updatedAdt = await prisma.adt.update({
|
||||
where: { id: Number(id) },
|
||||
data: {
|
||||
title: validatedData.title,
|
||||
description: validatedData.description,
|
||||
price: validatedData.price,
|
||||
address: validatedData.address || '',
|
||||
image: validatedData.image,
|
||||
categoryId: validatedData.categoryId,
|
||||
countryId: validatedData.countryId,
|
||||
cityId: validatedData.cityId
|
||||
},
|
||||
include: {
|
||||
category: true
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json(updatedAdt);
|
||||
} catch (error) {
|
||||
console.error('Error updating adt:', error);
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ошибка валидации данных' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Ошибка при обновлении объявления' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@ export default async function Categories() {
|
||||
>
|
||||
<div className="bg-white p-2 rounded-md shadow-sm hover:shadow-md transition-shadow duration-200 flex flex-col items-center justify-center min-h-[70px] border border-gray-100">
|
||||
{category.icon && (
|
||||
<span className="text-xl mb-1 group-hover:scale-110 transition-transform duration-200">
|
||||
<span className="text-xl mb-1 ">
|
||||
{category.icon}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@ -7,6 +7,7 @@ import { Button } from './ui/button';
|
||||
import { ProfileButton } from './shared/profile-button';
|
||||
import { AuthModal } from './shared/modals/auth-modal/auth-modal';
|
||||
import AddButton from './shared/add-button';
|
||||
import { SearchBar } from './shared/search-bar'
|
||||
|
||||
export default function Header() {
|
||||
const [openAuthModal, setOpenAuthModal] = React.useState(false)
|
||||
@ -24,14 +25,7 @@ export default function Header() {
|
||||
|
||||
{/* Desktop Search */}
|
||||
<div className="hidden sm:flex flex-1 max-w-2xl mx-8">
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search listings..."
|
||||
className="w-full pl-10 pr-4 py-2 rounded-lg border border-gray-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
<Search className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<SearchBar />
|
||||
</div>
|
||||
|
||||
{/* Mobile Actions */}
|
||||
@ -68,14 +62,7 @@ export default function Header() {
|
||||
{/* Mobile Search */}
|
||||
{mobileSearchOpen && (
|
||||
<div className="sm:hidden py-2 border-t">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search listings..."
|
||||
className="w-full pl-10 pr-4 py-2 rounded-lg border border-gray-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
<Search className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<SearchBar />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
52
components/admin/AdminSidebar.tsx
Normal file
52
components/admin/AdminSidebar.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
MapPin,
|
||||
Globe,
|
||||
Tags
|
||||
} from "lucide-react";
|
||||
|
||||
const AdminSidebar = () => {
|
||||
const menuItems = [
|
||||
{
|
||||
title: "Панель управления",
|
||||
href: "/admin",
|
||||
icon: LayoutDashboard
|
||||
},
|
||||
{
|
||||
title: "Категории",
|
||||
href: "/admin/categories",
|
||||
icon: Tags
|
||||
},
|
||||
{
|
||||
title: "Города",
|
||||
href: "/admin/cities",
|
||||
icon: MapPin
|
||||
},
|
||||
{
|
||||
title: "Страны",
|
||||
href: "/admin/countries",
|
||||
icon: Globe
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-64 min-h-screen bg-gray-900 text-white p-4">
|
||||
<div className="text-xl font-bold mb-8">Админ панель</div>
|
||||
<nav>
|
||||
{menuItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="flex items-center gap-2 p-2 hover:bg-gray-800 rounded-lg mb-2"
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminSidebar;
|
||||
111
components/admin/CategoryForm.tsx
Normal file
111
components/admin/CategoryForm.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { createCategory, updateCategory } from "@/app/actions/categories";
|
||||
|
||||
interface CategoryFormProps {
|
||||
category?: {
|
||||
id: string;
|
||||
nameEn: string;
|
||||
nameAr: string;
|
||||
slug: string;
|
||||
icon?: string;
|
||||
};
|
||||
mode?: "create" | "edit";
|
||||
}
|
||||
|
||||
export default function CategoryForm({ category, mode = "create" }: CategoryFormProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData(e.currentTarget);
|
||||
|
||||
if (mode === "create") {
|
||||
await createCategory({
|
||||
nameEn: formData.get("nameEn") as string,
|
||||
nameAr: formData.get("nameAr") as string,
|
||||
slug: formData.get("slug") as string,
|
||||
icon: formData.get("icon") as string,
|
||||
});
|
||||
toast({
|
||||
title: "Категория создана",
|
||||
description: "Новая категория успешно добавлена",
|
||||
});
|
||||
} else {
|
||||
await updateCategory(category!.id, {
|
||||
nameEn: formData.get("nameEn") as string,
|
||||
nameAr: formData.get("nameAr") as string,
|
||||
slug: formData.get("slug") as string,
|
||||
icon: formData.get("icon") as string,
|
||||
});
|
||||
toast({
|
||||
title: "Категория обновлена",
|
||||
description: "Изменения успешно сохранены",
|
||||
});
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Ошибка",
|
||||
description: "Что-то пошло не так",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
{mode === "create" ? "Добавить категорию" : "Редактировать"}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === "create" ? "Новая категория" : "Редактировать категорию"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
|
||||
<Input
|
||||
name="nameEn"
|
||||
placeholder="Название на английском"
|
||||
defaultValue={category?.nameEn}
|
||||
/>
|
||||
<Input
|
||||
name="nameAr"
|
||||
placeholder="Название на арабском"
|
||||
defaultValue={category?.nameAr}
|
||||
/>
|
||||
<Input
|
||||
name="slug"
|
||||
placeholder="URL-slug"
|
||||
defaultValue={category?.slug}
|
||||
/>
|
||||
<Input
|
||||
name="icon"
|
||||
placeholder="Иконка (URL или название)"
|
||||
defaultValue={category?.icon}
|
||||
/>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Сохранение..." : "Сохранить"}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
77
components/admin/DataTable.tsx
Normal file
77
components/admin/DataTable.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[]
|
||||
data: TData[]
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
Нет данных
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
293
components/shared/adt-edit/adt-edit-form.tsx
Normal file
293
components/shared/adt-edit/adt-edit-form.tsx
Normal file
@ -0,0 +1,293 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useMemo, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { formAdtEditSchema, type TFormAdtEditValues } from './schemas';
|
||||
import Image from 'next/image';
|
||||
import { Category, Country, City, Adt } from '@prisma/client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface AdtEditFormProps {
|
||||
adt: Adt & {
|
||||
price: string;
|
||||
category: Category & {
|
||||
parent: Category | null;
|
||||
};
|
||||
};
|
||||
categories: (Category & {
|
||||
children: Category[];
|
||||
parent: Category | null;
|
||||
})[];
|
||||
countries: Country[];
|
||||
cities: City[];
|
||||
}
|
||||
|
||||
export default function AdtEditForm({ adt, categories, countries, cities }: AdtEditFormProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [preview, setPreview] = useState<string | null>(adt.image);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<TFormAdtEditValues>({
|
||||
resolver: zodResolver(formAdtEditSchema),
|
||||
defaultValues: {
|
||||
title: adt.title,
|
||||
description: adt.description || '',
|
||||
price: String(adt.price) || '',
|
||||
categoryId: adt.categoryId,
|
||||
countryId: String(adt.countryId),
|
||||
cityId: String(adt.cityId),
|
||||
address: adt.address || '',
|
||||
image: adt.image || '',
|
||||
}
|
||||
});
|
||||
|
||||
const [selectedCountry, setSelectedCountry] = useState<string>(adt.countryId);
|
||||
const [filteredCities, setFilteredCities] = useState<City[]>([]);
|
||||
const [selectedParentCategory, setSelectedParentCategory] = useState<string | null>(
|
||||
adt.category.parentId || adt.category.id
|
||||
);
|
||||
const [filteredSubCategories, setFilteredSubCategories] = useState<Category[]>([]);
|
||||
|
||||
const parentCategories = useMemo(() => {
|
||||
return categories.filter(cat => !cat.parentId);
|
||||
}, [categories]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCountry) {
|
||||
const countryCities = cities.filter(city => city.countryId === selectedCountry);
|
||||
setFilteredCities(countryCities);
|
||||
}
|
||||
}, [selectedCountry, cities]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedParentCategory) {
|
||||
const subCategories = categories.filter(cat => cat.parentId === selectedParentCategory);
|
||||
setFilteredSubCategories(subCategories);
|
||||
}
|
||||
}, [selectedParentCategory, categories]);
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setPreview(reader.result as string);
|
||||
form.setValue('image', reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: TFormAdtEditValues) => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
const response = await fetch('/api/adt', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ ...values, id: String(adt.id) }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
toast.error("Ошибка при обновлении объявления: " + error);
|
||||
throw new Error('Error updating adt');
|
||||
}
|
||||
|
||||
const updatedAdt = await response.json();
|
||||
toast.success("Объявление успешно обновлено");
|
||||
router.push(`/adt/${updatedAdt.id}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCountryChange = (countryId: string) => {
|
||||
setSelectedCountry(countryId);
|
||||
form.setValue('countryId', countryId);
|
||||
};
|
||||
|
||||
const handleCategoryParentChange = (categoryId: string) => {
|
||||
setSelectedParentCategory(categoryId);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="max-w-2xl mx-auto p-6 space-y-6">
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
|
||||
Заголовок*
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
{...form.register('title')}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
{form.formState.errors.title && (
|
||||
<p className="mt-1 text-sm text-red-600">{form.formState.errors.title.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
rows={4}
|
||||
{...form.register('description')}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="price" className="block text-sm font-medium text-gray-700">
|
||||
Цена
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="price"
|
||||
{...form.register('price')}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
placeholder="Например: 1000 ₽"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Основная категория*
|
||||
</label>
|
||||
<select
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
onChange={(e) => handleCategoryParentChange(e.target.value)}
|
||||
value={selectedParentCategory || ''}
|
||||
>
|
||||
{parentCategories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.nameEn}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedParentCategory && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Подкатегория*
|
||||
</label>
|
||||
<select
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
{...form.register('categoryId')}
|
||||
>
|
||||
{filteredSubCategories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.nameEn}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Страна*
|
||||
</label>
|
||||
<select
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
{...form.register('countryId')}
|
||||
onChange={(e) => handleCountryChange(e.target.value)}
|
||||
value={selectedCountry}
|
||||
>
|
||||
{countries.map((country) => (
|
||||
<option key={country.id} value={country.id}>
|
||||
{country.nameEn}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Город*
|
||||
</label>
|
||||
<select
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
{...form.register('cityId')}
|
||||
>
|
||||
{filteredCities.map((city) => (
|
||||
<option key={city.id} value={city.id}>
|
||||
{city.nameEn}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
|
||||
Адрес
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="address"
|
||||
{...form.register('address')}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Изображение
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
ref={fileInputRef}
|
||||
className="mt-1 block w-full text-sm text-gray-500
|
||||
file:mr-4 file:py-2 file:px-4
|
||||
file:rounded-md file:border-0
|
||||
file:text-sm file:font-semibold
|
||||
file:bg-indigo-50 file:text-indigo-700
|
||||
hover:file:bg-indigo-100"
|
||||
/>
|
||||
{preview && (
|
||||
<div className="mt-2 relative h-48 w-48">
|
||||
{/* <Image
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
fill
|
||||
className="object-cover rounded-md"
|
||||
/> */}
|
||||
<img
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
className="object-cover rounded-md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:bg-gray-400"
|
||||
>
|
||||
{isSubmitting ? 'Сохранение...' : 'Сохранить изменения'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
14
components/shared/adt-edit/schemas.ts
Normal file
14
components/shared/adt-edit/schemas.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const formAdtEditSchema = z.object({
|
||||
title: z.string().min(1, 'Заголовок обязателен'),
|
||||
description: z.string().optional(),
|
||||
price: z.string().optional(),
|
||||
categoryId: z.string().min(1, 'Выберите категорию'),
|
||||
countryId: z.string().min(1, 'Выберите страну'),
|
||||
cityId: z.string().min(1, 'Выберите город'),
|
||||
address: z.string().optional(),
|
||||
image: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TFormAdtEditValues = z.infer<typeof formAdtEditSchema>;
|
||||
120
components/shared/search-bar.tsx
Normal file
120
components/shared/search-bar.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
"use client"
|
||||
|
||||
import { Search } from "lucide-react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useEffect, useState, useRef } from "react"
|
||||
import { useDebounce } from "@/hooks/use-debounce"
|
||||
import { searchAdts } from "@/app/actions/search"
|
||||
import Link from "next/link"
|
||||
import { AdtWithRelations } from "@/@types/prisma"
|
||||
|
||||
export function SearchBar() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [value, setValue] = useState(searchParams.get('q') || '')
|
||||
const [suggestions, setSuggestions] = useState<AdtWithRelations[]>([])
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const debouncedValue = useDebounce(value, 300)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const getSuggestions = async () => {
|
||||
if (debouncedValue.length < 1) {
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
|
||||
const results = await searchAdts(debouncedValue)
|
||||
setSuggestions(results)
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
getSuggestions()
|
||||
}, [debouncedValue])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (value.trim()) {
|
||||
router.push(`/search?q=${encodeURIComponent(value.trim())}`)
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={wrapperRef}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => value.length >= 1 && setIsOpen(true)}
|
||||
placeholder="Поиск объявлений..."
|
||||
className="w-full pl-10 pr-4 py-2 rounded-lg border border-gray-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
<Search className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
</form>
|
||||
{/* Выпадающий список с предложениями */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="absolute z-50 w-full mt-1 bg-white rounded-md shadow-lg border border-gray-200 max-h-96 overflow-auto"
|
||||
>
|
||||
{suggestions.length > 0 ? (
|
||||
suggestions.map((adt) => (
|
||||
<Link
|
||||
key={adt.id}
|
||||
href={`/adt/${adt.id}`}
|
||||
onClick={() => {
|
||||
setIsOpen(false)
|
||||
setValue('')
|
||||
}}
|
||||
className="block px-4 py-2 hover:bg-gray-100 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{adt.image && (
|
||||
<img
|
||||
src={adt.image}
|
||||
alt={adt.title}
|
||||
className="w-12 h-12 object-cover rounded"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium">{adt.title}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{adt.price ? `${adt.price} ₽` : 'Цена не указана'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-3 text-sm text-gray-500">
|
||||
Ничего не найдено
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
120
components/ui/table.tsx
Normal file
120
components/ui/table.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-neutral-100/50 font-medium [&>tr]:last:border-b-0 dark:bg-neutral-800/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-neutral-100/50 data-[state=selected]:bg-neutral-100 dark:hover:bg-neutral-800/50 dark:data-[state=selected]:bg-neutral-800",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-neutral-500 [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] dark:text-neutral-400",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-neutral-500 dark:text-neutral-400", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
17
hooks/use-debounce.ts
Normal file
17
hooks/use-debounce.ts
Normal file
@ -0,0 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function useDebounce<T>(value: T, delay?: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay || 300)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
318
package-lock.json
generated
318
package-lock.json
generated
@ -7,7 +7,9 @@
|
||||
"": {
|
||||
"name": "bazar",
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@clerk/nextjs": "^6.5.0",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
@ -17,6 +19,7 @@
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"axios": "^1.7.7",
|
||||
"bcrypt": "^5.1.1",
|
||||
@ -69,6 +72,161 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/backend": {
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-1.18.0.tgz",
|
||||
"integrity": "sha512-FIGBtr8qIgS+NbovKcLOIg8BsQj1JaykW3VKft3H83tONQD5Rg9ENXj/JX1HyZGC3ixLv5BUxu4bihQ78L/gJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clerk/shared": "2.17.0",
|
||||
"@clerk/types": "4.35.0",
|
||||
"cookie": "0.7.0",
|
||||
"snakecase-keys": "5.4.4",
|
||||
"tslib": "2.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/backend/node_modules/cookie": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.0.tgz",
|
||||
"integrity": "sha512-qCf+V4dtlNhSRXGAZatc1TasyFO6GjohcOul807YOb5ik3+kQSnb4d7iajeCL8QHaJ4uZEjCgiCJerKXwdRVlQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/backend/node_modules/tslib": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
|
||||
"integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@clerk/clerk-react": {
|
||||
"version": "5.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.17.0.tgz",
|
||||
"integrity": "sha512-++PSiayBaK877XTjbTZzyqommVlxnFNevxCKsOXbq8+KENAyYkUZ6LJpyoWjfjg1fnKq6oUFax+TwnyOyyxoqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clerk/shared": "2.17.0",
|
||||
"@clerk/types": "4.35.0",
|
||||
"tslib": "2.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19.0.0-0",
|
||||
"react-dom": "^18 || ^19.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/clerk-react/node_modules/tslib": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
|
||||
"integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@clerk/nextjs": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-6.5.0.tgz",
|
||||
"integrity": "sha512-0+MhX4Hz/fdvVYeZIC7XNI8qZ7vX+UtfMksE6qdyWR5IGewCsNiIHlDW0fDtau6p1e6TsQ66qTnZz15yU4G7dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clerk/backend": "1.18.0",
|
||||
"@clerk/clerk-react": "5.17.0",
|
||||
"@clerk/shared": "2.17.0",
|
||||
"@clerk/types": "4.35.0",
|
||||
"crypto-js": "4.2.0",
|
||||
"ezheaders": "0.1.0",
|
||||
"server-only": "0.0.1",
|
||||
"tslib": "2.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": "^13.5.4 || ^14.0.3 || ^15.0.0",
|
||||
"react": "^18 || ^19.0.0-0",
|
||||
"react-dom": "^18 || ^19.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/nextjs/node_modules/tslib": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
|
||||
"integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@clerk/shared": {
|
||||
"version": "2.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-2.17.0.tgz",
|
||||
"integrity": "sha512-Jn/oBGjGfQQJIiMMf6Y0puC62R0rL1l/gLoPWnO6vNlCBzuFHGcsSTCWWDpmAXfyVwY7wJ7Dcq/B1UEsKeJ8zQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clerk/types": "4.35.0",
|
||||
"dequal": "2.0.3",
|
||||
"glob-to-regexp": "0.4.1",
|
||||
"js-cookie": "3.0.5",
|
||||
"std-env": "^3.7.0",
|
||||
"swr": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19.0.0-0",
|
||||
"react-dom": "^18 || ^19.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/shared/node_modules/swr": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz",
|
||||
"integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"client-only": "^0.0.1",
|
||||
"use-sync-external-store": "^1.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.11.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/shared/node_modules/swr/node_modules/use-sync-external-store": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz",
|
||||
"integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/types": {
|
||||
"version": "4.35.0",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.35.0.tgz",
|
||||
"integrity": "sha512-sBSYCCIXcwI+JHQRqBtskw10+rQ1NcA9w1G6ndnS48F5C+if6xI9OpYaaDjhhvzLjBeZFydKAOhe35mgC7bmoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/types/node_modules/csstype": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz",
|
||||
"integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz",
|
||||
@ -2108,6 +2266,39 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "8.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.5.tgz",
|
||||
"integrity": "sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "8.20.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz",
|
||||
"integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcrypt": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz",
|
||||
@ -3139,6 +3330,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@ -3293,6 +3490,15 @@
|
||||
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
|
||||
@ -3333,6 +3539,16 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dot-case": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
|
||||
"integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"no-case": "^3.0.4",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
@ -4018,6 +4234,14 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ezheaders": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ezheaders/-/ezheaders-0.1.0.tgz",
|
||||
"integrity": "sha512-U0wdCs2dS+IzFuxyHGyw1aWhiunW22sGqnyH4yQsovkgqUvO4YSbzQ5BQzV6HY4oFlNnK+TbFGJj8rvvX5aN7w==",
|
||||
"peerDependencies": {
|
||||
"next": "^13.5.4 || ^14 || ^15"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@ -4424,6 +4648,12 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-to-regexp": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
|
||||
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "13.24.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
|
||||
@ -5149,6 +5379,15 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-cookie": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@ -5312,6 +5551,15 @@
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lower-case": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
|
||||
"integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
@ -5351,6 +5599,18 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/map-obj": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz",
|
||||
"integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@ -5618,6 +5878,16 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/no-case": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
|
||||
"integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lower-case": "^2.0.2",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
|
||||
@ -6596,6 +6866,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/server-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
|
||||
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
@ -6738,6 +7014,42 @@
|
||||
"is-arrayish": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/snake-case": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",
|
||||
"integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dot-case": "^3.0.4",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/snakecase-keys": {
|
||||
"version": "5.4.4",
|
||||
"resolved": "https://registry.npmjs.org/snakecase-keys/-/snakecase-keys-5.4.4.tgz",
|
||||
"integrity": "sha512-YTywJG93yxwHLgrYLZjlC75moVEX04LZM4FHfihjHe1FCXm+QaLOFfSf535aXOAd0ArVQMWUAe8ZPm4VtWyXaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"map-obj": "^4.1.0",
|
||||
"snake-case": "^3.0.4",
|
||||
"type-fest": "^2.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/snakecase-keys/node_modules/type-fest": {
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
|
||||
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=12.20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@ -6747,6 +7059,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.0.tgz",
|
||||
"integrity": "sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/nextjs": "^6.5.0",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
@ -25,6 +26,7 @@
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"axios": "^1.7.7",
|
||||
"bcrypt": "^5.1.1",
|
||||
|
||||
@ -99,7 +99,7 @@ export const categories = [
|
||||
id: "cat-1",
|
||||
nameEn: "Vehicles",
|
||||
nameAr: "مركبات",
|
||||
slug: "vehicles",
|
||||
slug: "vehicles",
|
||||
icon: "🚗",
|
||||
isActive: true,
|
||||
order: 1
|
||||
@ -115,7 +115,7 @@ export const categories = [
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
id: "cat-1-2",
|
||||
id: "cat-1-2",
|
||||
nameEn: "Motorcycles",
|
||||
nameAr: "دراجات نارية",
|
||||
slug: "motorcycles",
|
||||
@ -130,7 +130,7 @@ export const categories = [
|
||||
nameAr: "قطع غيار",
|
||||
slug: "spare-parts",
|
||||
icon: "🔧",
|
||||
parentId: "cat-1",
|
||||
parentId: "cat-1",
|
||||
isActive: true,
|
||||
order: 3
|
||||
},
|
||||
@ -256,6 +256,252 @@ export const categories = [
|
||||
parentId: "cat-4",
|
||||
isActive: true,
|
||||
order: 3
|
||||
},
|
||||
|
||||
// Мебель и интерьер
|
||||
{
|
||||
id: "cat-5",
|
||||
nameEn: "Furniture & Interior",
|
||||
nameAr: "أثاث وديكور",
|
||||
slug: "furniture-interior",
|
||||
icon: "🛋️",
|
||||
isActive: true,
|
||||
order: 5
|
||||
},
|
||||
{
|
||||
id: "cat-5-1",
|
||||
nameEn: "Living Room",
|
||||
nameAr: "غرفة المعيشة",
|
||||
slug: "living-room",
|
||||
icon: "🛋️",
|
||||
parentId: "cat-5",
|
||||
isActive: true,
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
id: "cat-5-2",
|
||||
nameEn: "Bedroom",
|
||||
nameAr: "غرفة النوم",
|
||||
slug: "bedroom",
|
||||
icon: "🛏️",
|
||||
parentId: "cat-5",
|
||||
isActive: true,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
id: "cat-5-3",
|
||||
nameEn: "Kitchen",
|
||||
nameAr: "مطبخ",
|
||||
slug: "kitchen",
|
||||
icon: "🍳",
|
||||
parentId: "cat-5",
|
||||
isActive: true,
|
||||
order: 3
|
||||
},
|
||||
|
||||
// Спорт и отдых
|
||||
{
|
||||
id: "cat-6",
|
||||
nameEn: "Sports & Leisure",
|
||||
nameAr: "رياضة وترفيه",
|
||||
slug: "sports-leisure",
|
||||
icon: "⚽",
|
||||
isActive: true,
|
||||
order: 6
|
||||
},
|
||||
{
|
||||
id: "cat-6-1",
|
||||
nameEn: "Fitness Equipment",
|
||||
nameAr: "معدات رياضية",
|
||||
slug: "fitness",
|
||||
icon: "🏋️",
|
||||
parentId: "cat-6",
|
||||
isActive: true,
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
id: "cat-6-2",
|
||||
nameEn: "Outdoor Sports",
|
||||
nameAr: "رياضات خارجية",
|
||||
slug: "outdoor-sports",
|
||||
icon: "🎣",
|
||||
parentId: "cat-6",
|
||||
isActive: true,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
id: "cat-6-3",
|
||||
nameEn: "Water Sports",
|
||||
nameAr: "رياضات مائية",
|
||||
slug: "water-sports",
|
||||
icon: "🏊",
|
||||
parentId: "cat-6",
|
||||
isActive: true,
|
||||
order: 3
|
||||
},
|
||||
|
||||
// Бытовая техника
|
||||
{
|
||||
id: "cat-7",
|
||||
nameEn: "Home Appliances",
|
||||
nameAr: "أجهزة منزلية",
|
||||
slug: "home-appliances",
|
||||
icon: "🔌",
|
||||
isActive: true,
|
||||
order: 7
|
||||
},
|
||||
{
|
||||
id: "cat-7-1",
|
||||
nameEn: "Kitchen Appliances",
|
||||
nameAr: "أجهزة المطبخ",
|
||||
slug: "kitchen-appliances",
|
||||
icon: "🍳",
|
||||
parentId: "cat-7",
|
||||
isActive: true,
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
id: "cat-7-2",
|
||||
nameEn: "Cleaning Equipment",
|
||||
nameAr: "معدات تنظيف",
|
||||
slug: "cleaning",
|
||||
icon: "🧹",
|
||||
parentId: "cat-7",
|
||||
isActive: true,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
id: "cat-7-3",
|
||||
nameEn: "Climate Control",
|
||||
nameAr: "تحكم بالمناخ",
|
||||
slug: "climate",
|
||||
icon: "❄️",
|
||||
parentId: "cat-7",
|
||||
isActive: true,
|
||||
order: 3
|
||||
},
|
||||
|
||||
// Животные
|
||||
{
|
||||
id: "cat-8",
|
||||
nameEn: "Pets",
|
||||
nameAr: "حيوانات أليفة",
|
||||
slug: "pets",
|
||||
icon: "🐾",
|
||||
isActive: true,
|
||||
order: 8
|
||||
},
|
||||
{
|
||||
id: "cat-8-1",
|
||||
nameEn: "Dogs",
|
||||
nameAr: "كلاب",
|
||||
slug: "dogs",
|
||||
icon: "🐕",
|
||||
parentId: "cat-8",
|
||||
isActive: true,
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
id: "cat-8-2",
|
||||
nameEn: "Cats",
|
||||
nameAr: "قطط",
|
||||
slug: "cats",
|
||||
icon: "🐈",
|
||||
parentId: "cat-8",
|
||||
isActive: true,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
id: "cat-8-3",
|
||||
nameEn: "Pet Supplies",
|
||||
nameAr: "مستلزمات الحيوانات",
|
||||
slug: "pet-supplies",
|
||||
icon: "🦴",
|
||||
parentId: "cat-8",
|
||||
isActive: true,
|
||||
order: 3
|
||||
},
|
||||
|
||||
// Услуги
|
||||
{
|
||||
id: "cat-9",
|
||||
nameEn: "Services",
|
||||
nameAr: "خدمات",
|
||||
slug: "services",
|
||||
icon: "🛠️",
|
||||
isActive: true,
|
||||
order: 9
|
||||
},
|
||||
{
|
||||
id: "cat-9-1",
|
||||
nameEn: "Home Services",
|
||||
nameAr: "خدمات منزلية",
|
||||
slug: "home-services",
|
||||
icon: "🏠",
|
||||
parentId: "cat-9",
|
||||
isActive: true,
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
id: "cat-9-2",
|
||||
nameEn: "Business Services",
|
||||
nameAr: "خدمات تجارية",
|
||||
slug: "business-services",
|
||||
icon: "💼",
|
||||
parentId: "cat-9",
|
||||
isActive: true,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
id: "cat-9-3",
|
||||
nameEn: "Education",
|
||||
nameAr: "تعليم",
|
||||
slug: "education",
|
||||
icon: "📚",
|
||||
parentId: "cat-9",
|
||||
isActive: true,
|
||||
order: 3
|
||||
},
|
||||
|
||||
// Хобби и развлечения
|
||||
{
|
||||
id: "cat-10",
|
||||
nameEn: "Hobbies & Entertainment",
|
||||
nameAr: "هوايات وترفيه",
|
||||
slug: "hobbies",
|
||||
icon: "🎨",
|
||||
isActive: true,
|
||||
order: 10
|
||||
},
|
||||
{
|
||||
id: "cat-10-1",
|
||||
nameEn: "Musical Instruments",
|
||||
nameAr: "آلات موسيقية",
|
||||
slug: "music",
|
||||
icon: "🎸",
|
||||
parentId: "cat-10",
|
||||
isActive: true,
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
id: "cat-10-2",
|
||||
nameEn: "Art & Crafts",
|
||||
nameAr: "فنون وحرف",
|
||||
slug: "art",
|
||||
icon: "🎨",
|
||||
parentId: "cat-10",
|
||||
isActive: true,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
id: "cat-10-3",
|
||||
nameEn: "Books & Magazines",
|
||||
nameAr: "كتب ومجلات",
|
||||
slug: "books",
|
||||
icon: "📚",
|
||||
parentId: "cat-10",
|
||||
isActive: true,
|
||||
order: 3
|
||||
}
|
||||
];
|
||||
|
||||
@ -263,7 +509,7 @@ export const adts = [
|
||||
{
|
||||
title: "2023 Toyota Camry",
|
||||
description: "Excellent condition, low mileage",
|
||||
price: 75000.00,
|
||||
price: '75000.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
@ -279,8 +525,8 @@ export const adts = [
|
||||
},
|
||||
{
|
||||
title: "iPhone 14 Pro Max",
|
||||
description: "Новый, запечатанный iPhone 14 Pro Max 256GB",
|
||||
price: 4499.00,
|
||||
description: "Brand new sealed iPhone 14 Pro Max 256GB",
|
||||
price: '4499.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
@ -295,9 +541,9 @@ export const adts = [
|
||||
isPromoted: false
|
||||
},
|
||||
{
|
||||
title: "Роскошная вилла с бассейном",
|
||||
description: "6 спален, 7 ванных комнат, частный бассейн, сад",
|
||||
price: 12000000.00,
|
||||
title: "Luxury Villa with Pool",
|
||||
description: "6 bedrooms, 7 bathrooms, private pool, garden",
|
||||
price: '12000000.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
@ -312,9 +558,9 @@ export const adts = [
|
||||
isPromoted: true
|
||||
},
|
||||
{
|
||||
title: "Дизайнерская сумка Gucci",
|
||||
description: "Оригинальная сумка Gucci из новой коллекции",
|
||||
price: 8500.00,
|
||||
title: "Designer Gucci Bag",
|
||||
description: "Original Gucci bag from new collection",
|
||||
price: '8500.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
@ -329,43 +575,94 @@ export const adts = [
|
||||
isPromoted: false
|
||||
},
|
||||
{
|
||||
title: "Harley-Davidson Street Glide",
|
||||
description: "2022 год, пробег 5000 км, отличное состояние",
|
||||
price: 95000.00,
|
||||
title: "2022 Mercedes-Benz S-Class",
|
||||
description: "Luxury sedan with full options, only 3000km",
|
||||
price: '450000.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
address: "Motor City",
|
||||
latitude: 25.0511,
|
||||
longitude: 55.2492,
|
||||
address: "Sheikh Zayed Road",
|
||||
latitude: 25.2048,
|
||||
longitude: 55.2708,
|
||||
userId: 1,
|
||||
categoryId: "cat-1-2",
|
||||
categoryId: "cat-1-1",
|
||||
views: 0,
|
||||
image: "https://images.unsplash.com/photo-1558981806-ec527fa84c39",
|
||||
image: "https://images.unsplash.com/photo-1622194993799-e7f132e4126a",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: true
|
||||
},
|
||||
{
|
||||
title: "Оригинальные запчасти BMW",
|
||||
description: "Новые тормозные диски и колодки для BMW X5",
|
||||
price: 2500.00,
|
||||
title: "Gaming PC Setup",
|
||||
description: "High-end gaming PC with RTX 4090",
|
||||
price: '12000.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
address: "Al Quoz",
|
||||
latitude: 25.1539,
|
||||
longitude: 55.2289,
|
||||
address: "Business Bay",
|
||||
latitude: 25.1872,
|
||||
longitude: 55.2744,
|
||||
userId: 1,
|
||||
categoryId: "cat-1-3",
|
||||
categoryId: "cat-3-3",
|
||||
views: 0,
|
||||
image: "https://images.unsplash.com/photo-1486262715619-67b85e0b08d3",
|
||||
image: "https://images.unsplash.com/photo-1587202372634-32705e3bf49c",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: false
|
||||
},
|
||||
{
|
||||
title: "Женское вечернее платье",
|
||||
description: "Элегантное вечернее платье от известного дизайнера",
|
||||
price: 3500.00,
|
||||
title: "Luxury Apartment in Downtown",
|
||||
description: "3BR apartment with Burj Khalifa view",
|
||||
price: '3500000.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
address: "Downtown Dubai",
|
||||
latitude: 25.2048,
|
||||
longitude: 55.2708,
|
||||
userId: 1,
|
||||
categoryId: "cat-2-1",
|
||||
views: 0,
|
||||
image: "https://images.unsplash.com/photo-1522708323590-d24dbb6b0267",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: true
|
||||
},
|
||||
{
|
||||
title: "Professional DJ Equipment",
|
||||
description: "Complete DJ setup with Pioneer CDJs and mixer",
|
||||
price: '15000.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
address: "JLT",
|
||||
latitude: 25.0750,
|
||||
longitude: 55.1375,
|
||||
userId: 1,
|
||||
categoryId: "cat-10-1",
|
||||
views: 0,
|
||||
image: "https://images.unsplash.com/photo-1571935441005-55aaae1bd080",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: false
|
||||
},
|
||||
{
|
||||
title: "Modern Living Room Set",
|
||||
description: "Complete furniture set with sofa and tables",
|
||||
price: '25000.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
address: "Dubai Marina",
|
||||
latitude: 25.2048,
|
||||
longitude: 55.2708,
|
||||
userId: 1,
|
||||
categoryId: "cat-5-1",
|
||||
views: 0,
|
||||
image: "https://images.unsplash.com/photo-1555041469-a586c61ea9bc",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: true
|
||||
},
|
||||
{
|
||||
title: "MacBook Pro 16-inch",
|
||||
description: "M2 Max chip, 32GB RAM, 1TB SSD",
|
||||
price: '9500.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
@ -373,149 +670,283 @@ export const adts = [
|
||||
latitude: 25.1972,
|
||||
longitude: 55.2744,
|
||||
userId: 1,
|
||||
categoryId: "cat-4-2",
|
||||
categoryId: "cat-3-2",
|
||||
views: 0,
|
||||
image: "https://images.unsplash.com/photo-1566174053879-31528523f8ae",
|
||||
image: "https://images.unsplash.com/photo-1517336714731-489689fd1ca8",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: true
|
||||
isPromoted: false
|
||||
},
|
||||
{
|
||||
title: "Мужской костюм Tom Ford",
|
||||
description: "Новый костюм Tom Ford, размер 52",
|
||||
price: 12000.00,
|
||||
title: "iPhone 14 Pro Max",
|
||||
description: "Brand new, 256GB storage, Space Black",
|
||||
price: '4500.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
address: "Mall of Emirates",
|
||||
latitude: 25.1181,
|
||||
longitude: 55.2008,
|
||||
userId: 1,
|
||||
categoryId: "cat-4-1",
|
||||
categoryId: "cat-3-1",
|
||||
views: 0,
|
||||
image: "https://images.unsplash.com/photo-1594938298603-c8148c4dae35",
|
||||
image: "https://images.unsplash.com/photo-1678685888221-cda773a3dcdb",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: true
|
||||
},
|
||||
{
|
||||
title: "Designer Handbag",
|
||||
description: "Authentic Louis Vuitton Neverfull MM",
|
||||
price: '6000.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
address: "Dubai Mall",
|
||||
latitude: 25.1972,
|
||||
longitude: 55.2744,
|
||||
userId: 1,
|
||||
categoryId: "cat-4-3",
|
||||
views: 0,
|
||||
image: "https://images.unsplash.com/photo-1584917865442-de89df76afd3",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: false
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Modern Kitchen Set",
|
||||
description: "Complete kitchen cabinets with appliances",
|
||||
price: '35000.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
address: "Business Bay",
|
||||
latitude: 25.1857,
|
||||
longitude: 55.2644,
|
||||
userId: 1,
|
||||
categoryId: "cat-5-3",
|
||||
views: 0,
|
||||
image: "https://images.unsplash.com/photo-1556911220-bff31c812dba",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: true
|
||||
},
|
||||
{
|
||||
title: "2023 Toyota Camry",
|
||||
description: "Excellent condition, low mileage",
|
||||
price: '75000.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
address: "Dubai Marina",
|
||||
latitude: 25.2048,
|
||||
longitude: 55.2708,
|
||||
userId: 1,
|
||||
categoryId: "cat-1-1",
|
||||
views: 0,
|
||||
image: "https://example.com/camry.jpg",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: true
|
||||
},
|
||||
{
|
||||
title: "iPhone 14 Pro Max",
|
||||
description: "Brand new sealed iPhone 14 Pro Max 256GB",
|
||||
price: '4499.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
address: "Mall of Emirates",
|
||||
latitude: 25.1181,
|
||||
longitude: 55.2008,
|
||||
userId: 1,
|
||||
categoryId: "cat-3-1",
|
||||
views: 0,
|
||||
image: "https://images.unsplash.com/photo-1678685888221-cda773a3dcdb",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: false
|
||||
},
|
||||
{
|
||||
title: "Luxury Villa with Pool",
|
||||
description: "6 bedrooms, 7 bathrooms, private pool, garden",
|
||||
price: '12000000.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
address: "Palm Jumeirah",
|
||||
latitude: 25.1124,
|
||||
longitude: 55.1390,
|
||||
userId: 1,
|
||||
categoryId: "cat-2-2",
|
||||
views: 0,
|
||||
image: "https://images.unsplash.com/photo-1613977257363-707ba9348227",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: true
|
||||
},
|
||||
{
|
||||
title: "Designer Gucci Bag",
|
||||
description: "Original Gucci bag from new collection",
|
||||
price: '8500.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
address: "Dubai Mall",
|
||||
latitude: 25.1972,
|
||||
longitude: 55.2744,
|
||||
userId: 1,
|
||||
categoryId: "cat-4-3",
|
||||
views: 0,
|
||||
image: "https://images.unsplash.com/photo-1548036328-c9fa89d128fa",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: false
|
||||
},
|
||||
{
|
||||
title: "2022 Mercedes-Benz S-Class",
|
||||
description: "Luxury sedan with full options, only 3000km",
|
||||
price: '450000.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
address: "Sheikh Zayed Road",
|
||||
latitude: 25.2048,
|
||||
longitude: 55.2708,
|
||||
userId: 1,
|
||||
categoryId: "cat-1-1",
|
||||
views: 0,
|
||||
image: "https://images.unsplash.com/photo-1622194993799-e7f132e4126a",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: true
|
||||
},
|
||||
{
|
||||
title: "Gaming PC Setup",
|
||||
description: "High-end gaming PC with RTX 4090",
|
||||
price: '12000.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
address: "Business Bay",
|
||||
latitude: 25.1872,
|
||||
longitude: 55.2744,
|
||||
userId: 1,
|
||||
categoryId: "cat-3-3",
|
||||
views: 0,
|
||||
image: "https://images.unsplash.com/photo-1587202372634-32705e3bf49c",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: false
|
||||
},
|
||||
{
|
||||
title: "Luxury Apartment in Downtown",
|
||||
description: "3BR apartment with Burj Khalifa view",
|
||||
price: '3500000.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
address: "Downtown Dubai",
|
||||
latitude: 25.2048,
|
||||
longitude: 55.2708,
|
||||
userId: 1,
|
||||
categoryId: "cat-2-1",
|
||||
views: 0,
|
||||
image: "https://images.unsplash.com/photo-1522708323590-d24dbb6b0267",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: true
|
||||
},
|
||||
{
|
||||
title: "Professional DJ Equipment",
|
||||
description: "Complete DJ setup with Pioneer CDJs and mixer",
|
||||
price: '15000.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
address: "JLT",
|
||||
latitude: 25.0750,
|
||||
longitude: 55.1375,
|
||||
userId: 1,
|
||||
categoryId: "cat-10-1",
|
||||
views: 0,
|
||||
image: "https://images.unsplash.com/photo-1571935441005-55aaae1bd080",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: false
|
||||
},
|
||||
{
|
||||
title: "Modern Living Room Set",
|
||||
description: "Complete furniture set with sofa and tables",
|
||||
price: '25000.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
address: "Dubai Marina",
|
||||
latitude: 25.2048,
|
||||
longitude: 55.2708,
|
||||
userId: 1,
|
||||
categoryId: "cat-5-1",
|
||||
views: 0,
|
||||
image: "https://images.unsplash.com/photo-1555041469-a586c61ea9bc",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: true
|
||||
},
|
||||
{
|
||||
title: "MacBook Pro 16-inch",
|
||||
description: "M2 Max chip, 32GB RAM, 1TB SSD",
|
||||
price: '9500.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
address: "Dubai Mall",
|
||||
latitude: 25.1972,
|
||||
longitude: 55.2744,
|
||||
userId: 1,
|
||||
categoryId: "cat-3-2",
|
||||
views: 0,
|
||||
image: "https://images.unsplash.com/photo-1517336714731-489689fd1ca8",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: false
|
||||
},
|
||||
{
|
||||
title: "iPhone 14 Pro Max",
|
||||
description: "Brand new, 256GB storage, Space Black",
|
||||
price: '4500.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
address: "Mall of Emirates",
|
||||
latitude: 25.1181,
|
||||
longitude: 55.2008,
|
||||
userId: 1,
|
||||
categoryId: "cat-3-1",
|
||||
views: 0,
|
||||
image: "https://images.unsplash.com/photo-1678685888221-cda773a3dcdb",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: true
|
||||
},
|
||||
{
|
||||
title: "Designer Handbag",
|
||||
description: "Authentic Louis Vuitton Neverfull MM",
|
||||
price: '6000.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
address: "Dubai Mall",
|
||||
latitude: 25.1972,
|
||||
longitude: 55.2744,
|
||||
userId: 1,
|
||||
categoryId: "cat-4-3",
|
||||
views: 0,
|
||||
image: "https://images.unsplash.com/photo-1584917865442-de89df76afd3",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: false
|
||||
},
|
||||
{
|
||||
title: "Modern Kitchen Set",
|
||||
description: "Complete kitchen cabinets with appliances",
|
||||
price: '35000.00',
|
||||
status: Status.PUBLISHED,
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
address: "Business Bay",
|
||||
latitude: 25.1857,
|
||||
longitude: 55.2644,
|
||||
userId: 1,
|
||||
categoryId: "cat-5-3",
|
||||
views: 0,
|
||||
image: "https://images.unsplash.com/photo-1556911220-bff31c812dba",
|
||||
contactPhone: "+971501234567",
|
||||
isPromoted: true
|
||||
}
|
||||
];
|
||||
|
||||
// export const adts = [
|
||||
// {
|
||||
// title: "2023 Toyota Camry",
|
||||
// description: "Excellent condition, low mileage",
|
||||
// price: 75000.00,
|
||||
// status: Status.PUBLISHED,
|
||||
// countryId: "country-1",
|
||||
// cityId: "city-1",
|
||||
// address: "Dubai Marina",
|
||||
// latitude: 25.2048,
|
||||
// longitude: 55.2708,
|
||||
// userId: 1,
|
||||
// categoryId: "cat-1-1", // Обновлено на подкатегорию Cars
|
||||
// views: 0,
|
||||
// image: "https://example.com/camry.jpg",
|
||||
// contactPhone: "+971501234567",
|
||||
// isPromoted: true
|
||||
// },
|
||||
// {
|
||||
// title: "Студия в центре города",
|
||||
// description: "Современная студия с прекрасным видом",
|
||||
// price: 2200.00,
|
||||
// status: Status.PUBLISHED,
|
||||
// countryId: "country-1",
|
||||
// cityId: "city-1",
|
||||
// address: "Downtown Dubai",
|
||||
// latitude: 25.2048,
|
||||
// longitude: 55.2708,
|
||||
// userId: 1,
|
||||
// categoryId: "cat-2-1", // Обновлено на подкатегорию Apartments
|
||||
// views: 0,
|
||||
// image: "https://images.unsplash.com/photo-1522708323590-d24dbb6b0267",
|
||||
// contactPhone: "+971501234567",
|
||||
// isPromoted: true
|
||||
// },
|
||||
// {
|
||||
// title: "MacBook Pro M2",
|
||||
// description: "Новый MacBook Pro с чипом M2",
|
||||
// price: 2499.00,
|
||||
// status: Status.PUBLISHED,
|
||||
// countryId: "country-1",
|
||||
// cityId: "city-1",
|
||||
// address: "Dubai Mall",
|
||||
// latitude: 25.2048,
|
||||
// longitude: 55.2708,
|
||||
// userId: 1,
|
||||
// categoryId: "cat-3-2", // Обновлено на подкатегорию Laptops
|
||||
// views: 0,
|
||||
// image: "https://images.unsplash.com/photo-1517336714731-489689fd1ca8",
|
||||
// contactPhone: "+971501234567",
|
||||
// isPromoted: false
|
||||
// },
|
||||
// {
|
||||
// title: "Винтажная кожаная куртка",
|
||||
// description: "Оригинальная кожаная куртка, ручная работа",
|
||||
// price: 299.00,
|
||||
// status: Status.PUBLISHED,
|
||||
// countryId: "country-1",
|
||||
// cityId: "city-1",
|
||||
// address: "Portland Fashion District",
|
||||
// latitude: 45.5155,
|
||||
// longitude: -122.6789,
|
||||
// userId: 2,
|
||||
// categoryId: "cat-4",
|
||||
// views: 0,
|
||||
// image: "https://images.unsplash.com/photo-1551028719-00167b16eac5",
|
||||
// contactPhone: "+15035559876",
|
||||
// isPromoted: true
|
||||
// },
|
||||
// {
|
||||
// title: "Профессиональная камера DSLR",
|
||||
// description: "Полный комплект профессиональной фототехники",
|
||||
// price: 1899.00,
|
||||
// status: Status.PUBLISHED,
|
||||
// countryId: "country-1",
|
||||
// cityId: "city-1",
|
||||
// address: "New York Photography District",
|
||||
// latitude: 40.7128,
|
||||
// longitude: -74.0060,
|
||||
// userId: 1,
|
||||
// categoryId: "cat-3",
|
||||
// views: 0,
|
||||
// image: "https://images.unsplash.com/photo-1516035069371-29a1b244cc32",
|
||||
// contactPhone: "+12125558899",
|
||||
// isPromoted: true
|
||||
// },
|
||||
// {
|
||||
// title: "Игровая приставка PS5",
|
||||
// description: "Новая PS5 с дополнительным геймпадом",
|
||||
// price: 499.00,
|
||||
// status: Status.PUBLISHED,
|
||||
// countryId: "country-1",
|
||||
// cityId: "city-1",
|
||||
// address: "Las Vegas Gaming Center",
|
||||
// latitude: 36.1699,
|
||||
// longitude: -115.1398,
|
||||
// userId: 1,
|
||||
// categoryId: "cat-3",
|
||||
// views: 0,
|
||||
// image: "https://images.unsplash.com/photo-1606144042614-b2417e99c4e3",
|
||||
// contactPhone: "+17025553344",
|
||||
// isPromoted: false
|
||||
// },
|
||||
// {
|
||||
// title: "Электрогитара Fender Stratocaster",
|
||||
// description: "Классическая электрогитара в идеальном состоянии",
|
||||
// price: 1199.00,
|
||||
// status: Status.PUBLISHED,
|
||||
// countryId: "country-1",
|
||||
// cityId: "city-1",
|
||||
// address: "Nashville Music Row",
|
||||
// latitude: 36.1627,
|
||||
// longitude: -86.7816,
|
||||
// userId: 2,
|
||||
// categoryId: "cat-6",
|
||||
// views: 0,
|
||||
// image: "https://images.unsplash.com/photo-1564186763535-ebb21ef5277f",
|
||||
// contactPhone: "+16155557777",
|
||||
// isPromoted: true
|
||||
// }
|
||||
// ];
|
||||
|
||||
@ -43,7 +43,7 @@ model Adt {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
description String? @db.Text // Используем Text для длинных описаний
|
||||
price Decimal? @db.Decimal(10, 2) // Более точный тип для цены
|
||||
price String?
|
||||
status Status @default(CHECKING)
|
||||
|
||||
// Геолокация
|
||||
@ -62,7 +62,7 @@ model Adt {
|
||||
category Category @relation(fields: [categoryId], references: [id])
|
||||
|
||||
// Статистика
|
||||
views Int @default(0)
|
||||
views Int? @default(0)
|
||||
favorites Favorite[]
|
||||
image String?
|
||||
images Image[]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user