first commit

This commit is contained in:
Jelaletdin12
2026-02-01 20:55:57 +05:00
commit b8c871750a
128 changed files with 23114 additions and 0 deletions

261
app/[locale]/cart/page.tsx Normal file
View File

@@ -0,0 +1,261 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import CartItemCard from "../../../features/cart/components/CartItemCard";
import CartItemSkeleton from "../../../features/cart/components/CartItemSkeleton";
import OrderSummary from "../../../features/cart/components/OrderSummary";
import OrderSummarySkeleton from "../../../features/cart/components/OrderSummarySkeleton";
import {
useCart,
useCreateOrder,
useRegions,
usePaymentTypes,
} from "@/lib/hooks";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import type { DeliveryType, PaymentType } from "@/lib/types/api";
import EmptyCart from "@/features/cart/components/EmptyCart";
import ErrorPage from "@/components/ErrorPage";
export default function CartPage() {
const [isClient, setIsClient] = useState(false);
const [paymentType, setPaymentType] = useState<PaymentType | null>(null);
const [deliveryType, setDeliveryType] =
useState<DeliveryType>("SELECTED_DELIVERY");
const [selectedRegion, setSelectedRegion] = useState<string>("");
const [selectedProvince, setSelectedProvince] = useState<number | null>(null);
const [note, setNote] = useState<string>("");
const [phone, setPhone] = useState<string>("+993 ");
const [name, setName] = useState<string>("");
const [lastName, setLastName] = useState<string>("");
const router = useRouter();
const t = useTranslations();
const { data: cartResponse, isLoading: cartLoading, isError } = useCart();
const { data: provinces = [], isLoading: provincesLoading } = useRegions();
const { data: paymentTypes = [], isLoading: paymentTypesLoading } =
usePaymentTypes();
const { mutate: createOrder, isPending: isCreatingOrder } = useCreateOrder();
const cartItems = cartResponse?.data || [];
const isLoading = cartLoading || provincesLoading || paymentTypesLoading;
useEffect(() => {
setIsClient(true);
}, []);
const regionGroups = useMemo(() => {
return provinces.reduce((acc, province) => {
if (!acc[province.region]) {
acc[province.region] = [];
}
acc[province.region].push(province);
return acc;
}, {} as Record<string, typeof provinces>);
}, [provinces]);
const availableRegions = useMemo(
() => Object.keys(regionGroups),
[regionGroups]
);
const itemsBySeller = useMemo(() => {
return cartItems.reduce((acc, item) => {
const sellerId = item.product.channel?.[0]?.id || 0;
const sellerName = item.product.channel?.[0]?.name || "Unknown Seller";
if (!acc[sellerId]) {
acc[sellerId] = {
seller: { id: sellerId, name: sellerName },
items: [],
};
}
acc[sellerId].items.push(item);
return acc;
}, {} as Record<number, { seller: { id: number; name: string }; items: typeof cartItems }>);
}, [cartItems]);
const totalAmount = useMemo(() => {
return cartItems.reduce((sum, item) => {
const price = parseFloat(item.product.price_amount || "0");
return sum + price * item.product_quantity;
}, 0);
}, [cartItems]);
const handleDeliveryTypeChange = (type: DeliveryType) => {
setDeliveryType(type);
setSelectedProvince(null);
};
const formatPhoneForBackend = (phoneNumber: string): string => {
return phoneNumber.replace(/^\+993\s*/, "").replace(/\s+/g, "");
};
const handleCompleteOrder = () => {
if (!selectedRegion || !selectedProvince || !paymentType || !phone || !name) {
console.warn("Missing required fields for order");
return;
}
const phoneDigits = formatPhoneForBackend(phone);
if (phoneDigits.length !== 8) {
console.warn("Phone number must be exactly 8 digits");
return;
}
const selectedProvinceData = provinces.find((p) => p.id === selectedProvince);
if (!selectedProvinceData) return;
createOrder(
{
customer_name: `${name} ${lastName}`.trim(),
customer_phone: parseInt(phoneDigits, 10),
customer_address: selectedProvinceData.name,
shipping_method: "standart",
payment_type_id: paymentType.id,
region: selectedRegion,
note: note || undefined,
},
{
onSuccess: () => {
router.push(`/orders`);
},
}
);
};
if (!isClient) return null;
if (isLoading) {
return (
<div className="mx-auto px-2 md:px-4 lg:px-6 mb-18">
<h1 className="text-xl md:text-2xl lg:text-3xl font-bold mb-4 md:mb-6 pt-3">
{t("cart")}
</h1>
<div className="flex flex-col md:flex-row gap-6">
<div className="flex-1">
<Card className="p-4 md:p-6 rounded-xl">
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<CartItemSkeleton key={i} />
))}
</div>
</Card>
</div>
<OrderSummarySkeleton />
</div>
</div>
);
}
if (isError ) {
return <ErrorPage />;
}
if (cartItems.length === 0) {
return <EmptyCart />;
}
return (
<div className="flex flex-col mx-auto max-w-[1504px] px-2 md:px-4 lg:px-6 mb-18">
<h1 className="text-xl md:text-2xl lg:text-3xl font-bold mb-4 md:mb-6 pt-3">
{t("cart")}
</h1>
<div className="flex flex-col md:flex-row gap-6">
<div className="flex-1">
<Card className="p-4 md:p-6 rounded-xl">
{Object.entries(itemsBySeller).map(
([sellerId, { seller, items }]) => (
<div key={sellerId} className="mb-6">
<p className="text-base font-semibold mb-3">{seller.name}</p>
<div className="space-y-4">
{items.map((item) => {
const price = parseFloat(
item.product.price_amount || "0"
);
const quantity = item.product_quantity;
const total = price * quantity;
return (
<CartItemCard
key={item.id}
item={{
...item,
quantity: quantity,
price: price,
total: total,
seller: seller,
price_formatted: `${item.product.price_amount} TMT`,
sub_total_formatted: `${item.product.price_amount} TMT`,
total_formatted: `${total.toFixed(2)} TMT`,
discount_formatted: "0 TMT",
product: {
...item.product,
image:
item.product.media?.[0]?.images_800x800 ||
item.product.media?.[0]?.thumbnail,
images:
item.product.media?.map(
(m) => m.images_800x800 || m.thumbnail
) || [],
},
}}
/>
);
})}
</div>
{Object.entries(itemsBySeller).length > 1 && (
<Separator className="mt-4" />
)}
</div>
)
)}
</Card>
</div>
<OrderSummary
order={{
id: 1,
billing: {
body: [
{
title: t("products"),
value: `${totalAmount.toFixed(2)} TMT`,
},
],
footer: {
title: t("total_price"),
value: `${totalAmount.toFixed(2)} TMT`,
},
},
}}
paymentType={paymentType}
deliveryType={deliveryType}
selectedRegion={selectedRegion}
selectedProvince={selectedProvince}
note={note}
regionGroups={regionGroups}
availableRegions={availableRegions}
paymentTypes={paymentTypes}
phone={phone}
name={name}
lastName={lastName}
onPhoneChange={setPhone}
onNameChange={setName}
onLastNameChange={setLastName}
onPaymentTypeChange={setPaymentType}
onDeliveryTypeChange={handleDeliveryTypeChange}
onRegionChange={setSelectedRegion}
onProvinceChange={setSelectedProvince}
onNoteChange={setNote}
onCompleteOrder={handleCompleteOrder}
isLoading={isCreatingOrder}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import type { Metadata } from "next";
type Props = {
params: Promise<{ locale: string; slug: string }>;
};
export const revalidate = 600; // ISR: Revalidate every 10 minutes
const CATEGORY_META = {
tm: {
suffix: " | Post shop",
description: "Kategoriýa boýunça harytlary gözläň",
ogLocale: "tk_TM",
},
ru: {
suffix: " | Post shop",
description: "Просмотр товаров в данной категории",
ogLocale: "ru_RU",
},
} as const;
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale, slug } = await params;
const meta =
CATEGORY_META[locale as keyof typeof CATEGORY_META] ?? CATEGORY_META.ru;
return {
title: `${slug}${meta.suffix}`,
description: meta.description,
openGraph: {
locale: meta.ogLocale,
title: `${slug}${meta.suffix}`,
description: meta.description,
},
};
}
export async function generateStaticParams() {
const categories = ["electronics", "clothing", "home-garden"];
return categories.map((slug) => ({ slug }));
}
export default async function CategoryPage(props: Props) {
const params = await props.params;
const { slug } = params;
const CategoryPageClient = (
await import("../../../../features/category/components/CategoryPageClient")
).default;
return <CategoryPageClient params={params} />;
}

