diff --git a/next.config.ts b/next.config.ts index 347c61b..06d2ab2 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,6 +7,9 @@ const nextConfig: NextConfig = { // eslint: { // ignoreDuringBuilds: true, // }, + images: { + remotePatterns: [{ protocol: 'http', hostname: '**' }], + }, }; const withNextIntl = createNextIntlPlugin({ requestConfig: './src/shared/config/i18n/request.ts', diff --git a/package-lock.json b/package-lock.json index e996102..84fb777 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@hookform/resolvers": "^5.2.2", + "@pbe/react-yandex-maps": "^1.2.5", "@radix-ui/react-accordion": "^1.2.8", "@radix-ui/react-aspect-ratio": "^1.1.8", "@radix-ui/react-avatar": "^1.1.11", @@ -44,12 +46,13 @@ "next-themes": "^0.4.6", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-hook-form": "^7.68.0", "recharts": "^2.15.3", - "sonner": "^2.0.3", + "sonner": "^2.0.7", "swiper": "^12.0.3", "tailwind-merge": "^3.2.0", "vaul": "^1.1.2", - "zod": "^4.1.11", + "zod": "^4.2.1", "zustand": "^5.0.9" }, "devDependencies": { @@ -434,6 +437,18 @@ "tslib": "2" } }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1514,6 +1529,21 @@ "node": ">=0.10" } }, + "node_modules/@pbe/react-yandex-maps": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@pbe/react-yandex-maps/-/react-yandex-maps-1.2.5.tgz", + "integrity": "sha512-cBojin5e1fPx9XVCAqHQJsCnHGMeBNsP0TrNfpWCrPFfxb30ye+JgcGr2mn767Gbr1d+RufBLRiUcX2kaiAwjQ==", + "license": "MIT", + "dependencies": { + "@types/yandex-maps": "2.1.29" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x" + } + }, "node_modules/@pkgr/core": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", @@ -2923,6 +2953,12 @@ "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/core": { "version": "1.15.5", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.5.tgz", @@ -3626,6 +3662,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/yandex-maps": { + "version": "2.1.29", + "resolved": "https://registry.npmjs.org/@types/yandex-maps/-/yandex-maps-2.1.29.tgz", + "integrity": "sha512-nuibRWj3RU/9KXlCzTrRtDE+n6V9l7NbT9JakicqZ5OXIdwyb6blvV2Uwn6lB58WYm3DSUDP2I2AWlnWMc8z2w==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.49.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", @@ -8076,6 +8118,22 @@ "react": "^19.2.3" } }, + "node_modules/react-hook-form": { + "version": "7.68.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.68.0.tgz", + "integrity": "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -9582,9 +9640,9 @@ } }, "node_modules/zod": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.0.tgz", - "integrity": "sha512-Bd5fw9wlIhtqCCxotZgdTOMwGm1a0u75wARVEY9HMs1X17trvA/lMi4+MGK5EUfYkXVTbX8UDiDKW4OgzHVUZw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", + "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 8f8af58..891c4b9 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@hookform/resolvers": "^5.2.2", + "@pbe/react-yandex-maps": "^1.2.5", "@radix-ui/react-accordion": "^1.2.8", "@radix-ui/react-aspect-ratio": "^1.1.8", "@radix-ui/react-avatar": "^1.1.11", @@ -47,12 +49,13 @@ "next-themes": "^0.4.6", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-hook-form": "^7.68.0", "recharts": "^2.15.3", - "sonner": "^2.0.3", + "sonner": "^2.0.7", "swiper": "^12.0.3", "tailwind-merge": "^3.2.0", "vaul": "^1.1.2", - "zod": "^4.1.11", + "zod": "^4.2.1", "zustand": "^5.0.9" }, "devDependencies": { diff --git a/public/fine-dining-restaurant-plating.jpg b/public/fine-dining-restaurant-plating.jpg new file mode 100644 index 0000000..2b3c3bd Binary files /dev/null and b/public/fine-dining-restaurant-plating.jpg differ diff --git a/public/fresh-ingredients-culinary-market.jpg b/public/fresh-ingredients-culinary-market.jpg new file mode 100644 index 0000000..822a707 Binary files /dev/null and b/public/fresh-ingredients-culinary-market.jpg differ diff --git a/public/generic-company-logo.png b/public/generic-company-logo.png new file mode 100644 index 0000000..9d34545 Binary files /dev/null and b/public/generic-company-logo.png differ diff --git a/public/gourmet-food-culinary-magazine-hero-image.jpg b/public/gourmet-food-culinary-magazine-hero-image.jpg new file mode 100644 index 0000000..3b8d45e Binary files /dev/null and b/public/gourmet-food-culinary-magazine-hero-image.jpg differ diff --git a/public/professional-chef-cooking-gourmet-food.jpg b/public/professional-chef-cooking-gourmet-food.jpg new file mode 100644 index 0000000..17a741f Binary files /dev/null and b/public/professional-chef-cooking-gourmet-food.jpg differ diff --git a/src/app/[locale]/about/page.tsx b/src/app/[locale]/about/page.tsx new file mode 100644 index 0000000..1164e29 --- /dev/null +++ b/src/app/[locale]/about/page.tsx @@ -0,0 +1,15 @@ +import { AboutContent } from '@/features/about/ui/AboutContent'; +import { AboutHero } from '@/features/about/ui/AboutHero'; +import { PartnershipForm } from '@/features/about/ui/AboutPage'; + +const page = () => { + return ( +
+ + + +
+ ); +}; + +export default page; diff --git a/src/app/[locale]/faq/page.tsx b/src/app/[locale]/faq/page.tsx new file mode 100644 index 0000000..8aee4b4 --- /dev/null +++ b/src/app/[locale]/faq/page.tsx @@ -0,0 +1,11 @@ +import Faq from '@/features/faq/ui/Faq'; + +const page = () => { + return ( +
+ +
+ ); +}; + +export default page; diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index ba40a4b..0699b69 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -3,6 +3,7 @@ import { routing } from '@/shared/config/i18n/routing'; import QueryProvider from '@/shared/config/react-query/QueryProvider'; import { ThemeProvider } from '@/shared/config/theme-provider'; import { PRODUCT_INFO } from '@/shared/constants/data'; +import { Toaster } from '@/shared/ui/sonner'; import type { Metadata } from 'next'; import { hasLocale, Locale, NextIntlClientProvider } from 'next-intl'; import { setRequestLocale } from 'next-intl/server'; @@ -48,6 +49,7 @@ export default async function RootLayout({ children, params }: Props) { > {children} + diff --git a/src/app/[locale]/privacy-policy/page.tsx b/src/app/[locale]/privacy-policy/page.tsx new file mode 100644 index 0000000..e966baf --- /dev/null +++ b/src/app/[locale]/privacy-policy/page.tsx @@ -0,0 +1,11 @@ +import PrivacyPolicy from '@/features/privacy-policy/ui/PrivacyPlicy'; + +const page = () => { + return ( +
+ +
+ ); +}; + +export default page; diff --git a/src/features/about/ui/AboutContent.tsx b/src/features/about/ui/AboutContent.tsx new file mode 100644 index 0000000..8febd94 --- /dev/null +++ b/src/features/about/ui/AboutContent.tsx @@ -0,0 +1,110 @@ +import { Card } from '@/shared/ui/card'; +import Image from 'next/image'; + +export function AboutContent() { + const features = [ + { + number: '1', + title: 'Sifatli Kontent', + description: + "Jahon oshpazlik san'ati va zamonaviy gastronomiya tendentsiyalari haqida chuqur maqolalar va tahlillar", + }, + { + number: '2', + title: 'Professional Jamoa', + description: + 'Tajribali kulinariya mutaxassislari va oshpazlar tomonidan tayyorlangan kontent', + }, + { + number: '3', + title: 'Yangiliklar', + description: + "Gastronomiya sohasidagi so'nggi yangiliklar va eng yangi trendlar haqida xabarlar", + }, + ]; + + const images = [ + { + url: '/professional-chef-cooking-gourmet-food.jpg', + alt: 'Professional Oshpaz', + }, + { + url: '/fine-dining-restaurant-plating.jpg', + alt: 'Fine Dining', + }, + { + url: '/fresh-ingredients-culinary-market.jpg', + alt: 'Fresh Ingredients', + }, + ]; + + return ( +
+
+ {/* Mission Section */} +
+

+ Bizning maqsadimiz +

+
+ {features.map((feature) => ( + +
+ {feature.number} +
+

{feature.title}

+

+ {feature.description} +

+
+ ))} +
+
+ + {/* About Text */} +
+

+ Innovatsiya, sifat va professionallik +

+

+ {`Gastro Market – bu gastronomiya dunyosidagi eng so'nggi + yangiliklarni, retseptlarni va tendentsiyalarni taqdim etuvchi + onlayn platforma. Biz o'quvchilarimizga sifatli va qiziqarli kontent + taqdim etishga intilamiz.`} +

+

+ {`Bizning jamoamiz tajribali kulinariya mutaxassislari, oshpazlar va + gastronomiya sohasidagi ekspertlardan iborat. Biz har bir maqolada + sifat va professionallikka e'tibor qaratamiz.`} +

+
+ + {/* Image Gallery */} +
+

+ Bizning dunyo +

+
+ {images.map((image, idx) => ( +
+ {image.alt} +
+ ))} +
+
+
+
+ ); +} diff --git a/src/features/about/ui/AboutHero.tsx b/src/features/about/ui/AboutHero.tsx new file mode 100644 index 0000000..09333ef --- /dev/null +++ b/src/features/about/ui/AboutHero.tsx @@ -0,0 +1,27 @@ +import Image from 'next/image'; + +export function AboutHero() { + return ( +
+
+ Gastro Market +
+
+

+ Gastro Market +

+

+ { + "Gastronomiya va kulinariya san'ati haqidagi yetakchi onlayn magazin" + } +

+
+
+ ); +} diff --git a/src/features/about/ui/AboutPage.tsx b/src/features/about/ui/AboutPage.tsx new file mode 100644 index 0000000..b0ce1f9 --- /dev/null +++ b/src/features/about/ui/AboutPage.tsx @@ -0,0 +1,274 @@ +'use client'; + +import formatPhone from '@/shared/lib/formatPhone'; +import { Button } from '@/shared/ui/button'; +import { Card } from '@/shared/ui/card'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/shared/ui/form'; +import { Input } from '@/shared/ui/input'; +import { Label } from '@/shared/ui/label'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Upload } from 'lucide-react'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import * as z from 'zod'; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB +const ACCEPTED_FILE_TYPES = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', +]; + +const partnershipFormSchema = z.object({ + companyName: z.string().min(2, { + message: "Kompaniya nomi kamida 2 ta belgidan iborat bo'lishi kerak", + }), + website: z + .string() + .url({ message: "To'g'ri website manzilini kiriting" }) + .optional() + .or(z.literal('')), + contactPerson: z + .string() + .min(2, { message: "Ism kamida 2 ta belgidan iborat bo'lishi kerak" }), + email: z + .string() + .email({ message: "To'g'ri email manzilini kiriting" }) + .optional(), + phone: z + .string() + .min(9, { message: "To'g'ri telefon raqamini kiriting" }) + .regex(/^[\d\s+\-$$$$]+$/, { + message: "Telefon raqami faqat raqamlardan iborat bo'lishi kerak", + }), + companyFile: z + .custom() + .refine((files) => files?.length === 1, 'File yuklash majburiy') + .refine( + (files) => files?.[0]?.size <= MAX_FILE_SIZE, + 'File hajmi 5MB dan oshmasligi kerak', + ) + .refine( + (files) => ACCEPTED_FILE_TYPES.includes(files?.[0]?.type), + 'Faqat PDF yoki Word formatidagi fayllar qabul qilinadi', + ), +}); + +type PartnershipFormValues = z.infer; + +export function PartnershipForm() { + const [isSubmitting, setIsSubmitting] = useState(false); + + const form = useForm({ + resolver: zodResolver(partnershipFormSchema), + defaultValues: { + companyName: '', + website: '', + contactPerson: '', + email: '', + phone: '+998', + }, + }); + + async function onSubmit(data: PartnershipFormValues) { + console.log(data); + + setIsSubmitting(true); + + try { + await new Promise((resolve) => setTimeout(resolve, 2000)); + + toast.success("So'rov yuborildi!", { + richColors: true, + position: 'top-center', + }); + + form.reset(); + } catch { + toast.error('Xatolik yuz berdi', { + richColors: true, + position: 'top-center', + }); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+
+

+ {`Hamkor bo'ling`} +

+

+ {`Gastro Market bilan hamkorlik qilishni xohlaysizmi? Quyidagi formani + to'ldiring va biz siz bilan tez orada bog'lanamiz.`} +

+
+ + +
+ + ( + + + + + + + + )} + /> + + ( + + + + + + + + )} + /> + + ( + + + + + + + + )} + /> + +
+ ( + + + + + + + + )} + /> + + ( + + + + + + + + )} + /> +
+ + ( + + + +
+ { + const files = e.target.files; + onChange(files); + }} + {...field} + className="hidden" + /> + + +

