feat(seller): customizable profile banner + seller page polish

- 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>
This commit is contained in:
Arda Samadi 2026-07-01 12:00:23 +03:30
parent bb10907463
commit 660e01c3c3
5 changed files with 355 additions and 89 deletions

View File

@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { useNavigate } from "@remix-run/react";
import { Loader2, Save, Store } from "lucide-react";
import { Loader2, Save, Store, Upload } from "lucide-react";
import ProfilePagesHeader from "../ProfilePagesHeader";
import HeaderWithSupport from "../HeaderWithSupport";
import AppInput from "../AppInput";
@ -80,8 +80,26 @@ interface FormData {
selectedCity: string;
instagramId: string;
storeLogo: string;
bannerType: string;
bannerColor: string;
bannerImage: string;
}
// Preset banner colors offered alongside the custom color picker.
const BANNER_PRESETS = [
"#1d2b4d",
"#0f172a",
"#111827",
"#374151",
"#7c3aed",
"#be123c",
"#0e7490",
"#15803d",
"#b45309",
"#db2777",
];
const DEFAULT_BANNER_COLOR = "#1d2b4d";
interface StoreFormProps {
mode: "create" | "edit";
storeId?: string;
@ -109,6 +127,13 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// Banner state (edit mode)
const bannerFileInputRef = useRef<HTMLInputElement>(null);
const [selectedBannerImage, setSelectedBannerImage] = useState<File | null>(
null
);
const [bannerPreviewUrl, setBannerPreviewUrl] = useState<string | null>(null);
// Loading modal states
const [createSellerOpen, setCreateSellerOpen] = useState(false);
const [imageUploadOpen, setImageUploadOpen] = useState(false);
@ -122,6 +147,9 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
selectedCity: "",
instagramId: "",
storeLogo: "",
bannerType: "color",
bannerColor: "",
bannerImage: "",
});
// Validation errors state
@ -157,6 +185,9 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
selectedCity: "", // Not available in SellerUpdate
instagramId: sellerData.instagramId || "",
storeLogo: sellerData.storeLogo || "",
bannerType: sellerData.bannerType || "color",
bannerColor: sellerData.bannerColor || "",
bannerImage: sellerData.bannerImage || "",
};
setFormData(initialData);
}
@ -170,11 +201,15 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
formData.storeDescription !== (sellerData.storeDescription || "") ||
formData.instagramId !== (sellerData.instagramId || "") ||
formData.storeLogo !== (sellerData.storeLogo || "") ||
selectedImage !== null; // Include image changes
formData.bannerType !== (sellerData.bannerType || "color") ||
formData.bannerColor !== (sellerData.bannerColor || "") ||
formData.bannerImage !== (sellerData.bannerImage || "") ||
selectedImage !== null || // Include image changes
selectedBannerImage !== null;
setHasChanges(hasFormChanges);
}
}, [mode, formData, sellerData, selectedImage]);
}, [mode, formData, sellerData, selectedImage, selectedBannerImage]);
// Redirect if user already has a store (create mode only)
useEffect(() => {
@ -196,6 +231,15 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
};
}, [imagePreviewUrl]);
// Clean up banner preview URL
useEffect(() => {
return () => {
if (bannerPreviewUrl) {
URL.revokeObjectURL(bannerPreviewUrl);
}
};
}, [bannerPreviewUrl]);
// Show loading while checking ownership or loading seller data (edit mode)
if (mode === "edit" && (isOwnershipLoading || isSellerLoading)) {
return (
@ -311,6 +355,24 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
}
};
// Handle banner image selection (from gallery; GIFs preserved)
const handleBannerSelect = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const picked = event.target.files?.[0];
if (picked) {
// convertHeicToJpeg only touches HEIC; GIF/PNG/JPG pass through untouched.
const file = await convertHeicToJpeg(picked);
if (bannerPreviewUrl) {
URL.revokeObjectURL(bannerPreviewUrl);
}
const newUrl = URL.createObjectURL(file);
setSelectedBannerImage(file);
setBannerPreviewUrl(newUrl);
setFormData((prev) => ({ ...prev, bannerType: "image" }));
}
};
// Handle drawer actions
const handleUploadClick = () => {
setIsDrawerOpen(true);
@ -380,11 +442,38 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
}
}
// Upload a newly selected banner image first (GIFs preserved by the API).
let bannerImageUrl = formData.bannerImage;
if (selectedBannerImage) {
try {
setImageUploadOpen(true);
const bannerResponse = await imageUploadMutation.mutateAsync({
file: selectedBannerImage,
usageType: "banner",
});
bannerImageUrl = bannerResponse.originalUrl || formData.bannerImage;
setImageUploadOpen(false);
} catch (error) {
console.error("Error uploading banner:", error);
setImageUploadOpen(false);
toast({
title: "خطا در آپلود بنر",
description:
"متاسفانه خطایی در آپلود بنر رخ داد. لطفا مجددا تلاش کنید.",
variant: "destructive",
});
return;
}
}
const updateData = {
storeName: formData.storeName,
storeDescription: formData.storeDescription || null,
instagramId: formData.instagramId || null,
storeLogo: logoUrl || null,
bannerType: formData.bannerType || "color",
bannerColor: formData.bannerColor || null,
bannerImage: bannerImageUrl || null,
};
updateSellerMutation.mutate({
@ -509,6 +598,116 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
);
};
// Render banner editor (edit mode only): color swatches/picker or image/GIF
const renderBannerSection = () => {
const previewImage = bannerPreviewUrl || formData.bannerImage;
const previewColor = formData.bannerColor || DEFAULT_BANNER_COLOR;
return (
<div className="flex flex-col gap-3">
<p className="text-R14">بنر فروشگاه</p>
{/* Live preview */}
<div className="relative h-28 w-full rounded-[14px] overflow-hidden border border-WHITE2 bg-WHITE3">
{formData.bannerType === "image" && previewImage ? (
<img
src={previewImage}
alt="بنر فروشگاه"
className="w-full h-full object-cover"
/>
) : (
<div
className="w-full h-full"
style={{ backgroundColor: previewColor }}
/>
)}
</div>
{/* Type toggle */}
<div className="flex gap-2">
<button
type="button"
onClick={() => handleInputChange("bannerType", "color")}
className={`flex-1 h-10 rounded-[10px] border text-R12 ${
formData.bannerType === "color"
? "bg-BLACK text-WHITE border-BLACK"
: "bg-WHITE2 text-BLACK border-WHITE3"
}`}
>
رنگ
</button>
<button
type="button"
onClick={() => handleInputChange("bannerType", "image")}
className={`flex-1 h-10 rounded-[10px] border text-R12 ${
formData.bannerType === "image"
? "bg-BLACK text-WHITE border-BLACK"
: "bg-WHITE2 text-BLACK border-WHITE3"
}`}
>
تصویر
</button>
</div>
{formData.bannerType === "color" ? (
<div className="flex flex-wrap items-center gap-2">
{BANNER_PRESETS.map((c) => (
<button
key={c}
type="button"
onClick={() => handleInputChange("bannerColor", c)}
aria-label={c}
className={`w-8 h-8 rounded-full border-2 transition-all ${
(formData.bannerColor || "").toLowerCase() === c.toLowerCase()
? "border-BLACK scale-110"
: "border-WHITE2"
}`}
style={{ backgroundColor: c }}
/>
))}
{/* Custom color */}
<label
className="w-8 h-8 rounded-full border-2 border-dashed border-GRAY3 overflow-hidden flex items-center justify-center cursor-pointer"
aria-label="رنگ دلخواه"
>
<input
type="color"
value={previewColor}
onChange={(e) =>
handleInputChange("bannerColor", e.target.value)
}
className="w-10 h-10 cursor-pointer border-0 bg-transparent p-0"
/>
</label>
</div>
) : (
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => bannerFileInputRef.current?.click()}
className="w-full h-11 rounded-[10px] border-2 border-dashed border-GRAY3 flex items-center justify-center gap-2 text-GRAY text-R12 hover:border-primary hover:text-primary transition-colors"
>
<Upload size={18} />
{previewImage ? "تغییر تصویر بنر" : "آپلود تصویر بنر"}
</button>
<p className="text-R10 text-BLACK2 leading-5">
اندازه پیشنهادی: ۱۶۰۰×۵۰۰ پیکسل. بنر تمامعرض نمایش داده میشود و
فقط ارتفاع آن برش میخورد؛ بخشهای مهم (متن/لوگو) را وسط قرار دهید.
فرمت JPG/PNG/WebP یا GIF متحرک.
</p>
</div>
)}
<input
ref={bannerFileInputRef}
type="file"
accept="image/*,image/gif"
onChange={handleBannerSelect}
className="hidden"
/>
</div>
);
};
// Render description field based on mode
const renderDescriptionField = () => {
if (mode === "create") {
@ -630,6 +829,9 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
<div className={contentClass}>
{renderLogoSection()}
{/* Banner editor (edit mode) */}
{mode === "edit" && renderBannerSection()}
{/* Username Input - Only for create mode */}
{mode === "create" && (
<FormField

View File

@ -266,6 +266,9 @@ export const useSellerUpdate = () => {
storeDescription?: string | null;
storeLogo?: string | null;
instagramId?: string | null;
bannerType?: string | null;
bannerColor?: string | null;
bannerImage?: string | null;
};
}) => {
if (!token) throw new Error("Authentication required");

View File

@ -21,6 +21,7 @@ import {
BadgeCheck,
ArrowRight,
MessageCircle,
Search,
} from "lucide-react";
import { useState, useEffect } from "react";
import { DrawerContent, Drawer } from "../components/ui/drawer";
@ -33,7 +34,8 @@ import UiProvider from "../components/UiProvider";
import { useRootData } from "~/hooks/use-root-data";
import { LoginRequiredDialog } from "~/components/LoginRequiredDialog";
import SellerLogo from "../components/SellerLogo";
import SearchTopBar from "~/components/SearchTopBar";
import AppInput from "~/components/AppInput";
import FilterDrawer from "~/components/FilterDrawer";
interface LoaderData {
seller: SellerType;
@ -78,7 +80,7 @@ export async function loader({ params }: LoaderFunctionArgs) {
export default function Seller() {
const { sellerId } = useParams();
const { seller } = useLoaderData<typeof loader>();
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const { data: isFollowed, isLoading: isFollowStatusLoading } =
useSellerFollowStatus(seller?.id || "");
const { mutate: followSeller, isPending: isFollowPending } =
@ -123,6 +125,17 @@ export default function Seller() {
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);
@ -185,6 +198,18 @@ export default function Seller() {
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}
@ -196,7 +221,17 @@ export default function 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 bg-gradient-to-br from-VITROWN_BLUE to-[#14213d]">
<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">
@ -284,10 +319,20 @@ export default function Seller() {
<div className="border-b border-WHITE3 mt-6" />
</div>
{/* Mobile brand header (cover + overlapping logo + stats + actions) */}
{/* Mobile brand header (compact: ~chrome small so products fill the screen) */}
<div className="lg:hidden">
{/* Cover */}
<div className="relative h-[150px] bg-gradient-to-br from-VITROWN_BLUE to-[#14213d]">
<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"
@ -301,108 +346,98 @@ export default function Seller() {
</div>
{/* Head */}
<div className="px-4 -mt-10 relative z-10">
<div className="w-[84px] h-[84px] rounded-[24px] 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}
{/* 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-3">
<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>
{seller?.username ? (
<p className="text-GRAY text-[13px] mt-0.5" dir="ltr">
@{seller.username}
</p>
) : null}
{/* Stats */}
<div className="flex gap-8 py-3.5 mt-3 border-y border-WHITE3">
<div>
<b className="block text-[17px] font-extrabold">
{seller?.productCount ?? 0}
</b>
<span className="text-GRAY text-[11.5px]">محصول</span>
</div>
<div>
<b className="block text-[17px] font-extrabold">
{seller?.followerCount ?? 0}
</b>
<span className="text-GRAY text-[11.5px]">دنبالکننده</span>
</div>
<div>
<b className="flex items-center gap-1 text-[17px] font-extrabold">
{seller?.productReviewsCount ? seller?.reviewAverage : "—"}
{seller?.productReviewsCount ? (
<Star size={14} className="fill-BLACK" />
) : null}
</b>
<span className="text-GRAY text-[11.5px]">امتیاز</span>
</div>
{/* 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-3">
<div className="mt-2">
<ReadMoreText
sellerData={seller}
text={seller?.storeDescription || ""}
maxLength={150}
maxLength={110}
/>
</div>
) : null}
{/* Actions */}
<div className="flex gap-2.5 mt-4">
{/* Actions: follow + search + filter together */}
<div className="flex items-center gap-2 mt-3">
<Button
variant="dark"
size="lg"
className="flex-1"
className="shrink-0 !h-10"
onClick={handleFollowToggle}
disabled={isFollowMutating || isFollowStatusLoading}
>
{isFollowed ? (
<>
<Check size={22} />
<Check size={20} />
دنبال شده
</>
) : (
<>
<UserPlus size={22} />
<UserPlus size={20} />
دنبال کردن
</>
)}
</Button>
<Button
variant="outline"
size="lg"
className="shrink-0 px-4"
aria-label="ارسال پیام"
onClick={() => {
handleCreateChatThread();
}}
disabled={!seller?.id || isCreatingChatThread}
>
<MessageCircle size={22} />
</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">
{/* Search Bar (mobile) */}
<div className="lg:hidden">
<SearchTopBar
searchValue={searchValue}
setSearchValue={setSearchValue}
placeholder="جستجو در محصولات فروشگاه..."
minimumPrice={0}
maximumPrice={10000000}
/>
</div>
<div className="w-full mt-3 lg:mt-0">
<div className="hidden lg:block lg:mt-2" />
{/* seller products */}

View File

@ -101,16 +101,6 @@ export default function Sellers() {
<Store size={48} className="text-gray-400" />
</div>
)}
{seller.sellerName ? (
<>
<div className="hidden lg:block absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
<div className="hidden lg:flex absolute inset-x-0 bottom-0 p-4 items-end">
<span className="text-white font-bold text-[15px] truncate">
{seller.sellerName}
</span>
</div>
</>
) : null}
</Link>
))}
</div>

View File

@ -4468,6 +4468,24 @@ export interface Seller {
* @memberof Seller
*/
city?: number | null;
/**
* Banner type: "color" or "image".
* @type {string}
* @memberof Seller
*/
bannerType?: string | null;
/**
* Hex banner color when bannerType is "color".
* @type {string}
* @memberof Seller
*/
bannerColor?: string | null;
/**
* Banner image/GIF URL when bannerType is "image".
* @type {string}
* @memberof Seller
*/
bannerImage?: string | null;
}
@ -5589,6 +5607,24 @@ export interface SellerUpdate {
* @memberof SellerUpdate
*/
instagramId?: string | null;
/**
* Banner type: "color" or "image".
* @type {string}
* @memberof SellerUpdate
*/
bannerType?: string | null;
/**
* Hex banner color when bannerType is "color".
* @type {string}
* @memberof SellerUpdate
*/
bannerColor?: string | null;
/**
* Banner image/GIF URL when bannerType is "image".
* @type {string}
* @memberof SellerUpdate
*/
bannerImage?: string | null;
}
/**
*