View File

@@ -0,0 +1,64 @@
import type { Metadata } from "next";
type Props = {
params: Promise<{ locale: string; slug: string }>;
};
export const revalidate = 600; // ISR: 10 minutes
const META = {
tm: {
titleSuffix: " | Post shop",
description: (name: string) => `${name} kolleksiýasyndaky harytlary gözläň`,
ogLocale: "tk_TM",
},
ru: {
titleSuffix: " | Post shop",
description: (name: string) => `Просмотр товаров из коллекции «${name}»`,
ogLocale: "ru_RU",
},
} as const;
function formatSlug(slug: string) {
return slug
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale, slug } = await params;
const meta = META[locale as keyof typeof META] ?? META.ru;
const collectionName = formatSlug(slug);
const title = `${collectionName}${meta.titleSuffix}`;
const description = meta.description(collectionName);
return {
title,
description,
openGraph: {
type: "website",
locale: meta.ogLocale,
title,
description,
},
};
}
export async function generateStaticParams() {
const collections = ["new-arrivals", "best-sellers", "featured"];
return collections.map((slug) => ({ slug }));
}
export default async function CollectionPage(props: Props) {
const params = await props.params;
const CollectionPageClient = (
await import(
"../../../../features/collections/components/CollectionPageClient"
)
).default;
return <CollectionPageClient params={params} />;
}

