256 lines
8.9 KiB
TypeScript
256 lines
8.9 KiB
TypeScript
"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!;
|
||
const ASSET_BASE =
|
||
process.env.NEXT_PUBLIC_ASSET_URL || "https://forms.lasereverything.net";
|
||
|
||
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;
|
||
header?: { id?: string; filename_disk?: string };
|
||
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(
|
||
`${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`,
|
||
{ 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]);
|
||
|
||
// Prefer the public filename_disk path (like the list view).
|
||
const headerUrl =
|
||
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`
|
||
: 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">
|
||
<img
|
||
src={headerUrl}
|
||
alt="Header Image"
|
||
className="object-cover w-full h-full rounded-xl"
|
||
/>
|
||
</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>
|
||
{link.target && (
|
||
<span className="text-sm text-gray-500"> ({link.target})</span>
|
||
)}
|
||
</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>
|
||
);
|
||
}
|