Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added src/components/ActivityFeed.tsx
Empty file.
64 changes: 50 additions & 14 deletions src/components/search/AdvancedSearchInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,15 @@

import React, { useState, useCallback } from 'react';
import {
Search,
Filter,
Settings,
Sparkles,
ArrowLeft,
X,
History,
TrendingUp,
BrainCircuit,
Sparkles,
Share2,
Check,
} from 'lucide-react';
import { useAdvancedSearch } from '../../hooks/useAdvancedSearch';
import { useSearchState } from '../../hooks/useSearchState';
import { IntelligentAutoComplete } from './IntelligentAutoComplete';
import { FacetedFilterSystem } from './FacetedFilterSystem';
import { SearchResultsVisualizer } from './SearchResultsVisualizer';
Expand All @@ -28,10 +26,16 @@ export const AdvancedSearchInterface = React.memo(() => {
results,
isSearching,
history,
} = useAdvancedSearch();
copyShareableUrl,
} = useSearchState();

const [showFilters, setShowFilters] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [hasSearched, setHasSearched] = useState(() => {
// Derive initial value — if URL already had a query, we are in "searched" mode
if (typeof window === 'undefined') return false;
return new URLSearchParams(window.location.search).has('q');
});
const [copied, setCopied] = useState(false);

const handleSearch = useCallback(
(text: string) => {
Expand All @@ -48,6 +52,14 @@ export const AdvancedSearchInterface = React.memo(() => {
setHasSearched(false);
}, [clearFilters, updateSearchText]);

const handleShare = useCallback(async () => {
const ok = await copyShareableUrl();
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}, [copyShareableUrl]);

return (
<div className="max-w-6xl mx-auto px-4 py-12 space-y-12">
{/* Search Header Section */}
Expand Down Expand Up @@ -79,6 +91,8 @@ export const AdvancedSearchInterface = React.memo(() => {
history={history}
/>
</div>

{/* Filter toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center justify-center gap-2 px-6 py-4 rounded-2xl font-mono text-xs uppercase tracking-widest font-bold transition-all h-[56px] border ${
Expand All @@ -88,12 +102,31 @@ export const AdvancedSearchInterface = React.memo(() => {
}`}
>
<Filter
className={`w-4 h-4 ${
showFilters ? 'rotate-180' : ''
} transition-transform duration-300`}
className={`w-4 h-4 ${showFilters ? 'rotate-180' : ''} transition-transform duration-300`}
/>
{showFilters ? 'HIDE_FILTERS' : 'FILTERS'}
</button>

{/* Share button — only shown when there is active search state to share */}
{hasSearched && (
<button
onClick={handleShare}
aria-label="Copy shareable link"
className="flex items-center justify-center gap-2 px-5 py-4 rounded-2xl font-mono text-xs uppercase tracking-widest font-bold transition-all h-[56px] border bg-white text-slate-600 border-slate-200 hover:border-primary hover:text-primary"
>
{copied ? (
<>
<Check className="w-4 h-4 text-emerald-500" />
<span className="text-emerald-500">COPIED!</span>
</>
) : (
<>
<Share2 className="w-4 h-4" />
SHARE
</>
)}
</button>
)}
</div>

{/* Quick Insights / Trending Tags */}
Expand Down Expand Up @@ -123,7 +156,10 @@ export const AdvancedSearchInterface = React.memo(() => {
<FacetedFilterSystem
filters={query.filters}
onFilterChange={updateFilters}
onReset={clearFilters}
onReset={() => {
clearFilters();
setHasSearched(false);
}}
/>
</div>
)}
Expand Down Expand Up @@ -177,7 +213,7 @@ export const AdvancedSearchInterface = React.memo(() => {
</div>
</div>

{/* Search Insights Tooltip (Utility) */}
{/* History tooltip — bottom-left floating button */}
<div className="fixed bottom-8 left-8">
<div className="relative group">
<button className="w-12 h-12 bg-white border border-slate-200 rounded-2xl flex items-center justify-center text-slate-400 hover:text-primary hover:border-primary transition-all shadow-xl shadow-slate-200/50 hover:shadow-primary/20 hover:scale-110 active:scale-95">
Expand Down Expand Up @@ -212,4 +248,4 @@ export const AdvancedSearchInterface = React.memo(() => {

AdvancedSearchInterface.displayName = 'AdvancedSearchInterface';

export default AdvancedSearchInterface;
export default AdvancedSearchInterface;
Empty file added src/hooks/useActivityFeed.ts
Empty file.
218 changes: 218 additions & 0 deletions src/hooks/useSearchState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
'use client';

import { useCallback, useEffect, useRef } from 'react';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { useAdvancedSearch } from './useAdvancedSearch';
import type { SearchFilters, SearchQuery } from '../utils/searchUtils';

/**
* URL param keys — kept in one place so renaming is a one-line change.
*/
const PARAM = {
query: 'q',
sort: 'sort',
types: 'types',
topics: 'topics',
difficulty: 'difficulty',
priceMin: 'priceMin',
priceMax: 'priceMax',
rating: 'rating',
page: 'page',
} as const;

/** Default values that should be omitted from the URL to keep it clean. */
const DEFAULTS = {
sort: 'relevance',
priceMin: 0,
priceMax: 500,
page: 1,
} as const;

// ---------------------------------------------------------------------------
// Serialise / deserialise helpers
// ---------------------------------------------------------------------------

function filtersToParams(query: SearchQuery): URLSearchParams {
const params = new URLSearchParams();
const { text, filters, sortBy, page } = query;

if (text) params.set(PARAM.query, text);
if (sortBy && sortBy !== DEFAULTS.sort) params.set(PARAM.sort, sortBy);

const nonAllTypes = filters.types.filter((t) => t !== 'all');
if (nonAllTypes.length) params.set(PARAM.types, nonAllTypes.join(','));
if (filters.topics.length) params.set(PARAM.topics, filters.topics.join(','));
if (filters.difficulty.length) params.set(PARAM.difficulty, filters.difficulty.join(','));

if (filters.priceRange[0] !== DEFAULTS.priceMin)
params.set(PARAM.priceMin, String(filters.priceRange[0]));
if (filters.priceRange[1] !== DEFAULTS.priceMax)
params.set(PARAM.priceMax, String(filters.priceRange[1]));

if (filters.rating !== null && filters.rating !== undefined)
params.set(PARAM.rating, String(filters.rating));

if (page && page !== DEFAULTS.page) params.set(PARAM.page, String(page));

return params;
}

function paramsToQueryPatch(
searchParams: URLSearchParams,
): Partial<SearchQuery> & { filters: Partial<SearchFilters> } {
const text = searchParams.get(PARAM.query) ?? '';
const sortBy = (searchParams.get(PARAM.sort) ?? DEFAULTS.sort) as SearchQuery['sortBy'];
const page = searchParams.get(PARAM.page) ? Number(searchParams.get(PARAM.page)) : DEFAULTS.page;

const typesRaw = searchParams.get(PARAM.types);
const types: SearchFilters['types'] = typesRaw
? (typesRaw.split(',').filter(Boolean) as SearchFilters['types'])
: ['all'];

const topics = searchParams.get(PARAM.topics)?.split(',').filter(Boolean) ?? [];
const difficulty = searchParams.get(PARAM.difficulty)?.split(',').filter(Boolean) ?? [];

const priceMin = searchParams.get(PARAM.priceMin)
? Number(searchParams.get(PARAM.priceMin))
: DEFAULTS.priceMin;
const priceMax = searchParams.get(PARAM.priceMax)
? Number(searchParams.get(PARAM.priceMax))
: DEFAULTS.priceMax;

const ratingRaw = searchParams.get(PARAM.rating);
const rating = ratingRaw ? Number(ratingRaw) : null;

return {
text,
sortBy,
page,
filters: { types, topics, difficulty, priceRange: [priceMin, priceMax], rating, dateRange: null },
};
}

// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------

/**
* useSearchState
*
* Wraps `useAdvancedSearch` and adds:
* - URL ↔ state synchronisation (read on mount / external navigation, write on every change)
* - A shareable URL getter
* - Re-exports everything from useAdvancedSearch so callers only need this one hook
*
* Persistence strategy:
* 1. On mount, URL params are the source of truth — they initialise the search state.
* 2. Every state change is pushed to the URL via `router.replace` (no history entry added).
* 3. Browser back/forward restores the URL, which triggers a re-sync into state.
*/
export const useSearchState = () => {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();

const advancedSearch = useAdvancedSearch();
const { query, updateSearchText, updateFilters, updateSort, performSearch } = advancedSearch;

/**
* Ref guards against the hook writing to the URL triggering an immediate
* read-back that would duplicate state updates.
*/
const isSyncingToUrl = useRef(false);

// -------------------------------------------------------------------------
// 1. Initialise from URL on first render
// -------------------------------------------------------------------------
const hasHydrated = useRef(false);
useEffect(() => {
if (hasHydrated.current) return;
hasHydrated.current = true;

if (!searchParams) return;
const patch = paramsToQueryPatch(searchParams);

if (patch.text) updateSearchText(patch.text);
if (patch.filters) updateFilters(patch.filters as Partial<SearchFilters>);
if (patch.sortBy) updateSort(patch.sortBy as SearchQuery['sortBy']);

// Auto-run the search if URL already had a query or active filters
const hasActiveState =
!!patch.text ||
(patch.filters.types ?? []).some((t) => t !== 'all') ||
(patch.filters.topics ?? []).length > 0 ||
(patch.filters.difficulty ?? []).length > 0;

if (hasActiveState) {
// Defer one tick so state setters above have settled
setTimeout(() => performSearch(), 0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // intentionally empty — run once on mount

// -------------------------------------------------------------------------
// 2. Sync state → URL whenever query changes
// -------------------------------------------------------------------------
useEffect(() => {
if (!hasHydrated.current) return;

const params = filtersToParams(query);
const nextSearch = params.toString();
const currentSearch = searchParams?.toString() ?? '';

if (nextSearch === currentSearch) return;

isSyncingToUrl.current = true;
router.replace(
nextSearch ? `${pathname ?? ''}?${nextSearch}` : (pathname ?? '/'),
{ scroll: false },
);
}, [query, pathname, router, searchParams]);

// -------------------------------------------------------------------------
// 3. Sync URL → state on external navigation (browser back/forward)
// -------------------------------------------------------------------------
useEffect(() => {
if (isSyncingToUrl.current) {
isSyncingToUrl.current = false;
return;
}

if (!searchParams) return;
const patch = paramsToQueryPatch(searchParams);

// Only update fields that actually differ to avoid re-render loops
if (patch.text !== query.text) updateSearchText(patch.text ?? '');
if (patch.sortBy && patch.sortBy !== query.sortBy)
updateSort(patch.sortBy as SearchQuery['sortBy']);
if (patch.filters) updateFilters(patch.filters as Partial<SearchFilters>);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);

// -------------------------------------------------------------------------
// Share helper — returns the current canonical URL for sharing
// -------------------------------------------------------------------------
const getShareableUrl = useCallback((): string => {
const params = filtersToParams(query);
const base =
typeof window !== 'undefined'
? `${window.location.origin}${pathname ?? ''}`
: pathname ?? '';
return params.toString() ? `${base}?${params.toString()}` : base;
}, [query, pathname]);

const copyShareableUrl = useCallback(async (): Promise<boolean> => {
try {
await navigator.clipboard.writeText(getShareableUrl());
return true;
} catch {
return false;
}
}, [getShareableUrl]);

return {
...advancedSearch,
getShareableUrl,
copyShareableUrl,
};
};
Empty file.
Empty file.
Loading