/
opt
/
canhelp
/
apps
/
web
/
src
/
app
/
specialists
/
/opt/canhelp/apps/web/src/app/specialists
mkdir
upload
Name
Size
Mode
Actions
page.tsx
10839
0644
edit
dl
rm
Edit:
/opt/canhelp/apps/web/src/app/specialists/page.tsx
(10839B)
import Link from 'next/link' import { cookies } from 'next/headers' import { getSpecialists, getCategories, getLocations } from '@/lib/api' import { getTranslations } from '@/lib/translations' import { formatShortName } from '@/lib/formatName' import { formatLastSeen } from '@/lib/formatLastSeen' import { FavoriteSpecialistButton } from '@/components/FavoriteSpecialistButton' import { SpecialistsFiltersClient } from '@/components/SpecialistsFiltersClient' import { PlanBadge } from '@/components/PlanBadge' import type { Locale } from '@/lib/translations' interface Props { searchParams: Promise<Record<string, string>> } export default async function SpecialistsPage({ searchParams }: Props) { const params = await searchParams const page = Number(params.page || 1) const cookieStore = await cookies() const locale = (cookieStore.get('canhelp_locale')?.value ?? 'el') as Locale const t = getTranslations(locale) const cookieLocation = decodeURIComponent(cookieStore.get('canhelp_location')?.value ?? '') // Location filter only applies when explicitly set in URL params, not from cookie const effectiveLocation = params.location let specialists: any[] = [] let total = 0 let catTree: any[] = [] let locationTree: any[] = [] let favoriteIds = new Set<string>() const BASE = process.env.INTERNAL_API_URL || 'http://localhost:4000' const cookieHeader = cookieStore.getAll().map((c: any) => `${c.name}=${c.value}`).join('; ') try { const [result, cats, locs] = await Promise.all([ getSpecialists({ page: String(page), limit: '20', locale, ...params, ...(effectiveLocation ? { location: effectiveLocation } : {}) }), getCategories().catch(() => [] as any[]), getLocations().catch(() => [] as any[]), ]) console.log('[SpecialistsPage] got', result.total, 'specialists, data.length=', result.data.length) specialists = result.data total = result.total catTree = cats as any[] locationTree = locs as any[] } catch (err) { console.error('[SpecialistsPage] fetch error:', err) } if (cookieHeader) { try { const favRes = await fetch(`${BASE}/api/favorites/specialists`, { headers: { Cookie: cookieHeader }, }) if (favRes.ok) { const favData = await favRes.json() favoriteIds = new Set(favData.map((row: any) => row.specialist?.id).filter(Boolean)) } } catch {} } const totalPages = Math.ceil(total / 20) const dateLocale = locale === 'ru' ? 'ru-RU' : locale === 'uk' ? 'uk-UA' : locale === 'en' ? 'en-US' : 'el-GR' const catLocale = locale === 'en' ? 'en' : locale === 'ru' ? 'ru' : locale === 'uk' ? 'uk' : 'el' const buildUrl = (overrides: Record<string, string | undefined>) => { const merged = { ...params, ...(effectiveLocation ? { location: effectiveLocation } : {}), ...overrides } const qs = Object.entries(merged) .filter(([, v]) => v !== undefined && v !== '') .map(([k, v]) => `${k}=${encodeURIComponent(v!)}`) .join('&') return `/specialists${qs ? '?' + qs : ''}` } return ( <div className="max-w-7xl mx-auto px-4 py-8"> <div className="flex flex-col md:flex-row gap-8"> {/* Sidebar search */} <aside className="md:w-64 shrink-0"> <div className="bg-white rounded-xl border border-gray-200 p-5 sticky top-24"> <h3 className="font-semibold text-gray-900 mb-4">{t('tasks.search')}</h3> <SpecialistsFiltersClient catTree={catTree} locations={locationTree} initialQ={params.q} initialCategory={params.category} initialLocation={params.location} initialLanguages={params.language ? params.language.split(',').filter(Boolean) : []} /> </div> </aside> {/* Specialists list */} <div className="flex-1"> {(params.q || params.category || params.location || params.language) && ( <div className="mb-6"> <h1 className="text-xl font-bold text-white"> {total} {t('specialists.title').toLowerCase()} </h1> </div> )} {specialists.length === 0 ? ( <div className="text-center py-20 text-white/70"> <p className="text-lg">{t('specialists.empty')}</p> </div> ) : ( <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> {specialists.map((user: any) => { const displayName = formatShortName(user.firstName, user.lastName, user.name) const initials = `${user.firstName?.[0] ?? ''}${user.lastName?.[0] ?? ''}`.toUpperCase() || user.name?.[0]?.toUpperCase() || '?' return ( <div key={user.id} className="relative bg-white rounded-xl border border-gray-200 p-5 hover:shadow-md hover:border-green-300 transition flex flex-col" > {/* Stretched link — covers the whole card for navigation */} <Link href={`/users/${user.id}`} className="absolute inset-0 rounded-xl" aria-label={displayName} /> <FavoriteSpecialistButton specialistId={user.id} initialFavorite={favoriteIds.has(user.id)} favoriteLabel={t('specialist.favorite')} unfavoriteLabel={t('specialist.unfavorite')} /> <div className="flex items-center gap-3 mb-3"> {/* Avatar */} <div className="w-12 h-12 rounded-full bg-green-100 flex-shrink-0 flex items-center justify-center text-green-700 font-bold text-sm overflow-hidden border border-gray-100"> {user.image ? ( <img src={user.image} alt={displayName} className="w-full h-full object-cover" /> ) : ( initials )} </div> <div className="min-w-0 flex-1"> <h2 className="font-semibold text-gray-900 truncate">{displayName}</h2> <div className="flex items-center gap-2 flex-wrap"> {user.planTier && user.planTier !== 'free' && ( <PlanBadge tier={user.planTier} verified={user.hasVerifiedBadge} size="sm" /> )} {user.hasVerifiedBadge && user.planTier === 'free' && ( <PlanBadge tier="free" verified size="sm" /> )} {Array.isArray(user.activeStatuses) && user.activeStatuses.includes('can_help_now') && ( <span className="inline-flex items-center gap-1 text-xs font-medium text-green-700 bg-green-50 border border-green-200 px-2 py-0.5 rounded-full"> <span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" /> {t('status.can_help_now')} </span> )} {user.rating && ( <div className="flex items-center gap-1"> <span className="text-yellow-400 text-sm">★</span> <span className="text-sm font-medium text-gray-700"> {Number(user.rating).toFixed(1)} </span> </div> )} {user.todayStatus === 'available' && ( <span className="inline-flex items-center gap-1 text-xs font-medium text-green-700 bg-green-100 px-2 py-0.5 rounded-full"> <span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" /> {t('schedule.available')} </span> )} {user.todayStatus === 'busy' && ( <span className="inline-flex items-center gap-1 text-xs font-medium text-red-700 bg-red-100 px-2 py-0.5 rounded-full"> <span className="w-1.5 h-1.5 rounded-full bg-red-500 inline-block" /> {t('schedule.busy')} </span> )} </div> </div> </div> {/* Card titles */} {user.cards && user.cards.length > 0 && ( <div className="flex flex-wrap gap-1.5 mb-2"> {user.cards.slice(0, 2).map((card: any) => ( <span key={card.id} className="text-xs bg-gray-100 text-gray-700 px-2 py-0.5 rounded-full font-medium" > {card.title} </span> ))} {user.cards.length > 2 && ( <span className="text-xs text-gray-400">+{user.cards.length - 2}</span> )} </div> )} {/* Bio */} <p className="text-sm text-gray-500 line-clamp-2 flex-1 mt-1"> {user.bio || t('specialists.no_bio')} </p> {/* Last seen */} {user.lastSeenAt && ( <p className="text-xs text-gray-400 mt-3">{formatLastSeen(user.lastSeenAt, t)}</p> )} </div> ) })} </div> )} {/* Pagination */} {totalPages > 1 && ( <div className="flex justify-center gap-2 mt-8"> {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => ( <Link key={p} href={buildUrl({ page: String(p) })} className={`w-10 h-10 flex items-center justify-center rounded-lg text-sm font-medium ${ p === page ? 'bg-green-600 text-white' : 'bg-white border border-gray-200 text-gray-700 hover:border-green-400' }`} > {p} </Link> ))} </div> )} </div> </div> </div> ) }
Save
cmd:
run