search, db integration

This commit is contained in:
2026-06-05 21:47:00 -04:00
parent 365d1281dd
commit 71d7a9d6f0
25 changed files with 1573 additions and 244 deletions

View File

@@ -0,0 +1,98 @@
/**
* GET /api/plants/suggestions?q=<term>
*
* Returns autocomplete suggestions for the navbar search-as-you-type feature.
* Queries both plants and diseases from the database and returns an interleaved
* list with at most 8 suggestions total.
*
* Each suggestion includes: type (plant|disease), id, label, subtitle, emoji, href.
* Plants link to their browse detail page; diseases link to the plant page with
* a hash anchor to the specific disease card.
*/
import { NextRequest, NextResponse } from "next/server";
import { like, or, eq } from "drizzle-orm";
import { getDb } from "@/lib/db/index";
import { plants, diseases } from "@/lib/db/schema";
import { getEmojiForCategory } from "@/lib/display-helpers";
export const dynamic = "force-dynamic";
interface SuggestionItem {
type: "plant" | "disease";
id: string;
label: string;
subtitle: string;
emoji: string;
href: string;
}
export async function GET(request: NextRequest) {
const q = request.nextUrl.searchParams.get("q")?.trim() ?? "";
// Empty or very short queries return no suggestions
if (q.length < 1) {
return NextResponse.json({ suggestions: [] });
}
const db = getDb();
const term = `%${q.toLowerCase()}%`;
// Fetch matching plants (by common name or scientific name)
const plantRows = await db
.select({
id: plants.id,
commonName: plants.commonName,
scientificName: plants.scientificName,
category: plants.category,
})
.from(plants)
.where(or(like(plants.commonName, term), like(plants.scientificName, term)))
.limit(5);
// Fetch matching diseases (by name or scientific name) with parent plant info
const diseaseRows = await db
.select({
id: diseases.id,
name: diseases.name,
plantId: diseases.plantId,
plantCommonName: plants.commonName,
plantCategory: plants.category,
})
.from(diseases)
.leftJoin(plants, eq(diseases.plantId, plants.id))
.where(or(like(diseases.name, term), like(diseases.scientificName, term)))
.limit(5);
const plantSuggestions: SuggestionItem[] = plantRows.map((p) => ({
type: "plant" as const,
id: p.id,
label: p.commonName,
subtitle: p.scientificName,
emoji: getEmojiForCategory(p.category),
href: `/browse/${p.id}`,
}));
const diseaseSuggestions: SuggestionItem[] = diseaseRows.map((d) => ({
type: "disease" as const,
id: d.id,
label: d.name,
subtitle: `Disease on ${d.plantCommonName ?? "Unknown plant"}`,
emoji: getEmojiForCategory(d.plantCategory ?? "houseplant"),
href: `/browse/${d.plantId}#disease-${d.id}`,
}));
// Interleave plant and disease results so the dropdown shows variety
const interleaved: SuggestionItem[] = [];
const maxLen = Math.max(plantSuggestions.length, diseaseSuggestions.length);
for (let i = 0; i < maxLen && interleaved.length < 8; i++) {
if (i < plantSuggestions.length) {
interleaved.push(plantSuggestions[i]);
}
if (i < diseaseSuggestions.length && interleaved.length < 8) {
interleaved.push(diseaseSuggestions[i]);
}
}
return NextResponse.json({ suggestions: interleaved });
}

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import BrowseContent from "@/app/browse/BrowseContent";
import type { PlantCardData } from "@/components/PlantCard";
// Mock Next.js navigation
vi.mock("next/navigation", () => ({
@@ -11,10 +12,9 @@ vi.mock("next/navigation", () => ({
// Mock PlantCard
vi.mock("@/components/PlantCard", () => ({
default: ({ plant }: any) => (
default: ({ plant }: { plant: PlantCardData }) => (
<div data-testid={`plant-card-${plant.id}`}>
<span>{plant.commonName}</span>
<span>{plant.emoji}</span>
</div>
),
}));
@@ -30,18 +30,69 @@ vi.mock("@/components/EmptyState", () => ({
),
}));
const MOCK_PLANTS: PlantCardData[] = [
{
id: "tomato",
commonName: "Tomato",
scientificName: "Solanum lycopersicum",
family: "Solanaceae",
category: "vegetable",
diseaseCount: 15,
},
{
id: "basil",
commonName: "Basil",
scientificName: "Ocimum basilicum",
family: "Lamiaceae",
category: "herb",
diseaseCount: 3,
},
{
id: "rose",
commonName: "Rose",
scientificName: "Rosa spp.",
family: "Rosaceae",
category: "flower",
diseaseCount: 7,
},
{
id: "monstera",
commonName: "Monstera",
scientificName: "Monstera deliciosa",
family: "Araceae",
category: "houseplant",
diseaseCount: 5,
},
{
id: "snake-plant",
commonName: "Snake Plant",
scientificName: "Dracaena trifasciata",
family: "Asparagaceae",
category: "houseplant",
diseaseCount: 2,
},
{
id: "pepper",
commonName: "Bell Pepper",
scientificName: "Capsicum annuum",
family: "Solanaceae",
category: "vegetable",
diseaseCount: 9,
},
];
describe("BrowseContent", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders page header with plant count", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
expect(screen.getByText("Browse Plants")).toBeInTheDocument();
});
it("renders search input", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox", {
name: /Search plants and diseases/i,
});
@@ -49,7 +100,7 @@ describe("BrowseContent", () => {
});
it("filters plants by search query", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "tomato" } });
@@ -59,12 +110,12 @@ describe("BrowseContent", () => {
});
it("shows results count", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
expect(screen.getByText(/Showing \d+ plants/i)).toBeInTheDocument();
});
it("renders category filter tabs", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const tablist = screen.getByRole("tablist", { name: /Plant categories/i });
expect(tablist).toBeInTheDocument();
@@ -74,7 +125,7 @@ describe("BrowseContent", () => {
});
it("filters by category when tab is clicked", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const tabs = screen.getAllByRole("tab");
// Click a category tab (not 'all')
@@ -86,7 +137,7 @@ describe("BrowseContent", () => {
});
it("clears search when clear button is clicked", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "tomato" } });
@@ -99,7 +150,7 @@ describe("BrowseContent", () => {
});
it("shows empty state when no plants match search", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "xyznonexistent123" } });
@@ -108,7 +159,7 @@ describe("BrowseContent", () => {
});
it("shows empty state with search query in description", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "xyznonexistent123" } });
@@ -117,7 +168,7 @@ describe("BrowseContent", () => {
});
it("shows matching text in results count", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "tomato" } });
@@ -126,14 +177,14 @@ describe("BrowseContent", () => {
});
it("renders all plant cards when no filter applied", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
// Should show all plants
const plantCards = screen.getAllByTestId(/plant-card-/);
expect(plantCards.length).toBeGreaterThan(0);
expect(plantCards.length).toBe(MOCK_PLANTS.length);
});
it("searches by scientific name", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "solanum" } });
@@ -142,7 +193,7 @@ describe("BrowseContent", () => {
});
it("searches by family name", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "solanaceae" } });

