Compare commits

..

No commits in common. "main" and "security-fixes" have entirely different histories.

134 changed files with 1672 additions and 11769 deletions

View File

@ -21,6 +21,3 @@ VITE_BUILD_TARGET=development
# Support Configuration
VITE_SUPPORT_PHONE=+989223740993
# Map.ir key for the address map (MapLibre vector tiles + reverse geocoding)
VITE_MAPIR_KEY=your_mapir_api_key

View File

@ -39,10 +39,7 @@ const HeaderWithSupport = memo(function HeaderWithSupport({
return (
<>
{/* Mobile only — on desktop the dashboard sidebar / desktop header stands
in for this back+support bar. */}
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
<div className="relative flex flex-row h-[65px] bg-WHITE w-full items-center justify-center border-b border-inner-border">
<div className="flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={onBack}
className="absolute top-5 right-5 cursor-pointer"
@ -60,7 +57,6 @@ const HeaderWithSupport = memo(function HeaderWithSupport({
</div>
)}
</div>
</div>
<CallDialog
isCallModalOpen={isCallModalOpen}

View File

@ -116,22 +116,19 @@ const HorizontalCollectionsList = ({
{/* Scroll Container */}
<div
ref={scrollContainerRef}
className="w-full overflow-x-auto scrollbar-hide snap-x snap-mandatory px-4 lg:overflow-visible lg:px-0"
>
<div
className="flex lg:grid lg:grid-cols-4 lg:gap-4 lg:!w-full"
style={{ width: "max-content" }}
className="w-full overflow-x-auto scrollbar-hide snap-x snap-mandatory px-4"
>
<div className="flex" style={{ width: "max-content" }}>
{collections.map((collection, index) => (
<Link
key={collection.id || index}
to={`/collection/${collection.id}`}
className="flex flex-col pr-2 md:pr-4 snap-start group lg:!w-auto lg:!max-w-none lg:pr-0"
className="flex flex-col pr-2 md:pr-4 snap-start group"
style={{ width: "calc(85vw)", maxWidth: "300px" }}
>
<div className="flex flex-col gap-3 p-3 bg-WHITE2 rounded-2xl border border-inner-border hover:border-gray-300 transition-all duration-300 hover:shadow-lg h-full">
{/* Collection Image */}
<div className="relative overflow-hidden rounded-xl w-full h-64 lg:h-auto lg:aspect-[3/4] bg-gray-100">
<div className="relative overflow-hidden rounded-xl w-full h-64 bg-gray-100">
{collection.coverImageUrl ? (
<img
src={collection.coverImageUrl}
@ -177,7 +174,7 @@ const HorizontalCollectionsList = ({
{showViewAllButton && (
<Link
to="/collections"
className="flex flex-col pr-2 md:pr-4 snap-start group lg:!w-auto lg:!max-w-none lg:pr-0"
className="flex flex-col pr-2 md:pr-4 snap-start group"
style={{ width: "calc(85vw)", maxWidth: "300px" }}
>
<div className="flex flex-col items-center justify-center gap-3 p-3 bg-gradient-to-br from-black to-gray-800 rounded-2xl border border-inner-border hover:border-gray-600 transition-all duration-300 hover:shadow-lg h-full min-h-[304px]">
@ -206,9 +203,9 @@ const HorizontalCollectionsList = ({
)}
</div>
</div>
{/* Navigation Buttons Row (mobile only) */}
{/* Navigation Buttons Row */}
{collections.length > 1 && (
<div className="flex items-center justify-center gap-3 px-4 lg:hidden">
<div className="flex items-center justify-center gap-3 px-4">
<motion.button
whileHover={!showRightButton ? {} : { scale: 1.1 }}
whileTap={!showRightButton ? {} : { scale: 0.95 }}

View File

@ -6,7 +6,6 @@ import { Skeleton } from "./ui/skeleton";
import { ErrorState } from "./UiProvider";
import discountRed from "~/assets/icons/product/discount-red.svg";
import placeholderImage from "~/assets/icons/product.png";
import SellerChip from "./SellerChip";
interface HorizontalProduct {
id?: string;
@ -15,9 +14,6 @@ interface HorizontalProduct {
discountPercent?: number;
discountPrice?: number;
basePrice?: number;
sellerName?: string;
sellerLogo?: string | null;
sellerUsername?: string;
}
const HorizontalProductList = ({
@ -142,10 +138,6 @@ const HorizontalProductList = ({
)}
{/* Overlay info */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 via-black/40 to-transparent p-2 pt-6 pointer-events-none">
<SellerChip
name={product.sellerName}
logo={product.sellerLogo}
/>
<div className="flex w-full justify-between gap-1">
{/* Title */}
<h3 className="text-white text-xs font-semibold line-clamp-1 mb-0.5">

View File

@ -1,7 +1,15 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import "maplibre-gl/dist/maplibre-gl.css";
import type { Map as MaplibreMap, Marker as MaplibreMarker } from "maplibre-gl";
import { Search, X, ChevronLeft, MapPin, Crosshair } from "lucide-react";
import React, { useState, useEffect, useRef } from "react";
import {
MapContainer,
TileLayer,
Marker,
Popup,
useMapEvents,
useMap,
} from "react-leaflet";
import "leaflet/dist/leaflet.css";
import L from "leaflet";
import { Search, X, ChevronLeft } from "lucide-react";
import AppInput from "./AppInput";
import AppTextArea from "./AppTextArea";
import {
@ -24,25 +32,6 @@ import {
type IranianCity,
} from "../data/iranian-cities";
// Map.ir config (vector tiles + reverse geocoding). The key is a client-side
// map key (sent with every tile request), provided via env.
const MAPIR_KEY = import.meta.env.VITE_MAPIR_KEY || "";
const MAPIR_STYLE = "https://map.ir/vector/styles/main/mapir-style.json";
// Reverse geocode (lat/lng -> Persian address) via Map.ir.
async function reverseGeocode(lat: number, lon: number): Promise<string> {
try {
const res = await fetch(`https://map.ir/reverse?lat=${lat}&lon=${lon}`, {
headers: { "x-api-key": MAPIR_KEY },
});
const data = await res.json();
return data?.address_compact || data?.address || "آدرس نامشخص";
} catch (error) {
console.error("Error fetching address:", error);
return "";
}
}
// Form data interface
interface FormData {
title: string;
@ -82,6 +71,30 @@ function convertPersianToEnglish(str: string): string {
return result;
}
// Component to change map view when city is selected
function CityChangeView({ center }: { center: [number, number] }) {
const map = useMap();
useEffect(() => {
map.setView(center, 13);
}, [center, map]);
return null;
}
// Component to handle map clicks
function MapClickHandler({
onMapClick,
}: {
onMapClick: (latlng: [number, number]) => void;
}) {
useMapEvents({
click: (e) => {
const { lat, lng } = e.latlng;
onMapClick([lat, lng]);
},
});
return null;
}
export default function InteractiveMap({
onLocationSelected,
initialAddress,
@ -99,7 +112,7 @@ export default function InteractiveMap({
isEditing ? "" : "تهران"
);
const [currentCenter, setCurrentCenter] = useState<[number, number]>(
(iranianCities.find((c) => c.name === "تهران") || iranianCities[0]).position
iranianCities[0].position
);
const [mounted, setMounted] = useState<boolean>(false);
@ -153,13 +166,34 @@ export default function InteractiveMap({
isDefault: formData.is_default,
});
// MapLibre refs
const mapContainerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<MaplibreMap | null>(null);
const markerRef = useRef<MaplibreMarker | null>(null);
// select(lat,lng): drop/move the pin and reverse-geocode that point once.
const selectRef = useRef<((lat: number, lng: number) => void) | null>(null);
const [isLocating, setIsLocating] = useState<boolean>(false);
// Fix Leaflet's default icon path issues when the component mounts
useEffect(() => {
// Fix Leaflet icon issues
delete (L.Icon.Default.prototype as { _getIconUrl?: string })._getIconUrl;
L.Icon.Default.mergeOptions({
iconUrl: "https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon.png",
iconRetinaUrl:
"https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon-2x.png",
shadowUrl:
"https://unpkg.com/leaflet@1.7.1/dist/images/marker-shadow.png",
});
// Cleanup function to remove map instance when component unmounts
return () => {
// This code ensures previous maps are cleaned up
// eslint-disable-next-line react-hooks/exhaustive-deps
const mapContainer = document.getElementById(mapIdRef.current);
if (mapContainer) {
// Clean up previous map on this element
// In Leaflet version 1+, using map._leaflet no longer works
// and instead removeAttribute should be used
mapContainer.innerHTML = "";
mapContainer.removeAttribute("data-leaflet-internal-id");
mapContainer.removeAttribute("_leaflet_id");
console.log("Map container cleaned up successfully");
}
};
}, []);
// Initialize with the existing address data when in editing mode
useEffect(() => {
@ -211,123 +245,76 @@ export default function InteractiveMap({
setFilteredCities(cities);
}, [citySearchTerm, selectedProvince]);
// Function to get address from coordinates
const getAddressFromCoordinates = async (latlng: [number, number]) => {
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng[0]}&lon=${latlng[1]}&zoom=18&addressdetails=1&accept-language=fa&countrycodes=ir`,
{
headers: {
"Accept-Language": "fa",
},
}
);
const data = await response.json();
return data.display_name || "آدرس نامشخص";
} catch (error) {
console.error("Error fetching address:", error);
return "";
}
};
// Extract postal code from address data
const getPostalCodeFromAddress = async (latlng: [number, number]) => {
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng[0]}&lon=${latlng[1]}&zoom=18&addressdetails=1&accept-language=fa&countrycodes=ir`,
{
headers: {
"Accept-Language": "fa",
},
}
);
const data = await response.json();
if (data && data.address && data.address.postcode) {
return data.address.postcode;
}
return "";
} catch (error) {
console.error("Error fetching postal code:", error);
return "";
}
};
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [errors, setErrors] = useState<Record<string, string>>({});
// Reverse-geocode a point and push it into the form. Used by the map's
// center pin (on move) — replaces the old click handler.
const applyLocation = useCallback(
async (latlng: [number, number]) => {
const address = await reverseGeocode(latlng[0], latlng[1]);
const handleMapClick = async (latlng: [number, number]) => {
const address = await getAddressFromCoordinates(latlng);
const postalCode = await getPostalCodeFromAddress(latlng);
setSelectedPosition(latlng);
setSelectedAddress(address);
setFormData((prev) => ({
...prev,
// Don't update postal_code in edit mode, preserve the original value
setFormData({
...formData,
main_address: address,
// Don't set postal_code here in either mode, preserve the existing value in edit mode
latitude: latlng[0],
longitude: latlng[1],
}));
});
// If using as a location picker in another component, call the callback
if (onLocationSelected) {
onLocationSelected({ position: latlng, address });
}
},
[onLocationSelected]
);
// Keep a stable ref so the map's event listeners always call the latest.
const applyLocationRef = useRef(applyLocation);
useEffect(() => {
applyLocationRef.current = applyLocation;
}, [applyLocation]);
// Initialize the MapLibre map (client-only, once) with the Map.ir style.
useEffect(() => {
if (!mounted || formOnly || !mapContainerRef.current || mapRef.current) {
return;
}
let cancelled = false;
(async () => {
const maplibregl = (await import("maplibre-gl")).default;
if (cancelled || !mapContainerRef.current) return;
// Persian/Arabic labels need the RTL text plugin or they render
// reversed/broken. Safe to attempt once; throws if already set.
try {
if (
maplibregl.getRTLTextPluginStatus?.() === "unavailable"
) {
maplibregl.setRTLTextPlugin(
"https://unpkg.com/@mapbox/mapbox-gl-rtl-text@0.3.0/dist/mapbox-gl-rtl-text.js",
true // lazy: load when RTL text is first encountered
);
}
} catch {
/* plugin already registered */
}
const start = selectedPosition || currentCenter; // [lat, lng]
const map = new maplibregl.Map({
container: mapContainerRef.current,
style: MAPIR_STYLE,
center: [start[1], start[0]], // MapLibre uses [lng, lat]
zoom: 14,
attributionControl: false,
transformRequest: (url: string) =>
url.startsWith("https://map.ir")
? { url, headers: { "x-api-key": MAPIR_KEY } }
: { url },
});
mapRef.current = map;
map.addControl(
new maplibregl.NavigationControl({ showCompass: false }),
"top-left"
);
// Drop/move the pin at a point and reverse-geocode it once (saves
// tokens — we only geocode on an explicit tap, not on every move).
const select = (lat: number, lng: number) => {
if (markerRef.current) {
markerRef.current.setLngLat([lng, lat]);
} else {
markerRef.current = new maplibregl.Marker({ color: "#ff3b30" })
.setLngLat([lng, lat])
.addTo(map);
}
applyLocationRef.current([lat, lng]);
};
selectRef.current = select;
// Pan with drag; a tap selects that point.
map.on("click", (e) => select(e.lngLat.lat, e.lngLat.lng));
// Edit mode: show the existing pin without re-geocoding.
if (selectedPosition) {
markerRef.current = new maplibregl.Marker({ color: "#ff3b30" })
.setLngLat([selectedPosition[1], selectedPosition[0]])
.addTo(map);
}
})();
return () => {
cancelled = true;
if (markerRef.current) {
markerRef.current.remove();
markerRef.current = null;
}
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mounted, formOnly]);
// Recenter the map when the chosen city changes.
useEffect(() => {
if (mapRef.current) {
mapRef.current.flyTo({
center: [currentCenter[1], currentCenter[0]],
zoom: 14,
onLocationSelected({
position: latlng,
address,
postalCode: isEditing ? formData.postal_code : postalCode,
});
}
}, [currentCenter]);
};
const handleCitySelect = (cityName: string) => {
setSelectedCity(cityName);
@ -340,35 +327,6 @@ export default function InteractiveMap({
setIsCityDrawerOpen(false);
};
// Use the device's current location: fly there and select it.
const handleUseMyLocation = () => {
if (typeof navigator === "undefined" || !navigator.geolocation) {
toast({
title: "موقعیت مکانی پشتیبانی نمی‌شود",
variant: "destructive",
});
return;
}
setIsLocating(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
const { latitude, longitude } = pos.coords;
mapRef.current?.flyTo({ center: [longitude, latitude], zoom: 16 });
selectRef.current?.(latitude, longitude);
setIsLocating(false);
},
() => {
setIsLocating(false);
toast({
title: "دسترسی به موقعیت مکانی امکان‌پذیر نشد",
description: "لطفاً دسترسی موقعیت مکانی را در مرورگر فعال کنید.",
variant: "destructive",
});
},
{ enableHighAccuracy: true, timeout: 10000 }
);
};
const handleOpenDrawer = () => {
setIsDrawerOpen(true);
};
@ -520,6 +478,45 @@ export default function InteractiveMap({
}
}, [isEditing, initialAddress]);
// Add keyframes for the pulse animation
useEffect(() => {
const styleTag = document.createElement("style");
styleTag.innerHTML = `
@keyframes pulse {
0% {
transform: translate(-50%, -50%) scale(0.5);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(1.5);
opacity: 0;
}
}
`;
document.head.appendChild(styleTag);
// Listen for the custom location event
const handleLocationFound = (event: Event) => {
const customEvent = event as CustomEvent<{ lat: number; lng: number }>;
const { lat, lng } = customEvent.detail;
handleMapClick([lat, lng]);
};
window.addEventListener(
"user-location-found",
handleLocationFound as EventListener
);
return () => {
document.head.removeChild(styleTag);
window.removeEventListener(
"user-location-found",
handleLocationFound as EventListener
);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// If formOnly mode, render only the form
if (formOnly) {
return (
@ -835,44 +832,42 @@ export default function InteractiveMap({
</DrawerContent>
</Drawer>
{/* Map Container MapLibre + Map.ir vector tiles. dir=ltr keeps the
map's internal positioning correct inside the RTL page. */}
<div className="dd z-0 h-[500px] w-full relative" dir="ltr">
<div
ref={mapContainerRef}
id={mapIdRef.current}
className="h-full w-full"
{/* Map Container */}
<div className="dd z-0 h-[500px] w-full relative">
<div id={mapIdRef.current} className="h-full w-full">
{mounted && (
<MapContainer
key={mapIdRef.current}
center={currentCenter}
zoom={13}
scrollWheelZoom={true}
style={{ height: "100%", width: "100%" }}
>
<CityChangeView center={currentCenter} />
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Use my current location */}
<button
type="button"
onClick={handleUseMyLocation}
disabled={isLocating}
dir="rtl"
className="absolute top-3 right-3 z-[5] flex items-center gap-1.5 bg-WHITE/95 backdrop-blur rounded-full shadow-md px-3.5 h-10 text-R12 font-bold text-BLACK disabled:opacity-60"
>
<Crosshair size={18} className="text-VITROWN_BLUE" />
{isLocating ? "در حال یافتن..." : "موقعیت فعلی من"}
</button>
<MapClickHandler onMapClick={handleMapClick} />
{/* <LocationButton /> */}
{/* Hint / selected-address preview */}
<div
dir="rtl"
className="absolute left-3 right-3 bottom-3 z-[5] bg-WHITE/95 backdrop-blur rounded-xl shadow-md px-3.5 py-2.5"
>
{selectedAddress ? (
<>
<p className="text-R10 text-GRAY mb-0.5">موقعیت انتخابشده</p>
<p className="text-R12 font-bold line-clamp-2">
{selectedAddress}
</p>
</>
) : (
<p className="text-R12 font-medium text-BLACK2 flex items-center gap-1.5">
<MapPin size={16} className="text-RED shrink-0" />
برای انتخاب موقعیت، روی نقشه بزنید
{selectedPosition && (
<Marker position={selectedPosition}>
<Popup>
<div dir="rtl" className="text-right">
<p className="font-bold mb-1">آدرس:</p>
<p className="mb-2">{selectedAddress}</p>
<p className="mb-1">
<span className="font-bold">مختصات: </span>
{selectedPosition[0].toFixed(6)},{" "}
{selectedPosition[1].toFixed(6)}
</p>
</div>
</Popup>
</Marker>
)}
</MapContainer>
)}
</div>
</div>

View File

@ -166,7 +166,7 @@ export function LoginRequiredDialog({
</div>
<div className="w-full flex justify-center">
<InputOTP
maxLength={6}
maxLength={4}
onComplete={(finalValue) => {
const convertedValue = convertPersianToEnglish(finalValue);
setOtpValue(convertedValue);
@ -177,19 +177,17 @@ export function LoginRequiredDialog({
setOtpValue(convertedValue);
}}
>
<InputOTPGroup dir="ltr" className="gap-1.5 sm:gap-3">
<InputOTPSlot index={0} className="!w-9 sm:!w-12" />
<InputOTPSlot index={1} className="!w-9 sm:!w-12" />
<InputOTPSlot index={2} className="!w-9 sm:!w-12" />
<InputOTPSlot index={3} className="!w-9 sm:!w-12" />
<InputOTPSlot index={4} className="!w-9 sm:!w-12" />
<InputOTPSlot index={5} className="!w-9 sm:!w-12" />
<InputOTPGroup dir="ltr" className="gap-4">
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
</InputOTPGroup>
</InputOTP>
</div>
<div className="gap-3 w-full flex flex-col items-center">
<Button
disabled={verifyOtpMutation.isPending || !(otpValue.length === 6)}
disabled={verifyOtpMutation.isPending || !(otpValue.length === 4)}
className="w-full"
size="xl"
variant="dark"

View File

@ -11,7 +11,6 @@ import placeholderImage from "~/assets/icons/product.png";
import placeholderImageDark from "~/assets/icons/product-dark.png";
import { useRootData } from "~/hooks/use-root-data";
import discountRed from "~/assets/icons/product/discount-red.svg";
import SellerChip from "./SellerChip";
interface MondrianList {
id?: string;
@ -22,9 +21,6 @@ interface MondrianList {
discountPercent?: number;
discountPrice?: number;
basePrice?: number;
sellerName?: string;
sellerLogo?: string | null;
sellerUsername?: string;
}
// Utility function to detect media type
@ -91,24 +87,6 @@ const MondrianProductList = ({
return () => window.removeEventListener("scroll", handleScroll);
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
// Sentinel-based trigger — more reliable than scroll math on desktop, where
// the footer can keep the window-scroll threshold from being reached.
const loadMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = loadMoreRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ rootMargin: "600px" }
);
observer.observe(el);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
// Memoize expensive filtering operations
const { oddProducts, evenProducts } = useMemo(() => {
const odd = products.filter((_, index) => index % 2 === 0);
@ -118,17 +96,8 @@ const MondrianProductList = ({
const oddHeight = useMemo(() => [220, 260, 320, 200, 280], []);
const evenHeight = useMemo(() => [280, 200, 260, 220, 320], []);
// Desktop masonry: distribute products across 4 columns (round-robin).
const desktopColumns = useMemo(() => {
const cols: MondrianList[][] = [[], [], [], []];
products.forEach((p, i) => cols[i % 4].push(p));
return cols;
}, [products]);
const desktopHeights = useMemo(() => [300, 360, 260, 340, 280, 320], []);
return (
<div className="px-4 lg:px-0 flex flex-col w-full">
<div className="px-4 flex flex-col w-full">
{!isCommonList ? (
<>
{isLoading ? (
@ -144,8 +113,7 @@ const MondrianProductList = ({
/>
) : (
<>
{/* Mobile / tablet: original two-column masonry (unchanged) */}
<div className="flex gap-1 w-full lg:hidden">
<div className="flex gap-1 w-full">
{/* Column 1 (Odd products - index 0, 2, ...) */}
<div className="flex flex-col gap-1 w-[calc(50%)]">
{oddProducts.map((product, index: number) => {
@ -178,28 +146,7 @@ const MondrianProductList = ({
})}
</div>
</div>
{/* Desktop: 4-column masonry */}
<div className="hidden lg:flex gap-4 w-full">
{desktopColumns.map((col, ci) => (
<div key={ci} className="flex flex-col gap-4 flex-1 min-w-0">
{col.map((product, index) => (
<ProductCard
key={product.id}
product={product}
height={
desktopHeights[(ci + index) % desktopHeights.length]
}
storeId={storeId}
storePage={storePage}
/>
))}
</div>
))}
</div>
<div ref={loadMoreRef} className="h-px w-full" />
{isFetchingNextPage && <MondrianSkeleton isMini={true} />}
{hasNextPage && <MondrianSkeleton isMini={true} />}
</>
)}
</>
@ -404,7 +351,6 @@ const ProductCard = ({
)}
{/* Overlay info - Airbnb style */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 via-black/40 to-transparent p-2 pt-6 pointer-events-none">
<SellerChip name={product.sellerName} logo={product.sellerLogo} />
<div className="flex w-full justify-between gap-1">
{/* Title */}
<h3 className="text-white text-xs font-semibold line-clamp-1 mb-0.5">

View File

@ -8,14 +8,10 @@ import homeFilled from "app/assets/icons/navbar/home-filled.svg";
import profileFilled from "app/assets/icons/navbar/profile-filled.svg";
import searchFilled from "app/assets/icons/navbar/search-filled.svg";
import { Link, useLocation, useParams } from "@remix-run/react";
import DesktopHeader from "~/components/desktop/DesktopHeader";
import DesktopFooter from "~/components/desktop/DesktopFooter";
import { detectPage } from "~/utils/helpers";
import { useRootData } from "~/hooks/use-root-data";
import { useCartCount } from "~/requestHandler/use-cart-hooks";
import { useSellerOrderCount } from "~/requestHandler/use-order-hooks";
import { useBadgeCounts } from "~/hooks/useBadgeCounts";
import { PushPermissionBanner } from "~/components/notifications/PushPermissionBanner";
import settingIconFilled from "~/assets/icons/navbar/setting-filled.svg";
import settingIconOutline from "~/assets/icons/navbar/setting-outline.svg";
import documentFilled from "~/assets/icons/navbar/document-filled.svg";
@ -29,71 +25,25 @@ import productsOutline from "~/assets/icons/navbar/products-outline.svg";
*/
const MyLayout = React.memo(({ children }: { children: React.ReactNode }) => {
const location = useLocation();
// Desktop chrome is rolled out per-page. Only routes matched here get the
// full-width desktop layout; everything else keeps the current (mobile)
// experience at all widths so nothing regresses while we build the rest.
const p = location.pathname;
const isDesktopReady =
p === "/" ||
p === "/explore" ||
p.startsWith("/search/") ||
p.startsWith("/product/") ||
p.startsWith("/seller/") ||
p === "/collections" ||
p.startsWith("/collection/") ||
p === "/cart" ||
p.startsWith("/cart/") ||
p === "/sellers" ||
p === "/discounts" ||
p.startsWith("/profile");
// Auth pages get a full-width desktop canvas (so they can center their own
// card) but no shopping header/footer.
const isAuth = p === "/login" || p === "/logout";
// Seller dashboard: full-width canvas + its own sidebar (store.$storeId.tsx),
// no shopper header/footer.
const isStore = p.startsWith("/store/");
// Admin panel: full-screen own layout (admin.tsx sidebar), no shopper chrome
// and no shopper bottom nav.
const isAdmin = p === "/admin" || p.startsWith("/admin/");
const expandOnDesktop = isDesktopReady || isAuth || isStore || isAdmin;
const isThread = !!location.pathname.split("threads")[1];
return (
<div className="min-h-screen bg-gray-100 lg:bg-gray-200 lg:flex lg:flex-col">
{isDesktopReady && <DesktopHeader />}
<div
className={`max-w-md mx-auto bg-WHITE min-h-screen shadow-lg lg:shadow-xl ${
expandOnDesktop
? "lg:max-w-none lg:mx-0 lg:min-h-0 lg:flex-1 lg:shadow-none"
: ""
}`}
>
<div
className={`pb-[calc(50px_+_env(safe-area-inset-bottom))] ${
expandOnDesktop ? "lg:pb-0" : ""
}`}
>
<div className="min-h-screen bg-gray-100 lg:bg-gray-200">
<div className="max-w-md mx-auto bg-WHITE min-h-screen shadow-lg lg:shadow-xl">
<div className="pb-[calc(50px+env(safe-area-inset-bottom))]">
{children}
{isThread || isAdmin ? (
{location.pathname.split("threads")[1] ? (
<></>
) : (
<MobileBottomNavigation hideOnDesktop={expandOnDesktop} />
<MobileBottomNavigation />
)}
<PushPermissionBanner />
</div>
</div>
{isDesktopReady && <DesktopFooter />}
</div>
);
});
MyLayout.displayName = "Layout";
const MobileBottomNavigation = ({
hideOnDesktop = false,
}: {
hideOnDesktop?: boolean;
}) => {
const MobileBottomNavigation = () => {
const { theme } = useRootData();
const location = useLocation();
const currentPage = useMemo(
@ -104,11 +54,6 @@ const MobileBottomNavigation = ({
const { user } = useRootData();
const [isStoreMode, setIsStoreMode] = useState(false);
const sellerOrderCount = useSellerOrderCount(!!isStoreMode);
const { data: badgeCounts } = useBadgeCounts();
// Buyer-facing account activity (unread chats + order status changes) is
// surfaced on the profile icon, since both live under /profile.
const accountUnread =
(badgeCounts?.chat || 0) + (badgeCounts?.orderUpdates || 0);
const [storeId, setStoreId] = useState<string | null>(null);
// Initialize state from sessionStorage on mount - only once
@ -143,7 +88,6 @@ const MobileBottomNavigation = ({
path.match(/^\/store\/[^/]+\/products$/) || // Products list: /store/{id}/products
path.match(/^\/store\/[^/]+\/product\/[^/]+$/) || // Product detail: /store/{id}/products/{id}
path.match(/^\/store\/[^/]+\/edit$/) || // Edit store: /store/{id}/edit
path.match(/^\/store\/[^/]+\/team$/) || // Team management: /store/{id}/team
path.match(/^\/store\/add\/manual\/[^/]+$/) || // Add product: /store/add/manual/{id}
path.match(/^\/store\/create$/) || // Create store: /store/create
path.match(/^\/store\/authenticate\/instagram\/[^/]+$/) || // Instagram auth: /store/authenticate/instagram/{id}
@ -316,8 +260,6 @@ const MobileBottomNavigation = ({
: theme === "dark"
? profileFilled
: profileOutline,
badge:
accountUnread > 0 && user?.access_token ? accountUnread : null,
},
{
link: "/cart",
@ -359,7 +301,6 @@ const MobileBottomNavigation = ({
currentPage,
cartCount,
sellerOrderCount,
accountUnread,
user?.access_token,
isStoreMode,
storeId,
@ -368,12 +309,7 @@ const MobileBottomNavigation = ({
return (
<>
<div
className={`z-20 bg-WHITE fixed bottom-0 border-t w-full max-w-md mx-auto pb-[env(safe-area-inset-bottom)] lg:left-1/2 lg:transform lg:-translate-x-1/2 ${
hideOnDesktop ? "lg:hidden" : ""
}`}
>
<div className="h-[50px] flex gap-[48px] px-[20px] items-center justify-center">
<div className="h-[50px] z-20 bg-WHITE flex gap-[48px] px-[20px] fixed bottom-0 border-t w-full max-w-md mx-auto items-center justify-center pb-[env(safe-area-inset-bottom)] lg:left-1/2 lg:transform lg:-translate-x-1/2">
{navItems.map((item) => (
<Link
key={item.link}
@ -391,15 +327,14 @@ const MobileBottomNavigation = ({
) : (
item.icon
)}
{item.badge ? (
<span className="absolute -top-1.5 -right-1.5 min-w-[16px] h-4 px-1 rounded-full bg-RED text-white text-[10px] font-bold flex items-center justify-center leading-none">
{item.badge > 99 ? "99+" : item.badge}
{item.badge && (
<span className="absolute -top-1 -right-1 text-R12 ">
{item.badge}
</span>
) : null}
)}
</Link>
))}
</div>
</div>
</>
);
};

View File

@ -20,8 +20,7 @@ const ProfilePagesHeader = memo(function ProfilePagesHeader({
}, [navigate]);
return (
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
<div className="relative flex flex-row h-[65px] bg-WHITE w-full items-center justify-center border-b border-inner-border">
<div className="flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border">
{!hideBackButton && (
<ArrowRight
onClick={handleBack}
@ -33,7 +32,6 @@ const ProfilePagesHeader = memo(function ProfilePagesHeader({
<div className="absolute top-4 left-4">{rightContent}</div>
)}
</div>
</div>
);
});

View File

@ -42,7 +42,7 @@ const SearchTopBar: React.FC<SearchTopBarProps> = ({
};
return (
<div className="flex flex-col gap-4 pb-4 pt-[calc(1rem_+_env(safe-area-inset-top))] px-4 w-full sticky top-0 z-30 bg-WHITE">
<div className="flex flex-col gap-4 py-4 px-4 w-full sticky top-0 z-30 bg-WHITE">
<div className="flex items-center justify-center gap-2 rounded-[10px] w-full relative">
<AppInput
inputIcon={SearchIcon}

View File

@ -1,32 +0,0 @@
interface SellerChipProps {
name?: string;
logo?: string | null;
}
/**
* Small brand chip (logo + name) shown over a product card's image overlay.
* Renders nothing when there's no seller name. Falls back to the seller's
* initial when no logo is available (e.g. before the backend exposes it).
*/
export default function SellerChip({ name, logo }: SellerChipProps) {
if (!name) return null;
return (
<div className="flex items-center gap-1.5 mb-1 min-w-0">
<span className="w-[15px] h-[15px] rounded-full overflow-hidden border border-white/40 bg-white/20 shrink-0 grid place-items-center text-[8px] font-bold text-white">
{logo ? (
<img
src={logo}
alt=""
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
) : (
name.trim().charAt(0)
)}
</span>
<span className="text-white/85 text-[10.5px] truncate">{name}</span>
</div>
);
}

View File

@ -44,16 +44,14 @@ const SellerTilesGrid = ({
}
return (
<div className="w-full flex flex-col gap-4 px-4 lg:px-0">
{/* 2x2 on mobile, single row of 5 on desktop */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-2 lg:gap-4">
{sellers.slice(0, 5).map((seller, index) => (
<div className="w-full flex flex-col gap-4 px-4">
{/* Grid Container - 2x2 layout */}
<div className="grid grid-cols-2 gap-2">
{sellers.slice(0, 4).map((seller, index) => (
<Link
key={seller.id || index}
to={`/seller/${seller.sellerUsername}`}
className={`relative aspect-square overflow-hidden rounded-lg group ${
index === 4 ? "hidden lg:block" : ""
}`}
className="relative aspect-square overflow-hidden rounded-lg group"
>
{/* Background Image */}
{seller.imageUrl ? (
@ -67,7 +65,6 @@ const SellerTilesGrid = ({
<Store size={48} className="text-gray-400" />
</div>
)}
</Link>
))}
</div>

View File

@ -1,248 +0,0 @@
import { useEffect, useState } from "react";
import { ChevronLeft, ChevronRight, Search, Loader2 } from "lucide-react";
import { cn } from "~/lib/utils";
/* ------------------------------ helpers ------------------------------ */
export const faNum = (n?: number | string | null) =>
n === null || n === undefined || n === ""
? "—"
: Number(n).toLocaleString("fa-IR");
export const faToman = (n?: number | null) =>
n === null || n === undefined ? "—" : `${Math.round(n).toLocaleString("fa-IR")} تومان`;
export const faDate = (iso?: string | null) => {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("fa-IR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
} catch {
return "—";
}
};
/** Debounce a rapidly-changing value (e.g. a search box). */
export function useDebouncedValue<T>(value: T, delay = 350): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return debounced;
}
/* ---------------------------- status badge --------------------------- */
type Tone = "green" | "red" | "yellow" | "blue" | "gray";
const TONE: Record<Tone, string> = {
green: "bg-GREEN/10 text-GREEN",
red: "bg-RED/10 text-RED",
yellow: "bg-yellow-500/10 text-yellow-600",
blue: "bg-VITROWN_BLUE/10 text-VITROWN_BLUE",
gray: "bg-WHITE3 text-BLACK2",
};
export function StatusBadge({ label, tone }: { label: string; tone: Tone }) {
return (
<span
className={cn(
"inline-block px-2 py-0.5 rounded-full text-[11px] font-semibold whitespace-nowrap",
TONE[tone]
)}
>
{label}
</span>
);
}
/* ------------------------------- table ------------------------------- */
export interface Column<T> {
header: string;
render: (row: T) => React.ReactNode;
className?: string;
}
export function AdminTable<T>({
columns,
rows,
isLoading,
getRowKey,
emptyText = "موردی یافت نشد",
}: {
columns: Column<T>[];
rows: T[];
isLoading?: boolean;
getRowKey: (row: T) => string;
emptyText?: string;
}) {
return (
<div className="rounded-2xl border border-WHITE3 bg-WHITE overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-right border-collapse">
<thead>
<tr className="bg-WHITE2 text-GRAY text-[12px]">
{columns.map((c, i) => (
<th
key={i}
className={cn("px-3 py-3 font-bold whitespace-nowrap", c.className)}
>
{c.header}
</th>
))}
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={columns.length} className="py-12 text-center">
<Loader2 className="inline h-6 w-6 animate-spin text-GRAY" />
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className="py-12 text-center text-GRAY text-[13px]"
>
{emptyText}
</td>
</tr>
) : (
rows.map((row) => (
<tr
key={getRowKey(row)}
className="border-t border-WHITE3 hover:bg-WHITE2/60 transition-colors text-[13px]"
>
{columns.map((c, i) => (
<td
key={i}
className={cn("px-3 py-3 align-middle", c.className)}
>
{c.render(row)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
/* ---------------------------- pagination ----------------------------- */
export function AdminPagination({
page,
pageSize,
count,
onPageChange,
}: {
page: number;
pageSize: number;
count: number;
onPageChange: (page: number) => void;
}) {
const totalPages = Math.max(1, Math.ceil(count / pageSize));
const from = count === 0 ? 0 : (page - 1) * pageSize + 1;
const to = Math.min(count, page * pageSize);
const btn =
"h-8 w-8 grid place-items-center rounded-lg border border-WHITE3 text-BLACK2 disabled:opacity-40 hover:bg-WHITE2 transition-colors cursor-pointer disabled:cursor-default";
return (
<div className="flex items-center justify-between mt-3 text-[13px] text-GRAY">
<span>
{faNum(from)}{faNum(to)} از {faNum(count)}
</span>
<div className="flex items-center gap-1.5">
<button
className={btn}
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
aria-label="صفحه قبل"
>
<ChevronRight size={16} />
</button>
<span className="tabular-nums px-1">
{faNum(page)} / {faNum(totalPages)}
</span>
<button
className={btn}
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
aria-label="صفحه بعد"
>
<ChevronLeft size={16} />
</button>
</div>
</div>
);
}
/* --------------------------- toolbar bits ---------------------------- */
export function AdminSearch({
value,
onChange,
placeholder = "جستجو…",
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
}) {
return (
<div className="relative flex-1 min-w-[180px] max-w-[320px]">
<Search
size={16}
className="absolute right-3 top-1/2 -translate-y-1/2 text-GRAY"
/>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full rounded-xl border border-WHITE3 bg-WHITE pr-9 pl-3 py-2 text-[13px] outline-none focus:border-VITROWN_BLUE"
/>
</div>
);
}
export function AdminSelect({
value,
onChange,
options,
}: {
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="rounded-xl border border-WHITE3 bg-WHITE px-3 py-2 text-[13px] outline-none focus:border-VITROWN_BLUE cursor-pointer"
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
export function AdminToolbar({ children }: { children: React.ReactNode }) {
return <div className="flex flex-wrap items-center gap-2 mb-3">{children}</div>;
}
export function AdminPageTitle({ children }: { children: React.ReactNode }) {
return (
<h1 className="text-[22px] lg:text-[26px] font-extrabold mb-4">{children}</h1>
);
}

View File

@ -1,45 +0,0 @@
import type { LucideIcon } from "lucide-react";
import { cn } from "~/lib/utils";
interface KpiCardProps {
label: string;
value: string | number;
sub?: string;
icon?: LucideIcon;
accent?: "default" | "blue" | "green" | "red";
}
const ACCENT: Record<string, string> = {
default: "text-BLACK",
blue: "text-VITROWN_BLUE",
green: "text-GREEN",
red: "text-RED",
};
export function KpiCard({
label,
value,
sub,
icon: Icon,
accent = "default",
}: KpiCardProps) {
return (
<div className="rounded-2xl border border-WHITE3 bg-WHITE p-4">
<div className="flex items-center justify-between">
<span className="text-GRAY text-[13px]">{label}</span>
{Icon && <Icon size={18} className="text-GRAY shrink-0" />}
</div>
<p
className={cn(
"mt-2 text-[24px] font-extrabold tabular-nums leading-tight",
ACCENT[accent]
)}
>
{value}
</p>
{sub && <p className="text-[12px] text-GRAY mt-1">{sub}</p>}
</div>
);
}
export default KpiCard;

View File

@ -1,61 +0,0 @@
/**
* Dependency-free 14-day orders bar chart for the admin overview.
* (A richer charting lib can replace this in a later slice.)
*/
interface Point {
date: string;
orders: number;
revenue: number;
}
function faNum(n: number): string {
return Math.round(n).toLocaleString("fa-IR");
}
function dayLabel(iso: string): string {
// day-of-month in Persian digits
try {
const d = new Date(iso);
return d.toLocaleDateString("fa-IR", { day: "numeric" });
} catch {
return "";
}
}
export function OrdersBarChart({ data }: { data: Point[] }) {
const max = Math.max(1, ...data.map((d) => d.orders));
return (
<div className="rounded-2xl border border-WHITE3 bg-WHITE p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-[15px]">سفارشهای ۱۴ روز اخیر</h3>
<span className="text-[12px] text-GRAY">
مجموع: {faNum(data.reduce((s, d) => s + d.orders, 0))} سفارش
</span>
</div>
<div className="flex items-end gap-1.5 h-[160px]" dir="ltr">
{data.map((d) => (
<div
key={d.date}
className="flex-1 h-full flex flex-col items-center justify-end gap-1 group"
title={`${dayLabel(d.date)}${faNum(d.orders)} سفارش، ${faNum(
d.revenue
)} تومان`}
>
<div className="w-full flex-1 flex items-end">
<div
className="w-full rounded-t-md bg-VITROWN_BLUE/80 group-hover:bg-VITROWN_BLUE transition-colors min-h-[2px]"
style={{ height: `${(d.orders / max) * 100}%` }}
/>
</div>
<span className="text-[10px] text-GRAY tabular-nums">
{dayLabel(d.date)}
</span>
</div>
))}
</div>
</div>
);
}
export default OrdersBarChart;

View File

@ -14,7 +14,7 @@ export const CartHeader = ({ cart, step, setStep }: CartHeaderProps) => {
const navigate = useNavigate();
return (
<div
className={`flex flex-col gap-3 pt-[calc(1rem_+_env(safe-area-inset-top))] sticky top-0 z-30 bg-WHITE w-full items-center justify-center ${step !== 0 ? "pb-4" : ""}`}
className={`flex flex-col gap-3 pt-4 sticky top-0 z-30 bg-WHITE w-full items-center justify-center ${step !== 0 ? "pb-4" : ""}`}
>
<p className={"text-B16 font-bold px-4"}>
{step === 0
@ -29,7 +29,7 @@ export const CartHeader = ({ cart, step, setStep }: CartHeaderProps) => {
if (step > 0) setStep((p) => p - 1);
else safeGoBack(navigate);
}}
className="absolute top-[calc(1rem_+_env(safe-area-inset-top))] right-4"
className="absolute top-4 right-4"
/>
{cart && cart.items && cart.items.length > 0 && (
<>

View File

@ -1,4 +1,4 @@
import { Trash } from "lucide-react";
import { Check, Trash } from "lucide-react";
import { memo, useMemo } from "react";
import { QuantityControl } from "./QuantityControl";
import { formatPrice } from "../../utils/helpers";
@ -34,31 +34,20 @@ export const CartItem = memo(function CartItem({
);
}, [item.productVariant?.discountPrice, item.productVariant?.price]);
// Memoize video check
const isVideoMedia = useMemo(
() => isVideo(item.productVariant?.productImage || ""),
[item.productVariant?.productImage]
);
const color =
item.productVariant?.color?.value !== "UNSELECTED"
? item.productVariant?.color?.info?.detail || ""
: "";
const size =
item.productVariant?.size?.value !== "UNSELECTED"
? item.productVariant?.size?.info?.detail || ""
: "";
const effectivePrice =
item.productVariant?.discountPrice || item.productVariant?.price || "";
const inStock =
!!item?.productVariant?.stock && item.productVariant.stock > 0;
return (
<div className={`flex gap-3 p-4 w-full ${hasBorder && "border-b"}`}>
<div
className={`flex p-4 gap-4 w-full flex-col ${hasBorder && "border-b"}`}
>
<div className="flex gap-2 w-full items-center">
{isVideoMedia ? (
<video
src={item.productVariant?.productImage}
className="rounded-[11px] w-[88px] h-[110px] object-cover shrink-0"
className="rounded-[5px] w-[116px] h-[116px] object-cover"
muted
loop
autoPlay
@ -68,39 +57,65 @@ export const CartItem = memo(function CartItem({
<img
src={item.productVariant?.productImage}
alt={item.productVariant?.productTitle || ""}
className="rounded-[11px] w-[88px] h-[110px] object-cover shrink-0"
className="rounded-[5px] w-[116px] h-[116px] object-cover"
loading="lazy"
/>
)}
<div className="flex flex-col flex-1 min-w-0">
<h4 className="text-B14 font-bold line-clamp-2">
<div className="flex flex-col gap-2">
<p className="text-B14 font-bold">
{item.productVariant?.productTitle || ""}
</h4>
{/* Attribute chips */}
{(color || size) && (
<div className="flex gap-1.5 flex-wrap mt-2">
{color && (
<span className="inline-flex items-center gap-1.5 text-R12 text-BLACK2 bg-WHITE3 rounded-[7px] px-2 py-1">
<span
className="w-3.5 h-3.5 rounded-full border border-WHITE3"
style={{ backgroundColor: color }}
/>
رنگ
</span>
)}
{size && (
<span className="text-R12 text-BLACK2 bg-WHITE3 rounded-[7px] px-2 py-1">
سایز {size}
</span>
)}
</p>
<p className="text-R12 text-GRAY line-clamp-1">
کد کالا: {item.productVariant?.id || ""}
</p>
</div>
</div>
)}
{/* Bottom row: stepper + price */}
{inStock ? (
<div className="flex items-center gap-3 mt-auto pt-3">
<ColorSelector
color={
item.productVariant?.color?.value !== "UNSELECTED"
? item.productVariant?.color?.info?.detail || ""
: ""
}
/>
<SizeSelector
size={
item.productVariant?.size?.value !== "UNSELECTED"
? item.productVariant?.size?.info?.detail || ""
: ""
}
/>
<div className="flex w-full items-center justify-between">
<p className="text-B14 font-bold">قیمت واحد :</p>
<p className="text-B14 font-bold">
{formatPrice(item.productVariant?.price || "")} تومان
</p>
</div>
{discountPercent ? (
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<p className="text-B14 font-bold">مقدار تخفیف :</p>
<div className="bg-BLACK rounded-full text-WHITE text-R14 px-2 py-1">
{discountPercent}%
</div>
</div>
<p className="text-B14 font-bold">
{formatPrice(
(
Number(item.productVariant?.price) -
Number(item.productVariant?.discountPrice)
).toString()
)}{" "}
تومان
</p>
</div>
) : null}
{item?.productVariant?.stock && item?.productVariant?.stock > 0 ? (
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2"></div>
<QuantityControl
isPending={isLoading}
quantity={item.quantity}
@ -113,23 +128,10 @@ export const CartItem = memo(function CartItem({
}
onRemove={() => onRemoveItem(item.id || "")}
/>
<div className="ms-auto text-left">
<b className="text-B14 font-bold">
{formatPrice(effectivePrice)}
<span className="text-R10 font-semibold text-BLACK2"> ت</span>
</b>
{discountPercent ? (
<del className="block text-R10 text-GRAY">
{formatPrice(item.productVariant?.price || "")}
</del>
) : null}
</div>
</div>
) : (
<div className="flex w-full items-center justify-between mt-auto pt-3">
<p className="text-R12 font-bold text-RED">
در حال حاضر موجود نیست
</p>
<div className="flex w-full items-center justify-between">
<p className="text-B14 font-bold">محصول در حال حاضر موجود نیست</p>
<Button
variant="dark"
className="flex items-center gap-2 bg-red-500"
@ -141,6 +143,36 @@ export const CartItem = memo(function CartItem({
</div>
)}
</div>
</div>
);
});
// Color Selector Component
const ColorSelector = ({ color }: { color: string }) => (
<>
{color && (
<div className="flex w-full items-center justify-between">
<p className="text-B14 font-bold">رنگ :</p>
<div
className="w-10 h-10 rounded-[7px] flex items-center justify-center border border-BLACK"
style={{
backgroundColor: color,
}}
>
<Check size={24} className="text-WHITE" />
</div>
</div>
)}
</>
);
// Size Selector Component
const SizeSelector = ({ size }: { size: string }) => (
<>
{size && (
<div className="flex w-full items-center justify-between">
<p className="text-B14 font-bold">سایز :</p>
<p className="text-BLACK text-R12">{size}</p>
</div>
)}
</>
);

View File

@ -15,7 +15,7 @@ export const CartSummary = ({
step,
isLoading,
}: CartSummaryProps) => (
<div className="max-w-md mx-auto fixed flex flex-row gap-2 w-full bottom-[50px] left-0 right-0 border-t py-1 px-2 z-50 bg-WHITE lg:static lg:max-w-none lg:mx-0 lg:bottom-auto lg:flex-col-reverse lg:gap-4 lg:border lg:border-WHITE3 lg:rounded-xl lg:p-5 lg:z-auto">
<div className="max-w-md mx-auto fixed flex flex-row gap-2 w-full bottom-[50px] left-0 right-0 border-t py-1 px-2 z-50 bg-WHITE">
<Button
variant="dark"
size="lg"

View File

@ -1,100 +0,0 @@
import { Link } from "@remix-run/react";
import { Instagram, Send } from "lucide-react";
import logo from "~/assets/logo/SVG-07.svg";
type FooterLink = { label: string; to: string };
type FooterCol = { heading: string; links: FooterLink[] };
const COLUMNS: FooterCol[] = [
{
heading: "خرید",
links: [
{ label: "تازه‌ها", to: "/explore" },
{ label: "تخفیف‌ها", to: "/" },
{ label: "کالکشن‌ها", to: "/collections" },
{ label: "برندها", to: "/sellers" },
],
},
{
heading: "ویترون",
links: [
{ label: "درباره ما", to: "/profile/about" },
{ label: "سوالات متداول", to: "/profile/faq" },
],
},
{
heading: "پشتیبانی",
links: [
{ label: "راهنمای خرید", to: "/profile/faq" },
{ label: "پیگیری سفارش", to: "/profile/orders" },
],
},
];
/**
* Desktop-only footer. Hidden below the `lg` breakpoint.
*/
const DesktopFooter = () => {
return (
<footer className="hidden lg:block border-t border-WHITE3 bg-WHITE2">
<div className="max-w-[1320px] mx-auto px-8 pt-14 pb-8 grid grid-cols-[1.6fr_1fr_1fr_1fr] gap-10">
<div>
<div className="flex items-center gap-2.5">
<img src={logo} alt="ویترون" className="w-6 h-8 object-contain" />
<b className="text-[22px] font-extrabold text-VITROWN_BLUE">ویترون</b>
</div>
<p className="text-GRAY text-[13.5px] max-w-[280px] my-3 leading-8">
ویترین آنلاین برندهای پوشاک ایران. از بهترین فروشگاهها کشف کن، دنبال
کن و خرید کن.
</p>
<Link
to="/store/create"
className="inline-flex items-center justify-center h-[38px] px-3.5 rounded-[10px] bg-BLACK text-white text-[13.5px] font-bold hover:bg-black transition-colors"
>
فروشگاه خود را بسازید
</Link>
</div>
{COLUMNS.map((col) => (
<div key={col.heading}>
<h4 className="text-[14px] font-bold mb-3.5">{col.heading}</h4>
<ul className="flex flex-col gap-2.5">
{col.links.map((l) => (
<li key={l.label + l.to}>
<Link
to={l.to}
className="text-BLACK2 text-[13.5px] hover:text-BLACK transition-colors"
>
{l.label}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
<div className="max-w-[1320px] mx-auto px-8 py-5 pb-10 flex justify-between items-center border-t border-WHITE3 text-GRAY text-[12.5px]">
<span>© ۱۴۰۴ ویترون تمامی حقوق محفوظ است.</span>
<div className="flex gap-2.5">
<a
href="#"
aria-label="اینستاگرام"
className="w-[38px] h-[38px] rounded-full bg-WHITE border border-GRAY3 grid place-items-center text-BLACK2 hover:text-BLACK hover:border-BLACK transition-colors"
>
<Instagram size={18} />
</a>
<a
href="#"
aria-label="تلگرام"
className="w-[38px] h-[38px] rounded-full bg-WHITE border border-GRAY3 grid place-items-center text-BLACK2 hover:text-BLACK hover:border-BLACK transition-colors"
>
<Send size={18} />
</a>
</div>
</div>
</footer>
);
};
export default DesktopFooter;

View File

@ -1,249 +0,0 @@
import { Link, useLocation, useNavigate } from "@remix-run/react";
import { useState, useRef, useEffect, type FormEvent } from "react";
import { Search, Heart, ShoppingBag, User, Store } from "lucide-react";
import { useRootData } from "~/hooks/use-root-data";
import { useCartCount } from "~/requestHandler/use-cart-hooks";
import { useMyStores } from "~/requestHandler/use-seller-hooks";
import { useSearchAutocomplete } from "~/requestHandler/use-search-hooks";
import { useDebounce } from "~/hooks/useDebounce";
import { SunMoonToggle } from "~/components/SunMoonToggle";
import { AutocompleteSuggestionTypeEnum } from "../../../src/api/types";
import logo from "~/assets/logo/SVG-07.svg";
const NAV = [
{ label: "خانه", to: "/" },
{ label: "کاوش", to: "/explore" },
{ label: "برندها", to: "/sellers" },
{ label: "کالکشن‌ها", to: "/collections" },
];
/**
* Desktop-only top chrome (sticky header with brand, nav, search and actions).
* Hidden below the `lg` breakpoint; the mobile experience is unchanged.
*/
const DesktopHeader = () => {
const location = useLocation();
const navigate = useNavigate();
const { user, theme } = useRootData();
const cartCount = useCartCount();
const { data: myStores } = useMyStores();
// Mirror the mobile "ورود به فروشگاه من" flow: prefer a store the user owns,
// fall back to the first store they staff. If they don't manage any store,
// the CTA is hidden.
const targetStore =
myStores?.find((s) => s.role === "owner") ?? myStores?.[0];
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const isLoggedIn = !!user?.access_token;
const debouncedQ = useDebounce(q.trim(), 400);
const { data: autocompleteData, isLoading: isSuggesting } =
useSearchAutocomplete(debouncedQ);
const suggestions = autocompleteData?.suggestions ?? [];
const showSuggestions = open && q.trim().length > 0;
// Close the suggestions dropdown when clicking outside the search box.
useEffect(() => {
const onClickOutside = (e: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, []);
const isActive = (to: string) =>
to === "/" ? location.pathname === "/" : location.pathname.startsWith(to);
const goToSearch = (term: string) => {
const t = term.trim();
if (!t) return;
setOpen(false);
navigate(`/search/${encodeURIComponent(t)}`);
};
const onSearch = (e: FormEvent) => {
e.preventDefault();
goToSearch(q);
};
const onSelectSuggestion = (sug: {
type: AutocompleteSuggestionTypeEnum;
text?: string;
name?: string;
id?: string;
}) => {
if (sug.type === AutocompleteSuggestionTypeEnum.Seller && sug.id) {
setOpen(false);
navigate(`/seller/${sug.id}`);
return;
}
goToSearch(sug.text || sug.name || "");
};
return (
<header className="hidden lg:block sticky top-0 z-40 bg-WHITE/85 backdrop-blur-xl border-b border-WHITE3">
<div className="max-w-[1320px] mx-auto h-[72px] px-8 flex items-center gap-7">
<Link to="/" className="flex items-center gap-2.5 shrink-0">
<img src={logo} alt="ویترون" className="w-7 h-9 object-contain" />
<b className="text-[23px] font-extrabold tracking-tight text-VITROWN_BLUE">
ویترون
</b>
</Link>
<nav className="flex items-center gap-1">
{NAV.map((n) => (
<Link
key={n.to}
to={n.to}
prefetch="intent"
className={`px-3.5 py-2 rounded-[10px] text-[14.5px] transition-colors ${
isActive(n.to)
? "text-BLACK font-bold"
: "text-BLACK2 font-semibold hover:bg-WHITE3 hover:text-BLACK"
}`}
>
{n.label}
</Link>
))}
</nav>
<div ref={searchRef} className="flex-1 max-w-[460px] mx-auto relative">
<form
onSubmit={onSearch}
className="flex items-center gap-2.5 bg-WHITE3 border border-transparent rounded-xl px-3.5 h-11 transition-colors focus-within:bg-WHITE focus-within:border-GRAY3"
>
<Search size={20} className="text-GRAY shrink-0" />
<input
value={q}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Escape") setOpen(false);
}}
name="q"
autoComplete="off"
placeholder="جستجوی محصول، برند یا فروشگاه…"
aria-label="جستجو"
className="w-full bg-transparent outline-none text-[14.5px] text-BLACK placeholder:text-GRAY"
/>
</form>
{showSuggestions ? (
<div
dir="rtl"
className="absolute top-full inset-x-0 mt-2 max-h-[360px] overflow-y-auto rounded-xl bg-WHITE border border-WHITE3 shadow-xl py-1.5 z-50"
>
{isSuggesting && suggestions.length === 0 ? (
<p className="px-4 py-3 text-[13px] text-GRAY">در حال جستجو...</p>
) : suggestions.length > 0 ? (
suggestions.map((sug, index) => {
const isSeller =
sug.type === AutocompleteSuggestionTypeEnum.Seller;
const label = isSeller ? sug.name : sug.text;
if (!label) return null;
return (
<button
key={`${sug.type}-${sug.id ?? sug.text ?? index}`}
type="button"
onClick={() => onSelectSuggestion(sug)}
className="w-full flex items-center gap-2.5 px-4 h-11 text-right hover:bg-WHITE2 transition-colors"
>
{isSeller ? (
<Store size={17} className="text-GRAY shrink-0" />
) : (
<Search size={17} className="text-GRAY shrink-0" />
)}
<span className="text-[14px] text-BLACK truncate">
{label}
</span>
{isSeller ? (
<span className="mr-auto text-[11px] text-GRAY shrink-0">
فروشگاه
</span>
) : null}
</button>
);
})
) : (
<button
type="button"
onClick={() => goToSearch(q)}
className="w-full flex items-center gap-2.5 px-4 h-11 text-right hover:bg-WHITE2 transition-colors"
>
<Search size={17} className="text-GRAY shrink-0" />
<span className="text-[14px] text-BLACK truncate">{q}</span>
</button>
)}
</div>
) : null}
</div>
<div className="flex items-center gap-2.5 shrink-0">
<SunMoonToggle theme={theme} />
{isLoggedIn ? (
<>
{targetStore ? (
<Link
to={`/store/${targetStore.username}`}
aria-label="ورود به فروشگاه من"
prefetch="intent"
className="inline-flex items-center gap-2 h-[42px] px-3.5 rounded-xl bg-WHITE3 text-BLACK text-[13.5px] font-bold hover:bg-WHITE2 transition-colors whitespace-nowrap"
>
<Store size={18} />
<span>فروشگاه من</span>
</Link>
) : null}
<Link
to="/profile/bookmarks"
aria-label="نشان‌شده‌ها"
prefetch="intent"
className="w-[42px] h-[42px] rounded-xl grid place-items-center text-BLACK hover:bg-WHITE3 transition-colors"
>
<Heart size={21} />
</Link>
<Link
to="/cart"
aria-label="سبد خرید"
prefetch="intent"
className="relative w-[42px] h-[42px] rounded-xl grid place-items-center text-BLACK hover:bg-WHITE3 transition-colors"
>
{cartCount > 0 ? (
<span className="absolute top-1 left-1 min-w-[17px] h-[17px] bg-RED text-white rounded-full text-[10.5px] font-bold grid place-items-center px-1 border-2 border-WHITE">
{cartCount}
</span>
) : null}
<ShoppingBag size={21} />
</Link>
<Link
to="/profile"
aria-label="پروفایل"
prefetch="intent"
className="w-[42px] h-[42px] rounded-xl grid place-items-center text-BLACK hover:bg-WHITE3 transition-colors"
>
<span className="w-9 h-9 rounded-full bg-WHITE3 grid place-items-center text-GRAY overflow-hidden">
<User size={20} />
</span>
</Link>
</>
) : (
<Link
to="/login"
prefetch="intent"
className="inline-flex items-center h-10 px-4 rounded-xl bg-VITROWN_BLUE text-white text-[14px] font-bold hover:bg-[#012f6b] transition-colors whitespace-nowrap"
>
ورود / ثبت نام
</Link>
)}
</div>
</div>
</header>
);
};
export default DesktopHeader;

View File

@ -41,7 +41,7 @@ const ExploreTopBar = memo(function ExploreTopBar({
}, [setSearchValue]);
return (
<div className="flex flex-col gap-4 pb-4 pt-[calc(1rem_+_env(safe-area-inset-top))] px-4 w-full sticky top-0 z-30 bg-WHITE">
<div className="flex flex-col gap-4 py-4 px-4 w-full sticky top-0 z-30 bg-WHITE">
<div className="flex items-center justify-center gap-2 rounded-[10px] w-full relative">
<AppInput
inputIcon={Search}

View File

@ -47,7 +47,7 @@ const MainSection = () => {
);
return (
<div className="flex flex-col gap-6 w-full pb-20 lg:max-w-[1320px] lg:mx-auto lg:px-8 lg:pt-6">
<div className="flex flex-col gap-6 w-full pb-20">
<TabContainer
activeTab={activeTab}
setActiveTab={setActiveTab}
@ -64,8 +64,8 @@ const MainSection = () => {
</p>
</div>
) : (
<div className="px-4 lg:px-0 flex flex-col gap-6 w-full">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 w-full">
<div className="px-4 flex flex-col gap-6 w-full">
<div className="grid grid-cols-2 gap-4 w-full">
{categories?.map((category, index) => {
return (
<Link
@ -75,7 +75,7 @@ const MainSection = () => {
>
<img
src={category.imageUrl}
className="w-full h-[120px] lg:h-[180px] object-cover rounded-[15px]"
className="w-full h-[120px] object-cover rounded-[15px]"
alt={category.categoryName}
/>
<p className="text-B16 text-center line-clamp-1 w-full font-bold text-white absolute bottom-2 left-2/4 -translate-x-1/2">
@ -104,9 +104,6 @@ const MainSection = () => {
basePrice: product.basePrice
? parseInt(product.basePrice)
: undefined,
sellerName: product.sellerName,
sellerLogo: product.sellerLogo,
sellerUsername: product.sellerUsername,
};
})}
fetchNextPage={fetchNextPage}

View File

@ -1,74 +1,103 @@
import { memo, useCallback } from "react";
import { Link } from "@remix-run/react";
import { Bell, Search, ShoppingBag } from "lucide-react";
import { useState, useEffect, useMemo, memo, useCallback } from "react";
import logo from "../../assets/logo/SVG-07.svg";
import { useCartCount } from "~/requestHandler/use-cart-hooks";
import { useBadgeCounts } from "~/hooks/useBadgeCounts";
import { useRootData } from "~/hooks/use-root-data";
const HomePageTopBar = memo(() => {
const { user } = useRootData();
const isLoggedIn = !!user?.access_token;
const cartCount = useCartCount();
const { data: badgeCounts } = useBadgeCounts();
const accountUnread =
(badgeCounts?.chat || 0) + (badgeCounts?.orderUpdates || 0);
const [activeTab, setActiveTab] = useState(0);
const tabs = useMemo(() => ["تخفیف‌ها", "برترین فروشگاه‌ها", "برای شما"], []);
// Scroll to section when tab is clicked
const scrollToSection = useCallback((sectionId: string) => {
const section = document.getElementById(sectionId);
if (section) {
section.scrollIntoView({ behavior: "smooth" });
}
}, []);
// Update active tab based on scroll position with throttling
useEffect(() => {
let ticking = false;
const handleScroll = () => {
if (!ticking) {
window.requestAnimationFrame(() => {
const section1 = document.getElementById("section1");
const section2 = document.getElementById("section2");
const section3 = document.getElementById("section3");
if (section1 && section2 && section3) {
const scrollPosition = window.scrollY;
if (scrollPosition >= section3.offsetTop - 100) {
setActiveTab(2);
} else if (scrollPosition >= section2.offsetTop - 100) {
setActiveTab(1);
} else {
setActiveTab(0);
}
}
ticking = false;
});
ticking = true;
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const scrollToTop = useCallback(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, []);
return (
<div className="bg-WHITE sticky top-0 z-30 pt-[env(safe-area-inset-top)]">
<div className="px-4 pt-2.5 pb-3 flex flex-col gap-3">
{/* Brand row */}
<div className="flex items-center gap-2.5">
<button
onClick={scrollToTop}
className="flex items-center gap-2"
aria-label="ویترون"
>
<img src={logo} className="w-7 h-8 object-contain" alt="" />
<b className="text-[21px] font-extrabold text-BLACK tracking-tight">
ویترون
</b>
</button>
<span className="flex-1" />
<Link
to="/profile"
prefetch="intent"
aria-label="اعلان‌ها"
className="relative w-10 h-10 rounded-xl bg-WHITE2 grid place-items-center text-BLACK"
>
{isLoggedIn && accountUnread > 0 ? (
<span className="absolute top-2 left-2.5 w-2 h-2 rounded-full bg-RED border-[1.5px] border-WHITE2" />
) : null}
<Bell size={21} />
</Link>
<Link
to="/cart"
prefetch="intent"
aria-label="سبد خرید"
className="relative w-10 h-10 rounded-xl bg-WHITE2 grid place-items-center text-BLACK"
>
{isLoggedIn && cartCount > 0 ? (
<span className="absolute top-1 left-1 min-w-[16px] h-4 px-1 rounded-full bg-RED text-white text-[10px] font-bold grid place-items-center border-[1.5px] border-WHITE2">
{cartCount > 99 ? "99+" : cartCount}
</span>
) : null}
<ShoppingBag size={21} />
</Link>
</div>
const handleTabClick = useCallback(
(index: number) => {
setActiveTab(index);
scrollToSection(`section${index + 1}`);
},
[scrollToSection]
);
{/* Search-first */}
<Link
to="/explore"
prefetch="intent"
className="flex items-center gap-2.5 h-[46px] px-3.5 bg-WHITE3 rounded-2xl text-GRAY"
return (
<div className="flex gap-2 flex-row h-[48px] w-full bg-WHITE items-center px-4 sticky top-0 z-30">
<div className="flex relative items-center gap-2 justify-center w-full">
<div
className="absolute right-0"
onClick={() => scrollToTop()}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
scrollToTop();
}
}}
>
<Search size={19} className="shrink-0" />
<span className="text-R14">جستجوی محصول، برند یا فروشگاه</span>
</Link>
<img src={logo} className="w-8 h-8 object-contain" alt="logo" />
</div>
<div className="flex gap-4 justify-center">
{tabs.map((tab, index) => (
<div
key={index}
onClick={() => handleTabClick(index)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleTabClick(index);
}
}}
role="button"
tabIndex={index}
className="flex items-center"
>
<p
className={`text-B12 border-b-[2px] transition-all duration-300 ease-in-out py-1 ${
index === activeTab
? "text-BLACK border-BLACK font-bold"
: "text-GRAY border-transparent"
}`}
>
{tab}
</p>
</div>
))}
</div>
</div>
</div>
);

View File

@ -1,67 +0,0 @@
import { useEffect, useState } from "react";
import { Bell, X } from "lucide-react";
import { useRootData } from "~/hooks/use-root-data";
import { usePushNotifications } from "~/hooks/usePushNotifications";
const DISMISS_KEY = "push_banner_dismissed";
/**
* One-time, dismissible bar prompting logged-in users to enable web push.
* Shown only when: logged in, push is supported, permission not yet decided,
* and the user hasn't dismissed or already subscribed. A permanent toggle in
* settings lets them change their mind later.
*/
export function PushPermissionBanner() {
const { user } = useRootData();
const { supported, permission, isSubscribed, subscribe, busy } =
usePushNotifications();
const [visible, setVisible] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const dismissed = localStorage.getItem(DISMISS_KEY) === "1";
setVisible(
!!user?.access_token &&
supported &&
permission === "default" &&
!isSubscribed &&
!dismissed
);
}, [user?.access_token, supported, permission, isSubscribed]);
if (!visible) return null;
const dismiss = () => {
localStorage.setItem(DISMISS_KEY, "1");
setVisible(false);
};
const enable = async () => {
const ok = await subscribe();
// Whether granted or denied, don't nag again from the banner.
localStorage.setItem(DISMISS_KEY, "1");
setVisible(false);
return ok;
};
return (
<div className="fixed bottom-[calc(58px+env(safe-area-inset-bottom))] lg:bottom-4 left-1/2 -translate-x-1/2 z-40 w-[92%] max-w-md lg:max-w-sm">
<div className="flex items-center gap-3 rounded-2xl bg-BLACK text-white shadow-lg px-4 py-3">
<Bell size={20} className="shrink-0" />
<p className="text-[13px] leading-5 flex-1">
اعلانها را فعال کنید تا از پیامها و وضعیت سفارشها باخبر شوید.
</p>
<button
onClick={enable}
disabled={busy}
className="shrink-0 rounded-xl bg-VITROWN_BLUE px-3 py-1.5 text-[13px] font-bold disabled:opacity-60"
>
{busy ? "..." : "فعال‌سازی"}
</button>
<button onClick={dismiss} aria-label="بستن" className="shrink-0 opacity-70">
<X size={18} />
</button>
</div>
</div>
);
}

View File

@ -1,50 +0,0 @@
import { usePushNotifications } from "~/hooks/usePushNotifications";
/**
* A settings row with a switch to enable/disable web push notifications.
* Mirrors the existing theme-toggle row styling on the settings pages.
*/
export function PushSettingToggle({ className = "" }: { className?: string }) {
const { supported, permission, isSubscribed, subscribe, disable, busy } =
usePushNotifications();
const blocked = permission === "denied";
const checked = isSubscribed && permission === "granted";
const onToggle = () => {
if (busy || blocked) return;
if (checked) {
disable();
} else {
subscribe();
}
};
return (
<div className={className}>
<div className="flex w-full items-center justify-between">
<div className="flex flex-col gap-1">
<p className="text-R12 lg:text-B16 font-bold">اعلانها</p>
<p className="text-R10 lg:text-R12 text-GRAY">
{blocked
? "اعلان‌ها در مرورگر مسدود شده‌اند. از تنظیمات مرورگر اجازه دهید."
: !supported
? "مرورگر شما از اعلان پشتیبانی نمی‌کند."
: "دریافت اعلان برای پیام‌ها و وضعیت سفارش‌ها"}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={onToggle}
disabled={busy || blocked || !supported}
className="sr-only peer"
aria-label="اعلان‌ها"
/>
<div className="w-11 h-6 bg-WHITE2 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-WHITE after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-WHITE after:border-GRAY3 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-VITROWN_BLUE border peer-disabled:opacity-50"></div>
</label>
</div>
</div>
);
}

View File

@ -62,8 +62,8 @@ interface AddToCartButtonProps {
const AddToCartButton = ({ isOutOfStock, onClick }: AddToCartButtonProps) => (
<Button
variant="dark"
size="xl"
className="flex-1 h-[52px] rounded-[14px] text-B14 font-bold flex items-center justify-center gap-2 bg-VITROWN_BLUE text-white"
size="lg"
className="font-bold flex items-center gap-2 bg-VITROWN_BLUE text-white"
disabled={isOutOfStock}
onClick={onClick}
>
@ -71,7 +71,7 @@ const AddToCartButton = ({ isOutOfStock, onClick }: AddToCartButtonProps) => (
<span className="text-B14 font-bold">ناموجود</span>
) : (
<>
<ShoppingCart size={22} />
<ShoppingCart size={24} />
افزودن به سبد خرید
</>
)}
@ -194,12 +194,19 @@ export const BottomBar = ({
return (
<>
<div className="max-w-md mx-auto fixed gap-3 items-center bottom-[calc(50px+env(safe-area-inset-bottom))] left-0 right-0 border-t border-inner-border bg-WHITE p-4 z-50 flex flex-row lg:static lg:max-w-none lg:mx-0 lg:border lg:rounded-xl lg:mt-6 lg:z-auto">
<div className="max-w-md mx-auto fixed gap-2 bottom-[calc(50px+env(safe-area-inset-bottom))] left-0 right-0 border-t border-inner-border bg-WHITE p-4 z-50 flex flex-row">
<div className="flex w-full items-center justify-between">
<div className="flex flex-col gap-1">
<AddToCartButton
isOutOfStock={isOutOfStock}
onClick={handleAddToCart}
/>
</div>
<PriceDisplay
currentPrice={currentPrice}
currentDiscountPrice={currentDiscountPrice}
/>
<AddToCartButton isOutOfStock={isOutOfStock} onClick={handleAddToCart} />
</div>
</div>
<CartDrawer

View File

@ -1,3 +1,5 @@
import { Check } from "lucide-react";
interface ColorSelectorProps {
colors: string[] | undefined;
selectedColor: string | null;
@ -10,23 +12,24 @@ export const ColorSelector = ({
setSelectedColor,
}: ColorSelectorProps) => {
return (
<div className="flex flex-col gap-4 mt-10 px-4 lg:px-0">
<div className="flex flex-col gap-4 mt-10 px-4">
<p className="text-B18 font-bold">انتخاب رنگ</p>
<div className="gap-4 flex">
{colors?.map((enColor: string, index: number) => {
const isSelected = selectedColor === enColor;
return (
<button
<div
key={index}
type="button"
aria-label={`رنگ ${index + 1}`}
aria-pressed={isSelected}
className={`w-9 h-9 rounded-full border-2 border-WHITE transition-shadow ring-offset-2 ring-offset-WHITE ${
isSelected ? "ring-2 ring-BLACK" : "ring-1 ring-WHITE3"
}`}
className="w-10 border h-10 rounded-md flex items-center justify-center"
style={{ backgroundColor: enColor }}
onClick={() => setSelectedColor(enColor)}
/>
onKeyDown={() => {}}
role="button"
tabIndex={0}
>
{selectedColor === enColor && (
<Check size={24} className="text-WHITE" />
)}
</div>
);
})}
</div>

View File

@ -19,14 +19,11 @@ export const ContactButton = ({ product }: { product: ProductDetail }) => {
return;
}
if (!product.seller?.id) return;
createChatThread(
{ participantId: product.seller.id, productId: product.id },
{
createChatThread(product.seller.id, {
onSuccess: (threadData) => {
navigate(`/profile/threads/${threadData.id}`);
},
}
);
});
};
return (
<>

View File

@ -4,6 +4,7 @@ import {
MessageSquare,
Send,
Share2,
Star,
X,
Loader2,
Copy,
@ -19,6 +20,8 @@ import {
} from "~/components/ui/drawer";
import { Skeleton } from "~/components/ui/skeleton";
import {
useServiceGetProductRating,
useServiceGetComments,
useServiceBookmarkProduct,
useServiceDeleteBookmark,
useServiceCheckBookmark,
@ -59,6 +62,9 @@ export const ProductActions = ({ productId }: { productId: string }) => {
const { toast } = useToast();
const { user } = useRootData();
const { data: ratingData, isLoading: ratingLoading } =
useServiceGetProductRating(productId || "");
const { data: commentsData } = useServiceGetComments(1, productId || "");
const { data: bookmarkData, isLoading: isCheckingBookmark } =
useServiceCheckBookmark(productId || "");
const { mutate: bookmarkProduct, isPending: isBookmarking } =
@ -67,6 +73,8 @@ export const ProductActions = ({ productId }: { productId: string }) => {
useServiceDeleteBookmark();
const isBookmarked = bookmarkData?.bookmarked === true;
const averageRating = ratingData?.averageRating || 0;
const commentCount = commentsData?.count || 0;
const handleBookmark = () => {
// Check if user is logged in
@ -152,58 +160,53 @@ export const ProductActions = ({ productId }: { productId: string }) => {
}
};
const actionBtn =
"flex items-center justify-center cursor-pointer transition-colors lg:w-10 lg:h-10 lg:rounded-full lg:border lg:border-WHITE3 lg:hover:bg-WHITE2";
return (
<>
<div className="flex w-full mt-4 px-4 lg:mt-0 lg:px-0">
<div className="flex gap-8 lg:gap-2.5">
<button
type="button"
onClick={handleBookmark}
aria-label="ذخیره"
className={actionBtn}
>
<div className="flex w-full mt-4 px-4 justify-between">
<div className="flex gap-8">
{isCheckingBookmark ? (
<Skeleton className="h-6 w-6 rounded-full" />
) : isBookmarked ? (
isDeletingBookmark ? (
<>
{isDeletingBookmark ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Bookmark size={20} className="text-PRIMARY fill-current" />
)
) : isBookmarking ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<BookmarkMinus size={20} />
<Bookmark
size={20}
className="text-PRIMARY fill-current cursor-pointer"
onClick={handleBookmark}
/>
)}
</button>
<button
type="button"
</>
) : (
<>
{isBookmarking ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<BookmarkMinus
size={20}
className="cursor-pointer"
onClick={handleBookmark}
/>
)}
</>
)}
<Share2
size={20}
className="cursor-pointer"
onClick={() => setIsShareDrawerOpen(true)}
aria-label="اشتراک‌گذاری"
className={actionBtn}
>
<Share2 size={20} />
</button>
<button
type="button"
/>
<Layers
size={20}
className="cursor-pointer"
onClick={handleAddToSet}
aria-label="افزودن به ست"
className={actionBtn}
>
<Layers size={20} />
</button>
/>
<div className="relative">
<button
type="button"
<Sparkles
size={20}
className="cursor-pointer animate-pulse text-PRIMARY"
onClick={handleOpenAIModal}
aria-label="پرو با هوش مصنوعی"
className={actionBtn}
>
<Sparkles size={20} className="animate-pulse text-PRIMARY" />
</button>
/>
{showAIFeatureTooltip && (
<div className="absolute bottom-full right-0 mb-2 z-50 animate-bounce">
<div className="relative bg-gradient-to-r from-purple-600 to-pink-500 text-WHITE px-3 py-2 rounded-lg shadow-xl whitespace-nowrap border border-white/20">
@ -229,6 +232,17 @@ export const ProductActions = ({ productId }: { productId: string }) => {
)}
</div>
</div>
<div className="flex gap-3 items-center">
<div className="flex gap-1 items-center">
{!ratingLoading && averageRating > 0 && (
<>
<p className="text-B12 font-bold text-GRAY">({commentCount})</p>
<p className="text-B12 font-bold">{averageRating.toFixed(1)}</p>
<Star className="text-BLACK fill-current" size={20} />
</>
)}
</div>
</div>
</div>
<Drawer open={isShareDrawerOpen} onOpenChange={setIsShareDrawerOpen}>

View File

@ -1,33 +0,0 @@
import { AlignRight } from "lucide-react";
interface ProductDescriptionProps {
description?: string | null;
}
/**
* Full product description block. Preserves the seller's line breaks (bullet
* lists from Instagram/WooCommerce imports come in as `\n`-separated lines),
* with a proper heading so it reads as its own section on the detail page.
*
* Renders nothing when no description is set the section header shouldn't
* appear on products the seller hasn't filled out yet.
*/
export function ProductDescription({ description }: ProductDescriptionProps) {
const body = (description || "").trim();
if (!body) return null;
return (
<section className="w-full px-4 lg:px-0 my-6">
<div className="flex items-center gap-2 mb-3">
<AlignRight size={18} className="text-BLACK" />
<h2 className="text-B16 lg:text-B20 font-bold">توضیحات محصول</h2>
</div>
<div
className="text-R14 lg:text-[15px] leading-8 text-BLACK2 whitespace-pre-line"
dir="rtl"
>
{body}
</div>
</section>
);
}

View File

@ -1,9 +1,9 @@
import { useState, useEffect, useMemo, memo } from "react";
import {
ProductHeader,
ProductImageCarousel,
ProductActions,
ProductInfo,
ProductDescription,
ColorSelector,
SizeSelector,
ContactButton,
@ -11,40 +11,11 @@ import {
BottomBar,
} from "./index";
import { ProductDetail as ProductDetailType } from "../../../src/api/types";
import {
ArrowRight,
Eye,
EyeOff,
Heart,
Sparkles,
Star,
ShoppingCart,
Truck,
RotateCcw,
ShieldCheck,
MessageCircle,
} from "lucide-react";
import { Eye, EyeOff } from "lucide-react";
import { Button } from "../ui/button";
import { useNavigate, Link } from "@remix-run/react";
import { useNavigate } from "@remix-run/react";
import { useSimilarProductsInfinite } from "~/requestHandler/use-product-hooks";
import MondrianProductList from "../MondrianProductList";
import SellerLogo from "../SellerLogo";
import {
useServiceGetProductRating,
useServiceGetComments,
useServiceCheckBookmark,
useServiceBookmarkProduct,
useServiceDeleteBookmark,
} from "~/utils/RequestHandler";
import { useAddToCart } from "~/requestHandler/use-cart-hooks";
import { useCreateChatThread } from "~/requestHandler/use-chat-hooks";
import { useRecordProductView } from "~/requestHandler/use-view-hooks";
import { findVariantId, formatPrice, safeGoBack } from "~/utils/helpers";
import { useToast } from "~/hooks/use-toast";
import { useRootData } from "~/hooks/use-root-data";
import { LoginRequiredDialog } from "../LoginRequiredDialog";
import AIPromoteModal from "../AIPromoteModal";
import { SizeGuideDialog } from "./SizeGuideDialog";
interface ProductDetailViewProps {
product: ProductDetailType;
@ -79,10 +50,6 @@ export const ProductDetailView = memo(function ProductDetailView({
setProductActiveStatus(product.isActive || false);
}, [product.isActive]);
// Record a product view for the recommendation system (skip the owner's own
// product; only tracks authenticated users, deduped per product).
useRecordProductView(product.id, { enabled: !isOwner });
// Memoize available colors and sizes
const { availableColors, availableSizes } = useMemo(
() => ({
@ -131,63 +98,25 @@ export const ProductDetailView = memo(function ProductDetailView({
[stockedVariants.length, inStockColors.length, inStockSizes.length]
);
// Effective pricing for a variant: original price + sale price (or null when
// there's no real discount). Mirrors how the list/tile serializer computes
// discount_price so the detail page stays consistent with the tiles.
const pricingOf = (v?: (typeof stockedVariants)[number] | null) => ({
price: v?.price || product.basePrice,
discountPrice:
v?.discountPrice &&
v.discountPrice !== "0" &&
// Only surface as a "discount" when it's a real saving. A value equal
// to (or somehow larger than) `price` is stale/mis-entered state, not a
// discount — sellers occasionally end up with matching numbers when
// they set "no discount" on edit, and rendering a strikethrough on that
// fakes a promo the seller didn't intend.
Number(v.discountPrice) < Number(v.price)
? v.discountPrice
: null,
});
const [selectedColor, setSelectedColor] = useState<string | null>(
hasColorVariants && inStockColors.length > 0
? (inStockColors[0].info as unknown as { detail: string })?.detail
: null
);
// Cheapest in-stock variant by final (post-discount) price — this is the
// "starting from" price the product tile shows. Used as the default
// selection and as the fallback when no exact color/size combo is matched.
const defaultVariant = useMemo(() => {
const finalOf = (v: (typeof stockedVariants)[number]) => {
const pr = pricingOf(v);
return Number(pr.discountPrice ?? pr.price ?? 0);
};
const pool =
stockedVariants.length > 0 ? stockedVariants : product.variants ?? [];
if (pool.length === 0) return null;
return pool.reduce((best, v) => (finalOf(v) < finalOf(best) ? v : best));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stockedVariants, product.variants]);
const [selectedColor, setSelectedColor] = useState<string | null>(() => {
if (!hasColorVariants || inStockColors.length === 0) return null;
const colorVal = defaultVariant?.resolvedAttributes?.COLOR;
const match =
inStockColors.find((c) => c.value === colorVal) ?? inStockColors[0];
return (match.info as unknown as { detail: string })?.detail ?? null;
});
const [selectedSize, setSelectedSize] = useState<string | null>(() => {
if (!hasSizeVariants || inStockSizes.length === 0) return null;
return defaultVariant?.resolvedAttributes?.SIZE ?? inStockSizes[0].value;
});
const [selectedSize, setSelectedSize] = useState<string | null>(
hasSizeVariants && inStockSizes.length > 0 ? inStockSizes[0].value : null
);
// Handle product status change
const handleProductStatusChange = (newStatus: boolean) => {
setProductActiveStatus(newStatus);
};
const [currentPrice, setCurrentPrice] = useState(
() => pricingOf(defaultVariant).price
);
const [currentPrice, setCurrentPrice] = useState(product.basePrice);
const [currentDiscountPrice, setCurrentDiscountPrice] = useState<
string | null
>(() => pricingOf(defaultVariant).discountPrice);
>(null);
const [currentStock, setCurrentStock] = useState<number | null>(null);
useEffect(() => {
@ -223,29 +152,36 @@ export const ProductDetailView = memo(function ProductDetailView({
});
if (matchingVariant) {
const pr = pricingOf(matchingVariant);
setCurrentPrice(pr.price);
setCurrentDiscountPrice(pr.discountPrice);
setCurrentPrice(matchingVariant.price || product.basePrice);
setCurrentDiscountPrice(
matchingVariant.discountPrice &&
matchingVariant.discountPrice !== "0" &&
Number(matchingVariant.discountPrice) !==
Number(matchingVariant.price)
? matchingVariant.discountPrice
: null
);
setCurrentStock(matchingVariant.stock || null);
} else if (
!hasColorVariants &&
!hasSizeVariants &&
stockedVariants.length > 0
) {
// No color/size dimensions → use the first available variant.
} else {
// If no matching variant found, but we have variants and no color/size requirements
// Use the first available variant (for products with no color/size variants)
if (!hasColorVariants && !hasSizeVariants && stockedVariants.length > 0) {
const firstVariant = stockedVariants[0];
const pr = pricingOf(firstVariant);
setCurrentPrice(pr.price);
setCurrentDiscountPrice(pr.discountPrice);
setCurrentPrice(firstVariant.price || product.basePrice);
setCurrentDiscountPrice(
firstVariant.discountPrice &&
firstVariant.discountPrice !== "0" &&
Number(firstVariant.discountPrice) !== Number(firstVariant.price)
? firstVariant.discountPrice
: null
);
setCurrentStock(firstVariant.stock || null);
} else {
// Color/size not fully matched yet: show the cheapest variant's price +
// sale (same as the product tile) instead of basePrice with no discount.
const pr = pricingOf(defaultVariant);
setCurrentPrice(pr.price);
setCurrentDiscountPrice(pr.discountPrice);
setCurrentPrice(product.basePrice);
setCurrentDiscountPrice(null);
setCurrentStock(null);
}
}
}, [
selectedColor,
selectedSize,
@ -254,124 +190,12 @@ export const ProductDetailView = memo(function ProductDetailView({
hasColorVariants,
hasSizeVariants,
stockedVariants,
defaultVariant,
]);
const navigate = useNavigate();
// --- Desktop buy/actions state & data (overlays + info column) ---
const { user } = useRootData();
const { toast } = useToast();
const [qty, setQty] = useState(1);
const [isAIOpen, setIsAIOpen] = useState(false);
const [isLoginOpen, setIsLoginOpen] = useState(false);
const [isSizeGuideOpen, setIsSizeGuideOpen] = useState(false);
const hasSizeChart = !!(
product.sizeChart &&
product.sizeChart.columns?.length &&
product.sizeChart.rows?.length
);
const { data: ratingData } = useServiceGetProductRating(product.id || "");
const { data: commentsData } = useServiceGetComments(1, product.id || "");
const { data: bookmarkData } = useServiceCheckBookmark(product.id || "");
const { mutate: bookmarkProduct } = useServiceBookmarkProduct();
const { mutate: deleteBookmark } = useServiceDeleteBookmark();
const addToCartMutation = useAddToCart();
const { mutate: createChatThread } = useCreateChatThread();
const isBookmarked = bookmarkData?.bookmarked === true;
const avgRating = ratingData?.averageRating || 0;
const reviewCount = commentsData?.count || 0;
const isOutOfStock = currentStock === null || currentStock <= 0;
const selectedColorValue =
inStockColors.find(
(color) =>
(color.info as unknown as { detail: string })?.detail === selectedColor
)?.value || null;
const handleToggleBookmark = () => {
if (!user?.access_token) {
setIsLoginOpen(true);
return;
}
if (isBookmarked) deleteBookmark(product.id || "");
else bookmarkProduct(product.id || "");
};
const handleDesktopAddToCart = async () => {
if (!user?.access_token) {
setIsLoginOpen(true);
return;
}
if (isOutOfStock) return;
const variantId = findVariantId(product, selectedColorValue, selectedSize);
if (!variantId) {
toast({
title: "خطا",
description: "محصول با ویژگی‌های انتخابی شما یافت نشد",
variant: "destructive",
});
return;
}
try {
await addToCartMutation.mutateAsync({ variantId, quantity: qty });
toast({
title: "سبد خرید",
description: "محصول با موفقیت به سبد خرید اضافه شد",
});
} catch (e) {
toast({
title: "خطا",
description: "افزودن به سبد خرید با مشکل مواجه شد",
variant: "destructive",
});
}
};
const handleChat = () => {
if (!user?.access_token) {
setIsLoginOpen(true);
return;
}
if (!product.seller?.id) return;
createChatThread(
{ participantId: product.seller.id, productId: product.id },
{
onSuccess: (t) => navigate(`/profile/threads/${t.id}`),
}
);
};
return (
<div className="flex flex-col pb-20 lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8 lg:pb-12">
{/* Desktop breadcrumb */}
<nav className="hidden lg:flex items-center gap-2 text-[13px] text-GRAY mt-4 mb-4">
<Link to="/" className="hover:text-BLACK">
خانه
</Link>
<span>/</span>
{product.seller?.username ? (
<>
<Link
to={`/seller/${product.seller.username}`}
className="hover:text-BLACK"
>
{product.seller.username}
</Link>
<span>/</span>
</>
) : null}
<span className="text-BLACK truncate max-w-[320px]">
{product.title}
</span>
</nav>
{/* Top section: gallery | info (two columns on desktop) */}
<div className="lg:grid lg:grid-cols-2 lg:gap-10 lg:items-start">
{/* Gallery */}
<div className="relative lg:rounded-[14px] lg:overflow-hidden lg:shadow-sm lg:sticky lg:top-[88px] lg:aspect-[3/4] lg:bg-WHITE2 lg:[&_img]:!object-contain lg:[&_video]:!object-contain">
<div className="flex flex-col pb-20">
{!isOwner && <ProductHeader product={product} />}
<ProductImageCarousel
images={product.imageUrls || []}
productId={product.id}
@ -380,97 +204,13 @@ export const ProductDetailView = memo(function ProductDetailView({
borderRadius={0}
isActive={productActiveStatus}
controlsIsOn={true}
showArrows={true}
onStatusChange={handleProductStatusChange}
isChangeStatusDialogOpen={isChangeStatusDialogOpen}
setIsChangeStatusDialogOpen={setIsChangeStatusDialogOpen}
/>
{!isOwner && (
<>
{/* Save (desktop overlay) */}
<button
type="button"
onClick={handleToggleBookmark}
aria-label="ذخیره"
className="hidden lg:flex absolute top-3.5 left-3.5 z-20 w-10 h-10 rounded-full bg-WHITE/90 shadow-md items-center justify-center hover:bg-WHITE transition-colors"
>
<Heart
size={20}
className={isBookmarked ? "fill-RED text-RED" : "text-BLACK"}
/>
</button>
{/* AI try-on (desktop overlay) */}
<button
type="button"
onClick={() => setIsAIOpen(true)}
className="hidden lg:flex absolute bottom-4 left-1/2 -translate-x-1/2 z-20 items-center gap-2 px-5 h-11 rounded-full bg-BLACK/85 backdrop-blur text-white text-sm font-bold hover:bg-BLACK transition-colors"
>
<Sparkles size={18} />
آنلاین پرو کن
</button>
</>
)}
{/* Floating back button (mobile) */}
{!isOwner && (
<button
type="button"
onClick={() => safeGoBack(navigate)}
aria-label="بازگشت"
className="lg:hidden 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>
{/* Info */}
<div className="flex flex-col">
{/* Brand row (mobile) — moved out of the top bar, sits below image */}
{!isOwner && product.seller?.username ? (
<div className="lg:hidden flex items-center gap-3 px-4 mt-4">
<Link
to={`/seller/${product.seller.username}`}
className="flex items-center gap-2.5 min-w-0"
>
<SellerLogo
size="sm"
src={product.seller?.storeLogo}
alt="seller"
/>
<span className="font-bold text-[14px] truncate">
{product.seller.username}
</span>
</Link>
<button
type="button"
onClick={handleChat}
className="ms-auto shrink-0 h-9 px-4 rounded-full border border-BLACK text-BLACK text-[12px] font-bold flex items-center gap-1.5"
>
<MessageCircle size={16} />
ارسال پیام
</button>
</div>
) : null}
{/* Seller (desktop) */}
{!isOwner && product.seller?.username ? (
<Link
to={`/seller/${product.seller.username}`}
className="hidden lg:flex items-center gap-2.5 mb-1"
>
<SellerLogo
size="sm"
src={product.seller?.storeLogo}
alt="seller"
/>
<span className="font-bold text-[15px]">
{product.seller.username}
</span>
</Link>
) : null}
{/* Inactive Product Status */}
{!productActiveStatus && isOwner ? (
<div className="flex gap-1 px-4 w-full justify-between items-center mt-2 lg:px-0">
<div className="flex gap-1 px-4 w-full justify-between items-center mt-2">
<div className="flex items-center w-full gap-2 p-2 bg-WHITE3 rounded-lg">
<EyeOff size={16} className="text-BLACK2 font-bold" />
<span className="text-B12 font-bold text-BLACK2">
@ -487,51 +227,8 @@ export const ProductDetailView = memo(function ProductDetailView({
</Button>
</div>
) : !isOwner ? (
<div className="lg:hidden">
<ProductActions productId={product.id || ""} />
</div>
) : null}
{/* Desktop: title + rating + price */}
<div className="hidden lg:flex flex-col gap-3 mt-1">
<h1 className="text-[26px] font-extrabold leading-snug">
{product.title}
</h1>
<div className="flex items-center gap-2.5 text-[13.5px] text-BLACK2">
<span className="flex items-center">
{[1, 2, 3, 4, 5].map((i) => (
<Star
key={i}
size={15}
className={
i <= Math.round(avgRating)
? "fill-BLACK text-BLACK"
: "text-GRAY3"
}
/>
))}
</span>
{avgRating > 0 ? (
<b className="text-BLACK">{avgRating.toFixed(1)}</b>
) : null}
<span className="w-1 h-1 rounded-full bg-GRAY2" />
<span>{reviewCount} دیدگاه</span>
</div>
<div className="flex items-center gap-3 mt-1">
<b className="text-[28px] font-extrabold">
{formatPrice(currentDiscountPrice || currentPrice)}
<span className="text-[15px] font-semibold mr-1">تومان</span>
</b>
{currentDiscountPrice ? (
<del className="text-GRAY text-[17px]">
{formatPrice(currentPrice)}
</del>
) : null}
</div>
</div>
{/* Mobile: title + description */}
<div className="lg:hidden">
<ProductInfo
productDiscountPercent={Number(
!currentDiscountPrice ||
@ -546,37 +243,13 @@ export const ProductDetailView = memo(function ProductDetailView({
)}
product={{ ...product, isActive: productActiveStatus }}
/>
</div>
{/* Mobile: rating (only when > 0) */}
{!isOwner && avgRating > 0 ? (
<div className="lg:hidden flex items-center gap-2 px-4 mt-3 text-[13px] text-BLACK2">
<span className="flex items-center">
{[1, 2, 3, 4, 5].map((i) => (
<Star
key={i}
size={15}
className={
i <= Math.round(avgRating)
? "fill-BLACK text-BLACK"
: "text-GRAY3"
}
/>
))}
</span>
<b className="text-BLACK">{avgRating.toFixed(1)}</b>
<span className="w-1 h-1 rounded-full bg-GRAY2" />
<span>{reviewCount} دیدگاه</span>
</div>
) : null}
{hasAnyStock ? (
<>
{hasColorVariants && (
<ColorSelector
colors={inStockColors.map(
(color) =>
(color.info as unknown as { detail: string })?.detail
(color) => (color.info as unknown as { detail: string })?.detail
)}
selectedColor={selectedColor}
setSelectedColor={setSelectedColor}
@ -588,21 +261,16 @@ export const ProductDetailView = memo(function ProductDetailView({
sizes={inStockSizes.map((size) => size.value || "")}
selectedSize={selectedSize}
setSelectedSize={setSelectedSize}
onSizeGuide={
hasSizeChart
? () => setIsSizeGuideOpen(true)
: undefined
}
/>
)}
</>
) : (
<div className="flex flex-col gap-4 mt-10 px-4 lg:px-0">
<div className="flex flex-col gap-4 mt-10 px-4">
<p className="text-B18 font-bold">موجود نیست</p>
</div>
)}
{isOwner && (
<div className="w-full p-4 flex justify-center items-center lg:justify-start lg:px-0">
<div className="w-full p-4 flex justify-center items-center">
<Button
variant="dark"
size="sm"
@ -616,127 +284,16 @@ export const ProductDetailView = memo(function ProductDetailView({
</Button>
</div>
)}
{/* Desktop buy row + assurances */}
{!isOwner && (
<div className="hidden lg:block">
<div className="flex items-center gap-3 mt-6">
<button
type="button"
onClick={handleChat}
aria-label="گفتگو با فروشنده"
className="w-12 h-12 shrink-0 rounded-xl border border-GRAY3 flex items-center justify-center text-BLACK hover:bg-WHITE2 transition-colors"
>
<MessageCircle size={22} />
</button>
<Button
variant="dark"
size="lg"
className="flex-1 bg-BLACK text-white font-bold gap-2"
disabled={isOutOfStock || addToCartMutation.isPending}
onClick={handleDesktopAddToCart}
>
{isOutOfStock ? (
"ناموجود"
) : (
<>
<ShoppingCart size={20} />
افزودن به سبد خرید
</>
)}
</Button>
<div className="flex items-center shrink-0 border border-GRAY3 rounded-xl overflow-hidden h-12">
<button
type="button"
onClick={() => setQty((q) => q + 1)}
className="w-11 h-full hover:bg-WHITE2 text-lg"
aria-label="افزایش"
>
+
</button>
<span className="w-10 text-center font-bold">{qty}</span>
<button
type="button"
onClick={() => setQty((q) => Math.max(1, q - 1))}
className="w-11 h-full hover:bg-WHITE2 text-lg"
aria-label="کاهش"
>
</button>
</div>
</div>
<div className="grid grid-cols-3 gap-3 mt-6">
<div className="flex items-start gap-2.5 bg-WHITE2 rounded-xl p-3.5">
<Truck size={22} className="shrink-0 mt-0.5" />
<div>
<b className="text-[13px] block">ارسال سریع</b>
<small className="text-GRAY text-[11.5px]">
۲ تا ۴ روز کاری
</small>
</div>
</div>
<div className="flex items-start gap-2.5 bg-WHITE2 rounded-xl p-3.5">
<RotateCcw size={22} className="shrink-0 mt-0.5" />
<div>
<b className="text-[13px] block">۷ روز بازگشت</b>
<small className="text-GRAY text-[11.5px]">
ضمانت تعویض کالا
</small>
</div>
</div>
<div className="flex items-start gap-2.5 bg-WHITE2 rounded-xl p-3.5">
<ShieldCheck size={22} className="shrink-0 mt-0.5" />
<div>
<b className="text-[13px] block">پرداخت امن</b>
<small className="text-GRAY text-[11.5px]">
تضمین اصالت کالا
</small>
</div>
</div>
</div>
</div>
)}
{/* Buy box (fixed bottom bar — mobile only) */}
{!isOwner && (
<div className="lg:hidden">
<BottomBar
currentPrice={currentPrice}
currentDiscountPrice={currentDiscountPrice}
currentStock={currentStock}
product={product}
selectedColor={
inStockColors.find(
(color) =>
(color.info as unknown as { detail: string })?.detail ===
selectedColor
)?.value || null
}
selectedSize={selectedSize}
/>
</div>
)}
</div>
</div>
{/* Full-width below the fold: description, contact, reviews, similar */}
{productActiveStatus && (
<>
<ProductDescription description={product.description} />
{!isOwner && (
<div className="lg:hidden">
<ContactButton product={product} />
</div>
)}
{!isOwner && <ContactButton product={product} />}
<ReviewSection productId={product.id || ""} isOwner={isOwner} />
{/* Similar Products Section */}
{!isOwner && (
<section className="w-full gap-2 flex flex-col max-w-[100vw] my-6 lg:max-w-none">
<div className="w-full px-4 lg:px-0">
<p className="text-B16 lg:text-B24 font-bold">محصولات مشابه</p>
<p className="text-R14 text-GRAY">
محصولات مرتبط که ممکن است بپسندید
</p>
<section className="w-full gap-2 flex flex-col max-w-[100vw] my-6">
<div className="w-full px-4">
<p className="text-B16 font-bold">محصولات مشابه</p>
<p className="text-R14">محصولات مرتبط که ممکن است بپسندید</p>
</div>
<MondrianProductList
products={
@ -766,25 +323,23 @@ export const ProductDetailView = memo(function ProductDetailView({
/>
</section>
)}
{/* Bottom bar handles variant selection and pricing */}
</>
)}
{/* Desktop overlay/buy modals */}
<LoginRequiredDialog
loginTitle="برای ادامه ابتدا باید وارد حساب کاربری خود شوید."
isOpen={isLoginOpen}
onOpenChange={setIsLoginOpen}
/>
<AIPromoteModal
isOpen={isAIOpen}
onClose={() => setIsAIOpen(false)}
productId={product.id || ""}
/>
{hasSizeChart && product.sizeChart && (
<SizeGuideDialog
isOpen={isSizeGuideOpen}
onOpenChange={setIsSizeGuideOpen}
sizeChart={product.sizeChart}
{!isOwner && (
<BottomBar
currentPrice={currentPrice}
currentDiscountPrice={currentDiscountPrice}
currentStock={currentStock}
product={product}
selectedColor={
inStockColors.find(
(color) =>
(color.info as unknown as { detail: string })?.detail ===
selectedColor
)?.value || null
}
selectedSize={selectedSize}
/>
)}
</div>

View File

@ -24,14 +24,11 @@ export const ProductHeader = ({ product }: ProductHeaderProps) => {
return;
}
if (!product.seller?.id) return;
createChatThread(
{ participantId: product.seller.id, productId: product.id },
{
createChatThread(product.seller.id, {
onSuccess: (threadData) => {
navigate(`/profile/threads/${threadData.id}`);
},
}
);
});
};
return (

View File

@ -25,8 +25,6 @@ import {
EyeOff,
Eye,
ArrowRight,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import {
useDeleteProduct,
@ -46,7 +44,6 @@ interface ProductImageCarouselProps {
isOwner?: boolean;
isActive?: boolean;
controlsIsOn?: boolean;
showArrows?: boolean;
onStatusChange?: (newStatus: boolean) => void;
isChangeStatusDialogOpen?: boolean;
setIsChangeStatusDialogOpen?: (open: boolean) => void;
@ -60,7 +57,6 @@ export const ProductImageCarousel = ({
height,
borderRadius,
controlsIsOn = false,
showArrows = false,
isOwner = false,
isActive = true,
onStatusChange,
@ -163,15 +159,13 @@ export const ProductImageCarousel = ({
<>
<button
onClick={handleSettingsClick}
style={{ top: "calc(env(safe-area-inset-top, 0px) + 12px)" }}
className="absolute left-2 z-10 bg-DARK_OVERLAY/90 text-WHITE2 rounded-full h-9 w-9 flex items-center justify-center hover:bg-DARK_OVERLAY_HOVER transition-colors"
className="absolute top-3 left-2 z-10 bg-DARK_OVERLAY/90 text-WHITE2 rounded-full h-9 w-9 flex items-center justify-center hover:bg-DARK_OVERLAY_HOVER transition-colors"
>
<EllipsisVertical size={20} className="text-WHITE2" />
</button>
<button
onClick={() => safeGoBack(navigate)}
style={{ top: "calc(env(safe-area-inset-top, 0px) + 12px)" }}
className="absolute right-2 z-10 bg-DARK_OVERLAY/90 text-WHITE2 rounded-full h-9 w-9 flex items-center justify-center hover:bg-DARK_OVERLAY_HOVER transition-colors"
className="absolute top-3 right-2 z-10 bg-DARK_OVERLAY/90 text-WHITE2 rounded-full h-9 w-9 flex items-center justify-center hover:bg-DARK_OVERLAY_HOVER transition-colors"
>
<ArrowRight size={20} className="text-WHITE2" />
</button>
@ -280,28 +274,6 @@ export const ProductImageCarousel = ({
</div>
)}
{/* Prev / next arrows (desktop, opt-in) */}
{showArrows && images.length > 1 && (
<>
<button
type="button"
onClick={() => api?.scrollPrev()}
aria-label="تصویر قبلی"
className="hidden lg:flex absolute top-1/2 right-3 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-WHITE/90 shadow-md items-center justify-center text-BLACK hover:bg-WHITE transition-colors"
>
<ChevronRight size={20} />
</button>
<button
type="button"
onClick={() => api?.scrollNext()}
aria-label="تصویر بعدی"
className="hidden lg:flex absolute top-1/2 left-3 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-WHITE/90 shadow-md items-center justify-center text-BLACK hover:bg-WHITE transition-colors"
>
<ChevronLeft size={20} />
</button>
</>
)}
{/* Settings Drawer */}
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerContent>

View File

@ -10,7 +10,7 @@ export const ProductInfo = ({
product,
productDiscountPercent,
}: ProductInfoProps) => (
<div className="flex flex-col gap-2 mt-8 px-4 lg:mt-2">
<div className="flex flex-col gap-2 mt-8 px-4">
<div className="flex gap-3 items-center">
{productDiscountPercent && productDiscountPercent > 0 ? (
<div className="flex gap-[2px] items-center">
@ -23,10 +23,8 @@ export const ProductInfo = ({
{productDiscountPercent && productDiscountPercent > 0 ? (
<div className="w-[6px] h-[6px] rounded-full bg-BLACK" />
) : null}
<p className="text-B18 lg:text-[26px] font-bold">{product.title}</p>
<p className="text-B18 font-bold">{product.title}</p>
</div>
{/* Description moved into the dedicated ProductDescription section below
the buy actions so multi-line seller copy renders with line breaks and
appears on desktop too. */}
<p className="text-R12 text-GRAY">{product.description}</p>
</div>
);

View File

@ -1,80 +0,0 @@
import { Dialog, DialogContent, DialogOverlay } from "../ui/dialog";
import type { SizeChart } from "../../../src/api/types";
interface SizeGuideDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
sizeChart: SizeChart;
}
const UNIT_LABELS: Record<string, string> = {
cm: "سانتی‌متر",
inch: "اینچ",
};
export function SizeGuideDialog({
isOpen,
onOpenChange,
sizeChart,
}: SizeGuideDialogProps) {
const { columns = [], rows = [], unit = "cm" } = sizeChart;
const unitLabel = UNIT_LABELS[unit] || unit;
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogOverlay />
<DialogContent className="p-4 bg-WHITE rounded-[15px] w-[calc(100%-40px)] max-w-lg max-h-[85vh] overflow-y-auto">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-B18 font-bold">راهنمای سایز</h2>
<span className="text-R12 text-GRAY">اندازهها به {unitLabel}</span>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-center">
<thead>
<tr>
<th className="sticky right-0 z-10 bg-WHITE2 border border-GRAY3 px-3 py-2 text-R12 font-bold text-BLACK2">
سایز
</th>
{columns.map((col, ci) => (
<th
key={ci}
className="border border-GRAY3 bg-WHITE2 px-3 py-2 text-R12 font-bold text-BLACK2 whitespace-nowrap"
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri}>
<td className="sticky right-0 z-10 bg-WHITE border border-GRAY3 px-3 py-2 text-R12 font-bold">
{row.label}
</td>
{columns.map((_, ci) => (
<td
key={ci}
className="border border-GRAY3 px-3 py-2 text-R12 text-BLACK2"
dir="ltr"
>
{row.values[ci]?.trim() ? row.values[ci] : "—"}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<p className="text-R12 text-GRAY leading-6">
اندازهها ممکن است بسته به روش اندازهگیری کمی متفاوت باشند.
</p>
</div>
</DialogContent>
</Dialog>
);
}
export default SizeGuideDialog;

View File

@ -1,33 +1,16 @@
import { Ruler } from "lucide-react";
interface SizeSelectorProps {
sizes: string[] | undefined;
selectedSize: string | null;
setSelectedSize: (size: string) => void;
/** When provided, shows a "راهنمای سایز" link that opens the size guide. */
onSizeGuide?: () => void;
}
export const SizeSelector = ({
sizes,
selectedSize,
setSelectedSize,
onSizeGuide,
}: SizeSelectorProps) => (
<div className="flex flex-col gap-4 mt-10 px-4">
<div className="flex items-center justify-between">
<p className="text-B18 font-bold">انتخاب سایز</p>
{onSizeGuide && (
<button
type="button"
onClick={onSizeGuide}
className="flex items-center gap-1 text-R12 text-VITROWN_BLUE hover:underline cursor-pointer"
>
<Ruler className="h-3.5 w-3.5" />
راهنمای سایز
</button>
)}
</div>
<div className="gap-4 flex">
{sizes?.map((size: string, index: number) => (
<div

View File

@ -2,7 +2,6 @@ export { ProductHeader } from "./ProductHeader";
export { ProductImageCarousel } from "./ProductImageCarousel";
export { ProductActions } from "./ProductActions";
export { ProductInfo } from "./ProductInfo";
export { ProductDescription } from "./ProductDescription";
export { ColorSelector } from "./ColorSelector";
export { SizeSelector } from "./SizeSelector";
export { ContactButton } from "./ContactButton";

View File

@ -1,227 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { Check, Loader2, Pencil, X } from "lucide-react";
import { cn } from "../../lib/utils";
/* ---------- digit helpers (accept both Persian & Latin input) ---------- */
const FA_DIGITS = "۰۱۲۳۴۵۶۷۸۹";
const AR_DIGITS = "٠١٢٣٤٥٦٧٨٩";
function toLatinDigits(input: string): string {
return input
.replace(/[۰-۹]/g, (d) => String(FA_DIGITS.indexOf(d)))
.replace(/[٠-٩]/g, (d) => String(AR_DIGITS.indexOf(d)));
}
/** Extract only the integer digits from any incoming value ("120000.00" -> "120000"). */
function normalizeIncoming(raw: string | null | undefined): string {
if (raw === null || raw === undefined || raw === "") return "";
const n = parseFloat(toLatinDigits(String(raw)));
if (!isFinite(n) || n <= 0) return "";
return String(Math.round(n));
}
/** Keep only digits typed by the user (drops separators, letters, Persian digits normalized). */
function onlyDigits(input: string): string {
return toLatinDigits(input).replace(/[^\d]/g, "");
}
/** Group with thousands separators for the edit input ("120000" -> "120,000"). */
function groupLatin(digits: string): string {
if (!digits) return "";
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
/** Persian-grouped display ("120000" -> "۱۲۰٬۰۰۰"). */
function toFaDisplay(digits: string): string {
if (!digits) return "";
return Number(digits).toLocaleString("fa-IR");
}
/* ------------------------------- component ------------------------------ */
interface InlinePriceEditorProps {
/** Current raw price (decimal string) or null/"" when unset. */
value: string | null | undefined;
/** Called with the new integer price string, or null when cleared (only if allowEmpty). */
onSave: (value: string | null) => void;
isPending?: boolean;
/** Allow saving an empty value as null (e.g. a variant inheriting the base price). */
allowEmpty?: boolean;
/** Muted hint shown when there is no value and we're not editing (e.g. inherited price). */
placeholder?: string;
suffix?: string;
size?: "sm" | "md";
align?: "start" | "center" | "end";
ariaLabel?: string;
className?: string;
}
export function InlinePriceEditor({
value,
onSave,
isPending = false,
allowEmpty = false,
placeholder,
suffix = "تومان",
size = "md",
align = "end",
ariaLabel = "ویرایش قیمت",
className,
}: InlinePriceEditorProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(""); // raw digits while editing
// Optimistic value shown during the async save so the number doesn't flicker
// back to the old server value before the refetch lands.
const [optimistic, setOptimistic] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const current = normalizeIncoming(value);
// Clear the optimistic value once the mutation settles.
useEffect(() => {
if (!isPending) setOptimistic(null);
}, [isPending]);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editing]);
const openEditor = () => {
if (isPending) return;
setDraft(current);
setEditing(true);
};
const commit = () => {
const next = onlyDigits(draft);
// No-op if unchanged.
if (next === current) {
setEditing(false);
return;
}
if (!next) {
if (allowEmpty) {
setOptimistic("");
onSave(null);
} else {
// Empty not allowed (e.g. base price) → discard.
setEditing(false);
return;
}
} else {
setOptimistic(next);
onSave(next);
}
setEditing(false);
};
const cancel = () => {
setDraft(current);
setEditing(false);
};
const sizeText = size === "sm" ? "text-[13px]" : "text-[14px]";
const alignClass =
align === "center"
? "justify-center text-center"
: align === "start"
? "justify-start text-right"
: "justify-end text-right";
/* ------------------------------ edit mode ----------------------------- */
if (editing) {
return (
<div
className={cn("flex items-center gap-1.5", alignClass, className)}
dir="ltr"
>
<div className="relative flex items-center">
<input
ref={inputRef}
type="text"
inputMode="numeric"
aria-label={ariaLabel}
value={groupLatin(draft)}
onChange={(e) => setDraft(onlyDigits(e.target.value))}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
placeholder={allowEmpty ? "پیش‌فرض" : "قیمت"}
className={cn(
"w-24 rounded-lg border border-VITROWN_BLUE bg-WHITE px-2 py-2 text-left tabular-nums outline-none",
"focus:ring-2 focus:ring-VITROWN_BLUE/30 transition-shadow",
sizeText
)}
/>
</div>
<button
type="button"
onClick={commit}
aria-label="ذخیره قیمت"
className="flex h-9 w-9 items-center justify-center rounded-lg bg-VITROWN_BLUE text-WHITE transition-colors hover:opacity-90 cursor-pointer"
>
<Check className="h-4 w-4" />
</button>
<button
type="button"
onClick={cancel}
aria-label="انصراف"
className="flex h-9 w-9 items-center justify-center rounded-lg bg-WHITE3 text-BLACK2 transition-colors hover:bg-GRAY3 cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
/* ---------------------------- display mode ---------------------------- */
const shown = isPending && optimistic !== null ? optimistic : current;
const hasValue = shown !== "";
return (
<button
type="button"
onClick={openEditor}
disabled={isPending}
aria-label={ariaLabel}
// Looks like an editable field (subtle border + grey fill) with an
// always-visible pencil, so it reads as tappable on touch devices where
// there is no hover to reveal the affordance.
className={cn(
"group inline-flex items-center gap-1.5 rounded-lg border border-GRAY3 bg-WHITE3 px-2.5 py-1.5 min-h-[36px]",
"transition-colors hover:border-VITROWN_BLUE hover:bg-VITROWN_BLUE/5 active:bg-VITROWN_BLUE/10",
"cursor-pointer disabled:cursor-default disabled:opacity-70",
alignClass,
className
)}
>
{isPending ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-VITROWN_BLUE" />
) : (
<Pencil className="h-3.5 w-3.5 shrink-0 text-VITROWN_BLUE/70 transition-colors group-hover:text-VITROWN_BLUE" />
)}
{hasValue ? (
<span className={cn("font-semibold tabular-nums", sizeText)}>
{toFaDisplay(shown)}
<span className="mr-1 text-GRAY font-normal">{suffix}</span>
</span>
) : (
<span className={cn("text-GRAY", sizeText)}>
{placeholder ?? "افزودن قیمت"}
</span>
)}
</button>
);
}
export default InlinePriceEditor;

View File

@ -1,5 +1,4 @@
import { Check, X } from "lucide-react";
import { useMemo } from "react";
import { useColorAttributes } from "~/requestHandler/use-product-hooks";
interface ColorSelectorProps {
@ -10,15 +9,6 @@ interface ColorSelectorProps {
onToggleUnselectedMode: (enabled: boolean) => void;
}
// The DB now carries a curated 81-color palette laid out as a 9×9 grid.
// Each AttributeValue's `info` object has:
// persian — display name in Persian
// detail — hex swatch color
// sort_order — 0..80, row-major position in the grid
// grid_row — 0..8
// grid_col — 0..8 (columns cluster by hue family)
// Anything without a numeric sort_order is treated as trailing so we don't
// accidentally hide legacy or admin-inserted values.
export const ColorSelector = ({
selectedColors,
onToggleColor,
@ -27,34 +17,15 @@ export const ColorSelector = ({
onToggleUnselectedMode,
}: ColorSelectorProps) => {
const { data: colors, isLoading } = useColorAttributes();
const sortedColors = useMemo(() => {
if (!colors) return [];
return colors
.filter((c) => c.value !== "UNSELECTED")
.slice()
.sort((a, b) => {
const ax =
typeof a.info?.sort_order === "number"
? a.info.sort_order
: Number.MAX_SAFE_INTEGER;
const bx =
typeof b.info?.sort_order === "number"
? b.info.sort_order
: Number.MAX_SAFE_INTEGER;
return ax - bx;
});
}, [colors]);
if (isLoading) {
return (
<div className="space-y-4">
<h4 className="font-medium text-right">انتخاب رنگ های موجود:</h4>
<div className="grid grid-cols-9 gap-1.5">
{[...Array(81)].map((_, i) => (
<div className="grid grid-cols-7 gap-3">
{[...Array(20)].map((_, i) => (
<div
key={i}
className="aspect-square rounded-md border border-WHITE2 bg-WHITE3 animate-pulse"
className="w-12 h-12 rounded-lg border-2 border-WHITE2 bg-WHITE3 animate-pulse"
/>
))}
</div>
@ -62,20 +33,6 @@ export const ColorSelector = ({
);
}
// Perceived lightness of the swatch — controls whether we overlay a light
// or dark checkmark for the "selected" state so it stays visible on both
// near-white swatches (سفید, شیری) and near-black swatches (مشکی, بنفش تیره).
const isDarkHex = (hex?: string) => {
if (!hex) return false;
const h = hex.replace("#", "");
if (h.length !== 6) return false;
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
// Rec. 709 luma; below 128 reads as "dark ground -> use white ink".
return 0.2126 * r + 0.7152 * g + 0.0722 * b < 128;
};
return (
<div className="space-y-4">
<h4 className="font-medium text-right">انتخاب رنگ های موجود:</h4>
@ -97,41 +54,59 @@ export const ColorSelector = ({
</label>
</div>
{/* 9×9 palette: columns are hue families, rows go light -> dark. */}
{/* Color Selection Grid - Disabled when unselected mode is on */}
<div
className={`${unselectedMode ? "opacity-50 pointer-events-none" : ""}`}
>
<div className="grid grid-cols-9 gap-1.5">
{sortedColors.map((color) => {
const isSelected = selectedColors.includes(color.value);
const hex = color.info?.detail || "#000000";
const dark = isDarkHex(hex);
return (
<div className="grid grid-cols-7 gap-1 flex-wrap">
{colors.map((color) => (
<>
{color.value !== "UNSELECTED" && (
<div
key={color.id}
className="flex flex-col items-center gap-1"
>
<button
key={color.id ?? color.value}
type="button"
onClick={() => !unselectedMode && onToggleColor(color.value)}
onClick={() =>
!unselectedMode && onToggleColor(color.value)
}
disabled={unselectedMode}
title={color.info?.persian || color.value}
aria-label={color.info?.persian || color.value}
aria-pressed={isSelected}
className={`relative aspect-square rounded-md border-2 transition-all ${
isSelected
? "border-VITROWN_BLUE shadow-md"
className={`relative w-12 h-12 rounded-lg border-2 transition-all ${
selectedColors.includes(color.value)
? "border-blue-500"
: "border-WHITE2 hover:border-GRAY3"
}`}
style={{ backgroundColor: hex }}
style={{ backgroundColor: color.info.detail }}
title={color.info?.persian}
>
{isSelected && (
{selectedColors.includes(color.value) && (
<div className="absolute inset-0 flex items-center justify-center">
<Check
className={`w-3.5 h-3.5 ${dark ? "text-WHITE" : "text-BLACK"}`}
className={`w-4 h-4 ${
color.info.detail === "#FFFFFF" ||
color.info.detail === "#F5F5DC" ||
color.info.detail === "#FAFAFA" ||
color.info.detail === "#F0F0F0"
? "text-BLACK"
: "text-WHITE"
}`}
/>
</div>
)}
</button>
);
})}
<span
className={`text-xs text-center truncate w-12 ${
selectedColors.includes(color.value)
? "font-semibold text-blue-700"
: "text-BLACK2"
}`}
>
{color.info?.persian || color.value}
</span>
</div>
)}
</>
))}
</div>
</div>
@ -139,16 +114,15 @@ export const ColorSelector = ({
{selectedColors.length > 0 && !unselectedMode && (
<div className="flex flex-wrap gap-2 mt-3">
{selectedColors.map((colorValue) => {
const colorInfo = sortedColors.find((c) => c.value === colorValue);
const hex = colorInfo?.info?.detail || "#EEE";
const colorInfo = colors.find((c) => c.value === colorValue);
return (
<div
key={colorValue}
className="flex items-center gap-1.5 bg-WHITE3 px-3 py-1 rounded-full text-sm"
className="flex items-center gap-1 bg-WHITE3 px-3 py-1 rounded-full text-sm"
>
<div
className="w-3 h-3 rounded-full border border-GRAY3"
style={{ backgroundColor: hex }}
style={{ backgroundColor: colorValue }}
/>
<span>{colorInfo?.info?.persian || colorValue}</span>
<X

View File

@ -302,30 +302,20 @@ export function ImageEditor({
const adjustedCropWidth = cropArea.width * scaleX;
const adjustedCropHeight = cropArea.height * scaleY;
// Crop rectangle expressed in the SOURCE image's native pixels
// (adjusted* already scales the on-screen crop back up to the original).
const srcX = Math.max(0, adjustedCropX);
const srcY = Math.max(0, adjustedCropY);
const srcWidth = Math.min(adjustedCropWidth, img.width - srcX);
const srcHeight = Math.min(adjustedCropHeight, img.height - srcY);
// Size the canvas to the full-resolution crop region, NOT the editor's
// on-screen crop size. Using the display size here was downscaling phone
// photos to a few hundred pixels, which is what made uploads blurry.
// The backend still caps the final image to 1600px.
canvas.width = Math.max(1, Math.round(srcWidth));
canvas.height = Math.max(1, Math.round(srcHeight));
// Set canvas size to crop area
canvas.width = cropArea.width;
canvas.height = cropArea.height;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw the cropped portion at native resolution
// Draw the cropped portion
ctx.drawImage(
img,
srcX,
srcY,
srcWidth,
srcHeight,
Math.max(0, adjustedCropX),
Math.max(0, adjustedCropY),
Math.min(adjustedCropWidth, img.width - Math.max(0, adjustedCropX)),
Math.min(adjustedCropHeight, img.height - Math.max(0, adjustedCropY)),
0,
0,
canvas.width,
@ -341,7 +331,7 @@ export function ImageEditor({
onClose();
},
"image/jpeg",
0.92
0.9
);
};

View File

@ -21,7 +21,7 @@ export const ProductDetailsForm: React.FC<ProductDetailsFormProps> = ({
<AppInput
inputTitle="عنوان محصول:"
inputValue={productTitle}
inputPlaceholder="مثل: تیشرت بیسیک"
inputPlaceholder="مثل: کفش آدیداس"
inputOnChange={onTitleChange}
inputDir="rtl"
/>

View File

@ -41,6 +41,7 @@ export const ProductVariantDrawer = ({
Record<string, number>
>({});
const [colorUnselectedMode, setColorUnselectedMode] = useState(false);
const [sizeUnselectedMode, setSizeUnselectedMode] = useState(false);
const initializationDoneRef = useRef<boolean>(false);
const prevIsOpenRef = useRef<boolean>(false);
@ -50,15 +51,19 @@ export const ProductVariantDrawer = ({
// Only initialize when drawer opens for the first time (not on re-renders)
if (isOpen && !prevIsOpenRef.current) {
if (initialVariants.length > 0) {
// Color still supports the "no color" escape hatch.
// Check if any variant has unselected values
const hasUnselectedColor = initialVariants.some(
(v) => v.color === "unselected"
);
setColorUnselectedMode(hasUnselectedColor);
const hasUnselectedSize = initialVariants.some(
(v) => v.size === "unselected"
);
// Extract unique colors and sizes from initial variants (excluding unselected).
// Legacy variants with size="unselected" are dropped here so the seller is
// forced to pick a real size (FreeSize covers one-size products).
// Set unselected modes
setColorUnselectedMode(hasUnselectedColor);
setSizeUnselectedMode(hasUnselectedSize);
// Extract unique colors and sizes from initial variants (excluding unselected)
const colors = [
...new Set(
initialVariants
@ -96,6 +101,7 @@ export const ProductVariantDrawer = ({
setBulkQuantity("1");
setCustomQuantities({});
setColorUnselectedMode(false);
setSizeUnselectedMode(false);
}
initializationDoneRef.current = true;
}
@ -108,6 +114,7 @@ export const ProductVariantDrawer = ({
setBulkQuantity("1");
setCustomQuantities({});
setColorUnselectedMode(false);
setSizeUnselectedMode(false);
initializationDoneRef.current = false;
}
@ -129,14 +136,19 @@ export const ProductVariantDrawer = ({
if (
JSON.stringify(currentVariantKeys) !== JSON.stringify(newVariantKeys)
) {
// Color still supports the "no color" escape hatch.
// Check if any variant has unselected values
const hasUnselectedColor = initialVariants.some(
(v) => v.color === "unselected"
);
setColorUnselectedMode(hasUnselectedColor);
const hasUnselectedSize = initialVariants.some(
(v) => v.size === "unselected"
);
// Extract unique colors and sizes from initial variants (excluding unselected).
// Legacy "unselected" size variants are ignored so the seller must pick.
// Set unselected modes
setColorUnselectedMode(hasUnselectedColor);
setSizeUnselectedMode(hasUnselectedSize);
// Extract unique colors and sizes from initial variants (excluding unselected)
const colors = [
...new Set(
initialVariants
@ -235,12 +247,19 @@ export const ProductVariantDrawer = ({
setCustomQuantities((prev) => ({ ...prev, ...newQuantities }));
};
// Get variants for quantity display. Size is always required (FreeSize covers
// one-size products); color still has an unselected escape hatch.
// Get variants for quantity display based on unselected modes
const getQuantityVariants = () => {
const variants: { color: string; size: string; key: string }[] = [];
if (colorUnselectedMode) {
if (colorUnselectedMode && sizeUnselectedMode) {
// Both unselected - show one general variant
variants.push({
color: "unselected",
size: "unselected",
key: "unselected-unselected",
});
} else if (colorUnselectedMode && !sizeUnselectedMode) {
// Color unselected, size selected - show variants for each selected size
selectedSizes.forEach((size) => {
variants.push({
color: "unselected",
@ -248,7 +267,17 @@ export const ProductVariantDrawer = ({
key: `unselected-${size}`,
});
});
} else if (!colorUnselectedMode && sizeUnselectedMode) {
// Size unselected, color selected - show variants for each selected color
selectedColors.forEach((color) => {
variants.push({
color: color,
size: "unselected",
key: `${color}-unselected`,
});
});
} else {
// Normal mode - show all combinations
selectedColors.forEach((color) => {
selectedSizes.forEach((size) => {
variants.push({
@ -264,23 +293,38 @@ export const ProductVariantDrawer = ({
};
const handleNext = () => {
// Size is always required; use "فری سایز" for one-size products.
// Check if at least one variant can be created
if (colorUnselectedMode && sizeUnselectedMode) {
// Both unselected - always valid
} else if (colorUnselectedMode && !sizeUnselectedMode) {
// Color unselected, need at least one size
if (selectedSizes.length === 0) {
toast({
title: "خطا",
description:
"لطفاً حداقل یک سایز انتخاب کنید. اگر محصول تک‌سایز است، «فری سایز» را انتخاب کنید.",
description: "لطفاً حداقل یک سایز انتخاب کنید",
});
return;
}
// Color is optional (colorUnselectedMode covers no-color products).
if (!colorUnselectedMode && selectedColors.length === 0) {
} else if (!colorUnselectedMode && sizeUnselectedMode) {
// Size unselected, need at least one color
if (selectedColors.length === 0) {
toast({
title: "خطا",
description: "لطفاً حداقل یک رنگ انتخاب کنید یا حالت بدون رنگ را فعال کنید",
description: "لطفاً حداقل یک رنگ انتخاب کنید",
});
return;
}
} else {
// Normal mode - need both color and size
if (selectedColors.length === 0 || selectedSizes.length === 0) {
toast({
title: "خطا",
description:
"لطفاً حداقل یک رنگ و یک سایز انتخاب کنید یا حالت بدون رنگ/سایز را فعال کنید",
});
return;
}
}
setStep("quantities");
};
@ -320,6 +364,8 @@ export const ProductVariantDrawer = ({
selectedSizes={selectedSizes}
onToggleSize={toggleSize}
onRemoveSize={removeSize}
unselectedMode={sizeUnselectedMode}
onToggleUnselectedMode={setSizeUnselectedMode}
/>
</div>
) : (
@ -378,14 +424,17 @@ export const ProductVariantDrawer = ({
(c) => c.value === color
);
// Helper function to get display text — colorless variants
// roll up under the size label; sized/colored variants show
// both.
// Helper function to get display text
const getDisplayText = () => {
if (colorUnselectedMode) {
if (colorUnselectedMode && sizeUnselectedMode) {
return "کل موجودی محصول";
} else if (colorUnselectedMode && !sizeUnselectedMode) {
return `موجودی سایز ${size}`;
} else if (!colorUnselectedMode && sizeUnselectedMode) {
return `موجودی ${colorInfo?.info.persian || color}`;
} else {
return null; // Normal mode - will show both color and size
}
return null; // Normal mode — render color + size below
};
const displayText = getDisplayText();

View File

@ -1,329 +0,0 @@
import { useMemo, useState } from "react";
import { Plus, X, Trash2, Ruler } from "lucide-react";
import type { SizeChart } from "../../../src/api/types";
import { cn } from "../../lib/utils";
import { FormField } from "../ui/FormField";
import {
getDefaultColumns,
getSizeSuggestions,
} from "../../lib/sizeChartSuggestions";
interface SizeChartSectionProps {
value: SizeChart | null;
onChange: (value: SizeChart | null) => void;
categoryName?: string | null;
/** Sizes the product currently offers (from its variants), used to seed rows. */
productSizes?: string[];
/** Product offers sizes → the chart is expected (visual emphasis + error). */
required?: boolean;
/** Show the "must fill" error (set when the seller tried to proceed). */
showError?: boolean;
}
export function SizeChartSection({
value,
onChange,
categoryName,
productSizes = [],
required = false,
showError = false,
}: SizeChartSectionProps) {
const [customColumn, setCustomColumn] = useState("");
const columns = value?.columns ?? [];
const rows = value?.rows ?? [];
const unit = value?.unit ?? "cm";
// Quick-add chips: category suggestions not already used as a column.
const suggestionChips = useMemo(() => {
const used = new Set(columns.map((c) => c.trim()));
return getSizeSuggestions(categoryName).filter((s) => !used.has(s));
}, [categoryName, columns]);
// Product sizes that don't yet have a row (offer to add them).
const missingSizes = useMemo(() => {
const present = new Set(rows.map((r) => r.label.trim()));
return productSizes.filter((s) => s && s.trim() && !present.has(s.trim()));
}, [productSizes, rows]);
// Offered sizes still lacking a row or any filled measurement (blocks publish).
const incompleteSizes = useMemo(() => {
return productSizes.filter((s) => {
const row = rows.find((r) => r.label.trim() === s.trim());
return !row || !row.values.some((v) => v && v.trim() !== "");
});
}, [productSizes, rows]);
const emit = (next: Partial<SizeChart>) => {
onChange({ unit, columns, rows, ...next });
};
const initialize = () => {
const seededColumns = getDefaultColumns(categoryName);
const seededRows = (productSizes.length ? productSizes : [""]).map((s) => ({
label: s,
values: seededColumns.map(() => ""),
}));
onChange({ unit: "cm", columns: seededColumns, rows: seededRows });
};
const addColumn = (name: string) => {
const trimmed = name.trim();
if (!trimmed || columns.includes(trimmed)) return;
emit({
columns: [...columns, trimmed],
rows: rows.map((r) => ({ ...r, values: [...r.values, ""] })),
});
};
const renameColumn = (index: number, name: string) => {
emit({ columns: columns.map((c, i) => (i === index ? name : c)) });
};
const removeColumn = (index: number) => {
emit({
columns: columns.filter((_, i) => i !== index),
rows: rows.map((r) => ({
...r,
values: r.values.filter((_, i) => i !== index),
})),
});
};
const addRow = (label = "") => {
emit({ rows: [...rows, { label, values: columns.map(() => "") }] });
};
const addMissingSizes = () => {
emit({
rows: [
...rows,
...missingSizes.map((label) => ({
label,
values: columns.map(() => ""),
})),
],
});
};
const setRowLabel = (index: number, label: string) => {
emit({ rows: rows.map((r, i) => (i === index ? { ...r, label } : r)) });
};
const setCell = (rowIndex: number, colIndex: number, val: string) => {
emit({
rows: rows.map((r, i) =>
i === rowIndex
? {
...r,
values: r.values.map((v, j) => (j === colIndex ? val : v)),
}
: r
),
});
};
const removeRow = (index: number) => {
emit({ rows: rows.filter((_, i) => i !== index) });
};
const cellInput =
"w-20 rounded-lg border border-GRAY3 bg-WHITE px-2 py-1.5 text-center text-R12 outline-none focus:border-VITROWN_BLUE";
return (
<FormField label="راهنمای سایز" required={required}>
<p className="text-R12 text-GRAY -mt-1 mb-2">
اندازههای هر سایز را وارد کنید تا خریدار بداند مثلاً «لارج» برای این محصول
چه اندازهای است.
</p>
{value == null ? (
<>
<button
type="button"
onClick={initialize}
className={cn(
"flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed py-4 text-R14 transition-colors cursor-pointer",
required && showError
? "border-RED text-RED"
: "border-GRAY3 text-BLACK2 hover:border-VITROWN_BLUE hover:text-VITROWN_BLUE"
)}
>
<Plus className="h-4 w-4" />
افزودن جدول سایز
</button>
{required && showError && (
<p className="mt-2 text-R12 text-RED">
این محصول سایز دارد؛ پر کردن راهنمای سایز الزامی است.
</p>
)}
</>
) : (
<div className="flex flex-col gap-3 rounded-xl border border-GRAY3 bg-WHITE p-3">
{required && showError && incompleteSizes.length > 0 && (
<p className="rounded-lg bg-RED/10 px-3 py-2 text-R12 text-RED">
برای این سایزها هنوز اندازهای وارد نشده: {incompleteSizes.join("، ")}
</p>
)}
<div className="flex items-center justify-end">
<span className="text-R12 text-GRAY">اندازهها به سانتیمتر</span>
</div>
{/* Grid */}
<div className="overflow-x-auto">
<table className="w-full border-separate border-spacing-1">
<thead>
<tr>
<th className="sticky right-0 z-10 bg-WHITE text-R12 text-GRAY font-normal">
سایز
</th>
{columns.map((col, ci) => (
<th key={ci} className="min-w-[6rem]">
<div className="flex items-center gap-1">
<input
value={col}
onChange={(e) => renameColumn(ci, e.target.value)}
placeholder="نام اندازه"
className="w-24 rounded-lg border border-GRAY3 bg-WHITE3 px-2 py-1.5 text-center text-R12 font-bold outline-none focus:border-VITROWN_BLUE"
/>
<button
type="button"
onClick={() => removeColumn(ci)}
aria-label={`حذف ستون ${col}`}
className="text-GRAY hover:text-RED transition-colors cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri}>
<td className="sticky right-0 z-10 bg-WHITE">
<div className="flex items-center gap-1">
<input
value={row.label}
onChange={(e) => setRowLabel(ri, e.target.value)}
placeholder="سایز"
className="w-16 rounded-lg border border-GRAY3 bg-WHITE3 px-2 py-1.5 text-center text-R12 font-bold outline-none focus:border-VITROWN_BLUE"
/>
<button
type="button"
onClick={() => removeRow(ri)}
aria-label={`حذف سایز ${row.label}`}
className="text-GRAY hover:text-RED transition-colors cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
{columns.map((_, ci) => (
<td key={ci} className="text-center">
<input
value={row.values[ci] ?? ""}
onChange={(e) => setCell(ri, ci, e.target.value)}
inputMode="numeric"
placeholder="—"
dir="ltr"
className={cellInput}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Add row / add missing product sizes */}
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => addRow()}
className="inline-flex items-center gap-1 rounded-lg border border-GRAY3 px-3 py-1.5 text-R12 text-BLACK2 transition-colors hover:border-VITROWN_BLUE hover:text-VITROWN_BLUE cursor-pointer"
>
<Plus className="h-3.5 w-3.5" />
افزودن سایز
</button>
{missingSizes.length > 0 && (
<button
type="button"
onClick={addMissingSizes}
className="inline-flex items-center gap-1 rounded-lg bg-VITROWN_BLUE/10 px-3 py-1.5 text-R12 text-VITROWN_BLUE transition-colors hover:bg-VITROWN_BLUE/20 cursor-pointer"
>
<Plus className="h-3.5 w-3.5" />
افزودن سایزهای محصول ({missingSizes.join("، ")})
</button>
)}
</div>
{/* Quick-add measurement columns */}
{suggestionChips.length > 0 && (
<div className="flex flex-col gap-1.5">
<span className="text-R12 text-GRAY">اندازههای پیشنهادی:</span>
<div className="flex flex-wrap gap-2">
{suggestionChips.map((chip) => (
<button
key={chip}
type="button"
onClick={() => addColumn(chip)}
className="inline-flex items-center gap-1 rounded-full bg-WHITE3 px-3 py-1 text-R12 text-BLACK2 transition-colors hover:bg-VITROWN_BLUE/10 hover:text-VITROWN_BLUE cursor-pointer"
>
<Plus className="h-3 w-3" />
{chip}
</button>
))}
</div>
</div>
)}
{/* Custom column */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Ruler className="absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-GRAY" />
<input
value={customColumn}
onChange={(e) => setCustomColumn(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addColumn(customColumn);
setCustomColumn("");
}
}}
placeholder="اندازه دلخواه (مثلاً دور یقه)"
className="w-full rounded-lg border border-GRAY3 bg-WHITE pr-7 pl-2 py-1.5 text-R12 outline-none focus:border-VITROWN_BLUE"
/>
</div>
<button
type="button"
onClick={() => {
addColumn(customColumn);
setCustomColumn("");
}}
disabled={!customColumn.trim()}
className="rounded-lg bg-BLACK px-3 py-1.5 text-R12 text-WHITE transition-colors hover:bg-BLACK2 disabled:opacity-40 cursor-pointer disabled:cursor-default"
>
افزودن ستون
</button>
</div>
{/* Remove whole chart */}
<button
type="button"
onClick={() => onChange(null)}
className="self-start text-R12 text-RED hover:underline cursor-pointer"
>
حذف جدول سایز
</button>
</div>
)}
</FormField>
);
}
export default SizeChartSection;

View File

@ -5,12 +5,16 @@ interface SizeSelectorProps {
selectedSizes: string[];
onToggleSize: (size: string) => void;
onRemoveSize: (size: string) => void;
unselectedMode: boolean;
onToggleUnselectedMode: (enabled: boolean) => void;
}
export const SizeSelector: React.FC<SizeSelectorProps> = ({
selectedSizes,
onToggleSize,
onRemoveSize,
unselectedMode,
onToggleUnselectedMode,
}: SizeSelectorProps) => {
const { data: sizes, isLoading } = useSizeAttributes();
if (isLoading) {
@ -32,17 +36,35 @@ export const SizeSelector: React.FC<SizeSelectorProps> = ({
return (
<div className="space-y-4">
<h4 className="font-medium text-right">انتخاب سایز های موجود:</h4>
<p className="text-xs text-GRAY text-right">
اگر محصول تکسایز است، «فری سایز» را انتخاب کنید.
</p>
<div className="grid grid-cols-4 gap-2">
{/* Unselected Mode Switch */}
<div className="flex items-center justify-between p-3 bg-GRAY3 rounded-lg">
<span className="text-sm font-medium text-BLACK2">
بدون مشخص کردن سایز
</span>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={unselectedMode}
onChange={(e) => onToggleUnselectedMode(e.target.checked)}
className="sr-only peer"
aria-label="حالت بدون سایز"
/>
<div className="w-11 h-6 bg-WHITE2 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-WHITE after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-WHITE after:border-GRAY3 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-VITROWN_BLUE border"></div>
</label>
</div>
{/* Size Selection Grid - Disabled when unselected mode is on */}
<div
className={`grid grid-cols-4 gap-2 ${unselectedMode ? "opacity-50 pointer-events-none" : ""}`}
>
{sizes.map((size) => (
<>
{size.value !== "UNSELECTED" && (
<button
key={size.id}
onClick={() => onToggleSize(size.value)}
onClick={() => !unselectedMode && onToggleSize(size.value)}
disabled={unselectedMode}
className={`py-3 px-4 rounded-lg border-2 text-sm font-medium transition-all ${
selectedSizes.includes(size.value)
? "border-blue-500 bg-blue-50 text-blue-700"
@ -57,7 +79,7 @@ export const SizeSelector: React.FC<SizeSelectorProps> = ({
</div>
{/* Selected Sizes */}
{selectedSizes.length > 0 && (
{selectedSizes.length > 0 && !unselectedMode && (
<div className="flex flex-wrap gap-2">
{selectedSizes.map((sizeValue) => {
const sizeInfo = sizes.find((s) => s.value === sizeValue);

View File

@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { useNavigate } from "@remix-run/react";
import { Loader2, Save, Store, Upload } from "lucide-react";
import { Loader2, Save, Store } from "lucide-react";
import ProfilePagesHeader from "../ProfilePagesHeader";
import HeaderWithSupport from "../HeaderWithSupport";
import AppInput from "../AppInput";
@ -9,7 +9,6 @@ import AppSelectBox from "../AppSelectBox";
import { Button } from "../ui/button";
import { FormField } from "../ui/FormField";
import { ImageUpload } from "./ImageUpload";
import { convertHeicToJpeg } from "~/utils/convert-heic";
import { ImageUploadDrawer } from "./ImageUploadDrawer";
import { Textarea } from "../ui/textarea";
import { useToast } from "../../hooks/use-toast";
@ -80,26 +79,8 @@ 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;
@ -116,31 +97,17 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
);
// Check store ownership for edit mode
const { isLoading: isOwnershipLoading, role } = useStoreOwnership(
const { isLoading: isOwnershipLoading } = useStoreOwnership(
mode === "edit" ? storeId : undefined,
mode
);
// Editing store info is owner-only; send staff back to the dashboard.
useEffect(() => {
if (mode === "edit" && !isOwnershipLoading && role && role !== "owner") {
navigate(`/store/${storeId}`);
}
}, [mode, isOwnershipLoading, role, storeId, navigate]);
// Form state
const [selectedImage, setSelectedImage] = useState<File | null>(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
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);
@ -154,9 +121,6 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
selectedCity: "",
instagramId: "",
storeLogo: "",
bannerType: "color",
bannerColor: "",
bannerImage: "",
});
// Validation errors state
@ -192,9 +156,6 @@ 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);
}
@ -208,15 +169,11 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
formData.storeDescription !== (sellerData.storeDescription || "") ||
formData.instagramId !== (sellerData.instagramId || "") ||
formData.storeLogo !== (sellerData.storeLogo || "") ||
formData.bannerType !== (sellerData.bannerType || "color") ||
formData.bannerColor !== (sellerData.bannerColor || "") ||
formData.bannerImage !== (sellerData.bannerImage || "") ||
selectedImage !== null || // Include image changes
selectedBannerImage !== null;
selectedImage !== null; // Include image changes
setHasChanges(hasFormChanges);
}
}, [mode, formData, sellerData, selectedImage, selectedBannerImage]);
}, [mode, formData, sellerData, selectedImage]);
// Redirect if user already has a store (create mode only)
useEffect(() => {
@ -238,15 +195,6 @@ 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 (
@ -346,13 +294,9 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
!validationErrors.instagramId;
// Handle image selection
const handleImageSelect = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const picked = event.target.files?.[0];
if (picked) {
// iPhone logos are often HEIC, which most browsers can't preview; convert.
const file = await convertHeicToJpeg(picked);
const handleImageSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (imagePreviewUrl) {
URL.revokeObjectURL(imagePreviewUrl);
}
@ -362,24 +306,6 @@ 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);
@ -449,38 +375,11 @@ 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({
@ -605,116 +504,6 @@ 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") {
@ -826,22 +615,16 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
const contentClass =
mode === "create"
? "flex flex-col gap-6 w-full px-4 pt-6 lg:max-w-[640px] lg:mx-auto lg:pt-8"
: "flex flex-col gap-6 p-4 bg-WHITE lg:max-w-[640px] lg:mx-auto lg:pt-8";
? "flex flex-col gap-6 w-full px-4 pt-6"
: "flex flex-col gap-6 p-4 bg-WHITE";
return (
<div className={containerClass}>
{renderHeader()}
<h1 className="hidden lg:block text-[28px] font-extrabold px-4 lg:max-w-[640px] lg:mx-auto lg:w-full lg:px-0 lg:pt-8">
{mode === "create" ? "ایجاد فروشگاه" : "ویرایش اطلاعات فروشگاه"}
</h1>
<div className={contentClass}>
{renderLogoSection()}
{/* Banner editor (edit mode) */}
{mode === "edit" && renderBannerSection()}
{/* Username Input - Only for create mode */}
{mode === "create" && (
<FormField

View File

@ -1,99 +0,0 @@
import { useEffect, useState } from "react";
import { Bell, BellOff, ChevronLeft } from "lucide-react";
import { usePushNotifications } from "~/hooks/usePushNotifications";
const SNOOZE_KEY = "store_push_prompt_snoozed_until";
const SNOOZE_MS = 24 * 60 * 60 * 1000; // 24h — a soft nudge, not a dismissal.
/**
* Seller-facing card at the top of the store dashboard that nudges the owner
* (or a staff member) to turn on browser/PWA notifications. Order notifications
* are business-critical, so unlike the global buyer banner this one is NOT
* dismissible-forever a snooze hides it for a day; the prompt returns until
* push is actually subscribed. When permission was denied, we can't
* re-request programmatically, so the copy switches to a "how to re-enable in
* the browser" instruction.
*/
export function StorePushPromptCard() {
const { supported, permission, isSubscribed, subscribe, busy } =
usePushNotifications();
const [snoozed, setSnoozed] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const until = Number(localStorage.getItem(SNOOZE_KEY) || 0);
setSnoozed(!!until && Date.now() < until);
}, []);
// Nothing to show if the browser can't do push, they already subscribed,
// or they hit "later" in the last 24h.
if (!supported) return null;
if (isSubscribed) return null;
if (snoozed) return null;
const snoozeUntilTomorrow = () => {
localStorage.setItem(SNOOZE_KEY, String(Date.now() + SNOOZE_MS));
setSnoozed(true);
};
const enable = async () => {
await subscribe();
};
// Permission denied — we cannot prompt again from JS. Show recovery hint.
if (permission === "denied") {
return (
<div className="mb-4 rounded-2xl border border-RED/30 bg-RED/5 p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-RED/10 grid place-items-center shrink-0">
<BellOff size={20} className="text-RED" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-[14.5px] text-BLACK">
اعلانها در مرورگر مسدود شدهاند
</p>
<p className="text-[13px] text-BLACK2 leading-6 mt-1">
برای دریافت اعلان سفارشهای جدید، از تنظیمات مرورگر یا PWA اعلانها را برای این سایت فعال کنید و سپس صفحه را رفرش کنید.
</p>
</div>
</div>
</div>
);
}
// Default (never asked) OR granted-but-not-subscribed — both resolvable by
// clicking Enable, which triggers the PushManager subscribe flow.
return (
<div className="mb-4 rounded-2xl border border-VITROWN_BLUE/20 bg-VITROWN_BLUE/5 p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-VITROWN_BLUE/10 grid place-items-center shrink-0">
<Bell size={20} className="text-VITROWN_BLUE" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-[14.5px] text-BLACK">
اعلان سفارشهای جدید را فعال کنید
</p>
<p className="text-[13px] text-BLACK2 leading-6 mt-1">
تا لحظهای که یک مشتری سفارش میثبت، روی مرورگر یا موبایل خود پیامی میگیرید و هیچ سفارشی را از دست نمیدهید.
</p>
<div className="flex items-center gap-2 mt-3">
<button
onClick={enable}
disabled={busy}
className="inline-flex items-center gap-1.5 rounded-xl bg-VITROWN_BLUE text-white text-[13px] font-bold px-3.5 py-2 disabled:opacity-60"
>
{busy ? "..." : "فعال‌سازی"}
<ChevronLeft size={16} />
</button>
<button
onClick={snoozeUntilTomorrow}
className="text-[13px] font-semibold text-GRAY px-2 py-2"
>
بعداً یادآوری کن
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -17,10 +17,10 @@ const ChatHeader = ({
const { theme } = useRootData();
const navigate = useNavigate();
return (
<div className="max-w-md mx-auto flex flex-row h-[calc(65px_+_env(safe-area-inset-top))] pt-[env(safe-area-inset-top)] bg-WHITE fixed top-0 left-0 w-full items-center justify-center border-b border-inner-border z-10">
<div className="max-w-md mx-auto flex flex-row h-[65px] bg-WHITE fixed top-0 left-0 w-full items-center justify-center border-b border-inner-border z-10">
<ArrowRight
onClick={() => safeGoBack(navigate)}
className="absolute top-[calc(1.25rem_+_env(safe-area-inset-top))] right-5"
className="absolute top-5 right-5"
/>
{isConnected ? (
<Link

View File

@ -1,5 +1,4 @@
import { Reply, MoreVertical, Trash2, Check } from "lucide-react";
import { Link } from "@remix-run/react";
import {
DropdownMenu,
@ -15,17 +14,9 @@ import { Button } from "../ui/button";
import { useState, useRef, useEffect } from "react";
// Message bubble component
//
// isCurrentUser: whether this message belongs to "our side" of the chat.
// In seller-side threads that includes coworkers, not just yourself.
// isCoworker: this message was written by a teammate (owner or staff) other
// than the viewer. We still put it on the right side, but we surface the
// sender's name so you can tell who typed it — and we don't offer to
// delete it (backend only lets a user delete their own messages).
export const MessageBubble = ({
message,
isCurrentUser,
isCoworker = false,
onReply,
onDelete,
replyToMessageContent,
@ -35,7 +26,6 @@ export const MessageBubble = ({
}: {
message: MessageList;
isCurrentUser: boolean;
isCoworker?: boolean;
onReply: (message: MessageList) => void;
onDelete?: (messageId: string) => void;
replyToMessageContent?: string | undefined;
@ -72,11 +62,6 @@ export const MessageBubble = ({
isCurrentUser ? "bg-BLUE2 text-WHITE" : "bg-GRAY3"
}`}
>
{isCoworker && message.senderUsername && (
<div className="text-R10 text-WHITE/80 mb-1 font-bold">
{message.senderUsername}
</div>
)}
{message.replyTo && (
<div
className={`text-xs italic mb-1 pb-1 border-b ${
@ -96,42 +81,7 @@ export const MessageBubble = ({
{replyToMessageContent || "پاسخ به پیام"}
</div>
)}
{message.product && (
<Link
to={`/product/${message.product.id}`}
className="flex items-center gap-2 mb-1 w-[210px] max-w-full rounded-lg bg-WHITE border border-GRAY3 p-2"
>
{message.product.primaryImage ? (
<img
src={message.product.primaryImage}
alt=""
className="w-12 h-12 rounded-md object-cover shrink-0"
/>
) : (
<div className="w-12 h-12 rounded-md bg-WHITE3 shrink-0" />
)}
<div className="min-w-0 text-right">
<p className="text-R12 text-BLACK truncate">
{message.product.title}
</p>
{message.product.basePrice && (
<p className="text-R10 text-GRAY mt-0.5">
{parseInt(message.product.basePrice).toLocaleString("fa-IR")}{" "}
تومان
</p>
)}
</div>
</Link>
)}
{message.imageUrl && (
<img
src={message.imageUrl}
alt=""
className="max-w-[240px] rounded-lg mb-1 cursor-pointer"
onClick={() => window.open(message.imageUrl, "_blank")}
/>
)}
{message.content && <p className="whitespace-pre-wrap">{message.content}</p>}
<p>{message.content}</p>
</div>
<div className="flex flex-col justify-between items-start">
<div className="flex flex-col gap-1 items-start">
@ -157,7 +107,7 @@ export const MessageBubble = ({
<Reply size={14} />
<span className="text-R12">پاسخ</span>
</DropdownMenuItem>
{isCurrentUser && !isCoworker && (
{isCurrentUser && (
<DropdownMenuItem
onClick={() => {
setShowDropdownMenu(false);

View File

@ -1,24 +1,12 @@
import { ArrowRight, Image as ImageIcon, Loader2, Reply, SmilePlus, X } from "lucide-react";
import { ArrowRight, Loader2, Reply, SmilePlus, X } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { useRef, useState } from "react";
import EmojiPicker from "./EmojiPicker";
import type { MessageList } from "../../../src/api/types/models";
import { useAuthToken } from "../../hooks/use-root-data";
import { getApiBaseUrl } from "../../utils/api-config";
import { toast } from "../../hooks/use-toast";
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic", "image/heif"];
// Message input component
//
// `canSendImage` toggles the paperclip button — only team members (owner or
// active staff of the thread's seller) can attach images. The buyer side
// leaves it off so customers can only send text.
const MessageInput = ({
onSend,
onSendImage,
canSendImage = false,
replyToMessage,
onCancelReply,
messageText,
@ -27,8 +15,6 @@ const MessageInput = ({
setReplyToMessage,
}: {
onSend: (text: string) => void;
onSendImage?: (imageUrl: string) => void;
canSendImage?: boolean;
replyToMessage: MessageList | undefined;
onCancelReply: () => void;
inputRef: React.RefObject<HTMLInputElement>;
@ -37,10 +23,6 @@ const MessageInput = ({
isSendingMessage: boolean;
setReplyToMessage: (message: MessageList | undefined) => void;
}) => {
const token = useAuthToken();
const apiBase = getApiBaseUrl();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploadingImage, setIsUploadingImage] = useState(false);
const [caretPosition, setCaretPosition] = useState<number | null>(null);
// Track current textarea height
@ -95,65 +77,6 @@ const MessageInput = ({
setMessageText(e.target.value);
};
const handlePickImage = () => {
if (!canSendImage || isUploadingImage) return;
fileInputRef.current?.click();
};
const handleFileSelected = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const file = e.target.files?.[0];
// Reset the input so the same file can be re-picked after cancel.
if (e.target) e.target.value = "";
if (!file || !canSendImage || !onSendImage) return;
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
toast({
title: "فرمت پشتیبانی نمی‌شود",
description: "فقط تصاویر JPG / PNG / WebP / HEIC مجاز است.",
variant: "destructive",
});
return;
}
if (file.size > MAX_IMAGE_BYTES) {
toast({
title: "حجم زیاد",
description: "حداکثر حجم تصویر ۸ مگابایت است.",
variant: "destructive",
});
return;
}
setIsUploadingImage(true);
try {
const form = new FormData();
form.append("file", file);
form.append("usage", "chat");
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`${apiBase}/api/media/v1/upload/`, {
method: "POST",
headers,
body: form,
});
if (!res.ok) throw new Error(`upload failed: ${res.status}`);
const data = await res.json();
const url: string | undefined = data.originalUrl || data.original_url;
if (!url) throw new Error("no url in upload response");
onSendImage(url);
} catch (err) {
console.error("chat image upload failed:", err);
toast({
title: "خطا در ارسال تصویر",
description: "لطفاً دوباره تلاش کنید.",
variant: "destructive",
});
} finally {
setIsUploadingImage(false);
}
};
return (
<>
{/* Reply indicator */}
@ -168,7 +91,7 @@ const MessageInput = ({
</button>
</div>
)}
<div className="max-w-md mx-auto fixed px-2 bottom-0 pt-2 pb-[calc(0.5rem_+_env(safe-area-inset-bottom))] w-full bg-WHITE">
<div className="max-w-md mx-auto fixed px-2 bottom-0 py-2 w-full bg-WHITE">
{replyToMessage && (
<div className="bg-GRAY3 rounded-lg p-2 flex justify-between items-center mb-2">
<div className="flex items-center">
@ -207,31 +130,6 @@ const MessageInput = ({
</PopoverContent>
</Popover>
{canSendImage && (
<>
<input
ref={fileInputRef}
type="file"
accept={ALLOWED_IMAGE_TYPES.join(",")}
onChange={handleFileSelected}
className="hidden"
/>
<button
type="button"
onClick={handlePickImage}
disabled={isUploadingImage}
className="absolute right-10 bottom-3 w-6 h-6 flex items-center justify-center disabled:opacity-50"
aria-label="ارسال تصویر"
>
{isUploadingImage ? (
<Loader2 className="w-5 h-5 animate-spin text-GRAY" />
) : (
<ImageIcon className="w-5 h-5 text-GRAY hover:text-BLACK" />
)}
</button>
</>
)}
<textarea
ref={textareaRef}
value={messageText}
@ -244,13 +142,16 @@ const MessageInput = ({
}
className="hide-scrollbar overflow-y-auto h-12 flex w-full rounded-[12px] border border-input bg-transparent px-3 pr-10 py-2 text-base shadow-sm resize-none outline-none transition-height duration-100"
style={{
paddingRight: canSendImage ? "72px" : "40px",
paddingRight: "40px",
paddingLeft: "48px",
direction: "rtl",
}}
// Enter inserts a newline. Send is via the button only — matches
// seller request: multi-line messages are common when replying to
// a customer with details, and accidental Enter-sends were noisy.
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
/>
<button
onClick={handleSend}

View File

@ -22,8 +22,6 @@ const MessageList = forwardRef(
isLoading,
isFetching,
userId,
teamUserIds,
viewerIsStore = false,
onReply,
onDelete,
onScroll,
@ -33,13 +31,6 @@ const MessageList = forwardRef(
isLoading: boolean;
isFetching: boolean;
userId: string | undefined;
// Senders on the store team. Only treated as "our side" when the VIEWER
// is store-side (viewerIsStore) — otherwise a customer would wrongly see
// the store's staff messages on their own (right) side.
teamUserIds?: readonly string[];
// True when the current viewer is on this thread's store team. Gates all
// team-grouping so the customer view stays "my messages right, store left".
viewerIsStore?: boolean;
onReply: (message: MessageListType) => void;
onDelete?: (messageId: string) => void;
onScroll?: (event: React.UIEvent<HTMLDivElement>) => void;
@ -172,18 +163,7 @@ const MessageList = forwardRef(
<MessageBubble
otherParticipant={otherParticipant}
message={message}
isCurrentUser={
message.sender === userId ||
(viewerIsStore &&
!!message.sender &&
!!teamUserIds?.includes(message.sender))
}
isCoworker={
viewerIsStore &&
message.sender !== userId &&
!!message.sender &&
!!teamUserIds?.includes(message.sender)
}
isCurrentUser={message.sender === userId}
onReply={onReply}
onDelete={onDelete}
replyToMessageContent={message.replyContent || ""}

View File

@ -80,7 +80,6 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
createdAt: new Date().toISOString(),
replyTo: newMessage.reply_to_id,
replyContent: newMessage.reply_content || "",
imageUrl: newMessage.image_url || undefined,
});
resetNewMessage();
setTimeout(() => {
@ -220,26 +219,6 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
setMessageText("");
};
// Send an image the user already uploaded. Content stays empty; the bubble
// renders the image with no accompanying text.
const handleSendImage = (imageUrl: string) => {
if (isConnected) {
sendWebSocketMessage(
"",
replyToMessage?.id,
replyToMessage?.content,
imageUrl,
);
}
setReplyToMessage(undefined);
};
// Team membership check: current user in teamUserIds → can attach images.
const currentUserIsTeam = !!(
user?.id &&
threadDetails?.teamUserIds?.includes(user.id)
);
// Handle reply to message
const handleReplyClick = (message: MessageListType) => {
setReplyToMessage(message);
@ -275,7 +254,7 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
) : (
<>
<div
className={`hide-scrollbar flex-grow w-full max-h-[calc(100vh_-_${replyToMessage ? "120px" : "75px"}_-_env(safe-area-inset-top)_-_env(safe-area-inset-bottom))] flex flex-col`}
className={`hide-scrollbar flex-grow w-full max-h-[calc(100vh-${replyToMessage ? "120px" : "75px"})] flex flex-col`}
>
{/* Messages list */}
<MessageList
@ -285,8 +264,6 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
isLoading={isLoadingMessages || isLoadingThread}
isFetching={isFetchingNextPage}
userId={user?.id}
teamUserIds={threadDetails?.teamUserIds}
viewerIsStore={threadDetails?.viewerIsStore}
onReply={handleReplyClick}
onDelete={handleDeleteMessage}
onScroll={handleScroll}
@ -299,8 +276,6 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
messageText={messageText}
setMessageText={setMessageText}
onSend={handleSendMessage}
onSendImage={handleSendImage}
canSendImage={currentUserIsTeam}
replyToMessage={replyToMessage}
onCancelReply={() => setReplyToMessage(undefined)}
inputRef={inputRef}

View File

@ -1,10 +1,7 @@
import { Link } from "@remix-run/react";
import AppInput from "../AppInput";
import { useState } from "react";
import {
useGetUserThreads,
useGetStoreThreads,
} from "../../requestHandler/use-chat-hooks";
import { useGetUserThreads } from "../../requestHandler/use-chat-hooks";
import {
ThreadList,
ThreadParticipant,
@ -13,33 +10,9 @@ import {
import { useServiceGetProfile } from "../../requestHandler/use-profile-hooks";
import UiProvider from "../UiProvider";
import SellerLogo from "../SellerLogo";
import { CheckCheck, MessageCircleWarning, Search, User } from "lucide-react";
import { Search, User } from "lucide-react";
import ProfilePagesHeader from "../ProfilePagesHeader";
// Persian-style relative timestamp for the inbox list. Older messages need a
// day label so the seller can tell "today's message" from "3 months ago"
// without reading the date.
function formatThreadTimestamp(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
const now = new Date();
const startOfDay = (x: Date) =>
new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
const dayDiff = Math.round(
(startOfDay(now) - startOfDay(d)) / (1000 * 60 * 60 * 24),
);
const timePart = d.toLocaleTimeString("fa-IR", {
hour: "2-digit",
minute: "2-digit",
});
if (dayDiff <= 0) return timePart; // today: just HH:MM
if (dayDiff === 1) return `دیروز ${timePart}`;
if (dayDiff < 7) return `${dayDiff} روز پیش`;
// Older than a week: show the date in Persian calendar.
return d.toLocaleDateString("fa-IR");
}
interface ThreadsPageProps {
type: "user" | "store";
storeId?: string;
@ -47,16 +20,7 @@ interface ThreadsPageProps {
export default function ThreadsPage({ type, storeId }: ThreadsPageProps) {
const [searchValue, setSearchValue] = useState("");
// Seller dashboard shows the STORE's inbox; the profile page shows the user's
// personal (buyer) chats. Two different endpoints so the two never mix.
const userThreads = useGetUserThreads();
const storeThreads = useGetStoreThreads(type === "store" ? storeId : undefined);
const {
data: threads,
isLoading,
isError,
refetch,
} = type === "store" ? storeThreads : userThreads;
const { data: threads, isLoading, isError, refetch } = useGetUserThreads();
const sortedThreads = threads?.sort((a, b) => {
const dateA = new Date(a.lastMessageTime || "2025-01-18T00:00:00.000Z");
const dateB = new Date(b.lastMessageTime || "2025-01-18T00:00:00.000Z");
@ -133,24 +97,6 @@ const ThreadsList = ({
);
const hasUnread = Number(thread.unreadCount || "0") > 0;
// Seller-inbox only. Shows what state the thread is in so the team
// can see at a glance which chats need attention.
// - "needs-reply" → last message is from the customer (or nobody
// on the team has replied yet).
// - "unread-by-customer" → team replied, customer hasn't seen it.
// - "seen" → team replied and the customer opened it.
const showStatus = type === "store";
const lastFromTeam = !!thread.lastMessageFromTeam;
const otherSeen = !!thread.otherPartySeenLast;
const status: "needs-reply" | "unread-by-customer" | "seen" | null =
!showStatus
? null
: !lastFromTeam
? "needs-reply"
: otherSeen
? "seen"
: "unread-by-customer";
// Generate link based on type
const linkTo =
type === "user"
@ -170,32 +116,22 @@ const ThreadsList = ({
)}
<div className="flex flex-col w-full">
<div className="w-full flex justify-between items-center">
<div className="flex items-center gap-1 min-w-0">
{status === "needs-reply" && (
<MessageCircleWarning
size={16}
className="text-RED shrink-0"
aria-label="در انتظار پاسخ شما"
/>
)}
{status === "seen" && (
<CheckCheck
size={16}
className="text-blue-500 shrink-0"
aria-label="پیام دیده شده"
/>
)}
<p className={`text-R14 truncate ${hasUnread || status === "needs-reply" ? "font-bold" : ""}`}>
<p className={`text-R14 ${hasUnread ? "font-bold" : ""}`}>
{thread.isCreator
? otherParticipant?.storeName ||
otherParticipant?.username
: otherParticipant?.name}
</p>
</div>
<div className="flex flex-col items-end justify-center">
<div className="flex flex-col items-center justify-center">
{thread.lastMessageTime && (
<p className="text-R10 text-GRAY">
{formatThreadTimestamp(thread.lastMessageTime)}
{new Date(thread.lastMessageTime).toLocaleTimeString(
"fa-IR",
{
hour: "2-digit",
minute: "2-digit",
}
)}
</p>
)}
{hasUnread && (
@ -208,12 +144,7 @@ const ThreadsList = ({
<p
className={`text-R14 ${hasUnread ? "text-BLACK font-bold" : "text-GRAY"}`}
>
{thread.lastMessage?.content ||
(thread.lastMessage?.imageUrl
? "📷 تصویر"
: thread.lastMessage?.product
? `🛍 ${thread.lastMessage.product.title || "محصول"}`
: "بدون پیام")}
{thread.lastMessage?.content || "بدون پیام"}
</p>
</div>
</div>

View File

@ -1,76 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthToken } from "./use-root-data";
import { getApiBaseUrl } from "~/utils/api-config";
// The API returns these as camelCase (DRF's global camel-case renderer converts
// the snake_case source names). Match the wire shape here so reads aren't
// silently undefined.
export interface BadgeCounts {
chat: number;
sellerChat: Record<string, number>;
orderUpdates: number;
sellerOrders: number;
}
const EMPTY: BadgeCounts = {
chat: 0,
sellerChat: {},
orderUpdates: 0,
sellerOrders: 0,
};
export const badgeKeys = {
all: ["notif-badges"] as const,
};
/**
* Polls unread counts for the nav badges (chat / buyer order updates / seller
* new orders). Lightweight: a single endpoint, refetched on an interval and
* when the tab regains focus.
*/
export function useBadgeCounts() {
const token = useAuthToken();
const apiBase = getApiBaseUrl();
return useQuery({
queryKey: badgeKeys.all,
enabled: !!token,
refetchInterval: 30000,
refetchOnWindowFocus: true,
queryFn: async (): Promise<BadgeCounts> => {
if (!token) return EMPTY;
const res = await fetch(`${apiBase}/api/notif/v1/badges/`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return EMPTY;
return (await res.json()) as BadgeCounts;
},
});
}
/**
* Marks a badge category ("order_updates" | "seller_orders") as read, then
* refreshes the counts. Chat clears via the existing per-thread read flow.
*/
export function useMarkBadgeRead() {
const token = useAuthToken();
const apiBase = getApiBaseUrl();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (category: "order_updates" | "seller_orders") => {
if (!token) return;
await fetch(`${apiBase}/api/notif/v1/badges/mark-read/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ category }),
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: badgeKeys.all });
},
});
}

View File

@ -1,254 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { useAuthToken } from "./use-root-data";
import { getApiBaseUrl } from "~/utils/api-config";
import { toast } from "./use-toast";
/**
* Web Push subscription management.
*
* Flow: ask permission -> fetch VAPID public key -> subscribe via the service
* worker's PushManager -> POST the subscription to the backend. Disabling
* unsubscribes locally and tells the backend to drop the row.
*
* Every awaited step is guarded by a timeout several of them (notably
* `pushManager.subscribe` and `serviceWorker.ready` on iOS Safari when the
* PWA isn't home-screen-installed) can return a Promise that never resolves.
* Without timeouts the toggle wedges as "..." forever.
*/
type PermissionState = NotificationPermission | "unsupported";
const STEP_TIMEOUT_MS = 15000;
function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const t = setTimeout(() => reject(new Error(`timeout: ${label}`)), ms);
p.then(
(v) => { clearTimeout(t); resolve(v); },
(e) => { clearTimeout(t); reject(e); },
);
});
}
function isIosSafari(): boolean {
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent || "";
const isIos = /iPad|iPhone|iPod/.test(ua) && !("MSStream" in window);
return isIos;
}
function isStandalonePwa(): boolean {
if (typeof window === "undefined") return false;
const mm = window.matchMedia?.("(display-mode: standalone)").matches;
// iOS-specific standalone flag on the Navigator object.
const nav = navigator as Navigator & { standalone?: boolean };
return !!(mm || nav.standalone);
}
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = atob(base64);
// Back the array with a concrete ArrayBuffer so it satisfies BufferSource.
const outputArray = new Uint8Array(new ArrayBuffer(rawData.length));
for (let i = 0; i < rawData.length; i++) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
function isPushSupported(): boolean {
return (
typeof window !== "undefined" &&
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window
);
}
export function usePushNotifications() {
const token = useAuthToken();
const apiBase = getApiBaseUrl();
const [permission, setPermission] = useState<PermissionState>("default");
const [isSubscribed, setIsSubscribed] = useState(false);
const [busy, setBusy] = useState(false);
const authHeaders = useCallback(() => {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
return headers;
}, [token]);
// Reflect current permission + subscription state on mount.
//
// We *also* re-POST the sub to the backend on every mount. Reasons:
// - iOS Safari silently expires push subs after a few days of inactivity;
// the local `getSubscription()` still returns the token but the backend
// row may have been 410'd and deleted. Re-POSTing recreates it.
// - Multi-device: a sub registered on device A may not exist server-side
// for device B. Idempotent upsert (`update_or_create` by endpoint) makes
// this safe to do on every visit.
useEffect(() => {
if (!isPushSupported()) {
setPermission("unsupported");
return;
}
setPermission(Notification.permission);
withTimeout(navigator.serviceWorker.ready, STEP_TIMEOUT_MS, "serviceWorker.ready")
.then((reg) => withTimeout(reg.pushManager.getSubscription(), STEP_TIMEOUT_MS, "getSubscription"))
.then((sub) => {
setIsSubscribed(!!sub);
if (sub && token) {
// Fire-and-forget refresh; failure is non-fatal.
fetch(`${apiBase}/api/notif/v1/push/subscribe/`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(sub.toJSON()),
}).catch(() => {});
}
})
.catch(() => setIsSubscribed(false));
}, [apiBase, token, authHeaders]);
const subscribe = useCallback(async (): Promise<boolean> => {
if (!isPushSupported() || !token) return false;
// iOS Safari only allows Web Push from a home-screen-installed PWA. In a
// regular Safari tab, `pushManager.subscribe` returns a Promise that
// never resolves — indistinguishable from a hang. Bail out early with a
// clear message rather than wedging the UI.
if (isIosSafari() && !isStandalonePwa()) {
toast({
title: "برای فعال‌سازی، ابتدا سایت را به صفحه اصلی اضافه کنید",
description:
"در Safari دکمه اشتراک‌گذاری را بزنید و «Add to Home Screen» را انتخاب کنید، سپس از آیکن روی صفحه اصلی وارد شوید و دوباره تلاش کنید.",
});
return false;
}
setBusy(true);
try {
const perm = await withTimeout(
Notification.requestPermission(),
STEP_TIMEOUT_MS,
"requestPermission",
);
setPermission(perm);
if (perm !== "granted") {
if (perm === "denied") {
toast({
title: "اعلان‌ها در مرورگر مسدود شده‌اند",
description:
"از تنظیمات مرورگر یا PWA اعلان‌ها را برای این سایت فعال کنید و صفحه را رفرش کنید.",
variant: "destructive",
});
}
return false;
}
// Get the VAPID public key from the backend. The backend's view returns
// {"public_key": ...} but its global CamelCaseJSONRenderer rewrites that
// to {"publicKey": ...} on the wire — so we read camelCase here.
const keyRes = await withTimeout(
fetch(`${apiBase}/api/notif/v1/push/vapid-public-key/`),
STEP_TIMEOUT_MS,
"vapid-key fetch",
);
const { publicKey } = await keyRes.json();
if (!publicKey) return false;
const reg = await withTimeout(
navigator.serviceWorker.ready,
STEP_TIMEOUT_MS,
"serviceWorker.ready",
);
let sub = await withTimeout(
reg.pushManager.getSubscription(),
STEP_TIMEOUT_MS,
"getSubscription",
);
if (!sub) {
sub = await withTimeout(
reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
}),
STEP_TIMEOUT_MS,
"pushManager.subscribe",
);
}
const res = await withTimeout(
fetch(`${apiBase}/api/notif/v1/push/subscribe/`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(sub.toJSON()),
}),
STEP_TIMEOUT_MS,
"backend subscribe",
);
const ok = res.ok;
setIsSubscribed(ok);
if (ok) {
toast({
title: "اعلان‌ها فعال شد",
description: "از این پس اعلان‌ها را دریافت می‌کنید.",
});
}
return ok;
} catch (e) {
console.error("Push subscribe failed:", e);
// Include the step name / error text in the toast so field debugging
// doesn't need browser console access — sellers can read out what
// failed to whoever's helping them.
const detail = e instanceof Error ? e.message : String(e);
toast({
title: "فعال‌سازی اعلان با خطا مواجه شد",
description: `${detail || "خطای ناشناخته"}. لطفاً چند لحظه بعد دوباره تلاش کنید.`,
variant: "destructive",
});
return false;
} finally {
setBusy(false);
}
}, [apiBase, token, authHeaders]);
const disable = useCallback(async (): Promise<void> => {
if (!isPushSupported()) return;
setBusy(true);
try {
const reg = await withTimeout(
navigator.serviceWorker.ready,
STEP_TIMEOUT_MS,
"serviceWorker.ready",
);
const sub = await withTimeout(
reg.pushManager.getSubscription(),
STEP_TIMEOUT_MS,
"getSubscription",
);
if (sub) {
await fetch(`${apiBase}/api/notif/v1/push/unsubscribe/`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ endpoint: sub.endpoint }),
}).catch(() => {});
await sub.unsubscribe().catch(() => {});
}
setIsSubscribed(false);
} catch (e) {
console.error("Push disable failed:", e);
} finally {
setBusy(false);
}
}, [apiBase, authHeaders]);
return {
supported: permission !== "unsupported",
permission,
isSubscribed,
busy,
subscribe,
disable,
};
}

View File

@ -1,23 +0,0 @@
import { useEffect } from "react";
import { useNavigate } from "@remix-run/react";
import { useServiceGetProfile } from "~/requestHandler/use-profile-hooks";
/**
* Gate a route to platform superusers. Non-admins (or unauthenticated) are
* bounced to the home page. Server-side, `/admin` is also in `protectedRoutes`
* so logged-out users never reach here this adds the is-superuser check.
*/
export function useRequireAdmin() {
const navigate = useNavigate();
const { data, isLoading, isError } = useServiceGetProfile();
const isAdmin = !!data?.isSuperuser;
useEffect(() => {
if (isLoading) return;
if (isError || !isAdmin) {
navigate("/", { replace: true });
}
}, [isLoading, isError, isAdmin, navigate]);
return { isLoading, isAdmin };
}

View File

@ -1,71 +1,46 @@
import { useEffect } from "react";
import { useNavigate } from "@remix-run/react";
import { useMyStores } from "../requestHandler/use-seller-hooks";
import { useServiceGetProfile } from "../requestHandler/use-profile-hooks";
/**
* Guards a store dashboard route. A user may enter a store they OWN or are an
* active STAFF member of. Non-members are redirected away. Returns the user's
* role for this store so callers can gate owner-only UI.
*/
export function useStoreOwnership(storeId: string | undefined, mode?: string) {
const navigate = useNavigate();
const { data: stores, isLoading, isFetching, isError } = useMyStores();
const current = stores?.find(
(s) => s.username?.toLowerCase() === storeId?.toLowerCase()
);
const { data: profileData, isLoading } = useServiceGetProfile();
useEffect(() => {
if (isLoading || isFetching || isError || mode === "create") return;
if (!storeId) {
// Wait for profile data to load
if (isLoading) return;
// If no storeId provided, redirect to home
if (!storeId && mode !== "create") {
navigate("/");
return;
}
if (!stores || stores.length === 0) return; // nothing to compare against yet
if (!current) {
// The user is a member of at least one OTHER store — send them there.
// If they're not a member of any store, do NOT bounce to home: staying
// put lets an owner see their (empty) dashboard rather than being
// silently kicked out on a transient/mismatched my-stores response.
navigate(`/store/${stores[0].username}`);
}
}, [
stores,
current,
storeId,
isLoading,
isFetching,
isError,
navigate,
mode,
]);
const ownedOrFirst = stores?.find((s) => s.role === "owner") ?? stores?.[0];
// If user doesn't have profile data, redirect to home
if (!profileData) {
navigate("/");
return;
}
// If user doesn't have a seller account, redirect to home
if (!profileData.sellerUsername && mode !== "create") {
navigate("/");
return;
}
// If the storeId doesn't match the user's seller username, redirect to their own store
if (
profileData.sellerUsername !== storeId &&
mode !== "create" &&
storeId
) {
navigate(`/store/${profileData.sellerUsername}`);
return;
}
}, [profileData, storeId, isLoading, navigate, mode]);
return {
isOwner: profileData?.sellerUsername === storeId,
isLoading,
isMember: !!current,
role: current?.role ?? null,
isOwner: current?.role === "owner",
userStoreId: ownedOrFirst?.username,
stores: stores ?? [],
userStoreId: profileData?.sellerUsername,
};
}
/**
* Guard for OWNER-ONLY store pages (financial, shipping, edit, team). Redirects
* non-members (via useStoreOwnership) and staff members back to the dashboard.
* Returns { isLoading, isOwner } so callers can render a spinner meanwhile.
*/
export function useRequireOwner(storeId: string | undefined) {
const navigate = useNavigate();
const { isLoading, role } = useStoreOwnership(storeId);
useEffect(() => {
if (!isLoading && role && role !== "owner") {
navigate(`/store/${storeId}`);
}
}, [isLoading, role, storeId, navigate]);
return { isLoading, isOwner: role === "owner" };
}

View File

@ -69,8 +69,8 @@ const useWebSocket = (threadId: string, userId: string | undefined) => {
const sendWebSocketMessage = (
content: string,
replyToId?: string | null,
replyContent?: string | null,
imageUrl?: string | null,
// messageId?: string | null,
replyContent?: string | null
) => {
if (!socket || socket.readyState !== WebSocket.OPEN || !userId) {
console.error("WebSocket is not connected");
@ -80,9 +80,10 @@ const useWebSocket = (threadId: string, userId: string | undefined) => {
const message: SendMessagePayload = {
type: "new_message",
content,
// sender_id: userId, // Remove this as it's not needed in client->server communication
...(replyToId && { reply_to_id: replyToId }),
...(replyContent && { reply_content: replyContent }),
...(imageUrl && { image_url: imageUrl }),
// ...(messageId && { message_id: messageId }),
};
socket.send(JSON.stringify(message));
@ -147,7 +148,6 @@ interface SendMessagePayload extends WebSocketMessage {
content: string;
reply_to_id?: string | null;
reply_content?: string | null;
image_url?: string | null;
}
interface MarkReadPayload extends WebSocketMessage {
@ -162,7 +162,6 @@ interface NewMessageEvent extends WebSocketMessage {
created_at: string;
reply_to_id?: string | null;
reply_content?: string | null;
image_url?: string | null;
}
interface ReadReceiptEvent extends WebSocketMessage {

View File

@ -1,71 +0,0 @@
/**
* Category-aware measurement suggestions for the product size chart (راهنمای سایز).
*
* Sellers can always type their own columns; these just power the quick-add
* chips and seed a sensible default column set per garment type. Matching is by
* Persian keyword substring against the product's category name, most-specific
* groups first, falling back to a general garment set.
*/
interface SuggestionGroup {
keys: string[];
suggestions: string[];
}
// Order matters: the first group whose keyword appears in the category name wins.
const GROUPS: SuggestionGroup[] = [
// ---- bottoms ----
{ keys: ["شلوار", "شلوارک"], suggestions: ["دور کمر", "دور باسن", "فاق", "دور ران", "قد", "دمپا"] },
{ keys: ["دامن"], suggestions: ["دور کمر", "دور باسن", "قد", "دمپا"] },
{ keys: ["مایو"], suggestions: ["دور سینه", "دور کمر", "دور باسن", "قد"] },
// ---- footwear ----
{ keys: ["کفش", "صندل", "دمپایی", "پاشنه"], suggestions: ["سایز اروپایی", "طول کف پا"] },
// ---- small accessories with their own sizing ----
{ keys: ["جوراب"], suggestions: ["سایز", "طول ساق"] },
{ keys: ["کلاه"], suggestions: ["دور سر"] },
{ keys: ["کمربند"], suggestions: ["طول", "عرض"] },
{ keys: ["دستکش"], suggestions: ["دور مچ", "طول دست"] },
{ keys: ["انگشتر"], suggestions: ["سایز انگشتر", "دور انگشت"] },
{ keys: ["دستبند", "پابند"], suggestions: ["دور مچ", "طول"] },
{ keys: ["گردنبند", "آویز"], suggestions: ["طول زنجیر"] },
{ keys: ["گوشواره", "گل سینه", "زیورآلات", "سردست"], suggestions: ["طول", "عرض"] },
{ keys: ["شال", "روسری", "دستمال"], suggestions: ["طول", "عرض"] },
{ keys: ["عینک"], suggestions: ["عرض فریم", "طول دسته"] },
// ---- bags ----
{ keys: ["کیف", "کوله"], suggestions: ["طول", "عرض", "ارتفاع", "طول بند"] },
// ---- one-piece / manteau ----
{ keys: ["مانتو", "کیمونو"], suggestions: ["دور سینه", "قد", "سرشانه", "آستین", "دور کمر"] },
{ keys: ["اورال", "پیراهن و اورال"], suggestions: ["دور سینه", "دور کمر", "دور باسن", "قد", "آستین"] },
// ---- tops & outerwear (broad garment fallback) ----
{
keys: [
"کاپشن", "بارانی", "پالتو", "کت", "ژاکت", "بامبر", "جلیقه", "دورس",
"سویشرت", "هودی", "پلیور", "بلوز", "شومیز", "تیشرت", "تاپ", "پلوشرت", "پیراهن",
],
suggestions: ["دور سینه", "قد", "سرشانه", "آستین", "عرض سینه", "دور کمر"],
},
];
// Generic fallback for anything unmatched (also the base defaults the seller named).
const DEFAULT_SUGGESTIONS = ["عرض سینه", "قد", "دور کمر", "دور باسن", "سرشانه", "آستین"];
/** All measurement suggestions for a category (used for the quick-add chips). */
export function getSizeSuggestions(categoryName?: string | null): string[] {
const name = (categoryName || "").trim();
if (name) {
for (const group of GROUPS) {
if (group.keys.some((k) => name.includes(k))) return group.suggestions;
}
}
return DEFAULT_SUGGESTIONS;
}
/** The columns to seed a brand-new (empty) chart with for this category. */
export function getDefaultColumns(categoryName?: string | null): string[] {
return getSizeSuggestions(categoryName).slice(0, 2);
}

View File

@ -1,818 +0,0 @@
import {
useQuery,
useMutation,
useQueryClient,
keepPreviousData,
} from "@tanstack/react-query";
import { useAuthToken } from "~/hooks/use-root-data";
import { getApiBaseUrl } from "~/utils/api-config";
/* ------------------------------------------------------------------ *
* Admin panel data hooks (superuser only). The admin API is a new,
* fast-moving surface, so these call it directly with fetch (same
* pattern as use-seller-hooks' useMyStores) rather than the generated
* client. Responses are camelCased by the backend renderer.
* ------------------------------------------------------------------ */
export interface AdminOverview {
generatedAt: string;
users: {
total: number;
active: number;
sellers: number;
new7d: number;
new30d: number;
};
sellers: {
total: number;
pending: number;
approved: number;
rejected: number;
verified: number;
};
products: { total: number; active: number };
orders: {
total: number;
pending: number;
confirmed: number;
shipped: number;
delivered: number;
cancelled: number;
paidCount: number;
revenueTotal: number;
ordersToday: number;
revenueToday: number;
};
money: {
walletHeld: number;
sellerPayable: number;
payoutsPendingCount: number;
payoutsPendingAmount: number;
};
ordersSeries: Array<{ date: string; orders: number; revenue: number }>;
}
async function adminGet<T>(path: string, token: string): Promise<T> {
const res = await fetch(`${getApiBaseUrl()}/api/adminpanel/v1/${path}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
throw new Error(`Admin request failed (${res.status})`);
}
return res.json();
}
async function adminPatch<T>(
path: string,
body: Record<string, unknown>,
token: string
): Promise<T> {
const res = await fetch(`${getApiBaseUrl()}/api/adminpanel/v1/${path}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`Admin update failed (${res.status})`);
}
return res.json();
}
export function useAdminOverview() {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", "overview"],
enabled: !!token,
refetchInterval: 60_000,
queryFn: () => adminGet<AdminOverview>("overview/", token as string),
});
}
/* ------------------------------ boards ------------------------------ */
export interface Paginated<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}
export type AdminListParams = Record<
string,
string | number | boolean | undefined
>;
function toQuery(params: AdminListParams): string {
const usp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== "" && v !== null) usp.set(k, String(v));
});
const s = usp.toString();
return s ? `?${s}` : "";
}
function useAdminList<T>(key: string, params: AdminListParams) {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", key, params],
enabled: !!token,
placeholderData: keepPreviousData,
queryFn: () =>
adminGet<Paginated<T>>(`${key}/${toQuery(params)}`, token as string),
});
}
export interface AdminOrderRow {
id: string;
createdAt: string;
status: string;
paymentStatus: string;
totalPrice: number;
userPhone: string | null;
userName: string | null;
sellerName: string | null;
sellerUsername: string | null;
}
export interface AdminSellerRow {
id: string;
username: string;
storeName: string;
storeLogo: string | null;
status: string;
isVerified: boolean;
platformFee: number;
productCount: number;
followerCount: number;
reviewAverage: number;
createdAt: string;
userPhone: string | null;
}
export interface AdminUserRow {
id: string;
phoneNumber: string;
username: string | null;
email: string | null;
isActive: boolean;
isSeller: boolean;
isStaff: boolean;
isSuperuser: boolean;
otpVerified: boolean;
createdAt: string;
}
export interface AdminProductRow {
id: string;
title: string;
basePrice: number;
isActive: boolean;
createdAt: string;
sellerName: string | null;
sellerUsername: string | null;
categoryName: string | null;
primaryImage: string | null;
}
export const useAdminOrders = (params: AdminListParams) =>
useAdminList<AdminOrderRow>("orders", params);
export const useAdminSellers = (params: AdminListParams) =>
useAdminList<AdminSellerRow>("sellers", params);
export const useAdminUsers = (params: AdminListParams) =>
useAdminList<AdminUserRow>("users", params);
export const useAdminProducts = (params: AdminListParams) =>
useAdminList<AdminProductRow>("products", params);
/* ----------------------------- actions ------------------------------ */
function useAdminUpdate(resource: "sellers" | "users" | "products") {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
adminPatch(`${resource}/${id}/`, data, token as string),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin", resource] });
qc.invalidateQueries({ queryKey: ["admin", "overview"] });
},
});
}
export const useAdminSellerUpdate = () => useAdminUpdate("sellers");
export const useAdminUserUpdate = () => useAdminUpdate("users");
export const useAdminProductUpdate = () => useAdminUpdate("products");
/* ------------------------------ ledger ------------------------------ */
export interface LedgerOverview {
walletHeld: number;
sellerPayable: number;
depositsTotal: number;
purchasesTotal: number;
payouts: {
pending: { amount: number; count: number };
completed: { amount: number; count: number };
rejected: { amount: number; count: number };
};
reconciliation: {
completedPayoutsNotDebited: number;
sellerPayableRecorded: number;
sellerPayableEstimatedTrue: number;
};
// Slice 2c — balances derived from the append-only journal.
journal: {
sellerPayable: number;
walletHeld: number;
platformRevenue: number;
sellerPayableDrift: number;
walletHeldDrift: number;
};
}
export interface LedgerEntryRow {
id: string;
createdAt: string;
entryType: string;
account: string;
delta: number;
sellerName: string | null;
userPhone: string | null;
order: string | null;
note: string | null;
}
export interface LedgerTransactionRow {
id: string;
transactionType: string;
amount: number;
userPhone: string | null;
order: string | null;
authority: string | null;
createdAt: string;
}
export interface LedgerPayoutRow {
id: string;
sellerName: string | null;
sellerUsername: string | null;
amount: number;
status: string;
requestedAt: string;
processedAt: string | null;
iban: string | null;
bankName: string | null;
accountHolder: string | null;
shomareMarja: string | null;
shomareErja: string | null;
}
export interface LedgerSellerRow {
sellerId: string;
storeName: string | null;
username: string | null;
availableFunds: number;
completedPayouts: number;
pendingPayouts: number;
}
export interface LedgerSellerDetail {
seller: { id: string; storeName: string; username: string; platformFee: number };
recordedBalance: number;
accruedFromOrders: number;
completedPayouts: number;
pendingPayouts: number;
derivedBalance: number;
discrepancy: number;
journalBalance: number;
journalDiscrepancy: number;
bankAccounts: Array<{
id: string;
accountHolder: string;
iban: string;
cardNumber: string;
bankName: string;
isVerified: boolean;
}>;
payouts: LedgerPayoutRow[];
journal: LedgerEntryRow[];
}
export function useLedgerOverview() {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", "ledger", "overview"],
enabled: !!token,
queryFn: () => adminGet<LedgerOverview>("ledger/overview/", token as string),
});
}
export const useLedgerTransactions = (params: AdminListParams) =>
useAdminList<LedgerTransactionRow>("ledger/transactions", params);
export const useLedgerPayouts = (params: AdminListParams) =>
useAdminList<LedgerPayoutRow>("ledger/payouts", params);
export const useLedgerSellers = (params: AdminListParams) =>
useAdminList<LedgerSellerRow>("ledger/sellers", params);
export const useLedgerJournal = (params: AdminListParams) =>
useAdminList<LedgerEntryRow>("ledger/journal", params);
/* ------------------------------ audit log ------------------------------ */
export interface AuditLogRow {
id: string;
createdAt: string;
actorPhone: string | null;
action: string;
targetType: string;
targetId: string | null;
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
note: string;
}
export const useAuditLog = (params: AdminListParams) =>
useAdminList<AuditLogRow>("audit", params);
/* -------------------- discounts & collections (3b) -------------------- */
export interface AdminDiscountRow {
id: string;
name: string;
code: number;
discountType: string;
value: number;
minPurchaseAmount: number | null;
maxDiscountAmount: number | null;
validFrom: string;
validTo: string;
isActive: boolean;
eligibleUsers: string[] | null;
maxCount: number | null;
isValid: boolean;
}
export interface AdminCollectionRow {
id: string;
title: string;
description: string | null;
creatorType: string;
creatorPhone: string | null;
isPublic: boolean;
coverImageUrl: string | null;
productCount: number;
createdAt: string;
}
/* --------------------------- moderation (3d) --------------------------- */
export interface AdminCommentRow {
id: string;
productId: string;
productTitle: string | null;
userPhone: string | null;
comment: string;
parentId: string | null;
isReply: boolean;
repliesCount: number;
isHidden: boolean;
isVerifiedBuyer: boolean;
createdAt: string;
}
export interface AdminChatMessageRow {
id: string;
content: string;
senderPhone: string | null;
threadId: string | null;
productId: string | null;
imageUrl: string | null;
isDeleted: boolean;
createdAt: string;
}
export const useAdminComments = (params: AdminListParams) =>
useAdminList<AdminCommentRow>("moderation/comments", params);
export const useAdminChatMessages = (params: AdminListParams) =>
useAdminList<AdminChatMessageRow>("moderation/chat", params);
export function useAdminCommentModerate() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, isHidden }: { id: string; isHidden: boolean }) =>
adminPatch(`moderation/comments/${id}/`, { isHidden }, token as string),
onSuccess: () =>
qc.invalidateQueries({ queryKey: ["admin", "moderation/comments"] }),
});
}
export function useAdminCommentDelete() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminDelete(`moderation/comments/${id}/`, token as string),
onSuccess: () =>
qc.invalidateQueries({ queryKey: ["admin", "moderation/comments"] }),
});
}
/* -------------------------- notifications (3e) -------------------------- */
export interface NotificationTypeRow {
id: number;
code: string;
title: string;
defaultSms: boolean;
defaultInApp: boolean;
template: string;
variables: string[];
}
export interface NotificationRow {
id: number;
typeCode: string | null;
typeTitle: string | null;
receiverPhone: string | null;
title: string;
body: string;
read: boolean;
sentSms: boolean;
sentInApp: boolean;
smsLineNumber: number | null;
createdAt: string;
}
export interface SmsBlacklistRow {
id: number;
mobile: string;
lineNumber: number;
reason: string | null;
createdAt: string;
}
export interface NotificationsOverview {
total: number;
smsSent: number;
inAppSent: number;
unread: number;
types: number;
pushSubscriptions: number;
blacklistedLines: number;
byType: { code: string; title: string; count: number }[];
}
export function useNotificationsOverview() {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", "notifications", "overview"],
enabled: !!token,
queryFn: () => adminGet<NotificationsOverview>("notifications/overview/", token as string),
});
}
export const useNotificationTypes = (params: AdminListParams) =>
useAdminList<NotificationTypeRow>("notifications/types", params);
export const useNotifications = (params: AdminListParams) =>
useAdminList<NotificationRow>("notifications/sent", params);
export const useSmsBlacklist = (params: AdminListParams) =>
useAdminList<SmsBlacklistRow>("notifications/blacklist", params);
export function useNotificationTypeCreate() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Record<string, unknown>) =>
adminPost("notifications/types/", data, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "notifications/types"] }),
});
}
export function useNotificationTypeUpdate() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) =>
adminPatch(`notifications/types/${id}/`, data, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "notifications/types"] }),
});
}
export function useNotificationTypeDelete() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => adminDelete(`notifications/types/${id}/`, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "notifications/types"] }),
});
}
export function useSmsBlacklistDelete() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => adminDelete(`notifications/blacklist/${id}/`, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "notifications/blacklist"] }),
});
}
/* --------------------- homepage / merchandising (3f) --------------------- */
export interface HomeBannerRow {
id: number;
title: string;
linkType: string;
linkId: number;
isActive: boolean;
isVisible: boolean;
imageUrl: string | null;
startDate: string;
endDate: string;
createdAt: string;
}
export interface HomeCategoryRow {
id: string;
categoryId: string;
categoryName: string | null;
imageUrl: string | null;
isActive: boolean;
}
export interface HomeSellerTileRow {
id: string;
sellerId: string;
sellerName: string | null;
imageUrl: string | null;
isActive: boolean;
order: number;
createdAt: string;
}
export const useHomeBanners = (params: AdminListParams) =>
useAdminList<HomeBannerRow>("homepage/banners", params);
export const useHomeCategories = (params: AdminListParams) =>
useAdminList<HomeCategoryRow>("homepage/categories", params);
export const useHomeSellerTiles = (params: AdminListParams) =>
useAdminList<HomeSellerTileRow>("homepage/tiles", params);
/** PATCH/DELETE a homepage entry. `kind` maps to the URL segment. */
function useHomeMutations(kind: "banners" | "categories" | "tiles") {
const token = useAuthToken();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: ["admin", `homepage/${kind}`] });
const update = useMutation({
mutationFn: ({ id, data }: { id: string | number; data: Record<string, unknown> }) =>
adminPatch(`homepage/${kind}/${id}/`, data, token as string),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string | number) => adminDelete(`homepage/${kind}/${id}/`, token as string),
onSuccess: invalidate,
});
return { update, remove };
}
export const useHomeBannerMutations = () => useHomeMutations("banners");
export const useHomeCategoryMutations = () => useHomeMutations("categories");
export const useHomeSellerTileMutations = () => useHomeMutations("tiles");
/* ------------------------ catalog attributes (3g) ------------------------ */
export interface CategoryLite {
id: string;
name: string;
}
export interface AttributeRow {
id: string;
name: string;
categories: string[];
categoryNames: string[];
valueCount: number;
}
export interface AttributeValueRow {
id: string;
attribute: string;
attributeName: string;
value: string;
info: unknown;
}
export function useCatalogCategories() {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", "catalog", "categories"],
enabled: !!token,
queryFn: () => adminGet<CategoryLite[]>("catalog/categories/", token as string),
});
}
export const useAttributes = (params: AdminListParams) =>
useAdminList<AttributeRow>("catalog/attributes", params);
export const useAttributeValues = (params: AdminListParams) =>
useAdminList<AttributeValueRow>("catalog/attribute-values", params);
function useCatalogMutations(kind: "attributes" | "attribute-values", invalidateKey: string) {
const token = useAuthToken();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: ["admin", invalidateKey] });
const create = useMutation({
mutationFn: (data: Record<string, unknown>) =>
adminPost(`catalog/${kind}/`, data, token as string),
onSuccess: invalidate,
});
const update = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
adminPatch(`catalog/${kind}/${id}/`, data, token as string),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => adminDelete(`catalog/${kind}/${id}/`, token as string),
onSuccess: invalidate,
});
return { create, update, remove };
}
export const useAttributeMutations = () =>
useCatalogMutations("attributes", "catalog/attributes");
export const useAttributeValueMutations = () =>
useCatalogMutations("attribute-values", "catalog/attribute-values");
/* ---------------------- AI pipelines health (3c) ---------------------- */
interface FailureRow {
id: string;
error: string;
createdAt?: string;
updatedAt?: string;
productId?: string;
sellerId?: string;
}
export interface AiHealthOverview {
generatedAt: string;
tryon: {
total: number;
byStatus: Record<string, number>;
last24h: number;
last7d: number;
recentFailures: FailureRow[];
};
metadata: {
total: number;
byStatus: Record<string, number>;
last24h: number;
last7d: number;
recentFailures: FailureRow[];
};
instagram: {
accountsTotal: number;
accountsActive: number;
postsTotal: number;
postsPendingAi: number;
postsConverted: number;
importedLast7d: number;
recentImports: { account: string; date: string; importedCount: number }[];
};
apgs: {
total: number;
success: number;
failed: number;
last24h: number;
last7d: number;
recentFailures: FailureRow[];
};
}
export function useAiHealth() {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", "ai-health"],
enabled: !!token,
refetchInterval: 60_000,
queryFn: () => adminGet<AiHealthOverview>("ai-health/overview/", token as string),
});
}
export const useAdminDiscounts = (params: AdminListParams) =>
useAdminList<AdminDiscountRow>("discounts", params);
export const useAdminCollections = (params: AdminListParams) =>
useAdminList<AdminCollectionRow>("collections", params);
export function useAdminDiscountCreate() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Record<string, unknown>) =>
adminPost("discounts/", data, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "discounts"] }),
});
}
export function useAdminDiscountUpdate() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
adminPatch(`discounts/${id}/`, data, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "discounts"] }),
});
}
export function useAdminDiscountDelete() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminDelete(`discounts/${id}/`, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "discounts"] }),
});
}
export function useAdminCollectionUpdate() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
adminPatch(`collections/${id}/`, data, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "collections"] }),
});
}
export function useAdminCollectionDelete() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminDelete(`collections/${id}/`, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "collections"] }),
});
}
export function useLedgerSellerDetail(id: string | undefined) {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", "ledger", "seller", id],
enabled: !!token && !!id,
queryFn: () =>
adminGet<LedgerSellerDetail>(`ledger/sellers/${id}/`, token as string),
});
}
async function adminPost<T>(
path: string,
body: Record<string, unknown>,
token: string
): Promise<T> {
const res = await fetch(`${getApiBaseUrl()}/api/adminpanel/v1/${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
let detail = `Admin action failed (${res.status})`;
try {
const j = await res.json();
if (j?.detail) detail = j.detail;
} catch {
/* ignore */
}
throw new Error(detail);
}
return res.json();
}
async function adminDelete(path: string, token: string): Promise<void> {
const res = await fetch(`${getApiBaseUrl()}/api/adminpanel/v1/${path}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok && res.status !== 204) {
let detail = `Admin delete failed (${res.status})`;
try {
const j = await res.json();
if (j?.detail) detail = j.detail;
} catch {
/* ignore */
}
throw new Error(detail);
}
}
/** Approve (complete → debits balance + journals) or reject a pending payout. */
export function useLedgerPayoutAction() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, action }: { id: string; action: "complete" | "reject" }) =>
adminPost(`ledger/payouts/${id}/action/`, { action }, token as string),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin", "ledger"] });
qc.invalidateQueries({ queryKey: ["admin", "overview"] });
},
});
}

View File

@ -31,30 +31,6 @@ export function useGetUserThreads() {
});
}
/**
* List a STORE's inbox (owner + staff), scoped to the store's own threads.
* Distinct from useGetUserThreads(), which returns the user's personal buyer chats.
*/
export function useGetStoreThreads(storeId?: string) {
const token = useAuthToken();
return useQuery({
queryKey: ["getStoreThreads", storeId],
queryFn: async () => {
if (!token) {
throw new Error("Authentication token is required");
}
const chatApi = createChatApi(token);
return await chatApi.apiChatV1ThreadsListList({ storeId });
},
enabled: !!token && !!storeId,
retry: 1,
retryDelay: 3000,
refetchOnWindowFocus: false,
refetchInterval: 20 * 1000,
});
}
/**
* Server-side function to get thread details
*/
@ -206,21 +182,15 @@ export function useGetThreadMessages(threadId: string) {
export function useCreateChatThread() {
const token = useAuthToken();
return useMutation({
mutationFn: async (
input: string | { participantId: string; productId?: string }
) => {
mutationFn: async (participantId: string) => {
if (!token) {
throw new Error("Authentication token and user ID are required");
}
// Back-compat: callers may pass a bare participantId string.
const { participantId, productId } =
typeof input === "string" ? { participantId: input, productId: undefined } : input;
try {
const chatApi = createChatApi(token);
return await chatApi.apiChatV1ThreadsCreateCreate({
data: {
participantId,
...(productId ? { productId } : {}),
participantId: participantId,
},
});
} catch (error) {

View File

@ -75,7 +75,7 @@ export function useGetHomeDiscounts() {
}
return undefined;
},
staleTime: 60 * 1000, // 60s — prices/discounts must stay reasonably fresh
staleTime: 5 * 60 * 1000, // 5 minutes cache
refetchOnWindowFocus: false,
});
}
@ -114,7 +114,7 @@ export function useGetHomeForYou() {
}
return undefined;
},
staleTime: 60 * 1000, // 60s — prices/discounts must stay reasonably fresh
staleTime: 5 * 60 * 1000, // 5 minutes cache
refetchOnWindowFocus: false,
});
}
@ -153,7 +153,7 @@ export function useGetHomeTopSellers() {
}
return undefined;
},
staleTime: 60 * 1000, // 60s — prices/discounts must stay reasonably fresh
staleTime: 5 * 60 * 1000, // 5 minutes cache
refetchOnWindowFocus: false,
});
}

View File

@ -14,10 +14,6 @@ import {
SellerProductListItem,
VariantStockUpdateRequest,
VariantStockUpdateResponse,
ProductBasePriceUpdateRequest,
ProductBasePriceUpdateResponse,
VariantPriceUpdateRequest,
VariantPriceUpdateResponse,
PaginatedSimilarProduct,
} from "../../src/api/types";
import { useToast } from "../hooks/use-toast";
@ -370,107 +366,6 @@ export const useUpdateVariantStock = () => {
});
};
/**
* Custom hook to update a product's base price (quick edit).
*/
export const useUpdateProductBasePrice = () => {
const token = useAuthToken();
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: async ({
productId,
basePrice,
}: {
productId: string;
basePrice: string;
}): Promise<ProductBasePriceUpdateResponse> => {
if (!token) throw new Error("Authentication required");
const productApi = createProductManagementApi(token);
const data: ProductBasePriceUpdateRequest = {
productId,
basePrice,
};
return await productApi.apiProductV1ProductsBasePricePartialUpdate({
data,
});
},
onSuccess: (data) => {
toast({
title: "موفق",
description: "قیمت با موفقیت به‌روزرسانی شد",
variant: "default",
});
queryClient.invalidateQueries({ queryKey: ["sellerProductsList"] });
queryClient.invalidateQueries({ queryKey: ["products"] });
if (data.id) {
queryClient.invalidateQueries({ queryKey: ["product", data.id] });
}
},
onError: (error: Error) => {
console.error("Base price update failed:", error);
toast({
title: "خطا",
description: "خطا در به‌روزرسانی قیمت. لطفاً دوباره تلاش کنید",
variant: "destructive",
});
},
});
};
/**
* Custom hook to update a single variant's price override (quick edit).
* Pass `price: null` to clear the override and inherit the base price.
*/
export const useUpdateVariantPrice = () => {
const token = useAuthToken();
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: async ({
variantId,
price,
}: {
variantId: string;
price: string | null;
}): Promise<VariantPriceUpdateResponse> => {
if (!token) throw new Error("Authentication required");
const productApi = createProductManagementApi(token);
const data: VariantPriceUpdateRequest = {
variantId,
price,
};
return await productApi.apiProductV1ProductsVariantsPricePartialUpdate({
data,
});
},
onSuccess: (data) => {
toast({
title: "موفق",
description: "قیمت با موفقیت به‌روزرسانی شد",
variant: "default",
});
queryClient.invalidateQueries({ queryKey: ["sellerProductsList"] });
queryClient.invalidateQueries({ queryKey: ["products"] });
if (data.product) {
queryClient.invalidateQueries({ queryKey: ["product", data.product] });
}
},
onError: (error: Error) => {
console.error("Variant price update failed:", error);
toast({
title: "خطا",
description: "خطا در به‌روزرسانی قیمت. لطفاً دوباره تلاش کنید",
variant: "destructive",
});
},
});
};
/**
* Custom hook to fetch similar products for a given product ID
*/

View File

@ -15,109 +15,6 @@ import {
createFollowsManagementApi,
createSellerManagementApi,
} from "~/utils/api-client-factory";
import { getApiBaseUrl } from "../utils/api-config";
// ── Multi-user team ─────────────────────────────────────────────────────────
export interface MyStore {
username: string;
storeName: string;
storeLogo: string | null;
role: "owner" | "staff" | null;
}
export interface StoreMember {
id: string;
userId: string | null;
name: string | null;
phone: string;
role: string;
status: "active" | "pending";
createdAt: string;
}
/** Stores the current user can manage (owned + memberships), with their role. */
export function useMyStores() {
const token = useAuthToken();
return useQuery({
queryKey: ["myStores", token ?? "anon"],
queryFn: async (): Promise<MyStore[]> => {
const res = await fetch(`${getApiBaseUrl()}/api/seller/v1/my-stores/`, {
headers: { Authorization: `Bearer ${token}` },
});
// Throw on failure so a transient error stays an ERROR state (data kept,
// retried) instead of resolving to [] — otherwise a blip would look like
// "no stores" and bounce an owner off their own dashboard.
if (!res.ok) throw new Error(`my-stores failed: ${res.status}`);
return res.json();
},
enabled: !!token,
retry: 1,
staleTime: 60 * 1000,
});
}
/** A store's team members (owner only). */
export function useStoreMembers(username?: string) {
const token = useAuthToken();
return useQuery({
queryKey: ["storeMembers", username],
queryFn: async (): Promise<StoreMember[]> => {
const res = await fetch(
`${getApiBaseUrl()}/api/seller/v1/members/${username}/`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) throw new Error("failed to load members");
return res.json();
},
enabled: !!token && !!username,
});
}
/** Invite a team member by phone number (owner only). */
export function useInviteMember(username?: string) {
const token = useAuthToken();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (phone: string) => {
const res = await fetch(
`${getApiBaseUrl()}/api/seller/v1/members/${username}/`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ phone }),
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || "خطا در دعوت عضو جدید");
}
return res.json();
},
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["storeMembers", username] }),
});
}
/** Remove a team member (owner only). */
export function useRemoveMember(username?: string) {
const token = useAuthToken();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (memberId: string) => {
const res = await fetch(
`${getApiBaseUrl()}/api/seller/v1/members/${username}/${memberId}/`,
{ method: "DELETE", headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) throw new Error("خطا در حذف عضو");
return res.json();
},
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["storeMembers", username] }),
});
}
/**
* Custom hook to fetch seller data by sellerId using React Query
@ -251,13 +148,6 @@ export const useGetSellerProducts = (
},
initialPageParam: 1,
enabled: !!sellerId,
// Public storefront: prices/discounts must reflect the seller's latest
// edits. Always treat as stale and revalidate on mount/refocus so the
// store list can't keep showing a pre-edit price (the detail page is SSR
// and already fresh, which caused the list-vs-detail mismatch).
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
});
};
@ -369,9 +259,6 @@ 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

@ -1,69 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { useAuthToken } from "../hooks/use-root-data";
import { getApiBaseUrl } from "../utils/api-config";
/**
* Records that the authenticated user viewed a product. Used as a signal for
* the recommendation system (alongside bookmarks, follows, orders, semantic
* proximity). Fire-and-forget: failures are swallowed so tracking never
* disrupts the browsing experience.
*
* NOTE: this hits /api/engagement/v1/views/ via a direct fetch because the
* endpoint is newer than the generated OpenAPI client. Swap to the generated
* EngagementApi method once the client is regenerated.
*/
export function useTrackProductView() {
const token = useAuthToken();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (productId: string) => {
if (!token || !productId) return { skipped: true };
const res = await fetch(`${getApiBaseUrl()}/api/engagement/v1/views/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ productId }),
});
if (!res.ok) {
throw new Error(`Failed to track view: ${res.status}`);
}
return res.json();
},
onSuccess: () => {
// Keep "recently viewed" fresh if/when it's rendered.
queryClient.invalidateQueries({ queryKey: ["recentlyViewedProducts"] });
},
// View tracking is best-effort; never surface errors to the user.
onError: () => {},
});
}
/**
* Fires a single product-view event when a product page is opened. Dedupes per
* productId so React strict-mode double-mounts / re-renders don't double-count
* within the same mount, and skips when not authenticated.
*/
export function useRecordProductView(
productId: string | undefined,
options?: { enabled?: boolean }
) {
const token = useAuthToken();
const { mutate } = useTrackProductView();
const lastTrackedRef = useRef<string | null>(null);
const enabled = options?.enabled ?? true;
useEffect(() => {
if (!enabled) return;
if (!token || !productId) return;
if (lastTrackedRef.current === productId) return;
lastTrackedRef.current = productId;
mutate(productId);
}, [productId, token, enabled, mutate]);
}

View File

@ -136,11 +136,6 @@ export function Layout({ children }: { children: React.ReactNode }) {
/>
<meta name="color-scheme" content="light dark" />
<meta name="mobile-web-app-capable" content="yes" />
{/* iOS-specific: standalone mode. Required for the status-bar-style
below to take effect without it iOS ignores black-translucent,
so the app isn't edge-to-edge and env(safe-area-inset-top) doesn't
line up with the headers. (Android uses mobile-web-app-capable.) */}
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"

View File

@ -75,15 +75,12 @@ export default function HomePage() {
return (
<>
<div className="flex flex-col pb-12">
<div className="lg:hidden">
<HomePageTopBar />
</div>
{/* Adding padding to account for fixed header */}
<div className="flex flex-col gap-10 lg:gap-14 lg:max-w-[1320px] lg:w-full lg:mx-auto lg:px-8">
{/* Hero: mobile = full-bleed banner carousel; desktop = main + 2 promos */}
<div className="w-full mt-2 lg:mt-7 lg:grid lg:grid-cols-3 lg:gap-4">
<div className="h-[260px] lg:h-[408px] lg:col-span-2 lg:rounded-[22px] lg:overflow-hidden lg:shadow-md">
<div className="flex flex-col gap-10">
{/* Banners Section */}
<div className="w-full mt-2 h-[260px]">
{isLoadingBanners ? (
<Skeleton className="w-full h-full" />
) : (
@ -95,63 +92,16 @@ export default function HomePage() {
)}
</div>
{/* Desktop-only side promos */}
<div className="hidden lg:grid lg:grid-rows-2 lg:gap-4">
<Link
to="/explore"
className="relative block rounded-[22px] overflow-hidden shadow-sm group"
>
<div className="absolute inset-0 bg-gradient-to-br from-[#6f7359] to-[#454935] transition-transform duration-500 group-hover:scale-105" />
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-5 text-white">
<small className="text-[12px] font-bold opacity-90">
تازه رسید
</small>
<h3 className="text-[20px] font-extrabold mt-1 leading-snug">
کالکشن جدید
</h3>
</div>
</Link>
<Link
to="/"
className="relative block rounded-[22px] overflow-hidden shadow-sm group"
>
<div className="absolute inset-0 bg-gradient-to-br from-[#b06a3c] to-[#7c4220] transition-transform duration-500 group-hover:scale-105" />
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-5 text-white">
<small className="text-[12px] font-bold opacity-90">
فروش ویژه
</small>
<h3 className="text-[20px] font-extrabold mt-1 leading-snug">
تا ۵۰٪ تخفیف
</h3>
</div>
</Link>
</div>
</div>
<section
id="section1"
className="w-full gap-2 flex flex-col max-w-[100vw] lg:max-w-none lg:gap-5"
className="w-full gap-2 flex flex-col max-w-[100vw]"
>
<div className="w-full px-4 lg:px-0 flex items-center justify-between">
<div>
<p className="text-B16 lg:text-B24 font-bold">تخفیفها</p>
<p className="text-R14 lg:text-R16 text-GRAY">
<div className="w-full px-4">
<p className="text-B16 font-bold">تخفیفها</p>
<p className="text-R14">
فرصتهایی محدود برای انتخابهای هوشمندانه
</p>
</div>
<Link
to="/discounts"
className="flex items-center gap-1 text-R14 text-gray-500 hover:text-gray-800 hover:underline transition-colors group"
>
<span>مشاهده همه</span>
<ChevronLeft
size={16}
className="group-hover:-translate-x-1 transition-transform"
/>
</Link>
</div>
<HorizontalProductList
products={
discounts?.map((product) => ({
@ -163,9 +113,6 @@ export default function HomePage() {
discountPercent: product?.discountPercent || 0,
discountPrice: product?.discountPrice || 0,
basePrice: product?.basePrice || 0,
sellerName: product?.sellerName,
sellerLogo: product?.sellerLogo,
sellerUsername: product?.sellerUsername,
})) || []
}
isLoading={isLoadingDiscounts}
@ -175,12 +122,12 @@ export default function HomePage() {
</section>
<section
id="section2"
className="w-full gap-2 flex flex-col max-w-[100vw] lg:max-w-none lg:gap-5"
className="w-full gap-2 flex flex-col max-w-[100vw]"
>
<div className="w-full px-4 lg:px-0 flex items-center justify-between">
<div className="w-full px-4 flex items-center justify-between">
<div>
<p className="text-B16 lg:text-B24 font-bold">برترین فروشگاهها</p>
<p className="text-R14 lg:text-R16 text-GRAY">
<p className="text-B16 font-bold">برترین فروشگاهها</p>
<p className="text-R14">
برگرفته از بالاترین سطح رضایت کاربران
</p>
</div>
@ -205,13 +152,11 @@ export default function HomePage() {
</section>
<section
id="collections"
className="w-full gap-2 flex flex-col max-w-[100vw] lg:max-w-none lg:gap-5"
className="w-full gap-2 flex flex-col max-w-[100vw]"
>
<div className="w-full px-4 lg:px-0">
<p className="text-B16 lg:text-B24 font-bold">کالکشنها</p>
<p className="text-R14 lg:text-R16 text-GRAY">
مجموعههای منتخب از بهترین محصولات
</p>
<div className="w-full px-4">
<p className="text-B16 font-bold">کالکشنها</p>
<p className="text-R14">مجموعههای منتخب از بهترین محصولات</p>
</div>
<HorizontalCollectionsList
collections={
@ -231,13 +176,11 @@ export default function HomePage() {
</section>
<section
id="section3"
className="w-full gap-2 flex flex-col max-w-[100vw] lg:max-w-none lg:gap-5"
className="w-full gap-2 flex flex-col max-w-[100vw]"
>
<div className="w-full px-4 lg:px-0">
<p className="text-B16 lg:text-B24 font-bold">برای شما</p>
<p className="text-R14 lg:text-R16 text-GRAY">
با الهام از انتخابهای شما
</p>
<div className="w-full px-4">
<p className="text-B16 font-bold">برای شما</p>
<p className="text-R14">با الهام از انتخابهای شما</p>
</div>
<MondrianProductList
products={
@ -250,9 +193,6 @@ export default function HomePage() {
discountPercent: product?.discountPercent || 0,
discountPrice: product?.discountPrice || 0,
basePrice: product?.basePrice || 0,
sellerName: product?.sellerName,
sellerLogo: product?.sellerLogo,
sellerUsername: product?.sellerUsername,
})) || []
}
fetchNextPage={fetchNextForYou}
@ -293,9 +233,7 @@ export const meta: MetaFunction = () => {
},
{ name: "author", content: "Vitrown" },
{ name: "robots", content: "index, follow" },
// viewport is defined globally in root.tsx (with viewport-fit=cover for
// iOS safe-area handling); don't override it here or the home page loses
// edge-to-edge / notch support.
{ name: "viewport", content: "width=device-width, initial-scale=1" },
// Open Graph
{ property: "og:type", content: "website" },

View File

@ -1,153 +0,0 @@
import type { MetaFunction } from "@remix-run/node";
import {
Loader2,
Wallet,
Landmark,
ShoppingBag,
TrendingUp,
Users as UsersIcon,
Store,
Package,
Clock,
} from "lucide-react";
import { useAdminOverview } from "~/requestHandler/use-admin-hooks";
import { KpiCard } from "~/components/admin/KpiCard";
import { OrdersBarChart } from "~/components/admin/OrdersBarChart";
export const meta: MetaFunction = () => [
{ title: "نمای کلی | پنل مدیریت ویترون" },
{ name: "robots", content: "noindex, nofollow" },
];
const faNum = (n?: number) => Math.round(n || 0).toLocaleString("fa-IR");
const faToman = (n?: number) => `${faNum(n)} تومان`;
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<h2 className="text-[15px] font-extrabold mt-7 mb-3 first:mt-0">
{children}
</h2>
);
}
export default function AdminOverview() {
const { data, isLoading, isError } = useAdminOverview();
if (isLoading) {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<Loader2 className="h-7 w-7 animate-spin text-GRAY" />
</div>
);
}
if (isError || !data) {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-RED text-[14px]">خطا در بارگذاری آمار</p>
</div>
);
}
const { users, sellers, products, orders, money, ordersSeries } = data;
return (
<div>
<div className="hidden lg:flex items-end justify-between mb-6">
<h1 className="text-[28px] font-extrabold">نمای کلی</h1>
<span className="text-[12px] text-GRAY">بهروزرسانی خودکار هر دقیقه</span>
</div>
{/* Money + today */}
<SectionTitle>امروز و مالی</SectionTitle>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiCard
label="درآمد امروز"
value={faToman(orders.revenueToday)}
icon={TrendingUp}
accent="green"
/>
<KpiCard
label="سفارش امروز"
value={faNum(orders.ordersToday)}
icon={ShoppingBag}
/>
<KpiCard
label="کل درآمد (پرداخت‌شده)"
value={faToman(orders.revenueTotal)}
sub={`${faNum(orders.paidCount)} سفارش پرداخت‌شده`}
icon={TrendingUp}
/>
<KpiCard
label="برداشت‌های در انتظار"
value={faNum(money.payoutsPendingCount)}
sub={faToman(money.payoutsPendingAmount)}
icon={Clock}
accent={money.payoutsPendingCount > 0 ? "red" : "default"}
/>
<KpiCard
label="موجودی کیف پول کاربران"
value={faToman(money.walletHeld)}
icon={Wallet}
/>
<KpiCard
label="مانده قابل پرداخت فروشندگان"
value={faToman(money.sellerPayable)}
icon={Landmark}
/>
</div>
{/* Chart */}
<div className="mt-4">
<OrdersBarChart data={ordersSeries} />
</div>
{/* Orders status */}
<SectionTitle>سفارشها</SectionTitle>
<div className="grid grid-cols-2 lg:grid-cols-6 gap-3">
<KpiCard label="کل سفارش‌ها" value={faNum(orders.total)} icon={ShoppingBag} />
<KpiCard label="در انتظار" value={faNum(orders.pending)} />
<KpiCard label="تأییدشده" value={faNum(orders.confirmed)} />
<KpiCard label="ارسال‌شده" value={faNum(orders.shipped)} />
<KpiCard label="تحویل‌شده" value={faNum(orders.delivered)} accent="green" />
<KpiCard label="لغوشده" value={faNum(orders.cancelled)} accent="red" />
</div>
{/* Sellers + Users + Products */}
<SectionTitle>فروشگاهها و کاربران</SectionTitle>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiCard
label="فروشگاه‌ها"
value={faNum(sellers.total)}
sub={`${faNum(sellers.verified)} تأییدشده`}
icon={Store}
/>
<KpiCard
label="در انتظار تأیید"
value={faNum(sellers.pending)}
icon={Clock}
accent={sellers.pending > 0 ? "red" : "default"}
/>
<KpiCard
label="کاربران"
value={faNum(users.total)}
sub={`${faNum(users.active)} فعال`}
icon={UsersIcon}
/>
<KpiCard
label="کاربران جدید (۳۰ روز)"
value={faNum(users.new30d)}
sub={`${faNum(users.new7d)} در ۷ روز`}
icon={UsersIcon}
/>
<KpiCard
label="محصولات"
value={faNum(products.total)}
sub={`${faNum(products.active)} فعال`}
icon={Package}
/>
<KpiCard label="فروشندگان" value={faNum(users.sellers)} icon={Store} />
</div>
</div>
);
}

View File

@ -1,182 +0,0 @@
import { Loader2, Sparkles, FileText, Instagram, Bot } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useAiHealth, type AiHealthOverview } from "~/requestHandler/use-admin-hooks";
import { KpiCard } from "~/components/admin/KpiCard";
import { AdminPageTitle, faNum, faDate } from "~/components/admin/DataTable";
const STATUS_LABEL: Record<string, string> = {
pending: "در انتظار",
processing: "در حال پردازش",
success: "موفق",
ready: "آماده",
failed: "ناموفق",
};
const STATUS_TONE: Record<string, string> = {
pending: "bg-yellow-500/10 text-yellow-600",
processing: "bg-VITROWN_BLUE/10 text-VITROWN_BLUE",
success: "bg-GREEN/10 text-GREEN",
ready: "bg-GREEN/10 text-GREEN",
failed: "bg-RED/10 text-RED",
};
function StatusPills({ byStatus }: { byStatus: Record<string, number> }) {
const entries = Object.entries(byStatus);
if (entries.length === 0) return <p className="text-GRAY text-[12px]">دادهای نیست</p>;
return (
<div className="flex flex-wrap gap-2">
{entries.map(([k, v]) => (
<span
key={k}
className={`text-[12px] font-semibold px-2.5 py-1 rounded-full ${
STATUS_TONE[k] || "bg-WHITE3 text-BLACK2"
}`}
>
{STATUS_LABEL[k] || k}: {faNum(v)}
</span>
))}
</div>
);
}
function Failures({
rows,
dateKey,
}: {
rows: AiHealthOverview["tryon"]["recentFailures"];
dateKey: "createdAt" | "updatedAt";
}) {
if (rows.length === 0)
return <p className="text-GREEN text-[12px] mt-2">خطای اخیری ثبت نشده </p>;
return (
<div className="mt-3 flex flex-col gap-1.5">
<p className="text-[12px] font-semibold text-RED">خطاهای اخیر</p>
{rows.map((r) => (
<div key={r.id} className="rounded-xl border border-RED/20 bg-RED/5 p-2.5">
<div className="flex items-center justify-between gap-2">
<span className="text-GRAY text-[10.5px] tabular-nums" dir="ltr">
{r.id.slice(0, 8)}
</span>
<span className="text-GRAY text-[10.5px] whitespace-nowrap">
{faDate(r[dateKey])}
</span>
</div>
<p className="text-[11.5px] text-BLACK2 mt-1 break-words" dir="ltr">
{r.error || "—"}
</p>
</div>
))}
</div>
);
}
function Section({
title,
icon: Icon,
children,
}: {
title: string;
icon: LucideIcon;
children: React.ReactNode;
}) {
return (
<section className="rounded-2xl border border-WHITE3 bg-WHITE p-4 sm:p-5">
<div className="flex items-center gap-2 mb-4">
<div className="h-9 w-9 rounded-xl bg-WHITE2 grid place-items-center">
<Icon size={18} />
</div>
<h2 className="font-extrabold text-[15px]">{title}</h2>
</div>
{children}
</section>
);
}
export default function AdminAiHealth() {
const { data, isLoading, isError } = useAiHealth();
if (isLoading)
return (
<div className="flex min-h-[40vh] items-center justify-center">
<Loader2 className="h-7 w-7 animate-spin text-GRAY" />
</div>
);
if (isError || !data)
return <p className="text-RED text-[14px]">خطا در بارگذاری وضعیت سرویسهای هوش مصنوعی</p>;
return (
<div>
<AdminPageTitle>سلامت سرویسهای هوش مصنوعی</AdminPageTitle>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Section title="پرو مجازی" icon={Sparkles}>
<div className="grid grid-cols-3 gap-3">
<KpiCard label="کل" value={faNum(data.tryon.total)} />
<KpiCard label="۲۴ ساعت" value={faNum(data.tryon.last24h)} />
<KpiCard label="۷ روز" value={faNum(data.tryon.last7d)} />
</div>
<div className="mt-3">
<StatusPills byStatus={data.tryon.byStatus} />
</div>
<Failures rows={data.tryon.recentFailures} dateKey="createdAt" />
</Section>
<Section title="متادیتای محصولات" icon={FileText}>
<div className="grid grid-cols-3 gap-3">
<KpiCard label="کل" value={faNum(data.metadata.total)} />
<KpiCard label="۲۴ ساعت" value={faNum(data.metadata.last24h)} />
<KpiCard label="۷ روز" value={faNum(data.metadata.last7d)} />
</div>
<div className="mt-3">
<StatusPills byStatus={data.metadata.byStatus} />
</div>
<Failures rows={data.metadata.recentFailures} dateKey="updatedAt" />
</Section>
<Section title="ایمپورت اینستاگرام" icon={Instagram}>
<div className="grid grid-cols-3 gap-3">
<KpiCard
label="اکانت‌ها"
value={faNum(data.instagram.accountsTotal)}
sub={`${faNum(data.instagram.accountsActive)} فعال`}
/>
<KpiCard
label="در صف AI"
value={faNum(data.instagram.postsPendingAi)}
accent={data.instagram.postsPendingAi > 0 ? "red" : "default"}
/>
<KpiCard label="تبدیل‌شده" value={faNum(data.instagram.postsConverted)} accent="green" />
</div>
<p className="text-[12px] text-GRAY mt-3">
ایمپورت ۷ روز اخیر: <b className="tabular-nums text-BLACK2">{faNum(data.instagram.importedLast7d)}</b>
</p>
{data.instagram.recentImports.length > 0 && (
<div className="mt-2 flex flex-col gap-1">
{data.instagram.recentImports.map((im, i) => (
<div key={i} className="flex items-center justify-between text-[12px] border-b border-WHITE3 py-1.5 last:border-0">
<span className="tabular-nums" dir="ltr">@{im.account}</span>
<span className="text-GRAY">
{faNum(im.importedCount)} {faDate(im.date)}
</span>
</div>
))}
</div>
)}
</Section>
<Section title="تولید محصول با هوش مصنوعی" icon={Bot}>
<div className="grid grid-cols-3 gap-3">
<KpiCard label="کل" value={faNum(data.apgs.total)} />
<KpiCard label="موفق" value={faNum(data.apgs.success)} accent="green" />
<KpiCard
label="ناموفق"
value={faNum(data.apgs.failed)}
accent={data.apgs.failed > 0 ? "red" : "default"}
/>
</div>
<Failures rows={data.apgs.recentFailures} dateKey="createdAt" />
</Section>
</div>
</div>
);
}

View File

@ -1,166 +0,0 @@
import { useState } from "react";
import { useAuditLog, type AuditLogRow } from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
const ACTION: Record<string, { label: string; tone: "green" | "blue" | "red" | "gray" }> = {
"seller.update": { label: "ویرایش فروشنده", tone: "blue" },
"user.update": { label: "ویرایش کاربر", tone: "blue" },
"product.update": { label: "ویرایش محصول", tone: "blue" },
"payout.complete": { label: "تکمیل تسویه", tone: "green" },
"payout.reject": { label: "رد تسویه", tone: "red" },
"ledger.backfill": { label: "بازسازی دفتر", tone: "gray" },
};
const TARGET_LABEL: Record<string, string> = {
seller: "فروشنده",
user: "کاربر",
product: "محصول",
payout: "تسویه",
ledger: "دفتر مالی",
};
/** Compact before→after diff of the touched fields. */
function Changes({ before, after }: Pick<AuditLogRow, "before" | "after">) {
const keys = Array.from(
new Set([...Object.keys(before || {}), ...Object.keys(after || {})])
);
const changed = keys.filter(
(k) => JSON.stringify(before?.[k]) !== JSON.stringify(after?.[k])
);
if (changed.length === 0)
return <span className="text-GRAY"></span>;
return (
<div className="flex flex-col gap-0.5" dir="ltr">
{changed.map((k) => (
<span key={k} className="text-[11px] tabular-nums whitespace-nowrap">
<span className="text-GRAY">{k}: </span>
<span className="text-RED">{fmt(before?.[k])}</span>
<span className="text-GRAY"> </span>
<span className="text-GREEN">{fmt(after?.[k])}</span>
</span>
))}
</div>
);
}
function fmt(v: unknown) {
if (v === null || v === undefined) return "∅";
if (typeof v === "boolean") return v ? "true" : "false";
return String(v);
}
export default function AdminAudit() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [action, setAction] = useState("");
const [targetType, setTargetType] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAuditLog({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
action,
target_type: targetType,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const columns: Column<AuditLogRow>[] = [
{
header: "عملیات",
render: (r) => {
const a = ACTION[r.action] || { label: r.action, tone: "gray" as const };
return <StatusBadge label={a.label} tone={a.tone} />;
},
},
{
header: "هدف",
render: (r) => (
<span className="text-[12px] whitespace-nowrap">
{TARGET_LABEL[r.targetType] || r.targetType}
{r.targetId && (
<span className="text-GRAY tabular-nums"> · {r.targetId.slice(0, 8)}</span>
)}
</span>
),
},
{
header: "تغییرات",
render: (r) => <Changes before={r.before} after={r.after} />,
},
{
header: "مدیر",
render: (r) => (
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{r.actorPhone || "سیستم"}
</span>
),
},
{
header: "تاریخ",
render: (r) => <span className="text-GRAY whitespace-nowrap">{faDate(r.createdAt)}</span>,
},
];
return (
<div>
<AdminPageTitle>گزارش فعالیت مدیران</AdminPageTitle>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی تلفن مدیر / شناسه هدف…"
/>
<AdminSelect
value={action}
onChange={(v) => reset(() => setAction(v))}
options={[
{ value: "", label: "همه عملیات‌ها" },
{ value: "seller.update", label: "ویرایش فروشنده" },
{ value: "user.update", label: "ویرایش کاربر" },
{ value: "product.update", label: "ویرایش محصول" },
{ value: "payout.complete", label: "تکمیل تسویه" },
{ value: "payout.reject", label: "رد تسویه" },
{ value: "ledger.backfill", label: "بازسازی دفتر" },
]}
/>
<AdminSelect
value={targetType}
onChange={(v) => reset(() => setTargetType(v))}
options={[
{ value: "", label: "همه هدف‌ها" },
{ value: "seller", label: "فروشنده" },
{ value: "user", label: "کاربر" },
{ value: "product", label: "محصول" },
{ value: "payout", label: "تسویه" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(r) => r.id}
emptyText="فعالیتی ثبت نشده است"
/>
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}

View File

@ -1,247 +0,0 @@
import { useState } from "react";
import { Plus, Pencil, Trash2, X, Tag, ChevronLeft } from "lucide-react";
import {
useCatalogCategories,
useAttributes,
useAttributeValues,
useAttributeMutations,
useAttributeValueMutations,
type AttributeRow,
type AttributeValueRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminPageTitle,
StatusBadge,
faNum,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
export default function AdminCatalog() {
const [selected, setSelected] = useState<AttributeRow | null>(null);
return (
<div>
<AdminPageTitle>ویژگیهای کاتالوگ</AdminPageTitle>
{selected ? (
<ValuesPanel attribute={selected} onBack={() => setSelected(null)} />
) : (
<AttributesPanel onSelect={setSelected} />
)}
</div>
);
}
/* ------------------------------ attributes ------------------------------ */
function AttributesPanel({ onSelect }: { onSelect: (a: AttributeRow) => void }) {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [editing, setEditing] = useState<AttributeRow | null>(null);
const [creating, setCreating] = useState(false);
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAttributes({ page, page_size: PAGE_SIZE, search: debouncedSearch });
const { remove } = useAttributeMutations();
const columns: Column<AttributeRow>[] = [
{ header: "نام", render: (r) => <span className="font-semibold">{r.name}</span> },
{
header: "دسته‌ها",
render: (r) => (
<div className="flex flex-wrap gap-1 max-w-[260px]">
{r.categoryNames.length ? (
r.categoryNames.map((c) => <StatusBadge key={c} label={c} tone="gray" />)
) : (
<span className="text-GRAY text-[11px]"></span>
)}
</div>
),
},
{
header: "مقادیر",
render: (r) => (
<button onClick={() => onSelect(r)} className="flex items-center gap-1 text-VITROWN_BLUE text-[12px] font-semibold">
{faNum(r.valueCount)} مقدار <ChevronLeft size={14} />
</button>
),
},
{
header: "",
render: (r) => (
<div className="flex items-center gap-1 justify-end">
<button onClick={() => setEditing(r)} className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 hover:bg-WHITE2" aria-label="ویرایش">
<Pencil size={14} />
</button>
<button
onClick={() => confirm(`حذف ویژگی «${r.name}»؟ مقادیر و انتساب‌های محصولات نیز حذف می‌شود.`) && remove.mutate(r.id)}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-RED hover:bg-RED/5"
aria-label="حذف"
>
<Trash2 size={14} />
</button>
</div>
),
},
];
return (
<div>
<div className="flex justify-end mb-3">
<button onClick={() => setCreating(true)} className="flex items-center gap-1.5 rounded-xl bg-BLACK text-white px-3.5 py-2 text-[13px] font-semibold hover:opacity-90">
<Plus size={16} /> ویژگی جدید
</button>
</div>
<AdminToolbar>
<AdminSearch value={search} onChange={(v) => { setSearch(v); setPage(1); }} placeholder="جستجوی نام ویژگی…" />
</AdminToolbar>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => r.id} emptyText="ویژگی‌ای ثبت نشده است" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
{(creating || editing) && <AttributeModal row={editing} onClose={() => { setCreating(false); setEditing(null); }} />}
</div>
);
}
function AttributeModal({ row, onClose }: { row: AttributeRow | null; onClose: () => void }) {
const [name, setName] = useState(row?.name ?? "");
const [cats, setCats] = useState<string[]>(row?.categories ?? []);
const [error, setError] = useState("");
const { data: categories } = useCatalogCategories();
const { create, update } = useAttributeMutations();
const busy = create.isPending || update.isPending;
const toggleCat = (id: string) =>
setCats((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]));
const submit = () => {
setError("");
if (!name.trim()) return setError("نام الزامی است.");
const payload = { name: name.trim(), categories: cats };
const onErr = (e: unknown) => setError(e instanceof Error ? e.message : "خطا در ذخیره");
if (row) update.mutate({ id: row.id, data: payload }, { onSuccess: onClose, onError: onErr });
else create.mutate(payload, { onSuccess: onClose, onError: onErr });
};
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-BLACK/40 sm:p-4" onClick={onClose}>
<div className="bg-WHITE w-full sm:max-w-md rounded-t-2xl sm:rounded-2xl max-h-[92vh] overflow-y-auto hide-scrollbar" onClick={(e) => e.stopPropagation()}>
<div className="sticky top-0 bg-WHITE flex items-center justify-between px-5 py-4 border-b border-WHITE3">
<h3 className="font-extrabold text-[16px]">{row ? "ویرایش ویژگی" : "ویژگی جدید"}</h3>
<button onClick={onClose} className="grid h-8 w-8 place-items-center rounded-lg hover:bg-WHITE2"><X size={18} /></button>
</div>
<div className="p-5 flex flex-col gap-3">
<div>
<label className="block text-[12px] text-GRAY mb-1">نام</label>
<input className="w-full rounded-xl border border-WHITE3 bg-WHITE px-3 py-2 text-[13.5px] focus:outline-none focus:border-GRAY" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<label className="block text-[12px] text-GRAY mb-1">دستههای مرتبط</label>
<div className="max-h-[220px] overflow-y-auto hide-scrollbar rounded-xl border border-WHITE3 p-2 flex flex-col gap-0.5">
{(categories || []).map((c) => (
<label key={c.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-WHITE2 cursor-pointer">
<input type="checkbox" checked={cats.includes(c.id)} onChange={() => toggleCat(c.id)} className="h-4 w-4" />
<span className="text-[13px]">{c.name}</span>
</label>
))}
{!categories?.length && <p className="text-GRAY text-[12px] p-2">دستهای موجود نیست.</p>}
</div>
</div>
{error && <p className="text-RED text-[12px]">{error}</p>}
</div>
<div className="sticky bottom-0 bg-WHITE flex gap-2 px-5 py-4 border-t border-WHITE3">
<button onClick={onClose} className="flex-1 rounded-xl border border-WHITE3 py-2.5 text-[13.5px] font-semibold hover:bg-WHITE2">انصراف</button>
<button onClick={submit} disabled={busy} className="flex-1 rounded-xl bg-BLACK text-white py-2.5 text-[13.5px] font-semibold hover:opacity-90 disabled:opacity-50">
{busy ? "در حال ذخیره…" : "ذخیره"}
</button>
</div>
</div>
</div>
);
}
/* --------------------------- attribute values --------------------------- */
function ValuesPanel({ attribute, onBack }: { attribute: AttributeRow; onBack: () => void }) {
const [page, setPage] = useState(1);
const { data, isLoading } = useAttributeValues({ page, page_size: PAGE_SIZE, attribute: attribute.id });
const { create, update, remove } = useAttributeValueMutations();
const [newValue, setNewValue] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editingText, setEditingText] = useState("");
const add = () => {
if (!newValue.trim()) return;
create.mutate({ attribute: attribute.id, value: newValue.trim() }, { onSuccess: () => setNewValue("") });
};
const saveEdit = (id: string) => {
update.mutate({ id, data: { value: editingText.trim() } }, { onSuccess: () => setEditingId(null) });
};
const columns: Column<AttributeValueRow>[] = [
{
header: "مقدار",
render: (r) =>
editingId === r.id ? (
<input
autoFocus
value={editingText}
onChange={(e) => setEditingText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && saveEdit(r.id)}
className="rounded-lg border border-GRAY px-2 py-1 text-[13px]"
/>
) : (
<span className="font-semibold">{r.value}</span>
),
},
{
header: "",
render: (r) => (
<div className="flex items-center gap-1 justify-end">
{editingId === r.id ? (
<button onClick={() => saveEdit(r.id)} className="rounded-lg bg-BLACK text-white px-3 h-8 text-[12px] font-semibold">ذخیره</button>
) : (
<button onClick={() => { setEditingId(r.id); setEditingText(r.value); }} className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 hover:bg-WHITE2" aria-label="ویرایش">
<Pencil size={14} />
</button>
)}
<button
onClick={() => confirm(`حذف مقدار «${r.value}»؟`) && remove.mutate(r.id)}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-RED hover:bg-RED/5"
aria-label="حذف"
>
<Trash2 size={14} />
</button>
</div>
),
},
];
return (
<div>
<button onClick={onBack} className="flex items-center gap-1 text-GRAY text-[13px] mb-3 hover:text-BLACK">
<ChevronLeft size={16} className="rotate-180" /> بازگشت به ویژگیها
</button>
<div className="flex items-center gap-2 mb-4">
<div className="h-9 w-9 rounded-xl bg-BLACK text-white grid place-items-center"><Tag size={16} /></div>
<h2 className="font-extrabold text-[17px]">مقادیر «{attribute.name}»</h2>
</div>
<div className="flex gap-2 mb-4">
<input
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && add()}
placeholder="مقدار جدید (مثلاً قرمز)…"
className="flex-1 rounded-xl border border-WHITE3 bg-WHITE px-3 py-2 text-[13.5px] focus:outline-none focus:border-GRAY"
/>
<button onClick={add} disabled={create.isPending} className="flex items-center gap-1.5 rounded-xl bg-BLACK text-white px-4 text-[13px] font-semibold hover:opacity-90 disabled:opacity-50">
<Plus size={16} /> افزودن
</button>
</div>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => r.id} emptyText="مقداری ثبت نشده است" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}

View File

@ -1,153 +0,0 @@
import { useState } from "react";
import { Trash2, Eye, EyeOff } from "lucide-react";
import {
useAdminCollections,
useAdminCollectionUpdate,
useAdminCollectionDelete,
type AdminCollectionRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faNum,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
const CREATOR: Record<string, string> = {
seller: "فروشنده",
admin: "مدیر",
user: "کاربر",
};
export default function AdminCollections() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [creatorType, setCreatorType] = useState("");
const [isPublic, setIsPublic] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAdminCollections({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
creator_type: creatorType,
is_public: isPublic,
});
const update = useAdminCollectionUpdate();
const del = useAdminCollectionDelete();
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const toggle = (r: AdminCollectionRow) =>
update.mutate({ id: r.id, data: { isPublic: !r.isPublic } });
const onDelete = (r: AdminCollectionRow) => {
if (confirm(`حذف مجموعه «${r.title}»؟`)) del.mutate(r.id);
};
const columns: Column<AdminCollectionRow>[] = [
{
header: "عنوان",
render: (r) => (
<div className="flex items-center gap-2 min-w-0">
{r.coverImageUrl ? (
<img src={r.coverImageUrl} alt="" className="h-9 w-9 rounded-lg object-cover shrink-0" />
) : (
<div className="h-9 w-9 rounded-lg bg-WHITE3 shrink-0" />
)}
<p className="font-semibold truncate">{r.title}</p>
</div>
),
},
{ header: "سازنده", render: (r) => <span className="text-[12px]">{CREATOR[r.creatorType] || r.creatorType}</span> },
{
header: "محصولات",
render: (r) => <span className="tabular-nums">{faNum(r.productCount)}</span>,
},
{
header: "نمایش",
render: (r) =>
r.isPublic ? (
<StatusBadge label="عمومی" tone="green" />
) : (
<StatusBadge label="خصوصی" tone="gray" />
),
},
{ header: "تاریخ", render: (r) => <span className="text-GRAY whitespace-nowrap">{faDate(r.createdAt)}</span> },
{
header: "",
render: (r) => (
<div className="flex items-center gap-1 justify-end">
<button
onClick={() => toggle(r)}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 hover:bg-WHITE2"
aria-label={r.isPublic ? "خصوصی کردن" : "عمومی کردن"}
title={r.isPublic ? "خصوصی کردن" : "عمومی کردن"}
>
{r.isPublic ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<button
onClick={() => onDelete(r)}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-RED hover:bg-RED/5"
aria-label="حذف"
>
<Trash2 size={14} />
</button>
</div>
),
},
];
return (
<div>
<AdminPageTitle>مجموعهها</AdminPageTitle>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی عنوان / تلفن سازنده…"
/>
<AdminSelect
value={creatorType}
onChange={(v) => reset(() => setCreatorType(v))}
options={[
{ value: "", label: "همه سازنده‌ها" },
{ value: "seller", label: "فروشنده" },
{ value: "admin", label: "مدیر" },
{ value: "user", label: "کاربر" },
]}
/>
<AdminSelect
value={isPublic}
onChange={(v) => reset(() => setIsPublic(v))}
options={[
{ value: "", label: "همه" },
{ value: "true", label: "عمومی" },
{ value: "false", label: "خصوصی" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(r) => r.id}
emptyText="مجموعه‌ای ثبت نشده است"
/>
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}

View File

@ -1,335 +0,0 @@
import { useState } from "react";
import { Plus, Pencil, Trash2, X } from "lucide-react";
import {
useAdminDiscounts,
useAdminDiscountCreate,
useAdminDiscountUpdate,
useAdminDiscountDelete,
type AdminDiscountRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faNum,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
const TYPE: Record<string, string> = {
user_based: "کاربر-محور",
count_based: "تعداد-محور",
value_based: "مبلغ-محور",
};
type FormState = {
name: string;
code: string;
discountType: string;
value: string;
minPurchaseAmount: string;
maxDiscountAmount: string;
validFrom: string;
validTo: string;
maxCount: string;
isActive: boolean;
};
const EMPTY: FormState = {
name: "",
code: "",
discountType: "value_based",
value: "",
minPurchaseAmount: "",
maxDiscountAmount: "",
validFrom: "",
validTo: "",
maxCount: "",
isActive: true,
};
const toLocal = (iso: string | null) => (iso ? iso.slice(0, 16) : "");
function fromRow(r: AdminDiscountRow): FormState {
return {
name: r.name,
code: String(r.code),
discountType: r.discountType,
value: String(r.value),
minPurchaseAmount: r.minPurchaseAmount != null ? String(r.minPurchaseAmount) : "",
maxDiscountAmount: r.maxDiscountAmount != null ? String(r.maxDiscountAmount) : "",
validFrom: toLocal(r.validFrom),
validTo: toLocal(r.validTo),
maxCount: r.maxCount != null ? String(r.maxCount) : "",
isActive: r.isActive,
};
}
export default function AdminDiscounts() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [active, setActive] = useState("");
const [editing, setEditing] = useState<AdminDiscountRow | null>(null);
const [creating, setCreating] = useState(false);
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAdminDiscounts({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
is_active: active,
});
const del = useAdminDiscountDelete();
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const onDelete = (r: AdminDiscountRow) => {
if (confirm(`حذف تخفیف «${r.name}»؟`)) del.mutate(r.id);
};
const columns: Column<AdminDiscountRow>[] = [
{
header: "نام",
render: (r) => (
<div>
<p className="font-semibold">{r.name}</p>
<p className="text-GRAY text-[11px] tabular-nums" dir="ltr">
#{faNum(r.code)}
</p>
</div>
),
},
{ header: "نوع", render: (r) => <span className="text-[12px]">{TYPE[r.discountType] || r.discountType}</span> },
{
header: "مقدار",
render: (r) => <span className="tabular-nums whitespace-nowrap">{faNum(r.value)}</span>,
},
{
header: "اعتبار",
render: (r) => (
<span className="text-GRAY text-[11px] whitespace-nowrap">
{faDate(r.validFrom)} {faDate(r.validTo)}
</span>
),
},
{
header: "وضعیت",
render: (r) =>
r.isValid ? (
<StatusBadge label="فعال" tone="green" />
) : r.isActive ? (
<StatusBadge label="خارج از بازه" tone="yellow" />
) : (
<StatusBadge label="غیرفعال" tone="gray" />
),
},
{
header: "",
render: (r) => (
<div className="flex items-center gap-1 justify-end">
<button
onClick={() => setEditing(r)}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 hover:bg-WHITE2"
aria-label="ویرایش"
>
<Pencil size={14} />
</button>
<button
onClick={() => onDelete(r)}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-RED hover:bg-RED/5"
aria-label="حذف"
>
<Trash2 size={14} />
</button>
</div>
),
},
];
return (
<div>
<div className="flex items-center justify-between gap-3">
<AdminPageTitle>تخفیفها</AdminPageTitle>
<button
onClick={() => setCreating(true)}
className="flex items-center gap-1.5 rounded-xl bg-BLACK text-white px-3.5 py-2 text-[13px] font-semibold hover:opacity-90"
>
<Plus size={16} /> تخفیف جدید
</button>
</div>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی نام تخفیف…"
/>
<AdminSelect
value={active}
onChange={(v) => reset(() => setActive(v))}
options={[
{ value: "", label: "همه" },
{ value: "true", label: "فعال" },
{ value: "false", label: "غیرفعال" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(r) => r.id}
emptyText="تخفیفی ثبت نشده است"
/>
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
{(creating || editing) && (
<DiscountModal
row={editing}
onClose={() => {
setCreating(false);
setEditing(null);
}}
/>
)}
</div>
);
}
function DiscountModal({ row, onClose }: { row: AdminDiscountRow | null; onClose: () => void }) {
const [form, setForm] = useState<FormState>(row ? fromRow(row) : EMPTY);
const [error, setError] = useState("");
const create = useAdminDiscountCreate();
const update = useAdminDiscountUpdate();
const busy = create.isPending || update.isPending;
const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
setForm((f) => ({ ...f, [k]: v }));
const submit = () => {
setError("");
if (!form.name || !form.code || !form.value || !form.validFrom || !form.validTo) {
setError("نام، کد، مقدار و بازه اعتبار الزامی است.");
return;
}
const payload = {
name: form.name,
code: Number(form.code),
discountType: form.discountType,
value: Number(form.value),
minPurchaseAmount: form.minPurchaseAmount ? Number(form.minPurchaseAmount) : null,
maxDiscountAmount: form.maxDiscountAmount ? Number(form.maxDiscountAmount) : null,
validFrom: form.validFrom,
validTo: form.validTo,
maxCount: form.maxCount ? Number(form.maxCount) : null,
isActive: form.isActive,
};
const onErr = (e: unknown) => setError(e instanceof Error ? e.message : "خطا در ذخیره");
if (row) {
update.mutate({ id: row.id, data: payload }, { onSuccess: onClose, onError: onErr });
} else {
create.mutate(payload, { onSuccess: onClose, onError: onErr });
}
};
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-BLACK/40 p-0 sm:p-4" onClick={onClose}>
<div
className="bg-WHITE w-full sm:max-w-lg rounded-t-2xl sm:rounded-2xl max-h-[92vh] overflow-y-auto hide-scrollbar"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-WHITE flex items-center justify-between px-5 py-4 border-b border-WHITE3">
<h3 className="font-extrabold text-[16px]">{row ? "ویرایش تخفیف" : "تخفیف جدید"}</h3>
<button onClick={onClose} className="grid h-8 w-8 place-items-center rounded-lg hover:bg-WHITE2">
<X size={18} />
</button>
</div>
<div className="p-5 grid grid-cols-2 gap-3">
<Field label="نام" className="col-span-2">
<input className={inputCls} value={form.name} onChange={(e) => set("name", e.target.value)} />
</Field>
<Field label="کد (عدد یکتا)">
<input type="number" className={inputCls} value={form.code} onChange={(e) => set("code", e.target.value)} dir="ltr" />
</Field>
<Field label="نوع">
<select className={inputCls} value={form.discountType} onChange={(e) => set("discountType", e.target.value)}>
<option value="value_based">مبلغ-محور</option>
<option value="user_based">کاربر-محور</option>
<option value="count_based">تعداد-محور</option>
</select>
</Field>
<Field label="مقدار">
<input type="number" className={inputCls} value={form.value} onChange={(e) => set("value", e.target.value)} dir="ltr" />
</Field>
<Field label="حداکثر دفعات (اختیاری)">
<input type="number" className={inputCls} value={form.maxCount} onChange={(e) => set("maxCount", e.target.value)} dir="ltr" />
</Field>
<Field label="حداقل مبلغ خرید (اختیاری)">
<input type="number" className={inputCls} value={form.minPurchaseAmount} onChange={(e) => set("minPurchaseAmount", e.target.value)} dir="ltr" />
</Field>
<Field label="حداکثر مبلغ تخفیف (اختیاری)">
<input type="number" className={inputCls} value={form.maxDiscountAmount} onChange={(e) => set("maxDiscountAmount", e.target.value)} dir="ltr" />
</Field>
<Field label="از تاریخ">
<input type="datetime-local" className={inputCls} value={form.validFrom} onChange={(e) => set("validFrom", e.target.value)} dir="ltr" />
</Field>
<Field label="تا تاریخ">
<input type="datetime-local" className={inputCls} value={form.validTo} onChange={(e) => set("validTo", e.target.value)} dir="ltr" />
</Field>
<label className="col-span-2 flex items-center gap-2 mt-1 cursor-pointer">
<input type="checkbox" checked={form.isActive} onChange={(e) => set("isActive", e.target.checked)} className="h-4 w-4" />
<span className="text-[13px] font-semibold">فعال</span>
</label>
{error && <p className="col-span-2 text-RED text-[12px]">{error}</p>}
</div>
<div className="sticky bottom-0 bg-WHITE flex gap-2 px-5 py-4 border-t border-WHITE3">
<button onClick={onClose} className="flex-1 rounded-xl border border-WHITE3 py-2.5 text-[13.5px] font-semibold hover:bg-WHITE2">
انصراف
</button>
<button
onClick={submit}
disabled={busy}
className="flex-1 rounded-xl bg-BLACK text-white py-2.5 text-[13.5px] font-semibold hover:opacity-90 disabled:opacity-50"
>
{busy ? "در حال ذخیره…" : "ذخیره"}
</button>
</div>
</div>
</div>
);
}
const inputCls =
"w-full rounded-xl border border-WHITE3 bg-WHITE px-3 py-2 text-[13.5px] focus:outline-none focus:border-GRAY";
function Field({
label,
children,
className,
}: {
label: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={className}>
<label className="block text-[12px] text-GRAY mb-1">{label}</label>
{children}
</div>
);
}

View File

@ -1,219 +0,0 @@
import { useState } from "react";
import { Trash2, ImageOff } from "lucide-react";
import {
useHomeBanners,
useHomeCategories,
useHomeSellerTiles,
useHomeBannerMutations,
useHomeCategoryMutations,
useHomeSellerTileMutations,
type HomeBannerRow,
type HomeCategoryRow,
type HomeSellerTileRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminPageTitle,
StatusBadge,
faNum,
faDate,
type Column,
} from "~/components/admin/DataTable";
import { cn } from "~/lib/utils";
const PAGE_SIZE = 25;
function Thumb({ url }: { url: string | null }) {
if (!url)
return (
<div className="h-11 w-11 rounded-lg border border-WHITE3 bg-WHITE2 grid place-items-center text-GRAY">
<ImageOff size={16} />
</div>
);
return <img src={url} alt="" className="h-11 w-11 rounded-lg object-cover border border-WHITE3" />;
}
function Toggle({ on, onClick }: { on: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
className={cn(
"relative h-6 w-11 rounded-full transition-colors",
on ? "bg-GREEN" : "bg-GRAY2"
)}
aria-pressed={on}
>
<span
className={cn(
"absolute top-0.5 h-5 w-5 rounded-full bg-white transition-all",
on ? "left-0.5" : "right-0.5"
)}
/>
</button>
);
}
function DeleteBtn({ onClick }: { onClick: () => void }) {
return (
<button
onClick={onClick}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-RED hover:bg-RED/5"
aria-label="حذف"
>
<Trash2 size={14} />
</button>
);
}
export default function AdminHomepage() {
const [tab, setTab] = useState<"banners" | "categories" | "tiles">("banners");
const TABS = [
{ k: "banners", label: "بنرها" },
{ k: "categories", label: "دسته‌ها" },
{ k: "tiles", label: "فروشگاه‌ها" },
] as const;
return (
<div>
<AdminPageTitle>صفحه اصلی</AdminPageTitle>
<p className="text-GRAY text-[12px] -mt-2 mb-4">
مدیریت آیتمهای موجود (فعال/غیرفعال، ترتیب، حذف). افزودن آیتم جدید از طریق آپلود تصویر انجام میشود.
</p>
<div className="flex gap-1 mb-5 border-b border-WHITE3">
{TABS.map((t) => (
<button
key={t.k}
onClick={() => setTab(t.k)}
className={cn(
"px-4 py-2 text-[13.5px] font-semibold border-b-2 -mb-px transition-colors",
tab === t.k ? "border-BLACK text-BLACK" : "border-transparent text-GRAY hover:text-BLACK2"
)}
>
{t.label}
</button>
))}
</div>
{tab === "banners" && <BannersTab />}
{tab === "categories" && <CategoriesTab />}
{tab === "tiles" && <TilesTab />}
</div>
);
}
function BannersTab() {
const [page, setPage] = useState(1);
const { data, isLoading } = useHomeBanners({ page, page_size: PAGE_SIZE });
const { update, remove } = useHomeBannerMutations();
const columns: Column<HomeBannerRow>[] = [
{ header: "", render: (r) => <Thumb url={r.imageUrl} /> },
{
header: "بنر",
render: (r) => (
<div>
<p className="font-semibold text-[13px]">{r.title || "—"}</p>
<p className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{r.linkType}:{r.linkId}
</p>
</div>
),
},
{
header: "بازه",
render: (r) => (
<span className="text-GRAY text-[11px] whitespace-nowrap">
{faDate(r.startDate)} {faDate(r.endDate)}
</span>
),
},
{
header: "نمایش",
render: (r) =>
r.isVisible ? <StatusBadge label="در حال نمایش" tone="green" /> : <StatusBadge label="غیرفعال" tone="gray" />,
},
{
header: "فعال",
render: (r) => (
<Toggle on={r.isActive} onClick={() => update.mutate({ id: r.id, data: { isActive: !r.isActive } })} />
),
},
{
header: "",
render: (r) => (
<DeleteBtn onClick={() => confirm("حذف این بنر؟") && remove.mutate(r.id)} />
),
},
];
return (
<div>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => String(r.id)} emptyText="بنری ثبت نشده است" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}
function CategoriesTab() {
const [page, setPage] = useState(1);
const { data, isLoading } = useHomeCategories({ page, page_size: PAGE_SIZE });
const { update, remove } = useHomeCategoryMutations();
const columns: Column<HomeCategoryRow>[] = [
{ header: "", render: (r) => <Thumb url={r.imageUrl} /> },
{ header: "دسته", render: (r) => <span className="font-semibold text-[13px]">{r.categoryName || "—"}</span> },
{
header: "فعال",
render: (r) => (
<Toggle on={r.isActive} onClick={() => update.mutate({ id: r.id, data: { isActive: !r.isActive } })} />
),
},
{ header: "", render: (r) => <DeleteBtn onClick={() => confirm("حذف این دسته از صفحه اصلی؟") && remove.mutate(r.id)} /> },
];
return (
<div>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => r.id} emptyText="دسته‌ای ثبت نشده است" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}
function TilesTab() {
const [page, setPage] = useState(1);
const { data, isLoading } = useHomeSellerTiles({ page, page_size: PAGE_SIZE });
const { update, remove } = useHomeSellerTileMutations();
const columns: Column<HomeSellerTileRow>[] = [
{ header: "", render: (r) => <Thumb url={r.imageUrl} /> },
{ header: "فروشگاه", render: (r) => <span className="font-semibold text-[13px]">{r.sellerName || "—"}</span> },
{
header: "ترتیب",
render: (r) => (
<input
type="number"
defaultValue={r.order}
onBlur={(e) => {
const v = Number(e.target.value);
if (v !== r.order) update.mutate({ id: r.id, data: { order: v } });
}}
className="w-16 rounded-lg border border-WHITE3 px-2 py-1 text-[13px] tabular-nums text-center"
dir="ltr"
/>
),
},
{
header: "فعال",
render: (r) => (
<Toggle on={r.isActive} onClick={() => update.mutate({ id: r.id, data: { isActive: !r.isActive } })} />
),
},
{ header: "", render: (r) => <DeleteBtn onClick={() => confirm("حذف این کاشی فروشگاه؟") && remove.mutate(r.id)} /> },
];
return (
<div>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => r.id} emptyText="کاشی فروشگاهی ثبت نشده است" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}

View File

@ -1,148 +0,0 @@
import {
Loader2,
Wallet,
Landmark,
ArrowDownCircle,
ArrowUpCircle,
AlertTriangle,
Clock,
CheckCircle2,
} from "lucide-react";
import { useLedgerOverview } from "~/requestHandler/use-admin-hooks";
import { KpiCard } from "~/components/admin/KpiCard";
import { faToman, faNum } from "~/components/admin/DataTable";
export default function LedgerOverview() {
const { data, isLoading, isError } = useLedgerOverview();
if (isLoading)
return (
<div className="flex min-h-[40vh] items-center justify-center">
<Loader2 className="h-7 w-7 animate-spin text-GRAY" />
</div>
);
if (isError || !data)
return <p className="text-RED text-[14px]">خطا در بارگذاری دفتر مالی</p>;
const { payouts, reconciliation, journal } = data;
const hasDiscrepancy = reconciliation.completedPayoutsNotDebited > 0;
const driftClean =
Math.abs(journal.sellerPayableDrift) < 1 && Math.abs(journal.walletHeldDrift) < 1;
return (
<div>
{/* Money held / owed */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiCard label="موجودی کیف پول کاربران" value={faToman(data.walletHeld)} icon={Wallet} />
<KpiCard label="مانده قابل پرداخت فروشندگان" value={faToman(data.sellerPayable)} icon={Landmark} />
<KpiCard label="کل واریزها (شارژ کیف پول)" value={faToman(data.depositsTotal)} icon={ArrowDownCircle} accent="green" />
<KpiCard label="کل خریدها" value={faToman(data.purchasesTotal)} icon={ArrowUpCircle} />
</div>
{/* Payouts by status */}
<h2 className="text-[15px] font-extrabold mt-7 mb-3">برداشتها</h2>
<div className="grid grid-cols-3 gap-3">
<KpiCard
label="در انتظار"
value={faToman(payouts.pending.amount)}
sub={`${faNum(payouts.pending.count)} درخواست`}
icon={Clock}
accent={payouts.pending.count > 0 ? "red" : "default"}
/>
<KpiCard
label="پرداخت‌شده"
value={faToman(payouts.completed.amount)}
sub={`${faNum(payouts.completed.count)} مورد`}
icon={CheckCircle2}
accent="green"
/>
<KpiCard
label="ردشده"
value={faToman(payouts.rejected.amount)}
sub={`${faNum(payouts.rejected.count)} مورد`}
/>
</div>
{/* Journal-derived truth (Slice 2c) */}
<div className="mt-7 flex items-center gap-2 mb-3">
<h2 className="text-[15px] font-extrabold">دفتر روزنامهای (منبع حقیقت)</h2>
<span
className={`text-[11px] font-semibold px-2 py-0.5 rounded-full ${
driftClean ? "bg-GREEN/10 text-GREEN" : "bg-yellow-500/10 text-yellow-600"
}`}
>
{driftClean ? "هم‌خوان با ثبت‌شده" : "نیازمند بک‌فیل"}
</span>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
<KpiCard
label="مانده فروشندگان (روزنامه)"
value={faToman(journal.sellerPayable)}
sub={`اختلاف با ثبت‌شده: ${faToman(journal.sellerPayableDrift)}`}
icon={Landmark}
accent={Math.abs(journal.sellerPayableDrift) < 1 ? "green" : "red"}
/>
<KpiCard
label="کیف پول کاربران (روزنامه)"
value={faToman(journal.walletHeld)}
sub={`اختلاف با ثبت‌شده: ${faToman(journal.walletHeldDrift)}`}
icon={Wallet}
accent={Math.abs(journal.walletHeldDrift) < 1 ? "green" : "red"}
/>
<KpiCard
label="درآمد پلتفرم (کارمزد)"
value={faToman(journal.platformRevenue)}
icon={ArrowUpCircle}
accent="green"
/>
</div>
{/* Reconciliation warning */}
{hasDiscrepancy && (
<div className="mt-7 rounded-2xl border border-RED/30 bg-RED/5 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-RED shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-extrabold text-[15px] text-RED">
مغایرت حسابداری شناسایی شد
</h3>
<p className="text-[13px] text-BLACK2 mt-1 leading-6">
مبلغ{" "}
<b className="tabular-nums">
{faToman(reconciliation.completedPayoutsNotDebited)}
</b>{" "}
بهعنوان برداشت «پرداختشده» ثبت شده اما از مانده فروشندگان کسر
نشده است. به همین دلیل «مانده قابل پرداخت» فعلی احتمالاً بیش از
مقدار واقعی است.
</p>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3 mt-3">
<div className="rounded-xl bg-WHITE border border-WHITE3 p-3">
<p className="text-[12px] text-GRAY">مانده ثبتشده</p>
<p className="font-bold tabular-nums mt-1">
{faToman(reconciliation.sellerPayableRecorded)}
</p>
</div>
<div className="rounded-xl bg-WHITE border border-WHITE3 p-3">
<p className="text-[12px] text-GRAY">برآورد مانده واقعی</p>
<p className="font-bold tabular-nums mt-1 text-GREEN">
{faToman(reconciliation.sellerPayableEstimatedTrue)}
</p>
</div>
<div className="rounded-xl bg-WHITE border border-WHITE3 p-3">
<p className="text-[12px] text-GRAY">اختلاف</p>
<p className="font-bold tabular-nums mt-1 text-RED">
{faToman(reconciliation.completedPayoutsNotDebited)}
</p>
</div>
</div>
<p className="text-[12px] text-GRAY mt-3">
این مورد در فاز بعدی (دفتر روزنامهای و اصلاح جریان پول) رفع خواهد
شد.
</p>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -1,156 +0,0 @@
import { useState } from "react";
import {
useLedgerJournal,
type LedgerEntryRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
StatusBadge,
faToman,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
// Journal entry_type → Persian label + tone.
const ENTRY: Record<string, { label: string; tone: "green" | "blue" | "red" }> = {
deposit: { label: "واریز", tone: "green" },
purchase: { label: "خرید", tone: "blue" },
seller_credit: { label: "اعتبار فروشنده", tone: "green" },
platform_fee: { label: "کارمزد پلتفرم", tone: "blue" },
refund: { label: "بازپرداخت", tone: "red" },
seller_reversal: { label: "برگشت فروشنده", tone: "red" },
payout: { label: "تسویه", tone: "red" },
};
const ACCOUNT: Record<string, string> = {
user_wallet: "کیف پول کاربر",
seller_balance: "موجودی فروشنده",
platform: "پلتفرم",
external: "خارجی",
};
export default function LedgerJournal() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [account, setAccount] = useState("");
const [entryType, setEntryType] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useLedgerJournal({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
account,
entry_type: entryType,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const columns: Column<LedgerEntryRow>[] = [
{
header: "نوع",
render: (e) => {
const t = ENTRY[e.entryType] || { label: e.entryType, tone: "blue" as const };
return <StatusBadge label={t.label} tone={t.tone} />;
},
},
{
header: "حساب",
render: (e) => (
<span className="text-GRAY text-[12px] whitespace-nowrap">
{ACCOUNT[e.account] || e.account}
</span>
),
},
{
header: "مبلغ",
render: (e) => (
<span
className={cnDelta(e.delta)}
dir="ltr"
>
{e.delta >= 0 ? "+" : ""}
{faToman(Math.abs(e.delta))}
</span>
),
},
{
header: "طرف",
render: (e) => (
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{e.sellerName || e.userPhone || "—"}
</span>
),
},
{
header: "سفارش",
render: (e) => (
<span className="text-GRAY tabular-nums">{e.order ? e.order.slice(0, 8) : "—"}</span>
),
},
{
header: "تاریخ",
render: (e) => <span className="text-GRAY whitespace-nowrap">{faDate(e.createdAt)}</span>,
},
];
return (
<div>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی فروشگاه / تلفن / توضیح…"
/>
<AdminSelect
value={account}
onChange={(v) => reset(() => setAccount(v))}
options={[
{ value: "", label: "همه حساب‌ها" },
{ value: "user_wallet", label: "کیف پول کاربر" },
{ value: "seller_balance", label: "موجودی فروشنده" },
{ value: "platform", label: "پلتفرم" },
{ value: "external", label: "خارجی" },
]}
/>
<AdminSelect
value={entryType}
onChange={(v) => reset(() => setEntryType(v))}
options={[
{ value: "", label: "همه انواع" },
{ value: "deposit", label: "واریز" },
{ value: "purchase", label: "خرید" },
{ value: "seller_credit", label: "اعتبار فروشنده" },
{ value: "platform_fee", label: "کارمزد پلتفرم" },
{ value: "refund", label: "بازپرداخت" },
{ value: "seller_reversal", label: "برگشت فروشنده" },
{ value: "payout", label: "تسویه" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(e) => e.id}
/>
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}
function cnDelta(delta: number) {
return `tabular-nums whitespace-nowrap font-semibold ${
delta >= 0 ? "text-GREEN" : "text-RED"
}`;
}

View File

@ -1,163 +0,0 @@
import { useState } from "react";
import { Check, X, Loader2 } from "lucide-react";
import {
useLedgerPayouts,
useLedgerPayoutAction,
type LedgerPayoutRow,
} from "~/requestHandler/use-admin-hooks";
import { useToast } from "~/hooks/use-toast";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
StatusBadge,
faToman,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
const STATUS: Record<string, { label: string; tone: "green" | "red" | "yellow" }> = {
pending: { label: "در انتظار", tone: "yellow" },
completed: { label: "پرداخت‌شده", tone: "green" },
rejected: { label: "ردشده", tone: "red" },
};
export default function LedgerPayouts() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [status, setStatus] = useState("");
const debouncedSearch = useDebouncedValue(search);
const action = useLedgerPayoutAction();
const { toast } = useToast();
const { data, isLoading } = useLedgerPayouts({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
status,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const run = (id: string, act: "complete" | "reject") =>
action.mutate(
{ id, action: act },
{
onSuccess: () =>
toast({
title: act === "complete" ? "برداشت تأیید شد" : "برداشت رد شد",
description:
act === "complete"
? "مبلغ از مانده فروشنده کسر و در دفتر ثبت شد."
: undefined,
}),
onError: (e: Error) =>
toast({ title: "خطا", description: e.message, variant: "destructive" }),
}
);
const pendingId = action.isPending ? action.variables?.id : null;
const actBtn =
"inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[12px] font-semibold transition-colors disabled:opacity-40 cursor-pointer";
const columns: Column<LedgerPayoutRow>[] = [
{
header: "فروشگاه",
render: (p) => (
<div className="min-w-[130px]">
<p className="font-semibold">{p.sellerName || "—"}</p>
<p className="text-GRAY text-[11px]" dir="ltr">@{p.sellerUsername}</p>
</div>
),
},
{
header: "مبلغ",
render: (p) => <span className="tabular-nums whitespace-nowrap">{faToman(p.amount)}</span>,
},
{
header: "وضعیت",
render: (p) => {
const s = STATUS[p.status] || { label: p.status, tone: "yellow" as const };
return <StatusBadge label={s.label} tone={s.tone} />;
},
},
{
header: "بانک",
render: (p) => (
<div className="min-w-[140px] text-[11px] text-GRAY" dir="ltr">
<p>{p.bankName || "—"}</p>
<p className="tabular-nums">{p.iban || ""}</p>
</div>
),
},
{
header: "درخواست",
render: (p) => <span className="text-GRAY whitespace-nowrap">{faDate(p.requestedAt)}</span>,
},
{
header: "پرداخت",
render: (p) => <span className="text-GRAY whitespace-nowrap">{faDate(p.processedAt)}</span>,
},
{
header: "عملیات",
className: "text-left",
render: (p) => {
if (p.status !== "pending") return <span className="text-GRAY"></span>;
if (pendingId === p.id)
return <Loader2 size={16} className="animate-spin text-GRAY inline" />;
return (
<div className="flex items-center gap-1.5 justify-end">
<button
className={`${actBtn} bg-GREEN/10 text-GREEN hover:bg-GREEN/20`}
onClick={() => run(p.id, "complete")}
>
<Check size={13} /> تأیید
</button>
<button
className={`${actBtn} bg-RED/10 text-RED hover:bg-RED/20`}
onClick={() => run(p.id, "reject")}
>
<X size={13} /> رد
</button>
</div>
);
},
},
];
return (
<div>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی فروشگاه…"
/>
<AdminSelect
value={status}
onChange={(v) => reset(() => setStatus(v))}
options={[
{ value: "", label: "همه وضعیت‌ها" },
{ value: "pending", label: "در انتظار" },
{ value: "completed", label: "پرداخت‌شده" },
{ value: "rejected", label: "ردشده" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(p) => p.id}
/>
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}

View File

@ -1,203 +0,0 @@
import { Link, useParams } from "@remix-run/react";
import { Loader2, ArrowRight, BadgeCheck } from "lucide-react";
import { useLedgerSellerDetail } from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
StatusBadge,
faToman,
faNum,
faDate,
type Column,
} from "~/components/admin/DataTable";
import type {
LedgerPayoutRow,
LedgerEntryRow,
} from "~/requestHandler/use-admin-hooks";
import { cn } from "~/lib/utils";
const ENTRY_LABEL: Record<string, string> = {
deposit: "واریز",
purchase: "خرید",
seller_credit: "اعتبار فروشنده",
platform_fee: "کارمزد پلتفرم",
refund: "بازپرداخت",
seller_reversal: "برگشت فروشنده",
payout: "تسویه",
};
const PAYOUT_STATUS: Record<string, { label: string; tone: "green" | "red" | "yellow" }> = {
pending: { label: "در انتظار", tone: "yellow" },
completed: { label: "پرداخت‌شده", tone: "green" },
rejected: { label: "ردشده", tone: "red" },
};
function Stat({
label,
value,
accent,
}: {
label: string;
value: string;
accent?: "red" | "green";
}) {
return (
<div className="rounded-2xl border border-WHITE3 bg-WHITE p-4">
<p className="text-[12px] text-GRAY">{label}</p>
<p
className={cn(
"font-extrabold tabular-nums mt-1 text-[18px]",
accent === "red" && "text-RED",
accent === "green" && "text-GREEN"
)}
>
{value}
</p>
</div>
);
}
export default function LedgerSellerDetail() {
const { id } = useParams();
const { data, isLoading, isError } = useLedgerSellerDetail(id);
if (isLoading)
return (
<div className="flex min-h-[40vh] items-center justify-center">
<Loader2 className="h-7 w-7 animate-spin text-GRAY" />
</div>
);
if (isError || !data)
return <p className="text-RED text-[14px]">خطا در بارگذاری اطلاعات فروشنده</p>;
const discrepant = Math.abs(data.discrepancy) > 1;
const journalDiscrepant = Math.abs(data.journalDiscrepancy) > 1;
const journalColumns: Column<LedgerEntryRow>[] = [
{
header: "نوع",
render: (e) => (
<span className="whitespace-nowrap">{ENTRY_LABEL[e.entryType] || e.entryType}</span>
),
},
{
header: "مبلغ",
render: (e) => (
<span
className={cn(
"tabular-nums whitespace-nowrap font-semibold",
e.delta >= 0 ? "text-GREEN" : "text-RED"
)}
dir="ltr"
>
{e.delta >= 0 ? "+" : ""}
{faToman(Math.abs(e.delta))}
</span>
),
},
{ header: "تاریخ", render: (e) => <span className="text-GRAY whitespace-nowrap">{faDate(e.createdAt)}</span> },
];
const payoutColumns: Column<LedgerPayoutRow>[] = [
{ header: "مبلغ", render: (p) => <span className="tabular-nums whitespace-nowrap">{faToman(p.amount)}</span> },
{
header: "وضعیت",
render: (p) => {
const s = PAYOUT_STATUS[p.status] || { label: p.status, tone: "yellow" as const };
return <StatusBadge label={s.label} tone={s.tone} />;
},
},
{ header: "درخواست", render: (p) => <span className="text-GRAY whitespace-nowrap">{faDate(p.requestedAt)}</span> },
{ header: "پرداخت", render: (p) => <span className="text-GRAY whitespace-nowrap">{faDate(p.processedAt)}</span> },
];
return (
<div>
<div className="flex items-center gap-2 mb-4">
<Link
to="/admin/ledger/sellers"
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 hover:bg-WHITE2"
>
<ArrowRight size={16} />
</Link>
<div>
<h2 className="font-extrabold text-[18px]">{data.seller.storeName}</h2>
<p className="text-GRAY text-[12px]" dir="ltr">
@{data.seller.username} · کمیسیون {faNum(data.seller.platformFee)}٪
</p>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
<Stat label="مانده ثبت‌شده" value={faToman(data.recordedBalance)} />
<Stat label="محاسبه‌شده از سفارش‌ها" value={faToman(data.accruedFromOrders)} />
<Stat label="مانده واقعی (تخمینی)" value={faToman(data.derivedBalance)} accent="green" />
<Stat
label="مانده روزنامه‌ای"
value={faToman(data.journalBalance)}
accent={journalDiscrepant ? "red" : "green"}
/>
<Stat label="برداشت‌های پرداخت‌شده" value={faToman(data.completedPayouts)} />
<Stat label="در انتظار برداشت" value={faToman(data.pendingPayouts)} />
<Stat
label="اختلاف"
value={faToman(data.discrepancy)}
accent={discrepant ? "red" : "green"}
/>
</div>
{discrepant && (
<p className="mt-3 text-[12px] text-RED">
مانده ثبتشده با مقدار محاسبهشده مغایرت دارد (بهاحتمال زیاد بهدلیل عدم
کسر برداشتهای پرداختشده از مانده).
</p>
)}
{/* Bank accounts */}
<h3 className="text-[15px] font-extrabold mt-7 mb-3">حسابهای بانکی</h3>
{data.bankAccounts.length === 0 ? (
<p className="text-GRAY text-[13px]">حساب بانکی ثبت نشده است.</p>
) : (
<div className="flex flex-col gap-2">
{data.bankAccounts.map((b) => (
<div
key={b.id}
className="rounded-xl border border-WHITE3 bg-WHITE p-3 flex items-center justify-between"
>
<div className="text-[13px]">
<p className="font-semibold">{b.accountHolder}</p>
<p className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{b.iban} · {b.bankName}
</p>
</div>
{b.isVerified ? (
<span className="flex items-center gap-1 text-[12px] text-VITROWN_BLUE">
<BadgeCheck size={15} /> تأییدشده
</span>
) : (
<StatusBadge label="تأییدنشده" tone="gray" />
)}
</div>
))}
</div>
)}
{/* Payout history */}
<h3 className="text-[15px] font-extrabold mt-7 mb-3">تاریخچه برداشت</h3>
<AdminTable
columns={payoutColumns}
rows={data.payouts}
getRowKey={(p) => p.id}
emptyText="برداشتی ثبت نشده است"
/>
{/* Journal history (Slice 2c) */}
<h3 className="text-[15px] font-extrabold mt-7 mb-3">دفتر روزنامهای</h3>
<AdminTable
columns={journalColumns}
rows={data.journal}
getRowKey={(e) => e.id}
emptyText="ثبتی در دفتر روزنامه وجود ندارد (پس از اجرای بک‌فیل نمایش داده می‌شود)"
/>
</div>
);
}

View File

@ -1,95 +0,0 @@
import { useState } from "react";
import { Link } from "@remix-run/react";
import { ChevronLeft } from "lucide-react";
import {
useLedgerSellers,
type LedgerSellerRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
faToman,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
export default function LedgerSellers() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useLedgerSellers({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const columns: Column<LedgerSellerRow>[] = [
{
header: "فروشگاه",
render: (s) => (
<div className="min-w-[140px]">
<p className="font-semibold">{s.storeName || "—"}</p>
<p className="text-GRAY text-[11px]" dir="ltr">@{s.username}</p>
</div>
),
},
{
header: "مانده",
render: (s) => (
<span className="tabular-nums font-semibold whitespace-nowrap">
{faToman(s.availableFunds)}
</span>
),
},
{
header: "برداشت‌شده",
render: (s) => <span className="tabular-nums whitespace-nowrap text-GRAY">{faToman(s.completedPayouts)}</span>,
},
{
header: "در انتظار برداشت",
render: (s) => <span className="tabular-nums whitespace-nowrap text-GRAY">{faToman(s.pendingPayouts)}</span>,
},
{
header: "",
className: "text-left w-10",
render: (s) => (
<Link
to={`/admin/ledger/sellers/${s.sellerId}`}
className="inline-grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-BLACK2 hover:bg-WHITE2"
aria-label="جزئیات"
>
<ChevronLeft size={16} />
</Link>
),
},
];
return (
<div>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی فروشگاه…"
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(s) => s.sellerId}
/>
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}

View File

@ -1,102 +0,0 @@
import { useState } from "react";
import {
useLedgerTransactions,
type LedgerTransactionRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
StatusBadge,
faToman,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
const TYPE: Record<string, { label: string; tone: "green" | "blue" }> = {
deposit: { label: "واریز", tone: "green" },
purchase: { label: "خرید", tone: "blue" },
};
export default function LedgerTransactions() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [type, setType] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useLedgerTransactions({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
transaction_type: type,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const columns: Column<LedgerTransactionRow>[] = [
{
header: "نوع",
render: (t) => {
const ty = TYPE[t.transactionType] || { label: t.transactionType, tone: "blue" as const };
return <StatusBadge label={ty.label} tone={ty.tone} />;
},
},
{
header: "مبلغ",
render: (t) => <span className="tabular-nums whitespace-nowrap">{faToman(t.amount)}</span>,
},
{
header: "کاربر",
render: (t) => (
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{t.userPhone || "—"}
</span>
),
},
{
header: "سفارش",
render: (t) => (
<span className="text-GRAY tabular-nums">{t.order ? t.order.slice(0, 8) : "—"}</span>
),
},
{
header: "تاریخ",
render: (t) => <span className="text-GRAY whitespace-nowrap">{faDate(t.createdAt)}</span>,
},
];
return (
<div>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی تلفن کاربر…"
/>
<AdminSelect
value={type}
onChange={(v) => reset(() => setType(v))}
options={[
{ value: "", label: "همه انواع" },
{ value: "deposit", label: "واریز" },
{ value: "purchase", label: "خرید" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(t) => t.id}
/>
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}

View File

@ -1,41 +0,0 @@
import { Link, Outlet, useLocation } from "@remix-run/react";
import { cn } from "~/lib/utils";
import { AdminPageTitle } from "~/components/admin/DataTable";
const TABS = [
{ label: "نمای کلی", to: "/admin/ledger", exact: true },
{ label: "برداشت‌ها", to: "/admin/ledger/payouts" },
{ label: "تراکنش‌ها", to: "/admin/ledger/transactions" },
{ label: "دفتر روزنامه", to: "/admin/ledger/journal" },
{ label: "فروشندگان", to: "/admin/ledger/sellers" },
];
export default function LedgerLayout() {
const loc = useLocation();
const active = (t: (typeof TABS)[number]) =>
t.exact ? loc.pathname === t.to : loc.pathname.startsWith(t.to);
return (
<div>
<AdminPageTitle>دفتر مالی</AdminPageTitle>
<div className="flex gap-1 mb-5 border-b border-WHITE3 overflow-x-auto hide-scrollbar">
{TABS.map((t) => (
<Link
key={t.to}
to={t.to}
prefetch="intent"
className={cn(
"px-4 py-2 text-[13.5px] font-semibold whitespace-nowrap border-b-2 -mb-px transition-colors",
active(t)
? "border-BLACK text-BLACK"
: "border-transparent text-GRAY hover:text-BLACK2"
)}
>
{t.label}
</Link>
))}
</div>
<Outlet />
</div>
);
}

View File

@ -1,228 +0,0 @@
import { useState } from "react";
import { Eye, EyeOff, Trash2 } from "lucide-react";
import {
useAdminComments,
useAdminChatMessages,
useAdminCommentModerate,
useAdminCommentDelete,
type AdminCommentRow,
type AdminChatMessageRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
import { cn } from "~/lib/utils";
const PAGE_SIZE = 25;
export default function AdminModeration() {
const [tab, setTab] = useState<"comments" | "chat">("comments");
return (
<div>
<AdminPageTitle>مدیریت محتوا</AdminPageTitle>
<div className="flex gap-1 mb-5 border-b border-WHITE3">
{(["comments", "chat"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
"px-4 py-2 text-[13.5px] font-semibold border-b-2 -mb-px transition-colors",
tab === t ? "border-BLACK text-BLACK" : "border-transparent text-GRAY hover:text-BLACK2"
)}
>
{t === "comments" ? "نظرات" : "گفتگوها"}
</button>
))}
</div>
{tab === "comments" ? <CommentsTab /> : <ChatTab />}
</div>
);
}
function CommentsTab() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [hidden, setHidden] = useState("");
const [kind, setKind] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAdminComments({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
is_hidden: hidden,
kind,
});
const moderate = useAdminCommentModerate();
const del = useAdminCommentDelete();
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const onDelete = (r: AdminCommentRow) => {
if (confirm("حذف این نظر؟ این عمل بازگشت‌پذیر نیست.")) del.mutate(r.id);
};
const columns: Column<AdminCommentRow>[] = [
{
header: "نظر",
render: (r) => (
<div className="max-w-[340px]">
<p className={cn("text-[13px] break-words", r.isHidden && "text-GRAY line-through")}>
{r.comment}
</p>
<p className="text-GRAY text-[11px] mt-0.5 truncate">
{r.productTitle || "—"}
{r.isReply && <span className="text-VITROWN_BLUE"> · پاسخ</span>}
{!r.isReply && r.repliesCount > 0 && <span> · {r.repliesCount} پاسخ</span>}
</p>
</div>
),
},
{
header: "کاربر",
render: (r) => (
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{r.userPhone || "—"}
</span>
),
},
{
header: "وضعیت",
render: (r) =>
r.isHidden ? (
<StatusBadge label="پنهان" tone="red" />
) : (
<StatusBadge label="نمایش" tone="green" />
),
},
{ header: "تاریخ", render: (r) => <span className="text-GRAY whitespace-nowrap">{faDate(r.createdAt)}</span> },
{
header: "",
render: (r) => (
<div className="flex items-center gap-1 justify-end">
<button
onClick={() => moderate.mutate({ id: r.id, isHidden: !r.isHidden })}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 hover:bg-WHITE2"
aria-label={r.isHidden ? "نمایش" : "پنهان کردن"}
title={r.isHidden ? "نمایش" : "پنهان کردن"}
>
{r.isHidden ? <Eye size={14} /> : <EyeOff size={14} />}
</button>
<button
onClick={() => onDelete(r)}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-RED hover:bg-RED/5"
aria-label="حذف"
>
<Trash2 size={14} />
</button>
</div>
),
},
];
return (
<div>
<AdminToolbar>
<AdminSearch value={search} onChange={(v) => reset(() => setSearch(v))} placeholder="جستجوی متن نظر / کاربر…" />
<AdminSelect
value={hidden}
onChange={(v) => reset(() => setHidden(v))}
options={[
{ value: "", label: "همه" },
{ value: "false", label: "نمایش‌داده‌شده" },
{ value: "true", label: "پنهان‌شده" },
]}
/>
<AdminSelect
value={kind}
onChange={(v) => reset(() => setKind(v))}
options={[
{ value: "", label: "همه" },
{ value: "top", label: "نظر اصلی" },
{ value: "reply", label: "پاسخ" },
]}
/>
</AdminToolbar>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => r.id} emptyText="نظری ثبت نشده است" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}
function ChatTab() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [deleted, setDeleted] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAdminChatMessages({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
is_deleted: deleted,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const columns: Column<AdminChatMessageRow>[] = [
{
header: "پیام",
render: (r) => (
<div className="max-w-[360px]">
<p className={cn("text-[13px] break-words", r.isDeleted && "text-GRAY line-through")}>
{r.content || (r.imageUrl ? "🖼 تصویر" : "—")}
</p>
</div>
),
},
{
header: "فرستنده",
render: (r) => (
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{r.senderPhone || "—"}
</span>
),
},
{
header: "وضعیت",
render: (r) =>
r.isDeleted ? <StatusBadge label="حذف‌شده" tone="gray" /> : <StatusBadge label="فعال" tone="green" />,
},
{ header: "تاریخ", render: (r) => <span className="text-GRAY whitespace-nowrap">{faDate(r.createdAt)}</span> },
];
return (
<div>
<AdminToolbar>
<AdminSearch value={search} onChange={(v) => reset(() => setSearch(v))} placeholder="جستجوی متن پیام / فرستنده…" />
<AdminSelect
value={deleted}
onChange={(v) => reset(() => setDeleted(v))}
options={[
{ value: "", label: "همه" },
{ value: "false", label: "فعال" },
{ value: "true", label: "حذف‌شده" },
]}
/>
</AdminToolbar>
<p className="text-GRAY text-[12px] mb-2">مشاهدهی فقطخواندنی گفتگوها.</p>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => r.id} emptyText="پیامی یافت نشد" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}

View File

@ -1,321 +0,0 @@
import { useState } from "react";
import { Plus, Pencil, Trash2, X, Ban } from "lucide-react";
import {
useNotificationsOverview,
useNotificationTypes,
useNotifications,
useSmsBlacklist,
useNotificationTypeCreate,
useNotificationTypeUpdate,
useNotificationTypeDelete,
useSmsBlacklistDelete,
type NotificationTypeRow,
type NotificationRow,
type SmsBlacklistRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faNum,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
import { KpiCard } from "~/components/admin/KpiCard";
import { cn } from "~/lib/utils";
const PAGE_SIZE = 25;
export default function AdminNotifications() {
const [tab, setTab] = useState<"types" | "sent" | "blacklist">("types");
const TABS = [
{ k: "types", label: "قالب‌ها" },
{ k: "sent", label: "ارسال‌ها" },
{ k: "blacklist", label: "لیست سیاه" },
] as const;
return (
<div>
<AdminPageTitle>اعلانها</AdminPageTitle>
<div className="flex gap-1 mb-5 border-b border-WHITE3">
{TABS.map((t) => (
<button
key={t.k}
onClick={() => setTab(t.k)}
className={cn(
"px-4 py-2 text-[13.5px] font-semibold border-b-2 -mb-px transition-colors",
tab === t.k ? "border-BLACK text-BLACK" : "border-transparent text-GRAY hover:text-BLACK2"
)}
>
{t.label}
</button>
))}
</div>
{tab === "types" && <TypesTab />}
{tab === "sent" && <SentTab />}
{tab === "blacklist" && <BlacklistTab />}
</div>
);
}
/* ------------------------------- templates ------------------------------- */
function TypesTab() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [editing, setEditing] = useState<NotificationTypeRow | null>(null);
const [creating, setCreating] = useState(false);
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useNotificationTypes({ page, page_size: PAGE_SIZE, search: debouncedSearch });
const del = useNotificationTypeDelete();
const columns: Column<NotificationTypeRow>[] = [
{
header: "عنوان",
render: (r) => (
<div>
<p className="font-semibold">{r.title}</p>
<p className="text-GRAY text-[11px] tabular-nums" dir="ltr">{r.code}</p>
</div>
),
},
{
header: "کانال‌ها",
render: (r) => (
<div className="flex gap-1">
{r.defaultInApp && <StatusBadge label="این‌اپ" tone="blue" />}
{r.defaultSms && <StatusBadge label="پیامک" tone="green" />}
</div>
),
},
{
header: "قالب",
render: (r) => (
<span className="text-GRAY text-[11px] line-clamp-1 max-w-[220px]">{r.template || "—"}</span>
),
},
{
header: "",
render: (r) => (
<div className="flex items-center gap-1 justify-end">
<button onClick={() => setEditing(r)} className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 hover:bg-WHITE2" aria-label="ویرایش">
<Pencil size={14} />
</button>
<button
onClick={() => confirm(`حذف قالب «${r.title}»؟`) && del.mutate(r.id)}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-RED hover:bg-RED/5"
aria-label="حذف"
>
<Trash2 size={14} />
</button>
</div>
),
},
];
return (
<div>
<div className="flex justify-end mb-3">
<button
onClick={() => setCreating(true)}
className="flex items-center gap-1.5 rounded-xl bg-BLACK text-white px-3.5 py-2 text-[13px] font-semibold hover:opacity-90"
>
<Plus size={16} /> قالب جدید
</button>
</div>
<AdminToolbar>
<AdminSearch value={search} onChange={(v) => { setSearch(v); setPage(1); }} placeholder="جستجوی کد / عنوان…" />
</AdminToolbar>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => String(r.id)} emptyText="قالبی ثبت نشده است" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
{(creating || editing) && (
<TypeModal row={editing} onClose={() => { setCreating(false); setEditing(null); }} />
)}
</div>
);
}
function TypeModal({ row, onClose }: { row: NotificationTypeRow | null; onClose: () => void }) {
const [code, setCode] = useState(row?.code ?? "");
const [title, setTitle] = useState(row?.title ?? "");
const [defaultSms, setDefaultSms] = useState(row?.defaultSms ?? false);
const [defaultInApp, setDefaultInApp] = useState(row?.defaultInApp ?? true);
const [template, setTemplate] = useState(row?.template ?? "");
const [variables, setVariables] = useState((row?.variables ?? []).join(", "));
const [error, setError] = useState("");
const create = useNotificationTypeCreate();
const update = useNotificationTypeUpdate();
const busy = create.isPending || update.isPending;
const submit = () => {
setError("");
if (!code || !title) return setError("کد و عنوان الزامی است.");
const payload = {
code,
title,
defaultSms,
defaultInApp,
template,
variables: variables.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
};
const onErr = (e: unknown) => setError(e instanceof Error ? e.message : "خطا در ذخیره");
if (row) update.mutate({ id: row.id, data: payload }, { onSuccess: onClose, onError: onErr });
else create.mutate(payload, { onSuccess: onClose, onError: onErr });
};
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-BLACK/40 sm:p-4" onClick={onClose}>
<div className="bg-WHITE w-full sm:max-w-lg rounded-t-2xl sm:rounded-2xl max-h-[92vh] overflow-y-auto hide-scrollbar" onClick={(e) => e.stopPropagation()}>
<div className="sticky top-0 bg-WHITE flex items-center justify-between px-5 py-4 border-b border-WHITE3">
<h3 className="font-extrabold text-[16px]">{row ? "ویرایش قالب" : "قالب جدید"}</h3>
<button onClick={onClose} className="grid h-8 w-8 place-items-center rounded-lg hover:bg-WHITE2"><X size={18} /></button>
</div>
<div className="p-5 flex flex-col gap-3">
<div>
<label className={labelCls}>کد (یکتا)</label>
<input className={inputCls} value={code} onChange={(e) => setCode(e.target.value)} dir="ltr" />
</div>
<div>
<label className={labelCls}>عنوان</label>
<input className={inputCls} value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={defaultInApp} onChange={(e) => setDefaultInApp(e.target.checked)} className="h-4 w-4" />
<span className="text-[13px] font-semibold">ایناپ پیشفرض</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={defaultSms} onChange={(e) => setDefaultSms(e.target.checked)} className="h-4 w-4" />
<span className="text-[13px] font-semibold">پیامک پیشفرض</span>
</label>
</div>
<div>
<label className={labelCls}>قالب پیام</label>
<textarea className={cn(inputCls, "min-h-[90px] resize-y")} value={template} onChange={(e) => setTemplate(e.target.value)} />
</div>
<div>
<label className={labelCls}>متغیرها (با کاما جدا شوند)</label>
<input className={inputCls} value={variables} onChange={(e) => setVariables(e.target.value)} dir="ltr" placeholder="seller.username, order.id" />
</div>
{error && <p className="text-RED text-[12px]">{error}</p>}
</div>
<div className="sticky bottom-0 bg-WHITE flex gap-2 px-5 py-4 border-t border-WHITE3">
<button onClick={onClose} className="flex-1 rounded-xl border border-WHITE3 py-2.5 text-[13.5px] font-semibold hover:bg-WHITE2">انصراف</button>
<button onClick={submit} disabled={busy} className="flex-1 rounded-xl bg-BLACK text-white py-2.5 text-[13.5px] font-semibold hover:opacity-90 disabled:opacity-50">
{busy ? "در حال ذخیره…" : "ذخیره"}
</button>
</div>
</div>
</div>
);
}
/* --------------------------------- sent --------------------------------- */
function SentTab() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [read, setRead] = useState("");
const [sentSms, setSentSms] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data: ov } = useNotificationsOverview();
const { data, isLoading } = useNotifications({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
read,
sent_sms: sentSms,
});
const reset = (fn: () => void) => { fn(); setPage(1); };
const columns: Column<NotificationRow>[] = [
{
header: "عنوان",
render: (r) => (
<div className="max-w-[280px]">
<p className="font-semibold text-[13px] truncate">{r.title}</p>
<p className="text-GRAY text-[11px]">{r.typeTitle || r.typeCode || "—"}</p>
</div>
),
},
{ header: "گیرنده", render: (r) => <span className="text-GRAY text-[11px] tabular-nums" dir="ltr">{r.receiverPhone || "—"}</span> },
{
header: "کانال",
render: (r) => (
<div className="flex gap-1">
{r.sentInApp && <StatusBadge label="این‌اپ" tone="blue" />}
{r.sentSms && <StatusBadge label="پیامک" tone="green" />}
{!r.sentInApp && !r.sentSms && <span className="text-GRAY text-[11px]"></span>}
</div>
),
},
{ header: "خوانده", render: (r) => (r.read ? <StatusBadge label="بله" tone="gray" /> : <StatusBadge label="خیر" tone="yellow" />) },
{ header: "تاریخ", render: (r) => <span className="text-GRAY whitespace-nowrap">{faDate(r.createdAt)}</span> },
];
return (
<div>
{ov && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<KpiCard label="کل اعلان‌ها" value={faNum(ov.total)} />
<KpiCard label="پیامک ارسال‌شده" value={faNum(ov.smsSent)} accent="green" />
<KpiCard label="خوانده‌نشده" value={faNum(ov.unread)} accent={ov.unread > 0 ? "red" : "default"} />
<KpiCard label="اشتراک پوش" value={faNum(ov.pushSubscriptions)} />
</div>
)}
<AdminToolbar>
<AdminSearch value={search} onChange={(v) => reset(() => setSearch(v))} placeholder="جستجوی گیرنده / عنوان…" />
<AdminSelect value={read} onChange={(v) => reset(() => setRead(v))} options={[{ value: "", label: "همه" }, { value: "false", label: "خوانده‌نشده" }, { value: "true", label: "خوانده‌شده" }]} />
<AdminSelect value={sentSms} onChange={(v) => reset(() => setSentSms(v))} options={[{ value: "", label: "همه" }, { value: "true", label: "با پیامک" }, { value: "false", label: "بدون پیامک" }]} />
</AdminToolbar>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => String(r.id)} emptyText="اعلانی یافت نشد" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}
/* ------------------------------- blacklist ------------------------------- */
function BlacklistTab() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useSmsBlacklist({ page, page_size: PAGE_SIZE, search: debouncedSearch });
const del = useSmsBlacklistDelete();
const columns: Column<SmsBlacklistRow>[] = [
{ header: "موبایل", render: (r) => <span className="tabular-nums" dir="ltr">{r.mobile}</span> },
{ header: "خط", render: (r) => <span className="tabular-nums" dir="ltr">{faNum(r.lineNumber)}</span> },
{ header: "دلیل", render: (r) => <span className="text-GRAY text-[11px] line-clamp-1 max-w-[220px]">{r.reason || "—"}</span> },
{ header: "تاریخ", render: (r) => <span className="text-GRAY whitespace-nowrap">{faDate(r.createdAt)}</span> },
{
header: "",
render: (r) => (
<button
onClick={() => confirm("حذف از لیست سیاه (رفع مسدودی)؟") && del.mutate(r.id)}
className="flex items-center gap-1 rounded-lg border border-WHITE3 px-2.5 h-8 text-[12px] hover:bg-WHITE2"
>
<Ban size={13} /> رفع مسدودی
</button>
),
},
];
return (
<div>
<AdminToolbar>
<AdminSearch value={search} onChange={(v) => { setSearch(v); setPage(1); }} placeholder="جستجوی موبایل…" />
</AdminToolbar>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => String(r.id)} emptyText="لیست سیاه خالی است" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}
const inputCls = "w-full rounded-xl border border-WHITE3 bg-WHITE px-3 py-2 text-[13.5px] focus:outline-none focus:border-GRAY";
const labelCls = "block text-[12px] text-GRAY mb-1";

View File

@ -1,150 +0,0 @@
import type { MetaFunction } from "@remix-run/node";
import { useState } from "react";
import { useAdminOrders } from "~/requestHandler/use-admin-hooks";
import type { AdminOrderRow } from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faToman,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
export const meta: MetaFunction = () => [
{ title: "سفارش‌ها | پنل مدیریت" },
{ name: "robots", content: "noindex, nofollow" },
];
const PAGE_SIZE = 25;
const STATUS: Record<string, { label: string; tone: "green" | "red" | "yellow" | "blue" | "gray" }> = {
pending: { label: "در انتظار", tone: "yellow" },
confirmed: { label: "تأییدشده", tone: "blue" },
shipped: { label: "ارسال‌شده", tone: "blue" },
delivered: { label: "تحویل‌شده", tone: "green" },
cancelled: { label: "لغوشده", tone: "red" },
};
const PAYMENT: Record<string, { label: string; tone: "green" | "red" | "yellow" }> = {
paid: { label: "پرداخت‌شده", tone: "green" },
pending: { label: "در انتظار", tone: "yellow" },
failed: { label: "ناموفق", tone: "red" },
};
export default function AdminOrders() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [status, setStatus] = useState("");
const [payment, setPayment] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAdminOrders({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
status,
payment_status: payment,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const columns: Column<AdminOrderRow>[] = [
{
header: "کد",
render: (o) => <span className="tabular-nums text-GRAY">{o.id.slice(0, 8)}</span>,
},
{
header: "خریدار",
render: (o) => (
<div className="min-w-[120px]">
<p className="font-semibold">{o.userName || "—"}</p>
<p className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{o.userPhone || ""}
</p>
</div>
),
},
{
header: "فروشگاه",
render: (o) => <span>{o.sellerName || o.sellerUsername || "—"}</span>,
},
{
header: "مبلغ",
render: (o) => <span className="tabular-nums whitespace-nowrap">{faToman(o.totalPrice)}</span>,
},
{
header: "پرداخت",
render: (o) => {
const p = PAYMENT[o.paymentStatus] || { label: o.paymentStatus, tone: "gray" as const };
return <StatusBadge label={p.label} tone={p.tone} />;
},
},
{
header: "وضعیت",
render: (o) => {
const s = STATUS[o.status] || { label: o.status, tone: "gray" as const };
return <StatusBadge label={s.label} tone={s.tone} />;
},
},
{
header: "تاریخ",
render: (o) => <span className="text-GRAY whitespace-nowrap">{faDate(o.createdAt)}</span>,
},
];
return (
<div>
<AdminPageTitle>سفارشها</AdminPageTitle>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی خریدار یا فروشگاه…"
/>
<AdminSelect
value={status}
onChange={(v) => reset(() => setStatus(v))}
options={[
{ value: "", label: "همه وضعیت‌ها" },
{ value: "pending", label: "در انتظار" },
{ value: "confirmed", label: "تأییدشده" },
{ value: "shipped", label: "ارسال‌شده" },
{ value: "delivered", label: "تحویل‌شده" },
{ value: "cancelled", label: "لغوشده" },
]}
/>
<AdminSelect
value={payment}
onChange={(v) => reset(() => setPayment(v))}
options={[
{ value: "", label: "همه پرداخت‌ها" },
{ value: "paid", label: "پرداخت‌شده" },
{ value: "pending", label: "در انتظار" },
{ value: "failed", label: "ناموفق" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(o) => o.id}
/>
<AdminPagination
page={page}
pageSize={PAGE_SIZE}
count={data?.count || 0}
onPageChange={setPage}
/>
</div>
);
}

View File

@ -1,160 +0,0 @@
import type { MetaFunction } from "@remix-run/node";
import { useState } from "react";
import { Link } from "@remix-run/react";
import { Loader2, Eye, EyeOff } from "lucide-react";
import {
useAdminProducts,
useAdminProductUpdate,
} from "~/requestHandler/use-admin-hooks";
import type { AdminProductRow } from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faToman,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
export const meta: MetaFunction = () => [
{ title: "محصولات | پنل مدیریت" },
{ name: "robots", content: "noindex, nofollow" },
];
const PAGE_SIZE = 25;
export default function AdminProducts() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [active, setActive] = useState("");
const debouncedSearch = useDebouncedValue(search);
const update = useAdminProductUpdate();
const { data, isLoading } = useAdminProducts({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
is_active: active,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const pendingId = update.isPending ? update.variables?.id : null;
const actBtn =
"inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[12px] font-semibold transition-colors disabled:opacity-40 cursor-pointer";
const columns: Column<AdminProductRow>[] = [
{
header: "محصول",
render: (p) => (
<Link
to={`/product/${p.id}`}
className="flex items-center gap-2 min-w-[180px] hover:opacity-80"
>
<div className="h-10 w-10 rounded-lg bg-WHITE3 overflow-hidden shrink-0">
{p.primaryImage && (
<img src={p.primaryImage} alt="" className="h-full w-full object-cover" />
)}
</div>
<p className="font-semibold truncate max-w-[220px]">{p.title}</p>
</Link>
),
},
{
header: "فروشگاه",
render: (p) => <span>{p.sellerName || p.sellerUsername || "—"}</span>,
},
{
header: "دسته",
render: (p) => <span className="text-GRAY">{p.categoryName || "—"}</span>,
},
{
header: "قیمت",
render: (p) => <span className="tabular-nums whitespace-nowrap">{faToman(p.basePrice)}</span>,
},
{
header: "وضعیت",
render: (p) =>
p.isActive ? (
<StatusBadge label="فعال" tone="green" />
) : (
<StatusBadge label="غیرفعال" tone="gray" />
),
},
{
header: "تاریخ",
render: (p) => <span className="text-GRAY whitespace-nowrap">{faDate(p.createdAt)}</span>,
},
{
header: "عملیات",
className: "text-left",
render: (p) => {
const busy = pendingId === p.id;
if (busy)
return <Loader2 size={16} className="animate-spin text-GRAY inline" />;
return (
<div className="flex justify-end">
{p.isActive ? (
<button
className={`${actBtn} bg-WHITE2 text-BLACK2 hover:bg-WHITE3`}
onClick={() => update.mutate({ id: p.id, data: { isActive: false } })}
>
<EyeOff size={13} /> غیرفعال
</button>
) : (
<button
className={`${actBtn} bg-GREEN/10 text-GREEN hover:bg-GREEN/20`}
onClick={() => update.mutate({ id: p.id, data: { isActive: true } })}
>
<Eye size={13} /> فعال
</button>
)}
</div>
);
},
},
];
return (
<div>
<AdminPageTitle>محصولات</AdminPageTitle>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی عنوان یا فروشگاه…"
/>
<AdminSelect
value={active}
onChange={(v) => reset(() => setActive(v))}
options={[
{ value: "", label: "همه" },
{ value: "true", label: "فعال" },
{ value: "false", label: "غیرفعال" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(p) => p.id}
/>
<AdminPagination
page={page}
pageSize={PAGE_SIZE}
count={data?.count || 0}
onPageChange={setPage}
/>
</div>
);
}

View File

@ -1,193 +0,0 @@
import type { MetaFunction } from "@remix-run/node";
import { useState } from "react";
import { Check, X, BadgeCheck, Loader2 } from "lucide-react";
import {
useAdminSellers,
useAdminSellerUpdate,
} from "~/requestHandler/use-admin-hooks";
import type { AdminSellerRow } from "~/requestHandler/use-admin-hooks";
import SellerLogo from "~/components/SellerLogo";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faNum,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
export const meta: MetaFunction = () => [
{ title: "فروشگاه‌ها | پنل مدیریت" },
{ name: "robots", content: "noindex, nofollow" },
];
const PAGE_SIZE = 25;
const STATUS: Record<string, { label: string; tone: "green" | "red" | "yellow" }> = {
pending: { label: "در انتظار", tone: "yellow" },
approved: { label: "تأییدشده", tone: "green" },
rejected: { label: "ردشده", tone: "red" },
};
export default function AdminSellers() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [status, setStatus] = useState("");
const [verified, setVerified] = useState("");
const debouncedSearch = useDebouncedValue(search);
const update = useAdminSellerUpdate();
const { data, isLoading } = useAdminSellers({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
status,
is_verified: verified,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const pendingId = update.isPending ? update.variables?.id : null;
const actBtn =
"inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[12px] font-semibold transition-colors disabled:opacity-40 cursor-pointer";
const columns: Column<AdminSellerRow>[] = [
{
header: "فروشگاه",
render: (s) => (
<div className="flex items-center gap-2 min-w-[160px]">
<SellerLogo size="sm" src={s.storeLogo || undefined} alt={s.storeName} />
<div className="min-w-0">
<p className="font-semibold truncate">{s.storeName}</p>
<p className="text-GRAY text-[11px]" dir="ltr">@{s.username}</p>
</div>
</div>
),
},
{
header: "تلفن",
render: (s) => (
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{s.userPhone || "—"}
</span>
),
},
{
header: "وضعیت",
render: (s) => {
const st = STATUS[s.status] || { label: s.status, tone: "yellow" as const };
return (
<div className="flex items-center gap-1.5">
<StatusBadge label={st.label} tone={st.tone} />
{s.isVerified && <BadgeCheck size={15} className="text-VITROWN_BLUE" />}
</div>
);
},
},
{
header: "کمیسیون",
render: (s) => <span className="tabular-nums">{faNum(s.platformFee)}٪</span>,
},
{
header: "محصولات",
render: (s) => <span className="tabular-nums">{faNum(s.productCount)}</span>,
},
{
header: "دنبال‌کننده",
render: (s) => <span className="tabular-nums">{faNum(s.followerCount)}</span>,
},
{
header: "تاریخ",
render: (s) => <span className="text-GRAY whitespace-nowrap">{faDate(s.createdAt)}</span>,
},
{
header: "عملیات",
className: "text-left",
render: (s) => {
const busy = pendingId === s.id;
if (busy)
return <Loader2 size={16} className="animate-spin text-GRAY inline" />;
return (
<div className="flex items-center gap-1.5 justify-end">
{s.status !== "approved" && (
<button
className={`${actBtn} bg-GREEN/10 text-GREEN hover:bg-GREEN/20`}
onClick={() => update.mutate({ id: s.id, data: { status: "approved" } })}
>
<Check size={13} /> تأیید
</button>
)}
{s.status !== "rejected" && (
<button
className={`${actBtn} bg-RED/10 text-RED hover:bg-RED/20`}
onClick={() => update.mutate({ id: s.id, data: { status: "rejected" } })}
>
<X size={13} /> رد
</button>
)}
<button
className={`${actBtn} bg-WHITE2 text-BLACK2 hover:bg-WHITE3`}
onClick={() => update.mutate({ id: s.id, data: { isVerified: !s.isVerified } })}
>
{s.isVerified ? "لغو احراز" : "احراز هویت"}
</button>
</div>
);
},
},
];
return (
<div>
<AdminPageTitle>فروشگاهها</AdminPageTitle>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی نام یا آیدی فروشگاه…"
/>
<AdminSelect
value={status}
onChange={(v) => reset(() => setStatus(v))}
options={[
{ value: "", label: "همه وضعیت‌ها" },
{ value: "pending", label: "در انتظار" },
{ value: "approved", label: "تأییدشده" },
{ value: "rejected", label: "ردشده" },
]}
/>
<AdminSelect
value={verified}
onChange={(v) => reset(() => setVerified(v))}
options={[
{ value: "", label: "احراز: همه" },
{ value: "true", label: "احرازشده" },
{ value: "false", label: "احرازنشده" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(s) => s.id}
/>
<AdminPagination
page={page}
pageSize={PAGE_SIZE}
count={data?.count || 0}
onPageChange={setPage}
/>
</div>
);
}

View File

@ -1,160 +0,0 @@
import { Link, Outlet, useLocation } from "@remix-run/react";
import {
LayoutDashboard,
ClipboardList,
Store,
Users,
Package,
Tags,
Ticket,
LayoutGrid,
Landmark,
LayoutTemplate,
Bell,
Activity,
MessageSquareWarning,
ScrollText,
ArrowRight,
ShieldCheck,
Loader2,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useRequireAdmin } from "~/hooks/useRequireAdmin";
interface NavItem {
label: string;
to: string;
icon: LucideIcon;
exact?: boolean;
soon?: boolean; // planned but not built yet
}
const NAV: NavItem[] = [
{ label: "نمای کلی", to: "/admin", icon: LayoutDashboard, exact: true },
{ label: "سفارش‌ها", to: "/admin/orders", icon: ClipboardList },
{ label: "فروشگاه‌ها", to: "/admin/sellers", icon: Store },
{ label: "کاربران", to: "/admin/users", icon: Users },
{ label: "محصولات", to: "/admin/products", icon: Package },
{ label: "ویژگی‌ها", to: "/admin/catalog", icon: Tags },
{ label: "تخفیف‌ها", to: "/admin/discounts", icon: Ticket },
{ label: "مجموعه‌ها", to: "/admin/collections", icon: LayoutGrid },
{ label: "دفتر مالی", to: "/admin/ledger", icon: Landmark },
{ label: "صفحه اصلی", to: "/admin/homepage", icon: LayoutTemplate },
{ label: "اعلان‌ها", to: "/admin/notifications", icon: Bell },
{ label: "سلامت AI", to: "/admin/ai-health", icon: Activity },
{ label: "مدیریت محتوا", to: "/admin/moderation", icon: MessageSquareWarning },
{ label: "گزارش فعالیت", to: "/admin/audit", icon: ScrollText },
];
export default function AdminLayout() {
const location = useLocation();
const { isLoading, isAdmin } = useRequireAdmin();
const isActive = (item: NavItem) =>
item.exact
? location.pathname === item.to
: location.pathname.startsWith(item.to);
if (isLoading || !isAdmin) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="h-7 w-7 animate-spin text-GRAY" />
</div>
);
}
const NavLinks = ({ onNavigate }: { onNavigate?: () => void }) => (
<>
{NAV.map((n) => {
const active = isActive(n);
const cls = `flex items-center gap-3 px-3.5 py-3 rounded-xl text-[14.5px] font-semibold transition-colors ${
active ? "bg-BLACK text-white" : "text-BLACK2 hover:bg-WHITE2"
} ${n.soon ? "opacity-45 cursor-not-allowed" : ""}`;
const inner = (
<>
<n.icon size={20} />
{n.label}
{n.soon && (
<span className="ms-auto text-[10px] text-GRAY font-normal">
بهزودی
</span>
)}
</>
);
return n.soon ? (
<div key={n.to} className={cls} aria-disabled>
{inner}
</div>
) : (
<Link
key={n.to}
to={n.to}
prefetch="intent"
onClick={onNavigate}
className={cls}
>
{inner}
</Link>
);
})}
<Link
to="/"
className="flex items-center gap-3 px-3.5 py-3 rounded-xl text-[14.5px] font-semibold text-BLACK2 hover:bg-WHITE2 transition-colors"
>
<ArrowRight size={20} />
بازگشت به سایت
</Link>
</>
);
return (
<div className="lg:max-w-[1440px] lg:mx-auto lg:w-full lg:px-8 lg:py-8 lg:grid lg:grid-cols-[248px_1fr] lg:gap-7 lg:items-start">
{/* Desktop sidebar */}
<aside className="hidden lg:block lg:sticky lg:top-6 border border-WHITE3 rounded-2xl overflow-hidden">
<div className="p-4 border-b border-WHITE3 flex items-center gap-3">
<div className="h-10 w-10 rounded-xl bg-BLACK text-white grid place-items-center shrink-0">
<ShieldCheck size={20} />
</div>
<div className="min-w-0">
<p className="font-bold text-[15px] truncate">پنل مدیریت</p>
<p className="text-GRAY text-[12px]">ویترون</p>
</div>
</div>
<nav className="p-2.5 flex flex-col gap-0.5">
<NavLinks />
</nav>
</aside>
{/* Mobile top bar + horizontal nav */}
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)] border-b border-WHITE3">
<div className="flex items-center gap-2 px-4 h-[56px]">
<div className="h-8 w-8 rounded-lg bg-BLACK text-white grid place-items-center">
<ShieldCheck size={16} />
</div>
<p className="font-bold text-[15px]">پنل مدیریت</p>
</div>
<div className="flex gap-2 overflow-x-auto px-4 pb-2 hide-scrollbar">
{NAV.map((n) => {
const active = isActive(n);
const cls = `whitespace-nowrap px-3 py-1.5 rounded-full text-[13px] font-semibold ${
active ? "bg-BLACK text-white" : "bg-WHITE2 text-BLACK2"
} ${n.soon ? "opacity-45" : ""}`;
return n.soon ? (
<span key={n.to} className={cls}>
{n.label}
</span>
) : (
<Link key={n.to} to={n.to} prefetch="intent" className={cls}>
{n.label}
</Link>
);
})}
</div>
</div>
<div className="min-w-0 px-4 py-4 lg:p-0">
<Outlet />
</div>
</div>
);
}

View File

@ -1,167 +0,0 @@
import type { MetaFunction } from "@remix-run/node";
import { useState } from "react";
import { Loader2, Ban, RotateCcw } from "lucide-react";
import {
useAdminUsers,
useAdminUserUpdate,
} from "~/requestHandler/use-admin-hooks";
import type { AdminUserRow } from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
export const meta: MetaFunction = () => [
{ title: "کاربران | پنل مدیریت" },
{ name: "robots", content: "noindex, nofollow" },
];
const PAGE_SIZE = 25;
export default function AdminUsers() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [active, setActive] = useState("");
const [role, setRole] = useState("");
const debouncedSearch = useDebouncedValue(search);
const update = useAdminUserUpdate();
const { data, isLoading } = useAdminUsers({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
is_active: active,
is_seller: role === "seller" ? "true" : undefined,
is_staff: role === "staff" ? "true" : undefined,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const pendingId = update.isPending ? update.variables?.id : null;
const actBtn =
"inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[12px] font-semibold transition-colors disabled:opacity-40 cursor-pointer";
const columns: Column<AdminUserRow>[] = [
{
header: "تلفن",
render: (u) => (
<span className="tabular-nums font-semibold" dir="ltr">
{u.phoneNumber}
</span>
),
},
{
header: "نام کاربری",
render: (u) => <span>{u.username || "—"}</span>,
},
{
header: "نقش",
render: (u) => (
<div className="flex items-center gap-1 flex-wrap">
{u.isSuperuser && <StatusBadge label="سوپرادمین" tone="red" />}
{u.isStaff && !u.isSuperuser && <StatusBadge label="ادمین" tone="blue" />}
{u.isSeller && <StatusBadge label="فروشنده" tone="gray" />}
{!u.isSeller && !u.isStaff && !u.isSuperuser && (
<span className="text-GRAY text-[12px]">کاربر</span>
)}
</div>
),
},
{
header: "وضعیت",
render: (u) =>
u.isActive ? (
<StatusBadge label="فعال" tone="green" />
) : (
<StatusBadge label="مسدود" tone="red" />
),
},
{
header: "عضویت",
render: (u) => <span className="text-GRAY whitespace-nowrap">{faDate(u.createdAt)}</span>,
},
{
header: "عملیات",
className: "text-left",
render: (u) => {
const busy = pendingId === u.id;
if (busy)
return <Loader2 size={16} className="animate-spin text-GRAY inline" />;
return (
<div className="flex justify-end">
{u.isActive ? (
<button
className={`${actBtn} bg-RED/10 text-RED hover:bg-RED/20`}
onClick={() => update.mutate({ id: u.id, data: { isActive: false } })}
>
<Ban size={13} /> مسدود
</button>
) : (
<button
className={`${actBtn} bg-GREEN/10 text-GREEN hover:bg-GREEN/20`}
onClick={() => update.mutate({ id: u.id, data: { isActive: true } })}
>
<RotateCcw size={13} /> فعالسازی
</button>
)}
</div>
);
},
},
];
return (
<div>
<AdminPageTitle>کاربران</AdminPageTitle>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی تلفن، نام کاربری یا ایمیل…"
/>
<AdminSelect
value={active}
onChange={(v) => reset(() => setActive(v))}
options={[
{ value: "", label: "همه" },
{ value: "true", label: "فعال" },
{ value: "false", label: "مسدود" },
]}
/>
<AdminSelect
value={role}
onChange={(v) => reset(() => setRole(v))}
options={[
{ value: "", label: "همه نقش‌ها" },
{ value: "seller", label: "فروشنده" },
{ value: "staff", label: "ادمین" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(u) => u.id}
/>
<AdminPagination
page={page}
pageSize={PAGE_SIZE}
count={data?.count || 0}
onPageChange={setPage}
/>
</div>
);
}

View File

@ -262,8 +262,7 @@ export default function CartDetail() {
isError={isError}
onRetry={refetch}
>
<div className="lg:grid lg:grid-cols-[1fr_360px] lg:gap-7 lg:items-start lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8 lg:py-6">
<div className="flex flex-col w-full">
<>
{step === 0 && (
<div className="flex flex-col w-full">
{cart?.items?.map((item, index) => (
@ -304,9 +303,7 @@ export default function CartDetail() {
)}
/>
)}
</div>
<div className="lg:sticky lg:top-[88px]">
<CartSummary
total={calculateTotal(
cart,
@ -321,8 +318,7 @@ export default function CartDetail() {
shippingInfoIsLoading
}
/>
</div>
</div>
</>
</UiProvider>
</div>
</div>

View File

@ -59,16 +59,11 @@ export default function CartIndex() {
};
return (
<div className="bg-background">
{/* Header (mobile) */}
<div className="lg:hidden">
{/* Header */}
<ProfilePagesHeader hideBackButton={true} title="لیست سبد خرید" />
</div>
{/* Cart Content */}
<div className="flex flex-col gap-4 p-4 lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8 lg:py-8">
<h1 className="hidden lg:block text-[28px] font-extrabold mb-2">
سبد خرید
</h1>
<div className="flex flex-col gap-4 p-4">
<UiProvider
isLoading={isLoading}
isError={isError}
@ -78,7 +73,7 @@ export default function CartIndex() {
}}
type="cart"
>
<div className="space-y-4 w-full lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-5">
<div className="space-y-4 w-full">
{carts.map((cart) => {
return (
<div
@ -116,9 +111,6 @@ export default function CartIndex() {
<p className="text-B12 font-bold">
{cart.seller?.name}
</p>
<span className="ms-auto shrink-0 text-R10 font-semibold text-green-600">
ارسال جداگانه
</span>
</a>
<p className="text-B12 font-bold line-clamp-1">
{cart.items

View File

@ -61,15 +61,13 @@ export default function CollectionDetail() {
return (
<div className="flex flex-col min-h-screen bg-WHITE pb-20">
{/* Header */}
<div className="sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
<div className="flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">جزئیات کالکشن</p>
</div>
</div>
{/* Loading Skeleton */}
<div className="flex flex-col gap-4 p-4">
@ -96,15 +94,13 @@ export default function CollectionDetail() {
return (
<div className="flex flex-col min-h-screen bg-WHITE pb-20">
{/* Header */}
<div className="sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
<div className="flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">جزئیات کالکشن</p>
</div>
</div>
{/* Error State */}
<div className="flex flex-col items-center justify-center py-20 px-4">
@ -133,23 +129,21 @@ export default function CollectionDetail() {
return (
<div className="flex flex-col min-h-screen bg-WHITE pb-20">
{/* Header (mobile) */}
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
{/* Header */}
<div className="flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">جزئیات کالکشن</p>
</div>
</div>
{/* Content */}
<div className="flex flex-col lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8 lg:pt-6">
<div className="flex flex-col">
{/* Collection Header */}
<div className="flex flex-col gap-4 p-4 border-b border-inner-border lg:grid lg:grid-cols-[360px_1fr] lg:gap-8 lg:items-start lg:p-0 lg:py-6 lg:border-0">
<div className="flex flex-col gap-4 p-4 border-b border-inner-border">
{/* Cover Image */}
<div className="relative overflow-hidden rounded-xl w-full mx-auto h-[65vh] bg-gray-100 lg:h-auto lg:aspect-[3/4]">
<div className="relative overflow-hidden rounded-xl w-full mx-auto h-[65vh] bg-gray-100">
{collection.coverImageUrl ? (
<img
src={collection.coverImageUrl}
@ -165,7 +159,7 @@ export default function CollectionDetail() {
{/* Collection Info */}
<div className="flex flex-col gap-3">
<h1 className="text-B20 lg:text-[28px] font-bold text-black">
<h1 className="text-B20 font-bold text-black">
{collection.title || "بدون عنوان"}
</h1>

View File

@ -41,7 +41,7 @@ export default function Collections() {
// Loading skeleton
const CollectionSkeleton = () => (
<div className="flex flex-col gap-4 px-4 lg:grid lg:grid-cols-4 lg:gap-5 lg:px-0">
<div className="flex flex-col gap-4 px-4">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
@ -117,22 +117,17 @@ export default function Collections() {
return (
<div className="flex flex-col bg-WHITE pb-20">
{/* Header (mobile) */}
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
{/* Header - مطابق با ProfilePagesHeader */}
<div className="flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">کالکشنها</p>
</div>
</div>
{/* Content */}
<div className="flex flex-col py-6 lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8">
<h1 className="hidden lg:block text-[28px] font-extrabold mb-6">
کالکشنها
</h1>
<div className="flex flex-col py-6">
{status === "pending" ? (
<CollectionSkeleton />
) : status === "error" ? (
@ -141,7 +136,7 @@ export default function Collections() {
<EmptyState />
) : (
<>
<div className="flex flex-col gap-4 px-4 lg:grid lg:grid-cols-4 lg:gap-5 lg:px-0">
<div className="flex flex-col gap-4 px-4">
{allCollections.map((collection) => (
<Link
key={collection.id}
@ -150,7 +145,7 @@ export default function Collections() {
>
<div className="flex flex-col gap-3 p-4 bg-WHITE2 rounded-2xl border border-inner-border hover:border-gray-300 transition-all duration-300 hover:shadow-lg">
{/* Collection Image - بزرگ */}
<div className="relative overflow-hidden rounded-xl w-full h-48 lg:h-auto lg:aspect-[3/4] bg-gray-100">
<div className="relative overflow-hidden rounded-xl w-full h-48 bg-gray-100">
{collection.coverImageUrl ? (
<img
src={collection.coverImageUrl}

View File

@ -1,104 +0,0 @@
import { MetaFunction } from "@remix-run/node";
import { useNavigate } from "@remix-run/react";
import { ArrowRight, Tag } from "lucide-react";
import { safeGoBack } from "~/utils/helpers";
import { useGetHomeDiscounts } from "~/requestHandler/use-home-hooks";
import MondrianProductList from "~/components/MondrianProductList";
export default function Discounts() {
const navigate = useNavigate();
const {
data,
isLoading,
isError,
refetch,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useGetHomeDiscounts();
const products =
data?.pages.flatMap((page) => (page?.results as unknown[]) || []) || [];
const handleBack = () => safeGoBack(navigate);
return (
<div className="flex flex-col bg-WHITE pb-20">
{/* Header (mobile) */}
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">تخفیفها</p>
</div>
</div>
{/* Content */}
<div className="flex flex-col py-6 lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8">
<div className="hidden lg:block mb-6 px-4 lg:px-0">
<h1 className="text-[28px] font-extrabold">تخفیفها</h1>
<p className="text-R16 text-GRAY mt-1">
فرصتهایی محدود برای انتخابهای هوشمندانه
</p>
</div>
<MondrianProductList
// eslint-disable-next-line @typescript-eslint/no-explicit-any
products={(products as any[]).map((product) => ({
id: product?.id || "",
title: product?.title || "",
images: Array.isArray(product?.imageUrls) ? product.imageUrls : [],
basePrice: product?.basePrice || 0,
discountPercent: product?.discountPercent || 0,
discountPrice: product?.discountPrice || 0,
sellerName: product?.sellerName,
sellerLogo: product?.sellerLogo,
sellerUsername: product?.sellerUsername,
}))}
fetchNextPage={fetchNextPage}
hasNextPage={!!hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isLoading={isLoading}
isError={isError}
refetch={refetch}
emptyState={{
image: <Tag size={80} />,
title: "در حال حاضر تخفیفی موجود نیست",
description: "به زودی پیشنهادهای ویژه‌ای اضافه خواهد شد",
}}
/>
</div>
</div>
);
}
export const meta: MetaFunction = () => {
const title = "تخفیف‌ها | پیشنهادهای ویژه ویترون";
const description =
"جدیدترین محصولات تخفیف‌دار ویترون. خرید با بهترین قیمت از فروشگاه‌های معتبر.";
const imageUrl =
"https://vitrownstatics.s3.ir-thr-at1.arvanstorage.ir/SVG-06.svg?versionId=";
return [
{ title },
{ name: "description", content: description },
{
name: "keywords",
content: "تخفیف، حراج، فروش ویژه، خرید ارزان، ویترون",
},
{ name: "robots", content: "index, follow" },
{ 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" },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: description },
];
};

View File

@ -9,13 +9,11 @@ export default function Explore() {
const [searchValue, setSearchValue] = useState("");
return (
<div className="flex flex-col">
<div className="lg:hidden">
<ExploreTopBar
setIsActiveSearchBar={setIsActiveSearchBar}
searchValue={searchValue}
setSearchValue={setSearchValue}
/>
</div>
{isActiveSearchBar ? (
<SearchSection searchValue={searchValue} />
) : (

View File

@ -21,7 +21,6 @@ import AppInput from "~/components/AppInput";
import { useToast } from "~/hooks/use-toast";
import { useSubmit, useLoaderData } from "@remix-run/react";
import { User } from "~/utils/token-manager.server";
import logo from "~/assets/logo/SVG-07.svg";
// Only allow same-site, path-relative redirects. Rejects absolute URLs
// (https://evil.com) and protocol-relative URLs (//evil.com) to prevent
@ -107,13 +106,7 @@ export default function LoginPage() {
};
return (
<div className="lg:min-h-screen lg:flex lg:items-center lg:justify-center lg:bg-WHITE2 lg:py-12">
<div className="flex flex-col gap-6 px-[20px] py-12 items-center w-full lg:max-w-[460px] lg:bg-WHITE lg:rounded-2xl lg:border lg:border-WHITE3 lg:shadow-lg lg:px-8 lg:py-10 lg:my-12">
{/* Brand (desktop) */}
<div className="hidden lg:flex items-center gap-2.5 mb-1">
<img src={logo} alt="ویترون" className="w-7 h-9 object-contain" />
<b className="text-[22px] font-extrabold text-VITROWN_BLUE">ویترون</b>
</div>
<div className="flex flex-col gap-6 px-[20px] py-12 items-center w-full">
{enableVerifySection ? (
<>
<div className="flex flex-col gap-4 w-full items-center">
@ -134,7 +127,7 @@ export default function LoginPage() {
</div>
<div className="w-full mt-2 flex justify-center">
<InputOTP
maxLength={6}
maxLength={4}
onComplete={(finalValue) => {
const convertedValue = convertPersianToEnglish(finalValue);
setOtpValue(convertedValue);
@ -145,19 +138,17 @@ export default function LoginPage() {
setOtpValue(convertedValue);
}}
>
<InputOTPGroup dir="ltr" className="gap-1.5 sm:gap-3">
<InputOTPSlot index={0} className="!w-9 sm:!w-12" />
<InputOTPSlot index={1} className="!w-9 sm:!w-12" />
<InputOTPSlot index={2} className="!w-9 sm:!w-12" />
<InputOTPSlot index={3} className="!w-9 sm:!w-12" />
<InputOTPSlot index={4} className="!w-9 sm:!w-12" />
<InputOTPSlot index={5} className="!w-9 sm:!w-12" />
<InputOTPGroup dir="ltr" className="gap-4">
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
</InputOTPGroup>
</InputOTP>
</div>
<div className="gap-4 w-full flex flex-col items-center">
<Button
disabled={verifyOtpMutation.isPending || !(otpValue.length === 6)}
disabled={verifyOtpMutation.isPending || !(otpValue.length === 4)}
className="w-[90%]"
size={"xl"}
variant="dark"
@ -245,7 +236,6 @@ export default function LoginPage() {
</>
)}
</div>
</div>
);
}

View File

@ -1,5 +1,4 @@
import { MetaFunction, redirect, type LoaderFunctionArgs } from "@remix-run/node";
import { sessionStorage } from "~/sessions.server";
import { MetaFunction } from "@remix-run/node";
import {
BookmarkMinus,
ChevronLeft,
@ -15,13 +14,9 @@ import {
CircleUserRound,
FileQuestion,
Layers,
Pencil,
} from "lucide-react";
import { Link, useNavigate } from "@remix-run/react";
import { useServiceGetProfile } from "~/utils/RequestHandler";
import { useMyStores } from "~/requestHandler/use-seller-hooks";
import { useServiceGetWallet } from "~/requestHandler/use-wallet-hooks";
import { useBadgeCounts } from "~/hooks/useBadgeCounts";
import { Skeleton } from "~/components/ui/skeleton";
import { useState } from "react";
import { Button } from "~/components/ui/button";
@ -37,76 +32,19 @@ interface MenuItemType {
pageName: string;
hasBorder?: boolean;
image: React.ReactNode;
badge?: number | null;
}
// Guard: the account hub requires login (sub-pages are already protected via
// validateTokens; this covers the /profile index itself).
export async function loader({ request }: LoaderFunctionArgs) {
const session = await sessionStorage.getSession(
request.headers.get("cookie")
);
const user = session.get("user");
if (!user?.access_token) {
throw redirect("/login");
}
return null;
}
export default function Profile() {
const { user, theme: initialTheme } = useRootData();
const [storeModalOpen, setStoreModalOpen] = useState(false);
const [loginModalOpen, setLoginModalOpen] = useState(false);
const [logoutModalOpen, setLogoutModalOpen] = useState(false);
const { data: profileData } = useServiceGetProfile();
const { data: myStores } = useMyStores();
const { data: walletData } = useServiceGetWallet();
const { data: badgeCounts } = useBadgeCounts();
const { data: followedSellers, isLoading: isFollowedSellersLoading } =
useServiceGetFollowedSellers();
// Calculate the count of followed sellers
const followedCount = followedSellers?.length || 0;
const walletBalance = walletData?.balance
? new Intl.NumberFormat("fa-IR").format(parseInt(walletData.balance))
: "۰";
// Top quick-actions (also live in the menu, but surfaced as a grid)
const quickActions = [
{
label: "سفارش‌ها",
link: "/profile/orders",
icon: <ShoppingBag size={22} />,
badge: (badgeCounts?.orderUpdates || null) as number | null,
},
{
label: "نشان‌ها",
link: "/profile/bookmarks",
icon: <BookmarkMinus size={22} />,
badge: null,
},
{
label: "دنبال‌ها",
link: "/profile/following",
icon: <CircleFadingPlus size={22} />,
badge: followedCount || null,
},
{
label: "آدرس‌ها",
link: "/profile/location",
icon: <MapPin size={22} />,
badge: null,
},
];
// The menu list below drops the items surfaced as quick actions / wallet card.
const quickPages = new Set([
"orders",
"bookmarks",
"following",
"location",
"wallet",
]);
// Create dynamic items with the followed count
const menuItems: MenuItemType[] = [
{
@ -129,7 +67,6 @@ export default function Profile() {
link: "/profile/threads",
pageName: "messages",
image: <MessageCircle size={20} />,
badge: badgeCounts?.chat || null,
},
{
title: "کیف پول",
@ -189,107 +126,34 @@ export default function Profile() {
const navigate = useNavigate();
return (
<div className="flex flex-col gap-0">
{/* Desktop: welcome (the sidebar provides the menu) */}
<div className="hidden lg:flex flex-col items-center justify-center text-center py-24 border border-WHITE3 rounded-2xl">
<CircleUserRound size={64} className="text-GRAY mb-4" />
<h2 className="text-[22px] font-extrabold mb-1">حساب کاربری</h2>
<p className="text-GRAY">یک گزینه را از منوی کناری انتخاب کنید</p>
</div>
{/* Mobile: account hub */}
<div className="flex flex-col gap-0 lg:hidden">
{/* Header: avatar + name + phone + edit */}
<div className="flex items-center gap-3.5 px-4 pt-[calc(1rem_+_env(safe-area-inset-top))] pb-3">
<CircleUserRound size={56} className="text-GRAY shrink-0" />
<div className="min-w-0 flex-1">
<h2 className="text-B16 font-bold truncate">
{profileData?.username || "کاربر ویترون"}
</h2>
{profileData?.phoneNumber ? (
<p className="text-GRAY text-R12 mt-0.5" dir="ltr">
{profileData.phoneNumber}
</p>
) : null}
</div>
<Link
to="/profile/setting"
aria-label="ویرایش"
className="w-10 h-10 rounded-xl border border-WHITE3 grid place-items-center text-BLACK shrink-0"
<ProfileTopBar user={user} />
<div
className={`flex flex-row h-[55px] w-full items-center gap-2 justify-between px-[16px] active:bg-hover transition`}
>
<Pencil size={18} />
</Link>
</div>
{/* Wallet card */}
<Link
to="/profile/wallet"
className="mx-4 mt-1 rounded-[18px] p-5 bg-gradient-to-br from-[#14213d] to-VITROWN_BLUE text-white flex items-center"
>
<div className="flex-1 min-w-0">
<small className="opacity-80 text-R12">موجودی کیف پول</small>
<b className="block text-[22px] font-extrabold mt-1">
{walletBalance}
<span className="text-[13px] font-semibold"> تومان</span>
</b>
</div>
<span className="bg-white text-BLACK rounded-xl px-4 py-2.5 text-R12 font-bold shrink-0">
افزایش موجودی
</span>
</Link>
{/* Quick actions */}
<div className="grid grid-cols-4 gap-2.5 px-4 pt-5 pb-1">
{quickActions.map((qa) => (
<Link
key={qa.link}
to={qa.link}
className="flex flex-col items-center gap-1.5"
>
<div className="relative w-[52px] h-[52px] rounded-2xl bg-WHITE2 grid place-items-center text-BLACK">
{qa.badge ? (
<span className="absolute -top-1 -left-1 min-w-[18px] h-[18px] px-1 rounded-full bg-RED text-white text-[10px] font-bold grid place-items-center border-2 border-WHITE">
{qa.badge}
</span>
) : null}
{qa.icon}
</div>
<span className="text-R12 text-BLACK2">{qa.label}</span>
</Link>
))}
</div>
<p className="text-R12 font-bold text-GRAY px-4 mt-4 mb-1">حساب من</p>
{/* Theme toggle */}
<div className="flex flex-row h-[55px] w-full items-center gap-2 justify-between px-[16px]">
<p className="text-R12">
<div className="flex flex-row gap-2 items-center">
<p className={"text-R12"}>
{initialTheme === "dark" ? "حالت تاریک" : "حالت روشن"}
</p>
</div>
<SunMoonToggle theme={initialTheme} />
</div>
<MenuItems
items={menuItems.filter((m) => !quickPages.has(m.pageName))}
items={menuItems}
onStoreClick={() => {
// Enter a store the user can manage: their own if they own one,
// otherwise the first store they're a staff member of.
const owned = myStores?.find((s) => s.role === "owner");
const target = owned || myStores?.[0];
if (profileData?.sellerUsername) {
navigate(`/store/${profileData.sellerUsername}`);
} else if (target) {
navigate(`/store/${target.username}`);
} else if (user?.access_token) {
} else {
if (user?.access_token) {
setStoreModalOpen(true);
} else {
setLoginModalOpen(true);
}
}
}}
onLogoutClick={() => {
setLogoutModalOpen(true);
}}
/>
</div>
<StoreModal open={storeModalOpen} onOpenChange={setStoreModalOpen} />
<LogoutModal open={logoutModalOpen} onOpenChange={setLogoutModalOpen} />
<LoginRequiredDialog
@ -317,7 +181,7 @@ const MenuItems = ({
};
return (
<div className="flex flex-col gap-0 w-full lg:grid lg:grid-cols-2 lg:gap-3 lg:mt-4">
<div className="flex flex-col gap-0 w-full">
{items.map((item: MenuItemType, index: number) => {
// Special handling for "support" item
if (item.pageName === "support") {
@ -375,21 +239,14 @@ const MenuItem = ({
const MenuItemContent = ({ item }: { item: MenuItemType }) => {
return (
<div
className={`flex flex-row gap-2 items-center justify-between px-[16px] active:bg-hover transition cursor-pointer ${item.hasBorder && "border-t-[2px]"} h-[55px] w-full lg:h-auto lg:py-4 lg:px-5 lg:border lg:border-WHITE3 lg:rounded-xl lg:border-t lg:hover:bg-WHITE2`}
className={`flex flex-row gap-2 items-center justify-between px-[16px] active:bg-hover transition cursor-pointer ${item.hasBorder && "border-t-[2px]"} h-[55px] w-full`}
>
<div className="flex flex-row gap-2 items-center">
{item.image}
<p className={"text-R12"}>{item.title}</p>
</div>
<div className="flex items-center gap-2">
{item.badge ? (
<span className="min-w-[18px] h-[18px] px-1 rounded-full bg-RED text-white text-[10px] font-bold grid place-items-center">
{item.badge > 99 ? "99+" : item.badge}
</span>
) : null}
<ChevronLeft className="size-4" />
</div>
</div>
);
};

View File

@ -47,14 +47,12 @@ export default function AboutPage() {
return (
<div className="flex flex-col pb-20">
{/* Header (mobile) */}
<div className="lg:hidden">
{/* Header */}
<HeaderWithSupport
hideSupportButton={true}
title="درباره ویترون"
onBack={() => safeGoBack(navigate)}
/>
</div>
{/* Content */}
<div className="flex flex-col gap-8 p-4 max-w-2xl mx-auto w-full">
{/* Logo Section */}

View File

@ -18,11 +18,8 @@ export default function Bookmarks() {
const bookmarks = data?.pages.flatMap((page) => page.results) || [];
return (
<div className="flex flex-col gap-6 lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8 lg:py-6">
<div className="flex flex-col gap-6">
<ProfilePagesHeader title="ذخیره شده‌ها" />
<h1 className="hidden lg:block text-[28px] font-extrabold">
ذخیره شدهها
</h1>
{/* Content */}
<MondrianProductList

View File

@ -135,13 +135,11 @@ export default function FAQ() {
return (
<div>
<div className="lg:hidden">
<HeaderWithSupport
hideSupportButton={true}
title="سوالات متداول"
onBack={() => safeGoBack(navigate)}
/>
</div>
<div className="max-w-4xl mx-auto px-4 py-12">
<div className="text-center mb-12">
<h1 className="text-3xl font-bold mb-4">سوالات متداول</h1>

View File

@ -15,11 +15,8 @@ export default function Following() {
} = useServiceGetFollowedSellers();
return (
<div className="flex flex-col gap-0 pb-20 lg:max-w-[720px] lg:mx-auto lg:w-full lg:px-6 lg:py-6">
<div className="flex flex-col gap-0 pb-20">
<ProfilePagesHeader title="فروشگاه‌های دنبال شده" />
<h1 className="hidden lg:block text-[28px] font-extrabold mb-4">
فروشگاههای دنبال شده
</h1>
<UiProvider
isLoading={isLoading}
isEmpty={!followedSellers || followedSellers.length === 0}

View File

@ -47,16 +47,10 @@ export const meta: MetaFunction = () => {
export default function Locaton() {
return (
<div className="flex flex-col gap-4 w-full items-center lg:max-w-[820px] lg:mx-auto lg:pt-6">
<div className="flex flex-col gap-4 w-full items-center">
<ProfilePagesHeader title="آدرس‌های من" />
<h1 className="hidden lg:block w-full text-[28px] font-extrabold px-4">
آدرسهای من
</h1>
<MainContent />
<Link
to={"/profile/location/add"}
className="fixed bottom-16 z-10 lg:static lg:bottom-auto lg:z-auto lg:mt-4"
>
<Link to={"/profile/location/add"} className="fixed bottom-16 z-10">
<Button size={"xl"} variant="dark">
<Plus className="text-WHITE size-6" />
افزودن آدرس جدید

Some files were not shown because too many files have changed in this diff Show More