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

256 lines
8.9 KiB
TypeScript
Raw 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.

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