View File

@@ -0,0 +1,91 @@
"use client";
import { useFavorites } from "@/lib/hooks";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "next-intl";
import ProductCard from "@/features/home/components/ProductCard";
import type { Favorite } from "@/lib/types/api";
import EmptyFavorites from "@/features/favorites/components/EmptyFavorites";
import ErrorPage from "@/components/ErrorPage";
import Placeholder from "@/public/logo.webp";
export default function FavoritesPage() {
const t = useTranslations();
const { data: favorites, isLoading, isError } = useFavorites();
if (isLoading) {
return (
<div className="mx-auto px-2 md:px-4 lg:px-6 pb-12 space-y-8 max-w-[1504px]">
<h1 className="bg-white text-3xl p-4 font-bold mb-0 pb-6">
{t("favorite_products")}
</h1>
<div className="bg-white grid grid-cols-2 sm:grid-cols-3 rounded-b-lg md:grid-cols-4 lg:grid-cols-5 gap-3 p-4">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="w-full h-[260px] rounded-xl" />
<Skeleton className="h-4 w-3/4 mx-2" />
<Skeleton className="h-6 w-1/2 mx-2" />
</div>
))}
</div>
</div>
);
}
if (isError) {
return <ErrorPage />;
}
if (!favorites || favorites.length === 0) {
return <EmptyFavorites />;
}
return (
<div
className=" mx-auto px-2 md:px-4 lg:px-6 pb-12 space-y-8 max-w-[1504px]
"
>
<h1 className="bg-white text-3xl p-4 font-bold mb-0 pb-6">
{t("favorite_products")}
</h1>
<div className="bg-white grid grid-cols-2 sm:grid-cols-3 rounded-b-lg md:grid-cols-4 lg:grid-cols-5 gap-3 p-4">
{favorites.map((favorite: Favorite) => {
const product = favorite.product;
const allImages = product.media
?.map(
(media) =>
media.images_800x800 ||
media.images_720x720 ||
media.images_400x400 ||
media.thumbnail
)
.filter(Boolean) || [Placeholder];
const formattedPrice = product.price_amount
? `${parseFloat(product.price_amount).toFixed(2)} TMT`
: "Price not available";
return (
<ProductCard
key={product.id}
id={product.id}
name={product.name}
price={
product.price_amount ? parseFloat(product.price_amount) : null
}
struct_price_text={formattedPrice}
images={allImages}
labels={[]}
price_color="#0059ff"
height={360}
width={250}
button={false}
stock={product.stock}
/>
);
})}
</div>
</div>
);
}

