Compare commits
84 Commits
security-f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ec5e4f6c78 | |||
| ef7804df0e | |||
| b4faa9d180 | |||
| 97948767a0 | |||
| b3189db773 | |||
| c1118f70d6 | |||
| 7ad3fccd6e | |||
| 40052b7002 | |||
| 24d4c8a302 | |||
| cedeef4746 | |||
| 935c337df0 | |||
| f8bf002b39 | |||
| 0360780411 | |||
| 31bc9f3c7b | |||
| c4e0f2f89f | |||
| 225161249e | |||
| 56fcce35c8 | |||
| 83b93fe523 | |||
| 6a300b8760 | |||
| 0b18438a97 | |||
| 7a385ba18e | |||
| 2cf1224107 | |||
| 7867db425e | |||
| d694040bf7 | |||
| d4316381d5 | |||
| 05cbbd8d5e | |||
| 72e4baab10 | |||
| 5c8e19001c | |||
| 16389fdd85 | |||
| a67e81ad55 | |||
| 0a482c70fa | |||
| b371c50d23 | |||
| 8b51c3252a | |||
| fea80647fa | |||
| 877b01f483 | |||
| 0d0da636fd | |||
| ba8e839959 | |||
| bb44ebf0e5 | |||
| a642fc0fae | |||
| 3502168a8b | |||
| e60a03ac65 | |||
| 660e01c3c3 | |||
| bb10907463 | |||
| 0b874dbd57 | |||
| 0f617b45c9 | |||
| 68786787b4 | |||
| 3c7ee5b24c | |||
| 668649d540 | |||
| 254fed4a99 | |||
| e6574b1e9e | |||
| 252d3f8f50 | |||
| 11656c2996 | |||
| 692d385d09 | |||
| b45e247dda | |||
| eefad5387c | |||
| f8289898bc | |||
| bac93568f5 | |||
| 641abf711c | |||
| 940cc3e859 | |||
| 272362cfdf | |||
| a405f6ffc4 | |||
| 501cf28b9a | |||
| b4d5f8d8db | |||
| 66b14cfa74 | |||
| fef53fd003 | |||
| 257871d5f1 | |||
| 4af143bccf | |||
| 207d46eefd | |||
| 24449fe5e9 | |||
| 030a883327 | |||
| 4a7bf51572 | |||
| 3156fb4204 | |||
| 96034fe61a | |||
| 7845ab5e75 | |||
| 5c64bbd66a | |||
| fa65db8804 | |||
| 60d6a2a6e4 | |||
| f57fe4e9d6 | |||
| cf55e16a9a | |||
| d0571f8253 | |||
| 232f7d1b3c | |||
| 9650073b04 | |||
| e1c5899002 | |||
| d75c6065e7 |
@ -21,3 +21,6 @@ VITE_BUILD_TARGET=development
|
|||||||
|
|
||||||
# Support Configuration
|
# Support Configuration
|
||||||
VITE_SUPPORT_PHONE=+989223740993
|
VITE_SUPPORT_PHONE=+989223740993
|
||||||
|
|
||||||
|
# Map.ir key for the address map (MapLibre vector tiles + reverse geocoding)
|
||||||
|
VITE_MAPIR_KEY=your_mapir_api_key
|
||||||
|
|||||||
@ -39,23 +39,27 @@ const HeaderWithSupport = memo(function HeaderWithSupport({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<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">
|
{/* Mobile only — on desktop the dashboard sidebar / desktop header stands
|
||||||
<ArrowRight
|
in for this back+support bar. */}
|
||||||
onClick={onBack}
|
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
|
||||||
className="absolute top-5 right-5 cursor-pointer"
|
<div className="relative flex flex-row h-[65px] bg-WHITE w-full items-center justify-center border-b border-inner-border">
|
||||||
/>
|
<ArrowRight
|
||||||
<p className="text-B16 font-bold ">{title}</p>
|
onClick={onBack}
|
||||||
{!hideSupportButton && (
|
className="absolute top-5 right-5 cursor-pointer"
|
||||||
<div
|
/>
|
||||||
className="absolute top-4 h-8 w-8 left-5 rounded-full bg-WHITE shadow-xl flex items-center justify-center cursor-pointer"
|
<p className="text-B16 font-bold ">{title}</p>
|
||||||
onClick={handleSupportClick}
|
{!hideSupportButton && (
|
||||||
role="button"
|
<div
|
||||||
tabIndex={0}
|
className="absolute top-4 h-8 w-8 left-5 rounded-full bg-WHITE shadow-xl flex items-center justify-center cursor-pointer"
|
||||||
onKeyDown={handleKeyDown}
|
onClick={handleSupportClick}
|
||||||
>
|
role="button"
|
||||||
<Headset size={20} />
|
tabIndex={0}
|
||||||
</div>
|
onKeyDown={handleKeyDown}
|
||||||
)}
|
>
|
||||||
|
<Headset size={20} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CallDialog
|
<CallDialog
|
||||||
|
|||||||
@ -116,19 +116,22 @@ const HorizontalCollectionsList = ({
|
|||||||
{/* Scroll Container */}
|
{/* Scroll Container */}
|
||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
className="w-full overflow-x-auto scrollbar-hide snap-x snap-mandatory px-4"
|
className="w-full overflow-x-auto scrollbar-hide snap-x snap-mandatory px-4 lg:overflow-visible lg:px-0"
|
||||||
>
|
>
|
||||||
<div className="flex" style={{ width: "max-content" }}>
|
<div
|
||||||
|
className="flex lg:grid lg:grid-cols-4 lg:gap-4 lg:!w-full"
|
||||||
|
style={{ width: "max-content" }}
|
||||||
|
>
|
||||||
{collections.map((collection, index) => (
|
{collections.map((collection, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={collection.id || index}
|
key={collection.id || index}
|
||||||
to={`/collection/${collection.id}`}
|
to={`/collection/${collection.id}`}
|
||||||
className="flex flex-col pr-2 md:pr-4 snap-start group"
|
className="flex flex-col pr-2 md:pr-4 snap-start group lg:!w-auto lg:!max-w-none lg:pr-0"
|
||||||
style={{ width: "calc(85vw)", maxWidth: "300px" }}
|
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">
|
<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 */}
|
{/* Collection Image */}
|
||||||
<div className="relative overflow-hidden rounded-xl w-full h-64 bg-gray-100">
|
<div className="relative overflow-hidden rounded-xl w-full h-64 lg:h-auto lg:aspect-[3/4] bg-gray-100">
|
||||||
{collection.coverImageUrl ? (
|
{collection.coverImageUrl ? (
|
||||||
<img
|
<img
|
||||||
src={collection.coverImageUrl}
|
src={collection.coverImageUrl}
|
||||||
@ -174,7 +177,7 @@ const HorizontalCollectionsList = ({
|
|||||||
{showViewAllButton && (
|
{showViewAllButton && (
|
||||||
<Link
|
<Link
|
||||||
to="/collections"
|
to="/collections"
|
||||||
className="flex flex-col pr-2 md:pr-4 snap-start group"
|
className="flex flex-col pr-2 md:pr-4 snap-start group lg:!w-auto lg:!max-w-none lg:pr-0"
|
||||||
style={{ width: "calc(85vw)", maxWidth: "300px" }}
|
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]">
|
<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]">
|
||||||
@ -203,9 +206,9 @@ const HorizontalCollectionsList = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Navigation Buttons Row */}
|
{/* Navigation Buttons Row (mobile only) */}
|
||||||
{collections.length > 1 && (
|
{collections.length > 1 && (
|
||||||
<div className="flex items-center justify-center gap-3 px-4">
|
<div className="flex items-center justify-center gap-3 px-4 lg:hidden">
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={!showRightButton ? {} : { scale: 1.1 }}
|
whileHover={!showRightButton ? {} : { scale: 1.1 }}
|
||||||
whileTap={!showRightButton ? {} : { scale: 0.95 }}
|
whileTap={!showRightButton ? {} : { scale: 0.95 }}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { Skeleton } from "./ui/skeleton";
|
|||||||
import { ErrorState } from "./UiProvider";
|
import { ErrorState } from "./UiProvider";
|
||||||
import discountRed from "~/assets/icons/product/discount-red.svg";
|
import discountRed from "~/assets/icons/product/discount-red.svg";
|
||||||
import placeholderImage from "~/assets/icons/product.png";
|
import placeholderImage from "~/assets/icons/product.png";
|
||||||
|
import SellerChip from "./SellerChip";
|
||||||
|
|
||||||
interface HorizontalProduct {
|
interface HorizontalProduct {
|
||||||
id?: string;
|
id?: string;
|
||||||
@ -14,6 +15,9 @@ interface HorizontalProduct {
|
|||||||
discountPercent?: number;
|
discountPercent?: number;
|
||||||
discountPrice?: number;
|
discountPrice?: number;
|
||||||
basePrice?: number;
|
basePrice?: number;
|
||||||
|
sellerName?: string;
|
||||||
|
sellerLogo?: string | null;
|
||||||
|
sellerUsername?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HorizontalProductList = ({
|
const HorizontalProductList = ({
|
||||||
@ -138,6 +142,10 @@ const HorizontalProductList = ({
|
|||||||
)}
|
)}
|
||||||
{/* Overlay info */}
|
{/* 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">
|
<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">
|
<div className="flex w-full justify-between gap-1">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h3 className="text-white text-xs font-semibold line-clamp-1 mb-0.5">
|
<h3 className="text-white text-xs font-semibold line-clamp-1 mb-0.5">
|
||||||
|
|||||||
@ -1,15 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import {
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
MapContainer,
|
import type { Map as MaplibreMap, Marker as MaplibreMarker } from "maplibre-gl";
|
||||||
TileLayer,
|
import { Search, X, ChevronLeft, MapPin, Crosshair } from "lucide-react";
|
||||||
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 AppInput from "./AppInput";
|
||||||
import AppTextArea from "./AppTextArea";
|
import AppTextArea from "./AppTextArea";
|
||||||
import {
|
import {
|
||||||
@ -32,6 +24,25 @@ import {
|
|||||||
type IranianCity,
|
type IranianCity,
|
||||||
} from "../data/iranian-cities";
|
} 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
|
// Form data interface
|
||||||
interface FormData {
|
interface FormData {
|
||||||
title: string;
|
title: string;
|
||||||
@ -71,30 +82,6 @@ function convertPersianToEnglish(str: string): string {
|
|||||||
return result;
|
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({
|
export default function InteractiveMap({
|
||||||
onLocationSelected,
|
onLocationSelected,
|
||||||
initialAddress,
|
initialAddress,
|
||||||
@ -112,7 +99,7 @@ export default function InteractiveMap({
|
|||||||
isEditing ? "" : "تهران"
|
isEditing ? "" : "تهران"
|
||||||
);
|
);
|
||||||
const [currentCenter, setCurrentCenter] = useState<[number, number]>(
|
const [currentCenter, setCurrentCenter] = useState<[number, number]>(
|
||||||
iranianCities[0].position
|
(iranianCities.find((c) => c.name === "تهران") || iranianCities[0]).position
|
||||||
);
|
);
|
||||||
const [mounted, setMounted] = useState<boolean>(false);
|
const [mounted, setMounted] = useState<boolean>(false);
|
||||||
|
|
||||||
@ -166,34 +153,13 @@ export default function InteractiveMap({
|
|||||||
isDefault: formData.is_default,
|
isDefault: formData.is_default,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fix Leaflet's default icon path issues when the component mounts
|
// MapLibre refs
|
||||||
useEffect(() => {
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
// Fix Leaflet icon issues
|
const mapRef = useRef<MaplibreMap | null>(null);
|
||||||
delete (L.Icon.Default.prototype as { _getIconUrl?: string })._getIconUrl;
|
const markerRef = useRef<MaplibreMarker | null>(null);
|
||||||
L.Icon.Default.mergeOptions({
|
// select(lat,lng): drop/move the pin and reverse-geocode that point once.
|
||||||
iconUrl: "https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon.png",
|
const selectRef = useRef<((lat: number, lng: number) => void) | null>(null);
|
||||||
iconRetinaUrl:
|
const [isLocating, setIsLocating] = useState<boolean>(false);
|
||||||
"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
|
// Initialize with the existing address data when in editing mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -245,76 +211,123 @@ export default function InteractiveMap({
|
|||||||
setFilteredCities(cities);
|
setFilteredCities(cities);
|
||||||
}, [citySearchTerm, selectedProvince]);
|
}, [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 [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const handleMapClick = async (latlng: [number, number]) => {
|
// Reverse-geocode a point and push it into the form. Used by the map's
|
||||||
const address = await getAddressFromCoordinates(latlng);
|
// center pin (on move) — replaces the old click handler.
|
||||||
const postalCode = await getPostalCodeFromAddress(latlng);
|
const applyLocation = useCallback(
|
||||||
|
async (latlng: [number, number]) => {
|
||||||
|
const address = await reverseGeocode(latlng[0], latlng[1]);
|
||||||
|
setSelectedPosition(latlng);
|
||||||
|
setSelectedAddress(address);
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
main_address: address,
|
||||||
|
latitude: latlng[0],
|
||||||
|
longitude: latlng[1],
|
||||||
|
}));
|
||||||
|
if (onLocationSelected) {
|
||||||
|
onLocationSelected({ position: latlng, address });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onLocationSelected]
|
||||||
|
);
|
||||||
|
|
||||||
setSelectedPosition(latlng);
|
// Keep a stable ref so the map's event listeners always call the latest.
|
||||||
setSelectedAddress(address);
|
const applyLocationRef = useRef(applyLocation);
|
||||||
|
useEffect(() => {
|
||||||
|
applyLocationRef.current = applyLocation;
|
||||||
|
}, [applyLocation]);
|
||||||
|
|
||||||
// Don't update postal_code in edit mode, preserve the original value
|
// Initialize the MapLibre map (client-only, once) with the Map.ir style.
|
||||||
setFormData({
|
useEffect(() => {
|
||||||
...formData,
|
if (!mounted || formOnly || !mapContainerRef.current || mapRef.current) {
|
||||||
main_address: address,
|
return;
|
||||||
// Don't set postal_code here in either mode, preserve the existing value in edit mode
|
}
|
||||||
latitude: latlng[0],
|
let cancelled = false;
|
||||||
longitude: latlng[1],
|
(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"
|
||||||
|
);
|
||||||
|
|
||||||
// If using as a location picker in another component, call the callback
|
// Drop/move the pin at a point and reverse-geocode it once (saves
|
||||||
if (onLocationSelected) {
|
// tokens — we only geocode on an explicit tap, not on every move).
|
||||||
onLocationSelected({
|
const select = (lat: number, lng: number) => {
|
||||||
position: latlng,
|
if (markerRef.current) {
|
||||||
address,
|
markerRef.current.setLngLat([lng, lat]);
|
||||||
postalCode: isEditing ? formData.postal_code : postalCode,
|
} 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}, [currentCenter]);
|
||||||
|
|
||||||
const handleCitySelect = (cityName: string) => {
|
const handleCitySelect = (cityName: string) => {
|
||||||
setSelectedCity(cityName);
|
setSelectedCity(cityName);
|
||||||
@ -327,6 +340,35 @@ export default function InteractiveMap({
|
|||||||
setIsCityDrawerOpen(false);
|
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 = () => {
|
const handleOpenDrawer = () => {
|
||||||
setIsDrawerOpen(true);
|
setIsDrawerOpen(true);
|
||||||
};
|
};
|
||||||
@ -478,45 +520,6 @@ export default function InteractiveMap({
|
|||||||
}
|
}
|
||||||
}, [isEditing, initialAddress]);
|
}, [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 mode, render only the form
|
||||||
if (formOnly) {
|
if (formOnly) {
|
||||||
return (
|
return (
|
||||||
@ -832,42 +835,44 @@ export default function InteractiveMap({
|
|||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
{/* Map Container */}
|
{/* Map Container — MapLibre + Map.ir vector tiles. dir=ltr keeps the
|
||||||
<div className="dd z-0 h-[500px] w-full relative">
|
map's internal positioning correct inside the RTL page. */}
|
||||||
<div id={mapIdRef.current} className="h-full w-full">
|
<div className="dd z-0 h-[500px] w-full relative" dir="ltr">
|
||||||
{mounted && (
|
<div
|
||||||
<MapContainer
|
ref={mapContainerRef}
|
||||||
key={mapIdRef.current}
|
id={mapIdRef.current}
|
||||||
center={currentCenter}
|
className="h-full w-full"
|
||||||
zoom={13}
|
/>
|
||||||
scrollWheelZoom={true}
|
|
||||||
style={{ height: "100%", width: "100%" }}
|
|
||||||
>
|
|
||||||
<CityChangeView center={currentCenter} />
|
|
||||||
<TileLayer
|
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MapClickHandler onMapClick={handleMapClick} />
|
{/* Use my current location */}
|
||||||
{/* <LocationButton /> */}
|
<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>
|
||||||
|
|
||||||
{selectedPosition && (
|
{/* Hint / selected-address preview */}
|
||||||
<Marker position={selectedPosition}>
|
<div
|
||||||
<Popup>
|
dir="rtl"
|
||||||
<div dir="rtl" className="text-right">
|
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"
|
||||||
<p className="font-bold mb-1">آدرس:</p>
|
>
|
||||||
<p className="mb-2">{selectedAddress}</p>
|
{selectedAddress ? (
|
||||||
<p className="mb-1">
|
<>
|
||||||
<span className="font-bold">مختصات: </span>
|
<p className="text-R10 text-GRAY mb-0.5">موقعیت انتخابشده</p>
|
||||||
{selectedPosition[0].toFixed(6)},{" "}
|
<p className="text-R12 font-bold line-clamp-2">
|
||||||
{selectedPosition[1].toFixed(6)}
|
{selectedAddress}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</>
|
||||||
</Popup>
|
) : (
|
||||||
</Marker>
|
<p className="text-R12 font-medium text-BLACK2 flex items-center gap-1.5">
|
||||||
)}
|
<MapPin size={16} className="text-RED shrink-0" />
|
||||||
</MapContainer>
|
برای انتخاب موقعیت، روی نقشه بزنید
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -166,7 +166,7 @@ export function LoginRequiredDialog({
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-full flex justify-center">
|
<div className="w-full flex justify-center">
|
||||||
<InputOTP
|
<InputOTP
|
||||||
maxLength={4}
|
maxLength={6}
|
||||||
onComplete={(finalValue) => {
|
onComplete={(finalValue) => {
|
||||||
const convertedValue = convertPersianToEnglish(finalValue);
|
const convertedValue = convertPersianToEnglish(finalValue);
|
||||||
setOtpValue(convertedValue);
|
setOtpValue(convertedValue);
|
||||||
@ -177,17 +177,19 @@ export function LoginRequiredDialog({
|
|||||||
setOtpValue(convertedValue);
|
setOtpValue(convertedValue);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<InputOTPGroup dir="ltr" className="gap-4">
|
<InputOTPGroup dir="ltr" className="gap-1.5 sm:gap-3">
|
||||||
<InputOTPSlot index={0} />
|
<InputOTPSlot index={0} className="!w-9 sm:!w-12" />
|
||||||
<InputOTPSlot index={1} />
|
<InputOTPSlot index={1} className="!w-9 sm:!w-12" />
|
||||||
<InputOTPSlot index={2} />
|
<InputOTPSlot index={2} className="!w-9 sm:!w-12" />
|
||||||
<InputOTPSlot index={3} />
|
<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>
|
</InputOTPGroup>
|
||||||
</InputOTP>
|
</InputOTP>
|
||||||
</div>
|
</div>
|
||||||
<div className="gap-3 w-full flex flex-col items-center">
|
<div className="gap-3 w-full flex flex-col items-center">
|
||||||
<Button
|
<Button
|
||||||
disabled={verifyOtpMutation.isPending || !(otpValue.length === 4)}
|
disabled={verifyOtpMutation.isPending || !(otpValue.length === 6)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
size="xl"
|
size="xl"
|
||||||
variant="dark"
|
variant="dark"
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import placeholderImage from "~/assets/icons/product.png";
|
|||||||
import placeholderImageDark from "~/assets/icons/product-dark.png";
|
import placeholderImageDark from "~/assets/icons/product-dark.png";
|
||||||
import { useRootData } from "~/hooks/use-root-data";
|
import { useRootData } from "~/hooks/use-root-data";
|
||||||
import discountRed from "~/assets/icons/product/discount-red.svg";
|
import discountRed from "~/assets/icons/product/discount-red.svg";
|
||||||
|
import SellerChip from "./SellerChip";
|
||||||
|
|
||||||
interface MondrianList {
|
interface MondrianList {
|
||||||
id?: string;
|
id?: string;
|
||||||
@ -21,6 +22,9 @@ interface MondrianList {
|
|||||||
discountPercent?: number;
|
discountPercent?: number;
|
||||||
discountPrice?: number;
|
discountPrice?: number;
|
||||||
basePrice?: number;
|
basePrice?: number;
|
||||||
|
sellerName?: string;
|
||||||
|
sellerLogo?: string | null;
|
||||||
|
sellerUsername?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility function to detect media type
|
// Utility function to detect media type
|
||||||
@ -87,6 +91,24 @@ const MondrianProductList = ({
|
|||||||
return () => window.removeEventListener("scroll", handleScroll);
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
}, [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
|
// Memoize expensive filtering operations
|
||||||
const { oddProducts, evenProducts } = useMemo(() => {
|
const { oddProducts, evenProducts } = useMemo(() => {
|
||||||
const odd = products.filter((_, index) => index % 2 === 0);
|
const odd = products.filter((_, index) => index % 2 === 0);
|
||||||
@ -96,8 +118,17 @@ const MondrianProductList = ({
|
|||||||
|
|
||||||
const oddHeight = useMemo(() => [220, 260, 320, 200, 280], []);
|
const oddHeight = useMemo(() => [220, 260, 320, 200, 280], []);
|
||||||
const evenHeight = useMemo(() => [280, 200, 260, 220, 320], []);
|
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 (
|
return (
|
||||||
<div className="px-4 flex flex-col w-full">
|
<div className="px-4 lg:px-0 flex flex-col w-full">
|
||||||
{!isCommonList ? (
|
{!isCommonList ? (
|
||||||
<>
|
<>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@ -113,7 +144,8 @@ const MondrianProductList = ({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-1 w-full">
|
{/* Mobile / tablet: original two-column masonry (unchanged) */}
|
||||||
|
<div className="flex gap-1 w-full lg:hidden">
|
||||||
{/* Column 1 (Odd products - index 0, 2, ...) */}
|
{/* Column 1 (Odd products - index 0, 2, ...) */}
|
||||||
<div className="flex flex-col gap-1 w-[calc(50%)]">
|
<div className="flex flex-col gap-1 w-[calc(50%)]">
|
||||||
{oddProducts.map((product, index: number) => {
|
{oddProducts.map((product, index: number) => {
|
||||||
@ -146,7 +178,28 @@ const MondrianProductList = ({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{hasNextPage && <MondrianSkeleton isMini={true} />}
|
|
||||||
|
{/* 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} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -351,6 +404,7 @@ const ProductCard = ({
|
|||||||
)}
|
)}
|
||||||
{/* Overlay info - Airbnb style */}
|
{/* 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">
|
<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">
|
<div className="flex w-full justify-between gap-1">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h3 className="text-white text-xs font-semibold line-clamp-1 mb-0.5">
|
<h3 className="text-white text-xs font-semibold line-clamp-1 mb-0.5">
|
||||||
|
|||||||
@ -8,10 +8,14 @@ import homeFilled from "app/assets/icons/navbar/home-filled.svg";
|
|||||||
import profileFilled from "app/assets/icons/navbar/profile-filled.svg";
|
import profileFilled from "app/assets/icons/navbar/profile-filled.svg";
|
||||||
import searchFilled from "app/assets/icons/navbar/search-filled.svg";
|
import searchFilled from "app/assets/icons/navbar/search-filled.svg";
|
||||||
import { Link, useLocation, useParams } from "@remix-run/react";
|
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 { detectPage } from "~/utils/helpers";
|
||||||
import { useRootData } from "~/hooks/use-root-data";
|
import { useRootData } from "~/hooks/use-root-data";
|
||||||
import { useCartCount } from "~/requestHandler/use-cart-hooks";
|
import { useCartCount } from "~/requestHandler/use-cart-hooks";
|
||||||
import { useSellerOrderCount } from "~/requestHandler/use-order-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 settingIconFilled from "~/assets/icons/navbar/setting-filled.svg";
|
||||||
import settingIconOutline from "~/assets/icons/navbar/setting-outline.svg";
|
import settingIconOutline from "~/assets/icons/navbar/setting-outline.svg";
|
||||||
import documentFilled from "~/assets/icons/navbar/document-filled.svg";
|
import documentFilled from "~/assets/icons/navbar/document-filled.svg";
|
||||||
@ -25,25 +29,71 @@ import productsOutline from "~/assets/icons/navbar/products-outline.svg";
|
|||||||
*/
|
*/
|
||||||
const MyLayout = React.memo(({ children }: { children: React.ReactNode }) => {
|
const MyLayout = React.memo(({ children }: { children: React.ReactNode }) => {
|
||||||
const location = useLocation();
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-100 lg:bg-gray-200">
|
<div className="min-h-screen bg-gray-100 lg:bg-gray-200 lg:flex lg:flex-col">
|
||||||
<div className="max-w-md mx-auto bg-WHITE min-h-screen shadow-lg lg:shadow-xl">
|
{isDesktopReady && <DesktopHeader />}
|
||||||
<div className="pb-[calc(50px+env(safe-area-inset-bottom))]">
|
<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" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
{location.pathname.split("threads")[1] ? (
|
{isThread || isAdmin ? (
|
||||||
<></>
|
<></>
|
||||||
) : (
|
) : (
|
||||||
<MobileBottomNavigation />
|
<MobileBottomNavigation hideOnDesktop={expandOnDesktop} />
|
||||||
)}
|
)}
|
||||||
|
<PushPermissionBanner />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isDesktopReady && <DesktopFooter />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
MyLayout.displayName = "Layout";
|
MyLayout.displayName = "Layout";
|
||||||
|
|
||||||
const MobileBottomNavigation = () => {
|
const MobileBottomNavigation = ({
|
||||||
|
hideOnDesktop = false,
|
||||||
|
}: {
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
}) => {
|
||||||
const { theme } = useRootData();
|
const { theme } = useRootData();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const currentPage = useMemo(
|
const currentPage = useMemo(
|
||||||
@ -54,6 +104,11 @@ const MobileBottomNavigation = () => {
|
|||||||
const { user } = useRootData();
|
const { user } = useRootData();
|
||||||
const [isStoreMode, setIsStoreMode] = useState(false);
|
const [isStoreMode, setIsStoreMode] = useState(false);
|
||||||
const sellerOrderCount = useSellerOrderCount(!!isStoreMode);
|
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);
|
const [storeId, setStoreId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Initialize state from sessionStorage on mount - only once
|
// Initialize state from sessionStorage on mount - only once
|
||||||
@ -88,6 +143,7 @@ const MobileBottomNavigation = () => {
|
|||||||
path.match(/^\/store\/[^/]+\/products$/) || // Products list: /store/{id}/products
|
path.match(/^\/store\/[^/]+\/products$/) || // Products list: /store/{id}/products
|
||||||
path.match(/^\/store\/[^/]+\/product\/[^/]+$/) || // Product detail: /store/{id}/products/{id}
|
path.match(/^\/store\/[^/]+\/product\/[^/]+$/) || // Product detail: /store/{id}/products/{id}
|
||||||
path.match(/^\/store\/[^/]+\/edit$/) || // Edit store: /store/{id}/edit
|
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\/add\/manual\/[^/]+$/) || // Add product: /store/add/manual/{id}
|
||||||
path.match(/^\/store\/create$/) || // Create store: /store/create
|
path.match(/^\/store\/create$/) || // Create store: /store/create
|
||||||
path.match(/^\/store\/authenticate\/instagram\/[^/]+$/) || // Instagram auth: /store/authenticate/instagram/{id}
|
path.match(/^\/store\/authenticate\/instagram\/[^/]+$/) || // Instagram auth: /store/authenticate/instagram/{id}
|
||||||
@ -260,6 +316,8 @@ const MobileBottomNavigation = () => {
|
|||||||
: theme === "dark"
|
: theme === "dark"
|
||||||
? profileFilled
|
? profileFilled
|
||||||
: profileOutline,
|
: profileOutline,
|
||||||
|
badge:
|
||||||
|
accountUnread > 0 && user?.access_token ? accountUnread : null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
link: "/cart",
|
link: "/cart",
|
||||||
@ -301,6 +359,7 @@ const MobileBottomNavigation = () => {
|
|||||||
currentPage,
|
currentPage,
|
||||||
cartCount,
|
cartCount,
|
||||||
sellerOrderCount,
|
sellerOrderCount,
|
||||||
|
accountUnread,
|
||||||
user?.access_token,
|
user?.access_token,
|
||||||
isStoreMode,
|
isStoreMode,
|
||||||
storeId,
|
storeId,
|
||||||
@ -309,31 +368,37 @@ const MobileBottomNavigation = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<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">
|
<div
|
||||||
{navItems.map((item) => (
|
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 ${
|
||||||
<Link
|
hideOnDesktop ? "lg:hidden" : ""
|
||||||
key={item.link}
|
}`}
|
||||||
to={item.link}
|
>
|
||||||
prefetch="intent"
|
<div className="h-[50px] flex gap-[48px] px-[20px] items-center justify-center">
|
||||||
className="relative"
|
{navItems.map((item) => (
|
||||||
>
|
<Link
|
||||||
{typeof item.icon === "string" ? (
|
key={item.link}
|
||||||
<img
|
to={item.link}
|
||||||
src={item.icon}
|
prefetch="intent"
|
||||||
className="h-6 w-6"
|
className="relative"
|
||||||
alt={item.link}
|
>
|
||||||
loading="eager"
|
{typeof item.icon === "string" ? (
|
||||||
/>
|
<img
|
||||||
) : (
|
src={item.icon}
|
||||||
item.icon
|
className="h-6 w-6"
|
||||||
)}
|
alt={item.link}
|
||||||
{item.badge && (
|
loading="eager"
|
||||||
<span className="absolute -top-1 -right-1 text-R12 ">
|
/>
|
||||||
{item.badge}
|
) : (
|
||||||
</span>
|
item.icon
|
||||||
)}
|
)}
|
||||||
</Link>
|
{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}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -20,17 +20,19 @@ const ProfilePagesHeader = memo(function ProfilePagesHeader({
|
|||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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">
|
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
|
||||||
{!hideBackButton && (
|
<div className="relative flex flex-row h-[65px] bg-WHITE w-full items-center justify-center border-b border-inner-border">
|
||||||
<ArrowRight
|
{!hideBackButton && (
|
||||||
onClick={handleBack}
|
<ArrowRight
|
||||||
className="absolute top-5 right-5 cursor-pointer"
|
onClick={handleBack}
|
||||||
/>
|
className="absolute top-5 right-5 cursor-pointer"
|
||||||
)}
|
/>
|
||||||
<p className="text-B16 font-bold">{title}</p>
|
)}
|
||||||
{rightContent && (
|
<p className="text-B16 font-bold">{title}</p>
|
||||||
<div className="absolute top-4 left-4">{rightContent}</div>
|
{rightContent && (
|
||||||
)}
|
<div className="absolute top-4 left-4">{rightContent}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -42,7 +42,7 @@ const SearchTopBar: React.FC<SearchTopBarProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 py-4 px-4 w-full sticky top-0 z-30 bg-WHITE">
|
<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 items-center justify-center gap-2 rounded-[10px] w-full relative">
|
<div className="flex items-center justify-center gap-2 rounded-[10px] w-full relative">
|
||||||
<AppInput
|
<AppInput
|
||||||
inputIcon={SearchIcon}
|
inputIcon={SearchIcon}
|
||||||
|
|||||||
32
app/components/SellerChip.tsx
Normal file
32
app/components/SellerChip.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -44,14 +44,16 @@ const SellerTilesGrid = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col gap-4 px-4">
|
<div className="w-full flex flex-col gap-4 px-4 lg:px-0">
|
||||||
{/* Grid Container - 2x2 layout */}
|
{/* 2x2 on mobile, single row of 5 on desktop */}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 lg:grid-cols-5 gap-2 lg:gap-4">
|
||||||
{sellers.slice(0, 4).map((seller, index) => (
|
{sellers.slice(0, 5).map((seller, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={seller.id || index}
|
key={seller.id || index}
|
||||||
to={`/seller/${seller.sellerUsername}`}
|
to={`/seller/${seller.sellerUsername}`}
|
||||||
className="relative aspect-square overflow-hidden rounded-lg group"
|
className={`relative aspect-square overflow-hidden rounded-lg group ${
|
||||||
|
index === 4 ? "hidden lg:block" : ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{/* Background Image */}
|
{/* Background Image */}
|
||||||
{seller.imageUrl ? (
|
{seller.imageUrl ? (
|
||||||
@ -65,6 +67,7 @@ const SellerTilesGrid = ({
|
|||||||
<Store size={48} className="text-gray-400" />
|
<Store size={48} className="text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
248
app/components/admin/DataTable.tsx
Normal file
248
app/components/admin/DataTable.tsx
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
app/components/admin/KpiCard.tsx
Normal file
45
app/components/admin/KpiCard.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
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;
|
||||||
61
app/components/admin/OrdersBarChart.tsx
Normal file
61
app/components/admin/OrdersBarChart.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
@ -14,7 +14,7 @@ export const CartHeader = ({ cart, step, setStep }: CartHeaderProps) => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
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" : ""}`}
|
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" : ""}`}
|
||||||
>
|
>
|
||||||
<p className={"text-B16 font-bold px-4"}>
|
<p className={"text-B16 font-bold px-4"}>
|
||||||
{step === 0
|
{step === 0
|
||||||
@ -29,7 +29,7 @@ export const CartHeader = ({ cart, step, setStep }: CartHeaderProps) => {
|
|||||||
if (step > 0) setStep((p) => p - 1);
|
if (step > 0) setStep((p) => p - 1);
|
||||||
else safeGoBack(navigate);
|
else safeGoBack(navigate);
|
||||||
}}
|
}}
|
||||||
className="absolute top-4 right-4"
|
className="absolute top-[calc(1rem_+_env(safe-area-inset-top))] right-4"
|
||||||
/>
|
/>
|
||||||
{cart && cart.items && cart.items.length > 0 && (
|
{cart && cart.items && cart.items.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Check, Trash } from "lucide-react";
|
import { Trash } from "lucide-react";
|
||||||
import { memo, useMemo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
import { QuantityControl } from "./QuantityControl";
|
import { QuantityControl } from "./QuantityControl";
|
||||||
import { formatPrice } from "../../utils/helpers";
|
import { formatPrice } from "../../utils/helpers";
|
||||||
@ -34,145 +34,113 @@ export const CartItem = memo(function CartItem({
|
|||||||
);
|
);
|
||||||
}, [item.productVariant?.discountPrice, item.productVariant?.price]);
|
}, [item.productVariant?.discountPrice, item.productVariant?.price]);
|
||||||
|
|
||||||
// Memoize video check
|
|
||||||
const isVideoMedia = useMemo(
|
const isVideoMedia = useMemo(
|
||||||
() => isVideo(item.productVariant?.productImage || ""),
|
() => isVideo(item.productVariant?.productImage || ""),
|
||||||
[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 (
|
return (
|
||||||
<div
|
<div className={`flex gap-3 p-4 w-full ${hasBorder && "border-b"}`}>
|
||||||
className={`flex p-4 gap-4 w-full flex-col ${hasBorder && "border-b"}`}
|
{isVideoMedia ? (
|
||||||
>
|
<video
|
||||||
<div className="flex gap-2 w-full items-center">
|
src={item.productVariant?.productImage}
|
||||||
{isVideoMedia ? (
|
className="rounded-[11px] w-[88px] h-[110px] object-cover shrink-0"
|
||||||
<video
|
muted
|
||||||
src={item.productVariant?.productImage}
|
loop
|
||||||
className="rounded-[5px] w-[116px] h-[116px] object-cover"
|
autoPlay
|
||||||
muted
|
playsInline
|
||||||
loop
|
/>
|
||||||
autoPlay
|
) : (
|
||||||
playsInline
|
<img
|
||||||
/>
|
src={item.productVariant?.productImage}
|
||||||
) : (
|
alt={item.productVariant?.productTitle || ""}
|
||||||
<img
|
className="rounded-[11px] w-[88px] h-[110px] object-cover shrink-0"
|
||||||
src={item.productVariant?.productImage}
|
loading="lazy"
|
||||||
alt={item.productVariant?.productTitle || ""}
|
/>
|
||||||
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">
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<p className="text-B14 font-bold">
|
|
||||||
{item.productVariant?.productTitle || ""}
|
|
||||||
</p>
|
|
||||||
<p className="text-R12 text-GRAY line-clamp-1">
|
|
||||||
کد کالا: {item.productVariant?.id || ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ColorSelector
|
{/* Bottom row: stepper + price */}
|
||||||
color={
|
{inStock ? (
|
||||||
item.productVariant?.color?.value !== "UNSELECTED"
|
<div className="flex items-center gap-3 mt-auto pt-3">
|
||||||
? item.productVariant?.color?.info?.detail || ""
|
<QuantityControl
|
||||||
: ""
|
isPending={isLoading}
|
||||||
}
|
quantity={item.quantity}
|
||||||
/>
|
maxQuantity={item.productVariant?.stock || 0}
|
||||||
<SizeSelector
|
onIncrease={() =>
|
||||||
size={
|
onQuantityChange(item.id || "", item.quantity + 1)
|
||||||
item.productVariant?.size?.value !== "UNSELECTED"
|
}
|
||||||
? item.productVariant?.size?.info?.detail || ""
|
onDecrease={() =>
|
||||||
: ""
|
onQuantityChange(item.id || "", item.quantity - 1)
|
||||||
}
|
}
|
||||||
/>
|
onRemove={() => onRemoveItem(item.id || "")}
|
||||||
|
/>
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="ms-auto text-left">
|
||||||
<p className="text-B14 font-bold">قیمت واحد :</p>
|
<b className="text-B14 font-bold">
|
||||||
<p className="text-B14 font-bold">
|
{formatPrice(effectivePrice)}
|
||||||
{formatPrice(item.productVariant?.price || "")} تومان
|
<span className="text-R10 font-semibold text-BLACK2"> ت</span>
|
||||||
</p>
|
</b>
|
||||||
</div>
|
{discountPercent ? (
|
||||||
|
<del className="block text-R10 text-GRAY">
|
||||||
{discountPercent ? (
|
{formatPrice(item.productVariant?.price || "")}
|
||||||
<div className="flex w-full items-center justify-between">
|
</del>
|
||||||
<div className="flex items-center gap-2">
|
) : null}
|
||||||
<p className="text-B14 font-bold">مقدار تخفیف :</p>
|
|
||||||
<div className="bg-BLACK rounded-full text-WHITE text-R14 px-2 py-1">
|
|
||||||
{discountPercent}%
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-B14 font-bold">
|
) : (
|
||||||
{formatPrice(
|
<div className="flex w-full items-center justify-between mt-auto pt-3">
|
||||||
(
|
<p className="text-R12 font-bold text-RED">
|
||||||
Number(item.productVariant?.price) -
|
در حال حاضر موجود نیست
|
||||||
Number(item.productVariant?.discountPrice)
|
</p>
|
||||||
).toString()
|
<Button
|
||||||
)}{" "}
|
variant="dark"
|
||||||
تومان
|
className="flex items-center gap-2 bg-red-500"
|
||||||
</p>
|
size="sm"
|
||||||
</div>
|
onClick={() => onRemoveItem(item.id || "")}
|
||||||
) : null}
|
>
|
||||||
|
<Trash size={16} />
|
||||||
{item?.productVariant?.stock && item?.productVariant?.stock > 0 ? (
|
</Button>
|
||||||
<div className="flex w-full items-center justify-between">
|
</div>
|
||||||
<div className="flex items-center gap-2"></div>
|
)}
|
||||||
<QuantityControl
|
</div>
|
||||||
isPending={isLoading}
|
|
||||||
quantity={item.quantity}
|
|
||||||
maxQuantity={item.productVariant?.stock || 0}
|
|
||||||
onIncrease={() =>
|
|
||||||
onQuantityChange(item.id || "", item.quantity + 1)
|
|
||||||
}
|
|
||||||
onDecrease={() =>
|
|
||||||
onQuantityChange(item.id || "", item.quantity - 1)
|
|
||||||
}
|
|
||||||
onRemove={() => onRemoveItem(item.id || "")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<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"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onRemoveItem(item.id || "")}
|
|
||||||
>
|
|
||||||
<Trash size={16} />
|
|
||||||
</Button>
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export const CartSummary = ({
|
|||||||
step,
|
step,
|
||||||
isLoading,
|
isLoading,
|
||||||
}: CartSummaryProps) => (
|
}: 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">
|
<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">
|
||||||
<Button
|
<Button
|
||||||
variant="dark"
|
variant="dark"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
100
app/components/desktop/DesktopFooter.tsx
Normal file
100
app/components/desktop/DesktopFooter.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
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;
|
||||||
249
app/components/desktop/DesktopHeader.tsx
Normal file
249
app/components/desktop/DesktopHeader.tsx
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
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;
|
||||||
@ -41,7 +41,7 @@ const ExploreTopBar = memo(function ExploreTopBar({
|
|||||||
}, [setSearchValue]);
|
}, [setSearchValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 py-4 px-4 w-full sticky top-0 z-30 bg-WHITE">
|
<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 items-center justify-center gap-2 rounded-[10px] w-full relative">
|
<div className="flex items-center justify-center gap-2 rounded-[10px] w-full relative">
|
||||||
<AppInput
|
<AppInput
|
||||||
inputIcon={Search}
|
inputIcon={Search}
|
||||||
|
|||||||
@ -47,7 +47,7 @@ const MainSection = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 w-full pb-20">
|
<div className="flex flex-col gap-6 w-full pb-20 lg:max-w-[1320px] lg:mx-auto lg:px-8 lg:pt-6">
|
||||||
<TabContainer
|
<TabContainer
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
setActiveTab={setActiveTab}
|
setActiveTab={setActiveTab}
|
||||||
@ -64,8 +64,8 @@ const MainSection = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="px-4 flex flex-col gap-6 w-full">
|
<div className="px-4 lg:px-0 flex flex-col gap-6 w-full">
|
||||||
<div className="grid grid-cols-2 gap-4 w-full">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 w-full">
|
||||||
{categories?.map((category, index) => {
|
{categories?.map((category, index) => {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@ -75,7 +75,7 @@ const MainSection = () => {
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={category.imageUrl}
|
src={category.imageUrl}
|
||||||
className="w-full h-[120px] object-cover rounded-[15px]"
|
className="w-full h-[120px] lg:h-[180px] object-cover rounded-[15px]"
|
||||||
alt={category.categoryName}
|
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">
|
<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,6 +104,9 @@ const MainSection = () => {
|
|||||||
basePrice: product.basePrice
|
basePrice: product.basePrice
|
||||||
? parseInt(product.basePrice)
|
? parseInt(product.basePrice)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
sellerName: product.sellerName,
|
||||||
|
sellerLogo: product.sellerLogo,
|
||||||
|
sellerUsername: product.sellerUsername,
|
||||||
};
|
};
|
||||||
})}
|
})}
|
||||||
fetchNextPage={fetchNextPage}
|
fetchNextPage={fetchNextPage}
|
||||||
|
|||||||
@ -1,103 +1,74 @@
|
|||||||
import { useState, useEffect, useMemo, memo, useCallback } from "react";
|
import { memo, useCallback } from "react";
|
||||||
|
import { Link } from "@remix-run/react";
|
||||||
|
import { Bell, Search, ShoppingBag } from "lucide-react";
|
||||||
import logo from "../../assets/logo/SVG-07.svg";
|
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 HomePageTopBar = memo(() => {
|
||||||
const [activeTab, setActiveTab] = useState(0);
|
const { user } = useRootData();
|
||||||
const tabs = useMemo(() => ["تخفیفها", "برترین فروشگاهها", "برای شما"], []);
|
const isLoggedIn = !!user?.access_token;
|
||||||
|
const cartCount = useCartCount();
|
||||||
// Scroll to section when tab is clicked
|
const { data: badgeCounts } = useBadgeCounts();
|
||||||
const scrollToSection = useCallback((sectionId: string) => {
|
const accountUnread =
|
||||||
const section = document.getElementById(sectionId);
|
(badgeCounts?.chat || 0) + (badgeCounts?.orderUpdates || 0);
|
||||||
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(() => {
|
const scrollToTop = useCallback(() => {
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTabClick = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
setActiveTab(index);
|
|
||||||
scrollToSection(`section${index + 1}`);
|
|
||||||
},
|
|
||||||
[scrollToSection]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
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="bg-WHITE sticky top-0 z-30 pt-[env(safe-area-inset-top)]">
|
||||||
<div className="flex relative items-center gap-2 justify-center w-full">
|
<div className="px-4 pt-2.5 pb-3 flex flex-col gap-3">
|
||||||
<div
|
{/* Brand row */}
|
||||||
className="absolute right-0"
|
<div className="flex items-center gap-2.5">
|
||||||
onClick={() => scrollToTop()}
|
<button
|
||||||
role="button"
|
onClick={scrollToTop}
|
||||||
tabIndex={0}
|
className="flex items-center gap-2"
|
||||||
onKeyDown={(e) => {
|
aria-label="ویترون"
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
>
|
||||||
scrollToTop();
|
<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>
|
||||||
|
|
||||||
|
{/* 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"
|
||||||
>
|
>
|
||||||
<img src={logo} className="w-8 h-8 object-contain" alt="logo" />
|
<Search size={19} className="shrink-0" />
|
||||||
</div>
|
<span className="text-R14">جستجوی محصول، برند یا فروشگاه…</span>
|
||||||
<div className="flex gap-4 justify-center">
|
</Link>
|
||||||
{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
67
app/components/notifications/PushPermissionBanner.tsx
Normal file
67
app/components/notifications/PushPermissionBanner.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
app/components/notifications/PushSettingToggle.tsx
Normal file
50
app/components/notifications/PushSettingToggle.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -62,8 +62,8 @@ interface AddToCartButtonProps {
|
|||||||
const AddToCartButton = ({ isOutOfStock, onClick }: AddToCartButtonProps) => (
|
const AddToCartButton = ({ isOutOfStock, onClick }: AddToCartButtonProps) => (
|
||||||
<Button
|
<Button
|
||||||
variant="dark"
|
variant="dark"
|
||||||
size="lg"
|
size="xl"
|
||||||
className="font-bold flex items-center gap-2 bg-VITROWN_BLUE text-white"
|
className="flex-1 h-[52px] rounded-[14px] text-B14 font-bold flex items-center justify-center gap-2 bg-VITROWN_BLUE text-white"
|
||||||
disabled={isOutOfStock}
|
disabled={isOutOfStock}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
@ -71,7 +71,7 @@ const AddToCartButton = ({ isOutOfStock, onClick }: AddToCartButtonProps) => (
|
|||||||
<span className="text-B14 font-bold">ناموجود</span>
|
<span className="text-B14 font-bold">ناموجود</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ShoppingCart size={24} />
|
<ShoppingCart size={22} />
|
||||||
افزودن به سبد خرید
|
افزودن به سبد خرید
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -194,19 +194,12 @@ export const BottomBar = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<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="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="flex w-full items-center justify-between">
|
<PriceDisplay
|
||||||
<div className="flex flex-col gap-1">
|
currentPrice={currentPrice}
|
||||||
<AddToCartButton
|
currentDiscountPrice={currentDiscountPrice}
|
||||||
isOutOfStock={isOutOfStock}
|
/>
|
||||||
onClick={handleAddToCart}
|
<AddToCartButton isOutOfStock={isOutOfStock} onClick={handleAddToCart} />
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<PriceDisplay
|
|
||||||
currentPrice={currentPrice}
|
|
||||||
currentDiscountPrice={currentDiscountPrice}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CartDrawer
|
<CartDrawer
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { Check } from "lucide-react";
|
|
||||||
|
|
||||||
interface ColorSelectorProps {
|
interface ColorSelectorProps {
|
||||||
colors: string[] | undefined;
|
colors: string[] | undefined;
|
||||||
selectedColor: string | null;
|
selectedColor: string | null;
|
||||||
@ -12,24 +10,23 @@ export const ColorSelector = ({
|
|||||||
setSelectedColor,
|
setSelectedColor,
|
||||||
}: ColorSelectorProps) => {
|
}: ColorSelectorProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 mt-10 px-4">
|
<div className="flex flex-col gap-4 mt-10 px-4 lg:px-0">
|
||||||
<p className="text-B18 font-bold">انتخاب رنگ</p>
|
<p className="text-B18 font-bold">انتخاب رنگ</p>
|
||||||
<div className="gap-4 flex">
|
<div className="gap-4 flex">
|
||||||
{colors?.map((enColor: string, index: number) => {
|
{colors?.map((enColor: string, index: number) => {
|
||||||
|
const isSelected = selectedColor === enColor;
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
className="w-10 border h-10 rounded-md flex items-center justify-center"
|
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"
|
||||||
|
}`}
|
||||||
style={{ backgroundColor: enColor }}
|
style={{ backgroundColor: enColor }}
|
||||||
onClick={() => setSelectedColor(enColor)}
|
onClick={() => setSelectedColor(enColor)}
|
||||||
onKeyDown={() => {}}
|
/>
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
{selectedColor === enColor && (
|
|
||||||
<Check size={24} className="text-WHITE" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -19,11 +19,14 @@ export const ContactButton = ({ product }: { product: ProductDetail }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!product.seller?.id) return;
|
if (!product.seller?.id) return;
|
||||||
createChatThread(product.seller.id, {
|
createChatThread(
|
||||||
onSuccess: (threadData) => {
|
{ participantId: product.seller.id, productId: product.id },
|
||||||
navigate(`/profile/threads/${threadData.id}`);
|
{
|
||||||
},
|
onSuccess: (threadData) => {
|
||||||
});
|
navigate(`/profile/threads/${threadData.id}`);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Send,
|
Send,
|
||||||
Share2,
|
Share2,
|
||||||
Star,
|
|
||||||
X,
|
X,
|
||||||
Loader2,
|
Loader2,
|
||||||
Copy,
|
Copy,
|
||||||
@ -20,8 +19,6 @@ import {
|
|||||||
} from "~/components/ui/drawer";
|
} from "~/components/ui/drawer";
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
useServiceGetProductRating,
|
|
||||||
useServiceGetComments,
|
|
||||||
useServiceBookmarkProduct,
|
useServiceBookmarkProduct,
|
||||||
useServiceDeleteBookmark,
|
useServiceDeleteBookmark,
|
||||||
useServiceCheckBookmark,
|
useServiceCheckBookmark,
|
||||||
@ -62,9 +59,6 @@ export const ProductActions = ({ productId }: { productId: string }) => {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useRootData();
|
const { user } = useRootData();
|
||||||
|
|
||||||
const { data: ratingData, isLoading: ratingLoading } =
|
|
||||||
useServiceGetProductRating(productId || "");
|
|
||||||
const { data: commentsData } = useServiceGetComments(1, productId || "");
|
|
||||||
const { data: bookmarkData, isLoading: isCheckingBookmark } =
|
const { data: bookmarkData, isLoading: isCheckingBookmark } =
|
||||||
useServiceCheckBookmark(productId || "");
|
useServiceCheckBookmark(productId || "");
|
||||||
const { mutate: bookmarkProduct, isPending: isBookmarking } =
|
const { mutate: bookmarkProduct, isPending: isBookmarking } =
|
||||||
@ -73,8 +67,6 @@ export const ProductActions = ({ productId }: { productId: string }) => {
|
|||||||
useServiceDeleteBookmark();
|
useServiceDeleteBookmark();
|
||||||
|
|
||||||
const isBookmarked = bookmarkData?.bookmarked === true;
|
const isBookmarked = bookmarkData?.bookmarked === true;
|
||||||
const averageRating = ratingData?.averageRating || 0;
|
|
||||||
const commentCount = commentsData?.count || 0;
|
|
||||||
|
|
||||||
const handleBookmark = () => {
|
const handleBookmark = () => {
|
||||||
// Check if user is logged in
|
// Check if user is logged in
|
||||||
@ -160,53 +152,58 @@ 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full mt-4 px-4 justify-between">
|
<div className="flex w-full mt-4 px-4 lg:mt-0 lg:px-0">
|
||||||
<div className="flex gap-8">
|
<div className="flex gap-8 lg:gap-2.5">
|
||||||
{isCheckingBookmark ? (
|
<button
|
||||||
<Skeleton className="h-6 w-6 rounded-full" />
|
type="button"
|
||||||
) : isBookmarked ? (
|
onClick={handleBookmark}
|
||||||
<>
|
aria-label="ذخیره"
|
||||||
{isDeletingBookmark ? (
|
className={actionBtn}
|
||||||
|
>
|
||||||
|
{isCheckingBookmark ? (
|
||||||
|
<Skeleton className="h-6 w-6 rounded-full" />
|
||||||
|
) : isBookmarked ? (
|
||||||
|
isDeletingBookmark ? (
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Bookmark
|
<Bookmark size={20} className="text-PRIMARY fill-current" />
|
||||||
size={20}
|
)
|
||||||
className="text-PRIMARY fill-current cursor-pointer"
|
) : isBookmarking ? (
|
||||||
onClick={handleBookmark}
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
/>
|
) : (
|
||||||
)}
|
<BookmarkMinus size={20} />
|
||||||
</>
|
)}
|
||||||
) : (
|
</button>
|
||||||
<>
|
<button
|
||||||
{isBookmarking ? (
|
type="button"
|
||||||
<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)}
|
onClick={() => setIsShareDrawerOpen(true)}
|
||||||
/>
|
aria-label="اشتراکگذاری"
|
||||||
<Layers
|
className={actionBtn}
|
||||||
size={20}
|
>
|
||||||
className="cursor-pointer"
|
<Share2 size={20} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleAddToSet}
|
onClick={handleAddToSet}
|
||||||
/>
|
aria-label="افزودن به ست"
|
||||||
|
className={actionBtn}
|
||||||
|
>
|
||||||
|
<Layers size={20} />
|
||||||
|
</button>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Sparkles
|
<button
|
||||||
size={20}
|
type="button"
|
||||||
className="cursor-pointer animate-pulse text-PRIMARY"
|
|
||||||
onClick={handleOpenAIModal}
|
onClick={handleOpenAIModal}
|
||||||
/>
|
aria-label="پرو با هوش مصنوعی"
|
||||||
|
className={actionBtn}
|
||||||
|
>
|
||||||
|
<Sparkles size={20} className="animate-pulse text-PRIMARY" />
|
||||||
|
</button>
|
||||||
{showAIFeatureTooltip && (
|
{showAIFeatureTooltip && (
|
||||||
<div className="absolute bottom-full right-0 mb-2 z-50 animate-bounce">
|
<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">
|
<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">
|
||||||
@ -232,17 +229,6 @@ export const ProductActions = ({ productId }: { productId: string }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<Drawer open={isShareDrawerOpen} onOpenChange={setIsShareDrawerOpen}>
|
<Drawer open={isShareDrawerOpen} onOpenChange={setIsShareDrawerOpen}>
|
||||||
|
|||||||
33
app/components/product/ProductDescription.tsx
Normal file
33
app/components/product/ProductDescription.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import { useState, useEffect, useMemo, memo } from "react";
|
import { useState, useEffect, useMemo, memo } from "react";
|
||||||
import {
|
import {
|
||||||
ProductHeader,
|
|
||||||
ProductImageCarousel,
|
ProductImageCarousel,
|
||||||
ProductActions,
|
ProductActions,
|
||||||
ProductInfo,
|
ProductInfo,
|
||||||
|
ProductDescription,
|
||||||
ColorSelector,
|
ColorSelector,
|
||||||
SizeSelector,
|
SizeSelector,
|
||||||
ContactButton,
|
ContactButton,
|
||||||
@ -11,11 +11,40 @@ import {
|
|||||||
BottomBar,
|
BottomBar,
|
||||||
} from "./index";
|
} from "./index";
|
||||||
import { ProductDetail as ProductDetailType } from "../../../src/api/types";
|
import { ProductDetail as ProductDetailType } from "../../../src/api/types";
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Heart,
|
||||||
|
Sparkles,
|
||||||
|
Star,
|
||||||
|
ShoppingCart,
|
||||||
|
Truck,
|
||||||
|
RotateCcw,
|
||||||
|
ShieldCheck,
|
||||||
|
MessageCircle,
|
||||||
|
} from "lucide-react";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { useNavigate } from "@remix-run/react";
|
import { useNavigate, Link } from "@remix-run/react";
|
||||||
import { useSimilarProductsInfinite } from "~/requestHandler/use-product-hooks";
|
import { useSimilarProductsInfinite } from "~/requestHandler/use-product-hooks";
|
||||||
import MondrianProductList from "../MondrianProductList";
|
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 {
|
interface ProductDetailViewProps {
|
||||||
product: ProductDetailType;
|
product: ProductDetailType;
|
||||||
@ -50,6 +79,10 @@ export const ProductDetailView = memo(function ProductDetailView({
|
|||||||
setProductActiveStatus(product.isActive || false);
|
setProductActiveStatus(product.isActive || false);
|
||||||
}, [product.isActive]);
|
}, [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
|
// Memoize available colors and sizes
|
||||||
const { availableColors, availableSizes } = useMemo(
|
const { availableColors, availableSizes } = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@ -98,25 +131,63 @@ export const ProductDetailView = memo(function ProductDetailView({
|
|||||||
[stockedVariants.length, inStockColors.length, inStockSizes.length]
|
[stockedVariants.length, inStockColors.length, inStockSizes.length]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [selectedColor, setSelectedColor] = useState<string | null>(
|
// Effective pricing for a variant: original price + sale price (or null when
|
||||||
hasColorVariants && inStockColors.length > 0
|
// there's no real discount). Mirrors how the list/tile serializer computes
|
||||||
? (inStockColors[0].info as unknown as { detail: string })?.detail
|
// discount_price so the detail page stays consistent with the tiles.
|
||||||
: null
|
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 [selectedSize, setSelectedSize] = useState<string | null>(
|
// Cheapest in-stock variant by final (post-discount) price — this is the
|
||||||
hasSizeVariants && inStockSizes.length > 0 ? inStockSizes[0].value : null
|
// "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;
|
||||||
|
});
|
||||||
|
|
||||||
// Handle product status change
|
// Handle product status change
|
||||||
const handleProductStatusChange = (newStatus: boolean) => {
|
const handleProductStatusChange = (newStatus: boolean) => {
|
||||||
setProductActiveStatus(newStatus);
|
setProductActiveStatus(newStatus);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [currentPrice, setCurrentPrice] = useState(product.basePrice);
|
const [currentPrice, setCurrentPrice] = useState(
|
||||||
|
() => pricingOf(defaultVariant).price
|
||||||
|
);
|
||||||
const [currentDiscountPrice, setCurrentDiscountPrice] = useState<
|
const [currentDiscountPrice, setCurrentDiscountPrice] = useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(() => pricingOf(defaultVariant).discountPrice);
|
||||||
const [currentStock, setCurrentStock] = useState<number | null>(null);
|
const [currentStock, setCurrentStock] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -152,35 +223,28 @@ export const ProductDetailView = memo(function ProductDetailView({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (matchingVariant) {
|
if (matchingVariant) {
|
||||||
setCurrentPrice(matchingVariant.price || product.basePrice);
|
const pr = pricingOf(matchingVariant);
|
||||||
setCurrentDiscountPrice(
|
setCurrentPrice(pr.price);
|
||||||
matchingVariant.discountPrice &&
|
setCurrentDiscountPrice(pr.discountPrice);
|
||||||
matchingVariant.discountPrice !== "0" &&
|
|
||||||
Number(matchingVariant.discountPrice) !==
|
|
||||||
Number(matchingVariant.price)
|
|
||||||
? matchingVariant.discountPrice
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
setCurrentStock(matchingVariant.stock || null);
|
setCurrentStock(matchingVariant.stock || null);
|
||||||
|
} else if (
|
||||||
|
!hasColorVariants &&
|
||||||
|
!hasSizeVariants &&
|
||||||
|
stockedVariants.length > 0
|
||||||
|
) {
|
||||||
|
// No color/size dimensions → use the first available variant.
|
||||||
|
const firstVariant = stockedVariants[0];
|
||||||
|
const pr = pricingOf(firstVariant);
|
||||||
|
setCurrentPrice(pr.price);
|
||||||
|
setCurrentDiscountPrice(pr.discountPrice);
|
||||||
|
setCurrentStock(firstVariant.stock || null);
|
||||||
} else {
|
} else {
|
||||||
// If no matching variant found, but we have variants and no color/size requirements
|
// Color/size not fully matched yet: show the cheapest variant's price +
|
||||||
// Use the first available variant (for products with no color/size variants)
|
// sale (same as the product tile) instead of basePrice with no discount.
|
||||||
if (!hasColorVariants && !hasSizeVariants && stockedVariants.length > 0) {
|
const pr = pricingOf(defaultVariant);
|
||||||
const firstVariant = stockedVariants[0];
|
setCurrentPrice(pr.price);
|
||||||
setCurrentPrice(firstVariant.price || product.basePrice);
|
setCurrentDiscountPrice(pr.discountPrice);
|
||||||
setCurrentDiscountPrice(
|
setCurrentStock(null);
|
||||||
firstVariant.discountPrice &&
|
|
||||||
firstVariant.discountPrice !== "0" &&
|
|
||||||
Number(firstVariant.discountPrice) !== Number(firstVariant.price)
|
|
||||||
? firstVariant.discountPrice
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
setCurrentStock(firstVariant.stock || null);
|
|
||||||
} else {
|
|
||||||
setCurrentPrice(product.basePrice);
|
|
||||||
setCurrentDiscountPrice(null);
|
|
||||||
setCurrentStock(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
selectedColor,
|
selectedColor,
|
||||||
@ -190,110 +254,489 @@ export const ProductDetailView = memo(function ProductDetailView({
|
|||||||
hasColorVariants,
|
hasColorVariants,
|
||||||
hasSizeVariants,
|
hasSizeVariants,
|
||||||
stockedVariants,
|
stockedVariants,
|
||||||
|
defaultVariant,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
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 (
|
return (
|
||||||
<div className="flex flex-col pb-20">
|
<div className="flex flex-col pb-20 lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8 lg:pb-12">
|
||||||
{!isOwner && <ProductHeader product={product} />}
|
{/* Desktop breadcrumb */}
|
||||||
<ProductImageCarousel
|
<nav className="hidden lg:flex items-center gap-2 text-[13px] text-GRAY mt-4 mb-4">
|
||||||
images={product.imageUrls || []}
|
<Link to="/" className="hover:text-BLACK">
|
||||||
productId={product.id}
|
خانه
|
||||||
storeId={product.seller?.username}
|
</Link>
|
||||||
isOwner={isOwner}
|
<span>/</span>
|
||||||
borderRadius={0}
|
{product.seller?.username ? (
|
||||||
isActive={productActiveStatus}
|
<>
|
||||||
controlsIsOn={true}
|
<Link
|
||||||
onStatusChange={handleProductStatusChange}
|
to={`/seller/${product.seller.username}`}
|
||||||
isChangeStatusDialogOpen={isChangeStatusDialogOpen}
|
className="hover:text-BLACK"
|
||||||
setIsChangeStatusDialogOpen={setIsChangeStatusDialogOpen}
|
>
|
||||||
/>
|
{product.seller.username}
|
||||||
{/* Inactive Product Status */}
|
</Link>
|
||||||
{!productActiveStatus && isOwner ? (
|
<span>/</span>
|
||||||
<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">
|
) : null}
|
||||||
<EyeOff size={16} className="text-BLACK2 font-bold" />
|
<span className="text-BLACK truncate max-w-[320px]">
|
||||||
<span className="text-B12 font-bold text-BLACK2">
|
{product.title}
|
||||||
این محصول در حال حاضر غیرفعال است
|
</span>
|
||||||
</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">
|
||||||
|
<ProductImageCarousel
|
||||||
|
images={product.imageUrls || []}
|
||||||
|
productId={product.id}
|
||||||
|
storeId={product.seller?.username}
|
||||||
|
isOwner={isOwner}
|
||||||
|
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 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">
|
||||||
|
این محصول در حال حاضر غیرفعال است
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="dark"
|
||||||
|
size="sm"
|
||||||
|
className="text-B10 h-8 rounded-md"
|
||||||
|
onClick={() => setIsChangeStatusDialogOpen(true)}
|
||||||
|
>
|
||||||
|
فعال کردن
|
||||||
|
</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>
|
</div>
|
||||||
<Button
|
|
||||||
variant="dark"
|
|
||||||
size="sm"
|
|
||||||
className="text-B10 h-8 rounded-md"
|
|
||||||
onClick={() => setIsChangeStatusDialogOpen(true)}
|
|
||||||
>
|
|
||||||
فعال کردن
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : !isOwner ? (
|
|
||||||
<ProductActions productId={product.id || ""} />
|
|
||||||
) : null}
|
|
||||||
<ProductInfo
|
|
||||||
productDiscountPercent={Number(
|
|
||||||
!currentDiscountPrice ||
|
|
||||||
!currentPrice ||
|
|
||||||
Number(currentDiscountPrice) === Number(currentPrice)
|
|
||||||
? 0
|
|
||||||
: (
|
|
||||||
((Number(currentPrice) - Number(currentDiscountPrice)) /
|
|
||||||
Number(currentPrice)) *
|
|
||||||
100
|
|
||||||
).toFixed(2)
|
|
||||||
)}
|
|
||||||
product={{ ...product, isActive: productActiveStatus }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{hasAnyStock ? (
|
{/* Mobile: title + description */}
|
||||||
<>
|
<div className="lg:hidden">
|
||||||
{hasColorVariants && (
|
<ProductInfo
|
||||||
<ColorSelector
|
productDiscountPercent={Number(
|
||||||
colors={inStockColors.map(
|
!currentDiscountPrice ||
|
||||||
(color) => (color.info as unknown as { detail: string })?.detail
|
!currentPrice ||
|
||||||
|
Number(currentDiscountPrice) === Number(currentPrice)
|
||||||
|
? 0
|
||||||
|
: (
|
||||||
|
((Number(currentPrice) - Number(currentDiscountPrice)) /
|
||||||
|
Number(currentPrice)) *
|
||||||
|
100
|
||||||
|
).toFixed(2)
|
||||||
)}
|
)}
|
||||||
selectedColor={selectedColor}
|
product={{ ...product, isActive: productActiveStatus }}
|
||||||
setSelectedColor={setSelectedColor}
|
|
||||||
/>
|
/>
|
||||||
|
</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
|
||||||
|
)}
|
||||||
|
selectedColor={selectedColor}
|
||||||
|
setSelectedColor={setSelectedColor}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSizeVariants && (
|
||||||
|
<SizeSelector
|
||||||
|
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">
|
||||||
|
<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">
|
||||||
|
<Button
|
||||||
|
variant="dark"
|
||||||
|
size="sm"
|
||||||
|
className="text-B10 h-8 rounded-md bg-VITROWN_BLUE text-white"
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/product/${product.id}?mode=store`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye size={16} className="text-WHITE" />
|
||||||
|
دیدن محصول از نگاه مشتری
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasSizeVariants && (
|
{/* Desktop buy row + assurances */}
|
||||||
<SizeSelector
|
{!isOwner && (
|
||||||
sizes={inStockSizes.map((size) => size.value || "")}
|
<div className="hidden lg:block">
|
||||||
selectedSize={selectedSize}
|
<div className="flex items-center gap-3 mt-6">
|
||||||
setSelectedSize={setSelectedSize}
|
<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 className="flex flex-col gap-4 mt-10 px-4">
|
|
||||||
<p className="text-B18 font-bold">موجود نیست</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{isOwner && (
|
|
||||||
<div className="w-full p-4 flex justify-center items-center">
|
{/* Full-width below the fold: description, contact, reviews, similar */}
|
||||||
<Button
|
|
||||||
variant="dark"
|
|
||||||
size="sm"
|
|
||||||
className="text-B10 h-8 rounded-md bg-VITROWN_BLUE text-white"
|
|
||||||
onClick={() => {
|
|
||||||
navigate(`/product/${product.id}?mode=store`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Eye size={16} className="text-WHITE" />
|
|
||||||
دیدن محصول از نگاه مشتری
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{productActiveStatus && (
|
{productActiveStatus && (
|
||||||
<>
|
<>
|
||||||
{!isOwner && <ContactButton product={product} />}
|
<ProductDescription description={product.description} />
|
||||||
|
{!isOwner && (
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<ContactButton product={product} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ReviewSection productId={product.id || ""} isOwner={isOwner} />
|
<ReviewSection productId={product.id || ""} isOwner={isOwner} />
|
||||||
{/* Similar Products Section */}
|
{/* Similar Products Section */}
|
||||||
{!isOwner && (
|
{!isOwner && (
|
||||||
<section className="w-full gap-2 flex flex-col max-w-[100vw] my-6">
|
<section className="w-full gap-2 flex flex-col max-w-[100vw] my-6 lg:max-w-none">
|
||||||
<div className="w-full px-4">
|
<div className="w-full px-4 lg:px-0">
|
||||||
<p className="text-B16 font-bold">محصولات مشابه</p>
|
<p className="text-B16 lg:text-B24 font-bold">محصولات مشابه</p>
|
||||||
<p className="text-R14">محصولات مرتبط که ممکن است بپسندید</p>
|
<p className="text-R14 text-GRAY">
|
||||||
|
محصولات مرتبط که ممکن است بپسندید
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<MondrianProductList
|
<MondrianProductList
|
||||||
products={
|
products={
|
||||||
@ -323,23 +766,25 @@ export const ProductDetailView = memo(function ProductDetailView({
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{/* Bottom bar handles variant selection and pricing */}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isOwner && (
|
|
||||||
<BottomBar
|
{/* Desktop overlay/buy modals */}
|
||||||
currentPrice={currentPrice}
|
<LoginRequiredDialog
|
||||||
currentDiscountPrice={currentDiscountPrice}
|
loginTitle="برای ادامه ابتدا باید وارد حساب کاربری خود شوید."
|
||||||
currentStock={currentStock}
|
isOpen={isLoginOpen}
|
||||||
product={product}
|
onOpenChange={setIsLoginOpen}
|
||||||
selectedColor={
|
/>
|
||||||
inStockColors.find(
|
<AIPromoteModal
|
||||||
(color) =>
|
isOpen={isAIOpen}
|
||||||
(color.info as unknown as { detail: string })?.detail ===
|
onClose={() => setIsAIOpen(false)}
|
||||||
selectedColor
|
productId={product.id || ""}
|
||||||
)?.value || null
|
/>
|
||||||
}
|
{hasSizeChart && product.sizeChart && (
|
||||||
selectedSize={selectedSize}
|
<SizeGuideDialog
|
||||||
|
isOpen={isSizeGuideOpen}
|
||||||
|
onOpenChange={setIsSizeGuideOpen}
|
||||||
|
sizeChart={product.sizeChart}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -24,11 +24,14 @@ export const ProductHeader = ({ product }: ProductHeaderProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!product.seller?.id) return;
|
if (!product.seller?.id) return;
|
||||||
createChatThread(product.seller.id, {
|
createChatThread(
|
||||||
onSuccess: (threadData) => {
|
{ participantId: product.seller.id, productId: product.id },
|
||||||
navigate(`/profile/threads/${threadData.id}`);
|
{
|
||||||
},
|
onSuccess: (threadData) => {
|
||||||
});
|
navigate(`/profile/threads/${threadData.id}`);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -25,6 +25,8 @@ import {
|
|||||||
EyeOff,
|
EyeOff,
|
||||||
Eye,
|
Eye,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useDeleteProduct,
|
useDeleteProduct,
|
||||||
@ -44,6 +46,7 @@ interface ProductImageCarouselProps {
|
|||||||
isOwner?: boolean;
|
isOwner?: boolean;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
controlsIsOn?: boolean;
|
controlsIsOn?: boolean;
|
||||||
|
showArrows?: boolean;
|
||||||
onStatusChange?: (newStatus: boolean) => void;
|
onStatusChange?: (newStatus: boolean) => void;
|
||||||
isChangeStatusDialogOpen?: boolean;
|
isChangeStatusDialogOpen?: boolean;
|
||||||
setIsChangeStatusDialogOpen?: (open: boolean) => void;
|
setIsChangeStatusDialogOpen?: (open: boolean) => void;
|
||||||
@ -57,6 +60,7 @@ export const ProductImageCarousel = ({
|
|||||||
height,
|
height,
|
||||||
borderRadius,
|
borderRadius,
|
||||||
controlsIsOn = false,
|
controlsIsOn = false,
|
||||||
|
showArrows = false,
|
||||||
isOwner = false,
|
isOwner = false,
|
||||||
isActive = true,
|
isActive = true,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
@ -159,13 +163,15 @@ export const ProductImageCarousel = ({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={handleSettingsClick}
|
onClick={handleSettingsClick}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<EllipsisVertical size={20} className="text-WHITE2" />
|
<EllipsisVertical size={20} className="text-WHITE2" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => safeGoBack(navigate)}
|
onClick={() => safeGoBack(navigate)}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<ArrowRight size={20} className="text-WHITE2" />
|
<ArrowRight size={20} className="text-WHITE2" />
|
||||||
</button>
|
</button>
|
||||||
@ -274,6 +280,28 @@ export const ProductImageCarousel = ({
|
|||||||
</div>
|
</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 */}
|
{/* Settings Drawer */}
|
||||||
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export const ProductInfo = ({
|
|||||||
product,
|
product,
|
||||||
productDiscountPercent,
|
productDiscountPercent,
|
||||||
}: ProductInfoProps) => (
|
}: ProductInfoProps) => (
|
||||||
<div className="flex flex-col gap-2 mt-8 px-4">
|
<div className="flex flex-col gap-2 mt-8 px-4 lg:mt-2">
|
||||||
<div className="flex gap-3 items-center">
|
<div className="flex gap-3 items-center">
|
||||||
{productDiscountPercent && productDiscountPercent > 0 ? (
|
{productDiscountPercent && productDiscountPercent > 0 ? (
|
||||||
<div className="flex gap-[2px] items-center">
|
<div className="flex gap-[2px] items-center">
|
||||||
@ -23,8 +23,10 @@ export const ProductInfo = ({
|
|||||||
{productDiscountPercent && productDiscountPercent > 0 ? (
|
{productDiscountPercent && productDiscountPercent > 0 ? (
|
||||||
<div className="w-[6px] h-[6px] rounded-full bg-BLACK" />
|
<div className="w-[6px] h-[6px] rounded-full bg-BLACK" />
|
||||||
) : null}
|
) : null}
|
||||||
<p className="text-B18 font-bold">{product.title}</p>
|
<p className="text-B18 lg:text-[26px] font-bold">{product.title}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-R12 text-GRAY">{product.description}</p>
|
{/* 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. */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
80
app/components/product/SizeGuideDialog.tsx
Normal file
80
app/components/product/SizeGuideDialog.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
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;
|
||||||
@ -1,16 +1,33 @@
|
|||||||
|
import { Ruler } from "lucide-react";
|
||||||
|
|
||||||
interface SizeSelectorProps {
|
interface SizeSelectorProps {
|
||||||
sizes: string[] | undefined;
|
sizes: string[] | undefined;
|
||||||
selectedSize: string | null;
|
selectedSize: string | null;
|
||||||
setSelectedSize: (size: string) => void;
|
setSelectedSize: (size: string) => void;
|
||||||
|
/** When provided, shows a "راهنمای سایز" link that opens the size guide. */
|
||||||
|
onSizeGuide?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SizeSelector = ({
|
export const SizeSelector = ({
|
||||||
sizes,
|
sizes,
|
||||||
selectedSize,
|
selectedSize,
|
||||||
setSelectedSize,
|
setSelectedSize,
|
||||||
|
onSizeGuide,
|
||||||
}: SizeSelectorProps) => (
|
}: SizeSelectorProps) => (
|
||||||
<div className="flex flex-col gap-4 mt-10 px-4">
|
<div className="flex flex-col gap-4 mt-10 px-4">
|
||||||
<p className="text-B18 font-bold">انتخاب سایز</p>
|
<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">
|
<div className="gap-4 flex">
|
||||||
{sizes?.map((size: string, index: number) => (
|
{sizes?.map((size: string, index: number) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -2,6 +2,7 @@ export { ProductHeader } from "./ProductHeader";
|
|||||||
export { ProductImageCarousel } from "./ProductImageCarousel";
|
export { ProductImageCarousel } from "./ProductImageCarousel";
|
||||||
export { ProductActions } from "./ProductActions";
|
export { ProductActions } from "./ProductActions";
|
||||||
export { ProductInfo } from "./ProductInfo";
|
export { ProductInfo } from "./ProductInfo";
|
||||||
|
export { ProductDescription } from "./ProductDescription";
|
||||||
export { ColorSelector } from "./ColorSelector";
|
export { ColorSelector } from "./ColorSelector";
|
||||||
export { SizeSelector } from "./SizeSelector";
|
export { SizeSelector } from "./SizeSelector";
|
||||||
export { ContactButton } from "./ContactButton";
|
export { ContactButton } from "./ContactButton";
|
||||||
|
|||||||
227
app/components/seller/InlinePriceEditor.tsx
Normal file
227
app/components/seller/InlinePriceEditor.tsx
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
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;
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { Check, X } from "lucide-react";
|
import { Check, X } from "lucide-react";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { useColorAttributes } from "~/requestHandler/use-product-hooks";
|
import { useColorAttributes } from "~/requestHandler/use-product-hooks";
|
||||||
|
|
||||||
interface ColorSelectorProps {
|
interface ColorSelectorProps {
|
||||||
@ -9,6 +10,15 @@ interface ColorSelectorProps {
|
|||||||
onToggleUnselectedMode: (enabled: boolean) => void;
|
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 = ({
|
export const ColorSelector = ({
|
||||||
selectedColors,
|
selectedColors,
|
||||||
onToggleColor,
|
onToggleColor,
|
||||||
@ -17,15 +27,34 @@ export const ColorSelector = ({
|
|||||||
onToggleUnselectedMode,
|
onToggleUnselectedMode,
|
||||||
}: ColorSelectorProps) => {
|
}: ColorSelectorProps) => {
|
||||||
const { data: colors, isLoading } = useColorAttributes();
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="font-medium text-right">انتخاب رنگ های موجود:</h4>
|
<h4 className="font-medium text-right">انتخاب رنگ های موجود:</h4>
|
||||||
<div className="grid grid-cols-7 gap-3">
|
<div className="grid grid-cols-9 gap-1.5">
|
||||||
{[...Array(20)].map((_, i) => (
|
{[...Array(81)].map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="w-12 h-12 rounded-lg border-2 border-WHITE2 bg-WHITE3 animate-pulse"
|
className="aspect-square rounded-md border border-WHITE2 bg-WHITE3 animate-pulse"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -33,6 +62,20 @@ 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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="font-medium text-right">انتخاب رنگ های موجود:</h4>
|
<h4 className="font-medium text-right">انتخاب رنگ های موجود:</h4>
|
||||||
@ -54,59 +97,41 @@ export const ColorSelector = ({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Color Selection Grid - Disabled when unselected mode is on */}
|
{/* 9×9 palette: columns are hue families, rows go light -> dark. */}
|
||||||
<div
|
<div
|
||||||
className={`${unselectedMode ? "opacity-50 pointer-events-none" : ""}`}
|
className={`${unselectedMode ? "opacity-50 pointer-events-none" : ""}`}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-7 gap-1 flex-wrap">
|
<div className="grid grid-cols-9 gap-1.5">
|
||||||
{colors.map((color) => (
|
{sortedColors.map((color) => {
|
||||||
<>
|
const isSelected = selectedColors.includes(color.value);
|
||||||
{color.value !== "UNSELECTED" && (
|
const hex = color.info?.detail || "#000000";
|
||||||
<div
|
const dark = isDarkHex(hex);
|
||||||
key={color.id}
|
return (
|
||||||
className="flex flex-col items-center gap-1"
|
<button
|
||||||
>
|
key={color.id ?? color.value}
|
||||||
<button
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => !unselectedMode && onToggleColor(color.value)}
|
||||||
!unselectedMode && onToggleColor(color.value)
|
disabled={unselectedMode}
|
||||||
}
|
title={color.info?.persian || color.value}
|
||||||
disabled={unselectedMode}
|
aria-label={color.info?.persian || color.value}
|
||||||
className={`relative w-12 h-12 rounded-lg border-2 transition-all ${
|
aria-pressed={isSelected}
|
||||||
selectedColors.includes(color.value)
|
className={`relative aspect-square rounded-md border-2 transition-all ${
|
||||||
? "border-blue-500"
|
isSelected
|
||||||
: "border-WHITE2 hover:border-GRAY3"
|
? "border-VITROWN_BLUE shadow-md"
|
||||||
}`}
|
: "border-WHITE2 hover:border-GRAY3"
|
||||||
style={{ backgroundColor: color.info.detail }}
|
}`}
|
||||||
title={color.info?.persian}
|
style={{ backgroundColor: hex }}
|
||||||
>
|
>
|
||||||
{selectedColors.includes(color.value) && (
|
{isSelected && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<Check
|
<Check
|
||||||
className={`w-4 h-4 ${
|
className={`w-3.5 h-3.5 ${dark ? "text-WHITE" : "text-BLACK"}`}
|
||||||
color.info.detail === "#FFFFFF" ||
|
/>
|
||||||
color.info.detail === "#F5F5DC" ||
|
</div>
|
||||||
color.info.detail === "#FAFAFA" ||
|
)}
|
||||||
color.info.detail === "#F0F0F0"
|
</button>
|
||||||
? "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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -114,15 +139,16 @@ export const ColorSelector = ({
|
|||||||
{selectedColors.length > 0 && !unselectedMode && (
|
{selectedColors.length > 0 && !unselectedMode && (
|
||||||
<div className="flex flex-wrap gap-2 mt-3">
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
{selectedColors.map((colorValue) => {
|
{selectedColors.map((colorValue) => {
|
||||||
const colorInfo = colors.find((c) => c.value === colorValue);
|
const colorInfo = sortedColors.find((c) => c.value === colorValue);
|
||||||
|
const hex = colorInfo?.info?.detail || "#EEE";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={colorValue}
|
key={colorValue}
|
||||||
className="flex items-center gap-1 bg-WHITE3 px-3 py-1 rounded-full text-sm"
|
className="flex items-center gap-1.5 bg-WHITE3 px-3 py-1 rounded-full text-sm"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full border border-GRAY3"
|
className="w-3 h-3 rounded-full border border-GRAY3"
|
||||||
style={{ backgroundColor: colorValue }}
|
style={{ backgroundColor: hex }}
|
||||||
/>
|
/>
|
||||||
<span>{colorInfo?.info?.persian || colorValue}</span>
|
<span>{colorInfo?.info?.persian || colorValue}</span>
|
||||||
<X
|
<X
|
||||||
|
|||||||
@ -302,20 +302,30 @@ export function ImageEditor({
|
|||||||
const adjustedCropWidth = cropArea.width * scaleX;
|
const adjustedCropWidth = cropArea.width * scaleX;
|
||||||
const adjustedCropHeight = cropArea.height * scaleY;
|
const adjustedCropHeight = cropArea.height * scaleY;
|
||||||
|
|
||||||
// Set canvas size to crop area
|
// Crop rectangle expressed in the SOURCE image's native pixels
|
||||||
canvas.width = cropArea.width;
|
// (adjusted* already scales the on-screen crop back up to the original).
|
||||||
canvas.height = cropArea.height;
|
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));
|
||||||
|
|
||||||
// Clear canvas
|
// Clear canvas
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
// Draw the cropped portion
|
// Draw the cropped portion at native resolution
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
img,
|
img,
|
||||||
Math.max(0, adjustedCropX),
|
srcX,
|
||||||
Math.max(0, adjustedCropY),
|
srcY,
|
||||||
Math.min(adjustedCropWidth, img.width - Math.max(0, adjustedCropX)),
|
srcWidth,
|
||||||
Math.min(adjustedCropHeight, img.height - Math.max(0, adjustedCropY)),
|
srcHeight,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
canvas.width,
|
canvas.width,
|
||||||
@ -331,7 +341,7 @@ export function ImageEditor({
|
|||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
0.9
|
0.92
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export const ProductDetailsForm: React.FC<ProductDetailsFormProps> = ({
|
|||||||
<AppInput
|
<AppInput
|
||||||
inputTitle="عنوان محصول:"
|
inputTitle="عنوان محصول:"
|
||||||
inputValue={productTitle}
|
inputValue={productTitle}
|
||||||
inputPlaceholder="مثل: کفش آدیداس"
|
inputPlaceholder="مثل: تیشرت بیسیک"
|
||||||
inputOnChange={onTitleChange}
|
inputOnChange={onTitleChange}
|
||||||
inputDir="rtl"
|
inputDir="rtl"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -41,7 +41,6 @@ export const ProductVariantDrawer = ({
|
|||||||
Record<string, number>
|
Record<string, number>
|
||||||
>({});
|
>({});
|
||||||
const [colorUnselectedMode, setColorUnselectedMode] = useState(false);
|
const [colorUnselectedMode, setColorUnselectedMode] = useState(false);
|
||||||
const [sizeUnselectedMode, setSizeUnselectedMode] = useState(false);
|
|
||||||
|
|
||||||
const initializationDoneRef = useRef<boolean>(false);
|
const initializationDoneRef = useRef<boolean>(false);
|
||||||
const prevIsOpenRef = useRef<boolean>(false);
|
const prevIsOpenRef = useRef<boolean>(false);
|
||||||
@ -51,19 +50,15 @@ export const ProductVariantDrawer = ({
|
|||||||
// Only initialize when drawer opens for the first time (not on re-renders)
|
// Only initialize when drawer opens for the first time (not on re-renders)
|
||||||
if (isOpen && !prevIsOpenRef.current) {
|
if (isOpen && !prevIsOpenRef.current) {
|
||||||
if (initialVariants.length > 0) {
|
if (initialVariants.length > 0) {
|
||||||
// Check if any variant has unselected values
|
// Color still supports the "no color" escape hatch.
|
||||||
const hasUnselectedColor = initialVariants.some(
|
const hasUnselectedColor = initialVariants.some(
|
||||||
(v) => v.color === "unselected"
|
(v) => v.color === "unselected"
|
||||||
);
|
);
|
||||||
const hasUnselectedSize = initialVariants.some(
|
|
||||||
(v) => v.size === "unselected"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set unselected modes
|
|
||||||
setColorUnselectedMode(hasUnselectedColor);
|
setColorUnselectedMode(hasUnselectedColor);
|
||||||
setSizeUnselectedMode(hasUnselectedSize);
|
|
||||||
|
|
||||||
// Extract unique colors and sizes from initial variants (excluding 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).
|
||||||
const colors = [
|
const colors = [
|
||||||
...new Set(
|
...new Set(
|
||||||
initialVariants
|
initialVariants
|
||||||
@ -101,7 +96,6 @@ export const ProductVariantDrawer = ({
|
|||||||
setBulkQuantity("1");
|
setBulkQuantity("1");
|
||||||
setCustomQuantities({});
|
setCustomQuantities({});
|
||||||
setColorUnselectedMode(false);
|
setColorUnselectedMode(false);
|
||||||
setSizeUnselectedMode(false);
|
|
||||||
}
|
}
|
||||||
initializationDoneRef.current = true;
|
initializationDoneRef.current = true;
|
||||||
}
|
}
|
||||||
@ -114,7 +108,6 @@ export const ProductVariantDrawer = ({
|
|||||||
setBulkQuantity("1");
|
setBulkQuantity("1");
|
||||||
setCustomQuantities({});
|
setCustomQuantities({});
|
||||||
setColorUnselectedMode(false);
|
setColorUnselectedMode(false);
|
||||||
setSizeUnselectedMode(false);
|
|
||||||
initializationDoneRef.current = false;
|
initializationDoneRef.current = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,19 +129,14 @@ export const ProductVariantDrawer = ({
|
|||||||
if (
|
if (
|
||||||
JSON.stringify(currentVariantKeys) !== JSON.stringify(newVariantKeys)
|
JSON.stringify(currentVariantKeys) !== JSON.stringify(newVariantKeys)
|
||||||
) {
|
) {
|
||||||
// Check if any variant has unselected values
|
// Color still supports the "no color" escape hatch.
|
||||||
const hasUnselectedColor = initialVariants.some(
|
const hasUnselectedColor = initialVariants.some(
|
||||||
(v) => v.color === "unselected"
|
(v) => v.color === "unselected"
|
||||||
);
|
);
|
||||||
const hasUnselectedSize = initialVariants.some(
|
|
||||||
(v) => v.size === "unselected"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set unselected modes
|
|
||||||
setColorUnselectedMode(hasUnselectedColor);
|
setColorUnselectedMode(hasUnselectedColor);
|
||||||
setSizeUnselectedMode(hasUnselectedSize);
|
|
||||||
|
|
||||||
// Extract unique colors and sizes from initial variants (excluding unselected)
|
// Extract unique colors and sizes from initial variants (excluding unselected).
|
||||||
|
// Legacy "unselected" size variants are ignored so the seller must pick.
|
||||||
const colors = [
|
const colors = [
|
||||||
...new Set(
|
...new Set(
|
||||||
initialVariants
|
initialVariants
|
||||||
@ -247,19 +235,12 @@ export const ProductVariantDrawer = ({
|
|||||||
setCustomQuantities((prev) => ({ ...prev, ...newQuantities }));
|
setCustomQuantities((prev) => ({ ...prev, ...newQuantities }));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get variants for quantity display based on unselected modes
|
// Get variants for quantity display. Size is always required (FreeSize covers
|
||||||
|
// one-size products); color still has an unselected escape hatch.
|
||||||
const getQuantityVariants = () => {
|
const getQuantityVariants = () => {
|
||||||
const variants: { color: string; size: string; key: string }[] = [];
|
const variants: { color: string; size: string; key: string }[] = [];
|
||||||
|
|
||||||
if (colorUnselectedMode && sizeUnselectedMode) {
|
if (colorUnselectedMode) {
|
||||||
// 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) => {
|
selectedSizes.forEach((size) => {
|
||||||
variants.push({
|
variants.push({
|
||||||
color: "unselected",
|
color: "unselected",
|
||||||
@ -267,17 +248,7 @@ export const ProductVariantDrawer = ({
|
|||||||
key: `unselected-${size}`,
|
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 {
|
} else {
|
||||||
// Normal mode - show all combinations
|
|
||||||
selectedColors.forEach((color) => {
|
selectedColors.forEach((color) => {
|
||||||
selectedSizes.forEach((size) => {
|
selectedSizes.forEach((size) => {
|
||||||
variants.push({
|
variants.push({
|
||||||
@ -293,37 +264,22 @@ export const ProductVariantDrawer = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
// Check if at least one variant can be created
|
// Size is always required; use "فری سایز" for one-size products.
|
||||||
if (colorUnselectedMode && sizeUnselectedMode) {
|
if (selectedSizes.length === 0) {
|
||||||
// Both unselected - always valid
|
toast({
|
||||||
} else if (colorUnselectedMode && !sizeUnselectedMode) {
|
title: "خطا",
|
||||||
// Color unselected, need at least one size
|
description:
|
||||||
if (selectedSizes.length === 0) {
|
"لطفاً حداقل یک سایز انتخاب کنید. اگر محصول تکسایز است، «فری سایز» را انتخاب کنید.",
|
||||||
toast({
|
});
|
||||||
title: "خطا",
|
return;
|
||||||
description: "لطفاً حداقل یک سایز انتخاب کنید",
|
}
|
||||||
});
|
// Color is optional (colorUnselectedMode covers no-color products).
|
||||||
return;
|
if (!colorUnselectedMode && selectedColors.length === 0) {
|
||||||
}
|
toast({
|
||||||
} else if (!colorUnselectedMode && sizeUnselectedMode) {
|
title: "خطا",
|
||||||
// Size unselected, need at least one color
|
description: "لطفاً حداقل یک رنگ انتخاب کنید یا حالت بدون رنگ را فعال کنید",
|
||||||
if (selectedColors.length === 0) {
|
});
|
||||||
toast({
|
return;
|
||||||
title: "خطا",
|
|
||||||
description: "لطفاً حداقل یک رنگ انتخاب کنید",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Normal mode - need both color and size
|
|
||||||
if (selectedColors.length === 0 || selectedSizes.length === 0) {
|
|
||||||
toast({
|
|
||||||
title: "خطا",
|
|
||||||
description:
|
|
||||||
"لطفاً حداقل یک رنگ و یک سایز انتخاب کنید یا حالت بدون رنگ/سایز را فعال کنید",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setStep("quantities");
|
setStep("quantities");
|
||||||
};
|
};
|
||||||
@ -364,8 +320,6 @@ export const ProductVariantDrawer = ({
|
|||||||
selectedSizes={selectedSizes}
|
selectedSizes={selectedSizes}
|
||||||
onToggleSize={toggleSize}
|
onToggleSize={toggleSize}
|
||||||
onRemoveSize={removeSize}
|
onRemoveSize={removeSize}
|
||||||
unselectedMode={sizeUnselectedMode}
|
|
||||||
onToggleUnselectedMode={setSizeUnselectedMode}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -424,17 +378,14 @@ export const ProductVariantDrawer = ({
|
|||||||
(c) => c.value === color
|
(c) => c.value === color
|
||||||
);
|
);
|
||||||
|
|
||||||
// Helper function to get display text
|
// Helper function to get display text — colorless variants
|
||||||
|
// roll up under the size label; sized/colored variants show
|
||||||
|
// both.
|
||||||
const getDisplayText = () => {
|
const getDisplayText = () => {
|
||||||
if (colorUnselectedMode && sizeUnselectedMode) {
|
if (colorUnselectedMode) {
|
||||||
return "کل موجودی محصول";
|
|
||||||
} else if (colorUnselectedMode && !sizeUnselectedMode) {
|
|
||||||
return `موجودی سایز ${size}`;
|
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();
|
const displayText = getDisplayText();
|
||||||
|
|||||||
329
app/components/store/SizeChartSection.tsx
Normal file
329
app/components/store/SizeChartSection.tsx
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
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;
|
||||||
@ -5,16 +5,12 @@ interface SizeSelectorProps {
|
|||||||
selectedSizes: string[];
|
selectedSizes: string[];
|
||||||
onToggleSize: (size: string) => void;
|
onToggleSize: (size: string) => void;
|
||||||
onRemoveSize: (size: string) => void;
|
onRemoveSize: (size: string) => void;
|
||||||
unselectedMode: boolean;
|
|
||||||
onToggleUnselectedMode: (enabled: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SizeSelector: React.FC<SizeSelectorProps> = ({
|
export const SizeSelector: React.FC<SizeSelectorProps> = ({
|
||||||
selectedSizes,
|
selectedSizes,
|
||||||
onToggleSize,
|
onToggleSize,
|
||||||
onRemoveSize,
|
onRemoveSize,
|
||||||
unselectedMode,
|
|
||||||
onToggleUnselectedMode,
|
|
||||||
}: SizeSelectorProps) => {
|
}: SizeSelectorProps) => {
|
||||||
const { data: sizes, isLoading } = useSizeAttributes();
|
const { data: sizes, isLoading } = useSizeAttributes();
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@ -36,35 +32,17 @@ export const SizeSelector: React.FC<SizeSelectorProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="font-medium text-right">انتخاب سایز های موجود:</h4>
|
<h4 className="font-medium text-right">انتخاب سایز های موجود:</h4>
|
||||||
|
<p className="text-xs text-GRAY text-right">
|
||||||
|
اگر محصول تکسایز است، «فری سایز» را انتخاب کنید.
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Unselected Mode Switch */}
|
<div className="grid grid-cols-4 gap-2">
|
||||||
<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) => (
|
{sizes.map((size) => (
|
||||||
<>
|
<>
|
||||||
{size.value !== "UNSELECTED" && (
|
{size.value !== "UNSELECTED" && (
|
||||||
<button
|
<button
|
||||||
key={size.id}
|
key={size.id}
|
||||||
onClick={() => !unselectedMode && onToggleSize(size.value)}
|
onClick={() => onToggleSize(size.value)}
|
||||||
disabled={unselectedMode}
|
|
||||||
className={`py-3 px-4 rounded-lg border-2 text-sm font-medium transition-all ${
|
className={`py-3 px-4 rounded-lg border-2 text-sm font-medium transition-all ${
|
||||||
selectedSizes.includes(size.value)
|
selectedSizes.includes(size.value)
|
||||||
? "border-blue-500 bg-blue-50 text-blue-700"
|
? "border-blue-500 bg-blue-50 text-blue-700"
|
||||||
@ -79,7 +57,7 @@ export const SizeSelector: React.FC<SizeSelectorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selected Sizes */}
|
{/* Selected Sizes */}
|
||||||
{selectedSizes.length > 0 && !unselectedMode && (
|
{selectedSizes.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{selectedSizes.map((sizeValue) => {
|
{selectedSizes.map((sizeValue) => {
|
||||||
const sizeInfo = sizes.find((s) => s.value === sizeValue);
|
const sizeInfo = sizes.find((s) => s.value === sizeValue);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useNavigate } from "@remix-run/react";
|
import { useNavigate } from "@remix-run/react";
|
||||||
import { Loader2, Save, Store } from "lucide-react";
|
import { Loader2, Save, Store, Upload } from "lucide-react";
|
||||||
import ProfilePagesHeader from "../ProfilePagesHeader";
|
import ProfilePagesHeader from "../ProfilePagesHeader";
|
||||||
import HeaderWithSupport from "../HeaderWithSupport";
|
import HeaderWithSupport from "../HeaderWithSupport";
|
||||||
import AppInput from "../AppInput";
|
import AppInput from "../AppInput";
|
||||||
@ -9,6 +9,7 @@ import AppSelectBox from "../AppSelectBox";
|
|||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { FormField } from "../ui/FormField";
|
import { FormField } from "../ui/FormField";
|
||||||
import { ImageUpload } from "./ImageUpload";
|
import { ImageUpload } from "./ImageUpload";
|
||||||
|
import { convertHeicToJpeg } from "~/utils/convert-heic";
|
||||||
import { ImageUploadDrawer } from "./ImageUploadDrawer";
|
import { ImageUploadDrawer } from "./ImageUploadDrawer";
|
||||||
import { Textarea } from "../ui/textarea";
|
import { Textarea } from "../ui/textarea";
|
||||||
import { useToast } from "../../hooks/use-toast";
|
import { useToast } from "../../hooks/use-toast";
|
||||||
@ -79,8 +80,26 @@ interface FormData {
|
|||||||
selectedCity: string;
|
selectedCity: string;
|
||||||
instagramId: string;
|
instagramId: string;
|
||||||
storeLogo: 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 {
|
interface StoreFormProps {
|
||||||
mode: "create" | "edit";
|
mode: "create" | "edit";
|
||||||
storeId?: string;
|
storeId?: string;
|
||||||
@ -97,17 +116,31 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Check store ownership for edit mode
|
// Check store ownership for edit mode
|
||||||
const { isLoading: isOwnershipLoading } = useStoreOwnership(
|
const { isLoading: isOwnershipLoading, role } = useStoreOwnership(
|
||||||
mode === "edit" ? storeId : undefined,
|
mode === "edit" ? storeId : undefined,
|
||||||
mode
|
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
|
// Form state
|
||||||
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||||
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
||||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
const [hasChanges, setHasChanges] = 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
|
// Loading modal states
|
||||||
const [createSellerOpen, setCreateSellerOpen] = useState(false);
|
const [createSellerOpen, setCreateSellerOpen] = useState(false);
|
||||||
const [imageUploadOpen, setImageUploadOpen] = useState(false);
|
const [imageUploadOpen, setImageUploadOpen] = useState(false);
|
||||||
@ -121,6 +154,9 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
|
|||||||
selectedCity: "",
|
selectedCity: "",
|
||||||
instagramId: "",
|
instagramId: "",
|
||||||
storeLogo: "",
|
storeLogo: "",
|
||||||
|
bannerType: "color",
|
||||||
|
bannerColor: "",
|
||||||
|
bannerImage: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validation errors state
|
// Validation errors state
|
||||||
@ -156,6 +192,9 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
|
|||||||
selectedCity: "", // Not available in SellerUpdate
|
selectedCity: "", // Not available in SellerUpdate
|
||||||
instagramId: sellerData.instagramId || "",
|
instagramId: sellerData.instagramId || "",
|
||||||
storeLogo: sellerData.storeLogo || "",
|
storeLogo: sellerData.storeLogo || "",
|
||||||
|
bannerType: sellerData.bannerType || "color",
|
||||||
|
bannerColor: sellerData.bannerColor || "",
|
||||||
|
bannerImage: sellerData.bannerImage || "",
|
||||||
};
|
};
|
||||||
setFormData(initialData);
|
setFormData(initialData);
|
||||||
}
|
}
|
||||||
@ -169,11 +208,15 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
|
|||||||
formData.storeDescription !== (sellerData.storeDescription || "") ||
|
formData.storeDescription !== (sellerData.storeDescription || "") ||
|
||||||
formData.instagramId !== (sellerData.instagramId || "") ||
|
formData.instagramId !== (sellerData.instagramId || "") ||
|
||||||
formData.storeLogo !== (sellerData.storeLogo || "") ||
|
formData.storeLogo !== (sellerData.storeLogo || "") ||
|
||||||
selectedImage !== null; // Include image changes
|
formData.bannerType !== (sellerData.bannerType || "color") ||
|
||||||
|
formData.bannerColor !== (sellerData.bannerColor || "") ||
|
||||||
|
formData.bannerImage !== (sellerData.bannerImage || "") ||
|
||||||
|
selectedImage !== null || // Include image changes
|
||||||
|
selectedBannerImage !== null;
|
||||||
|
|
||||||
setHasChanges(hasFormChanges);
|
setHasChanges(hasFormChanges);
|
||||||
}
|
}
|
||||||
}, [mode, formData, sellerData, selectedImage]);
|
}, [mode, formData, sellerData, selectedImage, selectedBannerImage]);
|
||||||
|
|
||||||
// Redirect if user already has a store (create mode only)
|
// Redirect if user already has a store (create mode only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -195,6 +238,15 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
|
|||||||
};
|
};
|
||||||
}, [imagePreviewUrl]);
|
}, [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)
|
// Show loading while checking ownership or loading seller data (edit mode)
|
||||||
if (mode === "edit" && (isOwnershipLoading || isSellerLoading)) {
|
if (mode === "edit" && (isOwnershipLoading || isSellerLoading)) {
|
||||||
return (
|
return (
|
||||||
@ -294,9 +346,13 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
|
|||||||
!validationErrors.instagramId;
|
!validationErrors.instagramId;
|
||||||
|
|
||||||
// Handle image selection
|
// Handle image selection
|
||||||
const handleImageSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageSelect = async (
|
||||||
const file = event.target.files?.[0];
|
event: React.ChangeEvent<HTMLInputElement>
|
||||||
if (file) {
|
) => {
|
||||||
|
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);
|
||||||
if (imagePreviewUrl) {
|
if (imagePreviewUrl) {
|
||||||
URL.revokeObjectURL(imagePreviewUrl);
|
URL.revokeObjectURL(imagePreviewUrl);
|
||||||
}
|
}
|
||||||
@ -306,6 +362,24 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle banner image selection (from gallery; GIFs preserved)
|
||||||
|
const handleBannerSelect = async (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>
|
||||||
|
) => {
|
||||||
|
const picked = event.target.files?.[0];
|
||||||
|
if (picked) {
|
||||||
|
// convertHeicToJpeg only touches HEIC; GIF/PNG/JPG pass through untouched.
|
||||||
|
const file = await convertHeicToJpeg(picked);
|
||||||
|
if (bannerPreviewUrl) {
|
||||||
|
URL.revokeObjectURL(bannerPreviewUrl);
|
||||||
|
}
|
||||||
|
const newUrl = URL.createObjectURL(file);
|
||||||
|
setSelectedBannerImage(file);
|
||||||
|
setBannerPreviewUrl(newUrl);
|
||||||
|
setFormData((prev) => ({ ...prev, bannerType: "image" }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle drawer actions
|
// Handle drawer actions
|
||||||
const handleUploadClick = () => {
|
const handleUploadClick = () => {
|
||||||
setIsDrawerOpen(true);
|
setIsDrawerOpen(true);
|
||||||
@ -375,11 +449,38 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upload a newly selected banner image first (GIFs preserved by the API).
|
||||||
|
let bannerImageUrl = formData.bannerImage;
|
||||||
|
if (selectedBannerImage) {
|
||||||
|
try {
|
||||||
|
setImageUploadOpen(true);
|
||||||
|
const bannerResponse = await imageUploadMutation.mutateAsync({
|
||||||
|
file: selectedBannerImage,
|
||||||
|
usageType: "banner",
|
||||||
|
});
|
||||||
|
bannerImageUrl = bannerResponse.originalUrl || formData.bannerImage;
|
||||||
|
setImageUploadOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error uploading banner:", error);
|
||||||
|
setImageUploadOpen(false);
|
||||||
|
toast({
|
||||||
|
title: "خطا در آپلود بنر",
|
||||||
|
description:
|
||||||
|
"متاسفانه خطایی در آپلود بنر رخ داد. لطفا مجددا تلاش کنید.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updateData = {
|
const updateData = {
|
||||||
storeName: formData.storeName,
|
storeName: formData.storeName,
|
||||||
storeDescription: formData.storeDescription || null,
|
storeDescription: formData.storeDescription || null,
|
||||||
instagramId: formData.instagramId || null,
|
instagramId: formData.instagramId || null,
|
||||||
storeLogo: logoUrl || null,
|
storeLogo: logoUrl || null,
|
||||||
|
bannerType: formData.bannerType || "color",
|
||||||
|
bannerColor: formData.bannerColor || null,
|
||||||
|
bannerImage: bannerImageUrl || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
updateSellerMutation.mutate({
|
updateSellerMutation.mutate({
|
||||||
@ -504,6 +605,116 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Render banner editor (edit mode only): color swatches/picker or image/GIF
|
||||||
|
const renderBannerSection = () => {
|
||||||
|
const previewImage = bannerPreviewUrl || formData.bannerImage;
|
||||||
|
const previewColor = formData.bannerColor || DEFAULT_BANNER_COLOR;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-R14">بنر فروشگاه</p>
|
||||||
|
|
||||||
|
{/* Live preview */}
|
||||||
|
<div className="relative h-28 w-full rounded-[14px] overflow-hidden border border-WHITE2 bg-WHITE3">
|
||||||
|
{formData.bannerType === "image" && previewImage ? (
|
||||||
|
<img
|
||||||
|
src={previewImage}
|
||||||
|
alt="بنر فروشگاه"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-full h-full"
|
||||||
|
style={{ backgroundColor: previewColor }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type toggle */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleInputChange("bannerType", "color")}
|
||||||
|
className={`flex-1 h-10 rounded-[10px] border text-R12 ${
|
||||||
|
formData.bannerType === "color"
|
||||||
|
? "bg-BLACK text-WHITE border-BLACK"
|
||||||
|
: "bg-WHITE2 text-BLACK border-WHITE3"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
رنگ
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleInputChange("bannerType", "image")}
|
||||||
|
className={`flex-1 h-10 rounded-[10px] border text-R12 ${
|
||||||
|
formData.bannerType === "image"
|
||||||
|
? "bg-BLACK text-WHITE border-BLACK"
|
||||||
|
: "bg-WHITE2 text-BLACK border-WHITE3"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
تصویر
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.bannerType === "color" ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{BANNER_PRESETS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleInputChange("bannerColor", c)}
|
||||||
|
aria-label={c}
|
||||||
|
className={`w-8 h-8 rounded-full border-2 transition-all ${
|
||||||
|
(formData.bannerColor || "").toLowerCase() === c.toLowerCase()
|
||||||
|
? "border-BLACK scale-110"
|
||||||
|
: "border-WHITE2"
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* Custom color */}
|
||||||
|
<label
|
||||||
|
className="w-8 h-8 rounded-full border-2 border-dashed border-GRAY3 overflow-hidden flex items-center justify-center cursor-pointer"
|
||||||
|
aria-label="رنگ دلخواه"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={previewColor}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("bannerColor", e.target.value)
|
||||||
|
}
|
||||||
|
className="w-10 h-10 cursor-pointer border-0 bg-transparent p-0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => bannerFileInputRef.current?.click()}
|
||||||
|
className="w-full h-11 rounded-[10px] border-2 border-dashed border-GRAY3 flex items-center justify-center gap-2 text-GRAY text-R12 hover:border-primary hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Upload size={18} />
|
||||||
|
{previewImage ? "تغییر تصویر بنر" : "آپلود تصویر بنر"}
|
||||||
|
</button>
|
||||||
|
<p className="text-R10 text-BLACK2 leading-5">
|
||||||
|
اندازه پیشنهادی: ۱۶۰۰×۵۰۰ پیکسل. بنر تمامعرض نمایش داده میشود و
|
||||||
|
فقط ارتفاع آن برش میخورد؛ بخشهای مهم (متن/لوگو) را وسط قرار دهید.
|
||||||
|
فرمت JPG/PNG/WebP یا GIF متحرک.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={bannerFileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*,image/gif"
|
||||||
|
onChange={handleBannerSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Render description field based on mode
|
// Render description field based on mode
|
||||||
const renderDescriptionField = () => {
|
const renderDescriptionField = () => {
|
||||||
if (mode === "create") {
|
if (mode === "create") {
|
||||||
@ -615,16 +826,22 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
|
|||||||
|
|
||||||
const contentClass =
|
const contentClass =
|
||||||
mode === "create"
|
mode === "create"
|
||||||
? "flex flex-col gap-6 w-full px-4 pt-6"
|
? "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";
|
: "flex flex-col gap-6 p-4 bg-WHITE lg:max-w-[640px] lg:mx-auto lg:pt-8";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={containerClass}>
|
<div className={containerClass}>
|
||||||
{renderHeader()}
|
{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}>
|
<div className={contentClass}>
|
||||||
{renderLogoSection()}
|
{renderLogoSection()}
|
||||||
|
|
||||||
|
{/* Banner editor (edit mode) */}
|
||||||
|
{mode === "edit" && renderBannerSection()}
|
||||||
|
|
||||||
{/* Username Input - Only for create mode */}
|
{/* Username Input - Only for create mode */}
|
||||||
{mode === "create" && (
|
{mode === "create" && (
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
99
app/components/store/StorePushPromptCard.tsx
Normal file
99
app/components/store/StorePushPromptCard.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -17,10 +17,10 @@ const ChatHeader = ({
|
|||||||
const { theme } = useRootData();
|
const { theme } = useRootData();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<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">
|
<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">
|
||||||
<ArrowRight
|
<ArrowRight
|
||||||
onClick={() => safeGoBack(navigate)}
|
onClick={() => safeGoBack(navigate)}
|
||||||
className="absolute top-5 right-5"
|
className="absolute top-[calc(1.25rem_+_env(safe-area-inset-top))] right-5"
|
||||||
/>
|
/>
|
||||||
{isConnected ? (
|
{isConnected ? (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Reply, MoreVertical, Trash2, Check } from "lucide-react";
|
import { Reply, MoreVertical, Trash2, Check } from "lucide-react";
|
||||||
|
import { Link } from "@remix-run/react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@ -14,9 +15,17 @@ import { Button } from "../ui/button";
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
|
||||||
// Message bubble component
|
// 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 = ({
|
export const MessageBubble = ({
|
||||||
message,
|
message,
|
||||||
isCurrentUser,
|
isCurrentUser,
|
||||||
|
isCoworker = false,
|
||||||
onReply,
|
onReply,
|
||||||
onDelete,
|
onDelete,
|
||||||
replyToMessageContent,
|
replyToMessageContent,
|
||||||
@ -26,6 +35,7 @@ export const MessageBubble = ({
|
|||||||
}: {
|
}: {
|
||||||
message: MessageList;
|
message: MessageList;
|
||||||
isCurrentUser: boolean;
|
isCurrentUser: boolean;
|
||||||
|
isCoworker?: boolean;
|
||||||
onReply: (message: MessageList) => void;
|
onReply: (message: MessageList) => void;
|
||||||
onDelete?: (messageId: string) => void;
|
onDelete?: (messageId: string) => void;
|
||||||
replyToMessageContent?: string | undefined;
|
replyToMessageContent?: string | undefined;
|
||||||
@ -62,6 +72,11 @@ export const MessageBubble = ({
|
|||||||
isCurrentUser ? "bg-BLUE2 text-WHITE" : "bg-GRAY3"
|
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 && (
|
{message.replyTo && (
|
||||||
<div
|
<div
|
||||||
className={`text-xs italic mb-1 pb-1 border-b ${
|
className={`text-xs italic mb-1 pb-1 border-b ${
|
||||||
@ -81,7 +96,42 @@ export const MessageBubble = ({
|
|||||||
{replyToMessageContent || "پاسخ به پیام"}
|
{replyToMessageContent || "پاسخ به پیام"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p>{message.content}</p>
|
{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>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col justify-between items-start">
|
<div className="flex flex-col justify-between items-start">
|
||||||
<div className="flex flex-col gap-1 items-start">
|
<div className="flex flex-col gap-1 items-start">
|
||||||
@ -107,7 +157,7 @@ export const MessageBubble = ({
|
|||||||
<Reply size={14} />
|
<Reply size={14} />
|
||||||
<span className="text-R12">پاسخ</span>
|
<span className="text-R12">پاسخ</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{isCurrentUser && (
|
{isCurrentUser && !isCoworker && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowDropdownMenu(false);
|
setShowDropdownMenu(false);
|
||||||
|
|||||||
@ -1,12 +1,24 @@
|
|||||||
import { ArrowRight, Loader2, Reply, SmilePlus, X } from "lucide-react";
|
import { ArrowRight, Image as ImageIcon, Loader2, Reply, SmilePlus, X } from "lucide-react";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import EmojiPicker from "./EmojiPicker";
|
import EmojiPicker from "./EmojiPicker";
|
||||||
import type { MessageList } from "../../../src/api/types/models";
|
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
|
// 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 = ({
|
const MessageInput = ({
|
||||||
onSend,
|
onSend,
|
||||||
|
onSendImage,
|
||||||
|
canSendImage = false,
|
||||||
replyToMessage,
|
replyToMessage,
|
||||||
onCancelReply,
|
onCancelReply,
|
||||||
messageText,
|
messageText,
|
||||||
@ -15,6 +27,8 @@ const MessageInput = ({
|
|||||||
setReplyToMessage,
|
setReplyToMessage,
|
||||||
}: {
|
}: {
|
||||||
onSend: (text: string) => void;
|
onSend: (text: string) => void;
|
||||||
|
onSendImage?: (imageUrl: string) => void;
|
||||||
|
canSendImage?: boolean;
|
||||||
replyToMessage: MessageList | undefined;
|
replyToMessage: MessageList | undefined;
|
||||||
onCancelReply: () => void;
|
onCancelReply: () => void;
|
||||||
inputRef: React.RefObject<HTMLInputElement>;
|
inputRef: React.RefObject<HTMLInputElement>;
|
||||||
@ -23,6 +37,10 @@ const MessageInput = ({
|
|||||||
isSendingMessage: boolean;
|
isSendingMessage: boolean;
|
||||||
setReplyToMessage: (message: MessageList | undefined) => void;
|
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);
|
const [caretPosition, setCaretPosition] = useState<number | null>(null);
|
||||||
// Track current textarea height
|
// Track current textarea height
|
||||||
|
|
||||||
@ -77,6 +95,65 @@ const MessageInput = ({
|
|||||||
setMessageText(e.target.value);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Reply indicator */}
|
{/* Reply indicator */}
|
||||||
@ -91,7 +168,7 @@ const MessageInput = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="max-w-md mx-auto fixed px-2 bottom-0 py-2 w-full bg-WHITE">
|
<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">
|
||||||
{replyToMessage && (
|
{replyToMessage && (
|
||||||
<div className="bg-GRAY3 rounded-lg p-2 flex justify-between items-center mb-2">
|
<div className="bg-GRAY3 rounded-lg p-2 flex justify-between items-center mb-2">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@ -130,6 +207,31 @@ const MessageInput = ({
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</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
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={messageText}
|
value={messageText}
|
||||||
@ -142,16 +244,13 @@ 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"
|
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={{
|
style={{
|
||||||
paddingRight: "40px",
|
paddingRight: canSendImage ? "72px" : "40px",
|
||||||
paddingLeft: "48px",
|
paddingLeft: "48px",
|
||||||
direction: "rtl",
|
direction: "rtl",
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
// Enter inserts a newline. Send is via the button only — matches
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
// seller request: multi-line messages are common when replying to
|
||||||
e.preventDefault();
|
// a customer with details, and accidental Enter-sends were noisy.
|
||||||
handleSend();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
|
|||||||
@ -22,6 +22,8 @@ const MessageList = forwardRef(
|
|||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
userId,
|
userId,
|
||||||
|
teamUserIds,
|
||||||
|
viewerIsStore = false,
|
||||||
onReply,
|
onReply,
|
||||||
onDelete,
|
onDelete,
|
||||||
onScroll,
|
onScroll,
|
||||||
@ -31,6 +33,13 @@ const MessageList = forwardRef(
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
userId: string | undefined;
|
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;
|
onReply: (message: MessageListType) => void;
|
||||||
onDelete?: (messageId: string) => void;
|
onDelete?: (messageId: string) => void;
|
||||||
onScroll?: (event: React.UIEvent<HTMLDivElement>) => void;
|
onScroll?: (event: React.UIEvent<HTMLDivElement>) => void;
|
||||||
@ -163,7 +172,18 @@ const MessageList = forwardRef(
|
|||||||
<MessageBubble
|
<MessageBubble
|
||||||
otherParticipant={otherParticipant}
|
otherParticipant={otherParticipant}
|
||||||
message={message}
|
message={message}
|
||||||
isCurrentUser={message.sender === userId}
|
isCurrentUser={
|
||||||
|
message.sender === userId ||
|
||||||
|
(viewerIsStore &&
|
||||||
|
!!message.sender &&
|
||||||
|
!!teamUserIds?.includes(message.sender))
|
||||||
|
}
|
||||||
|
isCoworker={
|
||||||
|
viewerIsStore &&
|
||||||
|
message.sender !== userId &&
|
||||||
|
!!message.sender &&
|
||||||
|
!!teamUserIds?.includes(message.sender)
|
||||||
|
}
|
||||||
onReply={onReply}
|
onReply={onReply}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
replyToMessageContent={message.replyContent || ""}
|
replyToMessageContent={message.replyContent || ""}
|
||||||
|
|||||||
@ -80,6 +80,7 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
replyTo: newMessage.reply_to_id,
|
replyTo: newMessage.reply_to_id,
|
||||||
replyContent: newMessage.reply_content || "",
|
replyContent: newMessage.reply_content || "",
|
||||||
|
imageUrl: newMessage.image_url || undefined,
|
||||||
});
|
});
|
||||||
resetNewMessage();
|
resetNewMessage();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -219,6 +220,26 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
|
|||||||
setMessageText("");
|
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
|
// Handle reply to message
|
||||||
const handleReplyClick = (message: MessageListType) => {
|
const handleReplyClick = (message: MessageListType) => {
|
||||||
setReplyToMessage(message);
|
setReplyToMessage(message);
|
||||||
@ -254,7 +275,7 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`hide-scrollbar flex-grow w-full max-h-[calc(100vh-${replyToMessage ? "120px" : "75px"})] flex flex-col`}
|
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`}
|
||||||
>
|
>
|
||||||
{/* Messages list */}
|
{/* Messages list */}
|
||||||
<MessageList
|
<MessageList
|
||||||
@ -264,6 +285,8 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
|
|||||||
isLoading={isLoadingMessages || isLoadingThread}
|
isLoading={isLoadingMessages || isLoadingThread}
|
||||||
isFetching={isFetchingNextPage}
|
isFetching={isFetchingNextPage}
|
||||||
userId={user?.id}
|
userId={user?.id}
|
||||||
|
teamUserIds={threadDetails?.teamUserIds}
|
||||||
|
viewerIsStore={threadDetails?.viewerIsStore}
|
||||||
onReply={handleReplyClick}
|
onReply={handleReplyClick}
|
||||||
onDelete={handleDeleteMessage}
|
onDelete={handleDeleteMessage}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
@ -276,6 +299,8 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
|
|||||||
messageText={messageText}
|
messageText={messageText}
|
||||||
setMessageText={setMessageText}
|
setMessageText={setMessageText}
|
||||||
onSend={handleSendMessage}
|
onSend={handleSendMessage}
|
||||||
|
onSendImage={handleSendImage}
|
||||||
|
canSendImage={currentUserIsTeam}
|
||||||
replyToMessage={replyToMessage}
|
replyToMessage={replyToMessage}
|
||||||
onCancelReply={() => setReplyToMessage(undefined)}
|
onCancelReply={() => setReplyToMessage(undefined)}
|
||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
import { Link } from "@remix-run/react";
|
import { Link } from "@remix-run/react";
|
||||||
import AppInput from "../AppInput";
|
import AppInput from "../AppInput";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useGetUserThreads } from "../../requestHandler/use-chat-hooks";
|
import {
|
||||||
|
useGetUserThreads,
|
||||||
|
useGetStoreThreads,
|
||||||
|
} from "../../requestHandler/use-chat-hooks";
|
||||||
import {
|
import {
|
||||||
ThreadList,
|
ThreadList,
|
||||||
ThreadParticipant,
|
ThreadParticipant,
|
||||||
@ -10,9 +13,33 @@ import {
|
|||||||
import { useServiceGetProfile } from "../../requestHandler/use-profile-hooks";
|
import { useServiceGetProfile } from "../../requestHandler/use-profile-hooks";
|
||||||
import UiProvider from "../UiProvider";
|
import UiProvider from "../UiProvider";
|
||||||
import SellerLogo from "../SellerLogo";
|
import SellerLogo from "../SellerLogo";
|
||||||
import { Search, User } from "lucide-react";
|
import { CheckCheck, MessageCircleWarning, Search, User } from "lucide-react";
|
||||||
import ProfilePagesHeader from "../ProfilePagesHeader";
|
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 {
|
interface ThreadsPageProps {
|
||||||
type: "user" | "store";
|
type: "user" | "store";
|
||||||
storeId?: string;
|
storeId?: string;
|
||||||
@ -20,7 +47,16 @@ interface ThreadsPageProps {
|
|||||||
|
|
||||||
export default function ThreadsPage({ type, storeId }: ThreadsPageProps) {
|
export default function ThreadsPage({ type, storeId }: ThreadsPageProps) {
|
||||||
const [searchValue, setSearchValue] = useState("");
|
const [searchValue, setSearchValue] = useState("");
|
||||||
const { data: threads, isLoading, isError, refetch } = useGetUserThreads();
|
// 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 sortedThreads = threads?.sort((a, b) => {
|
const sortedThreads = threads?.sort((a, b) => {
|
||||||
const dateA = new Date(a.lastMessageTime || "2025-01-18T00:00:00.000Z");
|
const dateA = new Date(a.lastMessageTime || "2025-01-18T00:00:00.000Z");
|
||||||
const dateB = new Date(b.lastMessageTime || "2025-01-18T00:00:00.000Z");
|
const dateB = new Date(b.lastMessageTime || "2025-01-18T00:00:00.000Z");
|
||||||
@ -97,6 +133,24 @@ const ThreadsList = ({
|
|||||||
);
|
);
|
||||||
const hasUnread = Number(thread.unreadCount || "0") > 0;
|
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
|
// Generate link based on type
|
||||||
const linkTo =
|
const linkTo =
|
||||||
type === "user"
|
type === "user"
|
||||||
@ -116,22 +170,32 @@ const ThreadsList = ({
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-col w-full">
|
<div className="flex flex-col w-full">
|
||||||
<div className="w-full flex justify-between items-center">
|
<div className="w-full flex justify-between items-center">
|
||||||
<p className={`text-R14 ${hasUnread ? "font-bold" : ""}`}>
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
{thread.isCreator
|
{status === "needs-reply" && (
|
||||||
? otherParticipant?.storeName ||
|
<MessageCircleWarning
|
||||||
otherParticipant?.username
|
size={16}
|
||||||
: otherParticipant?.name}
|
className="text-RED shrink-0"
|
||||||
</p>
|
aria-label="در انتظار پاسخ شما"
|
||||||
<div className="flex flex-col items-center justify-center">
|
/>
|
||||||
|
)}
|
||||||
|
{status === "seen" && (
|
||||||
|
<CheckCheck
|
||||||
|
size={16}
|
||||||
|
className="text-blue-500 shrink-0"
|
||||||
|
aria-label="پیام دیده شده"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className={`text-R14 truncate ${hasUnread || status === "needs-reply" ? "font-bold" : ""}`}>
|
||||||
|
{thread.isCreator
|
||||||
|
? otherParticipant?.storeName ||
|
||||||
|
otherParticipant?.username
|
||||||
|
: otherParticipant?.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end justify-center">
|
||||||
{thread.lastMessageTime && (
|
{thread.lastMessageTime && (
|
||||||
<p className="text-R10 text-GRAY">
|
<p className="text-R10 text-GRAY">
|
||||||
{new Date(thread.lastMessageTime).toLocaleTimeString(
|
{formatThreadTimestamp(thread.lastMessageTime)}
|
||||||
"fa-IR",
|
|
||||||
{
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{hasUnread && (
|
{hasUnread && (
|
||||||
@ -144,7 +208,12 @@ const ThreadsList = ({
|
|||||||
<p
|
<p
|
||||||
className={`text-R14 ${hasUnread ? "text-BLACK font-bold" : "text-GRAY"}`}
|
className={`text-R14 ${hasUnread ? "text-BLACK font-bold" : "text-GRAY"}`}
|
||||||
>
|
>
|
||||||
{thread.lastMessage?.content || "بدون پیام"}
|
{thread.lastMessage?.content ||
|
||||||
|
(thread.lastMessage?.imageUrl
|
||||||
|
? "📷 تصویر"
|
||||||
|
: thread.lastMessage?.product
|
||||||
|
? `🛍 ${thread.lastMessage.product.title || "محصول"}`
|
||||||
|
: "بدون پیام")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
76
app/hooks/useBadgeCounts.ts
Normal file
76
app/hooks/useBadgeCounts.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
254
app/hooks/usePushNotifications.ts
Normal file
254
app/hooks/usePushNotifications.ts
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
23
app/hooks/useRequireAdmin.ts
Normal file
23
app/hooks/useRequireAdmin.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@ -1,46 +1,71 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useNavigate } from "@remix-run/react";
|
import { useNavigate } from "@remix-run/react";
|
||||||
import { useServiceGetProfile } from "../requestHandler/use-profile-hooks";
|
import { useMyStores } from "../requestHandler/use-seller-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) {
|
export function useStoreOwnership(storeId: string | undefined, mode?: string) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: profileData, isLoading } = useServiceGetProfile();
|
const { data: stores, isLoading, isFetching, isError } = useMyStores();
|
||||||
|
|
||||||
|
const current = stores?.find(
|
||||||
|
(s) => s.username?.toLowerCase() === storeId?.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Wait for profile data to load
|
if (isLoading || isFetching || isError || mode === "create") return;
|
||||||
if (isLoading) return;
|
if (!storeId) {
|
||||||
|
|
||||||
// If no storeId provided, redirect to home
|
|
||||||
if (!storeId && mode !== "create") {
|
|
||||||
navigate("/");
|
navigate("/");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!stores || stores.length === 0) return; // nothing to compare against yet
|
||||||
// If user doesn't have profile data, redirect to home
|
if (!current) {
|
||||||
if (!profileData) {
|
// The user is a member of at least one OTHER store — send them there.
|
||||||
navigate("/");
|
// If they're not a member of any store, do NOT bounce to home: staying
|
||||||
return;
|
// 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,
|
||||||
|
]);
|
||||||
|
|
||||||
// If user doesn't have a seller account, redirect to home
|
const ownedOrFirst = stores?.find((s) => s.role === "owner") ?? stores?.[0];
|
||||||
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 {
|
return {
|
||||||
isOwner: profileData?.sellerUsername === storeId,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
userStoreId: profileData?.sellerUsername,
|
isMember: !!current,
|
||||||
|
role: current?.role ?? null,
|
||||||
|
isOwner: current?.role === "owner",
|
||||||
|
userStoreId: ownedOrFirst?.username,
|
||||||
|
stores: stores ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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" };
|
||||||
|
}
|
||||||
|
|||||||
@ -69,8 +69,8 @@ const useWebSocket = (threadId: string, userId: string | undefined) => {
|
|||||||
const sendWebSocketMessage = (
|
const sendWebSocketMessage = (
|
||||||
content: string,
|
content: string,
|
||||||
replyToId?: string | null,
|
replyToId?: string | null,
|
||||||
// messageId?: string | null,
|
replyContent?: string | null,
|
||||||
replyContent?: string | null
|
imageUrl?: string | null,
|
||||||
) => {
|
) => {
|
||||||
if (!socket || socket.readyState !== WebSocket.OPEN || !userId) {
|
if (!socket || socket.readyState !== WebSocket.OPEN || !userId) {
|
||||||
console.error("WebSocket is not connected");
|
console.error("WebSocket is not connected");
|
||||||
@ -80,10 +80,9 @@ const useWebSocket = (threadId: string, userId: string | undefined) => {
|
|||||||
const message: SendMessagePayload = {
|
const message: SendMessagePayload = {
|
||||||
type: "new_message",
|
type: "new_message",
|
||||||
content,
|
content,
|
||||||
// sender_id: userId, // Remove this as it's not needed in client->server communication
|
|
||||||
...(replyToId && { reply_to_id: replyToId }),
|
...(replyToId && { reply_to_id: replyToId }),
|
||||||
...(replyContent && { reply_content: replyContent }),
|
...(replyContent && { reply_content: replyContent }),
|
||||||
// ...(messageId && { message_id: messageId }),
|
...(imageUrl && { image_url: imageUrl }),
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.send(JSON.stringify(message));
|
socket.send(JSON.stringify(message));
|
||||||
@ -148,6 +147,7 @@ interface SendMessagePayload extends WebSocketMessage {
|
|||||||
content: string;
|
content: string;
|
||||||
reply_to_id?: string | null;
|
reply_to_id?: string | null;
|
||||||
reply_content?: string | null;
|
reply_content?: string | null;
|
||||||
|
image_url?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MarkReadPayload extends WebSocketMessage {
|
interface MarkReadPayload extends WebSocketMessage {
|
||||||
@ -162,6 +162,7 @@ interface NewMessageEvent extends WebSocketMessage {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
reply_to_id?: string | null;
|
reply_to_id?: string | null;
|
||||||
reply_content?: string | null;
|
reply_content?: string | null;
|
||||||
|
image_url?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReadReceiptEvent extends WebSocketMessage {
|
interface ReadReceiptEvent extends WebSocketMessage {
|
||||||
|
|||||||
71
app/lib/sizeChartSuggestions.ts
Normal file
71
app/lib/sizeChartSuggestions.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
818
app/requestHandler/use-admin-hooks.ts
Normal file
818
app/requestHandler/use-admin-hooks.ts
Normal file
@ -0,0 +1,818 @@
|
|||||||
|
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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -31,6 +31,30 @@ 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
|
* Server-side function to get thread details
|
||||||
*/
|
*/
|
||||||
@ -182,15 +206,21 @@ export function useGetThreadMessages(threadId: string) {
|
|||||||
export function useCreateChatThread() {
|
export function useCreateChatThread() {
|
||||||
const token = useAuthToken();
|
const token = useAuthToken();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (participantId: string) => {
|
mutationFn: async (
|
||||||
|
input: string | { participantId: string; productId?: string }
|
||||||
|
) => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error("Authentication token and user ID are required");
|
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 {
|
try {
|
||||||
const chatApi = createChatApi(token);
|
const chatApi = createChatApi(token);
|
||||||
return await chatApi.apiChatV1ThreadsCreateCreate({
|
return await chatApi.apiChatV1ThreadsCreateCreate({
|
||||||
data: {
|
data: {
|
||||||
participantId: participantId,
|
participantId,
|
||||||
|
...(productId ? { productId } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -75,7 +75,7 @@ export function useGetHomeDiscounts() {
|
|||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes cache
|
staleTime: 60 * 1000, // 60s — prices/discounts must stay reasonably fresh
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -114,7 +114,7 @@ export function useGetHomeForYou() {
|
|||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes cache
|
staleTime: 60 * 1000, // 60s — prices/discounts must stay reasonably fresh
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -153,7 +153,7 @@ export function useGetHomeTopSellers() {
|
|||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes cache
|
staleTime: 60 * 1000, // 60s — prices/discounts must stay reasonably fresh
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,10 @@ import {
|
|||||||
SellerProductListItem,
|
SellerProductListItem,
|
||||||
VariantStockUpdateRequest,
|
VariantStockUpdateRequest,
|
||||||
VariantStockUpdateResponse,
|
VariantStockUpdateResponse,
|
||||||
|
ProductBasePriceUpdateRequest,
|
||||||
|
ProductBasePriceUpdateResponse,
|
||||||
|
VariantPriceUpdateRequest,
|
||||||
|
VariantPriceUpdateResponse,
|
||||||
PaginatedSimilarProduct,
|
PaginatedSimilarProduct,
|
||||||
} from "../../src/api/types";
|
} from "../../src/api/types";
|
||||||
import { useToast } from "../hooks/use-toast";
|
import { useToast } from "../hooks/use-toast";
|
||||||
@ -366,6 +370,107 @@ 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
|
* Custom hook to fetch similar products for a given product ID
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -15,6 +15,109 @@ import {
|
|||||||
createFollowsManagementApi,
|
createFollowsManagementApi,
|
||||||
createSellerManagementApi,
|
createSellerManagementApi,
|
||||||
} from "~/utils/api-client-factory";
|
} 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
|
* Custom hook to fetch seller data by sellerId using React Query
|
||||||
@ -148,6 +251,13 @@ export const useGetSellerProducts = (
|
|||||||
},
|
},
|
||||||
initialPageParam: 1,
|
initialPageParam: 1,
|
||||||
enabled: !!sellerId,
|
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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -259,6 +369,9 @@ export const useSellerUpdate = () => {
|
|||||||
storeDescription?: string | null;
|
storeDescription?: string | null;
|
||||||
storeLogo?: string | null;
|
storeLogo?: string | null;
|
||||||
instagramId?: string | null;
|
instagramId?: string | null;
|
||||||
|
bannerType?: string | null;
|
||||||
|
bannerColor?: string | null;
|
||||||
|
bannerImage?: string | null;
|
||||||
};
|
};
|
||||||
}) => {
|
}) => {
|
||||||
if (!token) throw new Error("Authentication required");
|
if (!token) throw new Error("Authentication required");
|
||||||
|
|||||||
69
app/requestHandler/use-view-hooks.ts
Normal file
69
app/requestHandler/use-view-hooks.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
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]);
|
||||||
|
}
|
||||||
@ -136,6 +136,11 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
/>
|
/>
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta name="color-scheme" content="light dark" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<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
|
<meta
|
||||||
name="apple-mobile-web-app-status-bar-style"
|
name="apple-mobile-web-app-status-bar-style"
|
||||||
content="black-translucent"
|
content="black-translucent"
|
||||||
|
|||||||
@ -75,32 +75,82 @@ export default function HomePage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col pb-12">
|
<div className="flex flex-col pb-12">
|
||||||
<HomePageTopBar />
|
<div className="lg:hidden">
|
||||||
|
<HomePageTopBar />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Adding padding to account for fixed header */}
|
{/* Adding padding to account for fixed header */}
|
||||||
<div className="flex flex-col gap-10">
|
<div className="flex flex-col gap-10 lg:gap-14 lg:max-w-[1320px] lg:w-full lg:mx-auto lg:px-8">
|
||||||
{/* Banners Section */}
|
{/* Hero: mobile = full-bleed banner carousel; desktop = main + 2 promos */}
|
||||||
<div className="w-full mt-2 h-[260px]">
|
<div className="w-full mt-2 lg:mt-7 lg:grid lg:grid-cols-3 lg:gap-4">
|
||||||
{isLoadingBanners ? (
|
<div className="h-[260px] lg:h-[408px] lg:col-span-2 lg:rounded-[22px] lg:overflow-hidden lg:shadow-md">
|
||||||
<Skeleton className="w-full h-full" />
|
{isLoadingBanners ? (
|
||||||
) : (
|
<Skeleton className="w-full h-full" />
|
||||||
<ProductImageCarousel
|
) : (
|
||||||
images={banners?.map((banner) => banner.imageUrl || "") || []}
|
<ProductImageCarousel
|
||||||
hideIndex={true}
|
images={banners?.map((banner) => banner.imageUrl || "") || []}
|
||||||
borderRadius={0}
|
hideIndex={true}
|
||||||
/>
|
borderRadius={0}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
id="section1"
|
id="section1"
|
||||||
className="w-full gap-2 flex flex-col max-w-[100vw]"
|
className="w-full gap-2 flex flex-col max-w-[100vw] lg:max-w-none lg:gap-5"
|
||||||
>
|
>
|
||||||
<div className="w-full px-4">
|
<div className="w-full px-4 lg:px-0 flex items-center justify-between">
|
||||||
<p className="text-B16 font-bold">تخفیفها</p>
|
<div>
|
||||||
<p className="text-R14">
|
<p className="text-B16 lg:text-B24 font-bold">تخفیفها</p>
|
||||||
فرصتهایی محدود برای انتخابهای هوشمندانه
|
<p className="text-R14 lg:text-R16 text-GRAY">
|
||||||
</p>
|
فرصتهایی محدود برای انتخابهای هوشمندانه
|
||||||
|
</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>
|
</div>
|
||||||
<HorizontalProductList
|
<HorizontalProductList
|
||||||
products={
|
products={
|
||||||
@ -113,6 +163,9 @@ export default function HomePage() {
|
|||||||
discountPercent: product?.discountPercent || 0,
|
discountPercent: product?.discountPercent || 0,
|
||||||
discountPrice: product?.discountPrice || 0,
|
discountPrice: product?.discountPrice || 0,
|
||||||
basePrice: product?.basePrice || 0,
|
basePrice: product?.basePrice || 0,
|
||||||
|
sellerName: product?.sellerName,
|
||||||
|
sellerLogo: product?.sellerLogo,
|
||||||
|
sellerUsername: product?.sellerUsername,
|
||||||
})) || []
|
})) || []
|
||||||
}
|
}
|
||||||
isLoading={isLoadingDiscounts}
|
isLoading={isLoadingDiscounts}
|
||||||
@ -122,12 +175,12 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
<section
|
<section
|
||||||
id="section2"
|
id="section2"
|
||||||
className="w-full gap-2 flex flex-col max-w-[100vw]"
|
className="w-full gap-2 flex flex-col max-w-[100vw] lg:max-w-none lg:gap-5"
|
||||||
>
|
>
|
||||||
<div className="w-full px-4 flex items-center justify-between">
|
<div className="w-full px-4 lg:px-0 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-B16 font-bold">برترین فروشگاهها</p>
|
<p className="text-B16 lg:text-B24 font-bold">برترین فروشگاهها</p>
|
||||||
<p className="text-R14">
|
<p className="text-R14 lg:text-R16 text-GRAY">
|
||||||
برگرفته از بالاترین سطح رضایت کاربران
|
برگرفته از بالاترین سطح رضایت کاربران
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -152,11 +205,13 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
<section
|
<section
|
||||||
id="collections"
|
id="collections"
|
||||||
className="w-full gap-2 flex flex-col max-w-[100vw]"
|
className="w-full gap-2 flex flex-col max-w-[100vw] lg:max-w-none lg:gap-5"
|
||||||
>
|
>
|
||||||
<div className="w-full px-4">
|
<div className="w-full px-4 lg:px-0">
|
||||||
<p className="text-B16 font-bold">کالکشنها</p>
|
<p className="text-B16 lg:text-B24 font-bold">کالکشنها</p>
|
||||||
<p className="text-R14">مجموعههای منتخب از بهترین محصولات</p>
|
<p className="text-R14 lg:text-R16 text-GRAY">
|
||||||
|
مجموعههای منتخب از بهترین محصولات
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<HorizontalCollectionsList
|
<HorizontalCollectionsList
|
||||||
collections={
|
collections={
|
||||||
@ -176,11 +231,13 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
<section
|
<section
|
||||||
id="section3"
|
id="section3"
|
||||||
className="w-full gap-2 flex flex-col max-w-[100vw]"
|
className="w-full gap-2 flex flex-col max-w-[100vw] lg:max-w-none lg:gap-5"
|
||||||
>
|
>
|
||||||
<div className="w-full px-4">
|
<div className="w-full px-4 lg:px-0">
|
||||||
<p className="text-B16 font-bold">برای شما</p>
|
<p className="text-B16 lg:text-B24 font-bold">برای شما</p>
|
||||||
<p className="text-R14">با الهام از انتخابهای شما</p>
|
<p className="text-R14 lg:text-R16 text-GRAY">
|
||||||
|
با الهام از انتخابهای شما
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<MondrianProductList
|
<MondrianProductList
|
||||||
products={
|
products={
|
||||||
@ -193,6 +250,9 @@ export default function HomePage() {
|
|||||||
discountPercent: product?.discountPercent || 0,
|
discountPercent: product?.discountPercent || 0,
|
||||||
discountPrice: product?.discountPrice || 0,
|
discountPrice: product?.discountPrice || 0,
|
||||||
basePrice: product?.basePrice || 0,
|
basePrice: product?.basePrice || 0,
|
||||||
|
sellerName: product?.sellerName,
|
||||||
|
sellerLogo: product?.sellerLogo,
|
||||||
|
sellerUsername: product?.sellerUsername,
|
||||||
})) || []
|
})) || []
|
||||||
}
|
}
|
||||||
fetchNextPage={fetchNextForYou}
|
fetchNextPage={fetchNextForYou}
|
||||||
@ -233,7 +293,9 @@ export const meta: MetaFunction = () => {
|
|||||||
},
|
},
|
||||||
{ name: "author", content: "Vitrown" },
|
{ name: "author", content: "Vitrown" },
|
||||||
{ name: "robots", content: "index, follow" },
|
{ name: "robots", content: "index, follow" },
|
||||||
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
// 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.
|
||||||
|
|
||||||
// Open Graph
|
// Open Graph
|
||||||
{ property: "og:type", content: "website" },
|
{ property: "og:type", content: "website" },
|
||||||
|
|||||||
153
app/routes/admin._index.tsx
Normal file
153
app/routes/admin._index.tsx
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
app/routes/admin.ai-health.tsx
Normal file
182
app/routes/admin.ai-health.tsx
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
app/routes/admin.audit.tsx
Normal file
166
app/routes/admin.audit.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
247
app/routes/admin.catalog.tsx
Normal file
247
app/routes/admin.catalog.tsx
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
app/routes/admin.collections.tsx
Normal file
153
app/routes/admin.collections.tsx
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
335
app/routes/admin.discounts.tsx
Normal file
335
app/routes/admin.discounts.tsx
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
app/routes/admin.homepage.tsx
Normal file
219
app/routes/admin.homepage.tsx
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
app/routes/admin.ledger._index.tsx
Normal file
148
app/routes/admin.ledger._index.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
app/routes/admin.ledger.journal.tsx
Normal file
156
app/routes/admin.ledger.journal.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
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"
|
||||||
|
}`;
|
||||||
|
}
|
||||||
163
app/routes/admin.ledger.payouts.tsx
Normal file
163
app/routes/admin.ledger.payouts.tsx
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
app/routes/admin.ledger.sellers.$id.tsx
Normal file
203
app/routes/admin.ledger.sellers.$id.tsx
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
app/routes/admin.ledger.sellers.tsx
Normal file
95
app/routes/admin.ledger.sellers.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
app/routes/admin.ledger.transactions.tsx
Normal file
102
app/routes/admin.ledger.transactions.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
app/routes/admin.ledger.tsx
Normal file
41
app/routes/admin.ledger.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
app/routes/admin.moderation.tsx
Normal file
228
app/routes/admin.moderation.tsx
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
321
app/routes/admin.notifications.tsx
Normal file
321
app/routes/admin.notifications.tsx
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
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";
|
||||||
150
app/routes/admin.orders.tsx
Normal file
150
app/routes/admin.orders.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
app/routes/admin.products.tsx
Normal file
160
app/routes/admin.products.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
app/routes/admin.sellers.tsx
Normal file
193
app/routes/admin.sellers.tsx
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
app/routes/admin.tsx
Normal file
160
app/routes/admin.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
app/routes/admin.users.tsx
Normal file
167
app/routes/admin.users.tsx
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -262,63 +262,67 @@ export default function CartDetail() {
|
|||||||
isError={isError}
|
isError={isError}
|
||||||
onRetry={refetch}
|
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">
|
||||||
{step === 0 && (
|
<div className="flex flex-col w-full">
|
||||||
<div className="flex flex-col w-full">
|
{step === 0 && (
|
||||||
{cart?.items?.map((item, index) => (
|
<div className="flex flex-col w-full">
|
||||||
<CartItem
|
{cart?.items?.map((item, index) => (
|
||||||
key={item.id}
|
<CartItem
|
||||||
item={item}
|
key={item.id}
|
||||||
onQuantityChange={handleQuantityChange}
|
item={item}
|
||||||
onRemoveItem={handleRemoveItem}
|
onQuantityChange={handleQuantityChange}
|
||||||
isLoading={removeMutation.isPending}
|
onRemoveItem={handleRemoveItem}
|
||||||
hasBorder={
|
isLoading={removeMutation.isPending}
|
||||||
!!(cart?.items && index !== cart?.items?.length - 1)
|
hasBorder={
|
||||||
}
|
!!(cart?.items && index !== cart?.items?.length - 1)
|
||||||
/>
|
}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
{step === 1 && (
|
)}
|
||||||
<ShippingInformation
|
{step === 1 && (
|
||||||
sellerId={cart?.seller?.username || ""}
|
<ShippingInformation
|
||||||
total={calculateTotal(cart)}
|
sellerId={cart?.seller?.username || ""}
|
||||||
setSelectedAddressInfo={setSelectedAddressInfo}
|
total={calculateTotal(cart)}
|
||||||
setSelectedCourierInfo={setSelectedCourierInfo}
|
setSelectedAddressInfo={setSelectedAddressInfo}
|
||||||
setShippingInfoIsLoading={setShippingInfoIsLoading}
|
setSelectedCourierInfo={setSelectedCourierInfo}
|
||||||
selectedAddressInfo={selectedAddressInfo}
|
setShippingInfoIsLoading={setShippingInfoIsLoading}
|
||||||
cartId={cartId}
|
selectedAddressInfo={selectedAddressInfo}
|
||||||
/>
|
cartId={cartId}
|
||||||
)}
|
/>
|
||||||
{step === 2 && (
|
)}
|
||||||
<ReceiverInformation
|
{step === 2 && (
|
||||||
discountValue={Number(cart?.discountValue) || null}
|
<ReceiverInformation
|
||||||
cartItems={cart?.items || []}
|
discountValue={Number(cart?.discountValue) || null}
|
||||||
selectedAddressInfo={selectedAddressInfo}
|
cartItems={cart?.items || []}
|
||||||
selectedCourierInfo={selectedCourierInfo}
|
selectedAddressInfo={selectedAddressInfo}
|
||||||
|
selectedCourierInfo={selectedCourierInfo}
|
||||||
|
total={calculateTotal(
|
||||||
|
cart,
|
||||||
|
selectedCourierInfo?.courierIsFreightCollect ? 0 : selectedCourierInfo?.deliveryPrice,
|
||||||
|
Number(cart?.discountValue)
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:sticky lg:top-[88px]">
|
||||||
|
<CartSummary
|
||||||
total={calculateTotal(
|
total={calculateTotal(
|
||||||
cart,
|
cart,
|
||||||
selectedCourierInfo?.courierIsFreightCollect ? 0 : selectedCourierInfo?.deliveryPrice,
|
step === 2 && !selectedCourierInfo?.courierIsFreightCollect ? selectedCourierInfo?.deliveryPrice : 0,
|
||||||
Number(cart?.discountValue)
|
step === 2 ? Number(cart?.discountValue) : 0
|
||||||
)}
|
)}
|
||||||
|
onContinue={() => continueHandler()}
|
||||||
|
step={step}
|
||||||
|
isLoading={
|
||||||
|
isCreatingOrder ||
|
||||||
|
cashOutMutation.isPending ||
|
||||||
|
shippingInfoIsLoading
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
</div>
|
||||||
<CartSummary
|
|
||||||
total={calculateTotal(
|
|
||||||
cart,
|
|
||||||
step === 2 && !selectedCourierInfo?.courierIsFreightCollect ? selectedCourierInfo?.deliveryPrice : 0,
|
|
||||||
step === 2 ? Number(cart?.discountValue) : 0
|
|
||||||
)}
|
|
||||||
onContinue={() => continueHandler()}
|
|
||||||
step={step}
|
|
||||||
isLoading={
|
|
||||||
isCreatingOrder ||
|
|
||||||
cashOutMutation.isPending ||
|
|
||||||
shippingInfoIsLoading
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
</UiProvider>
|
</UiProvider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -59,11 +59,16 @@ export default function CartIndex() {
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="bg-background">
|
<div className="bg-background">
|
||||||
{/* Header */}
|
{/* Header (mobile) */}
|
||||||
<ProfilePagesHeader hideBackButton={true} title="لیست سبد خرید" />
|
<div className="lg:hidden">
|
||||||
|
<ProfilePagesHeader hideBackButton={true} title="لیست سبد خرید" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Cart Content */}
|
{/* Cart Content */}
|
||||||
<div className="flex flex-col gap-4 p-4">
|
<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>
|
||||||
<UiProvider
|
<UiProvider
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isError={isError}
|
isError={isError}
|
||||||
@ -73,7 +78,7 @@ export default function CartIndex() {
|
|||||||
}}
|
}}
|
||||||
type="cart"
|
type="cart"
|
||||||
>
|
>
|
||||||
<div className="space-y-4 w-full">
|
<div className="space-y-4 w-full lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-5">
|
||||||
{carts.map((cart) => {
|
{carts.map((cart) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -111,6 +116,9 @@ export default function CartIndex() {
|
|||||||
<p className="text-B12 font-bold">
|
<p className="text-B12 font-bold">
|
||||||
{cart.seller?.name}
|
{cart.seller?.name}
|
||||||
</p>
|
</p>
|
||||||
|
<span className="ms-auto shrink-0 text-R10 font-semibold text-green-600">
|
||||||
|
ارسال جداگانه
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<p className="text-B12 font-bold line-clamp-1">
|
<p className="text-B12 font-bold line-clamp-1">
|
||||||
{cart.items
|
{cart.items
|
||||||
|
|||||||
@ -61,12 +61,14 @@ export default function CollectionDetail() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-WHITE pb-20">
|
<div className="flex flex-col min-h-screen bg-WHITE pb-20">
|
||||||
{/* Header */}
|
{/* 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">
|
<div className="sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
|
||||||
<ArrowRight
|
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
|
||||||
onClick={handleBack}
|
<ArrowRight
|
||||||
className="absolute top-5 right-5 cursor-pointer"
|
onClick={handleBack}
|
||||||
/>
|
className="absolute top-5 right-5 cursor-pointer"
|
||||||
<p className="text-B16 font-bold">جزئیات کالکشن</p>
|
/>
|
||||||
|
<p className="text-B16 font-bold">جزئیات کالکشن</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Loading Skeleton */}
|
{/* Loading Skeleton */}
|
||||||
@ -94,12 +96,14 @@ export default function CollectionDetail() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-WHITE pb-20">
|
<div className="flex flex-col min-h-screen bg-WHITE pb-20">
|
||||||
{/* Header */}
|
{/* 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">
|
<div className="sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
|
||||||
<ArrowRight
|
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
|
||||||
onClick={handleBack}
|
<ArrowRight
|
||||||
className="absolute top-5 right-5 cursor-pointer"
|
onClick={handleBack}
|
||||||
/>
|
className="absolute top-5 right-5 cursor-pointer"
|
||||||
<p className="text-B16 font-bold">جزئیات کالکشن</p>
|
/>
|
||||||
|
<p className="text-B16 font-bold">جزئیات کالکشن</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error State */}
|
{/* Error State */}
|
||||||
@ -129,21 +133,23 @@ export default function CollectionDetail() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-WHITE pb-20">
|
<div className="flex flex-col min-h-screen bg-WHITE pb-20">
|
||||||
{/* Header */}
|
{/* Header (mobile) */}
|
||||||
<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">
|
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
|
||||||
<ArrowRight
|
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
|
||||||
onClick={handleBack}
|
<ArrowRight
|
||||||
className="absolute top-5 right-5 cursor-pointer"
|
onClick={handleBack}
|
||||||
/>
|
className="absolute top-5 right-5 cursor-pointer"
|
||||||
<p className="text-B16 font-bold">جزئیات کالکشن</p>
|
/>
|
||||||
|
<p className="text-B16 font-bold">جزئیات کالکشن</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8 lg:pt-6">
|
||||||
{/* Collection Header */}
|
{/* Collection Header */}
|
||||||
<div className="flex flex-col gap-4 p-4 border-b border-inner-border">
|
<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">
|
||||||
{/* Cover Image */}
|
{/* Cover Image */}
|
||||||
<div className="relative overflow-hidden rounded-xl w-full mx-auto h-[65vh] bg-gray-100">
|
<div className="relative overflow-hidden rounded-xl w-full mx-auto h-[65vh] bg-gray-100 lg:h-auto lg:aspect-[3/4]">
|
||||||
{collection.coverImageUrl ? (
|
{collection.coverImageUrl ? (
|
||||||
<img
|
<img
|
||||||
src={collection.coverImageUrl}
|
src={collection.coverImageUrl}
|
||||||
@ -159,7 +165,7 @@ export default function CollectionDetail() {
|
|||||||
|
|
||||||
{/* Collection Info */}
|
{/* Collection Info */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<h1 className="text-B20 font-bold text-black">
|
<h1 className="text-B20 lg:text-[28px] font-bold text-black">
|
||||||
{collection.title || "بدون عنوان"}
|
{collection.title || "بدون عنوان"}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
|||||||
@ -41,7 +41,7 @@ export default function Collections() {
|
|||||||
|
|
||||||
// Loading skeleton
|
// Loading skeleton
|
||||||
const CollectionSkeleton = () => (
|
const CollectionSkeleton = () => (
|
||||||
<div className="flex flex-col gap-4 px-4">
|
<div className="flex flex-col gap-4 px-4 lg:grid lg:grid-cols-4 lg:gap-5 lg:px-0">
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
@ -117,17 +117,22 @@ export default function Collections() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col bg-WHITE pb-20">
|
<div className="flex flex-col bg-WHITE pb-20">
|
||||||
{/* Header - مطابق با ProfilePagesHeader */}
|
{/* Header (mobile) */}
|
||||||
<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">
|
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
|
||||||
<ArrowRight
|
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
|
||||||
onClick={handleBack}
|
<ArrowRight
|
||||||
className="absolute top-5 right-5 cursor-pointer"
|
onClick={handleBack}
|
||||||
/>
|
className="absolute top-5 right-5 cursor-pointer"
|
||||||
<p className="text-B16 font-bold">کالکشنها</p>
|
/>
|
||||||
|
<p className="text-B16 font-bold">کالکشنها</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex flex-col py-6">
|
<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>
|
||||||
{status === "pending" ? (
|
{status === "pending" ? (
|
||||||
<CollectionSkeleton />
|
<CollectionSkeleton />
|
||||||
) : status === "error" ? (
|
) : status === "error" ? (
|
||||||
@ -136,7 +141,7 @@ export default function Collections() {
|
|||||||
<EmptyState />
|
<EmptyState />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4 px-4">
|
<div className="flex flex-col gap-4 px-4 lg:grid lg:grid-cols-4 lg:gap-5 lg:px-0">
|
||||||
{allCollections.map((collection) => (
|
{allCollections.map((collection) => (
|
||||||
<Link
|
<Link
|
||||||
key={collection.id}
|
key={collection.id}
|
||||||
@ -145,7 +150,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">
|
<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 - بزرگ */}
|
{/* Collection Image - بزرگ */}
|
||||||
<div className="relative overflow-hidden rounded-xl w-full h-48 bg-gray-100">
|
<div className="relative overflow-hidden rounded-xl w-full h-48 lg:h-auto lg:aspect-[3/4] bg-gray-100">
|
||||||
{collection.coverImageUrl ? (
|
{collection.coverImageUrl ? (
|
||||||
<img
|
<img
|
||||||
src={collection.coverImageUrl}
|
src={collection.coverImageUrl}
|
||||||
|
|||||||
104
app/routes/discounts.tsx
Normal file
104
app/routes/discounts.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
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 },
|
||||||
|
];
|
||||||
|
};
|
||||||
@ -9,11 +9,13 @@ export default function Explore() {
|
|||||||
const [searchValue, setSearchValue] = useState("");
|
const [searchValue, setSearchValue] = useState("");
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<ExploreTopBar
|
<div className="lg:hidden">
|
||||||
setIsActiveSearchBar={setIsActiveSearchBar}
|
<ExploreTopBar
|
||||||
searchValue={searchValue}
|
setIsActiveSearchBar={setIsActiveSearchBar}
|
||||||
setSearchValue={setSearchValue}
|
searchValue={searchValue}
|
||||||
/>
|
setSearchValue={setSearchValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{isActiveSearchBar ? (
|
{isActiveSearchBar ? (
|
||||||
<SearchSection searchValue={searchValue} />
|
<SearchSection searchValue={searchValue} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import AppInput from "~/components/AppInput";
|
|||||||
import { useToast } from "~/hooks/use-toast";
|
import { useToast } from "~/hooks/use-toast";
|
||||||
import { useSubmit, useLoaderData } from "@remix-run/react";
|
import { useSubmit, useLoaderData } from "@remix-run/react";
|
||||||
import { User } from "~/utils/token-manager.server";
|
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
|
// Only allow same-site, path-relative redirects. Rejects absolute URLs
|
||||||
// (https://evil.com) and protocol-relative URLs (//evil.com) to prevent
|
// (https://evil.com) and protocol-relative URLs (//evil.com) to prevent
|
||||||
@ -106,8 +107,14 @@ export default function LoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 px-[20px] py-12 items-center w-full">
|
<div className="lg:min-h-screen lg:flex lg:items-center lg:justify-center lg:bg-WHITE2 lg:py-12">
|
||||||
{enableVerifySection ? (
|
<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>
|
||||||
|
{enableVerifySection ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4 w-full items-center">
|
<div className="flex flex-col gap-4 w-full items-center">
|
||||||
<img alt="" src={verifyBanner} className="w-[85px] h-[70px]" />
|
<img alt="" src={verifyBanner} className="w-[85px] h-[70px]" />
|
||||||
@ -127,7 +134,7 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-full mt-2 flex justify-center">
|
<div className="w-full mt-2 flex justify-center">
|
||||||
<InputOTP
|
<InputOTP
|
||||||
maxLength={4}
|
maxLength={6}
|
||||||
onComplete={(finalValue) => {
|
onComplete={(finalValue) => {
|
||||||
const convertedValue = convertPersianToEnglish(finalValue);
|
const convertedValue = convertPersianToEnglish(finalValue);
|
||||||
setOtpValue(convertedValue);
|
setOtpValue(convertedValue);
|
||||||
@ -138,17 +145,19 @@ export default function LoginPage() {
|
|||||||
setOtpValue(convertedValue);
|
setOtpValue(convertedValue);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<InputOTPGroup dir="ltr" className="gap-4">
|
<InputOTPGroup dir="ltr" className="gap-1.5 sm:gap-3">
|
||||||
<InputOTPSlot index={0} />
|
<InputOTPSlot index={0} className="!w-9 sm:!w-12" />
|
||||||
<InputOTPSlot index={1} />
|
<InputOTPSlot index={1} className="!w-9 sm:!w-12" />
|
||||||
<InputOTPSlot index={2} />
|
<InputOTPSlot index={2} className="!w-9 sm:!w-12" />
|
||||||
<InputOTPSlot index={3} />
|
<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>
|
</InputOTPGroup>
|
||||||
</InputOTP>
|
</InputOTP>
|
||||||
</div>
|
</div>
|
||||||
<div className="gap-4 w-full flex flex-col items-center">
|
<div className="gap-4 w-full flex flex-col items-center">
|
||||||
<Button
|
<Button
|
||||||
disabled={verifyOtpMutation.isPending || !(otpValue.length === 4)}
|
disabled={verifyOtpMutation.isPending || !(otpValue.length === 6)}
|
||||||
className="w-[90%]"
|
className="w-[90%]"
|
||||||
size={"xl"}
|
size={"xl"}
|
||||||
variant="dark"
|
variant="dark"
|
||||||
@ -235,6 +244,7 @@ export default function LoginPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { MetaFunction } from "@remix-run/node";
|
import { MetaFunction, redirect, type LoaderFunctionArgs } from "@remix-run/node";
|
||||||
|
import { sessionStorage } from "~/sessions.server";
|
||||||
import {
|
import {
|
||||||
BookmarkMinus,
|
BookmarkMinus,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@ -14,9 +15,13 @@ import {
|
|||||||
CircleUserRound,
|
CircleUserRound,
|
||||||
FileQuestion,
|
FileQuestion,
|
||||||
Layers,
|
Layers,
|
||||||
|
Pencil,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Link, useNavigate } from "@remix-run/react";
|
import { Link, useNavigate } from "@remix-run/react";
|
||||||
import { useServiceGetProfile } from "~/utils/RequestHandler";
|
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 { Skeleton } from "~/components/ui/skeleton";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@ -32,19 +37,76 @@ interface MenuItemType {
|
|||||||
pageName: string;
|
pageName: string;
|
||||||
hasBorder?: boolean;
|
hasBorder?: boolean;
|
||||||
image: React.ReactNode;
|
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() {
|
export default function Profile() {
|
||||||
const { user, theme: initialTheme } = useRootData();
|
const { user, theme: initialTheme } = useRootData();
|
||||||
const [storeModalOpen, setStoreModalOpen] = useState(false);
|
const [storeModalOpen, setStoreModalOpen] = useState(false);
|
||||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||||
const [logoutModalOpen, setLogoutModalOpen] = useState(false);
|
const [logoutModalOpen, setLogoutModalOpen] = useState(false);
|
||||||
const { data: profileData } = useServiceGetProfile();
|
const { data: profileData } = useServiceGetProfile();
|
||||||
|
const { data: myStores } = useMyStores();
|
||||||
|
const { data: walletData } = useServiceGetWallet();
|
||||||
|
const { data: badgeCounts } = useBadgeCounts();
|
||||||
const { data: followedSellers, isLoading: isFollowedSellersLoading } =
|
const { data: followedSellers, isLoading: isFollowedSellersLoading } =
|
||||||
useServiceGetFollowedSellers();
|
useServiceGetFollowedSellers();
|
||||||
|
|
||||||
// Calculate the count of followed sellers
|
// Calculate the count of followed sellers
|
||||||
const followedCount = followedSellers?.length || 0;
|
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
|
// Create dynamic items with the followed count
|
||||||
const menuItems: MenuItemType[] = [
|
const menuItems: MenuItemType[] = [
|
||||||
{
|
{
|
||||||
@ -67,6 +129,7 @@ export default function Profile() {
|
|||||||
link: "/profile/threads",
|
link: "/profile/threads",
|
||||||
pageName: "messages",
|
pageName: "messages",
|
||||||
image: <MessageCircle size={20} />,
|
image: <MessageCircle size={20} />,
|
||||||
|
badge: badgeCounts?.chat || null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "کیف پول",
|
title: "کیف پول",
|
||||||
@ -126,34 +189,107 @@ export default function Profile() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0">
|
<div className="flex flex-col gap-0">
|
||||||
<ProfileTopBar user={user} />
|
{/* Desktop: welcome (the sidebar provides the menu) */}
|
||||||
<div
|
<div className="hidden lg:flex flex-col items-center justify-center text-center py-24 border border-WHITE3 rounded-2xl">
|
||||||
className={`flex flex-row h-[55px] w-full items-center gap-2 justify-between px-[16px] active:bg-hover transition`}
|
<CircleUserRound size={64} className="text-GRAY mb-4" />
|
||||||
>
|
<h2 className="text-[22px] font-extrabold mb-1">حساب کاربری</h2>
|
||||||
<div className="flex flex-row gap-2 items-center">
|
<p className="text-GRAY">یک گزینه را از منوی کناری انتخاب کنید</p>
|
||||||
<p className={"text-R12"}>
|
</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"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
{initialTheme === "dark" ? "حالت تاریک" : "حالت روشن"}
|
{initialTheme === "dark" ? "حالت تاریک" : "حالت روشن"}
|
||||||
</p>
|
</p>
|
||||||
|
<SunMoonToggle theme={initialTheme} />
|
||||||
</div>
|
</div>
|
||||||
<SunMoonToggle theme={initialTheme} />
|
|
||||||
</div>
|
<MenuItems
|
||||||
<MenuItems
|
items={menuItems.filter((m) => !quickPages.has(m.pageName))}
|
||||||
items={menuItems}
|
onStoreClick={() => {
|
||||||
onStoreClick={() => {
|
// Enter a store the user can manage: their own if they own one,
|
||||||
if (profileData?.sellerUsername) {
|
// otherwise the first store they're a staff member of.
|
||||||
navigate(`/store/${profileData.sellerUsername}`);
|
const owned = myStores?.find((s) => s.role === "owner");
|
||||||
} else {
|
const target = owned || myStores?.[0];
|
||||||
if (user?.access_token) {
|
if (profileData?.sellerUsername) {
|
||||||
|
navigate(`/store/${profileData.sellerUsername}`);
|
||||||
|
} else if (target) {
|
||||||
|
navigate(`/store/${target.username}`);
|
||||||
|
} else if (user?.access_token) {
|
||||||
setStoreModalOpen(true);
|
setStoreModalOpen(true);
|
||||||
} else {
|
} else {
|
||||||
setLoginModalOpen(true);
|
setLoginModalOpen(true);
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
}}
|
onLogoutClick={() => {
|
||||||
onLogoutClick={() => {
|
setLogoutModalOpen(true);
|
||||||
setLogoutModalOpen(true);
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
</div>
|
||||||
<StoreModal open={storeModalOpen} onOpenChange={setStoreModalOpen} />
|
<StoreModal open={storeModalOpen} onOpenChange={setStoreModalOpen} />
|
||||||
<LogoutModal open={logoutModalOpen} onOpenChange={setLogoutModalOpen} />
|
<LogoutModal open={logoutModalOpen} onOpenChange={setLogoutModalOpen} />
|
||||||
<LoginRequiredDialog
|
<LoginRequiredDialog
|
||||||
@ -181,7 +317,7 @@ const MenuItems = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0 w-full">
|
<div className="flex flex-col gap-0 w-full lg:grid lg:grid-cols-2 lg:gap-3 lg:mt-4">
|
||||||
{items.map((item: MenuItemType, index: number) => {
|
{items.map((item: MenuItemType, index: number) => {
|
||||||
// Special handling for "support" item
|
// Special handling for "support" item
|
||||||
if (item.pageName === "support") {
|
if (item.pageName === "support") {
|
||||||
@ -239,13 +375,20 @@ const MenuItem = ({
|
|||||||
const MenuItemContent = ({ item }: { item: MenuItemType }) => {
|
const MenuItemContent = ({ item }: { item: MenuItemType }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<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`}
|
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`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row gap-2 items-center">
|
<div className="flex flex-row gap-2 items-center">
|
||||||
{item.image}
|
{item.image}
|
||||||
<p className={"text-R12"}>{item.title}</p>
|
<p className={"text-R12"}>{item.title}</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronLeft className="size-4" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -47,12 +47,14 @@ export default function AboutPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col pb-20">
|
<div className="flex flex-col pb-20">
|
||||||
{/* Header */}
|
{/* Header (mobile) */}
|
||||||
<HeaderWithSupport
|
<div className="lg:hidden">
|
||||||
hideSupportButton={true}
|
<HeaderWithSupport
|
||||||
title="درباره ویترون"
|
hideSupportButton={true}
|
||||||
onBack={() => safeGoBack(navigate)}
|
title="درباره ویترون"
|
||||||
/>
|
onBack={() => safeGoBack(navigate)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex flex-col gap-8 p-4 max-w-2xl mx-auto w-full">
|
<div className="flex flex-col gap-8 p-4 max-w-2xl mx-auto w-full">
|
||||||
{/* Logo Section */}
|
{/* Logo Section */}
|
||||||
|
|||||||
@ -18,8 +18,11 @@ export default function Bookmarks() {
|
|||||||
const bookmarks = data?.pages.flatMap((page) => page.results) || [];
|
const bookmarks = data?.pages.flatMap((page) => page.results) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6 lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8 lg:py-6">
|
||||||
<ProfilePagesHeader title="ذخیره شدهها" />
|
<ProfilePagesHeader title="ذخیره شدهها" />
|
||||||
|
<h1 className="hidden lg:block text-[28px] font-extrabold">
|
||||||
|
ذخیره شدهها
|
||||||
|
</h1>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<MondrianProductList
|
<MondrianProductList
|
||||||
|
|||||||
@ -135,11 +135,13 @@ export default function FAQ() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<HeaderWithSupport
|
<div className="lg:hidden">
|
||||||
hideSupportButton={true}
|
<HeaderWithSupport
|
||||||
title="سوالات متداول"
|
hideSupportButton={true}
|
||||||
onBack={() => safeGoBack(navigate)}
|
title="سوالات متداول"
|
||||||
/>
|
onBack={() => safeGoBack(navigate)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="max-w-4xl mx-auto px-4 py-12">
|
<div className="max-w-4xl mx-auto px-4 py-12">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h1 className="text-3xl font-bold mb-4">سوالات متداول</h1>
|
<h1 className="text-3xl font-bold mb-4">سوالات متداول</h1>
|
||||||
|
|||||||
@ -15,8 +15,11 @@ export default function Following() {
|
|||||||
} = useServiceGetFollowedSellers();
|
} = useServiceGetFollowedSellers();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0 pb-20">
|
<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">
|
||||||
<ProfilePagesHeader title="فروشگاههای دنبال شده" />
|
<ProfilePagesHeader title="فروشگاههای دنبال شده" />
|
||||||
|
<h1 className="hidden lg:block text-[28px] font-extrabold mb-4">
|
||||||
|
فروشگاههای دنبال شده
|
||||||
|
</h1>
|
||||||
<UiProvider
|
<UiProvider
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isEmpty={!followedSellers || followedSellers.length === 0}
|
isEmpty={!followedSellers || followedSellers.length === 0}
|
||||||
|
|||||||
@ -47,10 +47,16 @@ export const meta: MetaFunction = () => {
|
|||||||
|
|
||||||
export default function Locaton() {
|
export default function Locaton() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 w-full items-center">
|
<div className="flex flex-col gap-4 w-full items-center lg:max-w-[820px] lg:mx-auto lg:pt-6">
|
||||||
<ProfilePagesHeader title="آدرسهای من" />
|
<ProfilePagesHeader title="آدرسهای من" />
|
||||||
|
<h1 className="hidden lg:block w-full text-[28px] font-extrabold px-4">
|
||||||
|
آدرسهای من
|
||||||
|
</h1>
|
||||||
<MainContent />
|
<MainContent />
|
||||||
<Link to={"/profile/location/add"} className="fixed bottom-16 z-10">
|
<Link
|
||||||
|
to={"/profile/location/add"}
|
||||||
|
className="fixed bottom-16 z-10 lg:static lg:bottom-auto lg:z-auto lg:mt-4"
|
||||||
|
>
|
||||||
<Button size={"xl"} variant="dark">
|
<Button size={"xl"} variant="dark">
|
||||||
<Plus className="text-WHITE size-6" />
|
<Plus className="text-WHITE size-6" />
|
||||||
افزودن آدرس جدید
|
افزودن آدرس جدید
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user