Vitron-Front/app/routes/store.$storeId.shipping-method.tsx
Arda Samadi 16389fdd85 fix(desktop): lay out dashboard sub-pages + form pages for wide screens
- 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>
2026-07-09 12:06:13 +03:30

535 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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:
"مدیریت و مشاهده تمام نحوه ارسال در فروشگاه شما. فیلتر بر اساس وضعیت و جستجو در نحوه ارسال.",
},
];
};