add category, city, country,

This commit is contained in:
Zikil 2024-11-24 22:33:34 +07:00
parent 1eb4e0eaa1
commit 93b86ae3cc
26 changed files with 2264 additions and 459 deletions

3
@types/prisma.ts Normal file
View File

@ -0,0 +1,3 @@
import { Adt, Category, City, User } from "@prisma/client";
export type AdtWithRelations = Adt & { category: Category; city: City; user: User };

View 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>
);
}

View File

@ -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>

View File

@ -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">

View File

@ -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)

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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
}
});

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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)}
/>

View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -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",

View File

@ -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",

View File

@ -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
// }
// ];

View File

@ -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 // Добавлен статус истечения срока
}

View File

@ -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`;
}