View File

@@ -4,16 +4,21 @@ import React, { useState, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import PlantCard from "@/components/PlantCard";
import EmptyState from "@/components/EmptyState";
import { plants, type Plant } from "@/data/plants";
import { PLANT_CATEGORIES } from "@/lib/constants";
import type { PlantCardData } from "@/components/PlantCard";
type Category = Plant["category"] | "all";
interface BrowseContentProps {
allPlants: PlantCardData[];
}
type Category = string | "all";
/**
* Client component that handles the interactive browse/search/filter logic.
* Receives all plants as props from the parent server component.
* Wrapped in a Suspense boundary in the parent page.
*/
export default function BrowseContent() {
export default function BrowseContent({ allPlants }: BrowseContentProps) {
const searchParams = useSearchParams();
const initialSearch = searchParams.get("search") || "";
@@ -21,7 +26,7 @@ export default function BrowseContent() {
const [activeCategory, setActiveCategory] = useState<Category>("all");
const filteredPlants = useMemo(() => {
let result = plants;
let result = allPlants;
if (activeCategory !== "all") {
result = result.filter((p) => p.category === activeCategory);
@@ -33,24 +38,20 @@ export default function BrowseContent() {
(p) =>
p.commonName.toLowerCase().includes(q) ||
p.scientificName.toLowerCase().includes(q) ||
p.family.toLowerCase().includes(q) ||
p.diseases.some((d) => d.name.toLowerCase().includes(q))
p.family.toLowerCase().includes(q),
);
}
return result;
}, [activeCategory, searchQuery]);
}, [activeCategory, searchQuery, allPlants]);
return (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
{/* Page header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-zinc-900 dark:text-zinc-100">
Browse Plants
</h1>
<h1 className="text-3xl font-bold text-zinc-900 dark:text-zinc-100">Browse Plants</h1>
<p className="mt-2 text-zinc-500 dark:text-zinc-400">
Explore our database of {plants.length} plants and their common
diseases.
Explore our database of {allPlants.length} plants and their common diseases.
</p>
</div>
@@ -79,7 +80,7 @@ export default function BrowseContent() {
<input
id="browse-search"
type="search"
placeholder="Search by plant name, scientific name, or disease..."
placeholder="Search by plant name, scientific name, or family..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 pl-10 pr-4 py-3 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:border-leaf-green-500 transition-all shadow-sm"
@@ -112,11 +113,7 @@ export default function BrowseContent() {
</div>
{/* Category filter chips */}
<div
className="flex flex-wrap gap-2 mb-8"
role="tablist"
aria-label="Plant categories"
>
<div className="flex flex-wrap gap-2 mb-8" role="tablist" aria-label="Plant categories">
{PLANT_CATEGORIES.map((cat) => (
<button
key={cat.value}

View File

@@ -1,44 +1,50 @@
import React from "react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getPlantById, type Disease } from "@/data/plants";
import type { Metadata } from "next";
import { getPlantWithDiseases } from "@/lib/api/diseases-db";
import { getEmojiForCategory, getPlantDescription } from "@/lib/display-helpers";
import type { Disease, CausalAgentType, Severity } from "@/lib/types";
interface Props {
params: Promise<{ plantId: string }>;
}
export async function generateStaticParams() {
const { plants } = await import("@/data/plants");
return plants.map((plant) => ({
plantId: plant.id,
const { getDb } = await import("@/lib/db/index");
const { plants } = await import("@/lib/db/schema");
const db = getDb();
const rows = await db.select({ id: plants.id }).from(plants);
return rows.map((p: { id: string }) => ({
plantId: p.id,
}));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { plantId } = await params;
const plant = getPlantById(plantId);
const result = await getPlantWithDiseases(plantId);
if (!plant) {
if (!result) {
return { title: "Plant Not Found" };
}
return {
title: `${plant.commonName} — Diseases & Care`,
description: `Learn about ${plant.commonName} (${plant.scientificName}) diseases, symptoms, causes, and treatments. ${plant.diseases.length} diseases documented.`,
title: `${result.plant.commonName} — Diseases & Care`,
description: `Learn about ${result.plant.commonName} (${result.plant.scientificName}) diseases, symptoms, causes, and treatments. ${result.diseases.length} diseases documented.`,
};
}
/* ─── Severity badge ─── */
function SeverityBadge({ severity }: { severity: Disease["severity"] }) {
const colors: Record<Disease["severity"], string> = {
// ─── Severity badge ───
function SeverityBadge({ severity }: { severity: Severity }) {
const colors: Record<Severity, string> = {
low: "bg-leaf-green-100 text-leaf-green-800 dark:bg-leaf-green-900/40 dark:text-leaf-green-300",
moderate: "bg-warning-amber-100 text-warning-amber-800 dark:bg-warning-amber-900/40 dark:text-warning-amber-300",
moderate:
"bg-warning-amber-100 text-warning-amber-800 dark:bg-warning-amber-900/40 dark:text-warning-amber-300",
high: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
critical: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
};
const labels: Record<Disease["severity"], string> = {
const labels: Record<Severity, string> = {
low: "Low",
moderate: "Moderate",
high: "High",
@@ -55,26 +61,27 @@ function SeverityBadge({ severity }: { severity: Disease["severity"] }) {
);
}
/* ─── Disease type badge ─── */
function TypeBadge({ type }: { type: Disease["type"] }) {
const colors: Record<Disease["type"], string> = {
// ─── Disease type badge ───
function TypeBadge({ type }: { type: CausalAgentType }) {
const colors: Record<CausalAgentType, string> = {
fungal: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300",
bacterial: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
viral: "bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-300",
pest: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
physiological: "bg-zinc-100 text-zinc-800 dark:bg-zinc-700 dark:text-zinc-300",
environmental: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
};
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[type]}`}
>
{type.charAt(0).toUpperCase() + type.slice(1)}
{type === "environmental" ? "Environmental" : type.charAt(0).toUpperCase() + type.slice(1)}
</span>
);
}
/* ─── Disease card (expandable) ─── */
// ─── Disease card ───
function DiseaseCard({ disease }: { disease: Disease }) {
return (
<div
@@ -95,11 +102,23 @@ function DiseaseCard({ disease }: { disease: Disease }) {
)}
</div>
<div className="flex flex-wrap gap-2">
<TypeBadge type={disease.type} />
<TypeBadge type={disease.causalAgentType} />
<SeverityBadge severity={disease.severity} />
</div>
</div>
{/* Disease image */}
{disease.imageUrl && (
<div className="mb-4 rounded-lg overflow-hidden border border-zinc-200 dark:border-zinc-700">
<img
src={disease.imageUrl}
alt={`${disease.name} symptoms on ${disease.plantId}`}
className="w-full h-48 sm:h-64 object-cover"
loading="lazy"
/>
</div>
)}
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed mb-4">
{disease.description}
</p>
@@ -148,11 +167,8 @@ function DiseaseCard({ disease }: { disease: Disease }) {
<span aria-hidden="true">💊</span> Treatment Steps
</h4>
<ol className="space-y-1.5 list-decimal list-inside">
{disease.treatmentSteps.map((step, i) => (
<li
key={i}
className="text-sm text-zinc-600 dark:text-zinc-300"
>
{disease.treatment.map((step, i) => (
<li key={i} className="text-sm text-zinc-600 dark:text-zinc-300">
{step}
</li>
))}
@@ -165,7 +181,7 @@ function DiseaseCard({ disease }: { disease: Disease }) {
<span aria-hidden="true">🛡</span> Prevention Tips
</h4>
<ul className="space-y-1.5">
{disease.preventionTips.map((tip, i) => (
{disease.prevention.map((tip, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
@@ -182,35 +198,49 @@ function DiseaseCard({ disease }: { disease: Disease }) {
);
}
/* ─── Plant Detail Page ─── */
// ─── Plant Detail Page ───
export default async function PlantDetailPage({ params }: Props) {
const { plantId } = await params;
const plant = getPlantById(plantId);
const result = await getPlantWithDiseases(plantId);
if (!plant) {
if (!result) {
notFound();
}
const { plant, diseases } = result;
const emoji = getEmojiForCategory(plant.category);
const description = getPlantDescription(
plant.commonName,
plant.scientificName,
plant.category,
plant.family,
);
return (
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
{/* Breadcrumb */}
<nav className="mb-6 text-sm" aria-label="Breadcrumb">
<ol className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
<li>
<Link href="/" className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors">
<Link
href="/"
className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors"
>
Home
</Link>
</li>
<li aria-hidden="true">/</li>
<li>
<Link href="/browse" className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors">
<Link
href="/browse"
className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors"
>
Browse
</Link>
</li>
<li aria-hidden="true">/</li>
<li className="text-zinc-800 dark:text-zinc-200 font-medium">
{plant.commonName}
</li>
<li className="text-zinc-800 dark:text-zinc-200 font-medium">{plant.commonName}</li>
</ol>
</nav>
@@ -219,7 +249,7 @@ export default async function PlantDetailPage({ params }: Props) {
{/* Emoji illustration */}
<div className="flex items-center justify-center h-32 w-32 sm:h-40 sm:w-40 shrink-0 rounded-2xl bg-gradient-to-br from-leaf-green-50 to-leaf-green-100 dark:from-leaf-green-950 dark:to-leaf-green-900">
<span className="text-6xl sm:text-7xl" role="img" aria-hidden="true">
{plant.imageEmoji}
{emoji}
</span>
</div>
@@ -233,11 +263,10 @@ export default async function PlantDetailPage({ params }: Props) {
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Family: <span className="font-medium">{plant.family}</span>
{" · "}
Category:{" "}
<span className="font-medium capitalize">{plant.category}</span>
Category: <span className="font-medium capitalize">{plant.category}</span>
</p>
<p className="mt-3 text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">
{plant.description}
{description}
</p>
<div className="mt-3 flex items-start gap-2 text-sm text-zinc-500 dark:text-zinc-400">
<span aria-hidden="true">💚</span>
@@ -258,7 +287,7 @@ export default async function PlantDetailPage({ params }: Props) {
</p>
</div>
<Link
href="/browse"
href="/upload"
className="inline-flex items-center gap-2 shrink-0 rounded-lg bg-leaf-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
>
📸 Identify a Disease
@@ -272,20 +301,22 @@ export default async function PlantDetailPage({ params }: Props) {
Known Diseases
</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
{plant.diseases.length === 0
{diseases.length === 0
? "No diseases currently documented for this plant."
: `${plant.diseases.length} ${plant.diseases.length === 1 ? "disease" : "diseases"} documented for ${plant.commonName}.`}
: `${diseases.length} ${diseases.length === 1 ? "disease" : "diseases"} documented for ${plant.commonName}.`}
</p>
{plant.diseases.length > 0 ? (
{diseases.length > 0 ? (
<div className="space-y-6">
{plant.diseases.map((disease) => (
{diseases.map((disease) => (
<DiseaseCard key={disease.id} disease={disease} />
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-zinc-300 dark:border-zinc-700 p-10 text-center">
<span className="text-4xl block mb-3" aria-hidden="true">🌿</span>
<span className="text-4xl block mb-3" aria-hidden="true">
🌿
</span>
<p className="text-zinc-500 dark:text-zinc-400 text-sm">
Disease data for {plant.commonName} is being researched and will be added soon.
</p>

View File

@@ -1,12 +1,16 @@
import React, { Suspense } from "react";
import { getBrowsePlants } from "@/lib/api/browse";
import BrowseContent from "./BrowseContent";
import { PlantCardSkeleton } from "@/components/LoadingSkeleton";
/**
* Browse page requires a Suspense boundary because it uses useSearchParams().
* The actual interactive content is in BrowseContent (client component).
* Browse page — fetches plants with disease counts from the database
* and passes them to the client-side search/filter component.
* Requires a Suspense boundary because BrowseContent uses useSearchParams().
*/
export default function BrowsePage() {
export default async function BrowsePage() {
const allPlants = await getBrowsePlants();
return (
<Suspense
fallback={
@@ -28,7 +32,7 @@ export default function BrowsePage() {
</div>
}
>
<BrowseContent />
<BrowseContent allPlants={allPlants} />
</Suspense>
);
}

View File

@@ -2,26 +2,20 @@ import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import Page from "@/app/page";
// Mock PlantCard
vi.mock("@/components/PlantCard", () => ({
default: ({ plant }: { plant: any }) => (
<div data-testid={`plant-card-${plant.id}`}>{plant.commonName}</div>
// Mock FeaturedPlantsSection (async server component — mocked for testing)
vi.mock("@/components/FeaturedPlantsSection", () => ({
FeaturedPlantsGrid: () => (
<>
<div data-testid="plant-card-tomato">Tomato</div>
<div data-testid="plant-card-pepper">Pepper</div>
<div data-testid="plant-card-cucumber">Cucumber</div>
</>
),
}));
// Mock data/plants
vi.mock("@/data/plants", () => ({
getFeaturedPlants: vi.fn(() => [
{ id: "tomato", commonName: "Tomato", imageEmoji: "🍅", diseases: [] },
{ id: "pepper", commonName: "Pepper", imageEmoji: "🌶️", diseases: [] },
{ id: "cucumber", commonName: "Cucumber", imageEmoji: "🥒", diseases: [] },
]),
}));
describe("Homepage (page.tsx)", () => {
it("renders hero section with title", () => {
render(<Page />);
// Hero section has the app tagline
expect(screen.getByText(/Snap. Identify. Treat/i)).toBeInTheDocument();
});
@@ -66,8 +60,7 @@ describe("Homepage (page.tsx)", () => {
it("renders trust signals", () => {
render(<Page />);
// Trust signals should be present
const trustSignals = screen.queryAllByText(/95/i);
const trustSignals = screen.queryAllByText(/300\+ plants/i);
expect(trustSignals.length).toBeGreaterThanOrEqual(0);
});

View File

@@ -1,12 +1,8 @@
import React from "react";
import Link from "next/link";
import PlantCard from "@/components/PlantCard";
import { getFeaturedPlants } from "@/data/plants";
import { FeaturedPlantsGrid } from "@/components/FeaturedPlantsSection";
import { TRUST_SIGNALS, HOW_IT_WORKS, APP_NAME, APP_TAGLINE } from "@/lib/constants";
export default function HomePage() {
const featuredPlants = getFeaturedPlants();
return (
<div className="flex flex-col">
{/* ─── Hero Section ─── */}
@@ -29,9 +25,8 @@ export default function HomePage() {
</h1>
<p className="mt-4 text-lg sm:text-xl text-zinc-600 dark:text-zinc-400 max-w-xl">
Upload a photo of your plant and get a hyper-specific disease
diagnosis with treatment steps, prevention tips, and confidence
scores all within seconds.
Upload a photo of your plant and get a hyper-specific disease diagnosis with treatment
steps, prevention tips, and confidence scores all within seconds.
</p>
{/* Upload CTA area */}
@@ -51,7 +46,10 @@ export default function HomePage() {
Tap to upload a photo and get started
</span>
</div>
<span className="text-leaf-green-600 dark:text-leaf-green-400 text-xl group-hover:translate-x-1 transition-transform" aria-hidden="true">
<span
className="text-leaf-green-600 dark:text-leaf-green-400 text-xl group-hover:translate-x-1 transition-transform"
aria-hidden="true"
>
</span>
</Link>
@@ -85,10 +83,7 @@ export default function HomePage() {
<div className="mt-12 grid grid-cols-1 gap-8 sm:grid-cols-3">
{HOW_IT_WORKS.map((step, index) => (
<div
key={step.step}
className="relative flex flex-col items-center text-center"
>
<div key={step.step} className="relative flex flex-col items-center text-center">
{/* Connector line (desktop) */}
{index < HOW_IT_WORKS.length - 1 && (
<div
@@ -141,9 +136,7 @@ export default function HomePage() {
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{featuredPlants.map((plant) => (
<PlantCard key={plant.id} plant={plant} />
))}
<FeaturedPlantsGrid />
</div>
</div>
</section>
@@ -158,8 +151,8 @@ export default function HomePage() {
Open Source &amp; Community Driven
</h2>
<p className="mt-3 text-zinc-500 dark:text-zinc-400 max-w-lg mx-auto">
{APP_NAME} is free and open source. Contributions, feedback, and
plant data are welcome from gardeners and developers alike.
{APP_NAME} is free and open source. Contributions, feedback, and plant data are welcome
from gardeners and developers alike.
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-4">
<Link