236
app/[locale]/globals.css Normal file
View File

@@ -0,0 +1,236 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: #eff3f6;
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
[data-sonner-toast] [data-description] {
color: #000 !important;
opacity: 0.9;
}
@layer utilities {
.text-fg {
color: var(--fg);
}
.bg-bg {
background-color: var(--bg);
}
.stroke-primary {
stroke: #005bff;
}
.stroke-track {
stroke: hsla(var(--hue), 10%, 10%, 0.1);
transition: stroke var(--trans-dur);
}
@media (prefers-color-scheme: dark) {
.stroke-track {
stroke: hsla(var(--hue), 10%, 90%, 0.1);
}
}
.animate-msg {
animation: msg 0.3s 13.7s linear forwards;
}
.animate-msgLast {
animation: msg 0.3s 14s linear reverse forwards;
}
.animate-cartLines {
animation: cartLines 2s ease-in-out infinite;
}
.animate-cartTop {
animation: cartTop 2s ease-in-out infinite;
}
.animate-cartWheel1 {
animation: cartWheel1 2s ease-in-out infinite;
transform: rotate(-0.25turn);
transform-origin: 43px 111px;
}
.animate-cartWheel2 {
animation: cartWheel2 2s ease-in-out infinite;
transform: rotate(0.25turn);
transform-origin: 102px 111px;
}
.animate-cartWheelStroke {
animation: cartWheelStroke 2s ease-in-out infinite;
}
}
@keyframes msg {
from {
opacity: 1;
visibility: visible;
}
99.9% {
opacity: 0;
visibility: visible;
}
to {
opacity: 0;
visibility: hidden;
}
}
@keyframes cartLines {
from,
to {
opacity: 0;
}
8%,
92% {
opacity: 1;
}
}
@keyframes cartTop {
from {
stroke-dashoffset: -338;
}
50% {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: 338;
}
}
@keyframes cartWheel1 {
from {
transform: rotate(-0.25turn);
}
to {
transform: rotate(2.75turn);
}
}
@keyframes cartWheel2 {
from {
transform: rotate(0.25turn);
}
to {
transform: rotate(3.25turn);
}
}
@keyframes cartWheelStroke {
from,
to {
stroke-dashoffset: 81.68;
}
50% {
stroke-dashoffset: 40.84;
}
}

67
app/[locale]/layout.tsx Normal file
View File

@@ -0,0 +1,67 @@
import type React from "react"
import type { Metadata } from "next"
import { Geist, Geist_Mono } from "next/font/google"
import { notFound } from "next/navigation"
import { NextIntlClientProvider } from "next-intl"
import "./globals.css"
import Header from "@/components/layout/Header"
import MobileBottomNav from "@/components/layout/MobileBar"
import { Toaster } from "@/components/ui/sonner"
import { Providers } from "@/context/Provider"
import AuthWrapper from "@/context/AuthWrapper"
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
})
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
})
export const metadata: Metadata = {
title: "Postshop",
description: "E-commerce platform",
}
type Props = {
children: React.ReactNode
params: Promise<{ locale: string }>
}
const locales = ["ru", "tm"]
export function generateStaticParams() {
return locales.map((locale) => ({ locale }))
}
export default async function RootLayout({ children, params }: Props) {
const { locale } = await params
if (!locales.includes(locale)) notFound()
let messages
try {
messages = (await import(`../../i18n/messages/${locale}.json`)).default
} catch {
messages = {}
}
return (
<html lang={locale} suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<Providers>
<NextIntlClientProvider locale={locale} messages={messages}>
<AuthWrapper locale={locale}>
<Header locale={locale} />
{children}
<MobileBottomNav locale={locale} />
<Toaster />
</AuthWrapper>
</NextIntlClientProvider>
</Providers>
</body>
</html>
)
}

