Create monorepo from known-good production state
This commit is contained in:
commit
c034824338
651 changed files with 120469 additions and 0 deletions
256
app/components/buying-guide/BuyingGuideProductClient.tsx
Normal file
256
app/components/buying-guide/BuyingGuideProductClient.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue