- Banner editor in store edit: color swatches + custom color picker, or image/GIF upload with a 1600x500 dimension hint. Renders the chosen banner (image cover / solid color) on the public seller page, falling back to the default gradient. - Sellers list: drop the desktop name overlay (match mobile, image-only tiles). - Seller page mobile header: chat moved top-left under the banner, search+filter beside the follow button, followers/product counts removed (rating kept), compacted so products fill more of the screen, circular logo, thinner action row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
614 lines
22 KiB
TypeScript
614 lines
22 KiB
TypeScript
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
|
||
import { useLoaderData } from "@remix-run/react";
|
||
import { safeGoBack } from "~/utils/helpers";
|
||
import { MetaFunction, LoaderFunctionArgs, json } from "@remix-run/node";
|
||
import { getInternalApiUrl } from "~/utils/api-config";
|
||
import {
|
||
useSellerFollowStatus,
|
||
useSellerFollow,
|
||
useSellerUnfollow,
|
||
useGetSellerProducts,
|
||
} from "../requestHandler/use-seller-hooks";
|
||
import { useCreateChatThread } from "../requestHandler/use-chat-hooks";
|
||
import {
|
||
Star,
|
||
ChevronDown,
|
||
X,
|
||
UserPlus,
|
||
Check,
|
||
UserMinus,
|
||
Shirt,
|
||
BadgeCheck,
|
||
ArrowRight,
|
||
MessageCircle,
|
||
Search,
|
||
} from "lucide-react";
|
||
import { useState, useEffect } from "react";
|
||
import { DrawerContent, Drawer } from "../components/ui/drawer";
|
||
import { Button } from "../components/ui/button";
|
||
import { Dialog, DialogContent, DialogOverlay } from "../components/ui/dialog";
|
||
import { toast } from "../hooks/use-toast";
|
||
import { Seller as SellerType } from "../../src/api/types";
|
||
import MondrianProductList from "../components/MondrianProductList";
|
||
import UiProvider from "../components/UiProvider";
|
||
import { useRootData } from "~/hooks/use-root-data";
|
||
import { LoginRequiredDialog } from "~/components/LoginRequiredDialog";
|
||
import SellerLogo from "../components/SellerLogo";
|
||
import AppInput from "~/components/AppInput";
|
||
import FilterDrawer from "~/components/FilterDrawer";
|
||
|
||
interface LoaderData {
|
||
seller: SellerType;
|
||
}
|
||
|
||
// Map order param to API ordering format
|
||
const mapOrderToOrdering = (order: string): string | undefined => {
|
||
switch (order) {
|
||
case "cheap":
|
||
return "base_price";
|
||
case "expensive":
|
||
return "-base_price";
|
||
case "newest":
|
||
return "-created_at";
|
||
default:
|
||
return undefined;
|
||
}
|
||
};
|
||
|
||
// Server-side loader to fetch seller data
|
||
export async function loader({ params }: LoaderFunctionArgs) {
|
||
const { sellerId } = params;
|
||
|
||
if (!sellerId) {
|
||
throw new Response("Seller ID is required", { status: 400 });
|
||
}
|
||
try {
|
||
const response = await fetch(`${getInternalApiUrl()}/seller/${encodeURIComponent(sellerId)}/`);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to fetch seller: ${response.status}`);
|
||
}
|
||
const seller = await response.json();
|
||
return json<LoaderData>({ seller });
|
||
} catch (error) {
|
||
console.error("Error fetching seller:", error);
|
||
throw new Response("Error loading seller", { status: 500 });
|
||
}
|
||
}
|
||
//
|
||
|
||
export default function Seller() {
|
||
const { sellerId } = useParams();
|
||
const { seller } = useLoaderData<typeof loader>();
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const { data: isFollowed, isLoading: isFollowStatusLoading } =
|
||
useSellerFollowStatus(seller?.id || "");
|
||
const { mutate: followSeller, isPending: isFollowPending } =
|
||
useSellerFollow();
|
||
const { mutate: unfollowSeller, isPending: isUnfollowPending } =
|
||
useSellerUnfollow();
|
||
const navigate = useNavigate();
|
||
const [showUnfollowDialog, setShowUnfollowDialog] = useState(false);
|
||
const { mutate: createChatThread, isPending: isCreatingChatThread } =
|
||
useCreateChatThread();
|
||
const { user } = useRootData();
|
||
const [isLoginDialogOpen, setIsLoginDialogOpen] = useState(false);
|
||
const [searchValue, setSearchValue] = useState(searchParams.get("q") || "");
|
||
|
||
// Get filter parameters from URL
|
||
const orderParam = searchParams.get("order");
|
||
const priceMinParam = searchParams.get("priceMin");
|
||
const priceMaxParam = searchParams.get("priceMax");
|
||
|
||
// Build filter options
|
||
const filterOptions = {
|
||
q: searchParams.get("q") || undefined,
|
||
priceMin: priceMinParam ? parseInt(priceMinParam, 10) : undefined,
|
||
priceMax: priceMaxParam ? parseInt(priceMaxParam, 10) : undefined,
|
||
ordering: orderParam ? mapOrderToOrdering(orderParam) : undefined,
|
||
};
|
||
|
||
// Fetch seller products with infinite scroll and filters
|
||
const {
|
||
data: productsData,
|
||
isLoading: isProductsLoading,
|
||
fetchNextPage,
|
||
hasNextPage,
|
||
isFetchingNextPage,
|
||
isError,
|
||
refetch,
|
||
} = useGetSellerProducts(sellerId || "", filterOptions);
|
||
const products = productsData?.pages.flatMap((page) => page.results) || [];
|
||
|
||
// Update search value when URL changes
|
||
useEffect(() => {
|
||
setSearchValue(searchParams.get("q") || "");
|
||
}, [searchParams]);
|
||
|
||
// Apply the in-header product search to the URL (drives useGetSellerProducts)
|
||
const handleProductSearch = () => {
|
||
const params = new URLSearchParams(searchParams);
|
||
if (searchValue.trim()) {
|
||
params.set("q", searchValue);
|
||
} else {
|
||
params.delete("q");
|
||
}
|
||
setSearchParams(params, { replace: true });
|
||
};
|
||
|
||
const handleFollowToggle = () => {
|
||
if (!user) {
|
||
setIsLoginDialogOpen(true);
|
||
return;
|
||
}
|
||
if (!sellerId) return;
|
||
|
||
if (isFollowed) {
|
||
// If already following, show confirmation dialog
|
||
setShowUnfollowDialog(true);
|
||
} else {
|
||
// If not following, follow
|
||
followSeller(
|
||
{ sellerId: seller?.id || "" },
|
||
{
|
||
onSuccess: () => {
|
||
// Close the dialog only after successful unfollow
|
||
toast({
|
||
title: "دنبال شد",
|
||
description: "شما با موفقیت فروشگاه را دنبال کردید",
|
||
});
|
||
setShowUnfollowDialog(false);
|
||
},
|
||
},
|
||
);
|
||
}
|
||
};
|
||
|
||
const confirmUnfollow = () => {
|
||
// Don't close the dialog immediately, wait for the request to succeed
|
||
unfollowSeller(
|
||
{ sellerId: seller?.id || "" },
|
||
{
|
||
onSuccess: () => {
|
||
// Close the dialog only after successful unfollow
|
||
toast({
|
||
title: "آنفالو شد",
|
||
description: "شما با موفقیت دنبال کردن فروشگاه را لغو کردید",
|
||
});
|
||
setShowUnfollowDialog(false);
|
||
},
|
||
},
|
||
);
|
||
};
|
||
|
||
const handleCreateChatThread = () => {
|
||
if (!user) {
|
||
setIsLoginDialogOpen(true);
|
||
return;
|
||
}
|
||
if (!seller?.id) return;
|
||
createChatThread(seller.id, {
|
||
onSuccess: (threadData) => {
|
||
navigate(`/profile/threads/${threadData.id}`);
|
||
},
|
||
});
|
||
};
|
||
|
||
const isFollowMutating = isFollowPending || isUnfollowPending;
|
||
const handleBack = () => {
|
||
safeGoBack(navigate);
|
||
};
|
||
|
||
// Customizable banner: image, solid color, or the default gradient fallback.
|
||
const bannerIsImage = seller?.bannerType === "image" && !!seller?.bannerImage;
|
||
const bannerIsColor = seller?.bannerType === "color" && !!seller?.bannerColor;
|
||
const bannerBgClass =
|
||
bannerIsImage || bannerIsColor
|
||
? ""
|
||
: "bg-gradient-to-br from-VITROWN_BLUE to-[#14213d]";
|
||
const bannerStyle = bannerIsColor
|
||
? { backgroundColor: seller?.bannerColor as string }
|
||
: undefined;
|
||
|
||
return (
|
||
<UiProvider
|
||
isLoading={false}
|
||
isError={isError}
|
||
isEmpty={false}
|
||
onRetry={refetch}
|
||
type="seller"
|
||
>
|
||
<div className="flex flex-col pb-20 lg:max-w-[1100px] lg:mx-auto lg:w-full lg:px-8 lg:pt-8">
|
||
{/* Desktop brand header (cover + overlapping logo + stats + actions) */}
|
||
<div className="hidden lg:block">
|
||
<div
|
||
className={`relative h-[200px] rounded-[22px] overflow-hidden ${bannerBgClass}`}
|
||
style={bannerStyle}
|
||
>
|
||
{bannerIsImage ? (
|
||
<img
|
||
src={seller?.bannerImage as string}
|
||
alt=""
|
||
className="absolute inset-0 w-full h-full object-cover"
|
||
/>
|
||
) : null}
|
||
<div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" />
|
||
</div>
|
||
<div className="flex items-start gap-6 px-2 mt-4 relative z-10">
|
||
<div className="w-[130px] h-[130px] -mt-[86px] rounded-[28px] border-[5px] border-WHITE shadow-md overflow-hidden bg-WHITE3 shrink-0">
|
||
{seller?.storeLogo ? (
|
||
<img
|
||
src={seller.storeLogo}
|
||
alt={seller?.storeName || "store"}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
) : null}
|
||
</div>
|
||
<div className="flex-1">
|
||
<h1 className="flex items-center gap-2 text-[28px] font-extrabold">
|
||
{seller?.storeName}
|
||
{seller?.isVerified ? (
|
||
<BadgeCheck className="text-BLUE" size={22} />
|
||
) : null}
|
||
</h1>
|
||
{seller?.username ? (
|
||
<p className="text-GRAY text-sm mt-0.5 mb-3" dir="ltr">
|
||
@{seller.username}
|
||
</p>
|
||
) : (
|
||
<div className="mb-3" />
|
||
)}
|
||
<div className="flex gap-8">
|
||
<div>
|
||
<b className="block text-[20px] font-extrabold">
|
||
{seller?.productCount ?? 0}
|
||
</b>
|
||
<span className="text-GRAY text-[13px]">محصول</span>
|
||
</div>
|
||
<div>
|
||
<b className="block text-[20px] font-extrabold">
|
||
{seller?.followerCount ?? 0}
|
||
</b>
|
||
<span className="text-GRAY text-[13px]">دنبالکننده</span>
|
||
</div>
|
||
<div>
|
||
<b className="flex items-center gap-1 text-[20px] font-extrabold">
|
||
{seller?.productReviewsCount ? seller?.reviewAverage : "—"}
|
||
{seller?.productReviewsCount ? (
|
||
<Star size={16} className="fill-BLACK" />
|
||
) : null}
|
||
</b>
|
||
<span className="text-GRAY text-[13px]">امتیاز فروشگاه</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2.5">
|
||
<Button
|
||
variant="dark"
|
||
size="lg"
|
||
onClick={handleFollowToggle}
|
||
disabled={isFollowMutating || isFollowStatusLoading}
|
||
>
|
||
{isFollowed ? (
|
||
<>
|
||
<Check size={20} />
|
||
دنبال شده
|
||
</>
|
||
) : (
|
||
<>
|
||
<UserPlus size={20} />
|
||
دنبال کردن
|
||
</>
|
||
)}
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="lg"
|
||
onClick={handleCreateChatThread}
|
||
disabled={!seller?.id || isCreatingChatThread}
|
||
>
|
||
{isCreatingChatThread ? "در حال اتصال..." : "ارسال پیام"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{seller?.storeDescription ? (
|
||
<p className="text-BLACK2 text-[14.5px] leading-8 max-w-[760px] mt-5 px-2">
|
||
{seller.storeDescription}
|
||
</p>
|
||
) : null}
|
||
<div className="border-b border-WHITE3 mt-6" />
|
||
</div>
|
||
|
||
{/* Mobile brand header (compact: ~chrome small so products fill the screen) */}
|
||
<div className="lg:hidden">
|
||
{/* Cover */}
|
||
<div
|
||
className={`relative h-[120px] ${bannerBgClass}`}
|
||
style={bannerStyle}
|
||
>
|
||
{bannerIsImage ? (
|
||
<img
|
||
src={seller?.bannerImage as string}
|
||
alt=""
|
||
className="absolute inset-0 w-full h-full object-cover"
|
||
/>
|
||
) : null}
|
||
<div className="absolute inset-0 bg-gradient-to-t from-black/35 to-transparent" />
|
||
<button
|
||
type="button"
|
||
onClick={handleBack}
|
||
aria-label="بازگشت"
|
||
className="absolute z-20 right-4 w-10 h-10 rounded-full bg-WHITE/90 backdrop-blur shadow-md flex items-center justify-center text-BLACK"
|
||
style={{ top: "calc(env(safe-area-inset-top, 0px) + 12px)" }}
|
||
>
|
||
<ArrowRight size={22} />
|
||
</button>
|
||
</div>
|
||
{/* Head */}
|
||
<div className="px-4 -mt-10 relative z-10">
|
||
{/* Logo (start/right) + Chat (end/left, just under the banner) */}
|
||
<div className="flex items-end justify-between">
|
||
<div className="w-[84px] h-[84px] rounded-full border-4 border-WHITE shadow-md overflow-hidden bg-WHITE3">
|
||
{seller?.storeLogo ? (
|
||
<img
|
||
src={seller.storeLogo}
|
||
alt={seller?.storeName || "store"}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
) : null}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={handleCreateChatThread}
|
||
disabled={!seller?.id || isCreatingChatThread}
|
||
aria-label="ارسال پیام"
|
||
className="w-10 h-10 rounded-full bg-WHITE shadow-md border border-WHITE3 flex items-center justify-center text-BLACK active:scale-95 transition-transform disabled:opacity-60"
|
||
>
|
||
<MessageCircle size={20} />
|
||
</button>
|
||
</div>
|
||
<h1 className="flex items-center gap-1.5 text-[21px] font-extrabold mt-2">
|
||
{seller?.storeName}
|
||
{seller?.isVerified ? (
|
||
<BadgeCheck className="text-BLUE" size={18} />
|
||
) : null}
|
||
</h1>
|
||
{/* Handle + rating inline (followers/product count removed) */}
|
||
<div className="flex items-center gap-2 mt-0.5">
|
||
{seller?.username ? (
|
||
<p className="text-GRAY text-[13px]" dir="ltr">
|
||
@{seller.username}
|
||
</p>
|
||
) : null}
|
||
{seller?.productReviewsCount ? (
|
||
<span className="flex items-center gap-1 text-[13px] font-bold">
|
||
<Star size={13} className="fill-BLACK" />
|
||
{seller?.reviewAverage}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
{/* Description */}
|
||
{seller?.storeDescription ? (
|
||
<div className="mt-2">
|
||
<ReadMoreText
|
||
sellerData={seller}
|
||
text={seller?.storeDescription || ""}
|
||
maxLength={110}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
{/* Actions: follow + search + filter together */}
|
||
<div className="flex items-center gap-2 mt-3">
|
||
<Button
|
||
variant="dark"
|
||
size="lg"
|
||
className="shrink-0 !h-10"
|
||
onClick={handleFollowToggle}
|
||
disabled={isFollowMutating || isFollowStatusLoading}
|
||
>
|
||
{isFollowed ? (
|
||
<>
|
||
<Check size={20} />
|
||
دنبال شده
|
||
</>
|
||
) : (
|
||
<>
|
||
<UserPlus size={20} />
|
||
دنبال کردن
|
||
</>
|
||
)}
|
||
</Button>
|
||
<div className="flex-1 flex items-center gap-2">
|
||
<AppInput
|
||
inputIcon={Search}
|
||
inputDir="rtl"
|
||
inputTitle={""}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") handleProductSearch();
|
||
}}
|
||
className="bg-WHITE2 focus:border-blue-500 !h-10"
|
||
inputValue={searchValue}
|
||
inputPlaceholder="جستجو در محصولات..."
|
||
inputOnChange={(e) => setSearchValue(e.target.value)}
|
||
/>
|
||
<FilterDrawer minimumPrice={0} maximumPrice={10000000} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="w-full mt-3 lg:mt-0">
|
||
<div className="hidden lg:block lg:mt-2" />
|
||
|
||
{/* seller products */}
|
||
<MondrianProductList
|
||
products={products.map((product) => ({
|
||
id: product?.id || "",
|
||
title: product?.title || "",
|
||
images: product?.imageUrls || [],
|
||
basePrice: product?.basePrice
|
||
? parseInt(product?.basePrice)
|
||
: undefined,
|
||
discountPercent: product?.discountPercent
|
||
? product?.discountPercent
|
||
: undefined,
|
||
discountPrice: product?.discountPrice
|
||
? product?.discountPrice
|
||
: undefined,
|
||
}))}
|
||
fetchNextPage={fetchNextPage}
|
||
hasNextPage={!!hasNextPage}
|
||
isFetchingNextPage={isFetchingNextPage}
|
||
isLoading={isProductsLoading}
|
||
isError={isError}
|
||
refetch={refetch}
|
||
emptyState={{
|
||
image: <Shirt size={80} />,
|
||
title: "این فروشگاه هیچ محصولی ندارد",
|
||
description: "به زودی محصولات جدیدی از این فروشگاه خواهیم داشت",
|
||
}}
|
||
/>
|
||
</div>
|
||
<Dialog open={showUnfollowDialog} onOpenChange={setShowUnfollowDialog}>
|
||
<DialogOverlay className="bg-BLACK/20" />
|
||
<DialogContent className="p-4 bg-WHITE overflow-y-auto max-h-[80vh] rounded-[15px] w-[calc(100%-40px)]">
|
||
<div className="flex flex-col gap-10">
|
||
<div className="flex flex-col gap-3 items-center w-full">
|
||
<UserMinus size={24} />
|
||
<p className="text-B12 font-bold mt-1">لغو دنبال کردن</p>
|
||
<p className="text-R12">
|
||
میخواهید دنبال کردن این فروشگاه را لغو کنید؟
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-row gap-6 w-full">
|
||
<Button
|
||
className="w-[100%]"
|
||
size={"sm"}
|
||
variant="dark"
|
||
onClick={() => setShowUnfollowDialog(false)}
|
||
>
|
||
انصراف
|
||
</Button>
|
||
<Button
|
||
disabled={isUnfollowPending}
|
||
className="w-[100%]"
|
||
size={"sm"}
|
||
variant="primary"
|
||
onClick={() => confirmUnfollow()}
|
||
>
|
||
{isUnfollowPending ? (
|
||
<>
|
||
<div className="relative w-5 h-5 mr-2">
|
||
<div className="w-full h-full border-2 border-t-RED border-r-transparent border-b-transparent border-l-transparent rounded-full animate-spin"></div>
|
||
<div
|
||
className=" w-4 h-4 border-2 border-t-transparent border-r-RED border-b-transparent border-l-transparent rounded-full animate-spin"
|
||
style={{
|
||
animationDirection: "reverse",
|
||
animationDuration: "0.7s",
|
||
}}
|
||
></div>
|
||
</div>
|
||
درحال لغو...
|
||
</>
|
||
) : (
|
||
"لغو دنبال کردن"
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
<LoginRequiredDialog
|
||
loginTitle="برای دنبال کردن فروشگاه ابتدا باید وارد حساب کاربری خود شوید."
|
||
isOpen={isLoginDialogOpen}
|
||
onOpenChange={setIsLoginDialogOpen}
|
||
/>
|
||
</div>
|
||
</UiProvider>
|
||
);
|
||
}
|
||
|
||
function ReadMoreText({
|
||
text,
|
||
maxLength,
|
||
sellerData,
|
||
}: {
|
||
text: string;
|
||
maxLength: number;
|
||
sellerData: SellerType | undefined;
|
||
}) {
|
||
const [aboutDrawerOpen, setAboutDrawerOpen] = useState(false);
|
||
if (text.length <= maxLength) {
|
||
return <p className="text-R12">{text}</p>;
|
||
}
|
||
return (
|
||
<>
|
||
<div className="relative">
|
||
<p className="text-R12">
|
||
{text.substring(0, maxLength)}
|
||
<span>...</span>
|
||
<button
|
||
onClick={() => setAboutDrawerOpen(true)}
|
||
className="inline-flex items-center text-blue-500 mr-1 focus:outline-none"
|
||
>
|
||
<span>بیشتر</span>
|
||
<ChevronDown size={16} />
|
||
</button>
|
||
</p>
|
||
</div>
|
||
<Drawer open={aboutDrawerOpen} onOpenChange={setAboutDrawerOpen}>
|
||
<DrawerContent className="flex flex-col gap-4">
|
||
<div className="flex relative items-center justify-center w-full">
|
||
<X
|
||
className="cursor-pointer"
|
||
size={24}
|
||
onClick={() => setAboutDrawerOpen(false)}
|
||
/>
|
||
<div className="flex flex-col gap-2 py-4 items-center">
|
||
<SellerLogo src={sellerData?.storeLogo} alt="Seller Logo" />
|
||
<p className="text-B14 font-bold">{sellerData?.storeName}</p>
|
||
</div>
|
||
</div>
|
||
<p className="text-R12 px-4 pb-10">{text}</p>
|
||
</DrawerContent>
|
||
</Drawer>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export const meta: MetaFunction<typeof loader> = ({ params, data }) => {
|
||
const sellerId = params.sellerId;
|
||
const sellerName = data?.seller?.storeName;
|
||
const title = `فروشگاه ${sellerName} | ویترون`;
|
||
const description = `مشاهده محصولات و اطلاعات فروشگاه ${sellerName} در ویترون`;
|
||
const imageUrl =
|
||
data?.seller?.storeLogo ||
|
||
"https://vitrownstatics.s3.ir-thr-at1.arvanstorage.ir/SVG-06.svg?versionId=";
|
||
const siteUrl = `${import.meta.env.VITE_SITE_URL || "https://vitrown.com"}/seller/${sellerId}`;
|
||
|
||
return [
|
||
{ title },
|
||
{ name: "description", content: description },
|
||
{
|
||
name: "keywords",
|
||
content: `فروشگاه، ${sellerName}، محصولات، ویترون`,
|
||
},
|
||
{ name: "robots", content: "index, follow" },
|
||
|
||
// Open Graph
|
||
{ property: "og:type", content: "website" },
|
||
{ property: "og:title", content: title },
|
||
{ property: "og:description", content: description },
|
||
{ property: "og:image", content: imageUrl },
|
||
{ property: "og:site_name", content: "ویترون" },
|
||
{ property: "og:locale", content: "fa_IR" },
|
||
{ property: "og:url", content: siteUrl },
|
||
|
||
// Twitter Card
|
||
{ name: "twitter:card", content: "summary_large_image" },
|
||
{ name: "twitter:title", content: title },
|
||
{ name: "twitter:description", content: description },
|
||
{ name: "twitter:image", content: imageUrl },
|
||
];
|
||
};
|