first commit
This commit is contained in:
261
app/[locale]/cart/page.tsx
Normal file
261
app/[locale]/cart/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
app/[locale]/category/[slug]/page.tsx
Normal file
52
app/[locale]/category/[slug]/page.tsx
Normal 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} />;
|
||||
}
|
||||
64
app/[locale]/collections/[slug]/page.tsx
Normal file
64
app/[locale]/collections/[slug]/page.tsx
Normal 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} />;
|
||||
}
|
||||
91
app/[locale]/favorites/page.tsx
Normal file
91
app/[locale]/favorites/page.tsx
Normal 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
236
app/[locale]/globals.css
Normal 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
67
app/[locale]/layout.tsx
Normal 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
16
app/[locale]/me/page.tsx
Normal 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} />
|
||||
}
|
||||
280
app/[locale]/openStore/page.tsx
Normal file
280
app/[locale]/openStore/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
app/[locale]/orders/page.tsx
Normal file
43
app/[locale]/orders/page.tsx
Normal 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
33
app/[locale]/page.tsx
Normal 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 />;
|
||||
}
|
||||
37
app/[locale]/product/[slug]/page.tsx
Normal file
37
app/[locale]/product/[slug]/page.tsx
Normal 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
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Reference in New Issue
Block a user