-
-
{adt.title}
- {adt.price}
-
-
- {adt.location}
+
+ {adt.address || 'Адрес не указан'}
-
- {String(adt.createdAt)}
+
+ {adt.city?.nameEn}
+
+
+ {new Date(adt.createdAt).toLocaleDateString()}
+
+
{adt.price ? `${adt.price} ₽` : 'Цена не указана'}
-
Description
+
Описание
- {adt.description}
+ {adt.description || 'Описание отсутствует'}
@@ -86,27 +96,20 @@ export default async function AdtPage(props: { params: Params }) {
className="w-12 h-12 rounded-full"
/>
-
{user?.name}
-
Member {String(user?.createdAt)}
+
{user?.name || 'Пользователь'}
+
На сайте с {new Date(user?.createdAt || '').toLocaleDateString()}
- {/*
*/}
diff --git a/app/(root)/adt/create/page.tsx b/app/(root)/adt/create/page.tsx
index f05ca20..3ffd295 100644
--- a/app/(root)/adt/create/page.tsx
+++ b/app/(root)/adt/create/page.tsx
@@ -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 (
-
+
//
diff --git a/app/(root)/page.tsx b/app/(root)/page.tsx
index 1a3de5a..63a1dfd 100644
--- a/app/(root)/page.tsx
+++ b/app/(root)/page.tsx
@@ -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)
diff --git a/app/(root)/profile/page.tsx b/app/(root)/profile/page.tsx
index 39f53c7..359c583 100644
--- a/app/(root)/profile/page.tsx
+++ b/app/(root)/profile/page.tsx
@@ -74,7 +74,7 @@ export default async function Profile() {
{user?.adts.map((adt) => (
-
+
))}
diff --git a/app/actions.ts b/app/actions.ts
index 83174c8..3232d64 100644
--- a/app/actions.ts
+++ b/app/actions.ts
@@ -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;
- }
-}
\ No newline at end of file
diff --git a/app/api/adt/route.ts b/app/api/adt/route.ts
index 1f580c8..a743e14 100644
--- a/app/api/adt/route.ts
+++ b/app/api/adt/route.ts
@@ -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
}
});
diff --git a/components/Categories.tsx b/components/Categories.tsx
index 2cb096b..20ce834 100644
--- a/components/Categories.tsx
+++ b/components/Categories.tsx
@@ -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 (
-
+
-
Browse Categories
-
- {categories.map((category) => {
- // const Icon = category.icon;
- return (
-
- );
- })}
+
Categories
+
+ {categories.map((category) => (
+
+
+ {category.icon && (
+
+ {category.icon}
+
+ )}
+
+ {category.nameEn}
+
+
+
+ ))}
diff --git a/components/shared/adt-create/adt-create-form.tsx b/components/shared/adt-create/adt-create-form.tsx
index bdacded..bcb9df0 100644
--- a/components/shared/adt-create/adt-create-form.tsx
+++ b/components/shared/adt-create/adt-create-form.tsx
@@ -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
(null);
const fileInputRef = useRef(null);
@@ -29,11 +31,35 @@ export default function AdtCreateForm({ categories }: CreateAdtFormProps) {
} = useForm({
resolver: zodResolver(formAdtCreateSchema),
defaultValues: {
- categoryIds: [],
+ categoryId: '',
}
});
- const selectedCategories = watch('categoryIds');
+ const selectedCategory = watch('categoryId');
+ const [selectedCountry, setSelectedCountry] = useState(null);
+ const [filteredCities, setFilteredCities] = useState([]);
+ const [selectedParentCategory, setSelectedParentCategory] = useState(null);
+ const [filteredSubCategories, setFilteredSubCategories] = useState([]);
+
+ 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) => {
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) {
)}
-
-
-
- {categories.map((category) => (
-
- ))}
+
+
+
+
- {errors.categoryIds && (
-
{errors.categoryIds.message}
+
+ {selectedParentCategory && (
+
+
+
+ {errors.categoryId && (
+
{errors.categoryId.message}
+ )}
+
+ )}
+
+
+
+
+
+
+ {errors.countryId && (
+
{errors.countryId.message}
+ )}
+
+
+ {selectedCountry && (
+
+
+
+ {errors.cityId && (
+
{errors.cityId.message}
+ )}
+
)}
-
diff --git a/components/shared/adt-create/schemas.ts b/components/shared/adt-create/schemas.ts
index e00cd27..633b28b 100644
--- a/components/shared/adt-create/schemas.ts
+++ b/components/shared/adt-create/schemas.ts
@@ -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
diff --git a/components/shared/block-adts.tsx b/components/shared/block-adts.tsx
index 4c0af76..c280376 100644
--- a/components/shared/block-adts.tsx
+++ b/components/shared/block-adts.tsx
@@ -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 = ({ category }) => {
// Состояния для хранения объявлений и управления их отображением
- const [adts, setAdts] = useState([]) // Массив объявлений
+ const [adts, setAdts] = useState([]) // Массив объявлений
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)}
/>
diff --git a/components/shared/breadcrumbs-category.tsx b/components/shared/breadcrumbs-category.tsx
new file mode 100644
index 0000000..c01c6e6
--- /dev/null
+++ b/components/shared/breadcrumbs-category.tsx
@@ -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 (
+
+
+
+
+
+ {categ?.parent?.nameEn}
+
+
+
+ {categ?.nameEn}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..3bd4a81
--- /dev/null
+++ b/components/ui/breadcrumb.tsx
@@ -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) => )
+Breadcrumb.displayName = "Breadcrumb"
+
+const BreadcrumbList = React.forwardRef<
+ HTMLOListElement,
+ React.ComponentPropsWithoutRef<"ol">
+>(({ className, ...props }, ref) => (
+
+))
+BreadcrumbList.displayName = "BreadcrumbList"
+
+const BreadcrumbItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentPropsWithoutRef<"li">
+>(({ className, ...props }, ref) => (
+
+))
+BreadcrumbItem.displayName = "BreadcrumbItem"
+
+const BreadcrumbLink = React.forwardRef<
+ HTMLAnchorElement,
+ React.ComponentPropsWithoutRef<"a"> & {
+ asChild?: boolean
+ }
+>(({ asChild, className, ...props }, ref) => {
+ const Comp = asChild ? Slot : "a"
+
+ return (
+
+ )
+})
+BreadcrumbLink.displayName = "BreadcrumbLink"
+
+const BreadcrumbPage = React.forwardRef<
+ HTMLSpanElement,
+ React.ComponentPropsWithoutRef<"span">
+>(({ className, ...props }, ref) => (
+
+))
+BreadcrumbPage.displayName = "BreadcrumbPage"
+
+const BreadcrumbSeparator = ({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) => (
+ svg]:w-3.5 [&>svg]:h-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+)
+BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
+
+const BreadcrumbEllipsis = ({
+ className,
+ ...props
+}: React.ComponentProps<"span">) => (
+
+
+ More
+
+)
+BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+}
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
new file mode 100644
index 0000000..be021b0
--- /dev/null
+++ b/components/ui/card.tsx
@@ -0,0 +1,76 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardDescription.displayName = "CardDescription"
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardContent.displayName = "CardContent"
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/components/ui/form.tsx b/components/ui/form.tsx
new file mode 100644
index 0000000..cef40bd
--- /dev/null
+++ b/components/ui/form.tsx
@@ -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 = FieldPath
+> = {
+ name: TName
+}
+
+const FormFieldContext = React.createContext(
+ {} as FormFieldContextValue
+)
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath
+>({
+ ...props
+}: ControllerProps) => {
+ return (
+
+
+
+ )
+}
+
+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 ")
+ }
+
+ 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(
+ {} as FormItemContextValue
+)
+
+const FormItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const id = React.useId()
+
+ return (
+
+
+
+ )
+})
+FormItem.displayName = "FormItem"
+
+const FormLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ const { error, formItemId } = useFormField()
+
+ return (
+
+ )
+})
+FormLabel.displayName = "FormLabel"
+
+const FormControl = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ ...props }, ref) => {
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
+
+ return (
+
+ )
+})
+FormControl.displayName = "FormControl"
+
+const FormDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { formDescriptionId } = useFormField()
+
+ return (
+
+ )
+})
+FormDescription.displayName = "FormDescription"
+
+const FormMessage = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, children, ...props }, ref) => {
+ const { error, formMessageId } = useFormField()
+ const body = error ? String(error?.message) : children
+
+ if (!body) {
+ return null
+ }
+
+ return (
+
+ {body}
+
+ )
+})
+FormMessage.displayName = "FormMessage"
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+}
diff --git a/components/ui/label.tsx b/components/ui/label.tsx
new file mode 100644
index 0000000..5341821
--- /dev/null
+++ b/components/ui/label.tsx
@@ -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,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/components/ui/select.tsx b/components/ui/select.tsx
new file mode 100644
index 0000000..8bb5d80
--- /dev/null
+++ b/components/ui/select.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ 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}
+
+
+
+
+))
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+))
+SelectContent.displayName = SelectPrimitive.Content.displayName
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectLabel.displayName = SelectPrimitive.Label.displayName
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+SelectItem.displayName = SelectPrimitive.Item.displayName
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+}
diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx
new file mode 100644
index 0000000..522c737
--- /dev/null
+++ b/components/ui/toast.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+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,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, variant, ...props }, ref) => {
+ return (
+
+ )
+})
+Toast.displayName = ToastPrimitives.Root.displayName
+
+const ToastAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastAction.displayName = ToastPrimitives.Action.displayName
+
+const ToastClose = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+ToastClose.displayName = ToastPrimitives.Close.displayName
+
+const ToastTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastTitle.displayName = ToastPrimitives.Title.displayName
+
+const ToastDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastDescription.displayName = ToastPrimitives.Description.displayName
+
+type ToastProps = React.ComponentPropsWithoutRef
+
+type ToastActionElement = React.ReactElement
+
+export {
+ type ToastProps,
+ type ToastActionElement,
+ ToastProvider,
+ ToastViewport,
+ Toast,
+ ToastTitle,
+ ToastDescription,
+ ToastClose,
+ ToastAction,
+}
diff --git a/components/ui/toaster.tsx b/components/ui/toaster.tsx
new file mode 100644
index 0000000..171beb4
--- /dev/null
+++ b/components/ui/toaster.tsx
@@ -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 (
+
+ {toasts.map(function ({ id, title, description, action, ...props }) {
+ return (
+
+
+ {title && {title}}
+ {description && (
+ {description}
+ )}
+
+ {action}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/hooks/use-toast.ts b/hooks/use-toast.ts
new file mode 100644
index 0000000..02e111d
--- /dev/null
+++ b/hooks/use-toast.ts
@@ -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
+ }
+ | {
+ type: ActionType["DISMISS_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
+ | {
+ type: ActionType["REMOVE_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
+
+interface State {
+ toasts: ToasterToast[]
+}
+
+const toastTimeouts = new Map>()
+
+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
+
+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(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 }
diff --git a/package-lock.json b/package-lock.json
index ed2b054..de9b38f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 7b11dd5..6a875bb 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/prisma/constant.ts b/prisma/constant.ts
index e107f7c..0052a56 100644
--- a/prisma/constant.ts
+++ b/prisma/constant.ts
@@ -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
+// }
+// ];
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 33f5a65..5c79d84 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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 // Добавлен статус истечения срока
+}
\ No newline at end of file
diff --git a/prisma/seed.ts b/prisma/seed.ts
index 9c78076..4dca126 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -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`;
}