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 { 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 { notFound } from 'next/navigation';
|
||||
import { ShowNumberModal } from '@/components/shared/modals/show-number';
|
||||
import { getUserSession } from '@/lib/get-user-session';
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
|
||||
import { BreadcrumbsCategory } from '@/components/shared/breadcrumbs-category';
|
||||
|
||||
type Params = Promise<{ id: string }>
|
||||
|
||||
export default async function AdtPage(props: { params: Params }) {
|
||||
const params = await props.params;
|
||||
|
||||
|
||||
const session = await getUserSession();
|
||||
const adt = await prisma.adt.findFirst({
|
||||
where: {
|
||||
id: Number(params.id),
|
||||
},
|
||||
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 { id } = params();
|
||||
// const adt = adts.find(l => l.id === id) || adts[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
<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="lg:col-span-2">
|
||||
<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" />
|
||||
<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-1">
|
||||
<MapPin className="h-5 w-5" />
|
||||
<span>{adt.location}</span>
|
||||
<MapPin className="h-5 w-5" />
|
||||
<span>{adt.address || 'Адрес не указан'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-5 w-5" />
|
||||
<span>{String(adt.createdAt)}</span>
|
||||
<Building className="h-5 w-5" />
|
||||
<span>{adt.city?.nameEn}</span>
|
||||
</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>
|
||||
|
||||
<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">
|
||||
{adt.description}
|
||||
{adt.description || 'Описание отсутствует'}
|
||||
</p>
|
||||
|
||||
<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">
|
||||
<Share2 className="h-5 w-5" />
|
||||
<span>Share</span>
|
||||
<Share2 className="h-5 w-5" />
|
||||
<span>Поделиться</span>
|
||||
</button>
|
||||
<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" />
|
||||
<span>Report</span>
|
||||
<Flag className="h-5 w-5" />
|
||||
<span>Пожаловаться</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -86,27 +96,20 @@ export default async function AdtPage(props: { params: Params }) {
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="font-semibold">{user?.name}</h3>
|
||||
<p className="text-sm text-gray-500">Member {String(user?.createdAt)}</p>
|
||||
<h3 className="font-semibold">{user?.name || 'Пользователь'}</h3>
|
||||
<p className="text-sm text-gray-500">На сайте с {new Date(user?.createdAt || '').toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<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">
|
||||
<MessageCircle className="h-5 w-5" />
|
||||
<span>Send Message (In development)</span>
|
||||
<span>Написать сообщение (В разработке)</span>
|
||||
</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">
|
||||
<Heart className="h-5 w-5" />
|
||||
<span>Save to Favorites (In development)</span>
|
||||
<span>Добавить в избранное (В разработке)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -8,11 +8,13 @@ const prisma = new PrismaClient();
|
||||
|
||||
export default async function CreateListing() {
|
||||
const categories = await prisma.category.findMany();
|
||||
const countries = await prisma.country.findMany();
|
||||
const cities = await prisma.city.findMany();
|
||||
|
||||
|
||||
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">
|
||||
|
||||
@ -8,7 +8,7 @@ import { prisma } from "@/prisma/prisma-client";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export default async function Home() {
|
||||
const adts = await prisma.adt.findMany()
|
||||
// const adts = await prisma.adt.findMany()
|
||||
// const session = await getUserSession()
|
||||
// // console.log(user)
|
||||
// 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">
|
||||
{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>
|
||||
</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 { PrismaClient } from '@prisma/client';
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
import { formAdtCreateSchema } from '@/components/shared/adt-create/schemas';
|
||||
import { getUserSession } from '@/lib/get-user-session';
|
||||
|
||||
@ -9,6 +9,7 @@ const prisma = new PrismaClient();
|
||||
// GET функция для получения списка объявлений с пагинацией
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
console.log("request",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 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({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
categories: true,
|
||||
category: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
}
|
||||
},
|
||||
city: {
|
||||
select: {
|
||||
nameEn: true,
|
||||
nameAr: true
|
||||
}
|
||||
},
|
||||
country: true
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc' // Сортировка по дате создания (новые первыми)
|
||||
@ -89,22 +137,21 @@ export async function POST(request: Request) {
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Создание объявления
|
||||
const adt = await prisma.adt.create({
|
||||
data: {
|
||||
title: validatedData.title,
|
||||
description: validatedData.description,
|
||||
price: validatedData.price,
|
||||
location: validatedData.location,
|
||||
address: validatedData.address,
|
||||
image: validatedData.image,
|
||||
userId: user.id,
|
||||
categories: {
|
||||
connect: validatedData.categoryIds.map(id => ({ id }))
|
||||
}
|
||||
categoryId: validatedData.categoryId,
|
||||
countryId: validatedData.countryId,
|
||||
cityId: validatedData.cityId
|
||||
},
|
||||
include: {
|
||||
categories: true
|
||||
category: true
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -1,40 +1,38 @@
|
||||
// "use client"
|
||||
import React from 'react';
|
||||
import { Car, Home, Laptop, Shirt, Briefcase, Dumbbell, Palette, Book } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
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() {
|
||||
const categories = await prisma.category.findMany();
|
||||
const categories = await prisma.category.findMany({
|
||||
where: {
|
||||
parentId: null,
|
||||
}
|
||||
});
|
||||
|
||||
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">
|
||||
<h2 className="text-xl font-semibold mb-6">Browse Categories</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-8 gap-4">
|
||||
{categories.map((category) => {
|
||||
// const Icon = category.icon;
|
||||
return (
|
||||
<button
|
||||
key={category.name}
|
||||
className="flex flex-col items-center p-4 bg-white rounded-xl hover:shadow-md transition-shadow"
|
||||
>
|
||||
{/* <Icon className="h-8 w-8 text-indigo-600 mb-2" /> */}
|
||||
<span className="text-sm text-gray-700">{category.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-4">Categories</h2>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 lg:grid-cols-8 gap-3">
|
||||
{categories.map((category) => (
|
||||
<Link
|
||||
key={category.id}
|
||||
href={`/${category.slug}`}
|
||||
className="group"
|
||||
>
|
||||
<div className="bg-white p-2 rounded-md shadow-sm hover:shadow-md transition-shadow duration-200 flex flex-col items-center justify-center min-h-[70px] border border-gray-100">
|
||||
{category.icon && (
|
||||
<span className="text-xl mb-1 group-hover:scale-110 transition-transform duration-200">
|
||||
{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>
|
||||
|
||||
@ -1,19 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useMemo, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { formAdtCreateSchema, type TFormAdtCreateValues } from './schemas';
|
||||
import Image from 'next/image';
|
||||
import { Category } from '@prisma/client';
|
||||
import { Category, Country, City } from '@prisma/client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface CreateAdtFormProps {
|
||||
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 [preview, setPreview] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@ -29,11 +31,35 @@ export default function AdtCreateForm({ categories }: CreateAdtFormProps) {
|
||||
} = useForm<TFormAdtCreateValues>({
|
||||
resolver: zodResolver(formAdtCreateSchema),
|
||||
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 file = e.target.files?.[0];
|
||||
@ -72,28 +98,23 @@ export default function AdtCreateForm({ categories }: CreateAdtFormProps) {
|
||||
}
|
||||
|
||||
toast.success("Adt created successfully")
|
||||
// Здесь можно добавить уведомление об успешном создании
|
||||
const data_f = await response.json();
|
||||
// Редирект на страницу созданного объявления
|
||||
router.push(`/adt/${data_f.id}`);
|
||||
// Обновляем кэш Next.js
|
||||
// router.refresh();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
// toast.error("Error creating adt: " + error)
|
||||
// Здесь можно добавить обработку ошибок
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategoryChange = (categoryId: number) => {
|
||||
const currentCategories = watch('categoryIds') || [];
|
||||
const updatedCategories = currentCategories.includes(categoryId)
|
||||
? currentCategories.filter(id => id !== categoryId)
|
||||
: [...currentCategories, categoryId];
|
||||
setValue('categoryIds', updatedCategories);
|
||||
const handleCountryChange = (countryId: string) => {
|
||||
setSelectedCountry(countryId);
|
||||
setValue('countryId', countryId);
|
||||
};
|
||||
|
||||
const handleCategoryParentChange = (categoryId: string) => {
|
||||
setSelectedParentCategory(categoryId);
|
||||
};
|
||||
|
||||
return (
|
||||
@ -144,40 +165,105 @@ export default function AdtCreateForm({ categories }: CreateAdtFormProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Категории*
|
||||
</label>
|
||||
<div className="mt-2 space-y-2">
|
||||
{categories.map((category) => (
|
||||
<label key={category.id} className="inline-flex items-center mr-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-checkbox h-4 w-4 text-indigo-600"
|
||||
onChange={() => handleCategoryChange(category.id)}
|
||||
checked={selectedCategories?.includes(category.id)}
|
||||
/>
|
||||
<span className="ml-2">{category.name}</span>
|
||||
</label>
|
||||
))}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Основная категория*
|
||||
</label>
|
||||
<select
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
onChange={(e) => handleCategoryParentChange(e.target.value)}
|
||||
value={selectedParentCategory || ''}
|
||||
>
|
||||
<option value="">Выберите категорию</option>
|
||||
{parentCategories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.nameEn}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
id="location"
|
||||
{...register('location')}
|
||||
id="address"
|
||||
{...register('address')}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
{errors.location && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.location.message}</p>
|
||||
{errors.address && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.address.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@ -9,14 +9,17 @@ export const formAdtCreateSchema = z.object({
|
||||
.max(1000, 'Описание не может быть длиннее 1000 символов')
|
||||
.nullable(),
|
||||
price: z.string()
|
||||
.min(1, 'Укажите цену')
|
||||
.nullable(),
|
||||
location: z.string()
|
||||
.min(2, 'Укажите местоположение')
|
||||
.max(100, 'Слишком длинное название местоположения')
|
||||
// .transform((val) => (val ? parseFloat(val) : null))
|
||||
.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(),
|
||||
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>
|
||||
|
||||
@ -3,15 +3,16 @@
|
||||
// Импортируем необходимые компоненты и типы
|
||||
import { FC, useEffect, useState, useRef, useCallback } from 'react'
|
||||
import ListingCard from '../ListingCard'
|
||||
import { Adt } from '@prisma/client'
|
||||
import toast from 'react-hot-toast'
|
||||
import { AdtWithRelations } from '@/@types/prisma'
|
||||
|
||||
// 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 [isLoading, setIsLoading] = useState(true) // Флаг загрузки
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false) // Флаг загрузки дополнительных объявлений
|
||||
@ -42,8 +43,10 @@ export const BlockAdts: FC = () => {
|
||||
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()
|
||||
|
||||
// Обновляем список объявлений
|
||||
@ -136,7 +139,7 @@ export const BlockAdts: FC = () => {
|
||||
title={adt.title}
|
||||
image={String(adt.image)}
|
||||
price={String(adt.price)}
|
||||
location={String(adt.location)}
|
||||
location={String(adt.city.nameEn)}
|
||||
date={String(adt.createdAt)}
|
||||
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-dialog": "^1.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-toast": "^1.2.2",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"axios": "^1.7.7",
|
||||
"bcrypt": "^5.1.1",
|
||||
@ -955,6 +958,12 @@
|
||||
"@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": {
|
||||
"version": "1.1.0",
|
||||
"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": {
|
||||
"version": "2.1.2",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"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-dialog": "^1.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-toast": "^1.2.2",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"axios": "^1.7.7",
|
||||
"bcrypt": "^5.1.1",
|
||||
|
||||
@ -1,248 +1,521 @@
|
||||
export const categories = [
|
||||
{
|
||||
name: "Vehicles",
|
||||
},
|
||||
{
|
||||
name: "Real Estate",
|
||||
},
|
||||
{
|
||||
name: "Electronics",
|
||||
},
|
||||
{
|
||||
name: "Fashion",
|
||||
},
|
||||
{
|
||||
name: "Sports",
|
||||
},
|
||||
{
|
||||
name: "Art",
|
||||
},
|
||||
{
|
||||
name: "Books",
|
||||
},
|
||||
{
|
||||
name: "etc",
|
||||
},
|
||||
]
|
||||
import { Status } from "@prisma/client";
|
||||
|
||||
export const adts = [
|
||||
{
|
||||
title: "2020 Tesla Model 3 Long Range",
|
||||
price: "$41,999",
|
||||
location: "San Francisco, CA",
|
||||
image: "https://images.unsplash.com/photo-1560958089-b8a1929cea89?auto=format&fit=crop&w=800",
|
||||
// date: "Posted 2 hours ago"
|
||||
userId: 1
|
||||
},
|
||||
{
|
||||
title: "Modern Studio Apartment in Downtown",
|
||||
price: "$2,200/mo",
|
||||
location: "Seattle, WA",
|
||||
image: "https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=800",
|
||||
// date: "Posted 5 hours ago"
|
||||
userId: 1
|
||||
},
|
||||
{
|
||||
title: "MacBook Pro M2 16-inch",
|
||||
price: "$2,499",
|
||||
location: "Austin, TX",
|
||||
image: "https://images.unsplash.com/photo-1517336714731-489689fd1ca8?auto=format&fit=crop&w=800",
|
||||
// date: "Posted 1 day ago"
|
||||
userId: 1
|
||||
},
|
||||
{
|
||||
title: "Vintage Leather Jacket",
|
||||
price: "$299",
|
||||
location: "Portland, OR",
|
||||
image: "https://images.unsplash.com/photo-1551028719-00167b16eac5?auto=format&fit=crop&w=800",
|
||||
// date: "Posted 2 days ago"
|
||||
userId: 2
|
||||
},
|
||||
{
|
||||
title: "Professional DSLR Camera Kit",
|
||||
price: "$1,899",
|
||||
location: "New York, NY",
|
||||
image: "https://images.unsplash.com/photo-1516035069371-29a1b244cc32?auto=format&fit=crop&w=800",
|
||||
// date: "Posted 3 days ago"
|
||||
userId: 1
|
||||
},
|
||||
{
|
||||
title: "Handcrafted Wooden Dining Table",
|
||||
price: "$899",
|
||||
location: "Denver, CO",
|
||||
image: "https://images.unsplash.com/photo-1577140917170-285929fb55b7?auto=format&fit=crop&w=800",
|
||||
// date: "Posted 4 days ago"
|
||||
userId: 2
|
||||
},
|
||||
{
|
||||
title: "Smart Bluetooth Speaker",
|
||||
price: "$99",
|
||||
location: "Los Angeles, CA",
|
||||
image: "https://images.unsplash.com/photo-1517336714731-489689fd1ca8?auto=format&fit=crop&w=800",
|
||||
// date: "Posted 5 days ago"
|
||||
userId: 1
|
||||
},
|
||||
{
|
||||
title: "Luxury Designer Watch",
|
||||
price: "$1,299",
|
||||
location: "Chicago, IL",
|
||||
image: "https://images.unsplash.com/photo-1551028719-00167b16eac5?auto=format&fit=crop&w=800",
|
||||
// date: "Posted 6 days ago"
|
||||
userId: 1
|
||||
},
|
||||
{
|
||||
title: "Gaming Laptop",
|
||||
price: "$1,499",
|
||||
location: "Miami, FL",
|
||||
image: "https://images.unsplash.com/photo-1516035069371-29a1b244cc32?auto=format&fit=crop&w=800",
|
||||
// date: "Posted 7 days ago"
|
||||
userId: 1
|
||||
},
|
||||
{
|
||||
title: "2020 Tesla Model 3 Long Range",
|
||||
price: "$41,999",
|
||||
location: "San Francisco, CA",
|
||||
image: "https://images.unsplash.com/photo-1560958089-b8a1929cea89?auto=format&fit=crop&w=800",
|
||||
userId: 1
|
||||
},
|
||||
{
|
||||
title: "Винтажный велосипед",
|
||||
price: "$450",
|
||||
location: "Portland, OR",
|
||||
image: "https://images.unsplash.com/photo-1485965120184-e220f721d03e?auto=format&fit=crop&w=800",
|
||||
userId: 2
|
||||
},
|
||||
{
|
||||
title: "Профессиональный набор для рисования",
|
||||
price: "$299",
|
||||
location: "Seattle, WA",
|
||||
image: "https://images.unsplash.com/photo-1513364776144-60967b0f800f?auto=format&fit=crop&w=800",
|
||||
userId: 1
|
||||
},
|
||||
{
|
||||
title: "Кожаный диван",
|
||||
price: "$1,299",
|
||||
location: "Austin, TX",
|
||||
image: "https://images.unsplash.com/photo-1493663284031-b7e3aefcae8e?auto=format&fit=crop&w=800",
|
||||
userId: 2
|
||||
},
|
||||
{
|
||||
title: "Коллекционные виниловые пластинки",
|
||||
price: "$599",
|
||||
location: "Nashville, TN",
|
||||
image: "https://images.unsplash.com/photo-1539375665275-f9de415ef9ac?auto=format&fit=crop&w=800",
|
||||
userId: 1
|
||||
},
|
||||
{
|
||||
title: "Беговая дорожка NordicTrack",
|
||||
price: "$899",
|
||||
location: "Phoenix, AZ",
|
||||
image: "https://images.unsplash.com/photo-1540497077202-7c8a3999166f?auto=format&fit=crop&w=800",
|
||||
userId: 2
|
||||
},
|
||||
{
|
||||
title: "Набор кухонной посуды",
|
||||
price: "$399",
|
||||
location: "Boston, MA",
|
||||
image: "https://images.unsplash.com/photo-1556911220-bff31c812dba?auto=format&fit=crop&w=800",
|
||||
userId: 1
|
||||
},
|
||||
{
|
||||
title: "Горный велосипед Trek",
|
||||
price: "$789",
|
||||
location: "Denver, CO",
|
||||
image: "https://images.unsplash.com/photo-1576435728678-68d0fbf94e91?auto=format&fit=crop&w=800",
|
||||
userId: 2
|
||||
},
|
||||
{
|
||||
title: "Игровая приставка PS5",
|
||||
price: "$499",
|
||||
location: "Las Vegas, NV",
|
||||
image: "https://images.unsplash.com/photo-1606144042614-b2417e99c4e3?auto=format&fit=crop&w=800",
|
||||
userId: 1
|
||||
},
|
||||
{
|
||||
title: "Антикварный комод",
|
||||
price: "$850",
|
||||
location: "Charleston, SC",
|
||||
image: "https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=800",
|
||||
userId: 2
|
||||
},
|
||||
{
|
||||
title: "Дрон DJI Mavic Air 2",
|
||||
price: "$799",
|
||||
location: "San Diego, CA",
|
||||
image: "https://images.unsplash.com/photo-1579829366248-204fe8413f31?auto=format&fit=crop&w=800",
|
||||
userId: 1
|
||||
},
|
||||
{
|
||||
title: "Электрогитара Fender",
|
||||
price: "$1,199",
|
||||
location: "Atlanta, GA",
|
||||
image: "https://images.unsplash.com/photo-1564186763535-ebb21ef5277f?auto=format&fit=crop&w=800",
|
||||
userId: 2
|
||||
},
|
||||
{
|
||||
title: "Кофемашина Breville",
|
||||
price: "$599",
|
||||
location: "Seattle, WA",
|
||||
image: "https://images.unsplash.com/photo-1517080317843-0b926a6ce350?auto=format&fit=crop&w=800",
|
||||
userId: 1
|
||||
},
|
||||
{
|
||||
title: "Умные часы Apple Watch",
|
||||
price: "$349",
|
||||
location: "Houston, TX",
|
||||
image: "https://images.unsplash.com/photo-1546868871-7041f2a55e12?auto=format&fit=crop&w=800",
|
||||
userId: 2
|
||||
},
|
||||
{
|
||||
title: "Винтажная печатная машинка",
|
||||
price: "$299",
|
||||
location: "Portland, ME",
|
||||
image: "https://images.unsplash.com/photo-1558522195-e1201b090344?auto=format&fit=crop&w=800",
|
||||
userId: 1
|
||||
},
|
||||
{
|
||||
title: "Телескоп Celestron",
|
||||
price: "$899",
|
||||
location: "Tucson, AZ",
|
||||
image: "https://images.unsplash.com/photo-1566004100631-35d015d6a491?auto=format&fit=crop&w=800",
|
||||
userId: 2
|
||||
},
|
||||
{
|
||||
title: "Набор для йоги",
|
||||
price: "$89",
|
||||
location: "Santa Monica, CA",
|
||||
image: "https://images.unsplash.com/photo-1544367567-0f2fcb009e0b?auto=format&fit=crop&w=800",
|
||||
userId: 1
|
||||
},
|
||||
{
|
||||
title: "Винный холодильник",
|
||||
price: "$599",
|
||||
location: "Napa Valley, CA",
|
||||
image: "https://images.unsplash.com/photo-1585703900468-13c7a978ad86?auto=format&fit=crop&w=800",
|
||||
userId: 2
|
||||
},
|
||||
{
|
||||
title: "Электросамокат Xiaomi",
|
||||
price: "$399",
|
||||
location: "Chicago, IL",
|
||||
image: "https://images.unsplash.com/photo-1589999562311-56254d5d4325?auto=format&fit=crop&w=800",
|
||||
userId: 1
|
||||
},
|
||||
{
|
||||
title: "Коллекционные монеты",
|
||||
price: "$2,999",
|
||||
location: "Washington, DC",
|
||||
image: "https://images.unsplash.com/photo-1566321343730-237ec35e53f3?auto=format&fit=crop&w=800",
|
||||
userId: 2
|
||||
},
|
||||
{
|
||||
title: "Профессиональный микрофон",
|
||||
price: "$299",
|
||||
location: "Nashville, TN",
|
||||
image: "https://images.unsplash.com/photo-1590602847861-f357a9332bbc?auto=format&fit=crop&w=800",
|
||||
userId: 1
|
||||
}
|
||||
];
|
||||
export const countries = [
|
||||
{
|
||||
id: "country-1",
|
||||
nameEn: "United Arab Emirates",
|
||||
nameAr: "الإمارات العربية المتحدة",
|
||||
code: "UAE",
|
||||
flag: "🇦🇪",
|
||||
currency: "AED",
|
||||
dialCode: "+971",
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
id: "country-2",
|
||||
nameEn: "Saudi Arabia",
|
||||
nameAr: "المملكة العربية السعودية",
|
||||
code: "SAU",
|
||||
flag: "🇸🇦",
|
||||
currency: "SAR",
|
||||
dialCode: "+966",
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
id: "country-3",
|
||||
nameEn: "Syria",
|
||||
nameAr: "سوريا",
|
||||
code: "SYR",
|
||||
flag: "🇸🇾",
|
||||
currency: "SYP",
|
||||
dialCode: "+963",
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
id: "country-4",
|
||||
nameEn: "Egypt",
|
||||
nameAr: "مصر",
|
||||
code: "EGY",
|
||||
flag: "🇪🇬",
|
||||
currency: "EGP",
|
||||
dialCode: "+20",
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
id: "country-5",
|
||||
nameEn: "Qatar",
|
||||
nameAr: "قطر",
|
||||
code: "QAT",
|
||||
flag: "🇶🇦",
|
||||
currency: "QAR",
|
||||
dialCode: "+974",
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
id: "country-6",
|
||||
nameEn: "Kuwait",
|
||||
nameAr: "الكويت",
|
||||
code: "KWT",
|
||||
flag: "🇰🇼",
|
||||
currency: "KWD",
|
||||
dialCode: "+965",
|
||||
isActive: true
|
||||
}
|
||||
];
|
||||
|
||||
export const cities = [
|
||||
{
|
||||
id: "city-1",
|
||||
nameEn: "Dubai",
|
||||
nameAr: "دبي",
|
||||
countryId: "country-1",
|
||||
latitude: 25.2048,
|
||||
longitude: 55.2708,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
id: "city-2",
|
||||
nameEn: "Abu Dhabi",
|
||||
nameAr: "أبوظبي",
|
||||
countryId: "country-1",
|
||||
latitude: 24.4539,
|
||||
longitude: 54.3773,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
id: "city-3",
|
||||
nameEn: "Riyadh",
|
||||
nameAr: "الرياض",
|
||||
countryId: "country-2",
|
||||
latitude: 24.7136,
|
||||
longitude: 46.6753,
|
||||
isActive: true
|
||||
}
|
||||
];
|
||||
|
||||
export const categories = [
|
||||
// Транспорт
|
||||
{
|
||||
id: "cat-1",
|
||||
nameEn: "Vehicles",
|
||||
nameAr: "مركبات",
|
||||
slug: "vehicles",
|
||||
icon: "🚗",
|
||||
isActive: true,
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
id: "cat-1-1",
|
||||
nameEn: "Cars",
|
||||
nameAr: "سيارات",
|
||||
slug: "cars",
|
||||
icon: "🚘",
|
||||
parentId: "cat-1",
|
||||
isActive: true,
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
id: "cat-1-2",
|
||||
nameEn: "Motorcycles",
|
||||
nameAr: "دراجات نارية",
|
||||
slug: "motorcycles",
|
||||
icon: "🏍️",
|
||||
parentId: "cat-1",
|
||||
isActive: true,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
id: "cat-1-3",
|
||||
nameEn: "Spare Parts",
|
||||
nameAr: "قطع غيار",
|
||||
slug: "spare-parts",
|
||||
icon: "🔧",
|
||||
parentId: "cat-1",
|
||||
isActive: true,
|
||||
order: 3
|
||||
},
|
||||
|
||||
// Недвижимость
|
||||
{
|
||||
id: "cat-2",
|
||||
nameEn: "Real Estate",
|
||||
nameAr: "عقارات",
|
||||
slug: "real-estate",
|
||||
icon: "🏠",
|
||||
isActive: true,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
id: "cat-2-1",
|
||||
nameEn: "Apartments",
|
||||
nameAr: "شقق",
|
||||
slug: "apartments",
|
||||
icon: "🏢",
|
||||
parentId: "cat-2",
|
||||
isActive: true,
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
id: "cat-2-2",
|
||||
nameEn: "Villas",
|
||||
nameAr: "فلل",
|
||||
slug: "villas",
|
||||
icon: "🏡",
|
||||
parentId: "cat-2",
|
||||
isActive: true,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
id: "cat-2-3",
|
||||
nameEn: "Commercial",
|
||||
nameAr: "تجاري",
|
||||
slug: "commercial",
|
||||
icon: "🏪",
|
||||
parentId: "cat-2",
|
||||
isActive: true,
|
||||
order: 3
|
||||
},
|
||||
|
||||
// Электроника
|
||||
{
|
||||
id: "cat-3",
|
||||
nameEn: "Electronics",
|
||||
nameAr: "إلكترونيات",
|
||||
slug: "electronics",
|
||||
icon: "📱",
|
||||
isActive: true,
|
||||
order: 3
|
||||
},
|
||||
{
|
||||
id: "cat-3-1",
|
||||
nameEn: "Phones",
|
||||
nameAr: "هواتف",
|
||||
slug: "phones",
|
||||
icon: "📱",
|
||||
parentId: "cat-3",
|
||||
isActive: true,
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
id: "cat-3-2",
|
||||
nameEn: "Laptops",
|
||||
nameAr: "حواسيب محمولة",
|
||||
slug: "laptops",
|
||||
icon: "💻",
|
||||
parentId: "cat-3",
|
||||
isActive: true,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
id: "cat-3-3",
|
||||
nameEn: "Gaming",
|
||||
nameAr: "ألعاب",
|
||||
slug: "gaming",
|
||||
icon: "🎮",
|
||||
parentId: "cat-3",
|
||||
isActive: true,
|
||||
order: 3
|
||||
},
|
||||
|
||||
// Мода
|
||||
{
|
||||
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"
|
||||
url = env("POSTGRES_URL")
|
||||
directUrl = env("POSTGRES_URL_NON_POOLING")
|
||||
|
||||
}
|
||||
|
||||
// Улучшенная модель пользователя
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
name String?
|
||||
password String
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
name 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[]
|
||||
// favoriteAdts Adt[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([email])
|
||||
@@index([phone])
|
||||
}
|
||||
|
||||
// Улучшенная модель объявления
|
||||
model Adt {
|
||||
id Int @id @default(autoincrement())
|
||||
|
||||
title String
|
||||
description String?
|
||||
price String?
|
||||
location String?
|
||||
image String?
|
||||
status Status @default(CHECKING)
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
description String? @db.Text // Используем Text для длинных описаний
|
||||
price Decimal? @db.Decimal(10, 2) // Более точный тип для цены
|
||||
status Status @default(CHECKING)
|
||||
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId Int?
|
||||
|
||||
categories Category[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
// Геолокация
|
||||
countryId String
|
||||
country Country @relation(fields: [countryId], references: [id])
|
||||
cityId String
|
||||
city City @relation(fields: [cityId], references: [id])
|
||||
address String?
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
|
||||
// Связи
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId Int?
|
||||
categoryId String
|
||||
category Category @relation(fields: [categoryId], references: [id])
|
||||
|
||||
// Статистика
|
||||
views Int @default(0)
|
||||
favorites Favorite[]
|
||||
image String?
|
||||
images Image[]
|
||||
|
||||
// Дополнительные поля
|
||||
expiresAt DateTime? // Срок действия объявления
|
||||
isPromoted Boolean @default(false) // Продвигаемое объявление
|
||||
contactPhone String? // Контактный телефон для объявления
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([countryId, cityId])
|
||||
@@index([categoryId])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
// Улучшенная модель категории
|
||||
model Category {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
adts Adt[]
|
||||
id String @id @default(cuid())
|
||||
nameEn String // Название на английском
|
||||
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 {
|
||||
USER
|
||||
ADMIN
|
||||
USER
|
||||
ADMIN
|
||||
MODERATOR // Добавлен модератор
|
||||
}
|
||||
|
||||
enum Status {
|
||||
CHECKING
|
||||
PUBLISHED
|
||||
CLOSED
|
||||
}
|
||||
CHECKING
|
||||
PUBLISHED
|
||||
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 { hashSync } from "bcrypt";
|
||||
|
||||
|
||||
async function up() {
|
||||
// Создаем страны
|
||||
await prisma.country.createMany({
|
||||
data: countries
|
||||
});
|
||||
|
||||
// Создаем города
|
||||
await prisma.city.createMany({
|
||||
data: cities
|
||||
});
|
||||
|
||||
// Создаем пользователей
|
||||
await prisma.user.createMany({
|
||||
data: [
|
||||
{
|
||||
name: "user",
|
||||
email: "j@j.com",
|
||||
email: "user@example.com",
|
||||
password: hashSync("123456", 10),
|
||||
// verified: new Date(),
|
||||
role: "USER",
|
||||
provider: 'credentials'
|
||||
},
|
||||
{
|
||||
name: "user2",
|
||||
email: "da@j.com",
|
||||
password: hashSync("123456", 10),
|
||||
// verified: new Date(),
|
||||
role: "USER",
|
||||
provider: 'credentials'
|
||||
provider: 'credentials',
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
phone: "+971501234567"
|
||||
},
|
||||
{
|
||||
name: "admin",
|
||||
email: "d@j.com",
|
||||
email: "admin@example.com",
|
||||
password: hashSync("123456", 10),
|
||||
// verified: new Date(),
|
||||
role: "ADMIN",
|
||||
provider: 'credentials'
|
||||
provider: 'credentials',
|
||||
countryId: "country-1",
|
||||
cityId: "city-1",
|
||||
phone: "+971501234568"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Создаем категории
|
||||
await prisma.category.createMany({
|
||||
data: categories
|
||||
});
|
||||
|
||||
// Создаем объявления
|
||||
await prisma.adt.createMany({
|
||||
data: adts
|
||||
});
|
||||
@ -47,6 +56,8 @@ async function down() {
|
||||
await prisma.$executeRaw`TRUNCATE TABLE "User" 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 "Country" RESTART IDENTITY CASCADE`;
|
||||
await prisma.$executeRaw`TRUNCATE TABLE "City" RESTART IDENTITY CASCADE`;
|
||||
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user