/opt/canhelp/apps/web/src/app/users/[id]
NameSizeModeActions
page.tsx376590644editdlrm
Edit: /opt/canhelp/apps/web/src/app/users/[id]/page.tsx (37659B)
'use client' import { useState, useEffect } from 'react' import { useParams, useRouter } from 'next/navigation' import { useLocale } from '@/context/locale' import { useSession } from '@/lib/auth' import { getUserProfile, getUserReviews, getUserRating, getSpecialistAvailability, getSpecialistCards, getCategories, getLocations, getSpecialistFavorites, addSpecialistFavorite, removeSpecialistFavorite, getOrCreateDirectRoom, getCanMessage, getPriceList } from '@/lib/api' import { UpgradePrompt } from '@/components/UpgradePrompt' import type { SpecialistCard, PriceListGroup } from '@/lib/api' import { formatShortName, shortName } from '@/lib/formatName' import { formatLastSeen } from '@/lib/formatLastSeen' import { PlanBadge } from '@/components/PlanBadge' import { CategoryIcon, isCategoryIconUrl } from '@/components/CategoryIcon' const MONTHS_BY_LOCALE: Record = { ru: ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'], en: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], el: ['Ιανουάριος', 'Φεβρουάριος', 'Μάρτιος', 'Απρίλιος', 'Μάιος', 'Ιούνιος', 'Ιούλιος', 'Αύγουστος', 'Σεπτέμβριος', 'Οκτώβριος', 'Νοέμβριος', 'Δεκέμβριος'], uk: ['Січень', 'Лютий', 'Березень', 'Квітень', 'Травень', 'Червень', 'Липень', 'Серпень', 'Вересень', 'Жовтень', 'Листопад', 'Грудень'], } const WEEK_DAYS_BY_LOCALE: Record = { ru: ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'], en: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'], el: ['Δε', 'Τρ', 'Τε', 'Πε', 'Πα', 'Σά', 'Κυ'], uk: ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Нд'], } export default function UserProfilePage() { const { id } = useParams<{ id: string }>() const { t, locale } = useLocale() const { data: session } = useSession() const router = useRouter() const [user, setUser] = useState(null) const [reviews, setReviews] = useState([]) const [rating, setRating] = useState(null) const [loading, setLoading] = useState(true) const [specialistCards, setSpecialistCards] = useState([]) const [priceLists, setPriceLists] = useState>({}) const [catMap, setCatMap] = useState>(new Map()) const [locMap, setLocMap] = useState>(new Map()) const [isFavorite, setIsFavorite] = useState(false) const [favLoading, setFavLoading] = useState(false) const [msgLoading, setMsgLoading] = useState(false) const [canMsg, setCanMsg] = useState<{ allowed: boolean; senderTier?: string } | null>(null) // Availability const now = new Date() const [availYear, setAvailYear] = useState(now.getFullYear()) const [availMonth, setAvailMonth] = useState(now.getMonth()) const [availMap, setAvailMap] = useState>({}) const today = new Date().toISOString().slice(0, 10) const dateLocale = locale === 'ru' ? 'ru-RU' : locale === 'uk' ? 'uk-UA' : locale === 'en' ? 'en-US' : 'el-GR' useEffect(() => { if (!id) return Promise.all([ getUserProfile(id, locale), getUserReviews(id), getUserRating(id), getCategories().catch(() => [] as any[]), getLocations().catch(() => [] as any[]), ]) .then(([u, r, rt, cats, locs]) => { setUser(u) setReviews(r) setRating(rt.avg) setCatMap(new Map(flattenTree(cats as any[]).map((c) => [c.slug, c]))) setLocMap(new Map(flattenTree(locs as any[]).map((l) => [l.slug, l]))) if (u.role === 'specialist' || u.role === 'admin') { getSpecialistCards(id, locale).then((cards) => { setSpecialistCards(cards) cards.forEach((c) => { getPriceList(c.id, locale).then((pl) => { if (pl.length > 0) setPriceLists((prev) => ({ ...prev, [c.id]: pl })) }).catch(() => {}) }) }).catch(() => {}) } }) .catch(() => {}) .finally(() => setLoading(false)) }, [id, locale]) // Load favorite status when session available useEffect(() => { if (!session || !id) return getSpecialistFavorites() .then((rows) => setIsFavorite(rows.some((r) => r.specialist?.id === id))) .catch(() => {}) // Check if current user can message this person const myId = (session.user as any)?.id if (myId && myId !== id) { getCanMessage(id).then(setCanMsg).catch(() => {}) } }, [session, id]) async function toggleFavorite() { if (!session) { router.push('/register'); return } setFavLoading(true) try { if (isFavorite) { await removeSpecialistFavorite(id) setIsFavorite(false) } else { await addSpecialistFavorite(id) setIsFavorite(true) } } catch {} setFavLoading(false) } async function handleMessage() { if (!session) { router.push('/login'); return } setMsgLoading(true) try { const { room } = await getOrCreateDirectRoom(id) router.push(`/chat?room=${room.id}`) } catch {} setMsgLoading(false) } useEffect(() => { if (!id) return const monthKey = `${availYear}-${String(availMonth + 1).padStart(2, '0')}` getSpecialistAvailability(id, monthKey) .then((rows) => { setAvailMap((prev) => { const next = { ...prev } rows.forEach((r) => { next[r.date] = r.status }) return next }) }) .catch(() => {}) }, [id, availYear, availMonth]) if (loading) return
{t('common.loading')}
if (!user) return
{t('common.error')}
const initials = `${user.firstName?.[0] ?? ''}${user.lastName?.[0] ?? ''}`.toUpperCase() || user.name?.[0]?.toUpperCase() || '?' const displayName = formatShortName(user.firstName, user.lastName, user.name) const profileBackgroundImage = typeof user.siteSettings?.profileBackgroundImage === 'string' && user.siteSettings.profileBackgroundImage.trim().length > 0 ? user.siteSettings.profileBackgroundImage : null return (
{/* Profile card */}
{user.image ? ( {displayName} ) : ( initials )}

{displayName}

{t('profile.member_since')} {new Date(user.createdAt).toLocaleDateString(dateLocale)}

{user.planTier && user.planTier !== 'free' && ( )} {user.hasVerifiedBadge && user.planTier === 'free' && ( )}
{/* Avatar */}
{user.image ? ( {displayName} ) : ( initials )}
{/* Info */}

{displayName}

{user.planTier && user.planTier !== 'free' && ( )} {user.hasVerifiedBadge && user.planTier === 'free' && ( )} {/* Active statuses */} {Array.isArray(user.activeStatuses) && user.activeStatuses.includes('can_help_now') && ( {t('status.can_help_now', 'Available now')} )} {Array.isArray(user.activeStatuses) && user.activeStatuses.includes('need_help') && ( {t('status.need_help', 'Need help')} )} {(session ? (session.user as any)?.id !== id : true) && (
{!session ? ( // Guest — show button that leads to registration {t('chat.write_btn', 'Write')} ) : canMsg?.allowed === false ? ( ) : ( )}
)}
{/* Rating */} {rating && (
{Number(rating).toFixed(1)} ({reviews.length} {t('profile.reviews').toLowerCase()})
)} {/* Member since */}

{t('profile.member_since')} {new Date(user.createdAt).toLocaleDateString(dateLocale)} {user.lastSeenAt && ( · {formatLastSeen(user.lastSeenAt, t)} )}

{/* Bio */} {user.bio && (

{user.bio}

)} {/* Phone (visible if showContactInfo enabled) */} {user.phone && ( )} {/* Personal site link */} {user.personalSiteSlug && ( )} {/* Languages */} {user.languages && user.languages.length > 0 && (

{t('profile.languages')}

{(user.languages as string[]).map((code) => { const langMap: Record = { el: { flag: '🇬🇷', label: 'Ελληνικά' }, en: { flag: '🇬🇧', label: 'English' }, ru: { flag: '🇷🇺', label: 'Русский' }, uk: { flag: '🇺🇦', label: 'Українська' }, de: { flag: '🇩🇪', label: 'Deutsch' }, fr: { flag: '🇫🇷', label: 'Français' }, it: { flag: '🇮🇹', label: 'Italiano' }, es: { flag: '🇪🇸', label: 'Español' }, ar: { flag: '🇸🇦', label: 'العربية' }, zh: { flag: '🇨🇳', label: '中文' }, } const lang = langMap[code] if (!lang) return null return ( {lang.flag} {lang.label} ) })}
)} {/* Skills (global, legacy) */} {user.skills && user.skills.length > 0 && specialistCards.length === 0 && (

{t('profile.skills.label')}

{user.skills.map((skill: string) => ( {skill} ))}
)} {/* Categories (legacy — hidden when cards exist) */} {user.role === 'specialist' && user.specialistCategories && user.specialistCategories.length > 0 && specialistCards.length === 0 && (

{t('user.categories')}

{user.specialistCategories.map((slug: string) => ( {slug} ))}
)} {/* Geography (legacy — hidden when cards exist) */} {user.role === 'specialist' && user.specialistLocations && user.specialistLocations.length > 0 && specialistCards.length === 0 && (

{t('user.work_area')}

{user.specialistLocations.map((slug: string) => ( 📍 {slug} ))}
)}
{/* Reviews */} {user.role === 'specialist' && (

📅 {t('nav.schedule')}

{t('schedule.available')} {t('schedule.busy')}
{ if (availMonth === 0) { setAvailYear((y) => y - 1); setAvailMonth(11) } else setAvailMonth((m) => m - 1) }} onNext={() => { if (availMonth === 11) { setAvailYear((y) => y + 1); setAvailMonth(0) } else setAvailMonth((m) => m + 1) }} />
)} {/* Specialist cards */} {(user.role === 'specialist' || user.role === 'admin') && specialistCards.length > 0 && (

{t('nav.specialists')}

{specialistCards.map((card) => ( ))}
)} {/* Reviews */}

{t('profile.reviews')} {reviews.length > 0 && ({reviews.length})}

{reviews.length === 0 ? (

{t('profile.no_reviews')}

) : (
{reviews.map(({ review, author }: any) => (
{author?.image ? ( {author.name} ) : ( author?.name?.[0]?.toUpperCase() ?? '?' )}
{shortName(author?.name) ?? '—'} {'★'.repeat(review.rating)}{'☆'.repeat(5 - review.rating)}
{review.comment && (

{review.comment}

)}

{new Date(review.createdAt).toLocaleDateString(dateLocale)}

))}
)}
) } function ReadOnlyCalendar({ year, month, availMap, today, onPrev, onNext, }: { year: number month: number availMap: Record today: string onPrev: () => void onNext: () => void }) { const { locale, t } = useLocale() const weekDays = WEEK_DAYS_BY_LOCALE[locale] ?? WEEK_DAYS_BY_LOCALE.ru const months = MONTHS_BY_LOCALE[locale] ?? MONTHS_BY_LOCALE.ru const firstDay = new Date(year, month, 1) const startDow = (firstDay.getDay() + 6) % 7 const daysInMonth = new Date(year, month + 1, 0).getDate() const prevMonthDays = new Date(year, month, 0).getDate() const cells: Array<{ day: number; cur: boolean; dateKey: string }> = [] for (let i = startDow - 1; i >= 0; i--) { cells.push({ day: prevMonthDays - i, cur: false, dateKey: '' }) } for (let d = 1; d <= daysInMonth; d++) { const dateKey = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}` cells.push({ day: d, cur: true, dateKey }) } const remainder = cells.length % 7 if (remainder !== 0) { for (let d = 1; d <= 7 - remainder; d++) { cells.push({ day: d, cur: false, dateKey: '' }) } } const monthPrefix = `${year}-${String(month + 1).padStart(2, '0')}` const availCount = Object.entries(availMap).filter(([k, v]) => k.startsWith(monthPrefix) && k >= today && v === 'available').length return (
{/* Month nav */}
{months[month]} {year} {availCount > 0 && ( {availCount} {t('schedule.free_days')} )}
{/* Day headers */}
{weekDays.map((d) => (
{d}
))}
{/* Cells */}
{cells.map((cell, idx) => { if (!cell.cur) { return
{cell.day}
} const status = availMap[cell.dateKey] const isPast = cell.dateKey < today const isToday = cell.dateKey === today let cls = 'h-9 flex items-center justify-center rounded-lg text-sm font-medium ' if (isPast) { cls += 'text-gray-200 ' } else if (status === 'available') { cls += 'bg-emerald-100 border border-emerald-300 text-emerald-700 ' } else if (status === 'busy') { cls += 'bg-red-100 border border-red-300 text-red-600 ' } else { cls += 'text-gray-500 ' } if (isToday) cls += 'ring-2 ring-blue-400 ring-offset-1 ' return (
{cell.day}
) })}
) } // ─── Public card view ───────────────────────────────────────────────────────── function flattenTree(nodes: any[]): any[] { return nodes.flatMap((n) => [n, ...flattenTree(n.children ?? [])]) } function PublicCardView({ card, locale, catMap, locMap, t, priceList }: { card: SpecialistCard; locale: string; catMap: Map; locMap: Map; t: (key: string) => string; priceList: PriceListGroup[] }) { const [lightbox, setLightbox] = useState(null) const [expandedAreas, setExpandedAreas] = useState([]) const locById = new Map() for (const node of locMap.values()) { if (node?.id) locById.set(node.id, node) } const getLocName = (loc: any, fallback: string) => (loc?.names?.[locale] || loc?.names?.el || fallback) const locationGroups = (() => { const grouped = new Map }>() for (const slug of card.locations ?? []) { const loc = locMap.get(slug) if (!loc) { if (!grouped.has(slug)) { grouped.set(slug, { root: { slug, names: { el: slug } }, rootSlug: slug, districts: [], districtSlugs: new Set(), }) } continue } let root = loc const seenIds = new Set() while (root?.parentId && locById.has(root.parentId) && !seenIds.has(root.parentId)) { seenIds.add(root.parentId) root = locById.get(root.parentId) } const rootSlug = root?.slug ?? slug if (!grouped.has(rootSlug)) { grouped.set(rootSlug, { root, rootSlug, districts: [], districtSlugs: new Set() }) } const group = grouped.get(rootSlug)! if (loc.slug !== rootSlug && !group.districtSlugs.has(loc.slug)) { group.districtSlugs.add(loc.slug) group.districts.push(loc) } } return Array.from(grouped.values()) })() return (
{/* Card header */}

{card.title}

{card.description &&

{card.description}

}
{/* Skills */} {card.skills && card.skills.length > 0 && (

{t('card.skills')}

{card.skills.map((s) => ( {s} ))}
)} {/* Categories */} {card.categories && card.categories.length > 0 && (

{t('card.categories')}

{card.categories.map((s) => { const cat = catMap.get(s) const name = cat ? (cat.names?.[locale] || cat.names?.el || s) : s return ( {cat?.icon ? ( ) : null} {name} ) })}
)} {/* Geography */} {card.locations && card.locations.length > 0 && (

{t('user.work_area')}

{locationGroups.map((group) => { const expanded = expandedAreas.includes(group.rootSlug) const canExpand = group.districts.length > 0 return ( ) })}
{locationGroups .filter((group) => expandedAreas.includes(group.rootSlug) && group.districts.length > 0) .map((group) => (

{getLocName(group.root, group.rootSlug)}

{group.districts.map((district) => ( {getLocName(district, district.slug)} ))}
))}
)} {/* Price list */} {priceList.length > 0 && (

{t('profile.card.tab.pricelist')}

{priceList.map((group) => (
{group.title}
{group.items.map((item) => (

{item.name}

{item.description &&

{item.description}

}
{(item.price || item.unit) && ( {item.price ? `€${item.price}` : ''}{item.unit ? ` ${item.unit}` : ''} )}
))}
))}
)} {/* Portfolio */} {card.portfolio && card.portfolio.length > 0 && (

{t('card.portfolio')}

{card.portfolio.map((item) => ( ))}
)}
{/* Lightbox */} {lightbox && (
setLightbox(null)}>
e.stopPropagation()}> {lightbox.title {(lightbox.title || lightbox.description) && (
{lightbox.title &&

{lightbox.title}

} {lightbox.description &&

{lightbox.description}

}
)}
)}
) }