Fix Projects nav double-click, shop verify badge, add send-to-users everywhere

- new-project/layout.tsx was hardcoding currentPage="/projects" while the
  real route is /new-project, so TabNavigation's isExactRoot guard thought
  it was already on the Projects tab and preventDefault'd the first click
  with no navigation — now passes the real path (isActive's highlight logic
  already had a dedicated case for /new-project, so nothing else changes)
- resolveShopVerificationTier: shop verify checkmarks always render green
  regardless of the underlying tier, applied at all 3 shop render sites
- Add a "send to users" icon/button next to share on the product detail
  page, the owner product-manage page, and the shop profile pages (public +
  owner), reusing/mirroring the existing SendProductModal pattern
- New SendShopModal + sharedShop chat-bubble kind (ChatMessageCard via
  SharedShopBubble) so a whole shop can be sent into a chat, not just one
  product — backed by the new POST /shops/:id/send endpoint

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
payacom
2026-09-16 14:06:57 +03:30
parent 60d1aac55b
commit e652c255c9
12 changed files with 401 additions and 26 deletions

View File

@@ -1,20 +1,25 @@
import Header from "@/components/main/Header";
import TabNavigation from "@/components/TabNavigation";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { generateSeoPageMetadata } from "@/lib/i18n/server";
export async function generateMetadata() {
return generateSeoPageMetadata("newProject", {
index: false,
});
}
import Header from "@/components/main/Header";
import TabNavigation from "@/components/TabNavigation";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { generateSeoPageMetadata } from "@/lib/i18n/server";
export async function generateMetadata() {
return generateSeoPageMetadata("newProject", {
index: false,
});
}
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<section className="pb-24">
<Header />
<LocalePageShell>{children}</LocalePageShell>
{/* واقعاً توی /new-project هستیم، نه /projects — قبلاً اینجا "/projects" هاردکد
شده بود که باعث می‌شد تبِ پروژه‌ها فکر کنه از قبل توی روتِ خودشه (isExactRoot)
و اولین کلیک روی اون تب preventDefault بشه بدون navigate (کاربر مجبور می‌شد
دوبار بزنه). isActive توی TabNavigation.tsx از قبل شرطِ جداگانه برای
"/new-project" داره، پس هایلایتِ تب دست‌نخورده می‌مونه */}
<TabNavigation currentPage="/new-project" />
</section>
);
}

View File

@@ -6,9 +6,12 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
import Header from "@/components/main/Header";
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
import ShareShopModal from "@/components/shops/ShareShopModal";
import SendShopModal from "@/components/shops/SendShopModal";
import ShopProductListRow from "@/components/shops/ShopProductListRow";
import PageTitle from "@/components/settings/PageTitle";
import VerificationBadge from "@/components/main/VerificationBadge";
import VerificationBadge, {
resolveShopVerificationTier,
} from "@/components/main/VerificationBadge";
import BoldIcon from "@/components/ui/BoldIcon";
import TabNavigation from "@/components/TabNavigation";
import useAxios from "@/hooks/useAxios";
@@ -40,6 +43,7 @@ export default function ShopProductsPage() {
const [search, setSearch] = useState("");
const [onlyInStock, setOnlyInStock] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [showSendModal, setShowSendModal] = useState(false);
useEffect(() => {
request<{ shop: ShopHeader }>("GET", `/shops/${shopId}`)
@@ -114,9 +118,20 @@ export default function ShopProductsPage() {
)}
>
{shop?.name}
<VerificationBadge verifyBadge={shop?.owner?.verify_badge} size={13} />
<VerificationBadge
verifyBadge={resolveShopVerificationTier(shop?.owner?.verify_badge)}
size={13}
/>
</Link>
<button
type="button"
onClick={() => setShowSendModal(true)}
aria-label={t("shops.sendShopTitle")}
className="flex h-8 w-8 shrink-0 items-center justify-center"
>
<BoldIcon name="send-2" size={18} tinted />
</button>
<button
type="button"
onClick={() => setShowShareModal(true)}
@@ -212,6 +227,15 @@ export default function ShopProductsPage() {
shopLogo={shop.logo}
/>
)}
{shop && (
<SendShopModal
open={showSendModal}
onClose={() => setShowSendModal(false)}
shopId={shop._id}
shopName={shop.name}
logo={shop.logo}
/>
)}
</>
);
}

View File

