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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|