Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
51
src/hooks/use-auth.ts
Normal file
51
src/hooks/use-auth.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from '@/lib/auth/client';
|
||||
import type { AuthUser, AuthSession } from '@/types/auth';
|
||||
|
||||
interface UseAuthReturn {
|
||||
user: AuthUser | null;
|
||||
session: AuthSession | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that wraps Better Auth's useSession and exposes a typed user + session.
|
||||
*/
|
||||
export function useAuth(): UseAuthReturn {
|
||||
const { data: session, isPending } = useSession();
|
||||
|
||||
const user: AuthUser | null = session?.user
|
||||
? {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
image: session.user.image ?? null,
|
||||
emailVerified: session.user.emailVerified,
|
||||
createdAt: session.user.createdAt,
|
||||
updatedAt: session.user.updatedAt,
|
||||
}
|
||||
: null;
|
||||
|
||||
const typedSession: AuthSession | null = session
|
||||
? {
|
||||
id: session.session.id,
|
||||
userId: session.session.userId,
|
||||
token: session.session.token,
|
||||
expiresAt: session.session.expiresAt,
|
||||
ipAddress: session.session.ipAddress ?? null,
|
||||
userAgent: session.session.userAgent ?? null,
|
||||
createdAt: session.session.createdAt,
|
||||
updatedAt: session.session.updatedAt,
|
||||
user,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
user,
|
||||
session: typedSession,
|
||||
isLoading: isPending,
|
||||
isAuthenticated: !!user,
|
||||
};
|
||||
}
|
||||
14
src/hooks/use-debounce.ts
Normal file
14
src/hooks/use-debounce.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
59
src/hooks/use-entity-options.ts
Normal file
59
src/hooks/use-entity-options.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface UseEntityOptionsParams {
|
||||
endpoint: string;
|
||||
labelKey?: string;
|
||||
valueKey?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface EntityOption {
|
||||
value: string;
|
||||
label: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a list of options for comboboxes/selects from an API endpoint.
|
||||
* Supports 300ms debounced search.
|
||||
*/
|
||||
export function useEntityOptions({
|
||||
endpoint,
|
||||
labelKey = 'name',
|
||||
valueKey = 'id',
|
||||
enabled = true,
|
||||
}: UseEntityOptionsParams) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
|
||||
// 300ms debounce
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(search), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
const queryParams = debouncedSearch ? `?search=${encodeURIComponent(debouncedSearch)}` : '';
|
||||
|
||||
const { data, isLoading } = useQuery<unknown[]>({
|
||||
queryKey: ['entityOptions', endpoint, debouncedSearch],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: unknown[] }>(`${endpoint}${queryParams}`).then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
|
||||
const options: EntityOption[] = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return data.map((item: any) => ({
|
||||
value: String(item[valueKey]),
|
||||
label: String(item[labelKey]),
|
||||
...item,
|
||||
}));
|
||||
}, [data, valueKey, labelKey]);
|
||||
|
||||
return { options, isLoading, search, setSearch };
|
||||
}
|
||||
18
src/hooks/use-feature-flag.ts
Normal file
18
src/hooks/use-feature-flag.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
/**
|
||||
* Returns true when the given feature flag is enabled for the current port.
|
||||
* Result is cached for 5 minutes.
|
||||
*/
|
||||
export function useFeatureFlag(key: string): boolean {
|
||||
const { data } = useQuery<{ enabled: boolean }>({
|
||||
queryKey: ['feature-flag', key],
|
||||
queryFn: () => apiFetch(`/api/v1/settings/feature-flag?key=${encodeURIComponent(key)}`),
|
||||
staleTime: 300_000, // 5 min
|
||||
});
|
||||
return data?.enabled ?? false;
|
||||
}
|
||||
47
src/hooks/use-notifications.ts
Normal file
47
src/hooks/use-notifications.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useSocket } from '@/providers/socket-provider';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
export function useNotifications() {
|
||||
const socket = useSocket();
|
||||
const queryClient = useQueryClient();
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
// Initial unread count
|
||||
const { data } = useQuery<{ count: number }>({
|
||||
queryKey: ['notifications', 'unread-count'],
|
||||
queryFn: () => apiFetch('/api/v1/notifications/unread-count'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setUnreadCount(data.count);
|
||||
}, [data]);
|
||||
|
||||
// Socket listeners
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleNew = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
};
|
||||
|
||||
const handleCount = (payload: { count: number }) => {
|
||||
setUnreadCount(payload.count);
|
||||
};
|
||||
|
||||
socket.on('notification:new' as any, handleNew);
|
||||
socket.on('notification:unreadCount' as any, handleCount);
|
||||
|
||||
return () => {
|
||||
socket.off('notification:new' as any, handleNew);
|
||||
socket.off('notification:unreadCount' as any, handleCount);
|
||||
};
|
||||
}, [socket, queryClient]);
|
||||
|
||||
return { unreadCount };
|
||||
}
|
||||
175
src/hooks/use-paginated-query.ts
Normal file
175
src/hooks/use-paginated-query.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useQuery, useQueryClient, type QueryKey } from '@tanstack/react-query';
|
||||
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { PaginatedResponse } from '@/types/api';
|
||||
import {
|
||||
serializeFiltersToParams,
|
||||
deserializeFiltersFromParams,
|
||||
type FilterDefinition,
|
||||
type FilterValues,
|
||||
} from '@/components/shared/filter-bar';
|
||||
|
||||
interface UsePaginatedQueryOptions<T> {
|
||||
queryKey: QueryKey;
|
||||
endpoint: string;
|
||||
initialPage?: number;
|
||||
initialPageSize?: number;
|
||||
filterDefinitions?: FilterDefinition[];
|
||||
}
|
||||
|
||||
export function usePaginatedQuery<T>({
|
||||
queryKey,
|
||||
endpoint,
|
||||
initialPage = 1,
|
||||
initialPageSize = 25,
|
||||
filterDefinitions = [],
|
||||
}: UsePaginatedQueryOptions<T>) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Read initial state from URL
|
||||
const pageFromUrl = Number(searchParams.get('page')) || initialPage;
|
||||
const pageSizeFromUrl = Number(searchParams.get('limit')) || initialPageSize;
|
||||
const sortFieldFromUrl = searchParams.get('sort') ?? undefined;
|
||||
const sortOrderFromUrl = (searchParams.get('order') as 'asc' | 'desc') ?? 'desc';
|
||||
|
||||
const [page, setPageState] = useState(pageFromUrl);
|
||||
const [pageSize, setPageSizeState] = useState(pageSizeFromUrl);
|
||||
const [sort, setSortState] = useState<{ field: string; direction: 'asc' | 'desc' } | undefined>(
|
||||
sortFieldFromUrl ? { field: sortFieldFromUrl, direction: sortOrderFromUrl } : undefined,
|
||||
);
|
||||
const [filters, setFiltersState] = useState<FilterValues>(() =>
|
||||
deserializeFiltersFromParams(searchParams, filterDefinitions),
|
||||
);
|
||||
|
||||
// Sync state to URL
|
||||
const syncUrl = useCallback(
|
||||
(p: number, ps: number, s?: typeof sort, f?: FilterValues) => {
|
||||
const params = new URLSearchParams();
|
||||
if (p !== 1) params.set('page', String(p));
|
||||
if (ps !== initialPageSize) params.set('limit', String(ps));
|
||||
if (s) {
|
||||
params.set('sort', s.field);
|
||||
params.set('order', s.direction);
|
||||
}
|
||||
if (f) {
|
||||
const filterParams = serializeFiltersToParams(f);
|
||||
filterParams.forEach((value, key) => params.set(key, value));
|
||||
}
|
||||
// Keep existing tab param
|
||||
const tab = searchParams.get('tab');
|
||||
if (tab) params.set('tab', tab);
|
||||
|
||||
const qs = params.toString();
|
||||
router.replace(`${pathname}${qs ? `?${qs}` : ''}` as any, { scroll: false });
|
||||
},
|
||||
[pathname, router, searchParams, initialPageSize],
|
||||
);
|
||||
|
||||
function setPage(p: number) {
|
||||
setPageState(p);
|
||||
syncUrl(p, pageSize, sort, filters);
|
||||
}
|
||||
|
||||
function setPageSize(ps: number) {
|
||||
setPageSizeState(ps);
|
||||
setPageState(1);
|
||||
syncUrl(1, ps, sort, filters);
|
||||
}
|
||||
|
||||
function setSort(field: string, direction: 'asc' | 'desc') {
|
||||
const newSort = { field, direction };
|
||||
setSortState(newSort);
|
||||
setPageState(1);
|
||||
syncUrl(1, pageSize, newSort, filters);
|
||||
}
|
||||
|
||||
function setFilter(key: string, value: unknown) {
|
||||
const newFilters = { ...filters };
|
||||
if (value === undefined || value === null) {
|
||||
delete newFilters[key];
|
||||
} else {
|
||||
newFilters[key] = value;
|
||||
}
|
||||
setFiltersState(newFilters);
|
||||
setPageState(1);
|
||||
syncUrl(1, pageSize, sort, newFilters);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
setFiltersState({});
|
||||
setPageState(1);
|
||||
syncUrl(1, pageSize, sort, {});
|
||||
}
|
||||
|
||||
// Build query string for API
|
||||
const apiParams = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
params.set('limit', String(pageSize));
|
||||
if (sort) {
|
||||
params.set('sort', sort.field);
|
||||
params.set('order', sort.direction);
|
||||
}
|
||||
const filterParams = serializeFiltersToParams(filters);
|
||||
filterParams.forEach((value, key) => params.set(key, value));
|
||||
return params.toString();
|
||||
}, [page, pageSize, sort, filters]);
|
||||
|
||||
const fullQueryKey = [...queryKey, apiParams];
|
||||
|
||||
const { data, isLoading, isFetching } = useQuery<PaginatedResponse<T>>({
|
||||
queryKey: fullQueryKey,
|
||||
queryFn: () =>
|
||||
apiFetch<PaginatedResponse<T>>(`${endpoint}?${apiParams}`),
|
||||
});
|
||||
|
||||
const pagination = data?.pagination
|
||||
? {
|
||||
page: data.pagination.page,
|
||||
pageSize: data.pagination.pageSize,
|
||||
total: data.pagination.total,
|
||||
totalPages: data.pagination.totalPages,
|
||||
}
|
||||
: { page, pageSize, total: 0, totalPages: 0 };
|
||||
|
||||
/**
|
||||
* Optimistically removes an item from the cached list (e.g., after archive).
|
||||
*/
|
||||
function optimisticRemove(id: string) {
|
||||
queryClient.setQueryData<PaginatedResponse<T>>(fullQueryKey, (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
data: old.data.filter((item: any) => item.id !== id),
|
||||
pagination: {
|
||||
...old.pagination,
|
||||
total: old.pagination.total - 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: data?.data ?? [],
|
||||
pagination,
|
||||
isLoading,
|
||||
isFetching,
|
||||
sort,
|
||||
setSort,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
filters,
|
||||
setFilter,
|
||||
clearFilters,
|
||||
optimisticRemove,
|
||||
};
|
||||
}
|
||||
35
src/hooks/use-permissions.ts
Normal file
35
src/hooks/use-permissions.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { usePermissionsStore } from '@/stores/permissions-store';
|
||||
import type { RolePermissions } from '@/lib/db/schema/users';
|
||||
|
||||
type Resource = keyof RolePermissions;
|
||||
type Action<R extends Resource> = keyof RolePermissions[R];
|
||||
|
||||
interface UsePermissionsReturn {
|
||||
can: <R extends Resource>(resource: R, action: Action<R>) => boolean;
|
||||
permissions: RolePermissions | null;
|
||||
isSuperAdmin: boolean;
|
||||
userId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that provides a `can(resource, action)` check using the current user's
|
||||
* permissions from the permissions store (populated by PermissionsProvider).
|
||||
*/
|
||||
export function usePermissions(): UsePermissionsReturn {
|
||||
const permissions = usePermissionsStore((s) => s.permissions);
|
||||
const isSuperAdmin = usePermissionsStore((s) => s.isSuperAdmin);
|
||||
const userId = usePermissionsStore((s) => s.userId);
|
||||
|
||||
function can<R extends Resource>(resource: R, action: Action<R>): boolean {
|
||||
// Super admins bypass all permission checks
|
||||
if (isSuperAdmin) return true;
|
||||
if (!permissions) return false;
|
||||
const resourcePerms = permissions[resource];
|
||||
if (!resourcePerms) return false;
|
||||
return !!(resourcePerms as Record<string, boolean>)[action as string];
|
||||
}
|
||||
|
||||
return { can, permissions, isSuperAdmin, userId };
|
||||
}
|
||||
31
src/hooks/use-port.ts
Normal file
31
src/hooks/use-port.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { usePortContext } from '@/providers/port-provider';
|
||||
import type { Port } from '@/lib/db/schema/ports';
|
||||
|
||||
interface UsePortReturn {
|
||||
currentPort: Port | null;
|
||||
currentPortId: string | null;
|
||||
currentPortSlug: string | null;
|
||||
ports: Port[];
|
||||
setPort: (portId: string, portSlug: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get current port context from Zustand store and PortContext.
|
||||
*/
|
||||
export function usePort(): UsePortReturn {
|
||||
const { currentPort, ports } = usePortContext();
|
||||
const currentPortId = useUIStore((s) => s.currentPortId);
|
||||
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const setPort = useUIStore((s) => s.setPort);
|
||||
|
||||
return {
|
||||
currentPort,
|
||||
currentPortId,
|
||||
currentPortSlug,
|
||||
ports,
|
||||
setPort,
|
||||
};
|
||||
}
|
||||
47
src/hooks/use-realtime-invalidation.ts
Normal file
47
src/hooks/use-realtime-invalidation.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useQueryClient, type QueryKey } from '@tanstack/react-query';
|
||||
|
||||
import { useSocket } from '@/providers/socket-provider';
|
||||
|
||||
/**
|
||||
* Subscribes to socket events and invalidates React Query caches.
|
||||
*
|
||||
* @param eventMap - Maps socket event names to arrays of query keys to invalidate.
|
||||
*
|
||||
* @example
|
||||
* useRealtimeInvalidation({
|
||||
* 'client:created': [['clients']],
|
||||
* 'client:updated': [['clients'], ['clients', clientId]],
|
||||
* 'client:archived': [['clients']],
|
||||
* });
|
||||
*/
|
||||
export function useRealtimeInvalidation(
|
||||
eventMap: Record<string, QueryKey[]>,
|
||||
) {
|
||||
const socket = useSocket();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handlers: Array<{ event: string; handler: (...args: unknown[]) => void }> = [];
|
||||
|
||||
for (const [event, queryKeys] of Object.entries(eventMap)) {
|
||||
const handler = () => {
|
||||
for (const key of queryKeys) {
|
||||
queryClient.invalidateQueries({ queryKey: key });
|
||||
}
|
||||
};
|
||||
socket.on(event as any, handler);
|
||||
handlers.push({ event, handler });
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const { event, handler } of handlers) {
|
||||
socket.off(event as any, handler);
|
||||
}
|
||||
};
|
||||
}, [socket, queryClient, eventMap]);
|
||||
}
|
||||
66
src/hooks/use-saved-views.ts
Normal file
66
src/hooks/use-saved-views.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { SavedView } from '@/lib/db/schema/system';
|
||||
|
||||
export function useSavedViews(entityType: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const [activeViewId, setActiveViewId] = useState<string | null>(null);
|
||||
|
||||
const queryKey = ['savedViews', entityType];
|
||||
|
||||
const { data: views = [] } = useQuery<SavedView[]>({
|
||||
queryKey,
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: SavedView[] }>(
|
||||
`/api/v1/saved-views?entityType=${entityType}`,
|
||||
).then((r) => r.data),
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (params: {
|
||||
name: string;
|
||||
filters: Record<string, unknown>;
|
||||
sortConfig?: unknown;
|
||||
}) =>
|
||||
apiFetch<{ data: SavedView }>('/api/v1/saved-views', {
|
||||
method: 'POST',
|
||||
body: { ...params, entityType },
|
||||
}),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (viewId: string) =>
|
||||
apiFetch(`/api/v1/saved-views/${viewId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||
});
|
||||
|
||||
async function saveCurrentView(
|
||||
name: string,
|
||||
filters: Record<string, unknown>,
|
||||
sortConfig?: unknown,
|
||||
) {
|
||||
await saveMutation.mutateAsync({ name, filters, sortConfig });
|
||||
}
|
||||
|
||||
function deleteView(viewId: string) {
|
||||
if (activeViewId === viewId) setActiveViewId(null);
|
||||
deleteMutation.mutate(viewId);
|
||||
}
|
||||
|
||||
function applyView(viewId: string) {
|
||||
setActiveViewId(viewId);
|
||||
}
|
||||
|
||||
return {
|
||||
views,
|
||||
activeViewId,
|
||||
saveCurrentView,
|
||||
deleteView,
|
||||
applyView,
|
||||
};
|
||||
}
|
||||
45
src/hooks/use-search.ts
Normal file
45
src/hooks/use-search.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useDebounce } from '@/hooks/use-debounce';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SearchResults {
|
||||
clients: Array<{ id: string; fullName: string; companyName: string | null }>;
|
||||
interests: Array<{
|
||||
id: string;
|
||||
clientName: string;
|
||||
berthMooringNumber: string | null;
|
||||
pipelineStage: string;
|
||||
}>;
|
||||
berths: Array<{ id: string; mooringNumber: string; area: string | null; status: string }>;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useSearch(query: string) {
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
|
||||
const searchQuery = useQuery<SearchResults>({
|
||||
queryKey: ['search', debouncedQuery],
|
||||
queryFn: () =>
|
||||
apiFetch<SearchResults>(`/api/v1/search?q=${encodeURIComponent(debouncedQuery)}`),
|
||||
enabled: debouncedQuery.length >= 2,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const recentQuery = useQuery<{ searches: string[] }>({
|
||||
queryKey: ['search', 'recent'],
|
||||
queryFn: () => apiFetch<{ searches: string[] }>('/api/v1/search/recent'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
return {
|
||||
results: searchQuery.data,
|
||||
isLoading: searchQuery.isLoading,
|
||||
recentSearches: recentQuery.data?.searches ?? [],
|
||||
};
|
||||
}
|
||||
3
src/hooks/use-socket.ts
Normal file
3
src/hooks/use-socket.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
'use client';
|
||||
|
||||
export { useSocket } from '@/providers/socket-provider';
|
||||
Reference in New Issue
Block a user