@@ -6,6 +6,7 @@ import Header from "@/components/main/Header";
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
import BoldIcon from "@/components/ui/BoldIcon";
import BackButton from "@/components/ui/BackButton";
import SendProductModal from "@/components/shops/SendProductModal";
import DiscountBadge from "@/components/ui/DiscountBadge";
import TabNavigation from "@/components/TabNavigation";
import useAxios from "@/hooks/useAxios";
@@ -53,6 +54,7 @@ export default function ProductManagePage() {
const queryClient = useQueryClient();
const [listing, setListing] = useState<IShopProductListing | null>(null);
const [showSendModal, setShowSendModal] = useState(false);
const [selectedColor, setSelectedColor] = useState<string | null>(null);
const [selectedSize, setSelectedSize] = useState<string | null>(null);
const [selectedWeight, setSelectedWeight] = useState<string | null>(null);
@@ -259,6 +261,14 @@ export default function ProductManagePage() {
</span>
<span className="text-sm font-bold">{shopInfo?.name}</span>
</div>
<button
type="button"
onClick={() => setShowSendModal(true)}
aria-label={t("shops.sendProductTitle")}
className="flex h-9 w-9 shrink-0 items-center justify-center"
>
<BoldIcon name="send-2" size={18} tinted />
</button>
<button
type="button"
onClick={() => void shareProduct()}
@@ -465,6 +475,14 @@ export default function ProductManagePage() {
</Container>
</LocalePageShell>
<TabNavigation currentPage="/settings" />
{listing ? (
<SendProductModal
open={showSendModal}
onClose={() => setShowSendModal(false)}
listing={listing}
/>
) : null}
</>
);
}

View File

@@ -5,11 +5,13 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
import Header from "@/components/main/Header";
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
import ProductImageLightbox from "@/components/shops/ProductImageLightbox";
import SendProductModal from "@/components/shops/SendProductModal";
import BoldIcon from "@/components/ui/BoldIcon";
import BackButton from "@/components/ui/BackButton";
import DiscountBadge from "@/components/ui/DiscountBadge";
import TabNavigation from "@/components/TabNavigation";
import useAxios from "@/hooks/useAxios";
import { useRequireAuth } from "@/lib/auth/useRequireAuth";
import { copyTextToClipboard } from "@/lib/chat/getCopyableMessageText";
import { cn } from "@/lib/utils";
import { btnDefault, btnPrimary } from "@/lib/ui/buttonStyles";
@@ -52,8 +54,10 @@ export default function ListingDetailClient() {
const router = useRouter();
const params = useParams<{ listingId: string }>();
const { request, loading } = useAxios();
const requireAuth = useRequireAuth();
const [listing, setListing] = useState<IShopProductListing | null>(null);
const [showSendModal, setShowSendModal] = useState(false);
const [selectedColor, setSelectedColor] = useState<string | null>(null);
const [selectedSize, setSelectedSize] = useState<string | null>(null);
const [selectedWeight, setSelectedWeight] = useState<string | null>(null);
@@ -308,6 +312,17 @@ export default function ListingDetailClient() {
</span>
<span className="text-sm font-bold">{shopInfo?.name}</span>
</Link>
<button
type="button"
onClick={() => {
if (!requireAuth()) return;
setShowSendModal(true);
}}
aria-label={t("shops.sendProductTitle")}
className="flex h-9 w-9 shrink-0 items-center justify-center"
>
<BoldIcon name="send-2" size={18} tinted />
</button>
<button
type="button"
onClick={() => void shareProduct()}
@@ -573,6 +588,12 @@ export default function ListingDetailClient() {
open={showLightbox}
onClose={() => setShowLightbox(false)}
/>
<SendProductModal
open={showSendModal}
onClose={() => setShowSendModal(false)}
listing={listing}
/>
</>
);
}

View File

@@ -9,9 +9,12 @@ 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 SendShopModal from "@/components/shops/SendShopModal";
import ShopContactInfoModal from "@/components/shops/ShopContactInfoModal";
import ShopMyOrdersModal from "@/components/shops/ShopMyOrdersModal";
import VerificationBadge from "@/components/main/VerificationBadge";
import VerificationBadge, {
resolveShopVerificationTier,
} from "@/components/main/VerificationBadge";
import useAxios from "@/hooks/useAxios";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { useUser } from "@/hooks/useUser";
@@ -96,6 +99,7 @@ export default function ShopProfileClient() {
const [showOrdersModal, setShowOrdersModal] = useState(false);
const [showLocationModal, setShowLocationModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [showSendModal, setShowSendModal] = useState(false);
useEffect(() => {
request<{ shop: PublicShop }>("GET", `/shops/public/${shopId}`)
@@ -244,7 +248,10 @@ export default function ShopProfileClient() {
)}
>
{shop.name}
<VerificationBadge verifyBadge={shop.owner?.verify_badge} size={16} />
<VerificationBadge
verifyBadge={resolveShopVerificationTier(shop.owner?.verify_badge)}
size={16}
/>
</h3>
{hasDescription ? (
<div
@@ -297,6 +304,16 @@ export default function ShopProfileClient() {
{t("shops.showLocation")}
</button>
)}
<button
type="button"
onClick={() => {
if (!requireAuth()) return;
setShowSendModal(true);
}}
className={profileActionBtnClass}
>
{t("shops.sendShopTitle")}
</button>
<button
type="button"
onClick={() => setShowShareModal(true)}
@@ -371,6 +388,13 @@ export default function ShopProfileClient() {
shopLogo={shop.logo}
responseSchedule={shop.response_schedule}
/>
<SendShopModal
open={showSendModal}
onClose={() => setShowSendModal(false)}
shopId={shop._id}
shopName={shop.name}
logo={shop.logo}
/>
<ShopContactInfoModal
open={showContactModal}
onClose={() => setShowContactModal(false)}

View File

@@ -39,6 +39,7 @@ import {
} from "@/lib/chat/messageGrouping";
import SharedPostBubble, { parseSharedPost } from "./SharedPostBubble";
import SharedProductBubble, { parseSharedProduct } from "./SharedProductBubble";
import SharedShopBubble, { parseSharedShop } from "./SharedShopBubble";
import SharedGiftBubble, { parseSharedGift } from "./SharedGiftBubble";
import SharedStoryBubble, {
parseSharedStory,
@@ -351,6 +352,7 @@ const ChatMessageCard = ({
message.forwardedFrom || parseForwarded(message.content || "");
const sharedPost = parseSharedPost(message.content || "");
const sharedProduct = parseSharedProduct(message.content || "");
const sharedShop = parseSharedShop(message.content || "");
const sharedGift = parseSharedGift(message.content || "");
const sharedStory = parseSharedStory(message.content || "");
const storyReactionText = sharedStory
@@ -754,6 +756,16 @@ const ChatMessageCard = ({
</div>
)}
{sharedShop && (
<div
className={cn("p-1", isSender ? "chat-bubble-out" : "chat-bubble-in")}
style={bubbleStyle}
>
<SharedShopBubble data={sharedShop} />
<TimeBelow />
</div>
)}
{sharedGift && (
<div
className={cn("p-1", isSender ? "chat-bubble-out" : "chat-bubble-in")}

View File

@@ -0,0 +1,45 @@
"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 SharedShopPayload {
shopId: string;
name?: string;
logo?: string;
url?: string;
}
export function parseSharedShop(content: string): SharedShopPayload | null {
if (!content) return null;
try {
const line = content.split("\n")[0];
const j = JSON.parse(line);
if (j?.e2ee) return null;
return j.sharedShop ?? null;
} catch {
return null;
}
}
export default function SharedShopBubble({ data }: { data: SharedShopPayload }) {
const { t } = useTranslation("common");
const href = data.url || `/shops/profile/${data.shopId}`;
const logo = data.logo ? IMAGE_BASE_URL + data.logo : null;
return (
<Link
href={href}
className="flex items-center gap-2 overflow-hidden rounded-2xl border border-white/20 bg-black/10 p-2 min-w-[200px] max-w-[240px]"
>
<span className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full bg-neutral-800">
{logo && <Image src={logo} alt="" fill sizes="40px" className="object-cover" />}
</span>
<p className="truncate text-[11px] font-semibold opacity-90">
{data.name || t("shops.viewShop")}
</p>
</Link>
);
}

View File

@@ -34,6 +34,14 @@ export function resolveVerificationStatus(
return verifyBadge as VerifyBadgeTier;
}
/** طبق خواسته‌ی صریح کاربر: تیکِ وریفایِ فروشگاه (برخلافِ کاربر که چهار سطح رنگی داره)
* همیشه سبز باشه، مستقل از سطح واقعیِ verify_badge — فقط برای رندرِ فروشگاه استفاده بشه */
export function resolveShopVerificationTier(
verifyBadge?: string | null
): "green" | null {
return resolveVerificationStatus(verifyBadge) ? "green" : null;
}
const VERIFICATION_ICON_SRC: Record<Exclude<VerifyBadgeTier, "none">, string> = {
gray: "/images/icons/verify-gray.svg",
blue: "/images/icons/verify-blue.svg",

View File

@@ -0,0 +1,205 @@
"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 { useTranslation } from "react-i18next";
import type { ChatUserItem } from "./SendProductModal";
interface SendShopModalProps {
open: boolean;
onClose: () => void;
shopId: string;
shopName: string;
logo?: string | null;
}
// عینِ SendProductModal.tsx، فقط برای خودِ فروشگاه (نه یه کالای خاص) — بک‌اند
// shopController.js: sendShopToUsers
export default function SendShopModal({
open,
onClose,
shopId,
shopName,
logo,
}: SendShopModalProps) {
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",
`/shops/${shopId}/send`,
{ receiverIds: Array.from(selected) },
{ noToast: true }
);
toast.success(
t("shops.sentShopToCount", { count: res?.sentCount ?? selected.size })
);
onClose();
setSelected(new Set());
} catch {
toast.error(t("shops.sendShopError"));
} finally {
setSending(false);
}
};
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.sendShopTitle")}
</h3>
<div className="mb-4 flex items-center gap-3 rounded-2xl bg-neutral-100 p-2 dark:bg-neutral-800">
{logo && (
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-full">
<Image
src={IMAGE_BASE_URL + logo}
alt=""
fill
sizes="48px"
className="object-cover"
/>
</div>
)}
<p className="line-clamp-2 text-xs text-neutral-600 dark:text-neutral-300">
{shopName}
</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-2xl 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
sizes="40px"
className="object-cover"
/>
) : 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

@@ -1,7 +1,9 @@
"use client";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import VerificationBadge from "@/components/main/VerificationBadge";
import VerificationBadge, {
resolveShopVerificationTier,
} from "@/components/main/VerificationBadge";
type ShopProductAuthorProps = {
shopName: string;
@@ -28,7 +30,10 @@ export default function ShopProductAuthor({
)}
<span className="flex min-w-0 items-center gap-1 truncate text-xs font-medium text-neutral-600 dark:text-neutral-300">
{shopName}
<VerificationBadge verifyBadge={shopVerifyBadge} size={12} />
<VerificationBadge
verifyBadge={resolveShopVerificationTier(shopVerifyBadge)}
size={12}
/>
</span>
</span>
);

View File

@@ -2851,6 +2851,7 @@
"editShopButton": "Edit shop",
"viewPublicProfile": "View shop page",
"viewProduct": "View product",
"viewShop": "View shop",
"myOrdersFromShop": "My orders from this shop",
"noOrdersFromShop": "You haven't ordered from this shop yet",
"satisfactionLabel": "Product satisfaction",
@@ -2869,6 +2870,9 @@
"sendProductTitle": "Send product",
"sentProductToCount": "Product sent to {{count}} people",
"sendProductError": "Failed to send product",
"sendShopTitle": "Send shop",
"sentShopToCount": "Shop sent to {{count}} people",
"sendShopError": "Failed to send shop",
"noChatMessagesYet": "No messages yet",
"performance": {
"totalRevenue": "Total revenue (Toman)",

View File

@@ -2856,6 +2856,7 @@
"editShopButton": "ویرایش فروشگاه",
"viewPublicProfile": "نمایش صفحه فروشگاه",
"viewProduct": "مشاهده کالا",
"viewShop": "مشاهده فروشگاه",
"myOrdersFromShop": "پیگیری سفارشات من",
"noOrdersFromShop": "هنوز سفارشی از این فروشگاه ثبت نکرده‌اید",
"satisfactionLabel": "رضایت از کالاها",
@@ -2874,6 +2875,9 @@
"sendProductTitle": "ارسال کالا",
"sentProductToCount": "کالا به {{count}} نفر ارسال شد",
"sendProductError": "خطا در ارسال کالا",
"sendShopTitle": "ارسال فروشگاه",
"sentShopToCount": "فروشگاه به {{count}} نفر ارسال شد",
"sendShopError": "خطا در ارسال فروشگاه",
"noChatMessagesYet": "هنوز پیامی رد و بدل نشده است",
"performance": {
"totalRevenue": "درآمد کل (تومان)",