16
app/[locale]/me/page.tsx Normal file
View File

@@ -0,0 +1,16 @@
import type { Metadata } from "next"
import ClientProfilePage from "../../../features/profile/components/ProfilePageContent"
export const metadata: Metadata = {
title: "My Profile | E-Commerce",
description: "Manage your profile settings",
robots: "noindex, nofollow", // Private page
}
export default function ProfilePage({
params,
}: {
params: Promise<{ locale: string }>
}) {
return <ClientProfilePage params={params} />
}

View File

@@ -0,0 +1,280 @@
"use client";
import type React from "react";
import { useState } from "react";
import { Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useOpenStore } from "@/lib/hooks";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
interface OpenStorePageProps {
locale?: string;
translations?: {
title: string;
firstName: string;
lastName: string;
email: string;
phone: string;
uploadPatent: string;
submit: string;
selectedFile: string;
firstNameRequired: string;
lastNameRequired: string;
emailInvalid: string;
phoneInvalid: string;
fileRequired: string;
fileSizeError: string;
fileTypeError: string;
};
}
interface FormData {
firstName: string;
lastName: string;
email: string;
phone: string;
file: File | null;
}
interface FormErrors {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
file?: string;
}
export default function OpenStorePage({}: OpenStorePageProps) {
const [formData, setFormData] = useState<FormData>({
firstName: "",
lastName: "",
email: "",
phone: "+993",
file: null,
});
const [errors, setErrors] = useState<FormErrors>({});
const [fileName, setFileName] = useState("");
const { mutate: submitOpenStore, isPending: loading } = useOpenStore();
const t = useTranslations();
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
if (!formData.firstName.trim()) {
newErrors.firstName = t("requiredField");
}
if (!formData.lastName.trim()) {
newErrors.lastName = t("requiredField");
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email)) {
newErrors.email = t("requiredField");
}
const phoneRegex = /^\+?[0-9]{6,15}$/;
if (!phoneRegex.test(formData.phone)) {
newErrors.phone = t("requiredField");
}
if (!formData.file) {
newErrors.file = t("fileRequired");
} else {
const allowedTypes = ["image/jpeg", "image/jpg", "application/pdf"];
if (!allowedTypes.includes(formData.file.type)) {
newErrors.file = t("fileTypeError");
}
if (formData.file.size > 25 * 1024 * 1024) {
newErrors.file = t("fileSizeError");
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
if (errors[name as keyof FormErrors]) {
setErrors((prev) => ({ ...prev, [name]: undefined }));
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setFormData((prev) => ({ ...prev, file }));
setFileName(file.name);
if (errors.file) {
setErrors((prev) => ({ ...prev, file: undefined }));
}
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
if (formData.file) {
submitOpenStore(
{
firstName: formData.firstName,
lastName: formData.lastName,
email: formData.email,
phone: formData.phone,
patentFile: formData.file,
},
{
onSuccess: () => {
toast.success(t("submit_success"));
setFormData({
firstName: "",
lastName: "",
email: "",
phone: "+993",
file: null,
});
setFileName("");
},
onError: (error: any) => {
toast.error(error?.message || t("submit_error"));
},
}
);
}
};
return (
<div className=" bg-gray-50 flex items-center justify-center p-4">
<Card className="w-full max-w-md shadow-lg">
<CardHeader>
<CardTitle className="text-2xl text-center">{t("title")}</CardTitle>
<CardDescription className="text-center">
Заполните форму для подачи заявления
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{/* First Name */}
<div className="space-y-2">
<Label htmlFor="firstName">{t("enter_first_name")}</Label>
<Input
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleInputChange}
className={errors.firstName ? "border-red-500" : ""}
/>
{errors.firstName && (
<p className="text-sm text-red-500">{errors.firstName}</p>
)}
</div>
{/* Last Name */}
<div className="space-y-2">
<Label htmlFor="lastName">{t("enter_last_name")}</Label>
<Input
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleInputChange}
className={errors.lastName ? "border-red-500" : ""}
/>
{errors.lastName && (
<p className="text-sm text-red-500">{errors.lastName}</p>
)}
</div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor="email">{t("enter_email")}</Label>
<Input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleInputChange}
className={errors.email ? "border-red-500" : ""}
/>
{errors.email && (
<p className="text-sm text-red-500">{errors.email}</p>
)}
</div>
{/* Phone */}
<div className="space-y-2">
<Label htmlFor="phone">{t("enter_phone")}</Label>
<Input
id="phone"
name="phone"
value={formData.phone}
onChange={handleInputChange}
placeholder="+99361111111"
className={errors.phone ? "border-red-500" : ""}
/>
{errors.phone && (
<p className="text-sm text-red-500">{errors.phone}</p>
)}
</div>
{/* File Upload */}
<div className="space-y-2">
<Label htmlFor="file">{t("uploadPatent")}</Label>
<div className="flex flex-col gap-2">
<Input
id="file"
type="file"
accept=".pdf,.jpg,.jpeg"
onChange={handleFileChange}
className="hidden"
/>
<Button
type="button"
variant="outline"
className="w-full bg-transparent cursor-pointer"
onClick={() => document.getElementById("file")?.click()}
>
<Upload className="mr-2 h-4 w-4" />
{t("uploadPatent")}
</Button>
{fileName && (
<p className="text-sm text-gray-600">
{t("selectedFile")}: {fileName}
</p>
)}
{errors.file && (
<p className="text-sm text-red-500">{errors.file}</p>
)}
</div>
</div>
{/* Submit Button */}
<Button
type="submit"
className="w-full cursor-pointer bg-[#005bff] hover:bg-[#0041c4]"
disabled={loading}
>
{loading ? t("submitting") : t("submit")}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import type { Metadata, ResolvingMetadata } from "next";
import OrdersPageClient from "../../../features/orders/components/OrderPage";
const metadataContent = {
tm: {
title: "Meniň Sargytlarym | Post shop",
description: "Sargytlaryňyzy görüň",
},
ru: {
title: "Мои Заказы | Пост-магазин",
description: "Просмотр истории заказов",
},
} as const;
interface PageProps {
params: Promise<{
locale: string;
}>;
}
export async function generateMetadata(
{ params }: PageProps,
parent: ResolvingMetadata
): Promise<Metadata> {
const { locale } = await params;
const localeKey = locale as keyof typeof metadataContent;
const content = metadataContent[localeKey] || metadataContent.ru;
return {
title: content.title,
description: content.description,
robots: {
index: false,
follow: false,
nocache: true,
},
};
}
export default async function OrdersPage({ params }: PageProps) {
const { locale } = await params;
return <OrdersPageClient locale={locale} />;
}

