shop
This commit is contained in:
@@ -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 (
|
||||
<Link
|
||||
key={`shop-${item.shop_id}-${item.buyer_id}`}
|
||||
href={`/settings/chats/shop/${item.shop_id}/${item.buyer_id}`}
|
||||
className="flex items-center justify-between py-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<ProfileAvatar
|
||||
src={item.profile_image}
|
||||
alt={item.display_name}
|
||||
size="sm"
|
||||
rounded="full"
|
||||
className="!h-12 !w-12"
|
||||
/>
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
<span className="truncate font-semibold">
|
||||
{item.display_name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1 pr-2">
|
||||
{item.unread_messages_count ? (
|
||||
<span className="rounded-full bg-[#387E65] px-2 py-0.5 text-xs text-white">
|
||||
{item.unread_messages_count}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const isOnline = isUserOnline(item.last_online, onlineLabel);
|
||||
const storyMeta =
|
||||
storyByUserId.get(String(item._id)) ??
|
||||
|
||||
171
src/app/settings/chats/shop/[shopId]/[buyerId]/page.tsx
Normal file
171
src/app/settings/chats/shop/[shopId]/[buyerId]/page.tsx
Normal file
@@ -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<ShopChatMessage[] | null>(null);
|
||||
const [counterpart, setCounterpart] = useState<Counterpart | null>(null);
|
||||
const [isOwner, setIsOwner] = useState(false);
|
||||
const [text, setText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(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 (
|
||||
<>
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container className="pb-24">
|
||||
<div className="mx-auto flex h-[calc(100dvh-140px)] w-full max-w-md flex-col">
|
||||
<div className="flex shrink-0 items-center gap-3 border-b border-neutral-200 py-3 dark:border-neutral-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
aria-label={t("common.back")}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center"
|
||||
>
|
||||
<BoldIcon name="arrow-right-3" size={20} className="block dark:invert" />
|
||||
</button>
|
||||
<ProfileAvatar
|
||||
src={counterpart?.logo}
|
||||
alt={counterpart?.name}
|
||||
size="xs"
|
||||
rounded="full"
|
||||
/>
|
||||
<span className="truncate text-sm font-semibold">
|
||||
{counterpart?.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-3">
|
||||
{messages === null ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<IOSSpinner />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-neutral-500">
|
||||
{t("shops.noChatMessagesYet")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{messages.map((m) => {
|
||||
const mine = m.senderRole === myRole;
|
||||
return (
|
||||
<div
|
||||
key={m._id}
|
||||
className={cn(
|
||||
"max-w-[75%] rounded-2xl px-3 py-2 text-sm break-words",
|
||||
mine
|
||||
? "self-end bg-[#387E65] text-white"
|
||||
: "self-start bg-neutral-100 dark:bg-neutral-800"
|
||||
)}
|
||||
>
|
||||
{m.content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2 border-t border-neutral-200 pt-3 dark:border-neutral-700">
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!text.trim() || sending}
|
||||
onClick={() => void handleSend()}
|
||||
className="shrink-0 rounded-full bg-[#387E65] px-4 py-2 text-xs font-bold text-white disabled:opacity-40"
|
||||
>
|
||||
{t("posts.send")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
100
src/app/settings/shop/list/page.tsx
Normal file
100
src/app/settings/shop/list/page.tsx
Normal file
@@ -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<MyShop[] | null>(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 (
|
||||
<>
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("shops.myShopsTitle")}</PageTitle>
|
||||
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<BoldIcon
|
||||
name="search-normal"
|
||||
size={18}
|
||||
tinted
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{myShops === null ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-500">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
) : filteredShops.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-500">
|
||||
{t("shops.noShopsYet")}
|
||||
</p>
|
||||
) : (
|
||||
filteredShops.map((shop) => <ShopListCard key={shop._id} shop={shop} />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => router.push("/shops/new/name")}
|
||||
className="mx-auto mt-6 flex h-11 w-full max-w-xs items-center justify-center gap-2"
|
||||
>
|
||||
<BoldIcon name="add" size={18} tinted className="text-white" />
|
||||
{t("shops.addShopNew")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<string>(FILTER_SELLER);
|
||||
const [filter, setFilter] = useState<string>(FILTER_BUYER);
|
||||
const [myShops, setMyShops] = useState<MyShop[] | null>(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 (
|
||||
<LocalePageShell>
|
||||
<>
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSwitcher((v) => !v)}
|
||||
aria-label={t("shops.mySwitcher")}
|
||||
>
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
alt=""
|
||||
src={staticIconUrl("/images/icons/shop-add.svg")}
|
||||
className="dark:invert"
|
||||
/>
|
||||
</button>
|
||||
<span className="w-20 shrink-0" />
|
||||
<PageTitle>{t("settings.nav.shop")}</PageTitle>
|
||||
<button type="button" onClick={handleAddShop} aria-label={t("shops.addShop")}>
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
alt=""
|
||||
src={staticIconUrl("/images/icons/add.svg")}
|
||||
className="dark:invert"
|
||||
/>
|
||||
</button>
|
||||
{filter === FILTER_SELLER ? (
|
||||
<div className="flex w-20 shrink-0 items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/settings/shop/list")}
|
||||
aria-label={t("shops.mySwitcher")}
|
||||
>
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
alt=""
|
||||
src={staticIconUrl("/images/icons/shop-add.svg")}
|
||||
className="dark:invert"
|
||||
/>
|
||||
</button>
|
||||
<button type="button" onClick={handleAddShop} aria-label={t("shops.addShop")}>
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
alt=""
|
||||
src={staticIconUrl("/images/icons/add.svg")}
|
||||
className="dark:invert"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="w-20 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSwitcher && (
|
||||
<div className="mx-auto mt-4 max-w-md rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700">
|
||||
{myShops === null ? (
|
||||
<p className="text-center text-sm text-neutral-500">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
) : myShops.length === 0 ? (
|
||||
<p className="text-center text-sm text-neutral-500">
|
||||
{t("shops.noShopsYet")}
|
||||
</p>
|
||||
) : (
|
||||
myShops.map((shop) => (
|
||||
<div
|
||||
key={shop._id}
|
||||
className="flex items-center gap-2 border-b border-neutral-100 py-2 last:border-none dark:border-neutral-800"
|
||||
>
|
||||
{shop.logo && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={IMAGE_BASE_URL + shop.logo}
|
||||
alt={shop.name}
|
||||
className="h-8 w-8 rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm font-semibold">{shop.name}</span>
|
||||
<span className="text-xs text-neutral-400">
|
||||
{t(`shops.status.${shop.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs md:text-sm">
|
||||
<UserDetails />
|
||||
<div className="mx-auto mt-8 grid w-full max-w-md grid-cols-2 gap-4">
|
||||
<div className="mx-auto mt-6 w-full max-w-md">
|
||||
<RoundedInput
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") setSearch(searchText);
|
||||
}}
|
||||
placeholder={t("shops.searchOrderPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx-auto mt-4 grid w-full max-w-md grid-cols-2 gap-4">
|
||||
<RoundedButton
|
||||
className={cn(toggleBtnClass(filter === FILTER_SELLER), "h-9")}
|
||||
onClick={() => setFilter(FILTER_SELLER)}
|
||||
@@ -186,6 +175,7 @@ export default function ShopSettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
</LocalePageShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ShopHeader | null>(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 (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="flex items-center justify-between">
|
||||
<PageTitle>{shop?.name || t("settings.nav.shop")}</PageTitle>
|
||||
<Link
|
||||
href={`/shops/${shopId}/products/new/name`}
|
||||
aria-label={t("shops.addProduct")}
|
||||
>
|
||||
<Image
|
||||
src="/images/icons/add.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt=""
|
||||
className="dark:invert"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
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]);
|
||||
|
||||
<ShopProductGrid
|
||||
listings={listings}
|
||||
shopName={shop?.name || ""}
|
||||
shopLogo={shop?.logo}
|
||||
isLoading={isLoading}
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowShareModal(true)}
|
||||
aria-label={t("shops.share.title")}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center"
|
||||
>
|
||||
<BoldIcon name="share" size={18} tinted />
|
||||
</button>
|
||||
{shopId && (
|
||||
<Link
|
||||
href={`/shops/profile/${shopId}`}
|
||||
aria-label={t("shops.viewPublicProfile")}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800"
|
||||
>
|
||||
{shop?.logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={IMAGE_BASE_URL + shop.logo}
|
||||
alt={shop.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
|
||||
width={16}
|
||||
height={16}
|
||||
alt=""
|
||||
className="dark:invert"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="truncate text-sm font-semibold">{shop?.name}</span>
|
||||
</div>
|
||||
|
||||
<h1 className="my-6 text-center text-2xl font-bold">
|
||||
{t("shops.myProductsTitle")}
|
||||
</h1>
|
||||
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<BoldIcon
|
||||
name="search-normal"
|
||||
size={18}
|
||||
tinted
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOnlyInStock((v) => !v)}
|
||||
aria-label={t("shops.inStock")}
|
||||
className={cn(
|
||||
"flex h-11 w-11 shrink-0 items-center justify-center rounded-full border",
|
||||
onlyInStock
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-200 text-neutral-500 dark:border-neutral-700"
|
||||
)}
|
||||
>
|
||||
<BoldIcon name="filter" size={18} tinted />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2.5">
|
||||
{isLoading ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-500">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
) : filteredListings.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-500">
|
||||
{t("shops.noProductsYet")}
|
||||
</p>
|
||||
) : (
|
||||
filteredListings.map((listing) => (
|
||||
<ShopProductListRow key={listing._id} listing={listing} shopId={shopId} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-6 flex w-full max-w-xs gap-2">
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => router.push(`/shops/${shopId}/products/new/name`)}
|
||||
className="flex h-11 flex-1 items-center justify-center gap-2"
|
||||
>
|
||||
<BoldIcon name="add" size={18} tinted className="text-white" />
|
||||
{t("shops.addProductNew")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
saveShopWizardId(shopId);
|
||||
router.push("/shops/new/name?edit=1");
|
||||
}}
|
||||
className="flex h-11 flex-1 items-center justify-center gap-2"
|
||||
>
|
||||
{t("shops.editShopButton")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/settings" />
|
||||
|
||||
{shop && (
|
||||
<ShareShopModal
|
||||
open={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
shopId={shop._id}
|
||||
shopName={shop.name}
|
||||
shopLogo={shop.logo}
|
||||
/>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
433
src/app/shops/[shopId]/products/[listingId]/page.tsx
Normal file
433
src/app/shops/[shopId]/products/[listingId]/page.tsx
Normal file
@@ -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 (
|
||||
<div className="flex w-full items-center gap-2 rounded-3xl border border-neutral-200 px-4 py-3 dark:border-neutral-700">
|
||||
<span className="shrink-0 text-sm font-bold">{label}</span>
|
||||
<div className="flex flex-1 flex-wrap items-center justify-end gap-2 text-right">
|
||||
{children}
|
||||
</div>
|
||||
<BoldIcon name="arrow-down" size={16} tinted className="shrink-0 text-neutral-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<IShopProductListing | null>(null);
|
||||
const [selectedColor, setSelectedColor] = useState<string | null>(null);
|
||||
const [selectedSize, setSelectedSize] = useState<string | null>(null);
|
||||
const [selectedWeight, setSelectedWeight] = useState<string | null>(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<HTMLDivElement>) => {
|
||||
const el = event.currentTarget;
|
||||
const index = Math.round(el.scrollLeft / el.clientWidth);
|
||||
setActiveImageIndex(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span className="text-sm font-bold">{shopInfo?.name}</span>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
|
||||
{shopInfo?.logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={IMAGE_BASE_URL + shopInfo.logo}
|
||||
alt={shopInfo.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
|
||||
width={18}
|
||||
height={18}
|
||||
alt=""
|
||||
className="dark:invert"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-4 flex w-full max-w-md flex-col gap-4 rounded-3xl border border-neutral-200 p-4 dark:border-neutral-700">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{discountPercent != null && (
|
||||
<span className="rounded-full bg-red-50 px-2 py-0.5 text-xs font-bold text-red-500 dark:bg-red-500/10">
|
||||
{t("shops.discountPercentLabel", { percent: discountPercent })}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="text-xl font-bold">{listing.title}</h1>
|
||||
</div>
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className="relative">
|
||||
<div
|
||||
onScroll={handleImageScroll}
|
||||
className="flex w-full snap-x snap-mandatory gap-0 overflow-x-auto rounded-2xl [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{images.map((img) => (
|
||||
<div
|
||||
key={img}
|
||||
className="relative aspect-[4/3] w-full shrink-0 snap-center overflow-hidden bg-neutral-200 dark:bg-neutral-800"
|
||||
>
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + img}
|
||||
alt={listing.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="400px"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{images.length > 1 && (
|
||||
<span className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-2 py-0.5 text-[10px] font-bold text-white">
|
||||
{activeImageIndex + 1}/{images.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm font-bold">{t("shops.specsTitle")}</p>
|
||||
|
||||
{categoryLine && (
|
||||
<SpecRow label={t("shops.categorySpecLabel")}>
|
||||
<span className="text-xs">{categoryLine}</span>
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{colors.length > 0 && (
|
||||
<SpecRow label={t("shops.colorLabel")}>
|
||||
{colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setSelectedColor(color)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
selectedColor === color
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{color}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{sizes.length > 0 && (
|
||||
<SpecRow label={t("shops.sizeLabel")}>
|
||||
{sizes.map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
type="button"
|
||||
onClick={() => setSelectedSize(size)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
selectedSize === size
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{weights.length > 0 && (
|
||||
<SpecRow label={t("shops.weightLabel")}>
|
||||
{weights.map((weight) => (
|
||||
<button
|
||||
key={weight}
|
||||
type="button"
|
||||
onClick={() => setSelectedWeight(weight)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
selectedWeight === weight
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{weight}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{listing.description && (
|
||||
<SpecRow label={t("shops.descriptionLabel")}>
|
||||
<span className="text-xs leading-relaxed">
|
||||
{descriptionExpanded || listing.description.length <= 50
|
||||
? listing.description
|
||||
: `${listing.description.slice(0, 50)}...`}
|
||||
{listing.description.length > 50 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDescriptionExpanded((v) => !v)}
|
||||
className="mr-1 font-bold text-pink-500"
|
||||
>
|
||||
{descriptionExpanded ? t("shops.showLess") : t("shops.showMore")}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-1 rounded-3xl bg-green-600 py-2.5 text-center text-sm font-bold text-white">
|
||||
{t("shops.manageStockButton")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!matchedVariant || savingStock}
|
||||
onClick={() => adjustStock(-1)}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-green-600 text-lg font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-8 shrink-0 text-center text-sm font-bold">
|
||||
{matchedVariant?.stock ?? 0}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!matchedVariant || savingStock}
|
||||
onClick={() => adjustStock(1)}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-green-600 text-lg font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{matchedVariant && (
|
||||
<SpecRow label={t("shops.priceSpecLabel")}>
|
||||
{matchedVariant.discount_price ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xs text-neutral-400 line-through">
|
||||
{matchedVariant.price.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-red-500">
|
||||
{matchedVariant.discount_price.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm font-bold">
|
||||
{matchedVariant.price.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
)}
|
||||
</SpecRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mb-6 mt-8 flex w-full max-w-md gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
className="flex-1 rounded-3xl bg-neutral-900 py-3 text-sm font-bold text-white dark:bg-neutral-800"
|
||||
>
|
||||
{t("shops.editProductButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleActive}
|
||||
disabled={savingStatus}
|
||||
className={cn(
|
||||
"flex-1 rounded-3xl py-3 text-sm font-bold text-white disabled:opacity-50",
|
||||
isInactive ? "bg-green-600" : "bg-red-500"
|
||||
)}
|
||||
>
|
||||
{isInactive ? t("shops.activateButton") : t("shops.deactivateButton")}
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/settings" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
137
src/app/shops/[shopId]/products/new/category/page.tsx
Normal file
137
src/app/shops/[shopId]/products/new/category/page.tsx
Normal file
@@ -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<IShopCategory[] | null>(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 (
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">{t("shops.productCategoryTitle")}</span>
|
||||
<ProductCategoryPicker
|
||||
categoryList={categoryList}
|
||||
category={category}
|
||||
subCategory={subCategory}
|
||||
subSubCategory={subSubCategory}
|
||||
onCategorySelect={handleCategorySelect}
|
||||
onSubCategorySelect={handleSubCategorySelect}
|
||||
onSubSubCategorySelect={setSubSubCategory}
|
||||
/>
|
||||
</AuthPageContent>
|
||||
<AuthFormFooter>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{t("shops.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProductCategoryPage;
|
||||
@@ -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<PickedImage[]>([]);
|
||||
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<HTMLInputElement>) => {
|
||||
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<PickedImage, { type: "existing" }> => img.type === "existing")
|
||||
.map((img) => img.path);
|
||||
const newFiles = images.filter(
|
||||
(img): img is Extract<PickedImage, { type: "new" }> => 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() {
|
||||
<div className="grid w-full max-w-sm grid-cols-3 gap-2">
|
||||
{images.map((img, index) => (
|
||||
<div
|
||||
key={img.preview}
|
||||
key={img.type === "existing" ? img.path : img.preview}
|
||||
className={cn(
|
||||
"relative aspect-square overflow-hidden rounded-xl border-2",
|
||||
"relative aspect-square overflow-hidden rounded-3xl border-2",
|
||||
primaryIndex === index
|
||||
? "border-pink-500"
|
||||
: "border-neutral-200 dark:border-neutral-700"
|
||||
@@ -93,7 +118,7 @@ function ProductImagesPage() {
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={img.preview}
|
||||
src={imageSrc(img)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
@@ -114,27 +139,24 @@ function ProductImagesPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(index)}
|
||||
aria-label={t("shops.removeImage")}
|
||||
className="absolute top-1 left-1 rounded-full bg-black/50 p-1"
|
||||
>
|
||||
<Image
|
||||
src="/images/icons/close-circle.svg"
|
||||
width={16}
|
||||
height={16}
|
||||
alt=""
|
||||
/>
|
||||
<BoldIcon name="close-circle" size={16} tinted className="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<label
|
||||
htmlFor="productImagesInput"
|
||||
className="flex aspect-square cursor-pointer items-center justify-center rounded-xl border-2 border-dashed border-neutral-300 dark:border-neutral-600"
|
||||
className="flex aspect-square cursor-pointer items-center justify-center rounded-3xl border-2 border-dashed border-neutral-300 dark:border-neutral-600"
|
||||
>
|
||||
<Image
|
||||
src="/images/icons/gallery-add.svg"
|
||||
width={36}
|
||||
height={36}
|
||||
alt=""
|
||||
className="dark:brightness-0 dark:invert"
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
|
||||
@@ -4,12 +4,12 @@ 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 { saveProductWizardId } from "@/hooks/useProductWizardId";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { getProductWizardId, saveProductWizardId } from "@/hooks/useProductWizardId";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -24,8 +24,13 @@ function ProductNamePage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const params = useParams<{ shopId: string }>();
|
||||
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<CatalogProduct[]>([]);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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() {
|
||||
<span className="text-xl font-bold mb-4">{t("shops.productNameTitle")}</span>
|
||||
|
||||
<div className="relative w-full max-w-sm">
|
||||
<AuthInput
|
||||
<RoundedInput
|
||||
name="productName"
|
||||
type="text"
|
||||
placeholder={t("shops.productNamePlaceholder")}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
useProductWizardId,
|
||||
} from "@/hooks/useProductWizardId";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -22,6 +22,7 @@ type VariantRow = {
|
||||
size: string | null;
|
||||
weight: string | null;
|
||||
price: string;
|
||||
discountPrice: string;
|
||||
stock: string;
|
||||
};
|
||||
|
||||
@@ -32,26 +33,37 @@ function parseOptions(value: string): string[] {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function rowKey(color: string | null, size: string | null, weight: string | null): string {
|
||||
return `${color || ""}|${size || ""}|${weight || ""}`;
|
||||
}
|
||||
|
||||
function buildCombinations(
|
||||
colors: string[],
|
||||
sizes: string[],
|
||||
weights: string[]
|
||||
): VariantRow[] {
|
||||
): { color: string | null; size: string | null; weight: string | null }[] {
|
||||
const colorOptions = colors.length > 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<VariantRow[] | null>(null);
|
||||
const [rows, setRows] = useState<VariantRow[]>([]);
|
||||
|
||||
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")}
|
||||
</AuthNextButton>
|
||||
|
||||
{rows && rows.length > 0 && (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
{rows.map((row, index) => (
|
||||
<div
|
||||
key={`${row.color}-${row.size}-${row.weight}-${index}`}
|
||||
className="flex items-center gap-2 rounded-2xl border border-neutral-200 p-2 dark:border-neutral-700"
|
||||
>
|
||||
<span className="w-24 shrink-0 text-xs">
|
||||
{[row.color, row.size, row.weight].filter(Boolean).join(" / ") ||
|
||||
t("shops.defaultVariant")}
|
||||
</span>
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={t("shops.pricePlaceholder")}
|
||||
className="text-xs"
|
||||
value={row.price}
|
||||
onChange={(e) => updateRow(index, "price", e.target.value)}
|
||||
/>
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={t("shops.stockPlaceholder")}
|
||||
className="text-xs"
|
||||
value={row.stock}
|
||||
onChange={(e) => updateRow(index, "stock", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{rows.length > 0 && (
|
||||
<div className="mt-4 overflow-hidden rounded-2xl border border-neutral-200 dark:border-neutral-700">
|
||||
<div className="grid grid-cols-4 gap-1 bg-neutral-50 px-2 py-2 text-center text-[11px] font-bold dark:bg-neutral-800">
|
||||
<span>{t("shops.colorColumn")}</span>
|
||||
<span>{t("shops.sizeColumn")}</span>
|
||||
<span>{t("shops.weightColumn")}</span>
|
||||
<span>{t("shops.priceColumn")}</span>
|
||||
</div>
|
||||
|
||||
{rows.map((row, index) => {
|
||||
const percent = discountPercent(row.price, row.discountPrice);
|
||||
return (
|
||||
<div
|
||||
key={rowKey(row.color, row.size, row.weight) + index}
|
||||
className="border-t border-neutral-200 p-2 dark:border-neutral-700"
|
||||
>
|
||||
<div className="grid grid-cols-4 items-center gap-1">
|
||||
<span className="truncate text-center text-xs">
|
||||
{row.color || t("shops.defaultVariant")}
|
||||
</span>
|
||||
<span className="truncate text-center text-xs">
|
||||
{row.size || t("shops.defaultVariant")}
|
||||
</span>
|
||||
<span className="truncate text-center text-xs">
|
||||
{row.weight || "-"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(index)}
|
||||
aria-label={t("shops.removeVariant")}
|
||||
className="mx-auto text-[10px] text-red-500"
|
||||
>
|
||||
{t("shops.removeVariant")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={t("shops.pricePlaceholder")}
|
||||
className="text-xs"
|
||||
value={row.price}
|
||||
onChange={(e) => updateRow(index, "price", e.target.value)}
|
||||
/>
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={t("shops.discountPricePlaceholder")}
|
||||
className="text-xs"
|
||||
value={row.discountPrice}
|
||||
onChange={(e) =>
|
||||
updateRow(index, "discountPrice", e.target.value)
|
||||
}
|
||||
/>
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={t("shops.stockPlaceholder")}
|
||||
className="text-xs"
|
||||
value={row.stock}
|
||||
onChange={(e) => updateRow(index, "stock", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{percent != null && (
|
||||
<span className="mt-1 inline-block rounded-md bg-red-50 px-1.5 py-0.5 text-[10px] font-bold text-red-600 dark:bg-red-500/10">
|
||||
{t("shops.discountPercentLabel", { percent })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="border-t border-neutral-200 bg-neutral-50 px-2 py-2 text-center text-xs font-bold dark:border-neutral-700 dark:bg-neutral-800">
|
||||
{t("shops.totalVariantsLabel", { count: rows.length })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -180,7 +290,7 @@ function ProductVariantsPage() {
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
loading={loading}
|
||||
disabled={loading || !rows}
|
||||
disabled={loading || rows.length === 0}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{t("shops.saveProduct")}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex w-full items-center gap-2 rounded-3xl border border-neutral-200 px-4 py-3 dark:border-neutral-700">
|
||||
<span className="shrink-0 text-sm font-bold">{label}</span>
|
||||
<div className="flex flex-1 flex-wrap items-center justify-end gap-2 text-right">
|
||||
{children}
|
||||
</div>
|
||||
<BoldIcon name="arrow-down" size={16} tinted className="shrink-0 text-neutral-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ListingDetailPage() {
|
||||
const { t } = useTranslation("common");
|
||||
@@ -23,45 +48,118 @@ export default function ListingDetailPage() {
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
const [listing, setListing] = useState<IShopProductListing | null>(null);
|
||||
const [shop, setShop] = useState<ShopInfo | null>(null);
|
||||
const [variantId, setVariantId] = useState<string>("");
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [selectedColor, setSelectedColor] = useState<string | null>(null);
|
||||
const [selectedSize, setSelectedSize] = useState<string | null>(null);
|
||||
const [selectedWeight, setSelectedWeight] = useState<string | null>(null);
|
||||
const [shippingMethod, setShippingMethod] = useState<string>("");
|
||||
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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{listing.title}</PageTitle>
|
||||
<>
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span className="text-sm font-bold">{shopInfo?.name}</span>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
|
||||
{shopInfo?.logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={IMAGE_BASE_URL + shopInfo.logo}
|
||||
alt={shopInfo.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
|
||||
width={18}
|
||||
height={18}
|
||||
alt=""
|
||||
className="dark:invert"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex max-w-md flex-col gap-4">
|
||||
{primaryImage && (
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-800">
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + primaryImage}
|
||||
alt={listing.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="400px"
|
||||
unoptimized
|
||||
/>
|
||||
<div className="mx-auto mt-4 flex w-full max-w-md flex-col gap-4 rounded-3xl border border-neutral-200 p-4 dark:border-neutral-700">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-right text-xl font-bold">{listing.title}</h1>
|
||||
{discountPercent != null && (
|
||||
<span className="relative flex h-11 w-11 shrink-0 items-center justify-center">
|
||||
<span className="absolute inset-0 rounded-lg bg-red-500" />
|
||||
<span className="absolute inset-0 rotate-[22.5deg] rounded-lg bg-red-500" />
|
||||
<span className="relative z-10 text-sm font-extrabold text-white">
|
||||
{t("shops.discountPercentBadge", { percent: discountPercent })}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{listing.description && (
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
{listing.description}
|
||||
</p>
|
||||
)}
|
||||
{images.length > 0 && (
|
||||
<div className="relative">
|
||||
<div
|
||||
onScroll={handleImageScroll}
|
||||
className="flex w-full snap-x snap-mandatory gap-0 overflow-x-auto rounded-2xl [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{images.map((img) => (
|
||||
<button
|
||||
key={img}
|
||||
type="button"
|
||||
onClick={() => setShowLightbox(true)}
|
||||
className="relative aspect-[4/3] w-full shrink-0 snap-center overflow-hidden bg-neutral-200 dark:bg-neutral-800"
|
||||
>
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + img}
|
||||
alt={listing.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="400px"
|
||||
unoptimized
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{images.length > 1 && (
|
||||
<span className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-2 py-0.5 text-[10px] font-bold text-white">
|
||||
{activeImageIndex + 1}/{images.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{listing.variants.length > 1 && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold">{t("shops.variantsTitle")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{listing.variants.map((v) => (
|
||||
<p className="text-sm font-bold">{t("shops.specsTitle")}</p>
|
||||
|
||||
{categoryLine && (
|
||||
<SpecRow label={t("shops.categorySpecLabel")}>
|
||||
<span className="text-xs">{categoryLine}</span>
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{colors.length > 0 && (
|
||||
<SpecRow label={t("shops.colorLabel")}>
|
||||
{colors.map((color) => (
|
||||
<button
|
||||
key={v._id}
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setVariantId(v._id)}
|
||||
className={`rounded-full border px-3 py-1 text-xs ${
|
||||
variantId === v._id
|
||||
onClick={() => setSelectedColor(color)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
selectedColor === color
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
}`}
|
||||
)}
|
||||
>
|
||||
{[v.color, v.size, v.weight].filter(Boolean).join(" / ") ||
|
||||
t("shops.defaultVariant")}
|
||||
{color}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{selectedVariant && (
|
||||
<p className="text-lg font-bold">
|
||||
{t("shops.priceLabel", {
|
||||
amount: (
|
||||
selectedVariant.discount_price || selectedVariant.price
|
||||
).toLocaleString(),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{sizes.length > 0 && (
|
||||
<SpecRow label={t("shops.sizeLabel")}>
|
||||
{sizes.map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
type="button"
|
||||
onClick={() => setSelectedSize(size)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
selectedSize === size
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs">{t("shops.quantityLabel")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
className="h-8 w-8 rounded-full border border-neutral-300 dark:border-neutral-600"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="w-6 text-center text-sm">{quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setQuantity((q) =>
|
||||
selectedVariant ? Math.min(selectedVariant.stock, q + 1) : q + 1
|
||||
)
|
||||
}
|
||||
className="h-8 w-8 rounded-full border border-neutral-300 dark:border-neutral-600"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{weights.length > 0 && (
|
||||
<SpecRow label={t("shops.weightLabel")}>
|
||||
{weights.map((weight) => (
|
||||
<button
|
||||
key={weight}
|
||||
type="button"
|
||||
onClick={() => setSelectedWeight(weight)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
selectedWeight === weight
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{weight}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{shop && shop.shipping_methods.filter((m) => m.enabled).length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold">{t("shops.shippingTitle")}</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{shop.shipping_methods
|
||||
.filter((m) => m.enabled)
|
||||
.map((m) => (
|
||||
<label key={m.method} className="flex items-center gap-2 text-xs">
|
||||
<input
|
||||
type="radio"
|
||||
name="shippingMethod"
|
||||
checked={shippingMethod === m.method}
|
||||
onChange={() => setShippingMethod(m.method)}
|
||||
/>
|
||||
{t(`shops.shippingMethods.${m.method}`)} —{" "}
|
||||
{m.cost.toLocaleString()} {t("settings.toman")}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{enabledShippingMethods.length > 0 && (
|
||||
<SpecRow label={t("shops.shippingTitle")}>
|
||||
{enabledShippingMethods.map((m) => (
|
||||
<button
|
||||
key={m.method}
|
||||
type="button"
|
||||
onClick={() => setShippingMethod(m.method)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
shippingMethod === m.method
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{t(`shops.shippingMethods.${m.method}`)}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
loading={loading}
|
||||
disabled={loading || !selectedVariant}
|
||||
onClick={handleBuy}
|
||||
>
|
||||
{t("shops.buyNow")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
{listing.description && (
|
||||
<SpecRow label={t("shops.descriptionLabel")}>
|
||||
<span className="text-xs leading-relaxed">
|
||||
{descriptionExpanded || listing.description.length <= 50
|
||||
? listing.description
|
||||
: `${listing.description.slice(0, 50)}...`}
|
||||
{listing.description.length > 50 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDescriptionExpanded((v) => !v)}
|
||||
className="mr-1 font-bold text-pink-500"
|
||||
>
|
||||
{descriptionExpanded ? t("shops.showLess") : t("shops.showMore")}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{showAddressModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 text-center dark:bg-neutral-900">
|
||||
<p className="mb-4 text-sm">{t("shops.addressRequiredHint")}</p>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => router.push("/settings/edit/location")}
|
||||
>
|
||||
{t("shops.goToLocationSettings")}
|
||||
</AuthNextButton>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 block w-full text-xs text-neutral-500"
|
||||
onClick={() => setShowAddressModal(false)}
|
||||
onClick={() => void handleBuy()}
|
||||
disabled={loading || !matchedVariant}
|
||||
className="flex-1 rounded-3xl bg-green-600 py-2.5 text-center text-sm font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
{t("auth.skip")}
|
||||
{t("shops.placeOrderButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-green-600 text-lg font-bold text-white"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-8 shrink-0 text-center text-sm font-bold">{quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setQuantity((q) =>
|
||||
matchedVariant ? Math.min(matchedVariant.stock, q + 1) : q + 1
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-green-600 text-lg font-bold text-white"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{matchedVariant && (
|
||||
<SpecRow label={t("shops.priceSpecLabel")}>
|
||||
{matchedVariant.discount_price ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xs text-neutral-400 line-through">
|
||||
{matchedVariant.price.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-red-500">
|
||||
{matchedVariant.discount_price.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm font-bold">
|
||||
{matchedVariant.price.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
)}
|
||||
</SpecRow>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
|
||||
<div className="mx-auto mb-6 mt-8 flex w-full max-w-md gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddToCart}
|
||||
className="flex-1 rounded-3xl bg-green-600 py-3 text-sm font-bold text-white"
|
||||
>
|
||||
{t("shops.addToCartButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleFavorite}
|
||||
className="flex-1 rounded-3xl bg-sky-500 py-3 text-sm font-bold text-white"
|
||||
>
|
||||
{favorite ? t("shops.removeFromFavoritesButton") : t("shops.addToFavoritesButton")}
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/settings" />
|
||||
|
||||
{showAddressModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 text-center dark:bg-neutral-900">
|
||||
<p className="mb-4 text-sm">{t("shops.addressRequiredHint")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/settings/edit/location")}
|
||||
className="w-full rounded-3xl bg-pink-500 py-2.5 text-sm font-bold text-white"
|
||||
>
|
||||
{t("shops.goToLocationSettings")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 block w-full text-xs text-neutral-500"
|
||||
onClick={() => setShowAddressModal(false)}
|
||||
>
|
||||
{t("auth.skip")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProductImageLightbox
|
||||
images={images}
|
||||
initialIndex={activeImageIndex}
|
||||
alt={listing.title}
|
||||
open={showLightbox}
|
||||
onClose={() => setShowLightbox(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<IShopCategory[] | null>(null);
|
||||
const [category, setCategory] = useState("");
|
||||
const [subCategory, setSubCategory] = useState<string[]>([]);
|
||||
const [displaySubCategory, setDisplaySubCategory] = useState<string | null>(
|
||||
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 (
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">{t("shops.categoryTitle")}</span>
|
||||
<ShopCategoryPicker
|
||||
categoryList={categoryList}
|
||||
category={category}
|
||||
subCategory={subCategory}
|
||||
displaySubCategory={displaySubCategory}
|
||||
onCategorySelect={handleCategorySelect}
|
||||
onSubCategoryToggle={handleSubToggle}
|
||||
onDisplaySubCategoryChange={setDisplaySubCategory}
|
||||
/>
|
||||
</AuthPageContent>
|
||||
<AuthFormFooter>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{t("shops.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShopCategoryPage;
|
||||
@@ -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<Record<Day, boolean>>({
|
||||
@@ -57,6 +59,42 @@ function ShopContactPage() {
|
||||
setEnabledDays((prev) => ({ ...prev, [day]: !prev[day] }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!shopId) return;
|
||||
request<{
|
||||
shop: {
|
||||
contact?: Record<string, string | null>;
|
||||
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 (
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
@@ -97,102 +171,80 @@ function ShopContactPage() {
|
||||
<span className="text-xl font-bold mb-4">{t("shops.contactTitle")}</span>
|
||||
|
||||
<div className="flex w-full max-w-sm flex-col gap-2">
|
||||
<RoundedInput
|
||||
type="text"
|
||||
placeholder={t("billboards.form.landline")}
|
||||
maxLength={11}
|
||||
value={contact.landline}
|
||||
onChange={(e) =>
|
||||
setContact((prev) => ({ ...prev, landline: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<RoundedInput
|
||||
type="text"
|
||||
placeholder={t("shops.phonePlaceholder")}
|
||||
maxLength={11}
|
||||
value={contact.phone}
|
||||
onChange={(e) =>
|
||||
setContact((prev) => ({ ...prev, phone: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<RoundedInput
|
||||
type="text"
|
||||
placeholder={t("billboards.form.mobile")}
|
||||
maxLength={11}
|
||||
value={contact.mobile}
|
||||
onChange={(e) =>
|
||||
setContact((prev) => ({ ...prev, mobile: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<RoundedInput
|
||||
type="text"
|
||||
placeholder={t("billboards.form.telegram")}
|
||||
value={contact.telegram}
|
||||
onChange={(e) =>
|
||||
setContact((prev) => ({ ...prev, telegram: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<RoundedInput
|
||||
type="text"
|
||||
placeholder={t("billboards.form.whatsapp")}
|
||||
maxLength={11}
|
||||
value={contact.whatsapp}
|
||||
onChange={(e) =>
|
||||
setContact((prev) => ({ ...prev, whatsapp: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<RoundedInput
|
||||
type="text"
|
||||
placeholder={t("billboards.form.instagram")}
|
||||
value={contact.instagram}
|
||||
onChange={(e) =>
|
||||
setContact((prev) => ({ ...prev, instagram: e.target.value }))
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="mt-4 mb-1 text-sm font-bold">
|
||||
{t("shops.responseScheduleTitle")}
|
||||
</p>
|
||||
{DAYS.map((day) => (
|
||||
<div key={day} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="scale-125"
|
||||
checked={enabledDays[day]}
|
||||
onChange={() => toggleDay(day)}
|
||||
/>
|
||||
<span className="w-16 shrink-0 text-sm">
|
||||
{t(`shops.days.${day}`)}
|
||||
{contactFields.map((field) => (
|
||||
<div key={field.key} className="relative">
|
||||
<span className="absolute right-4 top-1/2 -translate-y-1/2">
|
||||
<Image src={field.icon} width={20} height={20} alt="" />
|
||||
</span>
|
||||
{enabledDays[day] && (
|
||||
<>
|
||||
<input
|
||||
type="time"
|
||||
className="rounded-xl border border-neutral-300 bg-white p-1 text-xs dark:bg-neutral-900"
|
||||
value={times[day].start}
|
||||
onChange={(e) =>
|
||||
setTimes((prev) => ({
|
||||
...prev,
|
||||
[day]: { ...prev[day], start: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="text-xs">-</span>
|
||||
<input
|
||||
type="time"
|
||||
className="rounded-xl border border-neutral-300 bg-white p-1 text-xs dark:bg-neutral-900"
|
||||
value={times[day].end}
|
||||
onChange={(e) =>
|
||||
setTimes((prev) => ({
|
||||
...prev,
|
||||
[day]: { ...prev[day], end: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<RoundedInput
|
||||
type="text"
|
||||
placeholder={field.placeholder}
|
||||
maxLength={field.maxLength}
|
||||
className="pr-11"
|
||||
value={contact[field.key]}
|
||||
onChange={(e) =>
|
||||
setContact((prev) => ({ ...prev, [field.key]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<p className="mt-6 mb-1 text-sm font-bold">
|
||||
{t("shops.responseScheduleTitle")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 rounded-3xl border border-neutral-200 p-3 dark:border-neutral-700">
|
||||
{DAYS.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="flex items-center justify-between gap-2 rounded-2xl border border-neutral-100 p-2.5 dark:border-neutral-800"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Image
|
||||
src="/images/icons/vuesax/bold/calendar.svg"
|
||||
width={20}
|
||||
height={20}
|
||||
alt=""
|
||||
className={enabledDays[day] ? "" : "opacity-40 grayscale"}
|
||||
/>
|
||||
<span className="w-16 shrink-0 text-sm">
|
||||
{t(`shops.days.${day}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{enabledDays[day] && (
|
||||
<div className="flex items-center gap-1">
|
||||
<TimeSelect
|
||||
ariaLabel={t("shops.responseEndTime")}
|
||||
value={times[day].end}
|
||||
onChange={(value) =>
|
||||
setTimes((prev) => ({
|
||||
...prev,
|
||||
[day]: { ...prev[day], end: value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="text-xs">-</span>
|
||||
<TimeSelect
|
||||
ariaLabel={t("shops.responseStartTime")}
|
||||
value={times[day].start}
|
||||
onChange={(value) =>
|
||||
setTimes((prev) => ({
|
||||
...prev,
|
||||
[day]: { ...prev[day], start: value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ToggleSwitch
|
||||
checked={enabledDays[day]}
|
||||
onChange={() => toggleDay(day)}
|
||||
ariaLabel={t(`shops.days.${day}`)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
<AuthFormFooter>
|
||||
|
||||
@@ -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<HTMLSelectElement>) => {
|
||||
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() {
|
||||
<span className="text-sm">{t("shops.noPhysicalShop")}</span>
|
||||
</label>
|
||||
|
||||
{hasPhysicalLocation && (
|
||||
<>
|
||||
<SelectBox value={provinceId} onChange={handleProvinceChange}>
|
||||
<SelectBox value={provinceId} onChange={handleProvinceChange}>
|
||||
<option disabled value="">
|
||||
{t("filters.province")}
|
||||
</option>
|
||||
@@ -167,8 +206,6 @@ function ShopLocationPage() {
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
<AuthFormFooter>
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [logoFile, setLogoFile] = useState<File | null>(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<HTMLInputElement>) => {
|
||||
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 =
|
||||
|
||||
@@ -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 (
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
@@ -53,20 +82,32 @@ function ShopNamePage() {
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold mb-4">{t("shops.nameTitle")}</span>
|
||||
<AuthInput
|
||||
<RoundedInput
|
||||
name="name"
|
||||
type="text"
|
||||
className={`max-w-[290px] text-center ${
|
||||
formik.touched.name && formik.errors.name
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: ""
|
||||
}`}
|
||||
placeholder={t("shops.namePlaceholder")}
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={Boolean(formik.touched.name && formik.errors.name)}
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && (
|
||||
<small className="mt-2 block text-center text-red-500">
|
||||
{formik.errors.name}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
name="description"
|
||||
className="mt-4 h-28 w-full max-w-sm rounded-3xl border border-neutral-300 bg-white p-4 font-medium dark:border-neutral-600 dark:bg-neutral-950 dark:text-neutral-50"
|
||||
placeholder={t("shops.descriptionPlaceholder")}
|
||||
value={formik.values.description}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
</AuthPageContent>
|
||||
<AuthFormFooter>
|
||||
<AuthNextButton
|
||||
|
||||
@@ -1,28 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useShopWizardId } from "@/hooks/useShopWizardId";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Map, { Marker } from "react-map-gl";
|
||||
import "mapbox-gl/dist/mapbox-gl.css";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const METHOD_ICON: Record<string, string> = {
|
||||
post: "send",
|
||||
tipax: "truck-fast",
|
||||
chapar: "truck-tick",
|
||||
intercity_freight: "truck",
|
||||
courier: "car",
|
||||
own_vehicle: "smart-car",
|
||||
};
|
||||
|
||||
const CONTACT_CHANNELS: {
|
||||
key: "mobile" | "landline" | "telegram" | "whatsapp" | "instagram";
|
||||
icon: string;
|
||||
color: string;
|
||||
}[] = [
|
||||
{ key: "mobile", icon: "/images/icons/vuesax/bold/mobile.svg", color: "bg-green-500" },
|
||||
{ key: "landline", icon: "/images/icons/vuesax/bold/call.svg", color: "bg-orange-500" },
|
||||
{ key: "telegram", icon: "/images/icons/telegram.svg", color: "bg-sky-500" },
|
||||
{ key: "whatsapp", icon: "/images/icons/vuesax/bold/whatsapp.svg", color: "bg-emerald-600" },
|
||||
{
|
||||
key: "instagram",
|
||||
icon: "/images/icons/vuesax/bold/instagram.svg",
|
||||
color: "bg-gradient-to-tr from-yellow-400 via-pink-500 to-purple-600",
|
||||
},
|
||||
];
|
||||
|
||||
type ShopPreview = {
|
||||
name: string;
|
||||
logo?: string | null;
|
||||
category?: string | null;
|
||||
sub_category?: string | null;
|
||||
shipping_methods?: { method: string; cost: number }[];
|
||||
address?: string | null;
|
||||
cod_available?: boolean;
|
||||
same_day_available?: boolean;
|
||||
free_shipping_threshold?: number | null;
|
||||
estimated_delivery_text?: string | null;
|
||||
has_physical_location?: boolean;
|
||||
contact?: { mobile?: string | null; telegram?: string | null } | null;
|
||||
province?: { name?: string } | null;
|
||||
city?: { name?: string } | null;
|
||||
neighbourhood?: string | null;
|
||||
address?: string | null;
|
||||
lat?: string | null;
|
||||
lng?: string | null;
|
||||
contact?: {
|
||||
mobile?: string | null;
|
||||
landline?: string | null;
|
||||
telegram?: string | null;
|
||||
whatsapp?: string | null;
|
||||
instagram?: string | null;
|
||||
} | null;
|
||||
response_schedule?: { day: string; start_time: string | null; end_time: string | null }[];
|
||||
};
|
||||
|
||||
function ShopPreviewPage() {
|
||||
@@ -43,12 +83,12 @@ function ShopPreviewPage() {
|
||||
const handleSubmit = async () => {
|
||||
if (!shopId) return;
|
||||
try {
|
||||
await request("POST", `/shops/${shopId}/submit`);
|
||||
await request("POST", `/shops/${shopId}/submit`, {});
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("shop_wizard_shop_id");
|
||||
}
|
||||
toast.success(t("shops.submitSuccess"));
|
||||
router.push("/settings/shop");
|
||||
router.push("/settings/shop/list");
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response
|
||||
@@ -59,55 +99,179 @@ function ShopPreviewPage() {
|
||||
|
||||
if (!shopId || !shop) return null;
|
||||
|
||||
const orderInfoRows = [
|
||||
shop.free_shipping_threshold
|
||||
? {
|
||||
key: "minOrder",
|
||||
label: t("shops.previewMinOrder"),
|
||||
value: `${shop.free_shipping_threshold.toLocaleString()} ${t("settings.toman")}`,
|
||||
}
|
||||
: null,
|
||||
shop.estimated_delivery_text
|
||||
? { key: "delivery", label: t("shops.previewDeliveryTime"), value: shop.estimated_delivery_text }
|
||||
: null,
|
||||
shop.cod_available
|
||||
? { key: "cod", label: t("shops.codAvailable"), value: t("shops.previewYes") }
|
||||
: null,
|
||||
shop.same_day_available
|
||||
? { key: "sameDay", label: t("shops.sameDayAvailable"), value: t("shops.previewYes") }
|
||||
: null,
|
||||
].filter(Boolean) as { key: string; label: string; value: string }[];
|
||||
|
||||
const hasLocationInfo =
|
||||
shop.has_physical_location &&
|
||||
(shop.address || shop.neighbourhood || shop.city?.name || shop.province?.name);
|
||||
const hasMap = shop.has_physical_location && shop.lat && shop.lng;
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold mb-4">{t("shops.previewTitle")}</span>
|
||||
<Container>
|
||||
<div className="mx-auto flex w-full max-w-sm flex-col items-center gap-5 rounded-3xl bg-white p-6 text-center shadow-md dark:bg-neutral-900">
|
||||
<h1 className="text-xl font-bold">{t("shops.previewTitle")}</h1>
|
||||
|
||||
<div className="flex w-full max-w-sm flex-col items-center gap-3 text-center">
|
||||
{shop.logo && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={IMAGE_BASE_URL + shop.logo}
|
||||
alt={shop.name}
|
||||
className="h-24 w-24 rounded-2xl object-cover"
|
||||
/>
|
||||
)}
|
||||
<p className="text-lg font-bold">{shop.name}</p>
|
||||
{shop.category && (
|
||||
<p className="text-sm text-neutral-500">
|
||||
{shop.category}
|
||||
{shop.sub_category ? ` / ${shop.sub_category}` : ""}
|
||||
</p>
|
||||
)}
|
||||
{shop.shipping_methods && shop.shipping_methods.length > 0 && (
|
||||
<div className="w-full text-sm">
|
||||
<p className="mb-1 font-semibold">{t("shops.shippingTitle")}</p>
|
||||
{shop.logo && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={IMAGE_BASE_URL + shop.logo}
|
||||
alt={shop.name}
|
||||
className="h-32 w-32 rounded-2xl object-cover shadow-sm"
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="text-lg font-bold">{shop.name}</p>
|
||||
|
||||
{shop.shipping_methods && shop.shipping_methods.length > 0 && (
|
||||
<div className="flex w-full flex-col gap-2 text-right">
|
||||
<p className="text-sm font-semibold">{t("shops.shippingTitle")}</p>
|
||||
<div className="flex flex-col gap-1.5 rounded-2xl border border-neutral-200 p-2.5 dark:border-neutral-700">
|
||||
{shop.shipping_methods.map((m) => (
|
||||
<p key={m.method} className="text-neutral-500">
|
||||
{t(`shops.shippingMethods.${m.method}`)} —{" "}
|
||||
{m.cost.toLocaleString()} {t("settings.toman")}
|
||||
</p>
|
||||
<div key={m.method} className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-100 dark:bg-neutral-800">
|
||||
<BoldIcon name={METHOD_ICON[m.method] || "box"} size={16} tinted />
|
||||
</span>
|
||||
<span className="text-xs font-medium">
|
||||
{t(`shops.shippingMethods.${m.method}`)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{m.cost.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{orderInfoRows.length > 0 && (
|
||||
<div className="grid w-full grid-cols-2 gap-px overflow-hidden rounded-2xl border border-neutral-200 bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-700">
|
||||
{orderInfoRows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className="flex flex-col items-center gap-0.5 bg-white p-2.5 dark:bg-neutral-900"
|
||||
>
|
||||
<span className="text-[11px] text-neutral-400">{row.label}</span>
|
||||
<span className="text-xs font-semibold">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shop.contact &&
|
||||
CONTACT_CHANNELS.some((ch) => shop.contact?.[ch.key]) && (
|
||||
<div className="flex w-full flex-col gap-2 text-right">
|
||||
<p className="text-sm font-semibold">{t("shops.contactTitle")}</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{CONTACT_CHANNELS.filter((ch) => shop.contact?.[ch.key]).map((ch) => (
|
||||
<div
|
||||
key={ch.key}
|
||||
className="flex items-center gap-2 rounded-full bg-neutral-50 py-1 pl-3 pr-1 dark:bg-neutral-800"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full",
|
||||
ch.color
|
||||
)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={ch.icon} alt="" className="h-3.5 w-3.5 brightness-0 invert" />
|
||||
</span>
|
||||
<span className="text-xs font-medium" dir="ltr">
|
||||
{shop.contact?.[ch.key]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{shop.has_physical_location && shop.address && (
|
||||
<p className="text-sm text-neutral-500">{shop.address}</p>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
<AuthFormFooter>
|
||||
<AuthNextButton
|
||||
|
||||
{shop.response_schedule && shop.response_schedule.length > 0 && (
|
||||
<div className="flex w-full flex-col gap-2 text-right">
|
||||
<p className="text-sm font-semibold">{t("shops.responseScheduleTitle")}</p>
|
||||
<div className="flex flex-col gap-1.5 rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700">
|
||||
{shop.response_schedule.map((item) => (
|
||||
<div key={item.day} className="flex items-center justify-between text-xs">
|
||||
<span className="text-neutral-500" dir="ltr">
|
||||
{item.start_time || "--:--"} - {item.end_time || "--:--"}
|
||||
</span>
|
||||
<span className="font-medium">{t(`shops.days.${item.day}`)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasLocationInfo && (
|
||||
<div className="flex w-full flex-col gap-2 text-right">
|
||||
{(shop.city?.name || shop.neighbourhood) && (
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{shop.city?.name && (
|
||||
<span className="rounded-full border border-neutral-200 px-3 py-1 text-xs dark:border-neutral-700">
|
||||
{shop.city.name}
|
||||
</span>
|
||||
)}
|
||||
{shop.neighbourhood && (
|
||||
<span className="rounded-full border border-neutral-200 px-3 py-1 text-xs dark:border-neutral-700">
|
||||
{shop.neighbourhood}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{shop.address && (
|
||||
<div className="w-full rounded-2xl border border-neutral-200 p-3 text-xs text-neutral-600 dark:border-neutral-700 dark:text-neutral-300">
|
||||
{shop.address}
|
||||
</div>
|
||||
)}
|
||||
{hasMap && (
|
||||
<div className="w-full overflow-hidden rounded-2xl">
|
||||
<Map
|
||||
style={{ height: "150px" }}
|
||||
initialViewState={{
|
||||
longitude: Number(shop.lng),
|
||||
latitude: Number(shop.lat),
|
||||
zoom: 12,
|
||||
}}
|
||||
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
|
||||
mapStyle="mapbox://styles/mapbox/streets-v11"
|
||||
interactive={false}
|
||||
>
|
||||
<Marker latitude={Number(shop.lat)} longitude={Number(shop.lng)} />
|
||||
</Map>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RoundedButton
|
||||
type="button"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="mt-2 w-full max-w-[220px] border-2 border-neutral-200 bg-white text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white"
|
||||
>
|
||||
{t("shops.submitShop")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
{loading ? t("common.loading") : t("shops.submitShop")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,14 +7,16 @@ 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 BoldIcon from "@/components/ui/BoldIcon";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useShopWizardId } from "@/hooks/useShopWizardId";
|
||||
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";
|
||||
|
||||
const SHIPPING_METHODS = [
|
||||
const DELIVERY_METHODS = [
|
||||
"post",
|
||||
"tipax",
|
||||
"chapar",
|
||||
@@ -23,7 +25,16 @@ const SHIPPING_METHODS = [
|
||||
"own_vehicle",
|
||||
] as const;
|
||||
|
||||
type ShippingMethod = (typeof SHIPPING_METHODS)[number];
|
||||
const METHOD_ICON: Record<(typeof DELIVERY_METHODS)[number], string> = {
|
||||
post: "send",
|
||||
tipax: "truck-fast",
|
||||
chapar: "truck-tick",
|
||||
intercity_freight: "truck",
|
||||
courier: "car",
|
||||
own_vehicle: "smart-car",
|
||||
};
|
||||
|
||||
type DeliveryMethod = (typeof DELIVERY_METHODS)[number];
|
||||
|
||||
function ShopShippingPage() {
|
||||
const { t } = useTranslation("common");
|
||||
@@ -31,9 +42,7 @@ function ShopShippingPage() {
|
||||
const { request, loading } = useAxios();
|
||||
const shopId = useShopWizardId();
|
||||
|
||||
const [enabledMethods, setEnabledMethods] = useState<
|
||||
Record<ShippingMethod, boolean>
|
||||
>({
|
||||
const [enabledMethods, setEnabledMethods] = useState<Record<DeliveryMethod, boolean>>({
|
||||
post: false,
|
||||
tipax: false,
|
||||
chapar: false,
|
||||
@@ -41,7 +50,7 @@ function ShopShippingPage() {
|
||||
courier: false,
|
||||
own_vehicle: false,
|
||||
});
|
||||
const [costs, setCosts] = useState<Record<ShippingMethod, string>>({
|
||||
const [costs, setCosts] = useState<Record<DeliveryMethod, string>>({
|
||||
post: "",
|
||||
tipax: "",
|
||||
chapar: "",
|
||||
@@ -54,14 +63,49 @@ function ShopShippingPage() {
|
||||
const [freeShippingThreshold, setFreeShippingThreshold] = useState("");
|
||||
const [estimatedDeliveryText, setEstimatedDeliveryText] = useState("");
|
||||
|
||||
const toggleMethod = (method: ShippingMethod) => {
|
||||
useEffect(() => {
|
||||
if (!shopId) return;
|
||||
request<{
|
||||
shop: {
|
||||
shipping_methods?: { method: DeliveryMethod; cost: number }[];
|
||||
cod_available?: boolean;
|
||||
same_day_available?: boolean;
|
||||
free_shipping_threshold?: number | null;
|
||||
estimated_delivery_text?: string | null;
|
||||
};
|
||||
}>("GET", `/shops/${shopId}`).then((res) => {
|
||||
const shop = res?.shop;
|
||||
if (!shop) return;
|
||||
if (shop.shipping_methods?.length) {
|
||||
const nextEnabled = { ...enabledMethods };
|
||||
const nextCosts = { ...costs };
|
||||
shop.shipping_methods.forEach((m) => {
|
||||
nextEnabled[m.method] = true;
|
||||
nextCosts[m.method] = String(m.cost || "");
|
||||
});
|
||||
setEnabledMethods(nextEnabled);
|
||||
setCosts(nextCosts);
|
||||
}
|
||||
setCodAvailable(Boolean(shop.cod_available));
|
||||
setSameDayAvailable(Boolean(shop.same_day_available));
|
||||
if (shop.free_shipping_threshold) {
|
||||
setFreeShippingThreshold(String(shop.free_shipping_threshold));
|
||||
}
|
||||
if (shop.estimated_delivery_text) {
|
||||
setEstimatedDeliveryText(shop.estimated_delivery_text);
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shopId]);
|
||||
|
||||
const toggleMethod = (method: DeliveryMethod) => {
|
||||
setEnabledMethods((prev) => ({ ...prev, [method]: !prev[method] }));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!shopId) return;
|
||||
|
||||
const shipping_methods = SHIPPING_METHODS.filter(
|
||||
const shipping_methods = DELIVERY_METHODS.filter(
|
||||
(method) => enabledMethods[method]
|
||||
).map((method) => ({
|
||||
method,
|
||||
@@ -102,66 +146,92 @@ function ShopShippingPage() {
|
||||
<span className="text-xl font-bold mb-4">{t("shops.shippingTitle")}</span>
|
||||
|
||||
<div className="flex w-full max-w-sm flex-col gap-3">
|
||||
{SHIPPING_METHODS.map((method) => (
|
||||
<div key={method} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="scale-125"
|
||||
checked={enabledMethods[method]}
|
||||
onChange={() => toggleMethod(method)}
|
||||
/>
|
||||
<span className="w-32 shrink-0 text-sm">
|
||||
{t(`shops.shippingMethods.${method}`)}
|
||||
</span>
|
||||
{enabledMethods[method] && (
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={t("shops.shippingCostPlaceholder")}
|
||||
className="text-sm"
|
||||
value={costs[method]}
|
||||
onChange={(e) =>
|
||||
setCosts((prev) => ({ ...prev, [method]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="rounded-3xl border border-neutral-200 p-3 dark:border-neutral-700">
|
||||
<p className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
||||
<BoldIcon name="box" size={18} tinted />
|
||||
{t("shops.activeShippingMethods")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{DELIVERY_METHODS.map((method) => (
|
||||
<div
|
||||
key={method}
|
||||
className="flex flex-col gap-2 rounded-2xl border border-neutral-100 p-2.5 dark:border-neutral-800"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-100 dark:bg-neutral-800">
|
||||
<BoldIcon name={METHOD_ICON[method]} size={18} tinted />
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{t(`shops.shippingMethods.${method}`)}
|
||||
</span>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={enabledMethods[method]}
|
||||
onChange={() => toggleMethod(method)}
|
||||
ariaLabel={t(`shops.shippingMethods.${method}`)}
|
||||
/>
|
||||
</div>
|
||||
{enabledMethods[method] && (
|
||||
<div className="flex items-center gap-2">
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={t("shops.shippingCostPlaceholder")}
|
||||
className="flex-1 text-sm"
|
||||
value={costs[method]}
|
||||
onChange={(e) =>
|
||||
setCosts((prev) => ({ ...prev, [method]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{t("settings.toman")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="scale-125"
|
||||
checked={codAvailable}
|
||||
onChange={() => setCodAvailable((v) => !v)}
|
||||
/>
|
||||
<span className="text-sm">{t("shops.codAvailable")}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="scale-125"
|
||||
checked={sameDayAvailable}
|
||||
onChange={() => setSameDayAvailable((v) => !v)}
|
||||
/>
|
||||
<span className="text-sm">{t("shops.sameDayAvailable")}</span>
|
||||
</label>
|
||||
<div className="rounded-3xl border border-neutral-200 p-3 dark:border-neutral-700">
|
||||
<p className="mb-3 text-sm font-semibold">
|
||||
{t("shops.supplementarySettings")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between rounded-2xl border border-neutral-100 p-3 dark:border-neutral-800">
|
||||
<span className="text-sm">{t("shops.codAvailable")}</span>
|
||||
<ToggleSwitch
|
||||
checked={codAvailable}
|
||||
onChange={setCodAvailable}
|
||||
ariaLabel={t("shops.codAvailable")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-neutral-100 p-3 dark:border-neutral-800">
|
||||
<span className="text-sm">{t("shops.sameDayAvailable")}</span>
|
||||
<ToggleSwitch
|
||||
checked={sameDayAvailable}
|
||||
onChange={setSameDayAvailable}
|
||||
ariaLabel={t("shops.sameDayAvailable")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
className="mt-2"
|
||||
placeholder={t("shops.freeShippingThresholdPlaceholder")}
|
||||
value={freeShippingThreshold}
|
||||
onChange={(e) => setFreeShippingThreshold(e.target.value)}
|
||||
/>
|
||||
<RoundedInput
|
||||
type="text"
|
||||
className="mt-2"
|
||||
placeholder={t("shops.estimatedDeliveryPlaceholder")}
|
||||
value={estimatedDeliveryText}
|
||||
onChange={(e) => setEstimatedDeliveryText(e.target.value)}
|
||||
/>
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
className="mt-1"
|
||||
placeholder={t("shops.freeShippingThresholdPlaceholder")}
|
||||
value={freeShippingThreshold}
|
||||
onChange={(e) => setFreeShippingThreshold(e.target.value)}
|
||||
/>
|
||||
<RoundedInput
|
||||
type="text"
|
||||
placeholder={t("shops.estimatedDeliveryPlaceholder")}
|
||||
value={estimatedDeliveryText}
|
||||
onChange={(e) => setEstimatedDeliveryText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
<AuthFormFooter>
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import BuyerInfoModal from "@/components/shops/orders/BuyerInfoModal";
|
||||
import ShopInfoModal from "@/components/shops/orders/ShopInfoModal";
|
||||
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useParams } from "next/navigation";
|
||||
@@ -16,9 +20,23 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
const TRACKING_REQUIRED_METHODS = ["post", "tipax", "chapar", "intercity_freight"];
|
||||
const SELLER_STATUSES = ["on_hold", "processing", "shipped", "ready_for_pickup"];
|
||||
/** These carriers have a public tracking page we can deep-link to. */
|
||||
const BOLD_TRACKING_METHODS = ["post", "tipax", "chapar"];
|
||||
|
||||
function trackingUrl(method: string, code: string): string | null {
|
||||
if (method === "post") return `https://tracking.post.ir/?id=${code}`;
|
||||
if (method === "tipax") return `https://tipaxco.com/tracking?code=${code}`;
|
||||
if (method === "chapar") return `https://www.chapar.co/tracking?code=${code}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
type OrderReport = { _id: string; description: string; status: string; createdAt: string };
|
||||
type ReturnRequest = { _id: string; reason: string; status: string; createdAt: string };
|
||||
type ShopRating = { _id: string; rating: number; comment?: string | null };
|
||||
|
||||
type OrderDetail = {
|
||||
_id: string;
|
||||
order_number: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
total_amount: number;
|
||||
@@ -33,12 +51,26 @@ type OrderDetail = {
|
||||
buyerAddressSnapshot?: {
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
mobile?: string | null;
|
||||
address?: string | null;
|
||||
province?: { name?: string } | null;
|
||||
city?: { name?: string } | null;
|
||||
lat?: string | null;
|
||||
lng?: string | null;
|
||||
} | null;
|
||||
listing: { title: string; images: string[]; primaryImageIndex: number };
|
||||
shop: { _id: string; name: string; owner: string };
|
||||
shop: {
|
||||
_id: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
estimated_delivery_text?: string | null;
|
||||
has_physical_location?: boolean;
|
||||
province?: { name?: string } | null;
|
||||
city?: { name?: string } | null;
|
||||
address?: string | null;
|
||||
lat?: string | null;
|
||||
lng?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export default function OrderDetailPage() {
|
||||
@@ -48,23 +80,34 @@ export default function OrderDetailPage() {
|
||||
const user = useUser();
|
||||
|
||||
const [order, setOrder] = useState<OrderDetail | null>(null);
|
||||
const [report, setReport] = useState<OrderReport | null>(null);
|
||||
const [returnRequest, setReturnRequest] = useState<ReturnRequest | null>(null);
|
||||
const [shopRating, setShopRating] = useState<ShopRating | null>(null);
|
||||
const [trackingCode, setTrackingCode] = useState("");
|
||||
const [printMode, setPrintMode] = useState<"label-a5" | "label-a6" | "invoice" | null>(
|
||||
null
|
||||
);
|
||||
const [printMode, setPrintMode] = useState<"invoice" | null>(null);
|
||||
const [showRating, setShowRating] = useState(false);
|
||||
const [ratingScore, setRatingScore] = useState(5);
|
||||
const [ratingScore, setRatingScore] = useState(0);
|
||||
const [ratingComment, setRatingComment] = useState("");
|
||||
const [showReport, setShowReport] = useState(false);
|
||||
const [reportText, setReportText] = useState("");
|
||||
const [showReturn, setShowReturn] = useState(false);
|
||||
const [returnReason, setReturnReason] = useState("");
|
||||
const [showBuyerInfo, setShowBuyerInfo] = useState(false);
|
||||
const [showShopInfo, setShowShopInfo] = useState(false);
|
||||
|
||||
const loadOrder = () => {
|
||||
request<{ order: OrderDetail }>("GET", `/orders/${params.orderId}`)
|
||||
request<{
|
||||
order: OrderDetail;
|
||||
report?: OrderReport | null;
|
||||
returnRequest?: ReturnRequest | null;
|
||||
shopRating?: ShopRating | null;
|
||||
}>("GET", `/orders/${params.orderId}`)
|
||||
.then((res) => {
|
||||
setOrder(res?.order || null);
|
||||
setTrackingCode(res?.order?.tracking_code || "");
|
||||
setReport(res?.report || null);
|
||||
setReturnRequest(res?.returnRequest || null);
|
||||
setShopRating(res?.shopRating || null);
|
||||
})
|
||||
.catch(() => setOrder(null));
|
||||
};
|
||||
@@ -107,7 +150,7 @@ export default function OrderDetailPage() {
|
||||
};
|
||||
|
||||
const handleSubmitRating = async () => {
|
||||
if (!order) return;
|
||||
if (!order || ratingScore < 1) return;
|
||||
try {
|
||||
await request("POST", `/orders/${order._id}/rate`, {
|
||||
rating: ratingScore,
|
||||
@@ -116,6 +159,7 @@ export default function OrderDetailPage() {
|
||||
toast.success(t("shops.ratingSubmitted"));
|
||||
setShowRating(false);
|
||||
setRatingComment("");
|
||||
loadOrder();
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response
|
||||
@@ -133,6 +177,7 @@ export default function OrderDetailPage() {
|
||||
toast.success(t("shops.reportSubmitted"));
|
||||
setShowReport(false);
|
||||
setReportText("");
|
||||
loadOrder();
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response
|
||||
@@ -150,6 +195,7 @@ export default function OrderDetailPage() {
|
||||
toast.success(t("shops.returnRequestSubmitted"));
|
||||
setShowReturn(false);
|
||||
setReturnReason("");
|
||||
loadOrder();
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response
|
||||
@@ -209,235 +255,329 @@ export default function OrderDetailPage() {
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const boldTracking =
|
||||
order.shipping_method && BOLD_TRACKING_METHODS.includes(order.shipping_method);
|
||||
const trackingHref =
|
||||
order.tracking_code && order.shipping_method && boldTracking
|
||||
? trackingUrl(order.shipping_method, order.tracking_code)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="no-print">
|
||||
<PageTitle>{t("shops.orderDetailTitle")}</PageTitle>
|
||||
<>
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<div className="no-print">
|
||||
<PageTitle>{t("shops.orderDetailTitle")}</PageTitle>
|
||||
<p className="mx-auto -mt-2 mb-2 max-w-md text-center text-xs text-neutral-500">
|
||||
{t("shops.orderNumberLabel")}: {order.order_number}
|
||||
</p>
|
||||
|
||||
<div className="mx-auto flex max-w-md flex-col gap-4">
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-neutral-200 p-2 dark:border-neutral-700">
|
||||
{primaryImage && (
|
||||
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-neutral-200 dark:bg-neutral-800">
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + primaryImage}
|
||||
alt={order.listing.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="64px"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{order.listing.title}</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{order.total_amount.toLocaleString()} {t("settings.toman")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<p className="mb-1 font-semibold">{t("shops.buyerInfoTitle")}</p>
|
||||
<p className="text-neutral-500">{buyerName}</p>
|
||||
<p className="text-neutral-500">
|
||||
{order.buyerAddressSnapshot?.province?.name}{" "}
|
||||
{order.buyerAddressSnapshot?.city?.name}
|
||||
</p>
|
||||
<p className="text-neutral-500">{order.buyerAddressSnapshot?.address}</p>
|
||||
<p className="mt-1 text-neutral-500">{order.createdAt}</p>
|
||||
{order.shipping_method && (
|
||||
<p className="text-neutral-500">
|
||||
{t(`shops.shippingMethods.${order.shipping_method}`)}
|
||||
</p>
|
||||
)}
|
||||
{order.tracking_code &&
|
||||
(order.shipping_method === "post" ? (
|
||||
<a
|
||||
href={`https://tracking.post.ir/?id=${order.tracking_code}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-bold text-blue-600 underline dark:text-blue-400"
|
||||
>
|
||||
{t("shops.trackingCodeLabel")}: {order.tracking_code}
|
||||
</a>
|
||||
) : (
|
||||
<p className="font-bold">
|
||||
{t("shops.trackingCodeLabel")}: {order.tracking_code}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<AuthNextButton type="button" onClick={() => setPrintMode("label-a5")}>
|
||||
{t("shops.printLabelA5")}
|
||||
</AuthNextButton>
|
||||
<AuthNextButton type="button" onClick={() => setPrintMode("label-a6")}>
|
||||
{t("shops.printLabelA6")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
<AuthNextButton type="button" onClick={() => setPrintMode("invoice")}>
|
||||
{t("shops.printInvoice")}
|
||||
</AuthNextButton>
|
||||
|
||||
{isSeller && (
|
||||
<>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold">{t("shops.trackingCodeLabel")}</p>
|
||||
<div className="flex gap-2">
|
||||
<RoundedInput
|
||||
type="text"
|
||||
value={trackingCode}
|
||||
onChange={(e) => setTrackingCode(e.target.value)}
|
||||
<div className="mx-auto flex max-w-md flex-col gap-4">
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-neutral-200 p-2 dark:border-neutral-700">
|
||||
{primaryImage && (
|
||||
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-neutral-200 dark:bg-neutral-800">
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + primaryImage}
|
||||
alt={order.listing.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="64px"
|
||||
unoptimized
|
||||
/>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={handleSaveTracking}
|
||||
>
|
||||
{t("common.save")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold">{t("shops.changeStatusLabel")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SELLER_STATUSES.map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => handleStatusChange(status)}
|
||||
className={`rounded-full border px-3 py-1 text-xs ${
|
||||
order.status === status
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
}`}
|
||||
>
|
||||
{t(`shops.orderStatus.${status}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isBuyer && (
|
||||
<>
|
||||
{canConfirmReceipt && (
|
||||
<AuthNextButton type="button" onClick={handleConfirmReceipt}>
|
||||
{t("shops.confirmReceipt")}
|
||||
</AuthNextButton>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">
|
||||
{order.listing.title}{" "}
|
||||
<span className="font-normal text-neutral-500">× {order.quantity}</span>
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{order.total_amount.toLocaleString()} {t("settings.toman")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AuthNextButton type="button" onClick={() => setShowRating((v) => !v)}>
|
||||
{t("shops.rateShop")}
|
||||
{isSeller && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBuyerInfo(true)}
|
||||
className="rounded-2xl border border-neutral-200 p-3 text-right text-sm dark:border-neutral-700"
|
||||
>
|
||||
<p className="font-semibold">{t("shops.buyerInfoTitle")}</p>
|
||||
<p className="mt-1 text-xs text-neutral-500">{buyerName}</p>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isBuyer && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowShopInfo(true)}
|
||||
className="rounded-2xl border border-neutral-200 p-3 text-right text-sm dark:border-neutral-700"
|
||||
>
|
||||
<p className="font-semibold">{t("shops.shopInfoTitle")}</p>
|
||||
<p className="mt-1 text-xs text-neutral-500">{order.shop.name}</p>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="rounded-2xl border border-neutral-200 p-4 text-sm dark:border-neutral-700">
|
||||
<div className="flex items-center justify-between border-b border-neutral-100 py-2 dark:border-neutral-800">
|
||||
<span className="text-neutral-500">{t("shops.shippingTimeLabel")}</span>
|
||||
<span>
|
||||
{[order.createdAt, order.shop.estimated_delivery_text]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
</span>
|
||||
</div>
|
||||
{order.shipping_method && (
|
||||
<div className="flex items-center justify-between border-b border-neutral-100 py-2 dark:border-neutral-800">
|
||||
<span className="text-neutral-500">{t("shops.shippingTypeLabel")}</span>
|
||||
<span>{t(`shops.shippingMethods.${order.shipping_method}`)}</span>
|
||||
</div>
|
||||
)}
|
||||
{order.tracking_code && (
|
||||
<div className="flex items-center justify-between border-b border-neutral-100 py-2 dark:border-neutral-800">
|
||||
<span className="text-neutral-500">{t("shops.trackingCodeLabel")}</span>
|
||||
{trackingHref ? (
|
||||
<a
|
||||
href={trackingHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-bold text-blue-600 underline dark:text-blue-400"
|
||||
>
|
||||
{order.tracking_code}
|
||||
</a>
|
||||
) : (
|
||||
<span>{order.tracking_code}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between border-b border-neutral-100 py-2 dark:border-neutral-800">
|
||||
<span className="text-neutral-500">{t("shops.shippingCostLabel")}</span>
|
||||
<span>
|
||||
{order.shipping_cost.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="font-semibold">{t("shops.grandTotalLabel")}</span>
|
||||
<span className="font-bold text-green-600">
|
||||
{order.total_amount.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSeller && (
|
||||
<AuthNextButton type="button" onClick={() => setPrintMode("invoice")}>
|
||||
{t("shops.printInvoice")}
|
||||
</AuthNextButton>
|
||||
{showRating && (
|
||||
<div className="rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700">
|
||||
<div className="mb-2 flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
)}
|
||||
|
||||
{isSeller && (
|
||||
<>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold">{t("shops.trackingCodeLabel")}</p>
|
||||
<div className="flex gap-2">
|
||||
<RoundedInput
|
||||
type="text"
|
||||
value={trackingCode}
|
||||
onChange={(e) => setTrackingCode(e.target.value)}
|
||||
/>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={handleSaveTracking}
|
||||
>
|
||||
{t("common.save")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold">{t("shops.changeStatusLabel")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SELLER_STATUSES.map((status) => (
|
||||
<button
|
||||
key={n}
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => setRatingScore(n)}
|
||||
className={`h-8 w-8 rounded-full border text-xs ${
|
||||
ratingScore >= n
|
||||
onClick={() => handleStatusChange(status)}
|
||||
className={`rounded-full border px-3 py-1 text-xs ${
|
||||
order.status === status
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
{t(`shops.orderStatus.${status}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
className="h-20 w-full rounded-2xl border border-neutral-300 bg-white p-2 text-xs dark:border-neutral-600 dark:bg-neutral-950"
|
||||
placeholder={t("shops.ratingCommentPlaceholder")}
|
||||
value={ratingComment}
|
||||
onChange={(e) => setRatingComment(e.target.value)}
|
||||
/>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
className="mt-2"
|
||||
onClick={handleSubmitRating}
|
||||
>
|
||||
{t("common.save")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AuthNextButton type="button" onClick={() => setShowReport((v) => !v)}>
|
||||
{t("shops.reportProblem")}
|
||||
</AuthNextButton>
|
||||
{showReport && (
|
||||
<div className="rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700">
|
||||
<textarea
|
||||
className="h-20 w-full rounded-2xl border border-neutral-300 bg-white p-2 text-xs dark:border-neutral-600 dark:bg-neutral-950"
|
||||
placeholder={t("shops.reportPlaceholder")}
|
||||
value={reportText}
|
||||
onChange={(e) => setReportText(e.target.value)}
|
||||
/>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
className="mt-2"
|
||||
onClick={handleSubmitReport}
|
||||
>
|
||||
{t("shops.submitReport")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
)}
|
||||
{report && (
|
||||
<div className="rounded-2xl border border-amber-300 bg-amber-50 p-3 text-xs dark:border-amber-700 dark:bg-amber-950/30">
|
||||
<p className="mb-1 font-semibold">{t("shops.reportStatusTitle")}</p>
|
||||
<p className="text-neutral-600 dark:text-neutral-300">{report.description}</p>
|
||||
<p className="mt-1 text-neutral-500">
|
||||
{t(`shops.reportStatus.${report.status}`)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canRequestReturn && (
|
||||
<>
|
||||
<AuthNextButton type="button" onClick={() => setShowReturn((v) => !v)}>
|
||||
{t("shops.requestReturn")}
|
||||
{returnRequest && (
|
||||
<div className="rounded-2xl border border-neutral-200 p-3 text-xs dark:border-neutral-700">
|
||||
<p className="mb-1 font-semibold">{t("shops.returnStatusTitle")}</p>
|
||||
<p className="text-neutral-600 dark:text-neutral-300">{returnRequest.reason}</p>
|
||||
<p className="mt-1 text-neutral-500">
|
||||
{t(`shops.returnStatus.${returnRequest.status}`)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isBuyer && (
|
||||
<>
|
||||
{canConfirmReceipt && (
|
||||
<AuthNextButton type="button" onClick={handleConfirmReceipt}>
|
||||
{t("shops.confirmReceipt")}
|
||||
</AuthNextButton>
|
||||
{showReturn && (
|
||||
<div className="rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700">
|
||||
<textarea
|
||||
className="h-20 w-full rounded-2xl border border-neutral-300 bg-white p-2 text-xs dark:border-neutral-600 dark:bg-neutral-950"
|
||||
placeholder={t("shops.returnReasonPlaceholder")}
|
||||
value={returnReason}
|
||||
onChange={(e) => setReturnReason(e.target.value)}
|
||||
)}
|
||||
|
||||
{shopRating ? (
|
||||
<div className="rounded-2xl border border-neutral-200 p-3 text-xs dark:border-neutral-700">
|
||||
<p className="mb-1 flex items-center gap-1 font-semibold">
|
||||
{t("shops.yourRatingLabel")}: {shopRating.rating}
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/star1.png")}
|
||||
alt=""
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
className="mt-2"
|
||||
onClick={handleSubmitReturn}
|
||||
>
|
||||
{t("shops.submitReturn")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</p>
|
||||
{shopRating.comment && (
|
||||
<p className="text-neutral-600 dark:text-neutral-300">
|
||||
{shopRating.comment}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AuthNextButton type="button" onClick={() => setShowRating((v) => !v)}>
|
||||
{t("shops.rateShop")}
|
||||
</AuthNextButton>
|
||||
{showRating && (
|
||||
<div className="rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700">
|
||||
<div className="mb-2 flex items-center justify-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setRatingScore(n === ratingScore ? 0 : n)}
|
||||
className="p-0.5"
|
||||
>
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/star1.png")}
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
className={n <= ratingScore ? "opacity-100" : "opacity-30"}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
className="h-20 w-full rounded-2xl border border-neutral-300 bg-white p-2 text-xs dark:border-neutral-600 dark:bg-neutral-950"
|
||||
placeholder={t("shops.ratingCommentPlaceholder")}
|
||||
value={ratingComment}
|
||||
onChange={(e) => setRatingComment(e.target.value)}
|
||||
/>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
className="mt-2"
|
||||
disabled={ratingScore < 1}
|
||||
onClick={handleSubmitRating}
|
||||
>
|
||||
{t("common.save")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{printMode && (
|
||||
<div className="print-only">
|
||||
{(printMode === "label-a5" || printMode === "label-a6") && (
|
||||
<div className="p-6 text-sm">
|
||||
<p className="mb-4 text-lg font-bold">{order.shop.name}</p>
|
||||
<p>{t("shops.buyerInfoTitle")}:</p>
|
||||
<p>{buyerName}</p>
|
||||
<p>
|
||||
{order.buyerAddressSnapshot?.province?.name}{" "}
|
||||
{order.buyerAddressSnapshot?.city?.name}
|
||||
</p>
|
||||
<p>{order.buyerAddressSnapshot?.address}</p>
|
||||
{order.tracking_code && (
|
||||
<p className="mt-4">
|
||||
{t("shops.trackingCodeLabel")}: {order.tracking_code}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{printMode === "invoice" && (
|
||||
{report ? (
|
||||
<div className="rounded-2xl border border-neutral-200 p-3 text-xs dark:border-neutral-700">
|
||||
<p className="mb-1 font-semibold">{t("shops.reportStatusTitle")}</p>
|
||||
<p className="text-neutral-600 dark:text-neutral-300">{report.description}</p>
|
||||
<p className="mt-1 text-neutral-500">
|
||||
{t(`shops.reportStatus.${report.status}`)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AuthNextButton type="button" onClick={() => setShowReport((v) => !v)}>
|
||||
{t("shops.reportProblem")}
|
||||
</AuthNextButton>
|
||||
{showReport && (
|
||||
<div className="rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700">
|
||||
<textarea
|
||||
className="h-20 w-full rounded-2xl border border-neutral-300 bg-white p-2 text-xs dark:border-neutral-600 dark:bg-neutral-950"
|
||||
placeholder={t("shops.reportPlaceholder")}
|
||||
value={reportText}
|
||||
onChange={(e) => setReportText(e.target.value)}
|
||||
/>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
className="mt-2"
|
||||
onClick={handleSubmitReport}
|
||||
>
|
||||
{t("shops.submitReport")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{returnRequest ? (
|
||||
<div className="rounded-2xl border border-neutral-200 p-3 text-xs dark:border-neutral-700">
|
||||
<p className="mb-1 font-semibold">{t("shops.returnStatusTitle")}</p>
|
||||
<p className="text-neutral-600 dark:text-neutral-300">{returnRequest.reason}</p>
|
||||
<p className="mt-1 text-neutral-500">
|
||||
{t(`shops.returnStatus.${returnRequest.status}`)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
canRequestReturn && (
|
||||
<>
|
||||
<AuthNextButton type="button" onClick={() => setShowReturn((v) => !v)}>
|
||||
{t("shops.requestReturn")}
|
||||
</AuthNextButton>
|
||||
{showReturn && (
|
||||
<div className="rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700">
|
||||
<textarea
|
||||
className="h-20 w-full rounded-2xl border border-neutral-300 bg-white p-2 text-xs dark:border-neutral-600 dark:bg-neutral-950"
|
||||
placeholder={t("shops.returnReasonPlaceholder")}
|
||||
value={returnReason}
|
||||
onChange={(e) => setReturnReason(e.target.value)}
|
||||
/>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
className="mt-2"
|
||||
onClick={handleSubmitReturn}
|
||||
>
|
||||
{t("shops.submitReturn")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{printMode === "invoice" && (
|
||||
<div className="print-only">
|
||||
<div className="p-6 text-sm">
|
||||
<p className="mb-4 text-lg font-bold">{t("shops.invoiceTitle")}</p>
|
||||
<p>{order.listing.title}</p>
|
||||
@@ -450,19 +590,30 @@ export default function OrderDetailPage() {
|
||||
<p className="mt-4">{buyerName}</p>
|
||||
<p>{order.createdAt}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.print-only { display: none; }
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
.print-only { display: block !important; }
|
||||
@page { size: ${printMode === "label-a6" ? "A6" : "A5"}; }
|
||||
}
|
||||
`}</style>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<style>{`
|
||||
.print-only { display: none; }
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
.print-only { display: block !important; }
|
||||
@page { size: A5; }
|
||||
}
|
||||
`}</style>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/settings" />
|
||||
<BuyerInfoModal
|
||||
isOpen={showBuyerInfo}
|
||||
onClose={() => setShowBuyerInfo(false)}
|
||||
buyer={order.buyerAddressSnapshot}
|
||||
/>
|
||||
<ShopInfoModal
|
||||
isOpen={showShopInfo}
|
||||
onClose={() => setShowShopInfo(false)}
|
||||
shop={order.shop}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
384
src/app/shops/profile/[shopId]/page.tsx
Normal file
384
src/app/shops/profile/[shopId]/page.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import ExpandableBio from "@/components/profile/ExpandableBio";
|
||||
import ShareShopModal from "@/components/shops/ShareShopModal";
|
||||
import ShopContactInfoModal from "@/components/shops/ShopContactInfoModal";
|
||||
import ShopMyOrdersModal from "@/components/shops/ShopMyOrdersModal";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useRequireAuth } from "@/lib/auth/useRequireAuth";
|
||||
import { profileActionBtnClass } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IShopProductListing } from "@/types/types";
|
||||
import dynamic from "next/dynamic";
|
||||
import Image from "next/image";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const LocationModal = dynamic(
|
||||
() => import("@/components/models/ModelPage/LocationModal"),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
type PublicShop = {
|
||||
_id: string;
|
||||
name: string;
|
||||
logo?: string | null;
|
||||
description?: string | null;
|
||||
has_physical_location?: boolean;
|
||||
province?: { name: string } | null;
|
||||
city?: { name: string } | null;
|
||||
address?: string | null;
|
||||
lat?: string | null;
|
||||
lng?: string | null;
|
||||
contact?: {
|
||||
mobile?: string | null;
|
||||
landline?: string | null;
|
||||
telegram?: string | null;
|
||||
whatsapp?: string | null;
|
||||
instagram?: string | null;
|
||||
} | null;
|
||||
response_schedule?: { day: string; start_time?: string | null; end_time?: string | null }[];
|
||||
owner?: {
|
||||
_id: string;
|
||||
user_name: string;
|
||||
} | null;
|
||||
productCount: number;
|
||||
rating: { average: number; count: number };
|
||||
isFollowing: boolean;
|
||||
followedAt?: string | null;
|
||||
isOwner: boolean;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
type PerformanceTier = "weak" | "average" | "good" | "veryGood" | "excellent";
|
||||
|
||||
function getPerformanceTier(percent: number): PerformanceTier {
|
||||
if (percent < 60) return "weak";
|
||||
if (percent < 70) return "average";
|
||||
if (percent < 80) return "good";
|
||||
if (percent < 90) return "veryGood";
|
||||
return "excellent";
|
||||
}
|
||||
|
||||
function monthsSince(dateStr: string): number {
|
||||
const diffMs = Date.now() - new Date(dateStr).getTime();
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24 * 30));
|
||||
}
|
||||
|
||||
export default function ShopPublicProfilePage() {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
const isFa = (i18n.language || "fa").toLowerCase().startsWith("fa");
|
||||
const router = useRouter();
|
||||
const params = useParams<{ shopId: string }>();
|
||||
const shopId = params.shopId;
|
||||
const { request } = useAxios();
|
||||
const requireAuth = useRequireAuth();
|
||||
const currentUser = useUser();
|
||||
|
||||
const [shop, setShop] = useState<PublicShop | null>(null);
|
||||
const [isFollowing, setIsFollowing] = useState(false);
|
||||
const [followedAt, setFollowedAt] = useState<string | null>(null);
|
||||
const [followLoading, setFollowLoading] = useState(false);
|
||||
const [showContactModal, setShowContactModal] = useState(false);
|
||||
const [showOrdersModal, setShowOrdersModal] = useState(false);
|
||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ shop: PublicShop }>("GET", `/shops/public/${shopId}`)
|
||||
.then((res) => {
|
||||
const data = res?.shop || null;
|
||||
setShop(data);
|
||||
setIsFollowing(Boolean(data?.isFollowing));
|
||||
setFollowedAt(data?.followedAt || null);
|
||||
})
|
||||
.catch(() => setShop(null));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shopId]);
|
||||
|
||||
const { data, isLoading } = useInfiniteScroll({
|
||||
endpoint: "/shop-products",
|
||||
queryKey: ["shop-products", shopId],
|
||||
params: { shopId },
|
||||
});
|
||||
const listings: IShopProductListing[] =
|
||||
data?.pages.flatMap((page) => page.docs || []) || [];
|
||||
|
||||
const toggleFollow = async () => {
|
||||
if (!requireAuth() || !shop || followLoading) return;
|
||||
const nextFollowing = !isFollowing;
|
||||
setIsFollowing(nextFollowing);
|
||||
setFollowLoading(true);
|
||||
try {
|
||||
const res = await request<{ is_following: boolean; followed_at: string | null }>(
|
||||
"POST",
|
||||
`/shops/${shop._id}/follow`,
|
||||
{}
|
||||
);
|
||||
setIsFollowing(res?.is_following ?? nextFollowing);
|
||||
setFollowedAt(res?.followed_at || null);
|
||||
toast.success(
|
||||
(res?.is_following ?? nextFollowing)
|
||||
? t("shops.shopFollowSuccess")
|
||||
: t("shops.shopUnfollowSuccess")
|
||||
);
|
||||
} catch {
|
||||
setIsFollowing(!nextFollowing);
|
||||
} finally {
|
||||
setFollowLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessage = () => {
|
||||
if (!requireAuth() || !shop || !currentUser?._id) return;
|
||||
router.push(`/settings/chats/shop/${shop._id}/${currentUser._id}`);
|
||||
};
|
||||
|
||||
if (!shop) return null;
|
||||
|
||||
const percent =
|
||||
shop.rating.count > 0 ? Math.round((shop.rating.average / 5) * 100) : null;
|
||||
const performanceTier = percent != null ? getPerformanceTier(percent) : null;
|
||||
const hasDescription = Boolean(shop.description?.trim());
|
||||
|
||||
const shopTenureLabel = shop.createdAt
|
||||
? monthsSince(shop.createdAt) < 1
|
||||
? t("shops.lessThanOneMonth")
|
||||
: t("shops.monthsCount", { count: monthsSince(shop.createdAt) })
|
||||
: t("shops.followButton");
|
||||
|
||||
const followLabel = isFollowing
|
||||
? followedAt
|
||||
? monthsSince(followedAt) < 1
|
||||
? t("shops.lessThanOneMonth")
|
||||
: t("shops.monthsCount", { count: monthsSince(followedAt) })
|
||||
: t("shops.unfollowButton")
|
||||
: shopTenureLabel;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<div className="mx-auto w-full max-w-md" dir={isFa ? "ltr" : "rtl"}>
|
||||
<div className="flex w-full items-center justify-between py-2">
|
||||
<div className="flex flex-col items-center text-xs md:text-sm">
|
||||
<div className="mt-2 flex gap-5">
|
||||
<div className="flex flex-col items-center font-semibold max-sm:text-[10px]">
|
||||
<span>{shop.productCount}</span>
|
||||
<span>{t("shops.productsLabel")}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center font-semibold text-[#0C8002] max-sm:text-[10px]">
|
||||
<span>
|
||||
{performanceTier
|
||||
? t(`shops.performance.${performanceTier}`)
|
||||
: t("shops.underReview")}
|
||||
</span>
|
||||
<span>{t("shops.performanceLabel")}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center font-semibold text-[#3A59A9] max-sm:text-[10px]">
|
||||
<span>{percent != null ? `${percent}%` : t("shops.newShop")}</span>
|
||||
<span>{t("shops.satisfactionLabel")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
src={shop.logo}
|
||||
alt={shop.name}
|
||||
size="md"
|
||||
rounded="xl"
|
||||
className="md:h-[120px] md:w-[120px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full grid-cols-3 items-end py-2 text-xs font-semibold md:text-sm">
|
||||
<div className="flex items-center">
|
||||
<span>{shop.rating.count > 0 ? shop.rating.average.toFixed(1) : "0"}</span>
|
||||
<Image width={22} height={22} alt="star icon" src="/images/icons/star1.png" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<span>{shop.rating.count}</span>
|
||||
<Image width={22} height={22} alt="rate icon" src="/images/icons/medal-star.png" />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 max-w-full flex-col",
|
||||
isFa ? "items-end" : "items-start"
|
||||
)}
|
||||
>
|
||||
<h3
|
||||
className={cn(
|
||||
"max-w-full truncate whitespace-nowrap",
|
||||
isFa ? "text-right" : "text-left"
|
||||
)}
|
||||
>
|
||||
{shop.name}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full py-2 text-xs font-semibold md:text-sm">
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full gap-3",
|
||||
hasDescription
|
||||
? "items-baseline justify-between"
|
||||
: "items-center justify-end"
|
||||
)}
|
||||
>
|
||||
{hasDescription ? (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 flex-1 leading-6",
|
||||
isFa ? "text-right" : "text-left"
|
||||
)}
|
||||
>
|
||||
<ExpandableBio
|
||||
bio={shop.description}
|
||||
className={cn("leading-6", isFa ? "text-right" : "text-left")}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 leading-6 text-[#387E65]",
|
||||
isFa ? "text-right" : "text-left"
|
||||
)}
|
||||
>
|
||||
{shop.has_physical_location ? t("shops.inPerson") : t("shops.onlineOnly")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-3 gap-1 md:mt-4 md:gap-4">
|
||||
<button type="button" onClick={handleMessage} className={profileActionBtnClass}>
|
||||
{t("shops.sendMessage")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOrdersModal(true)}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("shops.myOrdersFromShop")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowContactModal(true)}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("shops.contactInfoButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleFollow()}
|
||||
disabled={followLoading}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{followLabel}
|
||||
</button>
|
||||
{shop.has_physical_location && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLocationModal(true)}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("shops.showLocation")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowShareModal(true)}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("shops.share.title")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
<p className="py-16 text-center text-sm text-neutral-500">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
) : listings.length === 0 ? (
|
||||
<p className="py-16 text-center text-sm text-neutral-500">
|
||||
{t("shops.noProductsYet")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-0.5 sm:gap-1">
|
||||
{listings.map((listing) => {
|
||||
const primaryImage =
|
||||
listing.images?.[listing.primaryImageIndex] || listing.images?.[0];
|
||||
return (
|
||||
<button
|
||||
key={listing._id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
router.push(`/shops/profile/${shopId}/reel/${listing._id}`)
|
||||
}
|
||||
className="relative aspect-square overflow-hidden rounded-md bg-neutral-100 sm:rounded-xl dark:bg-neutral-800"
|
||||
>
|
||||
{primaryImage && (
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + primaryImage}
|
||||
alt={listing.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="33vw"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/settings" />
|
||||
|
||||
<ShareShopModal
|
||||
open={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
shopId={shop._id}
|
||||
shopName={shop.name}
|
||||
shopLogo={shop.logo}
|
||||
responseSchedule={shop.response_schedule}
|
||||
/>
|
||||
<ShopContactInfoModal
|
||||
open={showContactModal}
|
||||
onClose={() => setShowContactModal(false)}
|
||||
contactInfo={shop.contact || {}}
|
||||
/>
|
||||
<ShopMyOrdersModal
|
||||
open={showOrdersModal}
|
||||
onClose={() => setShowOrdersModal(false)}
|
||||
shopId={shop._id}
|
||||
/>
|
||||
{showLocationModal && shop.has_physical_location && (
|
||||
<LocationModal
|
||||
isOpen={showLocationModal}
|
||||
onClose={() => setShowLocationModal(false)}
|
||||
location={{
|
||||
address: shop.address || undefined,
|
||||
lat: shop.lat || undefined,
|
||||
lng: shop.lng || undefined,
|
||||
city: shop.city || undefined,
|
||||
province: shop.province || undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
src/app/shops/profile/[shopId]/reel/[listingId]/page.tsx
Normal file
10
src/app/shops/profile/[shopId]/reel/[listingId]/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import ShopReelsView from "@/components/shops/ShopReelsView";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
export default function ShopReelPage() {
|
||||
const params = useParams<{ shopId: string; listingId: string }>();
|
||||
|
||||
return <ShopReelsView shopId={params.shopId} initialListingId={params.listingId} />;
|
||||
}
|
||||
@@ -71,7 +71,7 @@ function AuthPageLayout({ children, className }: AuthPageLayoutProps) {
|
||||
<Container>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-screen flex-col items-center p-4",
|
||||
"flex min-h-screen flex-col items-center py-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
getMessageRowSpacing,
|
||||
} from "@/lib/chat/messageGrouping";
|
||||
import SharedPostBubble, { parseSharedPost } from "./SharedPostBubble";
|
||||
import SharedProductBubble, { parseSharedProduct } from "./SharedProductBubble";
|
||||
import SharedStoryBubble, {
|
||||
parseSharedStory,
|
||||
extractStoryReactionText,
|
||||
@@ -318,6 +319,7 @@ const ChatMessageCard = ({
|
||||
const forwarded =
|
||||
message.forwardedFrom || parseForwarded(message.content || "");
|
||||
const sharedPost = parseSharedPost(message.content || "");
|
||||
const sharedProduct = parseSharedProduct(message.content || "");
|
||||
const sharedStory = parseSharedStory(message.content || "");
|
||||
const storyReactionText = sharedStory
|
||||
? extractStoryReactionText(message.content || "")
|
||||
@@ -331,6 +333,7 @@ const ChatMessageCard = ({
|
||||
!message.file &&
|
||||
!hasLocation &&
|
||||
!sharedPost &&
|
||||
!sharedProduct &&
|
||||
!sharedStory &&
|
||||
!(message.viewOnce && isViewOnceMediaType(message.fileType))
|
||||
) {
|
||||
@@ -735,6 +738,16 @@ const ChatMessageCard = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sharedProduct && (
|
||||
<div
|
||||
className={cn("p-1", isSender ? "chat-bubble-out" : "chat-bubble-in")}
|
||||
style={bubbleStyle}
|
||||
>
|
||||
<SharedProductBubble data={sharedProduct} />
|
||||
<TimeBelow />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTextBubble && !message.file && emojiOnly && (
|
||||
<p className="px-1 py-0.5 text-[2.5rem] leading-none select-text">{textContent}</p>
|
||||
)}
|
||||
|
||||
@@ -58,7 +58,9 @@ export default function ForwardMessageModal({
|
||||
{ noToast: true }
|
||||
);
|
||||
setUsers(
|
||||
(res?.filteredUsersData || []).filter((u) => u._id !== currentUserId)
|
||||
(res?.filteredUsersData || []).filter(
|
||||
(u) => u._id !== currentUserId && Boolean(u.user_name)
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
59
src/components/chat/SharedProductBubble.tsx
Normal file
59
src/components/chat/SharedProductBubble.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface SharedProductPayload {
|
||||
listingId: string;
|
||||
title?: string;
|
||||
preview?: string;
|
||||
price?: number | null;
|
||||
shopName?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export function parseSharedProduct(content: string): SharedProductPayload | null {
|
||||
if (!content) return null;
|
||||
try {
|
||||
const line = content.split("\n")[0];
|
||||
const j = JSON.parse(line);
|
||||
if (j?.e2ee) return null;
|
||||
return j.sharedProduct ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SharedProductBubble({ data }: { data: SharedProductPayload }) {
|
||||
const { t } = useTranslation("common");
|
||||
const href = data.url || `/shops/listing/${data.listingId}`;
|
||||
const preview = data.preview ? IMAGE_BASE_URL + data.preview : null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="block overflow-hidden rounded-xl border border-white/20 bg-black/10"
|
||||
>
|
||||
{preview && (
|
||||
<div className="relative aspect-[4/3] w-full min-w-[200px] max-w-[240px] bg-neutral-900">
|
||||
<Image src={preview} alt="" fill className="object-cover" unoptimized />
|
||||
</div>
|
||||
)}
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="text-[11px] font-semibold opacity-90">
|
||||
{data.title || t("shops.viewProduct")}
|
||||
</p>
|
||||
{data.price != null && (
|
||||
<p className="text-[10px] opacity-75">
|
||||
{t("shops.priceLabel", { amount: data.price.toLocaleString() })}
|
||||
</p>
|
||||
)}
|
||||
{data.shopName ? (
|
||||
<p className="text-[10px] opacity-60">{data.shopName}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,9 @@ export default function SendPostModal({
|
||||
{ noToast: true }
|
||||
);
|
||||
setUsers(
|
||||
(res?.filteredUsersData || []).filter((u) => u._id !== currentUserId)
|
||||
(res?.filteredUsersData || []).filter(
|
||||
(u) => u._id !== currentUserId && Boolean(u.user_name)
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
115
src/components/shops/ProductCategoryPicker.tsx
Normal file
115
src/components/shops/ProductCategoryPicker.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { IShopCategory } from "@/types/types";
|
||||
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ProductCategoryPickerProps = {
|
||||
categoryList: IShopCategory[] | null;
|
||||
category: string;
|
||||
subCategory: string;
|
||||
subSubCategory: string;
|
||||
onCategorySelect: (category: string) => void;
|
||||
onSubCategorySelect: (subCategory: string) => void;
|
||||
onSubSubCategorySelect: (subSubCategory: string) => void;
|
||||
};
|
||||
|
||||
export default function ProductCategoryPicker({
|
||||
categoryList,
|
||||
category,
|
||||
subCategory,
|
||||
subSubCategory,
|
||||
onCategorySelect,
|
||||
onSubCategorySelect,
|
||||
onSubSubCategorySelect,
|
||||
}: ProductCategoryPickerProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const mainCount = categoryList?.length ?? 0;
|
||||
const selectedCategory = categoryList?.find(
|
||||
(item) => item.category === category
|
||||
);
|
||||
const selectedSubCategory = selectedCategory?.sub_categories.find(
|
||||
(item) => item.name === subCategory
|
||||
);
|
||||
|
||||
if (!mainCount) {
|
||||
return (
|
||||
<p className="mt-4 text-center text-sm text-gray-500">
|
||||
{t("shops.loadingCategories")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex w-full flex-col items-center gap-6 px-2">
|
||||
<div className="flex w-full flex-col items-center">
|
||||
<p className="mb-2 text-center text-xs text-gray-500">
|
||||
{t("shops.selectMainCategory")}
|
||||
</p>
|
||||
<div className="grid w-full grid-cols-5 gap-1.5">
|
||||
{categoryList?.map((item) => (
|
||||
<button
|
||||
key={item._id}
|
||||
type="button"
|
||||
className={cn(
|
||||
toggleBtnClass(category === item.category),
|
||||
"min-h-9 p-1.5 text-[11px] leading-tight"
|
||||
)}
|
||||
onClick={() => onCategorySelect(item.category)}
|
||||
>
|
||||
{item.category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedCategory && selectedCategory.sub_categories.length > 0 && (
|
||||
<div className="flex w-full flex-col items-center">
|
||||
<p className="mb-2 text-center text-xs text-gray-500">
|
||||
{t("shops.selectSubCategory")}
|
||||
</p>
|
||||
<div className="grid w-full grid-cols-4 gap-1.5">
|
||||
{selectedCategory.sub_categories.map((item) => (
|
||||
<button
|
||||
key={item._id}
|
||||
type="button"
|
||||
className={cn(
|
||||
toggleBtnClass(subCategory === item.name),
|
||||
"min-h-9 p-1.5 text-[11px] leading-tight"
|
||||
)}
|
||||
onClick={() => onSubCategorySelect(item.name)}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSubCategory &&
|
||||
(selectedSubCategory.sub_sub_categories?.length ?? 0) > 0 && (
|
||||
<div className="flex w-full flex-col items-center">
|
||||
<p className="mb-2 text-center text-xs text-gray-500">
|
||||
{t("shops.selectSubSubCategory")}
|
||||
</p>
|
||||
<div className="grid w-full grid-cols-3 gap-1.5">
|
||||
{selectedSubCategory.sub_sub_categories?.map((item) => (
|
||||
<button
|
||||
key={item._id}
|
||||
type="button"
|
||||
className={cn(
|
||||
toggleBtnClass(subSubCategory === item.name),
|
||||
"min-h-9 p-1.5 text-[11px] leading-tight"
|
||||
)}
|
||||
onClick={() => onSubSubCategorySelect(item.name)}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
src/components/shops/ProductImageLightbox.tsx
Normal file
81
src/components/shops/ProductImageLightbox.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type ProductImageLightboxProps = {
|
||||
images: string[];
|
||||
initialIndex?: number;
|
||||
alt: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function ProductImageLightbox({
|
||||
images,
|
||||
initialIndex = 0,
|
||||
alt,
|
||||
open,
|
||||
onClose,
|
||||
}: ProductImageLightboxProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(initialIndex);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !scrollRef.current) return;
|
||||
scrollRef.current.scrollTo({
|
||||
left: initialIndex * scrollRef.current.clientWidth,
|
||||
behavior: "auto",
|
||||
});
|
||||
setActiveIndex(initialIndex);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, initialIndex]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = event.currentTarget;
|
||||
const index = Math.round(el.scrollLeft / el.clientWidth);
|
||||
setActiveIndex(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[999] flex flex-col bg-black">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="close"
|
||||
className="absolute right-4 top-4 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-black/50"
|
||||
>
|
||||
<BoldIcon name="close-circle" size={22} tinted className="text-white" />
|
||||
</button>
|
||||
|
||||
{images.length > 1 && (
|
||||
<span className="absolute left-1/2 top-4 z-10 -translate-x-1/2 rounded-full bg-black/50 px-2 py-0.5 text-[11px] font-bold text-white">
|
||||
{activeIndex + 1}/{images.length}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex h-full w-full snap-x snap-mandatory overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{images.map((img) => (
|
||||
<div key={img} className="relative h-full w-full shrink-0 snap-center">
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + img}
|
||||
alt={alt}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="100vw"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
src/components/shops/SendProductModal.tsx
Normal file
210
src/components/shops/SendProductModal.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { btnPrimary } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import toast from "react-hot-toast";
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
import { IShopProductListing } from "@/types/types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface ChatUserItem {
|
||||
_id: string;
|
||||
user_name: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
profile_image?: string;
|
||||
}
|
||||
|
||||
interface SendProductModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
listing: IShopProductListing;
|
||||
}
|
||||
|
||||
export default function SendProductModal({
|
||||
open,
|
||||
onClose,
|
||||
listing,
|
||||
}: SendProductModalProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [users, setUsers] = useState<ChatUserItem[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const loadUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request<{ filteredUsersData: ChatUserItem[] }>(
|
||||
"GET",
|
||||
"/messages?limit=100",
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
setUsers((res?.filteredUsersData || []).filter((u) => Boolean(u.user_name)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadUsers();
|
||||
setSelected(new Set());
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else if (next.size < 50) next.add(id);
|
||||
else toast.error(t("posts.maxUsers"));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
if (selected.size === 0) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await request<{ sentCount?: number }>(
|
||||
"POST",
|
||||
`/shop-products/${listing._id}/send`,
|
||||
{ receiverIds: Array.from(selected) },
|
||||
{ noToast: true }
|
||||
);
|
||||
toast.success(
|
||||
t("shops.sentProductToCount", { count: res?.sentCount ?? selected.size })
|
||||
);
|
||||
onClose();
|
||||
setSelected(new Set());
|
||||
} catch {
|
||||
toast.error(t("shops.sendProductError"));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const preview =
|
||||
listing.images?.[listing.primaryImageIndex] || listing.images?.[0] || null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="glass-modal-overlay fixed inset-0 z-[200] flex items-end justify-center sm:items-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 40, opacity: 0 }}
|
||||
className="glass-modal-panel w-full max-w-md rounded-t-3xl p-4 sm:rounded-3xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="mb-3 text-center text-sm font-bold">
|
||||
{t("shops.sendProductTitle")}
|
||||
</h3>
|
||||
|
||||
<div className="mb-4 flex items-center gap-3 rounded-xl bg-neutral-100 p-2 dark:bg-neutral-800">
|
||||
{preview && (
|
||||
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + preview}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="line-clamp-2 text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{listing.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<IOSSpinner />
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-neutral-500">
|
||||
{t("posts.noChatUsers")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="max-h-[45vh] space-y-1 overflow-y-auto">
|
||||
{users.map((u) => {
|
||||
const name =
|
||||
[u.first_name, u.last_name].filter(Boolean).join(" ") ||
|
||||
u.user_name;
|
||||
const checked = selected.has(u._id);
|
||||
return (
|
||||
<li key={u._id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(u._id)}
|
||||
className={`flex w-full items-center gap-3 rounded-xl px-2 py-2 text-right transition ${
|
||||
checked ? "bg-pink-50 dark:bg-pink-950/30" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full bg-neutral-200">
|
||||
{u.profile_image ? (
|
||||
<Image
|
||||
src={
|
||||
u.profile_image.startsWith("http")
|
||||
? u.profile_image
|
||||
: IMAGE_BASE_URL + u.profile_image
|
||||
}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="flex-1 text-sm font-medium">{name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-5 w-5 items-center justify-center rounded-full border-2",
|
||||
checked ? "btn-modern--selected border-[#FC8EAC]" : "border-neutral-300"
|
||||
)}
|
||||
>
|
||||
{checked ? (
|
||||
<span className="text-[10px] text-white">✓</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={selected.size === 0 || sending}
|
||||
onClick={send}
|
||||
className={cn(btnPrimary, "mt-4 w-full py-3 text-sm font-bold disabled:opacity-40")}
|
||||
>
|
||||
{sending
|
||||
? t("posts.sending")
|
||||
: t("posts.sendWithCount", { count: selected.size })}
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
115
src/components/shops/ShareProductModal.tsx
Normal file
115
src/components/shops/ShareProductModal.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Modal from "@/components/elements/Modal";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyTextToClipboard } from "@/lib/chat/getCopyableMessageText";
|
||||
|
||||
type ShareProductModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
listingId: string;
|
||||
title?: string | null;
|
||||
image?: string | null;
|
||||
};
|
||||
|
||||
export default function ShareProductModal({
|
||||
open,
|
||||
onClose,
|
||||
listingId,
|
||||
title,
|
||||
image,
|
||||
}: ShareProductModalProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const productUrl = useMemo(() => {
|
||||
if (!listingId) return "";
|
||||
if (typeof window !== "undefined") {
|
||||
return `${window.location.origin}/shops/listing/${listingId}`;
|
||||
}
|
||||
return `https://modstagram.com/shops/listing/${listingId}`;
|
||||
}, [listingId]);
|
||||
|
||||
const qrSrc = useMemo(() => {
|
||||
if (!productUrl) return "";
|
||||
return `https://api.qrserver.com/v1/create-qr-code/?size=220x220&margin=12&data=${encodeURIComponent(productUrl)}`;
|
||||
}, [productUrl]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!productUrl) return;
|
||||
const ok = await copyTextToClipboard(productUrl);
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
toast.success(t("shops.share.copied"));
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
toast.error(t("shops.share.copyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={open}
|
||||
onClose={onClose}
|
||||
elevated
|
||||
height="fit"
|
||||
panelClassName="!p-0 max-h-[90dvh] w-full max-w-sm overflow-y-auto"
|
||||
>
|
||||
<div className="px-5 py-6 text-center">
|
||||
<h2 className="mb-5 text-base font-bold">{t("shops.share.title")}</h2>
|
||||
|
||||
<div className="mb-4 flex flex-col items-center gap-2">
|
||||
<ProfileAvatar
|
||||
src={image || undefined}
|
||||
alt={title || "product"}
|
||||
size="md"
|
||||
rounded="2xl"
|
||||
/>
|
||||
{title ? <p className="text-sm font-semibold">{title}</p> : null}
|
||||
</div>
|
||||
|
||||
{qrSrc ? (
|
||||
<div className="mx-auto mb-5 flex h-[220px] w-[220px] items-center justify-center rounded-2xl bg-white p-3">
|
||||
<Image
|
||||
src={qrSrc}
|
||||
alt={t("shops.share.qrAlt")}
|
||||
width={196}
|
||||
height={196}
|
||||
unoptimized
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="mb-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t("shops.share.linkLabel")}
|
||||
</p>
|
||||
<div className="mb-4 break-all rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2 text-xs dark:border-neutral-700 dark:bg-neutral-900">
|
||||
{productUrl || "—"}
|
||||
</div>
|
||||
|
||||
<div className="mx-auto grid w-full max-w-sm grid-cols-2 gap-4">
|
||||
<RoundedButton
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="!border-transparent h-9 w-full !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
|
||||
>
|
||||
{t("shops.share.close")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
type="button"
|
||||
onClick={() => void handleCopy()}
|
||||
className="!border-transparent h-9 w-full !bg-sky-100 text-sky-600"
|
||||
>
|
||||
{copied ? t("shops.share.copied") : t("shops.share.copyLink")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
139
src/components/shops/ShareShopModal.tsx
Normal file
139
src/components/shops/ShareShopModal.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Modal from "@/components/elements/Modal";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyTextToClipboard } from "@/lib/chat/getCopyableMessageText";
|
||||
|
||||
type ResponseScheduleItem = {
|
||||
day: string;
|
||||
start_time?: string | null;
|
||||
end_time?: string | null;
|
||||
};
|
||||
|
||||
type ShareShopModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
shopId: string;
|
||||
shopName?: string | null;
|
||||
shopLogo?: string | null;
|
||||
responseSchedule?: ResponseScheduleItem[];
|
||||
};
|
||||
|
||||
export default function ShareShopModal({
|
||||
open,
|
||||
onClose,
|
||||
shopId,
|
||||
shopName,
|
||||
shopLogo,
|
||||
responseSchedule,
|
||||
}: ShareShopModalProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const shopUrl = useMemo(() => {
|
||||
if (!shopId) return "";
|
||||
if (typeof window !== "undefined") {
|
||||
return `${window.location.origin}/shops/profile/${shopId}`;
|
||||
}
|
||||
return `https://modstagram.com/shops/profile/${shopId}`;
|
||||
}, [shopId]);
|
||||
|
||||
const qrSrc = useMemo(() => {
|
||||
if (!shopUrl) return "";
|
||||
return `https://api.qrserver.com/v1/create-qr-code/?size=220x220&margin=12&data=${encodeURIComponent(shopUrl)}`;
|
||||
}, [shopUrl]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!shopUrl) return;
|
||||
const ok = await copyTextToClipboard(shopUrl);
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
toast.success(t("shops.share.copied"));
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
toast.error(t("shops.share.copyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={open}
|
||||
onClose={onClose}
|
||||
elevated
|
||||
height="fit"
|
||||
panelClassName="!p-0 max-h-[90dvh] w-full max-w-sm overflow-y-auto"
|
||||
>
|
||||
<div className="px-5 py-6 text-center">
|
||||
<h2 className="mb-5 text-base font-bold">{t("shops.share.title")}</h2>
|
||||
|
||||
<div className="mb-4 flex flex-col items-center gap-2">
|
||||
<ProfileAvatar
|
||||
src={shopLogo || undefined}
|
||||
alt={shopName || "shop"}
|
||||
size="md"
|
||||
rounded="2xl"
|
||||
/>
|
||||
{shopName ? <p className="text-sm font-semibold">{shopName}</p> : null}
|
||||
</div>
|
||||
|
||||
{qrSrc ? (
|
||||
<div className="mx-auto mb-5 flex h-[220px] w-[220px] items-center justify-center rounded-2xl bg-white p-3">
|
||||
<Image
|
||||
src={qrSrc}
|
||||
alt={t("shops.share.qrAlt")}
|
||||
width={196}
|
||||
height={196}
|
||||
unoptimized
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="mb-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t("shops.share.linkLabel")}
|
||||
</p>
|
||||
<div className="mb-4 break-all rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2 text-xs dark:border-neutral-700 dark:bg-neutral-900">
|
||||
{shopUrl || "—"}
|
||||
</div>
|
||||
|
||||
{responseSchedule && responseSchedule.length > 0 && (
|
||||
<div className="mb-5 flex w-full flex-col gap-2 text-right">
|
||||
<p className="text-sm font-semibold">{t("shops.responseScheduleTitle")}</p>
|
||||
<div className="flex flex-col gap-1.5 rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700">
|
||||
{responseSchedule.map((item) => (
|
||||
<div key={item.day} className="flex items-center justify-between text-xs">
|
||||
<span className="text-neutral-500" dir="ltr">
|
||||
{item.start_time || "--:--"} - {item.end_time || "--:--"}
|
||||
</span>
|
||||
<span className="font-medium">{t(`shops.days.${item.day}`)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-auto grid w-full max-w-sm grid-cols-2 gap-4">
|
||||
<RoundedButton
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="!border-transparent h-9 w-full !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
|
||||
>
|
||||
{t("shops.share.close")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
type="button"
|
||||
onClick={() => void handleCopy()}
|
||||
className="!border-transparent h-9 w-full !bg-sky-100 text-sky-600"
|
||||
>
|
||||
{copied ? t("shops.share.copied") : t("shops.share.copyLink")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -6,19 +6,7 @@ import { toggleBtnClass } from "@/lib/ui/buttonStyles";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function gridColsClass(count: number): string {
|
||||
if (count <= 1) return "grid-cols-1";
|
||||
if (count === 2) return "grid-cols-2";
|
||||
if (count === 3) return "grid-cols-3";
|
||||
if (count === 4) return "grid-cols-4";
|
||||
if (count === 5) return "grid-cols-5";
|
||||
return "grid-cols-3 sm:grid-cols-4 md:grid-cols-5";
|
||||
}
|
||||
|
||||
function gridMaxWidth(count: number): number {
|
||||
const cols = Math.min(Math.max(count, 1), 5);
|
||||
return cols * 108;
|
||||
}
|
||||
const FIXED_GRID_MAX_WIDTH = 440;
|
||||
|
||||
type ShopCategoryPickerProps = {
|
||||
categoryList: IShopCategory[] | null;
|
||||
@@ -57,8 +45,8 @@ export default function ShopCategoryPicker({
|
||||
return (
|
||||
<div className="mt-4 flex w-full flex-col items-center px-2">
|
||||
<div
|
||||
className={cn("grid w-full gap-2", gridColsClass(mainCount))}
|
||||
style={{ maxWidth: gridMaxWidth(mainCount) }}
|
||||
className="grid w-full grid-cols-4 gap-2"
|
||||
style={{ maxWidth: FIXED_GRID_MAX_WIDTH }}
|
||||
>
|
||||
{categoryList?.map((item) => (
|
||||
<button
|
||||
@@ -83,9 +71,12 @@ export default function ShopCategoryPicker({
|
||||
<p className="mb-2 text-center text-xs text-gray-400">
|
||||
{t("shops.subCategoryDisplayHint")}
|
||||
</p>
|
||||
<p className="mb-2 text-center text-xs text-gray-400">
|
||||
{t("shops.subCategoryMaxHint")}
|
||||
</p>
|
||||
<div
|
||||
className={cn("grid w-full gap-2", gridColsClass(subCount))}
|
||||
style={{ maxWidth: gridMaxWidth(subCount) }}
|
||||
className="grid w-full grid-cols-4 gap-2"
|
||||
style={{ maxWidth: FIXED_GRID_MAX_WIDTH }}
|
||||
>
|
||||
{selectedCategory.sub_categories.map((item) => {
|
||||
const isSelected = subCategory.includes(item.name);
|
||||
|
||||
83
src/components/shops/ShopContactInfoModal.tsx
Normal file
83
src/components/shops/ShopContactInfoModal.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import Modal from "@/components/elements/Modal";
|
||||
import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type IShopContactInfo = {
|
||||
mobile?: string | null;
|
||||
landline?: string | null;
|
||||
telegram?: string | null;
|
||||
whatsapp?: string | null;
|
||||
instagram?: string | null;
|
||||
};
|
||||
|
||||
interface IShopContactInfoModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
contactInfo: IShopContactInfo;
|
||||
}
|
||||
|
||||
const ShopContactInfoModal: React.FC<IShopContactInfoModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
contactInfo,
|
||||
}) => {
|
||||
const { t } = useTranslation("common");
|
||||
const contactMethods = [
|
||||
{ key: "mobile", icon: "mobile.svg", link: `tel:${contactInfo?.mobile}` },
|
||||
{ key: "landline", icon: "phone.svg", link: `tel:${contactInfo?.landline}` },
|
||||
{
|
||||
key: "whatsapp",
|
||||
icon: "whatsapp.svg",
|
||||
link: `https://wa.me/${contactInfo?.whatsapp}`,
|
||||
},
|
||||
{
|
||||
key: "telegram",
|
||||
icon: "telegram.svg",
|
||||
link: `https://t.me/${contactInfo?.telegram}`,
|
||||
},
|
||||
{
|
||||
key: "instagram",
|
||||
icon: "instagram.svg",
|
||||
link: `https://instagram.com/${contactInfo?.instagram}`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal isOpen={open} onClose={onClose} height="430px">
|
||||
<div className="mx-auto flex max-w-sm flex-col items-center pb-14">
|
||||
<span className="my-2 text-lg font-bold">{t("shops.contactTitle")}</span>
|
||||
<div className="mt-5 flex w-full flex-wrap justify-center gap-4">
|
||||
{contactMethods.map(
|
||||
({ key, icon, link }) =>
|
||||
contactInfo?.[key as keyof IShopContactInfo] && (
|
||||
<a
|
||||
key={key}
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex w-full items-center justify-center"
|
||||
>
|
||||
<RoundedDiv className="h-9 w-full gap-2">
|
||||
<span>{contactInfo[key as keyof IShopContactInfo]}</span>
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
alt={icon}
|
||||
src={`/images/icons/${icon}`}
|
||||
className="dark:invert"
|
||||
/>
|
||||
</RoundedDiv>
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShopContactInfoModal;
|
||||
96
src/components/shops/ShopListCard.tsx
Normal file
96
src/components/shops/ShopListCard.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ShopListCardProps = {
|
||||
shop: {
|
||||
_id: string;
|
||||
name: string;
|
||||
logo?: string | null;
|
||||
status: string;
|
||||
has_physical_location?: boolean;
|
||||
productCount?: number;
|
||||
activeProductCount?: number;
|
||||
soldCount?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const STATUS_TEXT_COLOR: Record<string, string> = {
|
||||
active: "text-green-600",
|
||||
pending_review: "text-amber-600",
|
||||
draft: "text-neutral-400",
|
||||
inactive: "text-red-500",
|
||||
rejected: "text-red-500",
|
||||
};
|
||||
|
||||
export default function ShopListCard({ shop }: ShopListCardProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/shops/${shop._id}`)}
|
||||
className="gentle-transition flex w-full items-center gap-3 rounded-[28px] border border-neutral-200 p-3 text-right active:scale-[0.99] dark:border-neutral-700"
|
||||
>
|
||||
<span className="flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-neutral-100 dark:bg-neutral-800">
|
||||
{shop.logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={IMAGE_BASE_URL + shop.logo}
|
||||
alt={shop.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
|
||||
width={28}
|
||||
height={28}
|
||||
alt=""
|
||||
className="dark:invert"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="flex min-w-0 flex-1 flex-col items-end gap-1.5 text-right">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="line-clamp-1 text-base font-bold">{shop.name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs font-semibold",
|
||||
STATUS_TEXT_COLOR[shop.status] || STATUS_TEXT_COLOR.draft
|
||||
)}
|
||||
>
|
||||
{t(`shops.status.${shop.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{typeof shop.activeProductCount === "number" && (
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t("shops.activeProductsLabel", { count: shop.activeProductCount })}
|
||||
</span>
|
||||
)}
|
||||
{typeof shop.soldCount === "number" && (
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t("shops.soldCountLabel", { count: shop.soldCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="text-xs font-semibold text-sky-500">
|
||||
{shop.has_physical_location ? t("shops.inPerson") : t("shops.onlineOnly")}
|
||||
</span>
|
||||
{typeof shop.productCount === "number" && (
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t("shops.itemsCount", { count: shop.productCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
56
src/components/shops/ShopMyOrdersModal.tsx
Normal file
56
src/components/shops/ShopMyOrdersModal.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import Modal from "@/components/elements/Modal";
|
||||
import BuyerOrderItem from "@/components/shops/orders/BuyerOrderItem";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type BuyerOrder = any;
|
||||
|
||||
type ShopMyOrdersModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
shopId: string;
|
||||
};
|
||||
|
||||
export default function ShopMyOrdersModal({ open, onClose, shopId }: ShopMyOrdersModalProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [orders, setOrders] = useState<BuyerOrder[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setOrders(null);
|
||||
request<{ docs: BuyerOrder[] }>("GET", `/orders?role=buyer&shopId=${shopId}`)
|
||||
.then((res) => setOrders(res?.docs || []))
|
||||
.catch(() => setOrders([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, shopId]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={open}
|
||||
onClose={onClose}
|
||||
elevated
|
||||
height="fit"
|
||||
panelClassName="!p-0 max-h-[80dvh] w-full max-w-sm overflow-y-auto"
|
||||
>
|
||||
<div className="px-4 py-5">
|
||||
<h2 className="mb-4 text-center text-base font-bold">
|
||||
{t("shops.myOrdersFromShop")}
|
||||
</h2>
|
||||
{orders === null ? (
|
||||
<p className="py-8 text-center text-sm text-neutral-500">{t("common.loading")}</p>
|
||||
) : orders.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-neutral-500">
|
||||
{t("shops.noOrdersFromShop")}
|
||||
</p>
|
||||
) : (
|
||||
orders.map((order) => <BuyerOrderItem key={order._id} order={order} />)
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
192
src/components/shops/ShopProductCommentsModal.tsx
Normal file
192
src/components/shops/ShopProductCommentsModal.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Modal from "@/components/elements/Modal";
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useRequireAuth } from "@/lib/auth/useRequireAuth";
|
||||
import { btnPrimary } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ProductComment = {
|
||||
_id: string;
|
||||
comment: string;
|
||||
rating?: number | null;
|
||||
createdAt: string;
|
||||
creator: {
|
||||
_id: string;
|
||||
user_name: string;
|
||||
profile_image?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ShopProductCommentsModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
listingId: string;
|
||||
};
|
||||
|
||||
export default function ShopProductCommentsModal({
|
||||
open,
|
||||
onClose,
|
||||
listingId,
|
||||
}: ShopProductCommentsModalProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const requireAuth = useRequireAuth();
|
||||
|
||||
const [comments, setComments] = useState<ProductComment[] | null>(null);
|
||||
const [hasRated, setHasRated] = useState(false);
|
||||
const [text, setText] = useState("");
|
||||
const [rate, setRate] = useState(0);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const loadComments = () => {
|
||||
setComments(null);
|
||||
request<{ docs: ProductComment[]; hasRated?: boolean }>(
|
||||
"GET",
|
||||
`/shop-products/${listingId}/comments`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
)
|
||||
.then((res) => {
|
||||
setComments(res?.docs || []);
|
||||
setHasRated(Boolean(res?.hasRated));
|
||||
})
|
||||
.catch(() => setComments([]));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open && listingId) {
|
||||
loadComments();
|
||||
setRate(0);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, listingId]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!requireAuth() || !text.trim() || sending) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await request<{ comment: ProductComment }>(
|
||||
"POST",
|
||||
`/shop-products/${listingId}/comments`,
|
||||
rate > 0 ? { comment: text.trim(), rate } : { comment: text.trim() }
|
||||
);
|
||||
if (res?.comment) {
|
||||
setComments((prev) => [res.comment, ...(prev || [])]);
|
||||
if (rate > 0) setHasRated(true);
|
||||
}
|
||||
setText("");
|
||||
setRate(0);
|
||||
} catch {
|
||||
toast.error(t("posts.commentError"));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={open} onClose={onClose} height="min(82vh, 520px)">
|
||||
<div className="flex h-full flex-col">
|
||||
<h3 className="mb-3 shrink-0 text-center text-sm font-bold">
|
||||
{t("posts.comments")}
|
||||
</h3>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-1">
|
||||
{comments === null ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<IOSSpinner />
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-neutral-500">
|
||||
{t("posts.noComments")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{comments.map((c) => (
|
||||
<li key={c._id} className="flex items-start gap-3">
|
||||
<div className="relative h-9 w-9 shrink-0 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-800">
|
||||
{c.creator?.profile_image ? (
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + c.creator.profile_image}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-xs font-semibold">{c.creator?.user_name}</p>
|
||||
{c.rating ? (
|
||||
<span className="flex items-center gap-0.5 text-[10px] font-bold text-amber-500">
|
||||
{c.rating}
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/star1.png")}
|
||||
alt=""
|
||||
width={12}
|
||||
height={12}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="break-words text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{c.comment}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 shrink-0 border-t border-neutral-200 pt-3 dark:border-neutral-700">
|
||||
{!hasRated && (
|
||||
<div className="mb-2 flex items-center justify-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setRate(n === rate ? 0 : n)}
|
||||
aria-label={t("posts.ratingOptional")}
|
||||
className="p-0.5"
|
||||
>
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/star1.png")}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className={n <= rate ? "opacity-100" : "opacity-30"}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={t("posts.writeComment")}
|
||||
className="flex-1 rounded-full border border-neutral-300 bg-transparent px-4 py-2 text-sm outline-none dark:border-neutral-600"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!text.trim() || sending}
|
||||
onClick={() => void handleSubmit()}
|
||||
className={cn(btnPrimary, "shrink-0 rounded-full px-4 py-2 text-xs font-bold disabled:opacity-40")}
|
||||
>
|
||||
{t("posts.send")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
105
src/components/shops/ShopProductListRow.tsx
Normal file
105
src/components/shops/ShopProductListRow.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IShopProductListing } from "@/types/types";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ShopProductListRowProps = {
|
||||
listing: IShopProductListing;
|
||||
shopId: string;
|
||||
};
|
||||
|
||||
const STATUS_TEXT_COLOR: Record<string, string> = {
|
||||
active: "text-green-600",
|
||||
pending_shop_approval: "text-amber-600",
|
||||
inactive: "text-red-500",
|
||||
};
|
||||
|
||||
function cheapestPrice(listing: IShopProductListing): number | null {
|
||||
if (!listing.variants || listing.variants.length === 0) return null;
|
||||
return Math.min(...listing.variants.map((v) => v.discount_price || v.price));
|
||||
}
|
||||
|
||||
function totalStock(listing: IShopProductListing): number {
|
||||
return listing.variants?.reduce((sum, v) => sum + (v.stock || 0), 0) ?? 0;
|
||||
}
|
||||
|
||||
function isInStock(listing: IShopProductListing): boolean {
|
||||
return listing.variants?.some((v) => v.stock > 0) ?? false;
|
||||
}
|
||||
|
||||
function leafCategory(listing: IShopProductListing): string | null {
|
||||
return listing.sub_sub_category || listing.sub_category || listing.category || null;
|
||||
}
|
||||
|
||||
export default function ShopProductListRow({ listing, shopId }: ShopProductListRowProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const primaryImage =
|
||||
listing.images?.[listing.primaryImageIndex] || listing.images?.[0];
|
||||
const price = cheapestPrice(listing);
|
||||
const stockCount = totalStock(listing);
|
||||
const inStock = isInStock(listing);
|
||||
const category = leafCategory(listing);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/shops/${shopId}/products/${listing._id}`}
|
||||
className="gentle-transition flex items-center gap-3 rounded-[28px] border border-neutral-200 p-3 active:scale-[0.99] dark:border-neutral-700"
|
||||
>
|
||||
<div className="relative h-20 w-20 shrink-0 overflow-hidden rounded-2xl bg-neutral-100 dark:bg-neutral-800">
|
||||
{primaryImage && (
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + primaryImage}
|
||||
alt={listing.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="80px"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col items-end gap-1.5 text-right">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="line-clamp-1 text-base font-bold">{listing.title}</span>
|
||||
<span className="shrink-0 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t("shops.stockCountLabel", { count: stockCount })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{price != null && (
|
||||
<span className="text-xs text-neutral-700 dark:text-neutral-300">
|
||||
{t("shops.priceLabel", { amount: price.toLocaleString() })}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs font-semibold",
|
||||
STATUS_TEXT_COLOR[listing.status] || STATUS_TEXT_COLOR.pending_shop_approval
|
||||
)}
|
||||
>
|
||||
{t(`shops.status.${listing.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{category && (
|
||||
<span className="line-clamp-1 shrink-0 text-xs font-semibold text-red-500">
|
||||
{category}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs font-semibold",
|
||||
inStock ? "text-green-600" : "text-red-500"
|
||||
)}
|
||||
>
|
||||
{inStock ? t("shops.inStock") : t("shops.outOfStock")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
283
src/components/shops/ShopReelsView.tsx
Normal file
283
src/components/shops/ShopReelsView.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import ReelsScrollSlot from "@/components/posts/ReelsScrollSlot";
|
||||
import SendProductModal from "@/components/shops/SendProductModal";
|
||||
import ShareProductModal from "@/components/shops/ShareProductModal";
|
||||
import ShopProductCommentsModal from "@/components/shops/ShopProductCommentsModal";
|
||||
import { useRequireAuth } from "@/lib/auth/useRequireAuth";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
import { REELS_SCROLL_CLASS, useReelsSnapScroll } from "@/hooks/useReelsSnapScroll";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { addToCart, isFavorite, toggleFavorite } from "@/lib/shops/localCart";
|
||||
import { IShopProductListing } from "@/types/types";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useLayoutEffect, useMemo, useRef, useState, useEffect } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ShopReelsViewProps = {
|
||||
shopId: string;
|
||||
initialListingId: string;
|
||||
};
|
||||
|
||||
function cheapestPrice(listing: IShopProductListing): number | null {
|
||||
if (!listing.variants || listing.variants.length === 0) return null;
|
||||
return Math.min(...listing.variants.map((v) => v.discount_price || v.price));
|
||||
}
|
||||
|
||||
export default function ShopReelsView({ shopId, initialListingId }: ShopReelsViewProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const requireAuth = useRequireAuth();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useReelsSnapScroll(scrollRef);
|
||||
const scrolledRef = useRef(false);
|
||||
|
||||
const [listings, setListings] = useState<IShopProductListing[] | null>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set());
|
||||
const [shareListing, setShareListing] = useState<IShopProductListing | null>(null);
|
||||
const [sendListing, setSendListing] = useState<IShopProductListing | null>(null);
|
||||
const [commentListingId, setCommentListingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ docs: IShopProductListing[] }>(
|
||||
"GET",
|
||||
`/shop-products?shopId=${shopId}&limit=100`
|
||||
)
|
||||
.then((res) => {
|
||||
const docs = res?.docs || [];
|
||||
setListings(docs);
|
||||
setFavoriteIds(
|
||||
new Set(docs.filter((l) => isFavorite(l._id)).map((l) => l._id))
|
||||
);
|
||||
})
|
||||
.catch(() => setListings([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shopId]);
|
||||
|
||||
const handleToggleFavorite = (listingId: string) => {
|
||||
const next = toggleFavorite(listingId);
|
||||
setFavoriteIds((prev) => {
|
||||
const nextSet = new Set(prev);
|
||||
if (next) nextSet.add(listingId);
|
||||
else nextSet.delete(listingId);
|
||||
return nextSet;
|
||||
});
|
||||
toast.success(
|
||||
next ? t("shops.addedToFavorites") : t("shops.removedFromFavorites")
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddToCart = (listing: IShopProductListing) => {
|
||||
const variant = listing.variants?.[0];
|
||||
if (!variant) return;
|
||||
addToCart(listing._id, variant._id, 1);
|
||||
toast.success(t("shops.addToCartSuccess"));
|
||||
};
|
||||
|
||||
const initialIndex = useMemo(() => {
|
||||
if (!listings) return 0;
|
||||
const idx = listings.findIndex((l) => l._id === initialListingId);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}, [listings, initialListingId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (scrolledRef.current || !listings?.length || !scrollRef.current) return;
|
||||
scrollRef.current.scrollTo({
|
||||
top: initialIndex * (scrollRef.current.clientHeight || window.innerHeight),
|
||||
behavior: "auto",
|
||||
});
|
||||
setActiveIndex(initialIndex);
|
||||
scrolledRef.current = true;
|
||||
}, [listings, initialIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || !listings?.length) return;
|
||||
|
||||
const onScroll = () => {
|
||||
const step = el.clientHeight || window.innerHeight;
|
||||
const index = Math.min(
|
||||
listings.length - 1,
|
||||
Math.max(0, Math.round(el.scrollTop / step))
|
||||
);
|
||||
setActiveIndex(index);
|
||||
};
|
||||
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, [listings]);
|
||||
|
||||
if (listings === null) {
|
||||
return <PageLoader className="min-h-[100dvh] bg-black" />;
|
||||
}
|
||||
|
||||
if (listings.length === 0) {
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] items-center justify-center bg-black">
|
||||
<p className="text-sm text-neutral-400">{t("shops.noProductsYet")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-[100dvh] bg-black">
|
||||
<Link
|
||||
href={`/shops/profile/${shopId}`}
|
||||
aria-label={t("common.close")}
|
||||
className="absolute right-4 top-4 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-black/50"
|
||||
>
|
||||
<BoldIcon name="close-circle" size={22} tinted className="text-white" />
|
||||
</Link>
|
||||
|
||||
<div ref={scrollRef} className={REELS_SCROLL_CLASS}>
|
||||
{listings.map((listing, index) => {
|
||||
const primaryImage =
|
||||
listing.images?.[listing.primaryImageIndex] || listing.images?.[0];
|
||||
const price = cheapestPrice(listing);
|
||||
|
||||
const favorited = favoriteIds.has(listing._id);
|
||||
|
||||
return (
|
||||
<ReelsScrollSlot key={listing._id} index={index} activeIndex={activeIndex}>
|
||||
<div className="relative flex h-full w-full items-center justify-center bg-black">
|
||||
{primaryImage && (
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + primaryImage}
|
||||
alt={listing.title}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="100vw"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-36 left-3 z-30 flex flex-col items-center gap-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!requireAuth()) return;
|
||||
setCommentListingId(listing._id);
|
||||
}}
|
||||
aria-label={t("posts.comments")}
|
||||
className="flex flex-col items-center gap-1"
|
||||
>
|
||||
<BoldIcon
|
||||
name="message"
|
||||
size={26}
|
||||
tinted
|
||||
className="text-white brightness-125 drop-shadow-lg"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!requireAuth()) return;
|
||||
setSendListing(listing);
|
||||
}}
|
||||
aria-label={t("shops.sendProductTitle")}
|
||||
className="flex flex-col items-center gap-1"
|
||||
>
|
||||
<BoldIcon
|
||||
name="send-2"
|
||||
size={26}
|
||||
tinted
|
||||
className="text-white brightness-125 drop-shadow-lg"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShareListing(listing)}
|
||||
aria-label={t("shops.share.title")}
|
||||
className="flex flex-col items-center gap-1"
|
||||
>
|
||||
<BoldIcon
|
||||
name="share"
|
||||
size={26}
|
||||
tinted
|
||||
className="text-white brightness-125 drop-shadow-lg"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleFavorite(listing._id)}
|
||||
aria-label={
|
||||
favorited
|
||||
? t("shops.removeFromFavoritesButton")
|
||||
: t("shops.addToFavoritesButton")
|
||||
}
|
||||
className="flex flex-col items-center gap-1"
|
||||
>
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/bookmark-post.svg")}
|
||||
alt=""
|
||||
width={28}
|
||||
height={28}
|
||||
className={favorited ? "drop-shadow-lg" : "invert brightness-125 drop-shadow-lg"}
|
||||
unoptimized
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4 pb-8 text-white">
|
||||
<p className="mb-1 line-clamp-1 text-base font-bold">{listing.title}</p>
|
||||
{price != null && (
|
||||
<p className="mb-3 text-sm text-neutral-200">
|
||||
{t("shops.priceLabel", { amount: price.toLocaleString() })}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/shops/listing/${listing._id}`}
|
||||
className="inline-block rounded-3xl bg-white px-5 py-2 text-sm font-bold text-black"
|
||||
>
|
||||
{t("shops.viewProduct")}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddToCart(listing)}
|
||||
className="inline-block rounded-3xl bg-green-500 px-5 py-2 text-sm font-bold text-white"
|
||||
>
|
||||
{t("shops.addToCartButton")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ReelsScrollSlot>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ShareProductModal
|
||||
open={Boolean(shareListing)}
|
||||
onClose={() => setShareListing(null)}
|
||||
listingId={shareListing?._id || ""}
|
||||
title={shareListing?.title}
|
||||
image={
|
||||
shareListing?.images?.[shareListing.primaryImageIndex] ||
|
||||
shareListing?.images?.[0]
|
||||
}
|
||||
/>
|
||||
|
||||
{sendListing && (
|
||||
<SendProductModal
|
||||
open={Boolean(sendListing)}
|
||||
onClose={() => setSendListing(null)}
|
||||
listing={sendListing}
|
||||
/>
|
||||
)}
|
||||
|
||||
{commentListingId && (
|
||||
<ShopProductCommentsModal
|
||||
open={Boolean(commentListingId)}
|
||||
onClose={() => setCommentListingId(null)}
|
||||
listingId={commentListingId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
src/components/shops/TimeSelect.tsx
Normal file
31
src/components/shops/TimeSelect.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
const TIME_OPTIONS = Array.from({ length: 48 }, (_, i) => {
|
||||
const hour = String(Math.floor(i / 2)).padStart(2, "0");
|
||||
const minute = i % 2 === 0 ? "00" : "30";
|
||||
return `${hour}:${minute}`;
|
||||
});
|
||||
|
||||
type TimeSelectProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
export default function TimeSelect({ value, onChange, ariaLabel }: TimeSelectProps) {
|
||||
return (
|
||||
<select
|
||||
aria-label={ariaLabel}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="rounded-xl border border-neutral-300 bg-white px-2 py-1.5 text-xs dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-50"
|
||||
>
|
||||
<option value="">--:--</option>
|
||||
{TIME_OPTIONS.map((time) => (
|
||||
<option key={time} value={time}>
|
||||
{time}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
33
src/components/shops/ToggleSwitch.tsx
Normal file
33
src/components/shops/ToggleSwitch.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
type ToggleSwitchProps = {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
export default function ToggleSwitch({
|
||||
checked,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: ToggleSwitchProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={ariaLabel}
|
||||
onClick={() => onChange(!checked)}
|
||||
dir="ltr"
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
|
||||
checked ? "bg-blue-600" : "bg-neutral-300 dark:bg-neutral-600"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
|
||||
checked ? "translate-x-5" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
145
src/components/shops/orders/BuyerInfoModal.tsx
Normal file
145
src/components/shops/orders/BuyerInfoModal.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type BuyerAddressSnapshot = {
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
mobile?: string | null;
|
||||
address?: string | null;
|
||||
province?: { name?: string } | null;
|
||||
city?: { name?: string } | null;
|
||||
lat?: string | null;
|
||||
lng?: string | null;
|
||||
};
|
||||
|
||||
type BuyerInfoModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
buyer?: BuyerAddressSnapshot | null;
|
||||
};
|
||||
|
||||
export default function BuyerInfoModal({ isOpen, onClose, buyer }: BuyerInfoModalProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const [coords, setCoords] = useState<[number, number] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setCoords(null);
|
||||
return;
|
||||
}
|
||||
const lat = parseFloat(buyer?.lat || "");
|
||||
const lng = parseFloat(buyer?.lng || "");
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
setCoords([lat, lng]);
|
||||
} else {
|
||||
setCoords(null);
|
||||
}
|
||||
}, [buyer, isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const fullName = [buyer?.first_name, buyer?.last_name].filter(Boolean).join(" ");
|
||||
const googleMapsUrl = coords
|
||||
? `https://www.google.com/maps/dir/?api=1&destination=${coords[0]},${coords[1]}`
|
||||
: "https://www.google.com/maps";
|
||||
|
||||
return (
|
||||
<div className="glass-modal-overlay fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="glass-modal-panel glass-modal-panel--center mx-4 max-h-[80vh] w-full max-w-md scale-95 overflow-y-auto p-6 shadow-2xl animate-fadeIn">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<BoldIcon name="user" size={20} tinted className="text-blue-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-neutral-100">
|
||||
{t("shops.buyerInfoTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 text-sm text-gray-600 dark:text-neutral-300">
|
||||
{fullName && (
|
||||
<p className="text-base font-semibold text-gray-800 dark:text-neutral-100">
|
||||
{fullName}
|
||||
</p>
|
||||
)}
|
||||
{buyer?.mobile && (
|
||||
<p className="mt-2">
|
||||
<strong className="text-gray-800 dark:text-neutral-100">
|
||||
{t("shops.buyerMobileLabel")}{" "}
|
||||
</strong>
|
||||
<span dir="ltr">{buyer.mobile}</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2">
|
||||
<strong className="text-gray-800 dark:text-neutral-100">
|
||||
{t("models.location.city")}{" "}
|
||||
</strong>
|
||||
{buyer?.city?.name || t("models.location.unknown")}،{" "}
|
||||
{buyer?.province?.name || t("models.location.unknown")}
|
||||
</p>
|
||||
{buyer?.address && (
|
||||
<p className="mt-2">
|
||||
<strong className="text-gray-800 dark:text-neutral-100">
|
||||
{t("models.location.address")}{" "}
|
||||
</strong>
|
||||
{buyer.address}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{coords ? (
|
||||
<div className="mb-6 overflow-hidden rounded-lg border border-gray-200">
|
||||
<iframe
|
||||
title={t("models.location.mapTitle")}
|
||||
width="100%"
|
||||
height="200"
|
||||
frameBorder="0"
|
||||
style={{ border: 0 }}
|
||||
src={`https://maps.google.com/maps?q=${coords[0]},${coords[1]}&z=15&output=embed`}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
></iframe>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mb-6 text-sm text-gray-500">{t("models.location.invalidCoords")}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<a
|
||||
href={googleMapsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(btnPrimary, "px-4 py-2 text-sm font-medium")}
|
||||
>
|
||||
{t("models.location.directions")}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn(btnDefault, "px-4 py-2 text-sm font-medium")}
|
||||
>
|
||||
{t("models.location.close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
type BuyerOrder = {
|
||||
_id: string;
|
||||
order_number?: string;
|
||||
total_amount: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
@@ -58,6 +59,11 @@ export default function BuyerOrderItem({ order }: { order: BuyerOrder }) {
|
||||
<p className="text-xs text-neutral-500">
|
||||
{order.total_amount.toLocaleString()} {t("settings.toman")}
|
||||
</p>
|
||||
{order.order_number && (
|
||||
<p className="text-[11px] text-neutral-400">
|
||||
{t("shops.orderNumberLabel")}: {order.order_number}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end max-sm:items-center max-sm:text-[11px]">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
type SellerOrder = {
|
||||
_id: string;
|
||||
order_number?: string;
|
||||
quantity: number;
|
||||
total_amount: number;
|
||||
status: string;
|
||||
@@ -64,6 +65,11 @@ export default function SellerOrderItem({ order }: { order: SellerOrder }) {
|
||||
<p className="text-xs text-neutral-500">
|
||||
{order.total_amount.toLocaleString()} {t("settings.toman")}
|
||||
</p>
|
||||
{order.order_number && (
|
||||
<p className="text-[11px] text-neutral-400">
|
||||
{t("shops.orderNumberLabel")}: {order.order_number}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end max-sm:items-center max-sm:text-[11px]">
|
||||
|
||||
149
src/components/shops/orders/ShopInfoModal.tsx
Normal file
149
src/components/shops/orders/ShopInfoModal.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ShopInfo = {
|
||||
_id: string;
|
||||
name: string;
|
||||
has_physical_location?: boolean;
|
||||
province?: { name?: string } | null;
|
||||
city?: { name?: string } | null;
|
||||
address?: string | null;
|
||||
lat?: string | null;
|
||||
lng?: string | null;
|
||||
};
|
||||
|
||||
type ShopInfoModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
shop?: ShopInfo | null;
|
||||
};
|
||||
|
||||
export default function ShopInfoModal({ isOpen, onClose, shop }: ShopInfoModalProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const [coords, setCoords] = useState<[number, number] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setCoords(null);
|
||||
return;
|
||||
}
|
||||
const lat = parseFloat(shop?.lat || "");
|
||||
const lng = parseFloat(shop?.lng || "");
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
setCoords([lat, lng]);
|
||||
} else {
|
||||
setCoords(null);
|
||||
}
|
||||
}, [shop, isOpen]);
|
||||
|
||||
if (!isOpen || !shop) return null;
|
||||
|
||||
const googleMapsUrl = coords
|
||||
? `https://www.google.com/maps/dir/?api=1&destination=${coords[0]},${coords[1]}`
|
||||
: "https://www.google.com/maps";
|
||||
|
||||
return (
|
||||
<div className="glass-modal-overlay fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="glass-modal-panel glass-modal-panel--center mx-4 max-h-[80vh] w-full max-w-md scale-95 overflow-y-auto p-6 shadow-2xl animate-fadeIn">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<BoldIcon name="shop" size={20} tinted className="text-blue-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-neutral-100">
|
||||
{t("shops.shopInfoTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-neutral-300">
|
||||
<p className="text-base font-semibold text-gray-800 dark:text-neutral-100">
|
||||
{shop.name}
|
||||
</p>
|
||||
{shop.has_physical_location && (
|
||||
<>
|
||||
<p className="mt-2">
|
||||
<strong className="text-gray-800 dark:text-neutral-100">
|
||||
{t("models.location.city")}{" "}
|
||||
</strong>
|
||||
{shop.city?.name || t("models.location.unknown")}،{" "}
|
||||
{shop.province?.name || t("models.location.unknown")}
|
||||
</p>
|
||||
{shop.address && (
|
||||
<p className="mt-2">
|
||||
<strong className="text-gray-800 dark:text-neutral-100">
|
||||
{t("models.location.address")}{" "}
|
||||
</strong>
|
||||
{shop.address}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/shops/profile/${shop._id}`}
|
||||
className={cn(btnDefault, "mb-4 block w-full px-4 py-2 text-center text-sm font-medium")}
|
||||
>
|
||||
{t("shops.viewShopProfileButton")}
|
||||
</Link>
|
||||
|
||||
{shop.has_physical_location &&
|
||||
(coords ? (
|
||||
<div className="mb-6 overflow-hidden rounded-lg border border-gray-200">
|
||||
<iframe
|
||||
title={t("models.location.mapTitle")}
|
||||
width="100%"
|
||||
height="200"
|
||||
frameBorder="0"
|
||||
style={{ border: 0 }}
|
||||
src={`https://maps.google.com/maps?q=${coords[0]},${coords[1]}&z=15&output=embed`}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
></iframe>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mb-6 text-sm text-gray-500">{t("models.location.invalidCoords")}</p>
|
||||
))}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
{shop.has_physical_location && (
|
||||
<a
|
||||
href={googleMapsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(btnPrimary, "px-4 py-2 text-sm font-medium")}
|
||||
>
|
||||
{t("models.location.directions")}
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn(btnDefault, "px-4 py-2 text-sm font-medium")}
|
||||
>
|
||||
{t("models.location.close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,9 @@ export default function SendStoryModal({
|
||||
{ noToast: true }
|
||||
);
|
||||
setUsers(
|
||||
(res?.filteredUsersData || []).filter((u) => u._id !== currentUserId)
|
||||
(res?.filteredUsersData || []).filter(
|
||||
(u) => u._id !== currentUserId && Boolean(u.user_name)
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -179,19 +179,25 @@ const useAxios = () => {
|
||||
try {
|
||||
const isFormData = data instanceof FormData;
|
||||
|
||||
const headers: NonNullable<AxiosRequestConfig["headers"]> = {
|
||||
...config.headers,
|
||||
...(isFormData ? {} : { "Content-Type": "application/json" }),
|
||||
...(config.noToast ? { "X-No-Toast": "true" } : {}), // ارسال noToast به عنوان یک هدر
|
||||
};
|
||||
// مرورگر باید Content-Type مولتیپارت را همراه با boundary خودش بسازد؛
|
||||
// مقداردهی دستی این هدر باعث حذف boundary و شکست پارس سمت سرور میشود.
|
||||
if (isFormData) {
|
||||
delete headers["Content-Type"];
|
||||
delete headers["content-type"];
|
||||
}
|
||||
|
||||
const response = await axiosInstance({
|
||||
method,
|
||||
url,
|
||||
data,
|
||||
baseURL: getApiBaseUrl(),
|
||||
...config,
|
||||
headers: {
|
||||
|
||||
|
||||
...config.headers,
|
||||
...(isFormData ? {} : { "Content-Type": "application/json" }), // حذف Content-Type برای FormData
|
||||
...(config.noToast ? { "X-No-Toast": "true" } : {}), // ارسال noToast به عنوان یک هدر
|
||||
},
|
||||
headers,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
|
||||
@@ -5,9 +5,11 @@ import useAxios from "@/hooks/useAxios";
|
||||
|
||||
interface FetchParams {
|
||||
endpoint: string;
|
||||
queryKey: (string | number)[];
|
||||
queryKey: (string | number)[];
|
||||
params?: Record<string, any>;
|
||||
limit?: number;
|
||||
/** Set to false to defer fetching until dependencies (e.g. an id) are ready. Defaults to true. */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const useInfiniteScroll = ({
|
||||
@@ -15,6 +17,7 @@ const useInfiniteScroll = ({
|
||||
queryKey,
|
||||
params = {},
|
||||
limit = 10,
|
||||
enabled = true,
|
||||
}: FetchParams) => {
|
||||
const { request } = useAxios();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -41,6 +44,7 @@ const useInfiniteScroll = ({
|
||||
lastPage.totalPages > allPages.length ? allPages.length + 1 : undefined,
|
||||
staleTime: 0,
|
||||
refetchOnMount: "always",
|
||||
enabled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -17,6 +17,12 @@ export function clearProductWizardId(shopId: string): void {
|
||||
localStorage.removeItem(storageKey(shopId));
|
||||
}
|
||||
|
||||
/** Synchronous, no-redirect read — used by the name step to detect edit mode. */
|
||||
export function getProductWizardId(shopId: string): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem(storageKey(shopId));
|
||||
}
|
||||
|
||||
/** Reads the in-progress listing id saved by the product name step; redirects back to it if missing. */
|
||||
export function useProductWizardId(shopId: string): string | null {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -3,16 +3,31 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const STORAGE_KEY = "shop_wizard_shop_id";
|
||||
|
||||
export function saveShopWizardId(shopId: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(STORAGE_KEY, shopId);
|
||||
}
|
||||
|
||||
export function clearShopWizardId(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
/** Synchronous, no-redirect read — used by the name step to detect edit mode. */
|
||||
export function getShopWizardId(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
/** Reads the in-progress shop id saved by the name step; redirects back to it if missing. */
|
||||
export function useShopWizardId(): string | null {
|
||||
const router = useRouter();
|
||||
const [shopId, setShopId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const stored =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem("shop_wizard_shop_id")
|
||||
: null;
|
||||
const stored = getShopWizardId();
|
||||
|
||||
if (!stored) {
|
||||
router.replace("/shops/new/name");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { parseLocationContent } from "@/components/chat/LocationMessageBubble";
|
||||
import { parseSharedPost } from "@/components/chat/SharedPostBubble";
|
||||
import { parseSharedProduct } from "@/components/chat/SharedProductBubble";
|
||||
import {
|
||||
extractStoryReactionText,
|
||||
parseSharedStory,
|
||||
@@ -54,6 +55,11 @@ export function getCopyableMessageText(
|
||||
return null;
|
||||
}
|
||||
|
||||
const sharedProduct = parseSharedProduct(content);
|
||||
if (sharedProduct) {
|
||||
return sharedProduct.title?.trim() || null;
|
||||
}
|
||||
|
||||
const sharedStory = parseSharedStory(content);
|
||||
if (sharedStory) {
|
||||
const reactionText = extractStoryReactionText(content);
|
||||
|
||||
63
src/lib/shops/localCart.ts
Normal file
63
src/lib/shops/localCart.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
export type CartItem = {
|
||||
listingId: string;
|
||||
variantId: string;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
const CART_KEY = "shop_local_cart";
|
||||
const FAVORITES_KEY = "shop_local_favorites";
|
||||
|
||||
function readJson<T>(key: string, fallback: T): T {
|
||||
if (typeof window === "undefined") return fallback;
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson<T>(key: string, value: T): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
export function getCart(): CartItem[] {
|
||||
return readJson<CartItem[]>(CART_KEY, []);
|
||||
}
|
||||
|
||||
export function addToCart(listingId: string, variantId: string, quantity = 1): void {
|
||||
const cart = getCart();
|
||||
const existing = cart.find(
|
||||
(item) => item.listingId === listingId && item.variantId === variantId
|
||||
);
|
||||
if (existing) {
|
||||
existing.quantity += quantity;
|
||||
} else {
|
||||
cart.push({ listingId, variantId, quantity });
|
||||
}
|
||||
writeJson(CART_KEY, cart);
|
||||
}
|
||||
|
||||
export function getFavorites(): string[] {
|
||||
return readJson<string[]>(FAVORITES_KEY, []);
|
||||
}
|
||||
|
||||
export function isFavorite(listingId: string): boolean {
|
||||
return getFavorites().includes(listingId);
|
||||
}
|
||||
|
||||
export function toggleFavorite(listingId: string): boolean {
|
||||
const favorites = getFavorites();
|
||||
const idx = favorites.indexOf(listingId);
|
||||
if (idx >= 0) {
|
||||
favorites.splice(idx, 1);
|
||||
writeJson(FAVORITES_KEY, favorites);
|
||||
return false;
|
||||
}
|
||||
favorites.push(listingId);
|
||||
writeJson(FAVORITES_KEY, favorites);
|
||||
return true;
|
||||
}
|
||||
@@ -2173,11 +2173,23 @@
|
||||
"loadingCategories": "Loading categories...",
|
||||
"selectSubCategory": "Select your sub-categories",
|
||||
"subCategoryDisplayHint": "Check an item to show it on the shop page",
|
||||
"subCategoryMaxHint": "You can select up to 3 sub-categories",
|
||||
"subCategoryMaxReached": "You can select up to 3 sub-categories",
|
||||
"displaySubCategoryAria": "Show {{name}} on the shop",
|
||||
"unknownError": "An unknown error occurred",
|
||||
"saveAndContinue": "Save and continue",
|
||||
"nameTitle": "Shop name",
|
||||
"namePlaceholder": "Enter shop name",
|
||||
"descriptionPlaceholder": "Shop description (optional)",
|
||||
"share": {
|
||||
"title": "Share shop",
|
||||
"linkLabel": "Shop link",
|
||||
"copyLink": "Copy link",
|
||||
"copied": "Link copied",
|
||||
"copyFailed": "Copy failed",
|
||||
"qrAlt": "Shop QR code",
|
||||
"close": "Close"
|
||||
},
|
||||
"nameRequired": "Shop name is required",
|
||||
"logoTitle": "Shop logo",
|
||||
"selectLogo": "Select/edit logo",
|
||||
@@ -2186,6 +2198,8 @@
|
||||
"categoryRequired": "Category, sub-category, and a display choice are required",
|
||||
"shippingTitle": "Shipping methods",
|
||||
"shippingMethodRequired": "Select at least one shipping method",
|
||||
"activeShippingMethods": "Active shipping methods",
|
||||
"supplementarySettings": "Supplementary settings & services",
|
||||
"shippingMethods": {
|
||||
"post": "Post",
|
||||
"tipax": "Tipax",
|
||||
@@ -2204,9 +2218,11 @@
|
||||
"neighbourhoodPlaceholder": "Neighbourhood (optional)",
|
||||
"addressPlaceholder": "Address (optional)",
|
||||
"contactTitle": "Shop support info",
|
||||
"phonePlaceholder": "Phone",
|
||||
"landlinePlaceholder": "Landline",
|
||||
"contactRequired": "Enter at least one contact method",
|
||||
"responseScheduleTitle": "Response days and hours",
|
||||
"responseStartTime": "Start time",
|
||||
"responseEndTime": "End time",
|
||||
"days": {
|
||||
"sat": "Saturday",
|
||||
"sun": "Sunday",
|
||||
@@ -2217,11 +2233,87 @@
|
||||
"fri": "Friday"
|
||||
},
|
||||
"previewTitle": "Shop preview",
|
||||
"previewMinOrder": "Minimum order",
|
||||
"previewDeliveryTime": "Delivery time",
|
||||
"previewYes": "Yes",
|
||||
"submitShop": "Submit shop",
|
||||
"submitSuccess": "Shop submitted for review successfully",
|
||||
"mySwitcher": "My shops",
|
||||
"addShop": "Add shop/product",
|
||||
"noShopsYet": "You haven't registered a shop yet",
|
||||
"myShopsTitle": "My shops",
|
||||
"myProductsTitle": "My products",
|
||||
"addShopNew": "Add new shop",
|
||||
"addProductNew": "Add product",
|
||||
"searchShopPlaceholder": "Search shop",
|
||||
"searchProductPlaceholder": "Search product",
|
||||
"onlineShop": "Online shop",
|
||||
"inPerson": "In person",
|
||||
"onlineOnly": "Online",
|
||||
"categoryLabel": "Category: {{category}}",
|
||||
"stockCountLabel": "{{count}} pcs",
|
||||
"specsTitle": "Product specifications",
|
||||
"categorySpecLabel": "Category",
|
||||
"sizeLabel": "Size",
|
||||
"colorLabel": "Color",
|
||||
"weightLabel": "Weight",
|
||||
"descriptionLabel": "Product description",
|
||||
"priceSpecLabel": "Price",
|
||||
"showMore": "More",
|
||||
"showLess": "Less",
|
||||
"manageStockButton": "Manage stock",
|
||||
"deactivateButton": "Deactivate",
|
||||
"activateButton": "Activate",
|
||||
"editProductButton": "Edit product info",
|
||||
"editShopButton": "Edit shop",
|
||||
"viewPublicProfile": "View shop page",
|
||||
"viewProduct": "View product",
|
||||
"myOrdersFromShop": "My orders from this shop",
|
||||
"noOrdersFromShop": "You haven't ordered from this shop yet",
|
||||
"satisfactionLabel": "Product satisfaction",
|
||||
"performanceLabel": "Shop performance",
|
||||
"noRatingYet": "No rating yet",
|
||||
"productsLabel": "Products",
|
||||
"underReview": "Under review",
|
||||
"newShop": "New shop",
|
||||
"placeOrderButton": "Place order",
|
||||
"addToCartButton": "Add to cart",
|
||||
"addToCartSuccess": "Added to cart",
|
||||
"addToFavoritesButton": "Add to favorites",
|
||||
"removeFromFavoritesButton": "Remove from favorites",
|
||||
"addedToFavorites": "Added to favorites",
|
||||
"removedFromFavorites": "Removed from favorites",
|
||||
"sendProductTitle": "Send product",
|
||||
"sentProductToCount": "Product sent to {{count}} people",
|
||||
"sendProductError": "Failed to send product",
|
||||
"noChatMessagesYet": "No messages yet",
|
||||
"performance": {
|
||||
"weak": "Weak",
|
||||
"average": "Average",
|
||||
"good": "Good",
|
||||
"veryGood": "Very good",
|
||||
"excellent": "Excellent"
|
||||
},
|
||||
"copyAddress": "Copy shop address",
|
||||
"addressCopied": "Address copied",
|
||||
"sendMessage": "Send message",
|
||||
"contactInfoButton": "Contact info",
|
||||
"followButton": "Follow",
|
||||
"unfollowButton": "Unfollow",
|
||||
"lessThanOneMonth": "Less than 1 month",
|
||||
"monthsCount": "{{count}} months",
|
||||
"showLocation": "Show location",
|
||||
"shopFollowSuccess": "Shop followed",
|
||||
"shopUnfollowSuccess": "Unfollowed",
|
||||
"productDeactivated": "Product deactivated",
|
||||
"productActivated": "Product activated",
|
||||
"directManagement": "Direct management",
|
||||
"itemsCount": "{{count}} items",
|
||||
"activeProductsLabel": "{{count}} active",
|
||||
"soldCountLabel": "{{count}} sold",
|
||||
"inStock": "In stock",
|
||||
"outOfStock": "Out of stock",
|
||||
"ordersTitle": "Shop orders",
|
||||
"sellerTab": "Seller",
|
||||
"buyerTab": "Buyer",
|
||||
"sellerOrdersEmpty": "No orders for your shop yet",
|
||||
@@ -2229,6 +2321,7 @@
|
||||
"status": {
|
||||
"draft": "Draft",
|
||||
"pending_review": "Pending review",
|
||||
"pending_shop_approval": "Pending approval",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"rejected": "Rejected"
|
||||
@@ -2237,9 +2330,14 @@
|
||||
"productNamePlaceholder": "Enter product name",
|
||||
"productNameRequired": "Product name is required",
|
||||
"productDescriptionPlaceholder": "Product description (optional)",
|
||||
"productCategoryTitle": "Product category",
|
||||
"productCategoryRequired": "Selecting a main category, subcategory, and tertiary category is required",
|
||||
"selectMainCategory": "Select the main category",
|
||||
"selectSubSubCategory": "Select the tertiary category",
|
||||
"productImagesTitle": "Product images",
|
||||
"productImagesHint": "Add multiple images and pick one as the primary image",
|
||||
"primaryImage": "Primary image",
|
||||
"removeImage": "Remove image",
|
||||
"setPrimaryImage": "Set as primary",
|
||||
"variantsTitle": "Price & specifications",
|
||||
"colorsPlaceholder": "Colors, comma-separated (e.g. red, blue)",
|
||||
@@ -2247,8 +2345,17 @@
|
||||
"weightsPlaceholder": "Weights, comma-separated (optional)",
|
||||
"generateVariants": "Build price table",
|
||||
"defaultVariant": "Default",
|
||||
"pricePlaceholder": "Price (Toman)",
|
||||
"pricePlaceholder": "Original price",
|
||||
"discountPricePlaceholder": "Discounted price",
|
||||
"stockPlaceholder": "Stock",
|
||||
"discountPercentLabel": "{{percent}}% off",
|
||||
"discountPercentBadge": "{{percent}}%",
|
||||
"colorColumn": "Color",
|
||||
"sizeColumn": "Size",
|
||||
"weightColumn": "Weight",
|
||||
"priceColumn": "Price (Toman)",
|
||||
"totalVariantsLabel": "Total variants: {{count}}",
|
||||
"removeVariant": "Remove",
|
||||
"variantsRequired": "Enter at least one combination with a valid price",
|
||||
"saveProduct": "Save product",
|
||||
"productSaved": "Product saved successfully",
|
||||
@@ -2308,6 +2415,30 @@
|
||||
"returnReasonPlaceholder": "Enter the reason for the return",
|
||||
"submitReturn": "Submit return request",
|
||||
"returnRequestSubmitted": "Return request submitted",
|
||||
"yourRatingLabel": "Your rating",
|
||||
"buyerMobileLabel": "Mobile:",
|
||||
"shopInfoTitle": "Shop info",
|
||||
"orderNumberLabel": "Order number",
|
||||
"searchOrderPlaceholder": "Search order number",
|
||||
"provinceRequired": "Province is required",
|
||||
"cityRequired": "City is required",
|
||||
"viewShopProfileButton": "View shop profile",
|
||||
"shippingTimeLabel": "Shipping time",
|
||||
"shippingTypeLabel": "Shipping type",
|
||||
"shippingCostLabel": "Shipping cost",
|
||||
"grandTotalLabel": "Grand total",
|
||||
"reportStatusTitle": "Reported issue",
|
||||
"returnStatusTitle": "Return request",
|
||||
"reportStatus": {
|
||||
"open": "Under review",
|
||||
"resolved": "Resolved"
|
||||
},
|
||||
"returnStatus": {
|
||||
"pending": "Pending review",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"refunded": "Refunded"
|
||||
},
|
||||
"withdrawalRequested": "Withdrawal request submitted successfully",
|
||||
"totalBalance": "Total wallet balance",
|
||||
"walletShopTab": "Shop",
|
||||
|
||||
@@ -2173,11 +2173,23 @@
|
||||
"loadingCategories": "در حال بارگذاری دستهبندیها...",
|
||||
"selectSubCategory": "زیردستههای خود را انتخاب کنید",
|
||||
"subCategoryDisplayHint": "تیک کنار هر مورد، نمایش آن در صفحه فروشگاه است",
|
||||
"subCategoryMaxHint": "حداکثر ۳ زیردسته قابل انتخاب است",
|
||||
"subCategoryMaxReached": "حداکثر ۳ زیردسته میتوانید انتخاب کنید",
|
||||
"displaySubCategoryAria": "نمایش {{name}} در فروشگاه",
|
||||
"unknownError": "خطای نامشخصی رخ داد",
|
||||
"saveAndContinue": "ثبت و ادامه",
|
||||
"nameTitle": "نام فروشگاه",
|
||||
"namePlaceholder": "نام فروشگاه را وارد کنید",
|
||||
"descriptionPlaceholder": "توضیحات فروشگاه (اختیاری)",
|
||||
"share": {
|
||||
"title": "اشتراکگذاری فروشگاه",
|
||||
"linkLabel": "لینک فروشگاه",
|
||||
"copyLink": "کپی لینک",
|
||||
"copied": "لینک کپی شد",
|
||||
"copyFailed": "کپی لینک انجام نشد",
|
||||
"qrAlt": "بارکد فروشگاه",
|
||||
"close": "بستن"
|
||||
},
|
||||
"nameRequired": "نام فروشگاه الزامی است",
|
||||
"logoTitle": "لوگوی فروشگاه",
|
||||
"selectLogo": "انتخاب/ویرایش لوگو",
|
||||
@@ -2186,6 +2198,8 @@
|
||||
"categoryRequired": "انتخاب دستهبندی، زیردسته و مورد نمایشی الزامی است",
|
||||
"shippingTitle": "روشهای ارسال",
|
||||
"shippingMethodRequired": "حداقل یک روش ارسال را انتخاب کنید",
|
||||
"activeShippingMethods": "لیست روشهای ارسال فعال",
|
||||
"supplementarySettings": "تنظیمات مکمل و خدمات",
|
||||
"shippingMethods": {
|
||||
"post": "پست",
|
||||
"tipax": "تیپاکس",
|
||||
@@ -2204,9 +2218,11 @@
|
||||
"neighbourhoodPlaceholder": "محله (اختیاری)",
|
||||
"addressPlaceholder": "آدرس (اختیاری)",
|
||||
"contactTitle": "اطلاعات پشتیبانی فروشگاه",
|
||||
"phonePlaceholder": "تلفن",
|
||||
"landlinePlaceholder": "تلفن ثابت",
|
||||
"contactRequired": "حداقل یک راه ارتباطی وارد کنید",
|
||||
"responseScheduleTitle": "روزها و ساعات پاسخگویی",
|
||||
"responseStartTime": "ساعت شروع",
|
||||
"responseEndTime": "ساعت پایان",
|
||||
"days": {
|
||||
"sat": "شنبه",
|
||||
"sun": "یکشنبه",
|
||||
@@ -2217,11 +2233,87 @@
|
||||
"fri": "جمعه"
|
||||
},
|
||||
"previewTitle": "پیشنمایش فروشگاه",
|
||||
"previewMinOrder": "حداقل سفارش",
|
||||
"previewDeliveryTime": "زمان ارسال",
|
||||
"previewYes": "دارم",
|
||||
"submitShop": "ثبت فروشگاه",
|
||||
"submitSuccess": "فروشگاه با موفقیت برای بررسی ارسال شد",
|
||||
"mySwitcher": "فروشگاههای من",
|
||||
"addShop": "افزودن فروشگاه/کالا",
|
||||
"noShopsYet": "هنوز فروشگاهی ثبت نکردهاید",
|
||||
"myShopsTitle": "لیست فروشگاههای من",
|
||||
"myProductsTitle": "محصولات من",
|
||||
"addShopNew": "افزودن فروشگاه جدید",
|
||||
"addProductNew": "افزودن کالا",
|
||||
"searchShopPlaceholder": "جستجوی فروشگاه",
|
||||
"searchProductPlaceholder": "جستجوی محصول",
|
||||
"onlineShop": "فروشگاه اینترنتی",
|
||||
"directManagement": "مدیریت مستقیم",
|
||||
"itemsCount": "{{count}} کالا",
|
||||
"activeProductsLabel": "{{count}} کالای فعال",
|
||||
"soldCountLabel": "{{count}} فروخته شده",
|
||||
"inStock": "موجود",
|
||||
"outOfStock": "اتمام موجودی",
|
||||
"inPerson": "حضوری",
|
||||
"onlineOnly": "اینترنتی",
|
||||
"categoryLabel": "دستهبندی: {{category}}",
|
||||
"stockCountLabel": "{{count}} عدد",
|
||||
"specsTitle": "مشخصات کالا",
|
||||
"categorySpecLabel": "دستهبندی",
|
||||
"sizeLabel": "سایز",
|
||||
"colorLabel": "رنگ",
|
||||
"weightLabel": "وزن",
|
||||
"descriptionLabel": "توضیحات محصول",
|
||||
"priceSpecLabel": "قیمت",
|
||||
"showMore": "بیشتر",
|
||||
"showLess": "کمتر",
|
||||
"manageStockButton": "مدیریت موجودی کالا",
|
||||
"deactivateButton": "غیرفعال کردن",
|
||||
"activateButton": "فعالسازی",
|
||||
"editProductButton": "ویرایش اطلاعات کالا",
|
||||
"editShopButton": "ویرایش فروشگاه",
|
||||
"viewPublicProfile": "نمایش صفحه فروشگاه",
|
||||
"viewProduct": "مشاهده کالا",
|
||||
"myOrdersFromShop": "پیگیری سفارشات من",
|
||||
"noOrdersFromShop": "هنوز سفارشی از این فروشگاه ثبت نکردهاید",
|
||||
"satisfactionLabel": "رضایت از کالاها",
|
||||
"performanceLabel": "عملکرد فروشگاه",
|
||||
"noRatingYet": "بدون امتیاز",
|
||||
"productsLabel": "کالا",
|
||||
"underReview": "در حال بررسی",
|
||||
"newShop": "فروشگاه جدید",
|
||||
"placeOrderButton": "ثبت خرید",
|
||||
"addToCartButton": "افزودن به سبد خرید",
|
||||
"addToCartSuccess": "به سبد خرید اضافه شد",
|
||||
"addToFavoritesButton": "افزودن به علاقمندیها",
|
||||
"removeFromFavoritesButton": "حذف از علاقمندیها",
|
||||
"addedToFavorites": "به علاقمندیها اضافه شد",
|
||||
"removedFromFavorites": "از علاقمندیها حذف شد",
|
||||
"sendProductTitle": "ارسال کالا",
|
||||
"sentProductToCount": "کالا به {{count}} نفر ارسال شد",
|
||||
"sendProductError": "خطا در ارسال کالا",
|
||||
"noChatMessagesYet": "هنوز پیامی رد و بدل نشده است",
|
||||
"performance": {
|
||||
"weak": "ضعیف",
|
||||
"average": "معمولی",
|
||||
"good": "خوب",
|
||||
"veryGood": "بسیار خوب",
|
||||
"excellent": "عالی"
|
||||
},
|
||||
"copyAddress": "کپی آدرس فروشگاه",
|
||||
"addressCopied": "آدرس کپی شد",
|
||||
"sendMessage": "ارسال پیام",
|
||||
"contactInfoButton": "اطلاعات تماس",
|
||||
"followButton": "دنبال کردن",
|
||||
"unfollowButton": "لغو دنبال کردن",
|
||||
"lessThanOneMonth": "کمتر از ۱ ماه",
|
||||
"monthsCount": "{{count}} ماه",
|
||||
"showLocation": "نمایش لوکیشن",
|
||||
"shopFollowSuccess": "فروشگاه دنبال شد",
|
||||
"shopUnfollowSuccess": "دنبال کردن لغو شد",
|
||||
"productDeactivated": "کالا غیرفعال شد",
|
||||
"productActivated": "کالا فعال شد",
|
||||
"ordersTitle": "سفارشهای فروشگاه",
|
||||
"sellerTab": "فروشنده",
|
||||
"buyerTab": "خریدار",
|
||||
"sellerOrdersEmpty": "هنوز سفارشی برای فروشگاه شما ثبت نشده است",
|
||||
@@ -2229,6 +2321,7 @@
|
||||
"status": {
|
||||
"draft": "پیشنویس",
|
||||
"pending_review": "در انتظار بررسی",
|
||||
"pending_shop_approval": "در انتظار تایید",
|
||||
"active": "فعال",
|
||||
"inactive": "غیرفعال",
|
||||
"rejected": "رد شده"
|
||||
@@ -2237,9 +2330,14 @@
|
||||
"productNamePlaceholder": "نام کالا را وارد کنید",
|
||||
"productNameRequired": "نام کالا الزامی است",
|
||||
"productDescriptionPlaceholder": "توضیحات کالا (اختیاری)",
|
||||
"productCategoryTitle": "دستهبندی کالا",
|
||||
"productCategoryRequired": "انتخاب دستهبندی اصلی، زیرگروه و دستهبندی فرعی الزامی است",
|
||||
"selectMainCategory": "دستهبندی اصلی را انتخاب کنید",
|
||||
"selectSubSubCategory": "دستهبندی فرعی را انتخاب کنید",
|
||||
"productImagesTitle": "تصاویر کالا",
|
||||
"productImagesHint": "میتوانید چند تصویر اضافه کنید و یکی را بهعنوان تصویر اصلی انتخاب کنید",
|
||||
"primaryImage": "تصویر اصلی",
|
||||
"removeImage": "حذف تصویر",
|
||||
"setPrimaryImage": "انتخاب بهعنوان اصلی",
|
||||
"variantsTitle": "قیمت و مشخصات کالا",
|
||||
"colorsPlaceholder": "رنگها را با کاما جدا کنید (مثلاً قرمز, آبی)",
|
||||
@@ -2247,8 +2345,17 @@
|
||||
"weightsPlaceholder": "وزنها را با کاما جدا کنید (اختیاری)",
|
||||
"generateVariants": "ساخت جدول قیمت",
|
||||
"defaultVariant": "پیشفرض",
|
||||
"pricePlaceholder": "قیمت (تومان)",
|
||||
"pricePlaceholder": "قیمت اصلی",
|
||||
"discountPricePlaceholder": "قیمت با تخفیف",
|
||||
"stockPlaceholder": "موجودی",
|
||||
"discountPercentLabel": "{{percent}}% تخفیف",
|
||||
"discountPercentBadge": "{{percent}}٪",
|
||||
"colorColumn": "رنگ",
|
||||
"sizeColumn": "سایز",
|
||||
"weightColumn": "وزن",
|
||||
"priceColumn": "قیمت (تومان)",
|
||||
"totalVariantsLabel": "تعداد کل تنوع کالا: {{count}}",
|
||||
"removeVariant": "حذف",
|
||||
"variantsRequired": "حداقل یک ترکیب با قیمت معتبر وارد کنید",
|
||||
"saveProduct": "ثبت کالا",
|
||||
"productSaved": "کالا با موفقیت ثبت شد",
|
||||
@@ -2308,6 +2415,30 @@
|
||||
"returnReasonPlaceholder": "دلیل درخواست مرجوعی را وارد کنید",
|
||||
"submitReturn": "ثبت درخواست مرجوعی",
|
||||
"returnRequestSubmitted": "درخواست مرجوعی با موفقیت ثبت شد",
|
||||
"yourRatingLabel": "امتیاز شما",
|
||||
"buyerMobileLabel": "شماره موبایل:",
|
||||
"shopInfoTitle": "اطلاعات فروشگاه",
|
||||
"orderNumberLabel": "شماره سفارش",
|
||||
"searchOrderPlaceholder": "جستجوی شماره سفارش",
|
||||
"provinceRequired": "انتخاب استان الزامی است",
|
||||
"cityRequired": "انتخاب شهر الزامی است",
|
||||
"viewShopProfileButton": "مشاهده پروفایل فروشگاه",
|
||||
"shippingTimeLabel": "زمان ارسال",
|
||||
"shippingTypeLabel": "نوع ارسال",
|
||||
"shippingCostLabel": "هزینه ارسال",
|
||||
"grandTotalLabel": "جمع کل",
|
||||
"reportStatusTitle": "مشکل ثبتشده",
|
||||
"returnStatusTitle": "درخواست مرجوعی",
|
||||
"reportStatus": {
|
||||
"open": "در حال بررسی",
|
||||
"resolved": "بررسی شده"
|
||||
},
|
||||
"returnStatus": {
|
||||
"pending": "در انتظار بررسی",
|
||||
"approved": "تایید شده",
|
||||
"rejected": "رد شده",
|
||||
"refunded": "مسترد شده"
|
||||
},
|
||||
"withdrawalRequested": "درخواست تسویه با موفقیت ثبت شد",
|
||||
"totalBalance": "موجودی کل کیف پول",
|
||||
"walletShopTab": "فروشگاه",
|
||||
|
||||
@@ -93,6 +93,12 @@ export interface IShopCategory {
|
||||
export interface ISubShopCategory {
|
||||
name: string;
|
||||
_id: string;
|
||||
sub_sub_categories?: ISubSubShopCategory[];
|
||||
}
|
||||
|
||||
export interface ISubSubShopCategory {
|
||||
name: string;
|
||||
_id: string;
|
||||
}
|
||||
|
||||
export interface IShopProductVariant {
|
||||
@@ -112,6 +118,9 @@ export interface IShopProductListing {
|
||||
catalogProduct: string | { _id: string; name: string; description?: string | null };
|
||||
title: string;
|
||||
description?: string | null;
|
||||
category?: string | null;
|
||||
sub_category?: string | null;
|
||||
sub_sub_category?: string | null;
|
||||
images: string[];
|
||||
primaryImageIndex: number;
|
||||
variants: IShopProductVariant[];
|
||||
|
||||
Reference in New Issue
Block a user