add category, city, country,
This commit is contained in:
parent
1eb4e0eaa1
commit
93b86ae3cc
3
@types/prisma.ts
Normal file
3
@types/prisma.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { Adt, Category, City, User } from "@prisma/client";
|
||||||
|
|
||||||
|
export type AdtWithRelations = Adt & { category: Category; city: City; user: User };
|
||||||
92
app/(root)/[category]/page.tsx
Normal file
92
app/(root)/[category]/page.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { BreadcrumbsCategory } from '@/components/shared/breadcrumbs-category';
|
||||||
|
import { BlockAdts } from '@/components/shared/block-adts';
|
||||||
|
import { prisma } from '@/prisma/prisma-client';
|
||||||
|
|
||||||
|
|
||||||
|
type Params = Promise<{ category: string }>
|
||||||
|
|
||||||
|
export default async function CategoryPage(props: { params: Params }) {
|
||||||
|
const params = await props.params;
|
||||||
|
const categorySlug = params.category;
|
||||||
|
|
||||||
|
const category = await prisma.category.findFirst({
|
||||||
|
where: {
|
||||||
|
slug: categorySlug
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
adts: true,
|
||||||
|
children: {
|
||||||
|
include: {
|
||||||
|
adts: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
parent: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Объединяем объявления текущей категории и всех подкатегорий
|
||||||
|
const allAdts = [
|
||||||
|
...category.adts,
|
||||||
|
...category.children.flatMap(child => child.adts)
|
||||||
|
];
|
||||||
|
|
||||||
|
// const aadts = await fetch(`/api/adt?category=${categorySlug}`).then(res => res.json());
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold">{category.nameEn}</h1>
|
||||||
|
{category.parentId && (
|
||||||
|
<BreadcrumbsCategory category={category} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{category.children.length > 0 && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Подкатегории</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{category.children.map((subcat) => (
|
||||||
|
<a
|
||||||
|
key={subcat.id}
|
||||||
|
href={`/${subcat.slug}`}
|
||||||
|
className="p-4 border rounded-lg hover:border-indigo-500 transition-colors"
|
||||||
|
>
|
||||||
|
{subcat.nameEn}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Объявления</h2>
|
||||||
|
<BlockAdts category={categorySlug} />
|
||||||
|
{/* {allAdts.map((adt) => (
|
||||||
|
<div key={adt.id} className="border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
|
||||||
|
{adt.image && (
|
||||||
|
<img
|
||||||
|
src={adt.image}
|
||||||
|
alt={adt.title}
|
||||||
|
className="w-full h-48 object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="font-semibold text-lg mb-2">{adt.title}</h3>
|
||||||
|
<p className="text-gray-600 mb-2 line-clamp-2">{adt.description}</p>
|
||||||
|
<p className="text-lg font-bold text-indigo-600">
|
||||||
|
{adt.price ? `${adt.price} ₽` : 'Цена не указана'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))} */}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,25 +2,32 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
// import { useParams } from 'next/navigation';
|
// import { useParams } from 'next/navigation';
|
||||||
import { MapPin, Calendar, Phone, MessageCircle, Share2, Flag, Heart } from 'lucide-react';
|
import { MapPin, Calendar, Phone, MessageCircle, Share2, Flag, Heart, Tag, Building, ChevronRight } from 'lucide-react';
|
||||||
import { prisma } from '@/prisma/prisma-client';
|
import { prisma } from '@/prisma/prisma-client';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { ShowNumberModal } from '@/components/shared/modals/show-number';
|
import { ShowNumberModal } from '@/components/shared/modals/show-number';
|
||||||
import { getUserSession } from '@/lib/get-user-session';
|
import { getUserSession } from '@/lib/get-user-session';
|
||||||
|
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
|
||||||
|
import { BreadcrumbsCategory } from '@/components/shared/breadcrumbs-category';
|
||||||
|
|
||||||
type Params = Promise<{ id: string }>
|
type Params = Promise<{ id: string }>
|
||||||
|
|
||||||
export default async function AdtPage(props: { params: Params }) {
|
export default async function AdtPage(props: { params: Params }) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
|
|
||||||
const session = await getUserSession();
|
const session = await getUserSession();
|
||||||
const adt = await prisma.adt.findFirst({
|
const adt = await prisma.adt.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: Number(params.id),
|
id: Number(params.id),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: true
|
user: true,
|
||||||
|
category: {
|
||||||
|
include: {
|
||||||
|
parent: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
city: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -30,47 +37,50 @@ export default async function AdtPage(props: { params: Params }) {
|
|||||||
|
|
||||||
const user = adt.user
|
const user = adt.user
|
||||||
|
|
||||||
// const { id } = params();
|
|
||||||
// const adt = adts.find(l => l.id === id) || adts[0];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{adt.title}</h1>
|
||||||
|
<BreadcrumbsCategory category={adt.category} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||||
<img src={String(adt.image)} alt={adt.title} className="w-full h-[400px] object-cover" />
|
<img src={String(adt.image)} alt={adt.title} className="w-full h-[400px] object-cover" />
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex justify-between items-start mb-4">
|
|
||||||
<h1 className="text-2xl font-semibold">{adt.title}</h1>
|
|
||||||
<span className="text-2xl font-bold text-indigo-600">{adt.price}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-gray-500 mb-6">
|
<div className="flex items-center gap-4 text-gray-500 mb-6">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<MapPin className="h-5 w-5" />
|
<MapPin className="h-5 w-5" />
|
||||||
<span>{adt.location}</span>
|
<span>{adt.address || 'Адрес не указан'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Calendar className="h-5 w-5" />
|
<Building className="h-5 w-5" />
|
||||||
<span>{String(adt.createdAt)}</span>
|
<span>{adt.city?.nameEn}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-5 w-5" />
|
||||||
|
<span>{new Date(adt.createdAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold text-indigo-600 ml-auto">{adt.price ? `${adt.price} ₽` : 'Цена не указана'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="font-semibold text-lg mb-3">Description</h2>
|
<h2 className="font-semibold text-lg mb-3">Описание</h2>
|
||||||
<p className="text-gray-600 mb-6">
|
<p className="text-gray-600 mb-6">
|
||||||
{adt.description}
|
{adt.description || 'Описание отсутствует'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button className="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-200 hover:bg-gray-50">
|
<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" />
|
<Share2 className="h-5 w-5" />
|
||||||
<span>Share</span>
|
<span>Поделиться</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-200 hover:bg-gray-50">
|
<button className="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-200 hover:bg-gray-50">
|
||||||
<Flag className="h-5 w-5" />
|
<Flag className="h-5 w-5" />
|
||||||
<span>Report</span>
|
<span>Пожаловаться</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -86,27 +96,20 @@ export default async function AdtPage(props: { params: Params }) {
|
|||||||
className="w-12 h-12 rounded-full"
|
className="w-12 h-12 rounded-full"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">{user?.name}</h3>
|
<h3 className="font-semibold">{user?.name || 'Пользователь'}</h3>
|
||||||
<p className="text-sm text-gray-500">Member {String(user?.createdAt)}</p>
|
<p className="text-sm text-gray-500">На сайте с {new Date(user?.createdAt || '').toLocaleDateString()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<ShowNumberModal phoneNumber={String(adt.user?.email)} session={session} />
|
<ShowNumberModal phoneNumber={String(adt.user?.email)} session={session} />
|
||||||
{/* <button
|
|
||||||
// onClick={() => setOpenShowNumberModal(true)}
|
|
||||||
className="w-full flex items-center justify-center gap-2 bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700"
|
|
||||||
>
|
|
||||||
<Phone className="h-5 w-5" />
|
|
||||||
<span>Show Phone Number</span>
|
|
||||||
</button> */}
|
|
||||||
<button className="w-full flex items-center justify-center gap-2 bg-white border border-indigo-600 text-indigo-600 px-4 py-2 rounded-lg hover:bg-indigo-50">
|
<button className="w-full flex items-center justify-center gap-2 bg-white border border-indigo-600 text-indigo-600 px-4 py-2 rounded-lg hover:bg-indigo-50">
|
||||||
<MessageCircle className="h-5 w-5" />
|
<MessageCircle className="h-5 w-5" />
|
||||||
<span>Send Message (In development)</span>
|
<span>Написать сообщение (В разработке)</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="w-full flex items-center justify-center gap-2 bg-white border border-gray-200 px-4 py-2 rounded-lg hover:bg-gray-50">
|
<button className="w-full flex items-center justify-center gap-2 bg-white border border-gray-200 px-4 py-2 rounded-lg hover:bg-gray-50">
|
||||||
<Heart className="h-5 w-5" />
|
<Heart className="h-5 w-5" />
|
||||||
<span>Save to Favorites (In development)</span>
|
<span>Добавить в избранное (В разработке)</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,11 +8,13 @@ const prisma = new PrismaClient();
|
|||||||
|
|
||||||
export default async function CreateListing() {
|
export default async function CreateListing() {
|
||||||
const categories = await prisma.category.findMany();
|
const categories = await prisma.category.findMany();
|
||||||
|
const countries = await prisma.country.findMany();
|
||||||
|
const cities = await prisma.city.findMany();
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<AdtCreateForm categories={categories} />
|
<AdtCreateForm categories={categories} countries={countries} cities={cities} />
|
||||||
|
|
||||||
|
|
||||||
// <main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
// <main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { prisma } from "@/prisma/prisma-client";
|
|||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const adts = await prisma.adt.findMany()
|
// const adts = await prisma.adt.findMany()
|
||||||
// const session = await getUserSession()
|
// const session = await getUserSession()
|
||||||
// // console.log(user)
|
// // console.log(user)
|
||||||
// console.log("session",session)
|
// console.log("session",session)
|
||||||
|
|||||||
@ -74,7 +74,7 @@ export default async function Profile() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{user?.adts.map((adt) => (
|
{user?.adts.map((adt) => (
|
||||||
<ListingCard key={adt.id} id={String(adt.id)} image={String(adt.image)} title={String(adt.title)} price={String(adt.price)} location={String(adt.location)} date={String(adt.createdAt)}/>
|
<ListingCard key={adt.id} id={String(adt.id)} image={String(adt.image)} title={String(adt.title)} price={String(adt.price)} location={String(adt.address)} date={String(adt.createdAt)}/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -65,33 +65,3 @@ export async function registerUser(body: Prisma.UserCreateInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function createAdt(body: Prisma.AdtCreateInput, categories: Category[]) {
|
|
||||||
try {
|
|
||||||
|
|
||||||
const currentUser = await getUserSession();
|
|
||||||
|
|
||||||
if (!currentUser) {
|
|
||||||
throw new Error('Not found user')
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
await prisma.adt.create({
|
|
||||||
data: {
|
|
||||||
title: body.title,
|
|
||||||
categories: {
|
|
||||||
connect: categories?.map((category) => ({
|
|
||||||
id: category.id,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
price: body.price,
|
|
||||||
description: body.description,
|
|
||||||
image: body.image,
|
|
||||||
location: body.location,
|
|
||||||
userId: Number(currentUser.id),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Error [create adt]', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { Prisma, PrismaClient } from '@prisma/client';
|
||||||
import { formAdtCreateSchema } from '@/components/shared/adt-create/schemas';
|
import { formAdtCreateSchema } from '@/components/shared/adt-create/schemas';
|
||||||
import { getUserSession } from '@/lib/get-user-session';
|
import { getUserSession } from '@/lib/get-user-session';
|
||||||
|
|
||||||
@ -9,6 +9,7 @@ const prisma = new PrismaClient();
|
|||||||
// GET функция для получения списка объявлений с пагинацией
|
// GET функция для получения списка объявлений с пагинацией
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
|
console.log("request",request)
|
||||||
// Получаем параметры из URL
|
// Получаем параметры из URL
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
@ -17,22 +18,69 @@ export async function GET(request: Request) {
|
|||||||
const limit = Number(searchParams.get('limit')) || 10;
|
const limit = Number(searchParams.get('limit')) || 10;
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
// Получаем общее количество объявлений для пагинации
|
// Получаем параметры фильтрации
|
||||||
const total = await prisma.adt.count();
|
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: {
|
||||||
|
children: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (categoryWithChildren) {
|
||||||
|
// Получаем ID текущей категории и всех дочерних категорий
|
||||||
|
const categoryIds = [
|
||||||
|
categoryWithChildren.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 });
|
||||||
|
|
||||||
|
// Получаем объявления с учетом пагинации и фильтров
|
||||||
const adts = await prisma.adt.findMany({
|
const adts = await prisma.adt.findMany({
|
||||||
|
where,
|
||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
include: {
|
include: {
|
||||||
categories: true,
|
category: true,
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
email: true
|
email: true
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
city: {
|
||||||
|
select: {
|
||||||
|
nameEn: true,
|
||||||
|
nameAr: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
country: true
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
createdAt: 'desc' // Сортировка по дате создания (новые первыми)
|
createdAt: 'desc' // Сортировка по дате создания (новые первыми)
|
||||||
@ -89,22 +137,21 @@ export async function POST(request: Request) {
|
|||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создание объявления
|
// Создание объявления
|
||||||
const adt = await prisma.adt.create({
|
const adt = await prisma.adt.create({
|
||||||
data: {
|
data: {
|
||||||
title: validatedData.title,
|
title: validatedData.title,
|
||||||
description: validatedData.description,
|
description: validatedData.description,
|
||||||
price: validatedData.price,
|
price: validatedData.price,
|
||||||
location: validatedData.location,
|
address: validatedData.address,
|
||||||
image: validatedData.image,
|
image: validatedData.image,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
categories: {
|
categoryId: validatedData.categoryId,
|
||||||
connect: validatedData.categoryIds.map(id => ({ id }))
|
countryId: validatedData.countryId,
|
||||||
}
|
cityId: validatedData.cityId
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
categories: true
|
category: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,40 +1,38 @@
|
|||||||
// "use client"
|
// "use client"
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Car, Home, Laptop, Shirt, Briefcase, Dumbbell, Palette, Book } from 'lucide-react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { prisma } from '@/prisma/prisma-client';
|
import { prisma } from '@/prisma/prisma-client';
|
||||||
|
|
||||||
const categories = [
|
|
||||||
{ name: 'Vehicles', icon: Car },
|
|
||||||
{ name: 'Real Estate', icon: Home },
|
|
||||||
{ name: 'Electronics', icon: Laptop },
|
|
||||||
{ name: 'Fashion', icon: Shirt },
|
|
||||||
{ name: 'Jobs', icon: Briefcase },
|
|
||||||
{ name: 'Sports', icon: Dumbbell },
|
|
||||||
{ name: 'Art', icon: Palette },
|
|
||||||
{ name: 'Books', icon: Book },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default async function Categories() {
|
export default async function Categories() {
|
||||||
const categories = await prisma.category.findMany();
|
const categories = await prisma.category.findMany({
|
||||||
|
where: {
|
||||||
|
parentId: null,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-8 bg-gray-50">
|
<div className="py-6 bg-gray-50">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<h2 className="text-xl font-semibold mb-6">Browse Categories</h2>
|
<h2 className="text-lg font-bold text-gray-900 mb-4">Categories</h2>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-8 gap-4">
|
<div className="grid grid-cols-4 sm:grid-cols-6 lg:grid-cols-8 gap-3">
|
||||||
{categories.map((category) => {
|
{categories.map((category) => (
|
||||||
// const Icon = category.icon;
|
<Link
|
||||||
return (
|
key={category.id}
|
||||||
<button
|
href={`/${category.slug}`}
|
||||||
key={category.name}
|
className="group"
|
||||||
className="flex flex-col items-center p-4 bg-white rounded-xl hover:shadow-md transition-shadow"
|
>
|
||||||
>
|
<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">
|
||||||
{/* <Icon className="h-8 w-8 text-indigo-600 mb-2" /> */}
|
{category.icon && (
|
||||||
<span className="text-sm text-gray-700">{category.name}</span>
|
<span className="text-xl mb-1 group-hover:scale-110 transition-transform duration-200">
|
||||||
</button>
|
{category.icon}
|
||||||
);
|
</span>
|
||||||
})}
|
)}
|
||||||
|
<span className="text-xs font-medium text-gray-900 text-center group-hover:text-indigo-600 transition-colors">
|
||||||
|
{category.nameEn}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,19 +1,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useMemo, useEffect } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { formAdtCreateSchema, type TFormAdtCreateValues } from './schemas';
|
import { formAdtCreateSchema, type TFormAdtCreateValues } from './schemas';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Category } from '@prisma/client';
|
import { Category, Country, City } from '@prisma/client';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
interface CreateAdtFormProps {
|
interface CreateAdtFormProps {
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
|
countries: Country[];
|
||||||
|
cities: City[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdtCreateForm({ categories }: CreateAdtFormProps) {
|
export default function AdtCreateForm({ categories, countries, cities }: CreateAdtFormProps) {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [preview, setPreview] = useState<string | null>(null);
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -29,11 +31,35 @@ export default function AdtCreateForm({ categories }: CreateAdtFormProps) {
|
|||||||
} = useForm<TFormAdtCreateValues>({
|
} = useForm<TFormAdtCreateValues>({
|
||||||
resolver: zodResolver(formAdtCreateSchema),
|
resolver: zodResolver(formAdtCreateSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
categoryIds: [],
|
categoryId: '',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedCategories = watch('categoryIds');
|
const selectedCategory = watch('categoryId');
|
||||||
|
const [selectedCountry, setSelectedCountry] = useState<string | null>(null);
|
||||||
|
const [filteredCities, setFilteredCities] = useState<City[]>([]);
|
||||||
|
const [selectedParentCategory, setSelectedParentCategory] = useState<string | null>(null);
|
||||||
|
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);
|
||||||
|
setValue('cityId', '');
|
||||||
|
}
|
||||||
|
}, [selectedCountry, cities]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedParentCategory) {
|
||||||
|
const subCategories = categories.filter(cat => cat.parentId === selectedParentCategory);
|
||||||
|
setFilteredSubCategories(subCategories);
|
||||||
|
setValue('categoryId', '');
|
||||||
|
}
|
||||||
|
}, [selectedParentCategory, categories]);
|
||||||
|
|
||||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@ -72,28 +98,23 @@ export default function AdtCreateForm({ categories }: CreateAdtFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast.success("Adt created successfully")
|
toast.success("Adt created successfully")
|
||||||
// Здесь можно добавить уведомление об успешном создании
|
|
||||||
const data_f = await response.json();
|
const data_f = await response.json();
|
||||||
// Редирект на страницу созданного объявления
|
|
||||||
router.push(`/adt/${data_f.id}`);
|
router.push(`/adt/${data_f.id}`);
|
||||||
// Обновляем кэш Next.js
|
|
||||||
// router.refresh();
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
// toast.error("Error creating adt: " + error)
|
|
||||||
// Здесь можно добавить обработку ошибок
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCategoryChange = (categoryId: number) => {
|
const handleCountryChange = (countryId: string) => {
|
||||||
const currentCategories = watch('categoryIds') || [];
|
setSelectedCountry(countryId);
|
||||||
const updatedCategories = currentCategories.includes(categoryId)
|
setValue('countryId', countryId);
|
||||||
? currentCategories.filter(id => id !== categoryId)
|
};
|
||||||
: [...currentCategories, categoryId];
|
|
||||||
setValue('categoryIds', updatedCategories);
|
const handleCategoryParentChange = (categoryId: string) => {
|
||||||
|
setSelectedParentCategory(categoryId);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -144,40 +165,105 @@ export default function AdtCreateForm({ categories }: CreateAdtFormProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<div>
|
||||||
Категории*
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
</label>
|
Основная категория*
|
||||||
<div className="mt-2 space-y-2">
|
</label>
|
||||||
{categories.map((category) => (
|
<select
|
||||||
<label key={category.id} className="inline-flex items-center mr-4">
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||||
<input
|
onChange={(e) => handleCategoryParentChange(e.target.value)}
|
||||||
type="checkbox"
|
value={selectedParentCategory || ''}
|
||||||
className="form-checkbox h-4 w-4 text-indigo-600"
|
>
|
||||||
onChange={() => handleCategoryChange(category.id)}
|
<option value="">Выберите категорию</option>
|
||||||
checked={selectedCategories?.includes(category.id)}
|
{parentCategories.map((category) => (
|
||||||
/>
|
<option key={category.id} value={category.id}>
|
||||||
<span className="ml-2">{category.name}</span>
|
{category.nameEn}
|
||||||
</label>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{errors.categoryIds && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{errors.categoryIds.message}</p>
|
{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"
|
||||||
|
{...register('categoryId')}
|
||||||
|
>
|
||||||
|
<option value="">Выберите подкатегорию</option>
|
||||||
|
{filteredSubCategories.map((category) => (
|
||||||
|
<option key={category.id} value={category.id}>
|
||||||
|
{category.nameEn}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{errors.categoryId && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.categoryId.message}</p>
|
||||||
|
)}
|
||||||
|
</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"
|
||||||
|
onChange={(e) => handleCountryChange(e.target.value)}
|
||||||
|
value={selectedCountry || ''}
|
||||||
|
>
|
||||||
|
<option value="">Выберите страну</option>
|
||||||
|
{countries.map((country) => (
|
||||||
|
<option key={country.id} value={country.id}>
|
||||||
|
{country.nameEn}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{errors.countryId && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.countryId.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedCountry && (
|
||||||
|
<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"
|
||||||
|
{...register('cityId')}
|
||||||
|
>
|
||||||
|
<option value="">Выберите город</option>
|
||||||
|
{filteredCities.map((city) => (
|
||||||
|
<option key={city.id} value={city.id}>
|
||||||
|
{city.nameEn}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{errors.cityId && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.cityId.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="location" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
|
||||||
Местоположение
|
Адрес
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="location"
|
id="address"
|
||||||
{...register('location')}
|
{...register('address')}
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||||
/>
|
/>
|
||||||
{errors.location && (
|
{errors.address && (
|
||||||
<p className="mt-1 text-sm text-red-600">{errors.location.message}</p>
|
<p className="mt-1 text-sm text-red-600">{errors.address.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -9,14 +9,17 @@ export const formAdtCreateSchema = z.object({
|
|||||||
.max(1000, 'Описание не может быть длиннее 1000 символов')
|
.max(1000, 'Описание не может быть длиннее 1000 символов')
|
||||||
.nullable(),
|
.nullable(),
|
||||||
price: z.string()
|
price: z.string()
|
||||||
.min(1, 'Укажите цену')
|
// .transform((val) => (val ? parseFloat(val) : null))
|
||||||
.nullable(),
|
|
||||||
location: z.string()
|
|
||||||
.min(2, 'Укажите местоположение')
|
|
||||||
.max(100, 'Слишком длинное название местоположения')
|
|
||||||
.nullable(),
|
.nullable(),
|
||||||
|
countryId: z.string().min(1, 'Выберите страну'),
|
||||||
|
cityId: z.string().min(1, 'Выберите город'),
|
||||||
|
address: z.string().nullable().optional(),
|
||||||
|
latitude: z.number().nullable().optional(),
|
||||||
|
longitude: z.number().nullable().optional(),
|
||||||
|
contactPhone: z.string().nullable().optional(),
|
||||||
image: z.string().nullable().optional(),
|
image: z.string().nullable().optional(),
|
||||||
categoryIds: z.array(z.number()).min(1, 'Выберите хотя бы одну категорию'),
|
images: z.array(z.string()).optional(),
|
||||||
});
|
categoryId: z.string().min(1, 'Выберите категорию'),
|
||||||
|
});
|
||||||
|
|
||||||
export type TFormAdtCreateValues = z.infer<typeof formAdtCreateSchema>
|
export type TFormAdtCreateValues = z.infer<typeof formAdtCreateSchema>
|
||||||
|
|||||||
@ -3,15 +3,16 @@
|
|||||||
// Импортируем необходимые компоненты и типы
|
// Импортируем необходимые компоненты и типы
|
||||||
import { FC, useEffect, useState, useRef, useCallback } from 'react'
|
import { FC, useEffect, useState, useRef, useCallback } from 'react'
|
||||||
import ListingCard from '../ListingCard'
|
import ListingCard from '../ListingCard'
|
||||||
import { Adt } from '@prisma/client'
|
import { AdtWithRelations } from '@/@types/prisma'
|
||||||
import toast from 'react-hot-toast'
|
|
||||||
|
|
||||||
// interface BlockAdtsProps {}
|
interface BlockAdtsProps {
|
||||||
|
category?: string
|
||||||
|
}
|
||||||
|
|
||||||
export const BlockAdts: FC = () => {
|
export const BlockAdts: FC<BlockAdtsProps> = ({ category }) => {
|
||||||
|
|
||||||
// Состояния для хранения объявлений и управления их отображением
|
// Состояния для хранения объявлений и управления их отображением
|
||||||
const [adts, setAdts] = useState<Adt[]>([]) // Массив объявлений
|
const [adts, setAdts] = useState<AdtWithRelations[]>([]) // Массив объявлений
|
||||||
const [sortBy, setSortBy] = useState('new') // Тип сортировки
|
const [sortBy, setSortBy] = useState('new') // Тип сортировки
|
||||||
const [isLoading, setIsLoading] = useState(true) // Флаг загрузки
|
const [isLoading, setIsLoading] = useState(true) // Флаг загрузки
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false) // Флаг загрузки дополнительных объявлений
|
const [isLoadingMore, setIsLoadingMore] = useState(false) // Флаг загрузки дополнительных объявлений
|
||||||
@ -42,8 +43,10 @@ export const BlockAdts: FC = () => {
|
|||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const categoryParam = category ? `&category=${category}` : '';
|
||||||
|
|
||||||
// Запрашиваем данные с сервера
|
// Запрашиваем данные с сервера
|
||||||
const response = await fetch(`/api/adt?page=${pageNum}&sort=${sortBy}`)
|
const response = await fetch(`/api/adt?page=${pageNum}&sort=${sortBy}${categoryParam}`)
|
||||||
const { data: newAdts, meta } = await response.json()
|
const { data: newAdts, meta } = await response.json()
|
||||||
|
|
||||||
// Обновляем список объявлений
|
// Обновляем список объявлений
|
||||||
@ -136,7 +139,7 @@ export const BlockAdts: FC = () => {
|
|||||||
title={adt.title}
|
title={adt.title}
|
||||||
image={String(adt.image)}
|
image={String(adt.image)}
|
||||||
price={String(adt.price)}
|
price={String(adt.price)}
|
||||||
location={String(adt.location)}
|
location={String(adt.city.nameEn)}
|
||||||
date={String(adt.createdAt)}
|
date={String(adt.createdAt)}
|
||||||
id={String(adt.id)}
|
id={String(adt.id)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
36
components/shared/breadcrumbs-category.tsx
Normal file
36
components/shared/breadcrumbs-category.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { Tag } from "lucide-react";
|
||||||
|
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
|
||||||
|
import { Category } from "@prisma/client";
|
||||||
|
import { prisma } from "@/prisma/prisma-client";
|
||||||
|
|
||||||
|
interface BreadcrumbsCategoryProps {
|
||||||
|
category?: Category
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const BreadcrumbsCategory = async ({ category }: BreadcrumbsCategoryProps) => {
|
||||||
|
const categ = await prisma.category.findFirst({
|
||||||
|
where: {
|
||||||
|
id: String(category?.id)
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
parent: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Tag className="h-4 w-4 text-gray-500" />
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink href={`/${categ?.parent?.slug}`}>{categ?.parent?.nameEn}</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink href={`/${categ?.slug}`}>{categ?.nameEn}</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
components/ui/breadcrumb.tsx
Normal file
115
components/ui/breadcrumb.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Breadcrumb = React.forwardRef<
|
||||||
|
HTMLElement,
|
||||||
|
React.ComponentPropsWithoutRef<"nav"> & {
|
||||||
|
separator?: React.ReactNode
|
||||||
|
}
|
||||||
|
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
|
||||||
|
Breadcrumb.displayName = "Breadcrumb"
|
||||||
|
|
||||||
|
const BreadcrumbList = React.forwardRef<
|
||||||
|
HTMLOListElement,
|
||||||
|
React.ComponentPropsWithoutRef<"ol">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ol
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center gap-1.5 break-words text-sm text-neutral-500 sm:gap-2.5 dark:text-neutral-400",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
BreadcrumbList.displayName = "BreadcrumbList"
|
||||||
|
|
||||||
|
const BreadcrumbItem = React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
React.ComponentPropsWithoutRef<"li">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<li
|
||||||
|
ref={ref}
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
BreadcrumbItem.displayName = "BreadcrumbItem"
|
||||||
|
|
||||||
|
const BreadcrumbLink = React.forwardRef<
|
||||||
|
HTMLAnchorElement,
|
||||||
|
React.ComponentPropsWithoutRef<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
>(({ asChild, className, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
className={cn("transition-colors hover:text-neutral-950 dark:hover:text-neutral-50", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
BreadcrumbLink.displayName = "BreadcrumbLink"
|
||||||
|
|
||||||
|
const BreadcrumbPage = React.forwardRef<
|
||||||
|
HTMLSpanElement,
|
||||||
|
React.ComponentPropsWithoutRef<"span">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("font-normal text-neutral-950 dark:text-neutral-50", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
BreadcrumbPage.displayName = "BreadcrumbPage"
|
||||||
|
|
||||||
|
const BreadcrumbSeparator = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) => (
|
||||||
|
<li
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
|
||||||
|
|
||||||
|
const BreadcrumbEllipsis = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) => (
|
||||||
|
<span
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
||||||
76
components/ui/card.tsx
Normal file
76
components/ui/card.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border border-neutral-200 bg-white text-neutral-950 shadow dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-neutral-500 dark:text-neutral-400", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
178
components/ui/form.tsx
Normal file
178
components/ui/form.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
ControllerProps,
|
||||||
|
FieldPath,
|
||||||
|
FieldValues,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
} from "react-hook-form"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/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, formState } = useFormContext()
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormItem.displayName = "FormItem"
|
||||||
|
|
||||||
|
const FormLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(error && "text-red-500 dark:text-red-900", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormLabel.displayName = "FormLabel"
|
||||||
|
|
||||||
|
const FormControl = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Slot>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
|
>(({ ...props }, ref) => {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
ref={ref}
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormControl.displayName = "FormControl"
|
||||||
|
|
||||||
|
const FormDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-[0.8rem] text-neutral-500 dark:text-neutral-400", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormDescription.displayName = "FormDescription"
|
||||||
|
|
||||||
|
const FormMessage = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message) : children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-[0.8rem] font-medium text-red-500 dark:text-red-900", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormMessage.displayName = "FormMessage"
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
||||||
26
components/ui/label.tsx
Normal file
26
components/ui/label.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
159
components/ui/select.tsx
Normal file
159
components/ui/select.tsx
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-neutral-200 bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-white placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-neutral-950 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 dark:border-neutral-800 dark:ring-offset-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-neutral-300",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
))
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
))
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-neutral-200 bg-white text-neutral-950 shadow-md 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 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-neutral-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-neutral-800 dark:focus:text-neutral-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-neutral-100 dark:bg-neutral-800", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
}
|
||||||
129
components/ui/toast.tsx
Normal file
129
components/ui/toast.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border border-neutral-200 p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full dark:border-neutral-800",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border bg-white text-neutral-950 dark:bg-neutral-950 dark:text-neutral-50",
|
||||||
|
destructive:
|
||||||
|
"destructive group border-red-500 bg-red-500 text-neutral-50 dark:border-red-900 dark:bg-red-900 dark:text-neutral-50",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||||
|
VariantProps<typeof toastVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ToastPrimitives.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toastVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border border-neutral-200 bg-transparent px-3 text-sm font-medium transition-colors hover:bg-neutral-100 focus:outline-none focus:ring-1 focus:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-neutral-100/40 group-[.destructive]:hover:border-red-500/30 group-[.destructive]:hover:bg-red-500 group-[.destructive]:hover:text-neutral-50 group-[.destructive]:focus:ring-red-500 dark:border-neutral-800 dark:hover:bg-neutral-800 dark:focus:ring-neutral-300 dark:group-[.destructive]:border-neutral-800/40 dark:group-[.destructive]:hover:border-red-900/30 dark:group-[.destructive]:hover:bg-red-900 dark:group-[.destructive]:hover:text-neutral-50 dark:group-[.destructive]:focus:ring-red-900",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-1 top-1 rounded-md p-1 text-neutral-950/50 opacity-0 transition-opacity hover:text-neutral-950 focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600 dark:text-neutral-50/50 dark:hover:text-neutral-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
))
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm opacity-90", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||||
|
|
||||||
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||||
|
|
||||||
|
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
type ToastActionElement,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
}
|
||||||
35
components/ui/toaster.tsx
Normal file
35
components/ui/toaster.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useToast } from "@/hooks/use-toast"
|
||||||
|
import {
|
||||||
|
Toast,
|
||||||
|
ToastClose,
|
||||||
|
ToastDescription,
|
||||||
|
ToastProvider,
|
||||||
|
ToastTitle,
|
||||||
|
ToastViewport,
|
||||||
|
} from "@/components/ui/toast"
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||||
|
return (
|
||||||
|
<Toast key={id} {...props}>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && (
|
||||||
|
<ToastDescription>{description}</ToastDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
194
hooks/use-toast.ts
Normal file
194
hooks/use-toast.ts
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
// Inspired by react-hot-toast library
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ToastActionElement,
|
||||||
|
ToastProps,
|
||||||
|
} from "@/components/ui/toast"
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000
|
||||||
|
|
||||||
|
type ToasterToast = ToastProps & {
|
||||||
|
id: string
|
||||||
|
title?: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
action?: ToastActionElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: "ADD_TOAST",
|
||||||
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||||
|
return count.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = typeof actionTypes
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: ActionType["ADD_TOAST"]
|
||||||
|
toast: ToasterToast
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["UPDATE_TOAST"]
|
||||||
|
toast: Partial<ToasterToast>
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["DISMISS_TOAST"]
|
||||||
|
toastId?: ToasterToast["id"]
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["REMOVE_TOAST"]
|
||||||
|
toastId?: ToasterToast["id"]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId)
|
||||||
|
dispatch({
|
||||||
|
type: "REMOVE_TOAST",
|
||||||
|
toastId: toastId,
|
||||||
|
})
|
||||||
|
}, TOAST_REMOVE_DELAY)
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "ADD_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "UPDATE_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "DISMISS_TOAST": {
|
||||||
|
const { toastId } = action
|
||||||
|
|
||||||
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
|
// but I'll keep it here for simplicity
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId)
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "REMOVE_TOAST":
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = []
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] }
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action)
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, "id">
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId()
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_TOAST",
|
||||||
|
toast: { ...props, id },
|
||||||
|
})
|
||||||
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_TOAST",
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
if (!open) dismiss()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState)
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState)
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast }
|
||||||
245
package-lock.json
generated
245
package-lock.json
generated
@ -13,7 +13,10 @@
|
|||||||
"@radix-ui/react-checkbox": "^1.1.2",
|
"@radix-ui/react-checkbox": "^1.1.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||||
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"@radix-ui/react-toast": "^1.2.2",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
@ -955,6 +958,12 @@
|
|||||||
"@prisma/debug": "5.22.0"
|
"@prisma/debug": "5.22.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/number": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/primitive": {
|
"node_modules/@radix-ui/primitive": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz",
|
||||||
@ -1363,6 +1372,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-label": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-peLblDlFw/ngk3UWq0VnYaOLy6agTZZ+MUO/WhVfm14vJGML+xH4FAl2XQGLqdefjNb7ApRg6Yn7U42ZhmYXdw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-menu": {
|
"node_modules/@radix-ui/react-menu": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.2.tgz",
|
||||||
@ -1693,6 +1725,162 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-select": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-rZJtWmorC7dFRi0owDmoijm6nSJH1tVw64QGiNIZ9PNLyBDtG+iAq+XGsya052At4BfarzY/Dhv9wrrUr6IMZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/number": "1.1.0",
|
||||||
|
"@radix-ui/primitive": "1.1.0",
|
||||||
|
"@radix-ui/react-collection": "1.1.0",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.0",
|
||||||
|
"@radix-ui/react-context": "1.1.1",
|
||||||
|
"@radix-ui/react-direction": "1.1.0",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.1",
|
||||||
|
"@radix-ui/react-focus-guards": "1.1.1",
|
||||||
|
"@radix-ui/react-focus-scope": "1.1.0",
|
||||||
|
"@radix-ui/react-id": "1.1.0",
|
||||||
|
"@radix-ui/react-popper": "1.2.0",
|
||||||
|
"@radix-ui/react-portal": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.0.0",
|
||||||
|
"@radix-ui/react-slot": "1.1.0",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.0",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.1.0",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.0",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.0",
|
||||||
|
"@radix-ui/react-visually-hidden": "1.1.0",
|
||||||
|
"aria-hidden": "^1.1.1",
|
||||||
|
"react-remove-scroll": "2.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-select/node_modules/react-remove-scroll": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-remove-scroll-bar": "^2.3.6",
|
||||||
|
"react-style-singleton": "^2.2.1",
|
||||||
|
"tslib": "^2.1.0",
|
||||||
|
"use-callback-ref": "^1.3.0",
|
||||||
|
"use-sidecar": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-select/node_modules/react-remove-scroll/node_modules/react-remove-scroll-bar": {
|
||||||
|
"version": "2.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz",
|
||||||
|
"integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-style-singleton": "^2.2.1",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-select/node_modules/react-remove-scroll/node_modules/react-style-singleton": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"get-nonce": "^1.0.0",
|
||||||
|
"invariant": "^2.2.4",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-select/node_modules/react-remove-scroll/node_modules/use-callback-ref": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-select/node_modules/react-remove-scroll/node_modules/use-sidecar": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-node-es": "^1.1.0",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-slot": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
|
||||||
@ -1711,6 +1899,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-toast": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-Z6pqSzmAP/bFJoqMAston4eSNa+ud44NSZTiZUmUen+IOZ5nBY8kzuU5WDBVyFXPtcW6yUalOHsxM/BP6Sv8ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.0",
|
||||||
|
"@radix-ui/react-collection": "1.1.0",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.0",
|
||||||
|
"@radix-ui/react-context": "1.1.1",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.1",
|
||||||
|
"@radix-ui/react-portal": "1.1.2",
|
||||||
|
"@radix-ui/react-presence": "1.1.1",
|
||||||
|
"@radix-ui/react-primitive": "2.0.0",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.0",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.1.0",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.0",
|
||||||
|
"@radix-ui/react-visually-hidden": "1.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
|
||||||
@ -1828,6 +2050,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-visually-hidden": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/rect": {
|
"node_modules/@radix-ui/rect": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz",
|
||||||
|
|||||||
@ -21,7 +21,10 @@
|
|||||||
"@radix-ui/react-checkbox": "^1.1.2",
|
"@radix-ui/react-checkbox": "^1.1.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||||
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"@radix-ui/react-toast": "^1.2.2",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
|
|||||||
@ -1,248 +1,521 @@
|
|||||||
export const categories = [
|
import { Status } from "@prisma/client";
|
||||||
{
|
|
||||||
name: "Vehicles",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Real Estate",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Electronics",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Fashion",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Sports",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Art",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Books",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "etc",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const adts = [
|
export const countries = [
|
||||||
{
|
{
|
||||||
title: "2020 Tesla Model 3 Long Range",
|
id: "country-1",
|
||||||
price: "$41,999",
|
nameEn: "United Arab Emirates",
|
||||||
location: "San Francisco, CA",
|
nameAr: "الإمارات العربية المتحدة",
|
||||||
image: "https://images.unsplash.com/photo-1560958089-b8a1929cea89?auto=format&fit=crop&w=800",
|
code: "UAE",
|
||||||
// date: "Posted 2 hours ago"
|
flag: "🇦🇪",
|
||||||
userId: 1
|
currency: "AED",
|
||||||
},
|
dialCode: "+971",
|
||||||
{
|
isActive: true
|
||||||
title: "Modern Studio Apartment in Downtown",
|
},
|
||||||
price: "$2,200/mo",
|
{
|
||||||
location: "Seattle, WA",
|
id: "country-2",
|
||||||
image: "https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=800",
|
nameEn: "Saudi Arabia",
|
||||||
// date: "Posted 5 hours ago"
|
nameAr: "المملكة العربية السعودية",
|
||||||
userId: 1
|
code: "SAU",
|
||||||
},
|
flag: "🇸🇦",
|
||||||
{
|
currency: "SAR",
|
||||||
title: "MacBook Pro M2 16-inch",
|
dialCode: "+966",
|
||||||
price: "$2,499",
|
isActive: true
|
||||||
location: "Austin, TX",
|
},
|
||||||
image: "https://images.unsplash.com/photo-1517336714731-489689fd1ca8?auto=format&fit=crop&w=800",
|
{
|
||||||
// date: "Posted 1 day ago"
|
id: "country-3",
|
||||||
userId: 1
|
nameEn: "Syria",
|
||||||
},
|
nameAr: "سوريا",
|
||||||
{
|
code: "SYR",
|
||||||
title: "Vintage Leather Jacket",
|
flag: "🇸🇾",
|
||||||
price: "$299",
|
currency: "SYP",
|
||||||
location: "Portland, OR",
|
dialCode: "+963",
|
||||||
image: "https://images.unsplash.com/photo-1551028719-00167b16eac5?auto=format&fit=crop&w=800",
|
isActive: true
|
||||||
// date: "Posted 2 days ago"
|
},
|
||||||
userId: 2
|
{
|
||||||
},
|
id: "country-4",
|
||||||
{
|
nameEn: "Egypt",
|
||||||
title: "Professional DSLR Camera Kit",
|
nameAr: "مصر",
|
||||||
price: "$1,899",
|
code: "EGY",
|
||||||
location: "New York, NY",
|
flag: "🇪🇬",
|
||||||
image: "https://images.unsplash.com/photo-1516035069371-29a1b244cc32?auto=format&fit=crop&w=800",
|
currency: "EGP",
|
||||||
// date: "Posted 3 days ago"
|
dialCode: "+20",
|
||||||
userId: 1
|
isActive: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Handcrafted Wooden Dining Table",
|
id: "country-5",
|
||||||
price: "$899",
|
nameEn: "Qatar",
|
||||||
location: "Denver, CO",
|
nameAr: "قطر",
|
||||||
image: "https://images.unsplash.com/photo-1577140917170-285929fb55b7?auto=format&fit=crop&w=800",
|
code: "QAT",
|
||||||
// date: "Posted 4 days ago"
|
flag: "🇶🇦",
|
||||||
userId: 2
|
currency: "QAR",
|
||||||
},
|
dialCode: "+974",
|
||||||
{
|
isActive: true
|
||||||
title: "Smart Bluetooth Speaker",
|
},
|
||||||
price: "$99",
|
{
|
||||||
location: "Los Angeles, CA",
|
id: "country-6",
|
||||||
image: "https://images.unsplash.com/photo-1517336714731-489689fd1ca8?auto=format&fit=crop&w=800",
|
nameEn: "Kuwait",
|
||||||
// date: "Posted 5 days ago"
|
nameAr: "الكويت",
|
||||||
userId: 1
|
code: "KWT",
|
||||||
},
|
flag: "🇰🇼",
|
||||||
{
|
currency: "KWD",
|
||||||
title: "Luxury Designer Watch",
|
dialCode: "+965",
|
||||||
price: "$1,299",
|
isActive: true
|
||||||
location: "Chicago, IL",
|
}
|
||||||
image: "https://images.unsplash.com/photo-1551028719-00167b16eac5?auto=format&fit=crop&w=800",
|
];
|
||||||
// date: "Posted 6 days ago"
|
|
||||||
userId: 1
|
export const cities = [
|
||||||
},
|
{
|
||||||
{
|
id: "city-1",
|
||||||
title: "Gaming Laptop",
|
nameEn: "Dubai",
|
||||||
price: "$1,499",
|
nameAr: "دبي",
|
||||||
location: "Miami, FL",
|
countryId: "country-1",
|
||||||
image: "https://images.unsplash.com/photo-1516035069371-29a1b244cc32?auto=format&fit=crop&w=800",
|
latitude: 25.2048,
|
||||||
// date: "Posted 7 days ago"
|
longitude: 55.2708,
|
||||||
userId: 1
|
isActive: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "2020 Tesla Model 3 Long Range",
|
id: "city-2",
|
||||||
price: "$41,999",
|
nameEn: "Abu Dhabi",
|
||||||
location: "San Francisco, CA",
|
nameAr: "أبوظبي",
|
||||||
image: "https://images.unsplash.com/photo-1560958089-b8a1929cea89?auto=format&fit=crop&w=800",
|
countryId: "country-1",
|
||||||
userId: 1
|
latitude: 24.4539,
|
||||||
},
|
longitude: 54.3773,
|
||||||
{
|
isActive: true
|
||||||
title: "Винтажный велосипед",
|
},
|
||||||
price: "$450",
|
{
|
||||||
location: "Portland, OR",
|
id: "city-3",
|
||||||
image: "https://images.unsplash.com/photo-1485965120184-e220f721d03e?auto=format&fit=crop&w=800",
|
nameEn: "Riyadh",
|
||||||
userId: 2
|
nameAr: "الرياض",
|
||||||
},
|
countryId: "country-2",
|
||||||
{
|
latitude: 24.7136,
|
||||||
title: "Профессиональный набор для рисования",
|
longitude: 46.6753,
|
||||||
price: "$299",
|
isActive: true
|
||||||
location: "Seattle, WA",
|
}
|
||||||
image: "https://images.unsplash.com/photo-1513364776144-60967b0f800f?auto=format&fit=crop&w=800",
|
];
|
||||||
userId: 1
|
|
||||||
},
|
export const categories = [
|
||||||
{
|
// Транспорт
|
||||||
title: "Кожаный диван",
|
{
|
||||||
price: "$1,299",
|
id: "cat-1",
|
||||||
location: "Austin, TX",
|
nameEn: "Vehicles",
|
||||||
image: "https://images.unsplash.com/photo-1493663284031-b7e3aefcae8e?auto=format&fit=crop&w=800",
|
nameAr: "مركبات",
|
||||||
userId: 2
|
slug: "vehicles",
|
||||||
},
|
icon: "🚗",
|
||||||
{
|
isActive: true,
|
||||||
title: "Коллекционные виниловые пластинки",
|
order: 1
|
||||||
price: "$599",
|
},
|
||||||
location: "Nashville, TN",
|
{
|
||||||
image: "https://images.unsplash.com/photo-1539375665275-f9de415ef9ac?auto=format&fit=crop&w=800",
|
id: "cat-1-1",
|
||||||
userId: 1
|
nameEn: "Cars",
|
||||||
},
|
nameAr: "سيارات",
|
||||||
{
|
slug: "cars",
|
||||||
title: "Беговая дорожка NordicTrack",
|
icon: "🚘",
|
||||||
price: "$899",
|
parentId: "cat-1",
|
||||||
location: "Phoenix, AZ",
|
isActive: true,
|
||||||
image: "https://images.unsplash.com/photo-1540497077202-7c8a3999166f?auto=format&fit=crop&w=800",
|
order: 1
|
||||||
userId: 2
|
},
|
||||||
},
|
{
|
||||||
{
|
id: "cat-1-2",
|
||||||
title: "Набор кухонной посуды",
|
nameEn: "Motorcycles",
|
||||||
price: "$399",
|
nameAr: "دراجات نارية",
|
||||||
location: "Boston, MA",
|
slug: "motorcycles",
|
||||||
image: "https://images.unsplash.com/photo-1556911220-bff31c812dba?auto=format&fit=crop&w=800",
|
icon: "🏍️",
|
||||||
userId: 1
|
parentId: "cat-1",
|
||||||
},
|
isActive: true,
|
||||||
{
|
order: 2
|
||||||
title: "Горный велосипед Trek",
|
},
|
||||||
price: "$789",
|
{
|
||||||
location: "Denver, CO",
|
id: "cat-1-3",
|
||||||
image: "https://images.unsplash.com/photo-1576435728678-68d0fbf94e91?auto=format&fit=crop&w=800",
|
nameEn: "Spare Parts",
|
||||||
userId: 2
|
nameAr: "قطع غيار",
|
||||||
},
|
slug: "spare-parts",
|
||||||
{
|
icon: "🔧",
|
||||||
title: "Игровая приставка PS5",
|
parentId: "cat-1",
|
||||||
price: "$499",
|
isActive: true,
|
||||||
location: "Las Vegas, NV",
|
order: 3
|
||||||
image: "https://images.unsplash.com/photo-1606144042614-b2417e99c4e3?auto=format&fit=crop&w=800",
|
},
|
||||||
userId: 1
|
|
||||||
},
|
// Недвижимость
|
||||||
{
|
{
|
||||||
title: "Антикварный комод",
|
id: "cat-2",
|
||||||
price: "$850",
|
nameEn: "Real Estate",
|
||||||
location: "Charleston, SC",
|
nameAr: "عقارات",
|
||||||
image: "https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=800",
|
slug: "real-estate",
|
||||||
userId: 2
|
icon: "🏠",
|
||||||
},
|
isActive: true,
|
||||||
{
|
order: 2
|
||||||
title: "Дрон DJI Mavic Air 2",
|
},
|
||||||
price: "$799",
|
{
|
||||||
location: "San Diego, CA",
|
id: "cat-2-1",
|
||||||
image: "https://images.unsplash.com/photo-1579829366248-204fe8413f31?auto=format&fit=crop&w=800",
|
nameEn: "Apartments",
|
||||||
userId: 1
|
nameAr: "شقق",
|
||||||
},
|
slug: "apartments",
|
||||||
{
|
icon: "🏢",
|
||||||
title: "Электрогитара Fender",
|
parentId: "cat-2",
|
||||||
price: "$1,199",
|
isActive: true,
|
||||||
location: "Atlanta, GA",
|
order: 1
|
||||||
image: "https://images.unsplash.com/photo-1564186763535-ebb21ef5277f?auto=format&fit=crop&w=800",
|
},
|
||||||
userId: 2
|
{
|
||||||
},
|
id: "cat-2-2",
|
||||||
{
|
nameEn: "Villas",
|
||||||
title: "Кофемашина Breville",
|
nameAr: "فلل",
|
||||||
price: "$599",
|
slug: "villas",
|
||||||
location: "Seattle, WA",
|
icon: "🏡",
|
||||||
image: "https://images.unsplash.com/photo-1517080317843-0b926a6ce350?auto=format&fit=crop&w=800",
|
parentId: "cat-2",
|
||||||
userId: 1
|
isActive: true,
|
||||||
},
|
order: 2
|
||||||
{
|
},
|
||||||
title: "Умные часы Apple Watch",
|
{
|
||||||
price: "$349",
|
id: "cat-2-3",
|
||||||
location: "Houston, TX",
|
nameEn: "Commercial",
|
||||||
image: "https://images.unsplash.com/photo-1546868871-7041f2a55e12?auto=format&fit=crop&w=800",
|
nameAr: "تجاري",
|
||||||
userId: 2
|
slug: "commercial",
|
||||||
},
|
icon: "🏪",
|
||||||
{
|
parentId: "cat-2",
|
||||||
title: "Винтажная печатная машинка",
|
isActive: true,
|
||||||
price: "$299",
|
order: 3
|
||||||
location: "Portland, ME",
|
},
|
||||||
image: "https://images.unsplash.com/photo-1558522195-e1201b090344?auto=format&fit=crop&w=800",
|
|
||||||
userId: 1
|
// Электроника
|
||||||
},
|
{
|
||||||
{
|
id: "cat-3",
|
||||||
title: "Телескоп Celestron",
|
nameEn: "Electronics",
|
||||||
price: "$899",
|
nameAr: "إلكترونيات",
|
||||||
location: "Tucson, AZ",
|
slug: "electronics",
|
||||||
image: "https://images.unsplash.com/photo-1566004100631-35d015d6a491?auto=format&fit=crop&w=800",
|
icon: "📱",
|
||||||
userId: 2
|
isActive: true,
|
||||||
},
|
order: 3
|
||||||
{
|
},
|
||||||
title: "Набор для йоги",
|
{
|
||||||
price: "$89",
|
id: "cat-3-1",
|
||||||
location: "Santa Monica, CA",
|
nameEn: "Phones",
|
||||||
image: "https://images.unsplash.com/photo-1544367567-0f2fcb009e0b?auto=format&fit=crop&w=800",
|
nameAr: "هواتف",
|
||||||
userId: 1
|
slug: "phones",
|
||||||
},
|
icon: "📱",
|
||||||
{
|
parentId: "cat-3",
|
||||||
title: "Винный холодильник",
|
isActive: true,
|
||||||
price: "$599",
|
order: 1
|
||||||
location: "Napa Valley, CA",
|
},
|
||||||
image: "https://images.unsplash.com/photo-1585703900468-13c7a978ad86?auto=format&fit=crop&w=800",
|
{
|
||||||
userId: 2
|
id: "cat-3-2",
|
||||||
},
|
nameEn: "Laptops",
|
||||||
{
|
nameAr: "حواسيب محمولة",
|
||||||
title: "Электросамокат Xiaomi",
|
slug: "laptops",
|
||||||
price: "$399",
|
icon: "💻",
|
||||||
location: "Chicago, IL",
|
parentId: "cat-3",
|
||||||
image: "https://images.unsplash.com/photo-1589999562311-56254d5d4325?auto=format&fit=crop&w=800",
|
isActive: true,
|
||||||
userId: 1
|
order: 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Коллекционные монеты",
|
id: "cat-3-3",
|
||||||
price: "$2,999",
|
nameEn: "Gaming",
|
||||||
location: "Washington, DC",
|
nameAr: "ألعاب",
|
||||||
image: "https://images.unsplash.com/photo-1566321343730-237ec35e53f3?auto=format&fit=crop&w=800",
|
slug: "gaming",
|
||||||
userId: 2
|
icon: "🎮",
|
||||||
},
|
parentId: "cat-3",
|
||||||
{
|
isActive: true,
|
||||||
title: "Профессиональный микрофон",
|
order: 3
|
||||||
price: "$299",
|
},
|
||||||
location: "Nashville, TN",
|
|
||||||
image: "https://images.unsplash.com/photo-1590602847861-f357a9332bbc?auto=format&fit=crop&w=800",
|
// Мода
|
||||||
userId: 1
|
{
|
||||||
}
|
id: "cat-4",
|
||||||
];
|
nameEn: "Fashion",
|
||||||
|
nameAr: "أزياء",
|
||||||
|
slug: "fashion",
|
||||||
|
icon: "👕",
|
||||||
|
isActive: true,
|
||||||
|
order: 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cat-4-1",
|
||||||
|
nameEn: "Men's Clothing",
|
||||||
|
nameAr: "ملابس رجالية",
|
||||||
|
slug: "mens-clothing",
|
||||||
|
icon: "👔",
|
||||||
|
parentId: "cat-4",
|
||||||
|
isActive: true,
|
||||||
|
order: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cat-4-2",
|
||||||
|
nameEn: "Women's Clothing",
|
||||||
|
nameAr: "ملابس نسائية",
|
||||||
|
slug: "womens-clothing",
|
||||||
|
icon: "👗",
|
||||||
|
parentId: "cat-4",
|
||||||
|
isActive: true,
|
||||||
|
order: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cat-4-3",
|
||||||
|
nameEn: "Accessories",
|
||||||
|
nameAr: "اكسسوارات",
|
||||||
|
slug: "accessories",
|
||||||
|
icon: "👜",
|
||||||
|
parentId: "cat-4",
|
||||||
|
isActive: true,
|
||||||
|
order: 3
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
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",
|
||||||
|
views: 0,
|
||||||
|
image: "https://example.com/camry.jpg",
|
||||||
|
contactPhone: "+971501234567",
|
||||||
|
isPromoted: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "iPhone 14 Pro Max",
|
||||||
|
description: "Новый, запечатанный 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: "Роскошная вилла с бассейном",
|
||||||
|
description: "6 спален, 7 ванных комнат, частный бассейн, сад",
|
||||||
|
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: "Дизайнерская сумка Gucci",
|
||||||
|
description: "Оригинальная сумка Gucci из новой коллекции",
|
||||||
|
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: "Harley-Davidson Street Glide",
|
||||||
|
description: "2022 год, пробег 5000 км, отличное состояние",
|
||||||
|
price: 95000.00,
|
||||||
|
status: Status.PUBLISHED,
|
||||||
|
countryId: "country-1",
|
||||||
|
cityId: "city-1",
|
||||||
|
address: "Motor City",
|
||||||
|
latitude: 25.0511,
|
||||||
|
longitude: 55.2492,
|
||||||
|
userId: 1,
|
||||||
|
categoryId: "cat-1-2",
|
||||||
|
views: 0,
|
||||||
|
image: "https://images.unsplash.com/photo-1558981806-ec527fa84c39",
|
||||||
|
contactPhone: "+971501234567",
|
||||||
|
isPromoted: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Оригинальные запчасти BMW",
|
||||||
|
description: "Новые тормозные диски и колодки для BMW X5",
|
||||||
|
price: 2500.00,
|
||||||
|
status: Status.PUBLISHED,
|
||||||
|
countryId: "country-1",
|
||||||
|
cityId: "city-1",
|
||||||
|
address: "Al Quoz",
|
||||||
|
latitude: 25.1539,
|
||||||
|
longitude: 55.2289,
|
||||||
|
userId: 1,
|
||||||
|
categoryId: "cat-1-3",
|
||||||
|
views: 0,
|
||||||
|
image: "https://images.unsplash.com/photo-1486262715619-67b85e0b08d3",
|
||||||
|
contactPhone: "+971501234567",
|
||||||
|
isPromoted: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Женское вечернее платье",
|
||||||
|
description: "Элегантное вечернее платье от известного дизайнера",
|
||||||
|
price: 3500.00,
|
||||||
|
status: Status.PUBLISHED,
|
||||||
|
countryId: "country-1",
|
||||||
|
cityId: "city-1",
|
||||||
|
address: "Dubai Mall",
|
||||||
|
latitude: 25.1972,
|
||||||
|
longitude: 55.2744,
|
||||||
|
userId: 1,
|
||||||
|
categoryId: "cat-4-2",
|
||||||
|
views: 0,
|
||||||
|
image: "https://images.unsplash.com/photo-1566174053879-31528523f8ae",
|
||||||
|
contactPhone: "+971501234567",
|
||||||
|
isPromoted: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Мужской костюм Tom Ford",
|
||||||
|
description: "Новый костюм Tom Ford, размер 52",
|
||||||
|
price: 12000.00,
|
||||||
|
status: Status.PUBLISHED,
|
||||||
|
countryId: "country-1",
|
||||||
|
cityId: "city-1",
|
||||||
|
address: "Mall of Emirates",
|
||||||
|
latitude: 25.1181,
|
||||||
|
longitude: 55.2008,
|
||||||
|
userId: 1,
|
||||||
|
categoryId: "cat-4-1",
|
||||||
|
views: 0,
|
||||||
|
image: "https://images.unsplash.com/photo-1594938298603-c8148c4dae35",
|
||||||
|
contactPhone: "+971501234567",
|
||||||
|
isPromoted: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// }
|
||||||
|
// ];
|
||||||
|
|||||||
@ -6,60 +6,178 @@ datasource db {
|
|||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("POSTGRES_URL")
|
url = env("POSTGRES_URL")
|
||||||
directUrl = env("POSTGRES_URL_NON_POOLING")
|
directUrl = env("POSTGRES_URL_NON_POOLING")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Улучшенная модель пользователя
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
email String @unique
|
email String @unique
|
||||||
name String?
|
name String?
|
||||||
password String
|
password String
|
||||||
|
phone String? // Добавляем телефон
|
||||||
|
avatar String? // Добавляем аватар
|
||||||
|
provider String?
|
||||||
|
providerId String?
|
||||||
|
role Role @default(USER)
|
||||||
|
isActive Boolean @default(true) // Статус активности пользователя
|
||||||
|
lastLoginAt DateTime? // Отслеживание последнего входа
|
||||||
|
|
||||||
provider String?
|
// Локация пользователя
|
||||||
providerId String?
|
countryId String?
|
||||||
|
country Country? @relation(fields: [countryId], references: [id])
|
||||||
|
cityId String?
|
||||||
|
city City? @relation(fields: [cityId], references: [id])
|
||||||
|
|
||||||
role Role @default(USER)
|
adts Adt[]
|
||||||
|
favoriteAdts Favorite[]
|
||||||
|
|
||||||
adts Adt[]
|
createdAt DateTime @default(now())
|
||||||
// favoriteAdts Adt[]
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
|
@@index([email])
|
||||||
|
@@index([phone])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Улучшенная модель объявления
|
||||||
model Adt {
|
model Adt {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
|
title String
|
||||||
|
description String? @db.Text // Используем Text для длинных описаний
|
||||||
|
price Decimal? @db.Decimal(10, 2) // Более точный тип для цены
|
||||||
|
status Status @default(CHECKING)
|
||||||
|
|
||||||
title String
|
// Геолокация
|
||||||
description String?
|
countryId String
|
||||||
price String?
|
country Country @relation(fields: [countryId], references: [id])
|
||||||
location String?
|
cityId String
|
||||||
image String?
|
city City @relation(fields: [cityId], references: [id])
|
||||||
status Status @default(CHECKING)
|
address String?
|
||||||
|
latitude Float?
|
||||||
|
longitude Float?
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id])
|
// Связи
|
||||||
userId Int?
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
userId Int?
|
||||||
|
categoryId String
|
||||||
|
category Category @relation(fields: [categoryId], references: [id])
|
||||||
|
|
||||||
categories Category[]
|
// Статистика
|
||||||
|
views Int @default(0)
|
||||||
|
favorites Favorite[]
|
||||||
|
image String?
|
||||||
|
images Image[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
// Дополнительные поля
|
||||||
updatedAt DateTime @updatedAt
|
expiresAt DateTime? // Срок действия объявления
|
||||||
|
isPromoted Boolean @default(false) // Продвигаемое объявление
|
||||||
|
contactPhone String? // Контактный телефон для объявления
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([countryId, cityId])
|
||||||
|
@@index([categoryId])
|
||||||
|
@@index([status])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Улучшенная модель категории
|
||||||
model Category {
|
model Category {
|
||||||
id Int @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
name String
|
nameEn String // Название на английском
|
||||||
adts Adt[]
|
nameAr String // Название на арабском
|
||||||
|
slug String @unique
|
||||||
|
image String?
|
||||||
|
icon String? // Иконка категории
|
||||||
|
parentId String?
|
||||||
|
parent Category? @relation("SubCategories", fields: [parentId], references: [id])
|
||||||
|
children Category[] @relation("SubCategories")
|
||||||
|
adts Adt[]
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
order Int @default(0) // Для сортировки категорий
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([parentId])
|
||||||
|
@@index([slug])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Новая модель страны
|
||||||
|
model Country {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nameEn String // Название на английском
|
||||||
|
nameAr String // Название на арабском
|
||||||
|
code String @unique // ISO код страны
|
||||||
|
flag String? // URL флага
|
||||||
|
currency String // Код валюты
|
||||||
|
dialCode String // Телефонный код
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
cities City[]
|
||||||
|
users User[]
|
||||||
|
adts Adt[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([code])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Новая модель города
|
||||||
|
model City {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nameEn String // Название на английском
|
||||||
|
nameAr String // Название на арабском
|
||||||
|
countryId String
|
||||||
|
country Country @relation(fields: [countryId], references: [id])
|
||||||
|
latitude Float?
|
||||||
|
longitude Float?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
users User[]
|
||||||
|
adts Adt[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([countryId])
|
||||||
|
@@unique([nameEn, countryId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Favorite {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
userId Int?
|
||||||
|
adtId Int
|
||||||
|
adt Adt @relation(fields: [adtId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([userId, adtId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([adtId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Image {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
url String
|
||||||
|
adtId Int
|
||||||
|
adt Adt @relation(fields: [adtId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
order Int @default(0) // Для сортировки изображений
|
||||||
|
|
||||||
|
@@index([adtId])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Role {
|
enum Role {
|
||||||
USER
|
USER
|
||||||
ADMIN
|
ADMIN
|
||||||
|
MODERATOR // Добавлен модератор
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Status {
|
enum Status {
|
||||||
CHECKING
|
CHECKING
|
||||||
PUBLISHED
|
PUBLISHED
|
||||||
CLOSED
|
REJECTED // Добавлен статус отклонения
|
||||||
|
CLOSED
|
||||||
|
EXPIRED // Добавлен статус истечения срока
|
||||||
}
|
}
|
||||||
@ -1,42 +1,51 @@
|
|||||||
import { adts, categories } from "./constant";
|
import { countries, cities, categories, adts } from "./constant";
|
||||||
import { prisma } from "./prisma-client";
|
import { prisma } from "./prisma-client";
|
||||||
import { hashSync } from "bcrypt";
|
import { hashSync } from "bcrypt";
|
||||||
|
|
||||||
|
|
||||||
async function up() {
|
async function up() {
|
||||||
|
// Создаем страны
|
||||||
|
await prisma.country.createMany({
|
||||||
|
data: countries
|
||||||
|
});
|
||||||
|
|
||||||
|
// Создаем города
|
||||||
|
await prisma.city.createMany({
|
||||||
|
data: cities
|
||||||
|
});
|
||||||
|
|
||||||
|
// Создаем пользователей
|
||||||
await prisma.user.createMany({
|
await prisma.user.createMany({
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
name: "user",
|
name: "user",
|
||||||
email: "j@j.com",
|
email: "user@example.com",
|
||||||
password: hashSync("123456", 10),
|
password: hashSync("123456", 10),
|
||||||
// verified: new Date(),
|
|
||||||
role: "USER",
|
role: "USER",
|
||||||
provider: 'credentials'
|
provider: 'credentials',
|
||||||
},
|
countryId: "country-1",
|
||||||
{
|
cityId: "city-1",
|
||||||
name: "user2",
|
phone: "+971501234567"
|
||||||
email: "da@j.com",
|
|
||||||
password: hashSync("123456", 10),
|
|
||||||
// verified: new Date(),
|
|
||||||
role: "USER",
|
|
||||||
provider: 'credentials'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "admin",
|
name: "admin",
|
||||||
email: "d@j.com",
|
email: "admin@example.com",
|
||||||
password: hashSync("123456", 10),
|
password: hashSync("123456", 10),
|
||||||
// verified: new Date(),
|
|
||||||
role: "ADMIN",
|
role: "ADMIN",
|
||||||
provider: 'credentials'
|
provider: 'credentials',
|
||||||
|
countryId: "country-1",
|
||||||
|
cityId: "city-1",
|
||||||
|
phone: "+971501234568"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Создаем категории
|
||||||
await prisma.category.createMany({
|
await prisma.category.createMany({
|
||||||
data: categories
|
data: categories
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Создаем объявления
|
||||||
await prisma.adt.createMany({
|
await prisma.adt.createMany({
|
||||||
data: adts
|
data: adts
|
||||||
});
|
});
|
||||||
@ -47,6 +56,8 @@ async function down() {
|
|||||||
await prisma.$executeRaw`TRUNCATE TABLE "User" RESTART IDENTITY CASCADE`;
|
await prisma.$executeRaw`TRUNCATE TABLE "User" RESTART IDENTITY CASCADE`;
|
||||||
await prisma.$executeRaw`TRUNCATE TABLE "Category" RESTART IDENTITY CASCADE`;
|
await prisma.$executeRaw`TRUNCATE TABLE "Category" RESTART IDENTITY CASCADE`;
|
||||||
await prisma.$executeRaw`TRUNCATE TABLE "Adt" RESTART IDENTITY CASCADE`;
|
await prisma.$executeRaw`TRUNCATE TABLE "Adt" RESTART IDENTITY CASCADE`;
|
||||||
|
await prisma.$executeRaw`TRUNCATE TABLE "Country" RESTART IDENTITY CASCADE`;
|
||||||
|
await prisma.$executeRaw`TRUNCATE TABLE "City" RESTART IDENTITY CASCADE`;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user