33
app/[locale]/page.tsx Normal file
View File

@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import HomePage from "@/features/home/components/HomePage";
const META = {
ru: {
title: "Интернет магазин - Лучшие товары по низким ценам",
description: "Качественные товары с быстрой доставкой по всей стране",
},
tm: {
title: "Post shop - Iň gowy harytlar, amatly bahada",
description:
"Ýokary hilli harytlar. Elektronika, eşik, arassaçylyk, sport, kosmetika",
},
} as const;
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const { title, description } = META[locale as keyof typeof META] || META.ru;
return {
title,
description,
openGraph: { type: "website", locale, title, description },
};
}
export default function Page() {
return <HomePage />;
}

View File

@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ProductPageContent from "../../../../features/products/components/ProductPageContent";
type Props = {
params: { locale: string; slug: string };
};
export const revalidate = 3600; // ISR: Revalidate every hour
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale, slug } = await params;
return {
title: `Product ${slug} | E-Commerce`,
description: `View details for product ${slug}`,
openGraph: {
locale,
type: "website",
title: `Product ${slug} | E-Commerce`,
description: `View details for product ${slug}`,
},
};
}
export async function generateStaticParams() {
return [{ slug: "nike-air-max" }, { slug: "adidas-ultraboost" }];
}
export default async function ProductPage(props: Props) {
const params = await props.params;
if (!params.slug) {
notFound();
}
return <ProductPageContent slug={params.slug} />;
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB