From dc4467d43de6217037f0a24e5d5d6c8f8b6a8308 Mon Sep 17 00:00:00 2001 From: payacom <59262811+payacom@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:20:08 +0330 Subject: [PATCH] shop --- src/app/settings/chats/page.tsx | 35 + .../chats/shop/[shopId]/[buyerId]/page.tsx | 171 +++++ .../settings/edit/instagram-import/page.tsx | 2 +- src/app/settings/shop/list/page.tsx | 100 +++ src/app/settings/shop/page.tsx | 136 ++-- src/app/shops/[shopId]/page.tsx | 189 +++++- .../[shopId]/products/[listingId]/page.tsx | 433 +++++++++++++ .../[shopId]/products/new/category/page.tsx | 137 ++++ .../[shopId]/products/new/images/page.tsx | 64 +- .../shops/[shopId]/products/new/name/page.tsx | 38 +- .../[shopId]/products/new/variants/page.tsx | 198 ++++-- src/app/shops/listing/[listingId]/page.tsx | 547 +++++++++++----- src/app/shops/new/category/page.tsx | 104 --- src/app/shops/new/contact/page.tsx | 244 ++++--- src/app/shops/new/location/page.tsx | 47 +- src/app/shops/new/logo/page.tsx | 19 +- src/app/shops/new/name/page.tsx | 57 +- src/app/shops/new/preview/page.tsx | 264 ++++++-- src/app/shops/new/shipping/page.tsx | 202 ++++-- src/app/shops/orders/[orderId]/page.tsx | 609 +++++++++++------- src/app/shops/profile/[shopId]/page.tsx | 384 +++++++++++ .../[shopId]/reel/[listingId]/page.tsx | 10 + src/components/auth/AuthPageLayout.tsx | 2 +- src/components/chat/ChatMessageCard.tsx | 13 + src/components/chat/ForwardMessageModal.tsx | 4 +- src/components/chat/SharedProductBubble.tsx | 59 ++ src/components/posts/SendPostModal.tsx | 4 +- .../shops/ProductCategoryPicker.tsx | 115 ++++ src/components/shops/ProductImageLightbox.tsx | 81 +++ src/components/shops/SendProductModal.tsx | 210 ++++++ src/components/shops/ShareProductModal.tsx | 115 ++++ src/components/shops/ShareShopModal.tsx | 139 ++++ src/components/shops/ShopCategoryPicker.tsx | 25 +- src/components/shops/ShopContactInfoModal.tsx | 83 +++ src/components/shops/ShopListCard.tsx | 96 +++ src/components/shops/ShopMyOrdersModal.tsx | 56 ++ .../shops/ShopProductCommentsModal.tsx | 192 ++++++ src/components/shops/ShopProductListRow.tsx | 105 +++ src/components/shops/ShopReelsView.tsx | 283 ++++++++ src/components/shops/TimeSelect.tsx | 31 + src/components/shops/ToggleSwitch.tsx | 33 + .../shops/orders/BuyerInfoModal.tsx | 145 +++++ .../shops/orders/BuyerOrderItem.tsx | 6 + .../shops/orders/SellerOrderItem.tsx | 6 + src/components/shops/orders/ShopInfoModal.tsx | 149 +++++ src/components/stories/SendStoryModal.tsx | 4 +- src/hooks/useAxios.tsx | 20 +- src/hooks/useInfiniteScroll.tsx | 6 +- src/hooks/useProductWizardId.ts | 6 + src/hooks/useShopWizardId.ts | 23 +- src/lib/chat/getCopyableMessageText.ts | 6 + src/lib/shops/localCart.ts | 63 ++ src/locales/en/common.json | 135 +++- src/locales/fa/common.json | 135 +++- src/types/types.ts | 9 + 55 files changed, 5429 insertions(+), 920 deletions(-) create mode 100644 src/app/settings/chats/shop/[shopId]/[buyerId]/page.tsx create mode 100644 src/app/settings/shop/list/page.tsx create mode 100644 src/app/shops/[shopId]/products/[listingId]/page.tsx create mode 100644 src/app/shops/[shopId]/products/new/category/page.tsx delete mode 100644 src/app/shops/new/category/page.tsx create mode 100644 src/app/shops/profile/[shopId]/page.tsx create mode 100644 src/app/shops/profile/[shopId]/reel/[listingId]/page.tsx create mode 100644 src/components/chat/SharedProductBubble.tsx create mode 100644 src/components/shops/ProductCategoryPicker.tsx create mode 100644 src/components/shops/ProductImageLightbox.tsx create mode 100644 src/components/shops/SendProductModal.tsx create mode 100644 src/components/shops/ShareProductModal.tsx create mode 100644 src/components/shops/ShareShopModal.tsx create mode 100644 src/components/shops/ShopContactInfoModal.tsx create mode 100644 src/components/shops/ShopListCard.tsx create mode 100644 src/components/shops/ShopMyOrdersModal.tsx create mode 100644 src/components/shops/ShopProductCommentsModal.tsx create mode 100644 src/components/shops/ShopProductListRow.tsx create mode 100644 src/components/shops/ShopReelsView.tsx create mode 100644 src/components/shops/TimeSelect.tsx create mode 100644 src/components/shops/ToggleSwitch.tsx create mode 100644 src/components/shops/orders/BuyerInfoModal.tsx create mode 100644 src/components/shops/orders/ShopInfoModal.tsx create mode 100644 src/lib/shops/localCart.ts diff --git a/src/app/settings/chats/page.tsx b/src/app/settings/chats/page.tsx index f70fb98..13105b4 100644 --- a/src/app/settings/chats/page.tsx +++ b/src/app/settings/chats/page.tsx @@ -37,6 +37,9 @@ export interface IMessage { blocked_you: boolean | null; display_name?: string; account_deactivated?: boolean; + chat_type?: "user" | "shop"; + shop_id?: string; + buyer_id?: string; } function Chats() { @@ -181,6 +184,38 @@ function Chats() { ) : ( data?.pages.map((page) => page.filteredUsersData?.map((item: IMessage) => { + if (item.chat_type === "shop") { + return ( + +
+ +
+ + {item.display_name} + +
+
+
+ {item.unread_messages_count ? ( + + {item.unread_messages_count} + + ) : null} +
+ + ); + } + const isOnline = isUserOnline(item.last_online, onlineLabel); const storyMeta = storyByUserId.get(String(item._id)) ?? diff --git a/src/app/settings/chats/shop/[shopId]/[buyerId]/page.tsx b/src/app/settings/chats/shop/[shopId]/[buyerId]/page.tsx new file mode 100644 index 0000000..eac601d --- /dev/null +++ b/src/app/settings/chats/shop/[shopId]/[buyerId]/page.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import Container from "@/components/elements/Container"; +import Header from "@/components/main/Header"; +import ProfileAvatar from "@/components/main/ProfileAvatar"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import BoldIcon from "@/components/ui/BoldIcon"; +import IOSSpinner from "@/components/ui/IOSSpinner"; +import useAxios from "@/hooks/useAxios"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; + +type ShopChatMessage = { + _id: string; + senderRole: "shop" | "buyer"; + content: string; + createdAt: string; +}; + +type Counterpart = { + name: string; + logo?: string | null; +}; + +export default function ShopChatPage() { + const { t } = useTranslation("common"); + const router = useRouter(); + const params = useParams<{ shopId: string; buyerId: string }>(); + const { shopId, buyerId } = params; + const { request } = useAxios(); + + const [messages, setMessages] = useState(null); + const [counterpart, setCounterpart] = useState(null); + const [isOwner, setIsOwner] = useState(false); + const [text, setText] = useState(""); + const [sending, setSending] = useState(false); + const bottomRef = useRef(null); + + const loadMessages = () => { + request<{ messages: ShopChatMessage[]; counterpart: Counterpart; isOwner: boolean }>( + "GET", + `/shop-chat/${shopId}/messages?buyerId=${buyerId}`, + null, + { noToast: true } + ) + .then((res) => { + setMessages(res?.messages || []); + setCounterpart(res?.counterpart || null); + setIsOwner(Boolean(res?.isOwner)); + }) + .catch(() => setMessages([])); + }; + + useEffect(() => { + if (!shopId || !buyerId) return; + loadMessages(); + const id = window.setInterval(loadMessages, 8000); + return () => window.clearInterval(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shopId, buyerId]); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const handleSend = async () => { + if (!text.trim() || sending) return; + setSending(true); + try { + const res = await request<{ message: ShopChatMessage }>( + "POST", + `/shop-chat/${shopId}/messages`, + { buyerId, content: text.trim() } + ); + if (res?.message) { + setMessages((prev) => [...(prev || []), res.message]); + } + setText(""); + } finally { + setSending(false); + } + }; + + const myRole = isOwner ? "shop" : "buyer"; + + return ( + <> +
+ + +
+
+ + + + {counterpart?.name} + +
+ +
+ {messages === null ? ( +
+ +
+ ) : messages.length === 0 ? ( +

+ {t("shops.noChatMessagesYet")} +

+ ) : ( +
+ {messages.map((m) => { + const mine = m.senderRole === myRole; + return ( +
+ {m.content} +
+ ); + })} +
+
+ )} +
+ +
+ setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handleSend(); + }} + placeholder={t("chats.placeholder.message")} + className="flex-1 rounded-full border border-neutral-300 bg-transparent px-4 py-2 text-sm outline-none dark:border-neutral-600" + /> + +
+
+ + + + ); +} diff --git a/src/app/settings/edit/instagram-import/page.tsx b/src/app/settings/edit/instagram-import/page.tsx index 9ee11d3..0a6b7f0 100644 --- a/src/app/settings/edit/instagram-import/page.tsx +++ b/src/app/settings/edit/instagram-import/page.tsx @@ -59,7 +59,7 @@ export default function InstagramImportSettingsPage() { const handleDisconnect = async () => { setDisconnecting(true); try { - await request("POST", "/auth/instagram/disconnect"); + await request("POST", "/auth/instagram/disconnect", {}); toast.success(t("settings.edit.instagramImport.disconnectSuccess")); await loadStatus(); } catch { diff --git a/src/app/settings/shop/list/page.tsx b/src/app/settings/shop/list/page.tsx new file mode 100644 index 0000000..331830f --- /dev/null +++ b/src/app/settings/shop/list/page.tsx @@ -0,0 +1,100 @@ +"use client"; + +import Container from "@/components/elements/Container"; +import RoundedButton from "@/components/elements/RoundedButton"; +import PageTitle from "@/components/settings/PageTitle"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import Header from "@/components/main/Header"; +import ShopListCard from "@/components/shops/ShopListCard"; +import BoldIcon from "@/components/ui/BoldIcon"; +import useAxios from "@/hooks/useAxios"; +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +type MyShop = { + _id: string; + name: string; + logo?: string | null; + status: string; + address?: string | null; + has_physical_location?: boolean; + productCount?: number; + activeProductCount?: number; + soldCount?: number; +}; + +export default function ShopSettingsPage() { + const { t } = useTranslation("common"); + const router = useRouter(); + const { request } = useAxios(); + const [myShops, setMyShops] = useState(null); + const [search, setSearch] = useState(""); + + useEffect(() => { + request<{ shops: MyShop[] }>("GET", "/shops/mine") + .then((res) => setMyShops(res?.shops || [])) + .catch(() => setMyShops([])); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const filteredShops = useMemo(() => { + if (!myShops) return []; + const query = search.trim(); + if (!query) return myShops; + return myShops.filter((shop) => shop.name.includes(query)); + }, [myShops, search]); + + return ( + <> +
+ + + {t("shops.myShopsTitle")} + +
+
+ setSearch(e.target.value)} + placeholder={t("shops.searchShopPlaceholder")} + className="w-full rounded-full border border-neutral-200 bg-white py-2.5 pl-4 pr-10 text-sm outline-none dark:border-neutral-700 dark:bg-neutral-900" + /> + +
+ +
+ {myShops === null ? ( +

+ {t("common.loading")} +

+ ) : filteredShops.length === 0 ? ( +

+ {t("shops.noShopsYet")} +

+ ) : ( + filteredShops.map((shop) => ) + )} +
+ + router.push("/shops/new/name")} + className="mx-auto mt-6 flex h-11 w-full max-w-xs items-center justify-center gap-2" + > + + {t("shops.addShopNew")} + +
+
+
+ + ); +} diff --git a/src/app/settings/shop/page.tsx b/src/app/settings/shop/page.tsx index 01e3cd8..46a34b2 100644 --- a/src/app/settings/shop/page.tsx +++ b/src/app/settings/shop/page.tsx @@ -2,16 +2,18 @@ import Container from "@/components/elements/Container"; import RoundedButton from "@/components/elements/RoundedButton"; +import RoundedInput from "@/components/elements/RoundedInput"; import PageTitle from "@/components/settings/PageTitle"; import UserDetails from "@/components/settings/UserDetails"; import LocalePageShell from "@/components/i18n/LocalePageShell"; +import Header from "@/components/main/Header"; import SellerOrderItem from "@/components/shops/orders/SellerOrderItem"; import BuyerOrderItem from "@/components/shops/orders/BuyerOrderItem"; import useAxios from "@/hooks/useAxios"; import useInfiniteScroll from "@/hooks/useInfiniteScroll"; import { toggleBtnClass } from "@/lib/ui/buttonStyles"; import { cn } from "@/lib/utils"; -import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl"; +import { staticIconUrl } from "@/components/main/BaseUrl"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; @@ -31,18 +33,19 @@ export default function ShopSettingsPage() { const { t } = useTranslation("common"); const router = useRouter(); const { request } = useAxios(); - const [filter, setFilter] = useState(FILTER_SELLER); + const [filter, setFilter] = useState(FILTER_BUYER); const [myShops, setMyShops] = useState(null); - const [showSwitcher, setShowSwitcher] = useState(false); - - const loadShops = () => { - request<{ shops: MyShop[] }>("GET", "/shops/mine") - .then((res) => setMyShops(res?.shops || [])) - .catch(() => setMyShops([])); - }; + const [searchText, setSearchText] = useState(""); + const [search, setSearch] = useState(""); useEffect(() => { - loadShops(); + request<{ shops: MyShop[] }>("GET", "/shops/mine") + .then((res) => { + const shops = res?.shops || []; + setMyShops(shops); + if (shops.length > 0) setFilter(FILTER_SELLER); + }) + .catch(() => setMyShops([])); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -59,8 +62,9 @@ export default function ShopSettingsPage() { const { data: sellerOrdersData, isLoading: sellerOrdersLoading } = useInfiniteScroll({ endpoint: "/orders", - queryKey: ["seller-orders", primaryShopId || ""], - params: primaryShopId ? { role: "seller", shopId: primaryShopId } : undefined, + queryKey: ["seller-orders", primaryShopId || "pending", search], + params: { role: "seller", shopId: primaryShopId || "", ...(search ? { q: search } : {}) }, + enabled: Boolean(primaryShopId), }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const sellerOrders: any[] = @@ -70,8 +74,8 @@ export default function ShopSettingsPage() { const { data: buyerOrdersData, isLoading: buyerOrdersLoading } = useInfiniteScroll({ endpoint: "/orders", - queryKey: ["buyer-orders"], - params: { role: "buyer" }, + queryKey: ["buyer-orders", search], + params: { role: "buyer", ...(search ? { q: search } : {}) }, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const buyerOrders: any[] = @@ -80,71 +84,56 @@ export default function ShopSettingsPage() { : []; return ( - + <> +
+
- + {t("settings.nav.shop")} - + {filter === FILTER_SELLER ? ( +
+ + +
+ ) : ( + + )}
- {showSwitcher && ( -
- {myShops === null ? ( -

- {t("common.loading")} -

- ) : myShops.length === 0 ? ( -

- {t("shops.noShopsYet")} -

- ) : ( - myShops.map((shop) => ( -
- {shop.logo && ( - // eslint-disable-next-line @next/next/no-img-element - {shop.name} - )} - {shop.name} - - {t(`shops.status.${shop.status}`)} - -
- )) - )} -
- )} -
-
+
+ setSearchText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") setSearch(searchText); + }} + placeholder={t("shops.searchOrderPlaceholder")} + /> +
+
setFilter(FILTER_SELLER)} @@ -186,6 +175,7 @@ export default function ShopSettingsPage() {
- + + ); } diff --git a/src/app/shops/[shopId]/page.tsx b/src/app/shops/[shopId]/page.tsx index 690abe1..a01c5be 100644 --- a/src/app/shops/[shopId]/page.tsx +++ b/src/app/shops/[shopId]/page.tsx @@ -1,31 +1,41 @@ "use client"; import Container from "@/components/elements/Container"; +import RoundedButton from "@/components/elements/RoundedButton"; import LocalePageShell from "@/components/i18n/LocalePageShell"; -import PageTitle from "@/components/settings/PageTitle"; -import ShopProductGrid from "@/components/shops/ShopProductGrid"; +import Header from "@/components/main/Header"; +import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl"; +import ShareShopModal from "@/components/shops/ShareShopModal"; +import ShopProductListRow from "@/components/shops/ShopProductListRow"; +import BoldIcon from "@/components/ui/BoldIcon"; +import TabNavigation from "@/components/TabNavigation"; import useAxios from "@/hooks/useAxios"; import useInfiniteScroll from "@/hooks/useInfiniteScroll"; +import { saveShopWizardId } from "@/hooks/useShopWizardId"; +import { cn } from "@/lib/utils"; import { IShopProductListing } from "@/types/types"; import Image from "next/image"; import Link from "next/link"; -import { useParams } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; type ShopHeader = { _id: string; name: string; logo?: string | null; - status: string; }; export default function ShopProductsPage() { const { t } = useTranslation("common"); + const router = useRouter(); const params = useParams<{ shopId: string }>(); const shopId = params.shopId; const { request } = useAxios(); const [shop, setShop] = useState(null); + const [search, setSearch] = useState(""); + const [onlyInStock, setOnlyInStock] = useState(false); + const [showShareModal, setShowShareModal] = useState(false); useEffect(() => { request<{ shop: ShopHeader }>("GET", `/shops/${shopId}`) @@ -43,32 +53,151 @@ export default function ShopProductsPage() { const listings: IShopProductListing[] = data?.pages.flatMap((page) => page.docs || []) || []; - return ( - - -
- {shop?.name || t("settings.nav.shop")} - - - -
+ const filteredListings = useMemo(() => { + let result = listings; + const query = search.trim(); + if (query) { + result = result.filter((listing) => listing.title.includes(query)); + } + if (onlyInStock) { + result = result.filter((listing) => + listing.variants?.some((v) => v.stock > 0) + ); + } + return result; + }, [listings, search, onlyInStock]); - +
+ + +
+
+ + {shopId && ( + + {shop?.logo ? ( + // eslint-disable-next-line @next/next/no-img-element + {shop.name} + ) : ( + + )} + + )} +
+ + {shop?.name} +
+ +

+ {t("shops.myProductsTitle")} +

+ +
+
+
+ setSearch(e.target.value)} + placeholder={t("shops.searchProductPlaceholder")} + className="w-full rounded-full border border-neutral-200 bg-white py-2.5 pl-4 pr-10 text-sm outline-none dark:border-neutral-700 dark:bg-neutral-900" + /> + +
+ +
+ +
+ {isLoading ? ( +

+ {t("common.loading")} +

+ ) : filteredListings.length === 0 ? ( +

+ {t("shops.noProductsYet")} +

+ ) : ( + filteredListings.map((listing) => ( + + )) + )} +
+ +
+ router.push(`/shops/${shopId}/products/new/name`)} + className="flex h-11 flex-1 items-center justify-center gap-2" + > + + {t("shops.addProductNew")} + + { + saveShopWizardId(shopId); + router.push("/shops/new/name?edit=1"); + }} + className="flex h-11 flex-1 items-center justify-center gap-2" + > + {t("shops.editShopButton")} + +
+
+
+
+ + + {shop && ( + setShowShareModal(false)} + shopId={shop._id} + shopName={shop.name} + shopLogo={shop.logo} /> - - + )} + ); } diff --git a/src/app/shops/[shopId]/products/[listingId]/page.tsx b/src/app/shops/[shopId]/products/[listingId]/page.tsx new file mode 100644 index 0000000..095d765 --- /dev/null +++ b/src/app/shops/[shopId]/products/[listingId]/page.tsx @@ -0,0 +1,433 @@ +"use client"; + +import Container from "@/components/elements/Container"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import Header from "@/components/main/Header"; +import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl"; +import BoldIcon from "@/components/ui/BoldIcon"; +import TabNavigation from "@/components/TabNavigation"; +import useAxios from "@/hooks/useAxios"; +import { saveProductWizardId } from "@/hooks/useProductWizardId"; +import { cn } from "@/lib/utils"; +import { IShopProductListing, IShopProductVariant } from "@/types/types"; +import { useQueryClient } from "@tanstack/react-query"; +import Image from "next/image"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; + +type ShopHeader = { _id: string; name: string; logo?: string | null }; + +function uniqueValues(values: (string | null | undefined)[]): string[] { + return Array.from(new Set(values.filter((v): v is string => Boolean(v)))); +} + +function SpecRow({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ {label} +
+ {children} +
+ +
+ ); +} + +export default function ProductManagePage() { + const { t } = useTranslation("common"); + const router = useRouter(); + const params = useParams<{ shopId: string; listingId: string }>(); + const { shopId, listingId } = params; + const { request } = useAxios(); + const queryClient = useQueryClient(); + + const [listing, setListing] = useState(null); + const [selectedColor, setSelectedColor] = useState(null); + const [selectedSize, setSelectedSize] = useState(null); + const [selectedWeight, setSelectedWeight] = useState(null); + const [savingStock, setSavingStock] = useState(false); + const [savingStatus, setSavingStatus] = useState(false); + const [activeImageIndex, setActiveImageIndex] = useState(0); + const [descriptionExpanded, setDescriptionExpanded] = useState(false); + + useEffect(() => { + request<{ listing: IShopProductListing }>("GET", `/shop-products/${listingId}`) + .then((res) => { + const data = res?.listing || null; + setListing(data); + const firstVariant = data?.variants?.[0]; + if (firstVariant) { + setSelectedColor(firstVariant.color || null); + setSelectedSize(firstVariant.size || null); + setSelectedWeight(firstVariant.weight || null); + } + }) + .catch(() => setListing(null)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [listingId]); + + const shopInfo: ShopHeader | null = + listing && typeof listing.shop === "object" ? (listing.shop as ShopHeader) : null; + + const colors = useMemo( + () => uniqueValues(listing?.variants.map((v) => v.color) || []), + [listing] + ); + + /** Sizes narrow down to whichever color is currently selected. */ + const sizes = useMemo(() => { + if (!listing) return []; + const relevant = listing.variants.filter( + (v) => colors.length === 0 || v.color === selectedColor + ); + return uniqueValues(relevant.map((v) => v.size)); + }, [listing, colors, selectedColor]); + + /** Weights narrow down to whichever color + size are currently selected. */ + const weights = useMemo(() => { + if (!listing) return []; + const relevant = listing.variants.filter( + (v) => + (colors.length === 0 || v.color === selectedColor) && + (sizes.length === 0 || v.size === selectedSize) + ); + return uniqueValues(relevant.map((v) => v.weight)); + }, [listing, colors, sizes, selectedColor, selectedSize]); + + // Keep size/weight selections valid as the color (and then size) selection narrows them down. + useEffect(() => { + if (sizes.length > 0 && (!selectedSize || !sizes.includes(selectedSize))) { + setSelectedSize(sizes[0]); + } + }, [sizes, selectedSize]); + + useEffect(() => { + if (weights.length > 0 && (!selectedWeight || !weights.includes(selectedWeight))) { + setSelectedWeight(weights[0]); + } + }, [weights, selectedWeight]); + + const matchedVariant: IShopProductVariant | null = useMemo(() => { + if (!listing) return null; + return ( + listing.variants.find( + (v) => + (colors.length === 0 || v.color === selectedColor) && + (sizes.length === 0 || v.size === selectedSize) && + (weights.length === 0 || v.weight === selectedWeight) + ) || + listing.variants[0] || + null + ); + }, [listing, colors, sizes, weights, selectedColor, selectedSize, selectedWeight]); + + const categoryLine = listing + ? [listing.category, listing.sub_category, listing.sub_sub_category] + .filter(Boolean) + .join(" / ") + : ""; + + const discountPercent = + matchedVariant?.discount_price && matchedVariant.discount_price < matchedVariant.price + ? Math.round( + ((matchedVariant.price - matchedVariant.discount_price) / matchedVariant.price) * 100 + ) + : null; + + const adjustStock = async (delta: number) => { + if (!listing || !matchedVariant || savingStock) return; + const nextStock = Math.max(0, matchedVariant.stock + delta); + const updatedVariants = listing.variants.map((v) => + v._id === matchedVariant._id ? { ...v, stock: nextStock } : v + ); + + setSavingStock(true); + try { + await request("PATCH", `/shop-products/${listing._id}`, { + variants: updatedVariants.map((v) => ({ + color: v.color, + size: v.size, + weight: v.weight, + price: v.price, + discount_price: v.discount_price, + stock: v._id === matchedVariant._id ? nextStock : v.stock, + sku: v.sku, + })), + }); + setListing({ ...listing, variants: updatedVariants }); + queryClient.invalidateQueries({ queryKey: ["shop-products", shopId] }); + } catch { + toast.error(t("shops.unknownError")); + } finally { + setSavingStock(false); + } + }; + + const toggleActive = async () => { + if (!listing || savingStatus) return; + const nextStatus = listing.status === "inactive" ? "active" : "inactive"; + setSavingStatus(true); + try { + await request("PATCH", `/shop-products/${listing._id}`, { status: nextStatus }); + setListing({ ...listing, status: nextStatus }); + queryClient.invalidateQueries({ queryKey: ["shop-products", shopId] }); + toast.success( + nextStatus === "inactive" ? t("shops.productDeactivated") : t("shops.productActivated") + ); + } catch { + toast.error(t("shops.unknownError")); + } finally { + setSavingStatus(false); + } + }; + + const handleEdit = () => { + if (!listing) return; + saveProductWizardId(shopId, listing._id); + router.push(`/shops/${shopId}/products/new/name?edit=1`); + }; + + if (!listing) return null; + + const images = listing.images || []; + const isInactive = listing.status === "inactive"; + + const handleImageScroll = (event: React.UIEvent) => { + const el = event.currentTarget; + const index = Math.round(el.scrollLeft / el.clientWidth); + setActiveImageIndex(index); + }; + + return ( + <> +
+ + +
+ {shopInfo?.name} + + {shopInfo?.logo ? ( + // eslint-disable-next-line @next/next/no-img-element + {shopInfo.name} + ) : ( + + )} + +
+ +
+
+ {discountPercent != null && ( + + {t("shops.discountPercentLabel", { percent: discountPercent })} + + )} +

{listing.title}

+
+ + {images.length > 0 && ( +
+
+ {images.map((img) => ( +
+ {listing.title} +
+ ))} +
+ {images.length > 1 && ( + + {activeImageIndex + 1}/{images.length} + + )} +
+ )} + +

{t("shops.specsTitle")}

+ + {categoryLine && ( + + {categoryLine} + + )} + + {colors.length > 0 && ( + + {colors.map((color) => ( + + ))} + + )} + + {sizes.length > 0 && ( + + {sizes.map((size) => ( + + ))} + + )} + + {weights.length > 0 && ( + + {weights.map((weight) => ( + + ))} + + )} + + {listing.description && ( + + + {descriptionExpanded || listing.description.length <= 50 + ? listing.description + : `${listing.description.slice(0, 50)}...`} + {listing.description.length > 50 && ( + + )} + + + )} + +
+ + {t("shops.manageStockButton")} + + + + {matchedVariant?.stock ?? 0} + + +
+ + {matchedVariant && ( + + {matchedVariant.discount_price ? ( + + + {matchedVariant.price.toLocaleString()} {t("settings.toman")} + + + {matchedVariant.discount_price.toLocaleString()} {t("settings.toman")} + + + ) : ( + + {matchedVariant.price.toLocaleString()} {t("settings.toman")} + + )} + + )} +
+ +
+ + +
+
+
+ + + ); +} diff --git a/src/app/shops/[shopId]/products/new/category/page.tsx b/src/app/shops/[shopId]/products/new/category/page.tsx new file mode 100644 index 0000000..ea37294 --- /dev/null +++ b/src/app/shops/[shopId]/products/new/category/page.tsx @@ -0,0 +1,137 @@ +"use client"; + +import AuthPageLayout, { + AuthFormFooter, + AuthPageContent, +} from "@/components/auth/AuthPageLayout"; +import AuthNextButton from "@/components/auth/AuthNextButton"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import ProductCategoryPicker from "@/components/shops/ProductCategoryPicker"; +import useAxios from "@/hooks/useAxios"; +import { useProductWizardId } from "@/hooks/useProductWizardId"; +import { IShopCategory } from "@/types/types"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; + +function ProductCategoryPage() { + const { t } = useTranslation("common"); + const router = useRouter(); + const params = useParams<{ shopId: string }>(); + const shopId = params.shopId; + const { request, loading } = useAxios(); + const listingId = useProductWizardId(shopId); + + const [categoryList, setCategoryList] = useState(null); + const [category, setCategory] = useState(""); + const [subCategory, setSubCategory] = useState(""); + const [subSubCategory, setSubSubCategory] = useState(""); + + useEffect(() => { + request<{ categories: IShopCategory[] }>("GET", "/shop-categories") + .then((res) => setCategoryList(res?.categories || [])) + .catch(() => setCategoryList([])); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!listingId) return; + request<{ + listing: { + category?: string | null; + sub_category?: string | null; + sub_sub_category?: string | null; + }; + }>("GET", `/shop-products/${listingId}`).then((res) => { + if (!res?.listing) return; + if (res.listing.category) setCategory(res.listing.category); + if (res.listing.sub_category) setSubCategory(res.listing.sub_category); + if (res.listing.sub_sub_category) setSubSubCategory(res.listing.sub_sub_category); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [listingId]); + + const handleCategorySelect = (value: string) => { + setCategory(value); + setSubCategory(""); + setSubSubCategory(""); + }; + + const handleSubCategorySelect = (value: string) => { + setSubCategory(value); + setSubSubCategory(""); + }; + + const handleSubmit = async () => { + if (!listingId) return; + + if (!category) { + toast.error(t("shops.productCategoryRequired")); + return; + } + + const selectedCategory = categoryList?.find((item) => item.category === category); + const hasSubCategories = (selectedCategory?.sub_categories.length ?? 0) > 0; + if (hasSubCategories && !subCategory) { + toast.error(t("shops.productCategoryRequired")); + return; + } + + const selectedSubCategory = selectedCategory?.sub_categories.find( + (item) => item.name === subCategory + ); + const hasSubSubCategories = (selectedSubCategory?.sub_sub_categories?.length ?? 0) > 0; + if (hasSubSubCategories && !subSubCategory) { + toast.error(t("shops.productCategoryRequired")); + return; + } + + try { + await request("PATCH", `/shop-products/${listingId}`, { + category, + sub_category: subCategory, + sub_sub_category: subSubCategory, + }); + router.push(`/shops/${shopId}/products/new/images`); + } catch (err: unknown) { + const message = + (err as { response?: { data?: { message?: string } } })?.response + ?.data?.message || t("shops.unknownError"); + toast.error(message); + } + }; + + if (!listingId) return null; + + return ( + + + + {t("shops.productCategoryTitle")} + + + + + {t("shops.saveAndContinue")} + + + + + ); +} + +export default ProductCategoryPage; diff --git a/src/app/shops/[shopId]/products/new/images/page.tsx b/src/app/shops/[shopId]/products/new/images/page.tsx index 85eff0e..81efd85 100644 --- a/src/app/shops/[shopId]/products/new/images/page.tsx +++ b/src/app/shops/[shopId]/products/new/images/page.tsx @@ -6,16 +6,24 @@ import AuthPageLayout, { } from "@/components/auth/AuthPageLayout"; import AuthNextButton from "@/components/auth/AuthNextButton"; import LocalePageShell from "@/components/i18n/LocalePageShell"; +import { IMAGE_BASE_URL } from "@/components/main/BaseUrl"; +import BoldIcon from "@/components/ui/BoldIcon"; import useAxios from "@/hooks/useAxios"; import { useProductWizardId } from "@/hooks/useProductWizardId"; import { cn } from "@/lib/utils"; import Image from "next/image"; import { useParams, useRouter } from "next/navigation"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; -type PickedImage = { file: File; preview: string }; +type PickedImage = + | { type: "existing"; path: string } + | { type: "new"; file: File; preview: string }; + +function imageSrc(img: PickedImage): string { + return img.type === "existing" ? IMAGE_BASE_URL + img.path : img.preview; +} function ProductImagesPage() { const { t } = useTranslation("common"); @@ -28,9 +36,23 @@ function ProductImagesPage() { const [images, setImages] = useState([]); const [primaryIndex, setPrimaryIndex] = useState(0); + useEffect(() => { + if (!listingId) return; + request<{ listing: { images: string[]; primaryImageIndex: number } }>( + "GET", + `/shop-products/${listingId}` + ).then((res) => { + if (!res?.listing) return; + setImages((res.listing.images || []).map((path) => ({ type: "existing", path }))); + setPrimaryIndex(res.listing.primaryImageIndex || 0); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [listingId]); + const handleSelect = (event: React.ChangeEvent) => { const files = Array.from(event.target.files || []); - const picked = files.map((file) => ({ + const picked: PickedImage[] = files.map((file) => ({ + type: "new", file, preview: URL.createObjectURL(file), })); @@ -48,18 +70,21 @@ function ProductImagesPage() { const handleSubmit = async () => { if (!listingId) return; - if (images.length === 0) { - goNext(); - return; - } + + const existingImages = images + .filter((img): img is Extract => img.type === "existing") + .map((img) => img.path); + const newFiles = images.filter( + (img): img is Extract => img.type === "new" + ); + const formData = new FormData(); - images.forEach((img) => formData.append("images", img.file, img.file.name)); + newFiles.forEach((img) => formData.append("images", img.file, img.file.name)); + formData.append("existing_images", JSON.stringify(existingImages)); formData.append("primaryImageIndex", String(primaryIndex)); try { - await request("PATCH", `/shop-products/${listingId}`, formData, { - headers: { "Content-Type": "multipart/form-data" }, - }); + await request("PATCH", `/shop-products/${listingId}`, formData); goNext(); } catch (err: unknown) { const message = @@ -83,9 +108,9 @@ function ProductImagesPage() {
{images.map((img, index) => (
{/* eslint-disable-next-line @next/next/no-img-element */} @@ -114,27 +139,24 @@ function ProductImagesPage() {
))} (); + const searchParams = useSearchParams(); const shopId = params.shopId; const { request, loading } = useAxios(); + const isEditMode = searchParams.get("edit") === "1"; + const [editListingId] = useState(() => + isEditMode ? getProductWizardId(shopId) : null + ); const [name, setName] = useState(""); const [description, setDescription] = useState(""); @@ -33,6 +38,20 @@ function ProductNamePage() { const [suggestions, setSuggestions] = useState([]); const debounceRef = useRef | null>(null); + useEffect(() => { + if (!editListingId) return; + request<{ listing: { title: string; description?: string | null } }>( + "GET", + `/shop-products/${editListingId}` + ).then((res) => { + if (res?.listing) { + setName(res.listing.title); + setDescription(res.listing.description || ""); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editListingId]); + useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); if (!name.trim()) { @@ -71,6 +90,15 @@ function ProductNamePage() { return; } try { + if (editListingId) { + await request("PATCH", `/shop-products/${editListingId}`, { + title: name.trim(), + description: description.trim() || undefined, + }); + router.push(`/shops/${shopId}/products/new/category`); + return; + } + const response = await request<{ listing: { _id: string } }>( "POST", "/shop-products", @@ -84,7 +112,7 @@ function ProductNamePage() { if (response?.listing?._id) { saveProductWizardId(shopId, response.listing._id); } - router.push(`/shops/${shopId}/products/new/images`); + router.push(`/shops/${shopId}/products/new/category`); } catch (err: unknown) { const message = (err as { response?: { data?: { message?: string } } })?.response @@ -100,7 +128,7 @@ function ProductNamePage() { {t("shops.productNameTitle")}
- 0 ? colors : [null]; const sizeOptions = sizes.length > 0 ? sizes : [null]; const weightOptions = weights.length > 0 ? weights : [null]; - const rows: VariantRow[] = []; + const rows: { color: string | null; size: string | null; weight: string | null }[] = []; for (const color of colorOptions) { for (const size of sizeOptions) { for (const weight of weightOptions) { - rows.push({ color, size, weight, price: "", stock: "" }); + rows.push({ color, size, weight }); } } } return rows; } +function discountPercent(price: string, discountPrice: string): number | null { + const original = Number(price); + const discount = Number(discountPrice); + if (!original || !discount || discount >= original) return null; + return Math.round(((original - discount) / original) * 100); +} + function ProductVariantsPage() { const { t } = useTranslation("common"); const router = useRouter(); @@ -63,26 +75,73 @@ function ProductVariantsPage() { const [colorsInput, setColorsInput] = useState(""); const [sizesInput, setSizesInput] = useState(""); const [weightsInput, setWeightsInput] = useState(""); - const [rows, setRows] = useState(null); + const [rows, setRows] = useState([]); - const parsedColors = useMemo(() => parseOptions(colorsInput), [colorsInput]); - const parsedSizes = useMemo(() => parseOptions(sizesInput), [sizesInput]); - const parsedWeights = useMemo(() => parseOptions(weightsInput), [weightsInput]); + useEffect(() => { + if (!listingId) return; + request<{ + listing: { + variants: { + color: string | null; + size: string | null; + weight: string | null; + price: number; + discount_price: number | null; + stock: number; + }[]; + }; + }>("GET", `/shop-products/${listingId}`).then((res) => { + const existing = res?.listing?.variants || []; + if (existing.length === 0) return; + setRows( + existing.map((v) => ({ + color: v.color, + size: v.size, + weight: v.weight, + price: String(v.price), + discountPrice: v.discount_price ? String(v.discount_price) : "", + stock: String(v.stock), + })) + ); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [listingId]); const handleGenerate = () => { - setRows(buildCombinations(parsedColors, parsedSizes, parsedWeights)); + const colors = parseOptions(colorsInput); + const sizes = parseOptions(sizesInput); + const weights = parseOptions(weightsInput); + const combinations = buildCombinations(colors, sizes, weights); + + setRows((prev) => { + const existingKeys = new Set(prev.map((r) => rowKey(r.color, r.size, r.weight))); + const newRows = combinations + .filter((c) => !existingKeys.has(rowKey(c.color, c.size, c.weight))) + .map((c) => ({ ...c, price: "", discountPrice: "", stock: "" })); + return [...prev, ...newRows]; + }); + + setColorsInput(""); + setSizesInput(""); + setWeightsInput(""); }; - const updateRow = (index: number, field: "price" | "stock", value: string) => { + const updateRow = ( + index: number, + field: "price" | "discountPrice" | "stock", + value: string + ) => { setRows((prev) => - prev - ? prev.map((row, i) => (i === index ? { ...row, [field]: value } : row)) - : prev + prev.map((row, i) => (i === index ? { ...row, [field]: value } : row)) ); }; + const removeRow = (index: number) => { + setRows((prev) => prev.filter((_, i) => i !== index)); + }; + const handleSubmit = async () => { - if (!listingId || !rows) return; + if (!listingId) return; const variants = rows .filter((row) => row.price && Number(row.price) > 0) @@ -91,6 +150,10 @@ function ProductVariantsPage() { size: row.size, weight: row.weight, price: Number(row.price), + discount_price: + row.discountPrice && Number(row.discountPrice) < Number(row.price) + ? Number(row.discountPrice) + : null, stock: Number(row.stock) || 0, })); @@ -143,35 +206,82 @@ function ProductVariantsPage() { {t("shops.generateVariants")} - {rows && rows.length > 0 && ( -
- {rows.map((row, index) => ( -
- - {[row.color, row.size, row.weight].filter(Boolean).join(" / ") || - t("shops.defaultVariant")} - - updateRow(index, "price", e.target.value)} - /> - updateRow(index, "stock", e.target.value)} - /> -
- ))} + {rows.length > 0 && ( +
+
+ {t("shops.colorColumn")} + {t("shops.sizeColumn")} + {t("shops.weightColumn")} + {t("shops.priceColumn")} +
+ + {rows.map((row, index) => { + const percent = discountPercent(row.price, row.discountPrice); + return ( +
+
+ + {row.color || t("shops.defaultVariant")} + + + {row.size || t("shops.defaultVariant")} + + + {row.weight || "-"} + + +
+ +
+ updateRow(index, "price", e.target.value)} + /> + + updateRow(index, "discountPrice", e.target.value) + } + /> + updateRow(index, "stock", e.target.value)} + /> +
+ {percent != null && ( + + {t("shops.discountPercentLabel", { percent })} + + )} +
+ ); + })} + +
+ {t("shops.totalVariantsLabel", { count: rows.length })} +
)}
@@ -180,7 +290,7 @@ function ProductVariantsPage() { {t("shops.saveProduct")} diff --git a/src/app/shops/listing/[listingId]/page.tsx b/src/app/shops/listing/[listingId]/page.tsx index b1f7e1e..c594112 100644 --- a/src/app/shops/listing/[listingId]/page.tsx +++ b/src/app/shops/listing/[listingId]/page.tsx @@ -2,11 +2,15 @@ import Container from "@/components/elements/Container"; import LocalePageShell from "@/components/i18n/LocalePageShell"; -import PageTitle from "@/components/settings/PageTitle"; -import AuthNextButton from "@/components/auth/AuthNextButton"; -import { IMAGE_BASE_URL } from "@/components/main/BaseUrl"; +import Header from "@/components/main/Header"; +import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl"; +import ProductImageLightbox from "@/components/shops/ProductImageLightbox"; +import BoldIcon from "@/components/ui/BoldIcon"; +import TabNavigation from "@/components/TabNavigation"; import useAxios from "@/hooks/useAxios"; -import { IShopProductListing } from "@/types/types"; +import { addToCart, isFavorite, toggleFavorite } from "@/lib/shops/localCart"; +import { cn } from "@/lib/utils"; +import { IShopProductListing, IShopProductVariant } from "@/types/types"; import Image from "next/image"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; @@ -14,7 +18,28 @@ import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; type ShippingMethod = { method: string; cost: number; enabled: boolean }; -type ShopInfo = { _id: string; name: string; shipping_methods: ShippingMethod[] }; +type ShopHeader = { + _id: string; + name: string; + logo?: string | null; + shipping_methods?: ShippingMethod[]; +}; + +function uniqueValues(values: (string | null | undefined)[]): string[] { + return Array.from(new Set(values.filter((v): v is string => Boolean(v)))); +} + +function SpecRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
+ {children} +
+ +
+ ); +} export default function ListingDetailPage() { const { t } = useTranslation("common"); @@ -23,45 +48,118 @@ export default function ListingDetailPage() { const { request, loading } = useAxios(); const [listing, setListing] = useState(null); - const [shop, setShop] = useState(null); - const [variantId, setVariantId] = useState(""); - const [quantity, setQuantity] = useState(1); + const [selectedColor, setSelectedColor] = useState(null); + const [selectedSize, setSelectedSize] = useState(null); + const [selectedWeight, setSelectedWeight] = useState(null); const [shippingMethod, setShippingMethod] = useState(""); + const [quantity, setQuantity] = useState(1); + const [activeImageIndex, setActiveImageIndex] = useState(0); + const [descriptionExpanded, setDescriptionExpanded] = useState(false); const [showAddressModal, setShowAddressModal] = useState(false); + const [showLightbox, setShowLightbox] = useState(false); + const [favorite, setFavorite] = useState(false); useEffect(() => { - request<{ listing: IShopProductListing }>( - "GET", - `/shop-products/${params.listingId}` - ).then((res) => { - const data = res?.listing || null; - setListing(data); - if (data && typeof data.shop === "object") { - setShop(data.shop as unknown as ShopInfo); + request<{ listing: IShopProductListing }>("GET", `/shop-products/${params.listingId}`).then( + (res) => { + const data = res?.listing || null; + setListing(data); + const firstVariant = data?.variants?.[0]; + if (firstVariant) { + setSelectedColor(firstVariant.color || null); + setSelectedSize(firstVariant.size || null); + setSelectedWeight(firstVariant.weight || null); + } + if (data) setFavorite(isFavorite(data._id)); } - if (data?.variants?.length) setVariantId(data.variants[0]._id); - }); + ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [params.listingId]); - const selectedVariant = useMemo( - () => listing?.variants.find((v) => v._id === variantId) || null, - [listing, variantId] + const shopInfo: ShopHeader | null = + listing && typeof listing.shop === "object" ? (listing.shop as ShopHeader) : null; + + const colors = useMemo( + () => uniqueValues(listing?.variants.map((v) => v.color) || []), + [listing] ); + const sizes = useMemo(() => { + if (!listing) return []; + const relevant = listing.variants.filter( + (v) => colors.length === 0 || v.color === selectedColor + ); + return uniqueValues(relevant.map((v) => v.size)); + }, [listing, colors, selectedColor]); + const weights = useMemo(() => { + if (!listing) return []; + const relevant = listing.variants.filter( + (v) => + (colors.length === 0 || v.color === selectedColor) && + (sizes.length === 0 || v.size === selectedSize) + ); + return uniqueValues(relevant.map((v) => v.weight)); + }, [listing, colors, sizes, selectedColor, selectedSize]); + + useEffect(() => { + if (sizes.length > 0 && (!selectedSize || !sizes.includes(selectedSize))) { + setSelectedSize(sizes[0]); + } + }, [sizes, selectedSize]); + + useEffect(() => { + if (weights.length > 0 && (!selectedWeight || !weights.includes(selectedWeight))) { + setSelectedWeight(weights[0]); + } + }, [weights, selectedWeight]); + + const matchedVariant: IShopProductVariant | null = useMemo(() => { + if (!listing) return null; + return ( + listing.variants.find( + (v) => + (colors.length === 0 || v.color === selectedColor) && + (sizes.length === 0 || v.size === selectedSize) && + (weights.length === 0 || v.weight === selectedWeight) + ) || + listing.variants[0] || + null + ); + }, [listing, colors, sizes, weights, selectedColor, selectedSize, selectedWeight]); + + useEffect(() => { + setQuantity(1); + }, [matchedVariant?._id]); + + const enabledShippingMethods = shopInfo?.shipping_methods?.filter((m) => m.enabled) || []; + + const categoryLine = listing + ? [listing.category, listing.sub_category, listing.sub_sub_category] + .filter(Boolean) + .join(" / ") + : ""; + + const discountPercent = + matchedVariant?.discount_price && matchedVariant.discount_price < matchedVariant.price + ? Math.round( + ((matchedVariant.price - matchedVariant.discount_price) / matchedVariant.price) * 100 + ) + : null; + + const handleImageScroll = (event: React.UIEvent) => { + const el = event.currentTarget; + const index = Math.round(el.scrollLeft / el.clientWidth); + setActiveImageIndex(index); + }; const handleBuy = async () => { - if (!selectedVariant) return; + if (!listing || !matchedVariant) return; try { - const response = await request<{ order: { _id: string } }>( - "POST", - "/orders/draft", - { - listingId: listing?._id, - variantId: selectedVariant._id, - quantity, - shippingMethod: shippingMethod || undefined, - } - ); + const response = await request<{ order: { _id: string } }>("POST", "/orders/draft", { + listingId: listing._id, + variantId: matchedVariant._id, + quantity, + shippingMethod: shippingMethod || undefined, + }); if (response?.order?._id) { router.push(`/shops/checkout/${response.order._id}`); } @@ -76,145 +174,302 @@ export default function ListingDetailPage() { } }; + const handleAddToCart = () => { + if (!listing || !matchedVariant) return; + addToCart(listing._id, matchedVariant._id, quantity); + toast.success(t("shops.addToCartSuccess")); + }; + + const handleToggleFavorite = () => { + if (!listing) return; + const next = toggleFavorite(listing._id); + setFavorite(next); + toast.success(next ? t("shops.addedToFavorites") : t("shops.removedFromFavorites")); + }; + if (!listing) return null; - const primaryImage = - listing.images?.[listing.primaryImageIndex] || listing.images?.[0]; + const images = listing.images || []; return ( - - - {listing.title} + <> +
+ + +
+ {shopInfo?.name} + + {shopInfo?.logo ? ( + // eslint-disable-next-line @next/next/no-img-element + {shopInfo.name} + ) : ( + + )} + +
-
- {primaryImage && ( -
- {listing.title} +
+
+

{listing.title}

+ {discountPercent != null && ( + + + + + {t("shops.discountPercentBadge", { percent: discountPercent })} + + + )}
- )} - {listing.description && ( -

- {listing.description} -

- )} + {images.length > 0 && ( +
+
+ {images.map((img) => ( + + ))} +
+ {images.length > 1 && ( + + {activeImageIndex + 1}/{images.length} + + )} +
+ )} - {listing.variants.length > 1 && ( -
-

{t("shops.variantsTitle")}

-
- {listing.variants.map((v) => ( +

{t("shops.specsTitle")}

+ + {categoryLine && ( + + {categoryLine} + + )} + + {colors.length > 0 && ( + + {colors.map((color) => ( ))} -
-
- )} + + )} - {selectedVariant && ( -

- {t("shops.priceLabel", { - amount: ( - selectedVariant.discount_price || selectedVariant.price - ).toLocaleString(), - })} -

- )} + {sizes.length > 0 && ( + + {sizes.map((size) => ( + + ))} + + )} -
- {t("shops.quantityLabel")} - - {quantity} - -
+ {weights.length > 0 && ( + + {weights.map((weight) => ( + + ))} + + )} - {shop && shop.shipping_methods.filter((m) => m.enabled).length > 0 && ( -
-

{t("shops.shippingTitle")}

-
- {shop.shipping_methods - .filter((m) => m.enabled) - .map((m) => ( - - ))} -
-
- )} + {enabledShippingMethods.length > 0 && ( + + {enabledShippingMethods.map((m) => ( + + ))} + + )} - - {t("shops.buyNow")} - -
+ {listing.description && ( + + + {descriptionExpanded || listing.description.length <= 50 + ? listing.description + : `${listing.description.slice(0, 50)}...`} + {listing.description.length > 50 && ( + + )} + + + )} - {showAddressModal && ( -
-
-

{t("shops.addressRequiredHint")}

- router.push("/settings/edit/location")} - > - {t("shops.goToLocationSettings")} - +
+ + {quantity} +
+ + {matchedVariant && ( + + {matchedVariant.discount_price ? ( + + + {matchedVariant.price.toLocaleString()} {t("settings.toman")} + + + {matchedVariant.discount_price.toLocaleString()} {t("settings.toman")} + + + ) : ( + + {matchedVariant.price.toLocaleString()} {t("settings.toman")} + + )} + + )}
- )} - - + +
+ + +
+ + + + + {showAddressModal && ( +
+
+

{t("shops.addressRequiredHint")}

+ + +
+
+ )} + + setShowLightbox(false)} + /> + ); } diff --git a/src/app/shops/new/category/page.tsx b/src/app/shops/new/category/page.tsx deleted file mode 100644 index 25bca7a..0000000 --- a/src/app/shops/new/category/page.tsx +++ /dev/null @@ -1,104 +0,0 @@ -"use client"; - -import AuthPageLayout, { - AuthFormFooter, - AuthPageContent, -} from "@/components/auth/AuthPageLayout"; -import AuthNextButton from "@/components/auth/AuthNextButton"; -import LocalePageShell from "@/components/i18n/LocalePageShell"; -import ShopCategoryPicker from "@/components/shops/ShopCategoryPicker"; -import useAxios from "@/hooks/useAxios"; -import { useShopWizardId } from "@/hooks/useShopWizardId"; -import { IShopCategory } from "@/types/types"; -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import toast from "react-hot-toast"; -import { useTranslation } from "react-i18next"; - -function ShopCategoryPage() { - const { t } = useTranslation("common"); - const router = useRouter(); - const { request, loading } = useAxios(); - const shopId = useShopWizardId(); - - const [categoryList, setCategoryList] = useState(null); - const [category, setCategory] = useState(""); - const [subCategory, setSubCategory] = useState([]); - const [displaySubCategory, setDisplaySubCategory] = useState( - null - ); - - useEffect(() => { - request<{ categories: IShopCategory[] }>("GET", "/shop-categories") - .then((res) => setCategoryList(res?.categories || [])) - .catch(() => setCategoryList([])); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const handleCategorySelect = (value: string) => { - setCategory(value); - setSubCategory([]); - setDisplaySubCategory(null); - }; - - const handleSubToggle = (value: string) => { - setSubCategory((prev) => - prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value] - ); - if (displaySubCategory === value) setDisplaySubCategory(null); - }; - - const handleSubmit = async () => { - if (!shopId) return; - if (!category || subCategory.length === 0 || !displaySubCategory) { - toast.error(t("shops.categoryRequired")); - return; - } - try { - await request("PATCH", `/shops/${shopId}/category`, { - category, - sub_category: subCategory.join(","), - display_sub_category: displaySubCategory, - }); - router.push("/shops/new/shipping"); - } catch (err: unknown) { - const message = - (err as { response?: { data?: { message?: string } } })?.response - ?.data?.message || t("shops.unknownError"); - toast.error(message); - } - }; - - if (!shopId) return null; - - return ( - - - - {t("shops.categoryTitle")} - - - - - {t("shops.saveAndContinue")} - - - - - ); -} - -export default ShopCategoryPage; diff --git a/src/app/shops/new/contact/page.tsx b/src/app/shops/new/contact/page.tsx index 1881346..2bc35c1 100644 --- a/src/app/shops/new/contact/page.tsx +++ b/src/app/shops/new/contact/page.tsx @@ -7,10 +7,13 @@ import AuthPageLayout, { import AuthNextButton from "@/components/auth/AuthNextButton"; import RoundedInput from "@/components/elements/RoundedInput"; import LocalePageShell from "@/components/i18n/LocalePageShell"; +import ToggleSwitch from "@/components/shops/ToggleSwitch"; +import TimeSelect from "@/components/shops/TimeSelect"; import useAxios from "@/hooks/useAxios"; import { useShopWizardId } from "@/hooks/useShopWizardId"; +import Image from "next/image"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; @@ -24,12 +27,11 @@ function ShopContactPage() { const shopId = useShopWizardId(); const [contact, setContact] = useState({ - phone: "", + landline: "", mobile: "", telegram: "", whatsapp: "", instagram: "", - landline: "", }); const [enabledDays, setEnabledDays] = useState>({ @@ -57,6 +59,42 @@ function ShopContactPage() { setEnabledDays((prev) => ({ ...prev, [day]: !prev[day] })); }; + useEffect(() => { + if (!shopId) return; + request<{ + shop: { + contact?: Record; + response_schedule?: { day: Day; start_time: string | null; end_time: string | null }[]; + }; + }>("GET", `/shops/${shopId}`).then((res) => { + const shop = res?.shop; + if (!shop) return; + if (shop.contact) { + setContact((prev) => ({ + landline: shop.contact?.landline || prev.landline, + mobile: shop.contact?.mobile || prev.mobile, + telegram: shop.contact?.telegram || prev.telegram, + whatsapp: shop.contact?.whatsapp || prev.whatsapp, + instagram: shop.contact?.instagram || prev.instagram, + })); + } + if (shop.response_schedule?.length) { + const nextEnabled = { ...enabledDays }; + const nextTimes = { ...times }; + shop.response_schedule.forEach((item) => { + nextEnabled[item.day] = true; + nextTimes[item.day] = { + start: item.start_time || "", + end: item.end_time || "", + }; + }); + setEnabledDays(nextEnabled); + setTimes(nextTimes); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shopId]); + const handleSubmit = async () => { if (!shopId) return; @@ -90,6 +128,42 @@ function ShopContactPage() { if (!shopId) return null; + const contactFields: { + key: keyof typeof contact; + icon: string; + placeholder: string; + maxLength?: number; + }[] = [ + { + key: "landline", + icon: "/images/icons/vuesax/bold/call.svg", + placeholder: t("shops.landlinePlaceholder"), + maxLength: 11, + }, + { + key: "mobile", + icon: "/images/icons/vuesax/bold/mobile.svg", + placeholder: t("billboards.form.mobile"), + maxLength: 11, + }, + { + key: "telegram", + icon: "/images/icons/telegram.svg", + placeholder: t("billboards.form.telegram"), + }, + { + key: "whatsapp", + icon: "/images/icons/vuesax/bold/whatsapp.svg", + placeholder: t("billboards.form.whatsapp"), + maxLength: 11, + }, + { + key: "instagram", + icon: "/images/icons/vuesax/bold/instagram.svg", + placeholder: t("billboards.form.instagram"), + }, + ]; + return ( @@ -97,102 +171,80 @@ function ShopContactPage() { {t("shops.contactTitle")}
- - setContact((prev) => ({ ...prev, landline: e.target.value })) - } - /> - - setContact((prev) => ({ ...prev, phone: e.target.value })) - } - /> - - setContact((prev) => ({ ...prev, mobile: e.target.value })) - } - /> - - setContact((prev) => ({ ...prev, telegram: e.target.value })) - } - /> - - setContact((prev) => ({ ...prev, whatsapp: e.target.value })) - } - /> - - setContact((prev) => ({ ...prev, instagram: e.target.value })) - } - /> - -

- {t("shops.responseScheduleTitle")} -

- {DAYS.map((day) => ( -
- toggleDay(day)} - /> - - {t(`shops.days.${day}`)} + {contactFields.map((field) => ( +
+ + - {enabledDays[day] && ( - <> - - setTimes((prev) => ({ - ...prev, - [day]: { ...prev[day], start: e.target.value }, - })) - } - /> - - - - setTimes((prev) => ({ - ...prev, - [day]: { ...prev[day], end: e.target.value }, - })) - } - /> - - )} + + setContact((prev) => ({ ...prev, [field.key]: e.target.value })) + } + />
))} + +

+ {t("shops.responseScheduleTitle")} +

+
+ {DAYS.map((day) => ( +
+
+ + + {t(`shops.days.${day}`)} + +
+ + {enabledDays[day] && ( +
+ + setTimes((prev) => ({ + ...prev, + [day]: { ...prev[day], end: value }, + })) + } + /> + - + + setTimes((prev) => ({ + ...prev, + [day]: { ...prev[day], start: value }, + })) + } + /> +
+ )} + + toggleDay(day)} + ariaLabel={t(`shops.days.${day}`)} + /> +
+ ))} +
diff --git a/src/app/shops/new/location/page.tsx b/src/app/shops/new/location/page.tsx index fab0a1f..e674606 100644 --- a/src/app/shops/new/location/page.tsx +++ b/src/app/shops/new/location/page.tsx @@ -43,6 +43,38 @@ function ShopLocationPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (!shopId) return; + request<{ + shop: { + has_physical_location?: boolean; + province?: IProvince | null; + city?: ICity | null; + neighbourhood?: string | null; + address?: string | null; + lat?: string | null; + lng?: string | null; + }; + }>("GET", `/shops/${shopId}`).then((res) => { + const shop = res?.shop; + if (!shop) return; + setHasPhysicalLocation(shop.has_physical_location ?? true); + if (shop.province) { + setProvinceId(String(shop.province.id)); + request<{ cities: ICity[] }>("GET", `/cities/${shop.province.id}`) + .then((r) => setCities(r?.cities || [])) + .catch(() => setCities([])); + } + if (shop.city) setCityId(String(shop.city.id)); + if (shop.neighbourhood) setNeighbourhood(shop.neighbourhood); + if (shop.address) setAddress(shop.address); + if (shop.lat && shop.lng) { + setMarker({ lat: Number(shop.lat), lng: Number(shop.lng) }); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shopId]); + const handleProvinceChange = (e: React.ChangeEvent) => { const id = e.target.value; setProvinceId(id); @@ -62,6 +94,15 @@ function ShopLocationPage() { const province = provinces?.find((p) => String(p.id) === provinceId) || null; const city = cities?.find((c) => String(c.id) === cityId) || null; + if (!province) { + toast.error(t("shops.provinceRequired")); + return; + } + if (!city) { + toast.error(t("shops.cityRequired")); + return; + } + try { await request("PATCH", `/shops/${shopId}/location`, { has_physical_location: hasPhysicalLocation, @@ -100,9 +141,7 @@ function ShopLocationPage() { {t("shops.noPhysicalShop")} - {hasPhysicalLocation && ( - <> - + @@ -167,8 +206,6 @@ function ShopLocationPage() { value={address} onChange={(e) => setAddress(e.target.value)} /> - - )}
diff --git a/src/app/shops/new/logo/page.tsx b/src/app/shops/new/logo/page.tsx index cc49cf7..6c7a2ce 100644 --- a/src/app/shops/new/logo/page.tsx +++ b/src/app/shops/new/logo/page.tsx @@ -6,11 +6,12 @@ import AuthPageLayout, { } from "@/components/auth/AuthPageLayout"; import AuthNextButton from "@/components/auth/AuthNextButton"; import LocalePageShell from "@/components/i18n/LocalePageShell"; +import { IMAGE_BASE_URL } from "@/components/main/BaseUrl"; import useAxios from "@/hooks/useAxios"; import { useShopWizardId } from "@/hooks/useShopWizardId"; import Image from "next/image"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; @@ -22,6 +23,16 @@ function ShopLogoPage() { const [logoPreview, setLogoPreview] = useState(null); const [logoFile, setLogoFile] = useState(null); + useEffect(() => { + if (!shopId) return; + request<{ shop: { logo?: string | null } }>("GET", `/shops/${shopId}`).then((res) => { + if (res?.shop?.logo) { + setLogoPreview(IMAGE_BASE_URL + res.shop.logo); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shopId]); + const selectImage = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; @@ -31,7 +42,7 @@ function ShopLogoPage() { reader.readAsDataURL(file); }; - const goNext = () => router.push("/shops/new/category"); + const goNext = () => router.push("/shops/new/shipping"); const handleUpload = async () => { if (!logoFile || !shopId) { @@ -41,9 +52,7 @@ function ShopLogoPage() { const formData = new FormData(); formData.append("logo", logoFile); try { - await request("PATCH", `/shops/${shopId}/logo`, formData, { - headers: { "Content-Type": "multipart/form-data" }, - }); + await request("PATCH", `/shops/${shopId}/logo`, formData); goNext(); } catch (err: unknown) { const message = diff --git a/src/app/shops/new/name/page.tsx b/src/app/shops/new/name/page.tsx index 33b87e7..88e4833 100644 --- a/src/app/shops/new/name/page.tsx +++ b/src/app/shops/new/name/page.tsx @@ -4,35 +4,49 @@ import AuthPageLayout, { AuthFormFooter, AuthPageContent, } from "@/components/auth/AuthPageLayout"; -import AuthInput from "@/components/auth/AuthInput"; import AuthNextButton from "@/components/auth/AuthNextButton"; +import RoundedInput from "@/components/elements/RoundedInput"; import LocalePageShell from "@/components/i18n/LocalePageShell"; import useAxios from "@/hooks/useAxios"; +import { getShopWizardId, saveShopWizardId } from "@/hooks/useShopWizardId"; import { useFormik } from "formik"; import * as yup from "yup"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; function ShopNamePage() { const { t } = useTranslation("common"); const router = useRouter(); + const searchParams = useSearchParams(); const { request, loading } = useAxios(); + const isEditMode = searchParams.get("edit") === "1"; + const [editShopId] = useState(() => (isEditMode ? getShopWizardId() : null)); const formik = useFormik({ - initialValues: { name: "" }, + initialValues: { name: "", description: "" }, validationSchema: yup.object({ name: yup.string().trim().required(t("shops.nameRequired")), }), onSubmit: async (values) => { try { + if (editShopId) { + await request("PATCH", `/shops/${editShopId}/basic`, { + name: values.name.trim(), + description: values.description.trim() || undefined, + }); + router.push("/shops/new/logo"); + return; + } + const response = await request<{ shopId: string }>( "POST", "/shops/draft", - { name: values.name.trim() } + { name: values.name.trim(), description: values.description.trim() || undefined } ); - if (typeof window !== "undefined" && response?.shopId) { - localStorage.setItem("shop_wizard_shop_id", String(response.shopId)); + if (response?.shopId) { + saveShopWizardId(String(response.shopId)); } router.push("/shops/new/logo"); } catch (err: unknown) { @@ -44,6 +58,21 @@ function ShopNamePage() { }, }); + useEffect(() => { + if (!editShopId) return; + request<{ shop: { name: string; description?: string | null } }>( + "GET", + `/shops/${editShopId}` + ).then((res) => { + if (!res?.shop) return; + formik.setValues({ + name: res.shop.name || "", + description: res.shop.description || "", + }); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editShopId]); + return ( @@ -53,20 +82,32 @@ function ShopNamePage() { > {t("shops.nameTitle")} - {formik.touched.name && formik.errors.name && ( {formik.errors.name} )} + +