- 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>
283 lines
9.3 KiB
TypeScript
283 lines
9.3 KiB
TypeScript
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",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
];
|
||
};
|