+ Faylni tanlang +

+ {value && value.length > 0 && ( +

+ Tanlangan fayl: {value[0].name} +

+ )} +
+
+
+ + PDF yoki Word formatida (maksimal 5MB) + + +
+ )} + /> + + + + +
+
+
+ ); +} diff --git a/src/features/cart/lib/form.ts b/src/features/cart/lib/form.ts new file mode 100644 index 0000000..9ee5d18 --- /dev/null +++ b/src/features/cart/lib/form.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const orderForm = z.object({ + firstName: z.string().min(1, { message: 'Majburiy maydon' }), + lastName: z.string().min(1, { message: 'Majburiy maydon' }), + phone: z.string().min(12, { message: 'Xato raqam kiritildi' }), + long: z.string().min(1, { message: 'Majburiy maydon' }), + lat: z.string().min(1, { message: 'Majburiy maydon' }), + city: z.string().optional(), +}); +// 998901234567 diff --git a/src/features/cart/ui/OrderPage.tsx b/src/features/cart/ui/OrderPage.tsx index e0b1eca..71dbc8c 100644 --- a/src/features/cart/ui/OrderPage.tsx +++ b/src/features/cart/ui/OrderPage.tsx @@ -1,14 +1,24 @@ 'use client'; import formatPhone from '@/shared/lib/formatPhone'; +import { Button } from '@/shared/ui/button'; +import { Form, FormControl, FormField, FormItem } from '@/shared/ui/form'; import { Input } from '@/shared/ui/input'; import { Label } from '@/shared/ui/label'; -import { Textarea } from '@/shared/ui/textarea'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { + Map, + Placemark, + Polygon, + YMaps, + ZoomControl, +} from '@pbe/react-yandex-maps'; import { Building2, CheckCircle2, Clock, CreditCard, + LocateFixed, MapPin, Package, Truck, @@ -16,17 +26,27 @@ import { Wallet, } from 'lucide-react'; import Image from 'next/image'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import z from 'zod'; +import { orderForm } from '../lib/form'; + +interface CoordsData { + lat: number; + lon: number; + polygon: [number, number][][]; +} const OrderPage = () => { - const [formData, setFormData] = useState({ - fullName: '', - phone: '+998', - email: '', - city: '', - address: '', - postalCode: '', - notes: '', + const form = useForm>({ + resolver: zodResolver(orderForm), + defaultValues: { + firstName: '', + lastName: '', + lat: '', + long: '', + phone: '', + }, }); const [paymentMethod, setPaymentMethod] = useState('cash'); @@ -34,7 +54,6 @@ const OrderPage = () => { const [isSubmitting, setIsSubmitting] = useState(false); const [orderSuccess, setOrderSuccess] = useState(false); - // Cart items from previous page (in real app, this would come from context/store) const cartItems = [ { id: 5, @@ -67,25 +86,109 @@ const OrderPage = () => { deliveryMethod === 'express' ? 25000 : subtotal > 50000 ? 0 : 15000; const total = subtotal + deliveryFee; - const handleInputChange = ( - e: React.ChangeEvent, - ) => { - setFormData({ - ...formData, - [e.target.name]: e.target.value, - }); + const [coords, setCoords] = useState({ + latitude: 41.311081, + longitude: 69.240562, + zoom: 12, + }); + + const [polygonCoords, setPolygonCoords] = useState< + [number, number][][] | null + >(null); + + const getCoords = async (name: string): Promise => { + const res = await fetch( + `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(name)}&format=json&polygon_geojson=1&limit=1`, + ); + const data = await res.json(); + + if (data.length > 0 && data[0].geojson) { + const lat = parseFloat(data[0].lat); + const lon = parseFloat(data[0].lon); + + let polygon: [number, number][][] = []; + + if (data[0].geojson.type === 'Polygon') { + polygon = data[0].geojson.coordinates.map((ring: [number, number][]) => + ring.map((coord: [number, number]) => [coord[1], coord[0]]), + ); + } else if (data[0].geojson.type === 'MultiPolygon') { + polygon = data[0].geojson.coordinates.map( + (poly: [number, number][][]) => + poly[0].map((coord: [number, number]) => [coord[1], coord[0]]), + ); + } + + return { lat, lon, polygon }; + } + + return null; }; - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - setIsSubmitting(true); + const handleMapClick = ( + e: ymaps.IEvent, + ) => { + const [lat, lon] = e.get('coords'); + + setCoords({ latitude: lat, longitude: lon, zoom: 14 }); + + form.setValue('lat', lat.toString(), { shouldDirty: true }); + form.setValue('long', lon.toString(), { shouldDirty: true }); + }; + + const handleShowMyLocation = () => { + if (!navigator.geolocation) { + alert('Sizning brauzeringiz geolokatsiyani qo‘llab-quvvatlamaydi'); + return; + } + navigator.geolocation.getCurrentPosition( + (position) => { + const lat = position.coords.latitude; + const lon = position.coords.longitude; + setCoords({ latitude: lat, longitude: lon, zoom: 14 }); + form.setValue('lat', lat.toString()); + form.setValue('long', lon.toString()); + }, + (error) => { + alert('Joylashuv aniqlanmadi: ' + error.message); + }, + ); + }; + + const cityValue = form.watch('city'); + + useEffect(() => { + if (!cityValue || cityValue.length < 3) return; + + const timeout = setTimeout(async () => { + const result = await getCoords(cityValue); + + if (!result) return; + + setCoords({ + latitude: result.lat, + longitude: result.lon, + zoom: 12, + }); + + setPolygonCoords(result.polygon); + + form.setValue('lat', result.lat.toString(), { shouldDirty: true }); + form.setValue('long', result.lon.toString(), { shouldDirty: true }); + }, 700); // debounce + + return () => clearTimeout(timeout); + }, [cityValue]); + + function onSubmit(value: z.infer) { + setIsSubmitting(true); + console.log(value); - // Simulate API call setTimeout(() => { setIsSubmitting(false); setOrderSuccess(true); }, 2000); - }; + } if (orderSuccess) { return ( @@ -133,302 +236,374 @@ const OrderPage = () => {

{"Ma'lumotlaringizni to'ldiring"}

- -
-
- {/* Left Column - Forms */} -
- {/* Contact Information */} -
-
- -

- {"Shaxsiy ma'lumotlar"} -

-
-
-
- - + + +
+ {/* Left Column - Forms */} +
+ {/* Contact Information */} +
+
+ +

+ {"Shaxsiy ma'lumotlar"} +

-
- - -
-
-
- - {/* Delivery Address */} -
-
- -

- Yetkazib berish manzili -

-
-
-
- - -
-
- -