Vitron-Front/app/routes/store.$storeId.financial-dashboard.transactions.tsx
Arda Samadi d694040bf7 fix(pwa): apply top safe-area inset to page headers (iOS notch)
MyLayout only pads the bottom inset globally; each top bar must add its own
top inset. Several inline page headers omitted it, so their content slid
under the notch in the iOS standalone PWA (home was fine — its bar has it).

Wrap the back-button headers the same way ProfilePagesHeader already does
(outer sticky element carries pt-[env(safe-area-inset-top)]; inner relative
bar holds the content) so the absolute back arrow stays out of the notch.
Fixed: /profile hub, /discounts, /collections, /collection/:id, /sellers,
seller store index, and financial transactions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:21:23 +03:30

428 lines
15 KiB
TypeScript
Raw 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 type { MetaFunction } from "@remix-run/node";
import { ArrowRight, Check, X, Loader2 } from "lucide-react";
import { formatDate, formatPrice, formatTime, safeGoBack } from "../utils/helpers";
import { useNavigate } from "@remix-run/react";
import { useState } from "react";
import { Button } from "../components/ui/button";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "../components/ui/drawer";
import { RadioGroup, RadioGroupItem } from "../components/ui/radio-group";
import PersianDatePicker from "../components/PersianDatePicker";
import { useSearchParams, useParams } from "react-router-dom";
import { useServiceGetPayoutRequests } from "../requestHandler/use-wallet-hooks";
import { useRequireOwner } from "../hooks/useStoreOwnership";
import jMoment from "moment-jalaali";
import filterIcon from "~/assets/icons/filter.png";
import filterIconDark from "~/assets/icons/filter-dark.png";
import { useRootData } from "~/hooks/use-root-data";
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:
"مشاهده و پیگیری تمام تراکنش‌های مالی فروشگاه شما در ویترون. فیلتر زمانی و جزئیات تراکنش‌ها.",
},
];
};
export default function Transactions() {
const navigate = useNavigate();
const { storeId } = useParams();
useRequireOwner(storeId); // financial is owner-only
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
const [searchParams] = useSearchParams();
const { theme } = useRootData();
// Convert Persian date to Gregorian ISO string
const convertPersianToGregorian = (persianDate: string): string => {
// Persian date format is expected to be YYYY/MM/DD
const [year, month, day] = persianDate.split("/");
const jMomentDate = jMoment(`${year}/${month}/${day}`, "jYYYY/jM/jD");
return jMomentDate.format("YYYY-MM-DD");
};
// Map URL params to API filters
const getFilters = () => {
const order = searchParams.get("order") as
| "all"
| "today"
| "lastWeek"
| "lastMonth"
| "other";
const fromDate = searchParams.get("fromDate");
const toDate = searchParams.get("toDate");
if (!order || order === "all") {
return undefined;
}
// Map URL params to API params
const apiFilters: {
dateFilter?: "today" | "last_7" | "last_30" | "custom";
fromDate?: string;
toDate?: string;
} = {};
switch (order) {
case "today":
apiFilters.dateFilter = "today";
break;
case "lastWeek":
apiFilters.dateFilter = "last_7";
break;
case "lastMonth":
apiFilters.dateFilter = "last_30";
break;
case "other":
// Allow custom filter even with only one date (fromDate OR toDate)
if (fromDate || toDate) {
apiFilters.dateFilter = "custom";
if (fromDate) {
try {
apiFilters.fromDate = convertPersianToGregorian(fromDate);
} catch (error) {
console.error("Error converting fromDate:", error);
apiFilters.fromDate = fromDate; // fallback to original
}
}
if (toDate) {
try {
apiFilters.toDate = convertPersianToGregorian(toDate);
} catch (error) {
console.error("Error converting toDate:", error);
apiFilters.toDate = toDate; // fallback to original
}
}
}
break;
}
return Object.keys(apiFilters).length > 0 ? apiFilters : undefined;
};
const { data: payoutRequests, isLoading } =
useServiceGetPayoutRequests(getFilters());
const handleBack = () => {
safeGoBack(navigate);
};
const filterIconClicked = () => {
setIsFilterDrawerOpen(true);
};
return (
<div className="bg-WHITE lg:max-w-[900px] lg:mx-auto lg:w-full lg:pt-6">
{/* Header (keeps the filter button; just constrained on desktop) */}
<div className="sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)] lg:static lg:pt-0">
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border lg:justify-start lg:gap-3">
<ArrowRight onClick={handleBack} className="absolute top-5 right-5 lg:hidden" />
<p className={"text-B16 lg:text-[28px] font-bold lg:font-extrabold"}>لیست تراکنشها</p>
<div
onClick={() => {
filterIconClicked();
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter") {
filterIconClicked();
}
}}
className="absolute top-4 h-8 w-8 left-5 rounded-full bg-WHITE shadow-xl flex items-center justify-center"
>
<img
src={theme === "dark" ? filterIconDark : filterIcon}
alt="filter"
className="w-6 h-6"
/>
</div>
</div>
</div>
<div className="flex flex-col w-full">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
) : payoutRequests && payoutRequests.length > 0 ? (
payoutRequests.map((request) => (
<div key={request.id} className="flex flex-col gap-2 p-4 border-b">
<div className="flex justify-between">
<div className="flex flex-row gap-2">
<p className="text-B12 font-bold">
{request.requestedAt
? formatDate(request.requestedAt)
: "تاریخ نامشخص"}
</p>
<p className="text-B12 font-bold">
{request.requestedAt ? formatTime(request.requestedAt) : ""}
</p>
</div>
{request.status === "completed" ? (
<div className="flex flex-row gap-1 text-green-500">
<Check size={16} />
<p className="text-B12 font-bold">موفق</p>
</div>
) : request.status === "rejected" ? (
<div className="flex flex-row gap-1 text-red-500">
<X size={16} />
<p className="text-B12 font-bold">ناموفق</p>
</div>
) : (
<div className="flex flex-row gap-1 text-yellow-500">
<Loader2 size={16} className="animate-spin" />
<p className="text-B12 font-bold">در انتظار</p>
</div>
)}
</div>
<div className="flex flex-row gap-2">
<p className="text-B12 font-bold">مبلغ</p>
<p className="text-R12">{formatPrice(request.amount)}</p>
</div>
{request.bankAccountCardNumber && (
<div className="flex flex-row gap-2">
<p className="text-B12 font-bold">شماره کارت:</p>
<p className="text-R12">{request.bankAccountCardNumber}</p>
</div>
)}
{request.bankAccountIban && (
<div className="flex flex-row gap-2">
<p className="text-B12 font-bold">شماره شبا:</p>
<p className="text-R12">{request.bankAccountIban}</p>
</div>
)}
{request.shomareMarja && (
<div className="flex flex-row gap-2 text-GRAY">
<p className="text-B12 font-bold">شماره مرجع بانک:</p>
<p className="text-R12">{request.shomareMarja}</p>
</div>
)}
{request.shomareErja && (
<div className="flex flex-row gap-2 text-GRAY">
<p className="text-B12 font-bold">شماره ارجاع:</p>
<p className="text-R12">{request.shomareErja}</p>
</div>
)}
{request.processedAt && (
<div className="flex flex-row gap-2 text-GRAY">
<p className="text-B12 font-bold">تاریخ پردازش:</p>
<p className="text-R12">
{formatDate(request.processedAt)}{" "}
{formatTime(request.processedAt)}
</p>
</div>
)}
</div>
))
) : (
<div className="flex flex-col items-center justify-center py-8 text-GRAY">
<p className="text-B16">هیچ تراکنشی یافت نشد</p>
<p className="text-R12 mt-2">هنوز درخواست برداشتی ثبت نکردهاید</p>
</div>
)}
</div>
<FilterDrawer
isOpen={isFilterDrawerOpen}
onClose={() => setIsFilterDrawerOpen(false)}
/>
</div>
);
}
type dateRangeTypes = "all" | "today" | "lastWeek" | "lastMonth" | "other";
const FilterDrawer = ({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) => {
const [searchParams, setSearchParams] = useSearchParams();
// Date states for custom range
const [order, setOrder] = useState<dateRangeTypes>(
(searchParams.get("order") as dateRangeTypes) || "all"
);
const [fromDate, setFromDate] = useState<string>(
searchParams.get("fromDate") || ""
);
const [toDate, setToDate] = useState<string>(
searchParams.get("toDate") || ""
);
const filterTransactions = () => {
const params: Record<string, string> = {};
if (order !== "all") {
params.order = order;
}
// Only include date params if order is "other" (custom)
if (order === "other") {
if (fromDate) {
params.fromDate = fromDate;
}
if (toDate) {
params.toDate = toDate;
}
}
// For non-custom filters, fromDate and toDate are intentionally not included
// This will remove them from the URL params
setSearchParams(params, { replace: true });
onClose();
};
return (
<Drawer open={isOpen} onOpenChange={onClose}>
<DrawerContent className="w-full pb-6">
<DrawerHeader className="flex flex-col py-3">
<div className="flex relative pt-2 text-B12 font-bold items-center justify-center w-full">
<X
className="absolute right-0 cursor-pointer"
size={24}
onClick={() => onClose()}
/>
<DrawerTitle className="text-B12 font-bold">
فیلتر نتایج
</DrawerTitle>
</div>
</DrawerHeader>
<div className="w-full p-4">
<div className="flex flex-col gap-4 w-full pb-4">
<RadioGroup
className="flex flex-col gap-4 w-full"
onValueChange={(value) => setOrder(value as dateRangeTypes)}
value={order}
>
<div className="flex w-full justify-between">
<RadioGroupItem
circleClass="h-4 w-4"
className="h-6 w-6"
value={"all"}
id="all"
/>
<label htmlFor="all" className="text-R16 w-full text-right">
همه نتایج
</label>
</div>
<div className="flex w-full justify-between">
<RadioGroupItem
circleClass="h-4 w-4"
className="h-6 w-6"
value={"today"}
id="today"
/>
<label htmlFor="today" className="text-R16 w-full text-right">
امروز
</label>
</div>
<div className="flex w-full justify-between">
<RadioGroupItem
circleClass="h-4 w-4"
className="h-6 w-6"
value={"lastWeek"}
id="lastWeek"
/>
<label
htmlFor="lastWeek"
className="text-R16 w-full text-right"
>
هفته اخیر
</label>
</div>
<div className="flex w-full justify-between">
<RadioGroupItem
circleClass="h-4 w-4"
className="h-6 w-6"
value={"lastMonth"}
id="lastMonth"
/>
<label
htmlFor="lastMonth"
className="text-R16 w-full text-right"
>
ماه اخیر
</label>
</div>
<div className="flex w-full justify-between">
<RadioGroupItem
circleClass="h-4 w-4"
className="h-6 w-6"
value={"other"}
id="other"
/>
<label htmlFor="other" className="text-R16 w-full text-right">
بازه دلخواه
</label>
</div>
</RadioGroup>
</div>
{/* Custom Date Range Picker */}
{order === "other" && (
<div className="flex gap-2 w-full pb-4">
{/* From Date */}
<div className="w-full">
<PersianDatePicker
value={fromDate}
onChange={setFromDate}
placeholder="از تاریخ"
label=""
/>
</div>
{/* To Date */}
<div className="w-full">
<PersianDatePicker
value={toDate}
onChange={setToDate}
placeholder="تا تاریخ"
label=""
/>
</div>
</div>
)}
</div>
<div className="flex flex-row gap-6 w-full px-4 pt-6 pb-2">
<Button
onClick={() => filterTransactions()}
className="w-[100%]"
size={"xl"}
variant="dark"
disabled={!order || (order === "other" && !fromDate && !toDate)}
>
مشاهده نتایج
</Button>
</div>
</DrawerContent>
</Drawer>
);
};