From 79cdb8cd045b625ed59be5a8a6d479aa8fb94381 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Mon, 13 Jul 2026 15:38:29 +0200 Subject: [PATCH] feat: merge people search into the recipe search bar on Explore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit People search existed but was buried in its own tab with no entry point from anywhere else — easy to conclude it "doesn't work" since nobody would find it. Now one query on Explore hits both /api/v1/search and /api/v1/users/search and renders People/Recipes sections together; the standalone people tab and its now-dead PeopleSearch component are gone. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 5 + apps/web/app/(app)/explore/page.tsx | 5 +- .../search/explore-page-content.tsx | 120 ++++++++++++------ apps/web/components/social/people-search.tsx | 84 ------------ apps/web/lib/changelog.ts | 9 +- apps/web/messages/en.json | 14 +- apps/web/messages/fr.json | 14 +- apps/web/package.json | 2 +- package.json | 2 +- 9 files changed, 109 insertions(+), 146 deletions(-) delete mode 100644 apps/web/components/social/people-search.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 6702fae..bbffb48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.13.0 — 2026-07-13 15:37 + +### Added +- **Explore search now finds people and recipes together**: one search bar, one query — no more separate, hard-to-find people tab. + ## 0.12.1 — 2026-07-13 13:25 ### Fixed diff --git a/apps/web/app/(app)/explore/page.tsx b/apps/web/app/(app)/explore/page.tsx index c4cf545..e525cf0 100644 --- a/apps/web/app/(app)/explore/page.tsx +++ b/apps/web/app/(app)/explore/page.tsx @@ -27,9 +27,9 @@ export type RecipeResult = { export default async function ExplorePage({ searchParams, }: { - searchParams: Promise<{ q?: string; tab?: string }>; + searchParams: Promise<{ q?: string }>; }) { - const { q, tab } = await searchParams; + const { q } = await searchParams; const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); // Trending: public recipes ordered by favorite count in last 7 days @@ -81,7 +81,6 @@ export default async function ExplorePage({ trending={trending} recent={recent} initialQuery={q ?? ""} - initialTab={tab === "people" ? "people" : "recipes"} /> ); } diff --git a/apps/web/components/search/explore-page-content.tsx b/apps/web/components/search/explore-page-content.tsx index 6e2e896..a518a7f 100644 --- a/apps/web/components/search/explore-page-content.tsx +++ b/apps/web/components/search/explore-page-content.tsx @@ -1,17 +1,18 @@ "use client"; import { useState, useEffect, useRef, useCallback } from "react"; +import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; -import { Flame, Clock, ChefHat, Search, Sparkles, Wand2, ArrowRight, Shuffle } from "lucide-react"; +import { Flame, Clock, ChefHat, Search, Sparkles, Wand2, ArrowRight, Shuffle, Users } from "lucide-react"; import { useTranslations } from "next-intl"; import type { RecipeResult as ExploreRecipeResult } from "@/app/(app)/explore/page"; import { SearchResultCard } from "@/components/recipe/search-result-card"; -import { PeopleSearch } from "@/components/social/people-search"; +import { FollowButton } from "@/components/social/follow-button"; const DIFFICULTY_COLORS: Record = { easy: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", @@ -36,6 +37,14 @@ type SearchResponse = { offset: number; }; +type PersonResult = { + id: string; + name: string; + username: string | null; + avatarUrl: string | null; + bio: string | null; +}; + type RecipeIdea = { title: string; description: string; @@ -70,17 +79,15 @@ type Props = { trending: ExploreRecipeResult[]; recent: ExploreRecipeResult[]; initialQuery: string; - initialTab: "recipes" | "people"; }; -export function ExplorePageContent({ trending, recent, initialQuery, initialTab }: Props) { +export function ExplorePageContent({ trending, recent, initialQuery }: Props) { const router = useRouter(); const searchParams = useSearchParams(); const t = useTranslations("explore"); const tCommon = useTranslations("common"); const tRecipe = useTranslations("recipe"); - - const [activeTab, setActiveTab] = useState<"recipes" | "people">(initialTab); + const tPeople = useTranslations("people"); const [inputValue, setInputValue] = useState(initialQuery); const [query, setQuery] = useState(initialQuery); @@ -92,6 +99,9 @@ export function ExplorePageContent({ trending, recent, initialQuery, initialTab const [loading, setLoading] = useState(false); const [loadingMore, setLoadingMore] = useState(false); + const [peopleResults, setPeopleResults] = useState([]); + const [peopleLoading, setPeopleLoading] = useState(false); + const [ideasPrompt, setIdeasPrompt] = useState(""); const [ideas, setIdeas] = useState([]); const [ideasLoading, setIdeasLoading] = useState(false); @@ -129,8 +139,27 @@ export function ExplorePageContent({ trending, recent, initialQuery, initialTab [] ); + const fetchPeople = useCallback(async (q: string) => { + if (q.trim().length < 2) { + setPeopleResults([]); + return; + } + setPeopleLoading(true); + try { + const res = await fetch(`/api/v1/users/search?q=${encodeURIComponent(q.trim())}`); + if (!res.ok) return; + const data = (await res.json()) as { users: PersonResult[] }; + setPeopleResults(data.users); + } finally { + setPeopleLoading(false); + } + }, []); + useEffect(() => { - if (initialQuery) fetchResults(initialQuery, "any", "", 0); + if (initialQuery) { + fetchResults(initialQuery, "any", "", 0); + fetchPeople(initialQuery); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -144,18 +173,6 @@ export function ExplorePageContent({ trending, recent, initialQuery, initialTab [router, searchParams] ); - const handleTabChange = useCallback( - (value: string | null) => { - const next = value === "people" ? "people" : "recipes"; - setActiveTab(next); - const params = new URLSearchParams(searchParams.toString()); - if (next === "people") params.set("tab", "people"); - else params.delete("tab"); - router.replace(`/explore?${params}`, { scroll: false }); - }, - [router, searchParams] - ); - const fetchIdeas = useCallback(async (prompt: string) => { setIdeasLoading(true); setIdeas([]); @@ -197,6 +214,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, initialTab setQuery(value); updateUrl(value); fetchResults(value, difficulty, maxMins, 0); + fetchPeople(value); }, 300); }; @@ -206,6 +224,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, initialTab setQuery(inputValue); updateUrl(inputValue); fetchResults(inputValue, difficulty, maxMins, 0); + fetchPeople(inputValue); }; const handleDifficultyChange = (value: string | null) => { @@ -224,6 +243,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, initialTab const hasQuery = query.trim().length > 0; const hasMore = results.length < total; + const hasNoResults = hasQuery && !loading && !peopleLoading && results.length === 0 && peopleResults.length === 0; return (
@@ -231,15 +251,8 @@ export function ExplorePageContent({ trending, recent, initialQuery, initialTab

{t("title")}

- - - {t("tabRecipes")} - {t("tabPeople")} - - - -
- {/* Search bar */} +
+ {/* Search bar — one query finds both recipes and people */}
@@ -283,7 +296,40 @@ export function ExplorePageContent({ trending, recent, initialQuery, initialTab )}
- {/* Search results */} + {/* People results */} + {hasQuery && (peopleLoading || peopleResults.length > 0) && ( +
+
+ +

{t("peopleHeading")}

+
+ {peopleLoading ? ( +

{tPeople("searching")}

+ ) : ( +
+ {peopleResults.map((person) => ( +
+ + + {person.avatarUrl && } + {person.name.slice(0, 2).toUpperCase()} + +
+

{person.name}

+

@{person.username}

+
+ + {person.username && ( + + )} +
+ ))} +
+ )} +
+ )} + + {/* Recipe search results */} {hasQuery && loading && (
{Array.from({ length: 8 }).map((_, i) => ( @@ -296,7 +342,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, initialTab
)} - {hasQuery && !loading && results.length === 0 && ( + {hasNoResults && (

{t("noResultsTitle", { query })}

@@ -306,6 +352,10 @@ export function ExplorePageContent({ trending, recent, initialQuery, initialTab {hasQuery && !loading && results.length > 0 && ( <> +
+ +

{t("recipesHeading")}

+
{results.map((recipe) => ( @@ -469,13 +519,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, initialTab )} -
- - - - - - +
); } diff --git a/apps/web/components/social/people-search.tsx b/apps/web/components/social/people-search.tsx deleted file mode 100644 index 5f0198d..0000000 --- a/apps/web/components/social/people-search.tsx +++ /dev/null @@ -1,84 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import Link from "next/link"; -import { useTranslations } from "next-intl"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Input } from "@/components/ui/input"; -import { FollowButton } from "@/components/social/follow-button"; -import { Search } from "lucide-react"; - -type PersonResult = { - id: string; - name: string; - username: string | null; - avatarUrl: string | null; - bio: string | null; -}; - -export function PeopleSearch() { - const t = useTranslations("people"); - const [q, setQ] = useState(""); - const [results, setResults] = useState([]); - const [loading, setLoading] = useState(false); - - useEffect(() => { - if (q.trim().length < 2) { - setResults([]); - return; - } - const timeout = setTimeout(async () => { - setLoading(true); - try { - const res = await fetch(`/api/v1/users/search?q=${encodeURIComponent(q.trim())}`); - if (res.ok) { - const data = (await res.json()) as { users: PersonResult[] }; - setResults(data.users); - } - } finally { - setLoading(false); - } - }, 300); - return () => clearTimeout(timeout); - }, [q]); - - return ( -
-
- - setQ(e.target.value)} - placeholder={t("searchPlaceholder")} - className="pl-9" - /> -
- - {loading &&

{t("searching")}

} - - {!loading && q.trim().length >= 2 && results.length === 0 && ( -

{t("noneFound")}

- )} - -
- {results.map((person) => ( -
- - - {person.avatarUrl && } - {person.name.slice(0, 2).toUpperCase()} - -
-

{person.name}

-

@{person.username}

-
- - {person.username && ( - - )} -
- ))} -
-
- ); -} diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 9deb656..635e090 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -1,5 +1,5 @@ // Mirrors CHANGELOG.md at the repo root — update both together. -export const APP_VERSION = "0.12.1"; +export const APP_VERSION = "0.13.0"; export type ChangelogEntry = { version: string; @@ -11,6 +11,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.13.0", + date: "2026-07-13 15:37", + added: [ + "**Explore search now finds people and recipes together**: one search bar, one query — no more separate, hard-to-find people tab.", + ], + }, { version: "0.12.1", date: "2026-07-13 13:25", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 4e8f650..99b81cd 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -438,11 +438,7 @@ "dismissAiScaling": "Dismiss AI scaling" }, "people": { - "title": "Find People", - "subtitle": "Search for cooks to follow by name or username.", - "searchPlaceholder": "Search people by name or username…", - "searching": "Searching…", - "noneFound": "No one found." + "searching": "Searching…" }, "messages": { "loading": "Loading…", @@ -497,15 +493,15 @@ }, "explore": { "title": "Explore", - "tabRecipes": "Recipes", - "tabPeople": "People", - "searchPlaceholder": "Search public recipes…", + "searchPlaceholder": "Search recipes and people…", + "peopleHeading": "People", + "recipesHeading": "Recipes", "maxMinutes": "Max minutes", "aiSearchPlaceholder": "e.g. quick weeknight dinners, Italian comfort food…", "anyDifficulty": "Any difficulty", "resultsFoundSingular": "{count} recipe found", "resultsFoundPlural": "{count} recipes found", - "noResultsTitle": "No recipes found for “{query}”", + "noResultsTitle": "No results for “{query}”", "noResultsHint": "Try different keywords or remove filters", "loadMore": "Load more", "recipeIdeas": "Recipe ideas", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 29dc022..fe07599 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -438,11 +438,7 @@ "dismissAiScaling": "Ignorer l'ajustement IA" }, "people": { - "title": "Trouver des personnes", - "subtitle": "Recherchez des cuisiniers à suivre par nom ou nom d'utilisateur.", - "searchPlaceholder": "Rechercher par nom ou nom d'utilisateur…", - "searching": "Recherche…", - "noneFound": "Personne trouvé." + "searching": "Recherche…" }, "messages": { "loading": "Chargement…", @@ -497,15 +493,15 @@ }, "explore": { "title": "Explorer", - "tabRecipes": "Recettes", - "tabPeople": "Personnes", - "searchPlaceholder": "Rechercher des recettes publiques…", + "searchPlaceholder": "Rechercher des recettes et des personnes…", + "peopleHeading": "Personnes", + "recipesHeading": "Recettes", "maxMinutes": "Minutes max", "aiSearchPlaceholder": "ex. dîners rapides en semaine, cuisine réconfortante italienne…", "anyDifficulty": "Toute difficulté", "resultsFoundSingular": "{count} recette trouvée", "resultsFoundPlural": "{count} recettes trouvées", - "noResultsTitle": "Aucune recette trouvée pour « {query} »", + "noResultsTitle": "Aucun résultat pour « {query} »", "noResultsHint": "Essayez d'autres mots-clés ou retirez des filtres", "loadMore": "Charger plus", "recipeIdeas": "Idées de recettes", diff --git a/apps/web/package.json b/apps/web/package.json index 720282d..ee95459 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.12.1", + "version": "0.13.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index f49837b..6b9e589 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.12.1", + "version": "0.13.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",