background remover fix
This commit is contained in:
parent
da4d0620a4
commit
22bc048475
3 changed files with 302 additions and 55 deletions
|
|
@ -6,7 +6,7 @@ import Link from "next/link";
|
|||
import Image from "next/image";
|
||||
|
||||
interface Entry {
|
||||
id: number;
|
||||
id: number | string;
|
||||
product_make: string;
|
||||
product_model: string;
|
||||
product_price?: string;
|
||||
|
|
@ -58,8 +58,10 @@ export default function BuyingGuidePage() {
|
|||
const fetchData = async () => {
|
||||
try {
|
||||
const [entriesRes, catRes, subCatRes] = await Promise.all([
|
||||
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/bg_entries?fields=id,index.id,index.filename_disk,index.type,header.id,header.filename_disk,product_make,product_model,product_price,review_overview_text,bg_entry_cat,bg_entry_sub_cat&limit=-1&sort[]=sort`),
|
||||
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/bg_cat?fields=id,name&limit=-1`),
|
||||
fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/bg_entries?fields=id,index.id,index.filename_disk,index.type,header.id,header.filename_disk,product_make,product_model,product_price,review_overview_text,bg_entry_cat,bg_entry_sub_cat&limit=-1&sort[]=sort`
|
||||
),
|
||||
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/bg_cat?fields=id,name&limit=-1`),
|
||||
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/bg_sub_cat?fields=id,name,bg_entry_cat&limit=-1`),
|
||||
]);
|
||||
|
||||
|
|
@ -99,9 +101,7 @@ export default function BuyingGuidePage() {
|
|||
}, [entries, debouncedQuery, selectedCat, selectedSubCat]);
|
||||
|
||||
const filteredSubcategories = useMemo(() => {
|
||||
return selectedCat
|
||||
? subcategories.filter((sub) => sub.bg_entry_cat === parseInt(selectedCat))
|
||||
: subcategories;
|
||||
return selectedCat ? subcategories.filter((sub) => sub.bg_entry_cat === parseInt(selectedCat)) : subcategories;
|
||||
}, [subcategories, selectedCat]);
|
||||
|
||||
const featuredEntry = useMemo(() => {
|
||||
|
|
@ -119,6 +119,13 @@ export default function BuyingGuidePage() {
|
|||
return entries[secondIndex];
|
||||
}, [entries, featuredEntry]);
|
||||
|
||||
// Build a URL that sets ?product=<id> while preserving any existing params.
|
||||
const makeProductHref = (id: number | string) => {
|
||||
const sp = new URLSearchParams(Array.from(searchParams.entries()));
|
||||
sp.set("product", String(id));
|
||||
return `?${sp.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<style jsx global>{`
|
||||
|
|
@ -198,18 +205,14 @@ export default function BuyingGuidePage() {
|
|||
placeholder="Search products by make, model, etc..."
|
||||
className="w-full mb-4 dark:bg-background border border-border rounded-md p-2"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Discover reviewed laser products and accessories.
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
className="inline-block mt-2 px-4 py-2 bg-accent text-background rounded-md text-sm"
|
||||
>
|
||||
<p className="text-sm text-muted-foreground mb-2">Discover reviewed laser products and accessories.</p>
|
||||
<a href="/" className="inline-block mt-2 px-4 py-2 bg-accent text-background rounded-md text-sm">
|
||||
← Back to Main Menu
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{[featuredEntry, secondFeaturedEntry].map((entry, idx) => (
|
||||
{[featuredEntry, secondFeaturedEntry].map(
|
||||
(entry, idx) =>
|
||||
entry && (
|
||||
<div key={idx} className="card bg-card text-card-foreground p-4">
|
||||
<h2 className="text-md font-semibold mb-2">Featured Product</h2>
|
||||
|
|
@ -227,21 +230,14 @@ export default function BuyingGuidePage() {
|
|||
No Header
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
href={`/buying-guide/product/${entry.id}`}
|
||||
className="text-accent font-semibold text-lg hover:underline"
|
||||
>
|
||||
<Link href={makeProductHref(entry.id)} className="text-accent font-semibold text-lg hover:underline">
|
||||
{entry.product_make} {entry.product_model}
|
||||
</Link>
|
||||
{entry.product_price && (
|
||||
<p className="text-sm text-white">Starting at {entry.product_price}</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{entry.review_overview_text?.slice(0, 140)}...
|
||||
</p>
|
||||
{entry.product_price && <p className="text-sm text-white">Starting at {entry.product_price}</p>}
|
||||
<p className="text-sm text-muted-foreground mt-1">{entry.review_overview_text?.slice(0, 140)}...</p>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
)}
|
||||
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h2 className="text-md font-semibold mb-2">Popular Categories</h2>
|
||||
|
|
@ -267,7 +263,7 @@ export default function BuyingGuidePage() {
|
|||
<ul className="text-sm space-y-1">
|
||||
{entries.slice(0, 3).map((e) => (
|
||||
<li key={e.id}>
|
||||
<Link href={`/buying-guide/product/${e.id}`} className="text-accent hover:underline">
|
||||
<Link href={makeProductHref(e.id)} className="text-accent hover:underline">
|
||||
{e.product_make} {e.product_model}
|
||||
</Link>
|
||||
</li>
|
||||
|
|
@ -278,7 +274,8 @@ export default function BuyingGuidePage() {
|
|||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h2 className="text-md font-semibold mb-2">What Is This?</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This Buying Guide helps you compare laser-related gear with hands-on reviews, scores, and recommendations. Use the filters and search to find what you’re looking for!
|
||||
This Buying Guide helps you compare laser-related gear with hands-on reviews, scores, and recommendations.
|
||||
Use the filters and search to find what you’re looking for!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -305,26 +302,20 @@ export default function BuyingGuidePage() {
|
|||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="entry-image bg-zinc-800 flex items-center justify-center text-zinc-400">
|
||||
No Image
|
||||
</div>
|
||||
<div className="entry-image bg-zinc-800 flex items-center justify-center text-zinc-400">No Image</div>
|
||||
)}
|
||||
<div className="entry-content">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground truncate-title">
|
||||
{entry.product_make}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-muted-foreground truncate-title">{entry.product_make}</p>
|
||||
<Link
|
||||
href={`/buying-guide/product/${entry.id}`}
|
||||
href={makeProductHref(entry.id)}
|
||||
className="text-lg font-semibold text-accent underline truncate-title"
|
||||
title={entry.product_model}
|
||||
>
|
||||
{entry.product_model}
|
||||
</Link>
|
||||
{entry.product_price !== undefined && (
|
||||
<p className="text-sm text-foreground mt-1 font-medium">
|
||||
Starting at {entry.product_price}
|
||||
</p>
|
||||
<p className="text-sm text-foreground mt-1 font-medium">Starting at {entry.product_price}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{entry.review_overview_text?.slice(0, 120)}...
|
||||
|
|
@ -339,4 +330,3 @@ export default function BuyingGuidePage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
246
components/buying-guide/BuyingGuideProductClient.tsx
Normal file
246
components/buying-guide/BuyingGuideProductClient.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
"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_URL = process.env.NEXT_PUBLIC_ASSET_URL!;
|
||||
|
||||
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 };
|
||||
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,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]);
|
||||
|
||||
const headerUrl =
|
||||
entry?.header?.id
|
||||
? `${ASSET_URL}/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>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import { useSearchParams, useRouter } from "next/navigation";
|
|||
import dynamic from "next/dynamic";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// these should already exist under components/buying-guide/*
|
||||
const BuyingGuideList = dynamic(
|
||||
() => import("@/components/buying-guide/BuyingGuideList"),
|
||||
{ ssr: false }
|
||||
|
|
@ -14,36 +13,41 @@ const LaserFinderPanel = dynamic(
|
|||
() => import("@/components/buying-guide/LaserFinderPanel"),
|
||||
{ ssr: false }
|
||||
);
|
||||
const BuyingGuideProductClient = dynamic(
|
||||
() => import("@/components/buying-guide/BuyingGuideProductClient"),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const TABS = [
|
||||
{ key: "list", label: "Guide" },
|
||||
{ key: "list", label: "Buying Guide" },
|
||||
{ key: "finder", label: "Laser Finder" },
|
||||
] as const;
|
||||
];
|
||||
|
||||
export default function BuyingGuideSwitcher() {
|
||||
const sp = useSearchParams();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const active = useMemo(() => {
|
||||
const t = (sp.get("bg") || TABS[0].key).toLowerCase();
|
||||
return TABS.some(x => x.key === t) ? t : TABS[0].key;
|
||||
}, [sp]);
|
||||
const active = useMemo(() => searchParams.get("tab") || "list", [searchParams]);
|
||||
const productId = searchParams.get("product");
|
||||
|
||||
function setTab(k: string) {
|
||||
const q = new URLSearchParams(sp.toString());
|
||||
q.set("bg", k);
|
||||
router.replace(`/portal/buying-guide?${q.toString()}`, { scroll: false });
|
||||
}
|
||||
const setTab = (key: string) => {
|
||||
const sp = new URLSearchParams(Array.from(searchParams.entries()));
|
||||
sp.set("tab", key);
|
||||
// When changing tabs, ensure product detail (if open) is cleared
|
||||
sp.delete("product");
|
||||
router.push(`?${sp.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
{TABS.map(t => (
|
||||
{/* Tabs */}
|
||||
<div className="inline-flex rounded-md border overflow-hidden">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-1.5 text-sm",
|
||||
"px-3 py-2 text-sm transition-colors",
|
||||
active === t.key ? "bg-primary text-primary-foreground" : "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
|
|
@ -52,8 +56,15 @@ export default function BuyingGuideSwitcher() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="rounded-md border p-4">
|
||||
{active === "finder" ? <LaserFinderPanel /> : <BuyingGuideList />}
|
||||
{productId ? (
|
||||
<BuyingGuideProductClient />
|
||||
) : active === "finder" ? (
|
||||
<LaserFinderPanel />
|
||||
) : (
|
||||
<BuyingGuideList />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue