makearmy-app/components/buying-guide/BuyingGuideProductClient.tsx

257 lines
8.9 KiB
TypeScript
Raw Permalink Normal View History

2025-10-15 18:19:23 -04:00
"use client";
import { useMemo, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import ReactMarkdown from "react-markdown";
const API_URL = process.env.NEXT_PUBLIC_API_BASE_URL!;
2025-10-15 18:23:06 -04:00
const ASSET_BASE =
process.env.NEXT_PUBLIC_ASSET_URL || "https://forms.lasereverything.net";
2025-10-15 18:19:23 -04:00
type Score = { id: string | number; cat: string; value: number | string; body?: string };
type LinkItem = { id?: string | number; text?: string; url: string; target?: string };
type Entry = {
id: number | string;
product_make?: string;
product_model?: string;
product_price?: string;
review_overview_text?: string;
review_intro_text?: string;
author?: string;
rec_text?: string;
updates?: string;
video_review_url?: string;
2025-10-15 18:23:06 -04:00
header?: { id?: string; filename_disk?: string };
2025-10-15 18:19:23 -04:00
date_updated?: string | number;
links?: LinkItem[];
scores?: Score[];
};
export default function BuyingGuideProductClient() {
const searchParams = useSearchParams();
const router = useRouter();
const productId = searchParams.get("product");
const [entry, setEntry] = useState<Entry | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// Fetch the entry when productId changes
useEffect(() => {
let abort = false;
async function run() {
if (!productId) return;
setLoading(true);
setError(null);
try {
const res = await fetch(
2025-10-15 18:23:06 -04:00
`${API_URL}/items/bg_entries/${productId}?fields=*,links.id,links.text,links.url,links.target,scores.id,scores.cat,scores.value,scores.body,header.id,header.filename_disk,date_updated`,
2025-10-15 18:19:23 -04:00
{ cache: "no-store" }
);
if (!res.ok) throw new Error(await res.text());
const { data } = await res.json();
if (!abort) setEntry(data);
} catch (e: any) {
if (!abort) setError(e?.message || "Failed to load product");
} finally {
if (!abort) setLoading(false);
}
}
run();
return () => {
abort = true;
};
}, [productId]);
const avgScore = useMemo(() => {
if (!entry?.scores?.length) return "N/A";
const sum = entry.scores.reduce((s, it) => s + Number(it.value ?? 0), 0);
return (sum / entry.scores.length).toFixed(1);
}, [entry]);
2025-10-15 18:23:06 -04:00
// Prefer the public filename_disk path (like the list view).
2025-10-15 18:19:23 -04:00
const headerUrl =
2025-10-15 18:23:06 -04:00
entry?.header?.filename_disk
? `${ASSET_BASE}/assets/${entry.header.filename_disk}`
: entry?.header?.id
? `${ASSET_BASE}/assets/${entry.header.id}?cache-buster=${entry.date_updated}&key=system-large-contain`
2025-10-15 18:19:23 -04:00
: null;
if (!productId) return null; // switcher guards this, but be defensive
if (loading) {
return <div className="max-w-4xl mx-auto px-4 py-8">Loading</div>;
}
if (error) {
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<p className="text-red-500 text-sm">Error: {error}</p>
<button
className="text-blue-500 underline mt-2"
onClick={() => {
const sp = new URLSearchParams(Array.from(searchParams.entries()));
sp.delete("product");
router.push(`?${sp.toString()}`, { scroll: false });
}}
>
Back to Buying Guide
</button>
</div>
);
}
if (!entry) return null;
return (
<div className="max-w-4xl mx-auto px-4 py-8 space-y-6">
{/* Header Banner */}
{headerUrl && (
<div className="w-full h-64 relative overflow-hidden rounded-xl shadow">
2025-10-15 18:23:06 -04:00
<img
src={headerUrl}
alt="Header Image"
className="object-cover w-full h-full rounded-xl"
/>
2025-10-15 18:19:23 -04:00
</div>
)}
{/* Title */}
<div className="flex justify-between items-start">
<div>
<h2 className="text-xl font-semibold text-white">{entry.product_make}</h2>
<h1 className="text-4xl font-bold text-yellow-500 mt-2">{entry.product_model}</h1>
</div>
<div className="text-right">
{entry.product_price && (
<p className="text-lg text-white font-medium mt-1">
{entry.product_price.startsWith("Starting at")
? entry.product_price
: `Starting at ${entry.product_price}`}
</p>
)}
<button
onClick={() => {
const sp = new URLSearchParams(Array.from(searchParams.entries()));
sp.delete("product");
router.push(`?${sp.toString()}`, { scroll: false });
}}
className="text-sm text-blue-500 underline mt-2 inline-block"
>
Back to Buying Guide
</button>
</div>
</div>
{/* Links & Score Summary */}
{(Array.isArray(entry.links) || Array.isArray(entry.scores)) && (
<div className="flex flex-col md:flex-row gap-8">
{Array.isArray(entry.links) && entry.links.length > 0 && (
<div className="md:w-1/2">
<h3 className="text-xl font-semibold mb-2">Links</h3>
<ul className="list-disc ml-6 space-y-1">
{entry.links.map((link: any, idx: number) => (
<li key={idx}>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-700 underline"
>
{link.text || link.url}
</a>
2025-10-15 18:23:06 -04:00
{link.target && (
<span className="text-sm text-gray-500"> ({link.target})</span>
)}
2025-10-15 18:19:23 -04:00
</li>
))}
</ul>
</div>
)}
{Array.isArray(entry.scores) && entry.scores.length > 0 && (
<div className="md:w-1/2">
<h3 className="text-xl font-semibold mb-2">Score Summary</h3>
<ul className="space-y-1">
{entry.scores.map((s: any, idx: number) => (
<li key={idx} className="flex justify-between">
<span>{s.cat}</span>
<span className="font-semibold">{s.value}/10</span>
</li>
))}
</ul>
<p className="mt-2 font-bold text-right">Total: {avgScore}</p>
</div>
)}
</div>
)}
{/* Overview */}
{entry.review_overview_text && (
<div className="prose max-w-none">
<h3 className="text-xl font-semibold mb-2">Overview</h3>
<ReactMarkdown>{entry.review_overview_text}</ReactMarkdown>
</div>
)}
{/* Intro */}
{entry.review_intro_text && (
<div className="prose max-w-none">
<h3 className="text-xl font-semibold mb-2">
{`${entry.product_make}, ${entry.product_model} Review by ${entry.author}`}
</h3>
<ReactMarkdown>{entry.review_intro_text}</ReactMarkdown>
</div>
)}
{/* Detailed Scores */}
{Array.isArray(entry.scores) && entry.scores.length > 0 && (
<div className="space-y-4">
{entry.scores.map((s: any, idx: number) => (
<div key={idx} className="p-4 rounded border">
<p className="text-xl font-semibold">
{s.cat} <span className="text-blue-600">{s.value}/10</span>
</p>
<div className="text-sm text-gray-400">
<ReactMarkdown>{s.body}</ReactMarkdown>
</div>
</div>
))}
</div>
)}
{/* Recommendation */}
{entry.rec_text && (
<div className="prose max-w-none">
<h3 className="text-xl font-semibold mb-2">Recommendation</h3>
<ReactMarkdown>{entry.rec_text}</ReactMarkdown>
</div>
)}
{/* Updates */}
{entry.updates && (
<div className="prose max-w-none">
<h3 className="text-xl font-semibold mb-2">Updates</h3>
<ReactMarkdown>{entry.updates}</ReactMarkdown>
</div>
)}
{/* Video */}
{entry.video_review_url && (
<div>
<h3 className="text-xl font-semibold mb-2">Video Review</h3>
<div className="aspect-w-16 aspect-h-9">
<iframe
src={entry.video_review_url.replace("watch?v=", "embed/")}
className="w-full h-96 rounded"
frameBorder="0"
allowFullScreen
/>
</div>
</div>
)}
</div>
);
}