- HeaderWithSupport is now lg:hidden (all its callers are desktop-full-width store/profile pages), removing the stray mobile back+support bar on desktop. - Constrain + title the previously mobile-only pages so their content doesn't stretch edge-to-edge on desktop: store edit/create form (StoreForm), add product, shipping method, financial sub-pages (cash-funds/reports/ transactions), order detail, instagram auth; and profile add/edit address, set detail, and payment result. Forms use a narrow centered column, lists/ details a wider one. Transactions keeps its filter button (header restyled, not hidden). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
535 lines
18 KiB
TypeScript
535 lines
18 KiB
TypeScript
import { useState, useEffect } from "react";
|
||
import { Save, Truck, Check } from "lucide-react";
|
||
import HeaderWithSupport from "../components/HeaderWithSupport";
|
||
import { Button } from "../components/ui/button";
|
||
import AppInput from "../components/AppInput";
|
||
import { Card } from "../components/ui/card";
|
||
import UiProvider from "../components/UiProvider";
|
||
import {
|
||
useServiceGetCouriers,
|
||
useGetAllCouriers,
|
||
useCreateSellerCourier,
|
||
useDeleteSellerCourier,
|
||
useUpdateSellerCourier,
|
||
} from "../requestHandler/use-courier-hooks";
|
||
import { SellerCourierCreate, SellerCourierUpdate } from "../../src/api/types";
|
||
import { useToast } from "../hooks/use-toast";
|
||
import { useParams, useNavigate } from "@remix-run/react";
|
||
import { useRequireOwner } from "../hooks/useStoreOwnership";
|
||
import { MetaFunction } from "@remix-run/node";
|
||
import { getPersianTextOfPrice, safeGoBack } from "~/utils/helpers";
|
||
|
||
interface CourierFormData {
|
||
courierId: string;
|
||
courierName: string;
|
||
deliveryPrice: number | null;
|
||
estimatedDays: number | null;
|
||
isEnabled: boolean;
|
||
isConfigured: boolean;
|
||
sellerCourierId?: string; // ID of the seller courier configuration
|
||
isFaceToFace: boolean;
|
||
isFreightCollect: boolean;
|
||
}
|
||
|
||
export default function ShippingMethod() {
|
||
const { storeId } = useParams();
|
||
const { toast } = useToast();
|
||
const navigate = useNavigate();
|
||
const [savingCourierId, setSavingCourierId] = useState<string | null>(null);
|
||
|
||
// Shipping/courier config is owner-only — redirect staff back to the dashboard.
|
||
const { isLoading: isOwnershipLoading } = useRequireOwner(storeId);
|
||
|
||
// Fetch all available couriers
|
||
const {
|
||
data: allCouriers,
|
||
isLoading: isAllCouriersLoading,
|
||
isError: isAllCouriersError,
|
||
refetch: refetchAllCouriers,
|
||
} = useGetAllCouriers();
|
||
|
||
// Fetch seller's configured couriers
|
||
const {
|
||
data: sellerCouriers,
|
||
isLoading: isSellerCouriersLoading,
|
||
isError: isSellerCouriersError,
|
||
refetch: refetchSellerCouriers,
|
||
} = useServiceGetCouriers(storeId || "");
|
||
|
||
const createSellerCourier = useCreateSellerCourier();
|
||
const deleteSellerCourier = useDeleteSellerCourier();
|
||
const updateSellerCourier = useUpdateSellerCourier();
|
||
|
||
// Combine all couriers with seller configurations
|
||
const [courierData, setCourierData] = useState<CourierFormData[]>([]);
|
||
const [initialCourierData, setInitialCourierData] = useState<
|
||
CourierFormData[]
|
||
>([]);
|
||
|
||
// Initialize courier data when both APIs return data
|
||
useEffect(() => {
|
||
if (allCouriers && sellerCouriers) {
|
||
const combined = allCouriers.map((courier) => {
|
||
const sellerConfig = sellerCouriers.find(
|
||
(sc) => sc.courier === courier.id
|
||
);
|
||
return {
|
||
courierId: courier.id || "",
|
||
courierName: courier.name,
|
||
deliveryPrice: sellerConfig?.deliveryPrice || null,
|
||
estimatedDays: sellerConfig?.estimatedDays || null,
|
||
isEnabled: sellerConfig?.isEnabled || false,
|
||
isConfigured: !!sellerConfig,
|
||
sellerCourierId: sellerConfig?.id,
|
||
isFaceToFace: courier.isFaceToFace || false,
|
||
isFreightCollect: courier.isFreightCollect || false,
|
||
};
|
||
});
|
||
setCourierData(combined);
|
||
setInitialCourierData(combined);
|
||
}
|
||
}, [allCouriers, sellerCouriers]);
|
||
|
||
const handleSaveCourier = async (courier: CourierFormData) => {
|
||
setSavingCourierId(courier.courierId);
|
||
|
||
const initialCourier = initialCourierData.find(
|
||
(c) => c.courierId === courier.courierId
|
||
);
|
||
|
||
// Check if this is the first time activating (not configured before)
|
||
const isFirstTimeActivation =
|
||
!initialCourier?.isConfigured && courier.isEnabled;
|
||
|
||
if (isFirstTimeActivation) {
|
||
// Use API for first time activation
|
||
try {
|
||
const data: SellerCourierCreate = {
|
||
courierId: courier.courierId,
|
||
// For face-to-face, set default values (0)
|
||
deliveryPrice:
|
||
courier.isFaceToFace || courier.isFreightCollect
|
||
? 0
|
||
: courier.deliveryPrice,
|
||
estimatedDays: courier.isFaceToFace ? 0 : courier.estimatedDays,
|
||
isEnabled: courier.isEnabled,
|
||
};
|
||
|
||
const result = await createSellerCourier.mutateAsync(data);
|
||
|
||
toast({
|
||
title: "موفقیت",
|
||
description: "روش پرداخت با موفقیت فعال شد",
|
||
});
|
||
|
||
// Update local state
|
||
setCourierData((prev) =>
|
||
prev.map((c) =>
|
||
c.courierId === courier.courierId
|
||
? { ...c, isConfigured: true, sellerCourierId: result.id }
|
||
: c
|
||
)
|
||
);
|
||
|
||
// Update initial data to reflect new saved state
|
||
setInitialCourierData((prev) =>
|
||
prev.map((c) =>
|
||
c.courierId === courier.courierId
|
||
? { ...courier, isConfigured: true, sellerCourierId: result.id }
|
||
: c
|
||
)
|
||
);
|
||
} catch (error) {
|
||
toast({
|
||
title: "خطا",
|
||
description: "خطا در فعال کردن روش پرداخت",
|
||
variant: "destructive",
|
||
});
|
||
console.error("Error creating courier:", error);
|
||
} finally {
|
||
setSavingCourierId(null);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// For editing or deactivating, use appropriate APIs
|
||
if (!courier.isEnabled && courier.isConfigured && courier.sellerCourierId) {
|
||
// Use delete API for deactivating
|
||
try {
|
||
await deleteSellerCourier.mutateAsync(courier.sellerCourierId);
|
||
|
||
toast({
|
||
title: "موفقیت",
|
||
description: "روش پرداخت غیرفعال شد",
|
||
});
|
||
|
||
// Update local state
|
||
setCourierData((prev) =>
|
||
prev.map((c) =>
|
||
c.courierId === courier.courierId
|
||
? {
|
||
...c,
|
||
isEnabled: false,
|
||
isConfigured: false,
|
||
sellerCourierId: undefined,
|
||
}
|
||
: c
|
||
)
|
||
);
|
||
|
||
// Update initial data to reflect new saved state
|
||
setInitialCourierData((prev) =>
|
||
prev.map((c) =>
|
||
c.courierId === courier.courierId
|
||
? {
|
||
...c,
|
||
isEnabled: false,
|
||
isConfigured: false,
|
||
sellerCourierId: undefined,
|
||
}
|
||
: c
|
||
)
|
||
);
|
||
} catch (error) {
|
||
toast({
|
||
title: "خطا",
|
||
description: "خطا در غیرفعال کردن روش پرداخت",
|
||
variant: "destructive",
|
||
});
|
||
console.error("Error deactivating courier:", error);
|
||
} finally {
|
||
setSavingCourierId(null);
|
||
}
|
||
return;
|
||
} else if (courier.isConfigured && courier.sellerCourierId) {
|
||
// Use update API for editing existing configuration
|
||
try {
|
||
const updateData: SellerCourierUpdate = {
|
||
// For face-to-face, set default values (0)
|
||
deliveryPrice:
|
||
courier.isFaceToFace || courier.isFreightCollect
|
||
? 0
|
||
: courier.deliveryPrice,
|
||
estimatedDays: courier.isFaceToFace ? 0 : courier.estimatedDays,
|
||
isEnabled: courier.isEnabled,
|
||
};
|
||
|
||
await updateSellerCourier.mutateAsync({
|
||
id: courier.sellerCourierId,
|
||
data: updateData,
|
||
});
|
||
|
||
toast({
|
||
title: "موفقیت",
|
||
description: "تنظیمات ارسال ویرایش شد",
|
||
});
|
||
|
||
// Update local state
|
||
setCourierData((prev) =>
|
||
prev.map((c) =>
|
||
c.courierId === courier.courierId ? { ...courier } : c
|
||
)
|
||
);
|
||
|
||
// Update initial data to reflect new saved state
|
||
setInitialCourierData((prev) =>
|
||
prev.map((c) =>
|
||
c.courierId === courier.courierId ? { ...courier } : c
|
||
)
|
||
);
|
||
} catch (error) {
|
||
toast({
|
||
title: "خطا",
|
||
description: "خطا در ویرایش تنظیمات ارسال",
|
||
variant: "destructive",
|
||
});
|
||
console.error("Error updating courier:", error);
|
||
} finally {
|
||
setSavingCourierId(null);
|
||
}
|
||
return;
|
||
}
|
||
|
||
setSavingCourierId(null);
|
||
};
|
||
|
||
const updateCourier = (
|
||
courierId: string,
|
||
updates: Partial<CourierFormData>
|
||
) => {
|
||
setCourierData((prev) =>
|
||
prev.map((c) => (c.courierId === courierId ? { ...c, ...updates } : c))
|
||
);
|
||
};
|
||
|
||
const isSaveDisabled = (courier: CourierFormData) => {
|
||
const initialCourier = initialCourierData.find(
|
||
(c) => c.courierId === courier.courierId
|
||
);
|
||
|
||
if (!initialCourier) return true;
|
||
|
||
// Check if enabled state has changed
|
||
const enabledStateChanged = courier.isEnabled !== initialCourier.isEnabled;
|
||
|
||
// For face-to-face couriers, only check if enabled state changed
|
||
if (courier.isFaceToFace) {
|
||
return !enabledStateChanged;
|
||
}
|
||
|
||
// For non-face-to-face couriers, check price and delivery days
|
||
const priceChanged = courier.deliveryPrice !== initialCourier.deliveryPrice;
|
||
const daysChanged = courier.estimatedDays !== initialCourier.estimatedDays;
|
||
const dataChanged = priceChanged || daysChanged;
|
||
|
||
// No changes at all
|
||
if (!enabledStateChanged && !dataChanged) return true;
|
||
|
||
// If being activated or is active, check if estimatedDays is valid (must be > 0)
|
||
if (
|
||
courier.isEnabled &&
|
||
(!courier.estimatedDays || courier.estimatedDays <= 0)
|
||
) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
};
|
||
|
||
const isLoading = isAllCouriersLoading || isSellerCouriersLoading;
|
||
const isError = isAllCouriersError || isSellerCouriersError;
|
||
|
||
const handleBack = () => {
|
||
safeGoBack(navigate);
|
||
};
|
||
|
||
// Show loading while checking ownership
|
||
if (isOwnershipLoading) {
|
||
return (
|
||
<div className="flex items-center justify-center min-h-[500px]">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col bg-WHITE lg:max-w-[820px] lg:mx-auto lg:w-full lg:pt-6">
|
||
<HeaderWithSupport title="نحوه ارسال" onBack={handleBack} />
|
||
<h1 className="hidden lg:block text-[28px] font-extrabold px-4 mb-2">
|
||
نحوه ارسال
|
||
</h1>
|
||
|
||
<UiProvider
|
||
isLoading={isLoading}
|
||
isError={isError}
|
||
isEmpty={!allCouriers?.length}
|
||
onRetry={() => {
|
||
refetchAllCouriers();
|
||
refetchSellerCouriers();
|
||
}}
|
||
type="seller"
|
||
>
|
||
<div className="flex flex-col gap-2 p-4">
|
||
<p className="text-B16 font-bold">
|
||
روش های ارسال مورد نظر خود را انتخاب کنید.
|
||
</p>
|
||
<p className="text-R14">میتوانید چند گزینه را همزمان فعال کنید.</p>
|
||
</div>
|
||
<div className="flex-1 p-4 space-y-4">
|
||
<div className="bg-blue-50 p-4 rounded-lg">
|
||
<div className="flex items-start gap-2">
|
||
<Truck className="text-blue-600 mt-1 shrink-0" size={20} />
|
||
<div>
|
||
<p className="text-sm font-medium text-blue-800">
|
||
راهنمای تنظیم ارسال
|
||
</p>
|
||
<p className="text-xs text-blue-600 mt-1">
|
||
برای هر روش ارسال، قیمت و ماکسیمم مدت زمان تحویل تخمینی را
|
||
تعیین کنید. مشتریان در هنگام خرید، این گزینهها را خواهند دید.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
{courierData.map((courier) => (
|
||
<CourierCard
|
||
key={courier.courierId}
|
||
courier={courier}
|
||
onUpdate={(updates) =>
|
||
updateCourier(courier.courierId, updates)
|
||
}
|
||
onSave={() => handleSaveCourier(courier)}
|
||
isSaving={savingCourierId === courier.courierId}
|
||
isDisabled={isSaveDisabled(courier)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</UiProvider>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface CourierCardProps {
|
||
courier: CourierFormData;
|
||
onUpdate: (updates: Partial<CourierFormData>) => void;
|
||
onSave: () => void;
|
||
isSaving: boolean;
|
||
isDisabled: boolean;
|
||
}
|
||
|
||
function CourierCard({
|
||
courier,
|
||
onUpdate,
|
||
onSave,
|
||
isSaving,
|
||
isDisabled,
|
||
}: CourierCardProps) {
|
||
return (
|
||
<Card className="p-4 space-y-4 bg-WHITE">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex gap-1">
|
||
<h3 className="font-medium">{courier.courierName}</h3>
|
||
{courier.isConfigured && (
|
||
<div className="flex items-center text-R10 text-green-600">
|
||
(<Check size={12} />
|
||
<span>فعال</span>)
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<label className="relative inline-flex items-center cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={courier.isEnabled}
|
||
onChange={(e) => onUpdate({ isEnabled: e.target.checked })}
|
||
className="sr-only peer"
|
||
aria-label={`انتخاب ${courier.courierName}`}
|
||
/>
|
||
<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>
|
||
</div>
|
||
|
||
<div className="space-y-3 pt-2 border-t">
|
||
{courier.isEnabled && !courier.isFaceToFace && (
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<AppInput
|
||
inputTitle="قیمت ارسال (تومان)"
|
||
type="number"
|
||
inputTitleFontSize="text-R12"
|
||
inputPlaceholder="0"
|
||
inputValue={courier.isFreightCollect ? "" : courier.deliveryPrice?.toString() || ""}
|
||
disabled={courier.isFreightCollect}
|
||
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
onUpdate({
|
||
deliveryPrice: e.target.value
|
||
? Number(e.target.value)
|
||
: null,
|
||
})
|
||
}
|
||
className="h-[30px] rounded-[8px]"
|
||
/>
|
||
{courier.isFreightCollect && (
|
||
<p className="text-R10 text-GRAY mt-1">این روش «پسپرداخت» است و قیمت را نمیتوان تعیین کرد.</p>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<AppInput
|
||
inputTitle="مدت تحویل (روز)"
|
||
type="number"
|
||
inputPlaceholder="7"
|
||
inputTitleFontSize="text-R12"
|
||
inputValue={courier.estimatedDays?.toString() || ""}
|
||
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const value = e.target.value;
|
||
const numValue = Number(value);
|
||
|
||
// Only allow positive numbers (> 0) for non-face-to-face
|
||
if (value === "") {
|
||
onUpdate({ estimatedDays: null });
|
||
} else if (numValue > 0) {
|
||
onUpdate({ estimatedDays: numValue });
|
||
}
|
||
}}
|
||
className="h-[30px] rounded-[8px]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{!courier.isFaceToFace && (
|
||
<div className="text-R12 text-GRAY">
|
||
شما برای این روش مبلغ{" "}
|
||
<span className="font-bold text-BLACK">
|
||
{courier.isFreightCollect
|
||
? "پس پرداخت"
|
||
: getPersianTextOfPrice(courier.deliveryPrice?.toString() || "0")}
|
||
</span>{" "}
|
||
و ماکسیمم مدت زمان تحویل{" "}
|
||
<span className="font-bold text-BLACK">
|
||
{courier.estimatedDays}
|
||
</span>{" "}
|
||
روز را تعیین کرده اید.
|
||
</div>
|
||
)}
|
||
|
||
<Button
|
||
onClick={onSave}
|
||
disabled={isSaving || isDisabled}
|
||
className="w-full"
|
||
size="lg"
|
||
variant="dark"
|
||
>
|
||
{isSaving ? (
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-4 h-4 border-2 border-WHITE/20 border-t-white rounded-full animate-spin" />
|
||
در حال ذخیره...
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-2">
|
||
<Save size={16} />
|
||
ذخیره تنظیمات
|
||
</div>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
export const meta: MetaFunction = () => {
|
||
return [
|
||
{ title: "نحوه ارسال - ویترون" },
|
||
{
|
||
name: "description",
|
||
content:
|
||
"مدیریت و مشاهده تمام نحوه ارسال در فروشگاه شما. فیلتر بر اساس وضعیت و جستجو در نحوه ارسال.",
|
||
},
|
||
{
|
||
name: "keywords",
|
||
content: "نحوه ارسال، مدیریت نحوه ارسال، فروشگاه، مشتری، ویترون",
|
||
},
|
||
{ name: "robots", content: "noindex, follow" },
|
||
{ property: "og:title", content: "نحوه ارسال - ویترون" },
|
||
{
|
||
property: "og:description",
|
||
content:
|
||
"مدیریت و مشاهده تمام نحوه ارسال در فروشگاه شما. فیلتر بر اساس وضعیت و جستجو در نحوه ارسال.",
|
||
},
|
||
{ property: "og:type", content: "website" },
|
||
{ name: "twitter:card", content: "summary" },
|
||
{ name: "twitter:title", content: "نحوه ارسال - ویترون" },
|
||
{
|
||
name: "twitter:description",
|
||
content:
|
||
"مدیریت و مشاهده تمام نحوه ارسال در فروشگاه شما. فیلتر بر اساس وضعیت و جستجو در نحوه ارسال.",
|
||
},
|
||
];
|
||
};
|