This commit is contained in:
payacom
2026-08-02 21:20:08 +03:30
parent 5ecbcd1f26
commit dc4467d43d
55 changed files with 5429 additions and 920 deletions

View File

@@ -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)) ??

View 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>
</>
);
}

View File

@@ -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 {

View 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>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
)}
</>
);
}

View 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" />
</>
);
}

View 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;

View File

@@ -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

View File

@@ -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")}

View File

@@ -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")}

View File

@@ -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)}
/>
</>
);
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 =

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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}
/>
</>
);
}

View 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,
}}
/>
)}
</>
);
}

View 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} />;
}

View File

@@ -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
)}
>

View File

@@ -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>
)}

View File

@@ -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);

View 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>
);
}

View File

@@ -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);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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);

View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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]">

View File

@@ -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]">

View 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>
);
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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(() => {

View File

@@ -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();

View File

@@ -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");

View File

@@ -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);

View 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;
}

View File

@@ -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",

View File

@@ -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": "فروشگاه",

View File

@@ -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[];