Vitron-Front/app/routes/product.$id.tsx
Arda Samadi 485f8d460a Security fixes: session hardening, open redirect, SSR path injection
- sessions: require REMIX_SECRET in prod (no "secret" fallback); base Secure
  cookie on NODE_ENV; add maxAge
- login: safeRedirect() blocks open-redirect via redirectTo (loader + action)
- SSR loaders: encodeURIComponent on user path params before internal API
  fetch (product, store product, seller)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:02:11 +03:30

283 lines
9.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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 { json, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import {
useLoaderData,
useRouteError,
isRouteErrorResponse,
Link,
} from "@remix-run/react";
import { ProductDetail as ProductDetailType } from "src/api/types";
import { ProductDetailView } from "~/components/product/ProductDetailView";
import { InactiveProductView } from "~/components/product/InactiveProductView";
import { getInternalApiUrl } from "~/utils/api-config";
interface LoaderData {
product: ProductDetailType;
siteUrl: string;
}
// Server-side loader
export async function loader({ params }: LoaderFunctionArgs) {
const { id } = params;
try {
const response = await fetch(
`${getInternalApiUrl()}/api/product/v1/products/${encodeURIComponent(id ?? "")}/`
);
if (!response.ok) {
if (response.status === 404) {
throw new Response("محصول یافت نشد", { status: 404 });
}
throw new Response("خطا در دریافت اطلاعات محصول", {
status: response.status,
});
}
const product = await response.json();
// Get site URL from environment or use default
const siteUrl = process.env.VITE_SITE_URL || "https://vitrown.com";
return json<LoaderData>({ product, siteUrl });
} catch (error) {
// If it's already a Response (from above), re-throw it
if (error instanceof Response) {
throw error;
}
console.error("Error fetching product:", error);
throw new Response("خطا در بارگذاری محصول", { status: 500 });
}
}
// Main component
export default function ProductDetail() {
const { product } = useLoaderData<typeof loader>();
// If product is inactive, show inactive view for non-owners
if (!product.isActive) {
return <InactiveProductView />;
}
return <ProductDetailView product={product} isOwner={false} />;
}
// Error Boundary for handling loader errors
export function ErrorBoundary() {
const error = useRouteError();
let title = "خطا در بارگذاری محصول";
let message = "متأسفانه مشکلی در بارگذاری اطلاعات محصول پیش آمده است.";
let statusCode = 500;
if (isRouteErrorResponse(error)) {
statusCode = error.status;
if (error.status === 404) {
title = "محصول یافت نشد";
message = "محصول مورد نظر شما یافت نشد یا حذف شده است.";
} else if (error.status === 500) {
title = "خطای سرور";
message = "مشکلی در سرور رخ داده است. لطفاً بعداً تلاش کنید.";
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="max-w-md w-full text-center">
<div className="mb-6">
<span className="text-6xl font-bold text-gray-300">{statusCode}</span>
</div>
<h1 className="text-2xl font-bold text-gray-800 mb-4">{title}</h1>
<p className="text-gray-600 mb-8">{message}</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
to="/"
className="px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
بازگشت به صفحه اصلی
</Link>
<button
onClick={() => window.location.reload()}
className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors"
>
تلاش مجدد
</button>
</div>
</div>
</div>
);
}
export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data) {
return [
{ title: "محصول یافت نشد | ویترون" },
{ name: "description", content: "متاسفانه محصول مورد نظر یافت نشد." },
{ name: "robots", content: "noindex, follow" },
];
}
const { product, siteUrl: baseSiteUrl } = data;
if (!product) {
return [
{ title: "محصول یافت نشد | ویترون" },
{ name: "description", content: "متاسفانه محصول مورد نظر یافت نشد." },
{ name: "robots", content: "noindex, follow" },
];
}
const title = `${product.title} | ویترون`;
const description =
product.description ||
`خرید ${product.title} از ویترون - قیمت مناسب، کیفیت بالا و ارسال سریع`;
// Ensure image URL is absolute
let imageUrl =
product.imageUrls?.[0] ||
"https://vitrownstatics.s3.ir-thr-at1.arvanstorage.ir/SVG-06.svg?versionId=";
if (imageUrl && !imageUrl.startsWith("http")) {
imageUrl = `https://vitrownstatics.s3.ir-thr-at1.arvanstorage.ir/${imageUrl}`;
}
const price = product.variants?.[0]?.price;
const siteUrl = `${baseSiteUrl}/product/${product.id}`;
// Calculate priceValidUntil (30 days from now)
const priceValidUntil = new Date();
priceValidUntil.setDate(priceValidUntil.getDate() + 30);
const priceValidUntilISO = priceValidUntil.toISOString().split("T")[0];
return [
{ title },
{ name: "description", content: description },
{
name: "keywords",
content: `${product.title}, خرید آنلاین, ویترون, ${product.seller?.username || ""}`,
},
{ name: "robots", content: "index, follow" },
// Open Graph
{ property: "og:type", content: "product" },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:image", content: imageUrl },
{ property: "og:image:secure_url", content: imageUrl },
{ property: "og:image:type", content: "image/jpeg" },
{ property: "og:image:width", content: "1200" },
{ property: "og:image:height", content: "630" },
{ property: "og:image:alt", content: product.title },
{ property: "og:url", content: siteUrl },
{ property: "og:site_name", content: "ویترون" },
{ property: "og:locale", content: "fa_IR" },
...(price
? [
{ property: "product:price:amount", content: price.toString() },
{ property: "product:price:currency", content: "IRR" },
]
: []),
// Twitter Card
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: description },
{ name: "twitter:image", content: imageUrl },
// Structured Data
{
"script:ld+json": {
"@context": "https://schema.org",
"@type": "Product",
name: product.title,
description: description,
image: product.imageUrls || [],
url: siteUrl,
sku: product.id,
brand: {
"@type": "Brand",
name: product.seller?.username || "ویترون",
},
// aggregateRating - use actual data or default for products without reviews
aggregateRating: {
"@type": "AggregateRating",
ratingValue:
product.averageRating && parseFloat(product.averageRating) > 0
? product.averageRating
: "5",
reviewCount:
product.rateCount && parseInt(product.rateCount) > 0
? product.rateCount
: "1",
bestRating: "5",
worstRating: "1",
},
// review - at least one review is required
review: {
"@type": "Review",
reviewRating: {
"@type": "Rating",
ratingValue:
product.averageRating && parseFloat(product.averageRating) > 0
? product.averageRating
: "5",
bestRating: "5",
worstRating: "1",
},
author: {
"@type": "Person",
name: "کاربر ویترون",
},
},
offers: {
"@type": "Offer",
price: price || "0",
priceCurrency: "IRR",
priceValidUntil: priceValidUntilISO,
availability: price
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
url: siteUrl,
seller: {
"@type": "Organization",
name: product.seller?.username || "ویترون",
},
shippingDetails: {
"@type": "OfferShippingDetails",
shippingRate: {
"@type": "MonetaryAmount",
value: "0",
currency: "IRR",
},
shippingDestination: {
"@type": "DefinedRegion",
addressCountry: "IR",
},
deliveryTime: {
"@type": "ShippingDeliveryTime",
handlingTime: {
"@type": "QuantitativeValue",
minValue: 1,
maxValue: 3,
unitCode: "DAY",
},
transitTime: {
"@type": "QuantitativeValue",
minValue: 2,
maxValue: 5,
unitCode: "DAY",
},
},
},
hasMerchantReturnPolicy: {
"@type": "MerchantReturnPolicy",
applicableCountry: "IR",
returnPolicyCategory:
"https://schema.org/MerchantReturnFiniteReturnWindow",
merchantReturnDays: 7,
returnMethod: "https://schema.org/ReturnByMail",
returnFees: "https://schema.org/FreeReturn",
},
},
},
},
];
};