background remover fix

This commit is contained in:
makearmy 2025-10-15 18:19:23 -04:00
parent da4d0620a4
commit 22bc048475
3 changed files with 302 additions and 55 deletions

View file

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

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

View file

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