/
opt
/
canhelp
/
apps
/
web
/
src
/
app
/
users
/
[id]
/
/opt/canhelp/apps/web/src/app/users/[id]
mkdir
upload
Name
Size
Mode
Actions
page.tsx
37659
0644
edit
dl
rm
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<string, string[]> = { ru: ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'], en: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], el: ['Ιανουάριος', 'Φεβρουάριος', 'Μάρτιος', 'Απρίλιος', 'Μάιος', 'Ιούνιος', 'Ιούλιος', 'Αύγουστος', 'Σεπτέμβριος', 'Οκτώβριος', 'Νοέμβριος', 'Δεκέμβριος'], uk: ['Січень', 'Лютий', 'Березень', 'Квітень', 'Травень', 'Червень', 'Липень', 'Серпень', 'Вересень', 'Жовтень', 'Листопад', 'Грудень'], } const WEEK_DAYS_BY_LOCALE: Record<string, string[]> = { 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<any>(null) const [reviews, setReviews] = useState<any[]>([]) const [rating, setRating] = useState<string | null>(null) const [loading, setLoading] = useState(true) const [specialistCards, setSpecialistCards] = useState<SpecialistCard[]>([]) const [priceLists, setPriceLists] = useState<Record<string, PriceListGroup[]>>({}) const [catMap, setCatMap] = useState<Map<string, any>>(new Map()) const [locMap, setLocMap] = useState<Map<string, any>>(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<Record<string, 'available' | 'busy'>>({}) 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 <div className="text-center py-20 text-white">{t('common.loading')}</div> if (!user) return <div className="text-center py-20 text-white/70">{t('common.error')}</div> 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 ( <div className="max-w-3xl mx-auto px-4 py-10"> {/* Profile card */} <div className="bg-white rounded-xl border border-gray-200 p-6 mb-6"> <div className="relative -mx-6 -mt-6 mb-5 h-44 overflow-hidden rounded-t-xl" style={{ backgroundImage: profileBackgroundImage ? `linear-gradient(135deg, rgba(21, 128, 61, 0.45), rgba(22, 163, 74, 0.65)), url(${profileBackgroundImage})` : 'linear-gradient(135deg, #22c55e 0%, #16a34a 45%, #166534 100%)', backgroundSize: 'cover', backgroundPosition: 'center', }} > <div className="absolute inset-0 bg-gradient-to-b from-black/10 to-black/30" /> <div className="relative h-full w-full p-5 flex items-end justify-between"> <div className="flex items-center gap-3 min-w-0"> <div className="w-14 h-14 rounded-full bg-white/20 border border-white/40 overflow-hidden flex items-center justify-center text-white text-xl font-bold backdrop-blur-sm"> {user.image ? ( <img src={user.image} alt={displayName} className="w-full h-full object-cover" /> ) : ( initials )} </div> <div className="min-w-0"> <p className="text-white text-lg font-bold truncate">{displayName}</p> <p className="text-white/85 text-xs">{t('profile.member_since')} {new Date(user.createdAt).toLocaleDateString(dateLocale)}</p> </div> </div> <div className="shrink-0"> {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" /> )} </div> </div> </div> <div className="flex items-start gap-5"> {/* Avatar */} <div className="w-20 h-20 rounded-full bg-green-100 flex-shrink-0 flex items-center justify-center text-green-700 text-2xl font-bold overflow-hidden border-2 border-gray-100"> {user.image ? ( <img src={user.image} alt={displayName} className="w-full h-full object-cover" /> ) : ( initials )} </div> {/* Info */} <div className="flex-1 min-w-0"> <div className="flex items-center gap-2 flex-wrap mb-1"> <h1 className="text-xl font-bold text-gray-900">{displayName}</h1> {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" /> )} {/* Active statuses */} {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', 'Available now')} </span> )} {Array.isArray(user.activeStatuses) && user.activeStatuses.includes('need_help') && ( <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" /> {t('status.need_help', 'Need help')} </span> )} {(session ? (session.user as any)?.id !== id : true) && ( <div className="ml-auto flex items-center gap-2"> {!session ? ( // Guest — show button that leads to registration <a href="/register" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-green-50 text-green-600 border border-green-200 hover:bg-green-100 transition" > <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> <path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /> </svg> <span>{t('chat.write_btn', 'Write')}</span> </a> ) : canMsg?.allowed === false ? ( <UpgradePrompt feature={t('chat.write_btn', 'Write')} requiredTier="pro" variant="inline" t={t} /> ) : ( <button onClick={handleMessage} disabled={msgLoading} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-green-50 text-green-600 border border-green-200 hover:bg-green-100 transition disabled:opacity-50" > <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> <path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /> </svg> <span>{t('chat.write_btn')}</span> </button> )} <button onClick={toggleFavorite} disabled={favLoading} title={isFavorite ? t('specialist.unfavorite') : t('specialist.favorite')} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50 ${ isFavorite ? 'bg-pink-50 text-pink-600 border border-pink-200 hover:bg-pink-100' : 'bg-gray-50 text-gray-600 border border-gray-200 hover:bg-gray-100' }`} > <span>{isFavorite ? '❤️' : '🤍'}</span> <span>{isFavorite ? t('specialist.unfavorite') : t('specialist.favorite')}</span> </button> </div> )} </div> {/* Rating */} {rating && ( <div className="flex items-center gap-1.5 mb-2"> <span className="text-yellow-400 text-lg">★</span> <span className="font-semibold text-gray-800">{Number(rating).toFixed(1)}</span> <span className="text-sm text-gray-400">({reviews.length} {t('profile.reviews').toLowerCase()})</span> </div> )} {/* Member since */} <p className="text-sm text-gray-400"> {t('profile.member_since')} {new Date(user.createdAt).toLocaleDateString(dateLocale)} {user.lastSeenAt && ( <span className="ml-2">· {formatLastSeen(user.lastSeenAt, t)}</span> )} </p> </div> </div> {/* Bio */} {user.bio && ( <p className="mt-4 text-gray-700 text-sm leading-relaxed border-t border-gray-100 pt-4"> {user.bio} </p> )} {/* Phone (visible if showContactInfo enabled) */} {user.phone && ( <div className="mt-4 border-t border-gray-100 pt-4 flex items-center gap-2"> <svg className="w-4 h-4 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> <path strokeLinecap="round" strokeLinejoin="round" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" /> </svg> <a href={`tel:${user.phone}`} className="text-sm font-medium text-gray-700 hover:text-green-600">{user.phone}</a> </div> )} {/* Personal site link */} {user.personalSiteSlug && ( <div className="mt-3 flex items-center gap-2"> <svg className="w-4 h-4 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> <path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> </svg> <a href={`/site/${user.personalSiteSlug}`} target="_blank" rel="noopener noreferrer" className="text-sm text-green-600 hover:underline"> canhelp.gr/site/{user.personalSiteSlug} </a> </div> )} {/* Languages */} {user.languages && user.languages.length > 0 && ( <div className="mt-4 border-t border-gray-100 pt-4"> <p className="text-xs font-medium text-gray-500 mb-2 uppercase tracking-wide">{t('profile.languages')}</p> <div className="flex flex-wrap gap-2"> {(user.languages as string[]).map((code) => { const langMap: Record<string, { flag: string; label: string }> = { 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 ( <span key={code} className="flex items-center gap-1.5 bg-indigo-50 text-indigo-700 text-sm px-3 py-1 rounded-full border border-indigo-100"> <span>{lang.flag}</span> <span>{lang.label}</span> </span> ) })} </div> </div> )} {/* Skills (global, legacy) */} {user.skills && user.skills.length > 0 && specialistCards.length === 0 && ( <div className="mt-4 border-t border-gray-100 pt-4"> <p className="text-xs font-medium text-gray-500 mb-2 uppercase tracking-wide">{t('profile.skills.label')}</p> <div className="flex flex-wrap gap-2"> {user.skills.map((skill: string) => ( <span key={skill} className="bg-green-50 text-green-700 text-sm px-3 py-1 rounded-full"> {skill} </span> ))} </div> </div> )} {/* Categories (legacy — hidden when cards exist) */} {user.role === 'specialist' && user.specialistCategories && user.specialistCategories.length > 0 && specialistCards.length === 0 && ( <div className="mt-4 border-t border-gray-100 pt-4"> <p className="text-xs font-medium text-gray-500 mb-2 uppercase tracking-wide"> {t('user.categories')} </p> <div className="flex flex-wrap gap-2"> {user.specialistCategories.map((slug: string) => ( <span key={slug} className="bg-purple-50 text-purple-700 text-sm px-3 py-1 rounded-full">{slug}</span> ))} </div> </div> )} {/* Geography (legacy — hidden when cards exist) */} {user.role === 'specialist' && user.specialistLocations && user.specialistLocations.length > 0 && specialistCards.length === 0 && ( <div className="mt-4 border-t border-gray-100 pt-4"> <p className="text-xs font-medium text-gray-500 mb-2 uppercase tracking-wide"> {t('user.work_area')} </p> <div className="flex flex-wrap gap-2"> {user.specialistLocations.map((slug: string) => ( <span key={slug} className="bg-emerald-50 text-emerald-700 text-sm px-3 py-1 rounded-full">📍 {slug}</span> ))} </div> </div> )} </div> {/* Reviews */} {user.role === 'specialist' && ( <div className="bg-white rounded-xl border border-gray-200 p-6 mb-6"> <div className="flex items-center justify-between mb-4"> <h2 className="font-bold text-gray-900 text-lg">📅 {t('nav.schedule')}</h2> <div className="flex items-center gap-3 text-xs text-gray-500"> <span className="flex items-center gap-1.5"> <span className="w-3 h-3 rounded bg-emerald-200 border border-emerald-400 inline-block" /> {t('schedule.available')} </span> <span className="flex items-center gap-1.5"> <span className="w-3 h-3 rounded bg-red-200 border border-red-400 inline-block" /> {t('schedule.busy')} </span> </div> </div> <ReadOnlyCalendar year={availYear} month={availMonth} availMap={availMap} today={today} onPrev={() => { 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) }} /> </div> )} {/* Specialist cards */} {(user.role === 'specialist' || user.role === 'admin') && specialistCards.length > 0 && ( <div className="space-y-4 mb-6"> <h2 className="font-bold text-white text-lg"> {t('nav.specialists')} </h2> {specialistCards.map((card) => ( <PublicCardView key={card.id} card={card} locale={locale} catMap={catMap} locMap={locMap} t={t} priceList={priceLists[card.id] ?? []} /> ))} </div> )} {/* Reviews */} <div className="bg-white rounded-xl border border-gray-200 p-6"> <h2 className="font-bold text-gray-900 text-lg mb-4"> {t('profile.reviews')} {reviews.length > 0 && <span className="text-gray-400 font-normal text-base ml-2">({reviews.length})</span>} </h2> {reviews.length === 0 ? ( <p className="text-gray-500 text-sm">{t('profile.no_reviews')}</p> ) : ( <div className="space-y-4"> {reviews.map(({ review, author }: any) => ( <div key={review.id} className="border-b border-gray-50 pb-4 last:border-0 last:pb-0"> <div className="flex items-start gap-3"> <div className="w-8 h-8 rounded-full bg-gray-100 flex-shrink-0 flex items-center justify-center text-gray-600 text-sm font-bold overflow-hidden"> {author?.image ? ( <img src={author.image} alt={author.name} className="w-full h-full object-cover" /> ) : ( author?.name?.[0]?.toUpperCase() ?? '?' )} </div> <div className="flex-1"> <div className="flex items-center gap-2 mb-1"> <span className="text-sm font-medium text-gray-800">{shortName(author?.name) ?? '—'}</span> <span className="text-yellow-400">{'★'.repeat(review.rating)}{'☆'.repeat(5 - review.rating)}</span> </div> {review.comment && ( <p className="text-sm text-gray-600">{review.comment}</p> )} <p className="text-xs text-gray-400 mt-1"> {new Date(review.createdAt).toLocaleDateString(dateLocale)} </p> </div> </div> </div> ))} </div> )} </div> </div> ) } function ReadOnlyCalendar({ year, month, availMap, today, onPrev, onNext, }: { year: number month: number availMap: Record<string, 'available' | 'busy'> 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 ( <div> {/* Month nav */} <div className="flex items-center justify-between mb-3"> <button onClick={onPrev} className="w-7 h-7 rounded-lg hover:bg-gray-100 flex items-center justify-center text-gray-500">‹</button> <div className="text-center"> <span className="font-medium text-gray-800 text-sm">{months[month]} {year}</span> {availCount > 0 && ( <span className="ml-2 text-xs text-emerald-600">{availCount} {t('schedule.free_days')}</span> )} </div> <button onClick={onNext} className="w-7 h-7 rounded-lg hover:bg-gray-100 flex items-center justify-center text-gray-500">›</button> </div> {/* Day headers */} <div className="grid grid-cols-7 mb-1"> {weekDays.map((d) => ( <div key={d} className="text-center text-xs text-gray-400 py-1">{d}</div> ))} </div> {/* Cells */} <div className="grid grid-cols-7 gap-0.5"> {cells.map((cell, idx) => { if (!cell.cur) { return <div key={`e-${idx}`} className="h-9 flex items-center justify-center text-xs text-gray-200">{cell.day}</div> } 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 ( <div key={cell.dateKey} className={cls}>{cell.day}</div> ) })} </div> </div> ) } // ─── 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<string, any>; locMap: Map<string, any>; t: (key: string) => string; priceList: PriceListGroup[] }) { const [lightbox, setLightbox] = useState<SpecialistCard['portfolio'][0] | null>(null) const [expandedAreas, setExpandedAreas] = useState<string[]>([]) const locById = new Map<string, any>() 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<string, { root: any; rootSlug: string; districts: any[]; districtSlugs: Set<string> }>() 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<string>() 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 ( <div className="bg-white rounded-xl border border-gray-200 overflow-hidden"> {/* Card header */} <div className="px-5 py-4 border-b border-gray-100"> <h3 className="font-bold text-gray-900">{card.title}</h3> {card.description && <p className="text-sm text-gray-600 mt-1">{card.description}</p>} </div> <div className="p-5 space-y-4"> {/* Skills */} {card.skills && card.skills.length > 0 && ( <div> <p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">{t('card.skills')}</p> <div className="flex flex-wrap gap-2"> {card.skills.map((s) => ( <span key={s} className="bg-green-50 text-green-700 text-sm px-3 py-1 rounded-full">{s}</span> ))} </div> </div> )} {/* Categories */} {card.categories && card.categories.length > 0 && ( <div> <p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">{t('card.categories')}</p> <div className="flex flex-wrap gap-2"> {card.categories.map((s) => { const cat = catMap.get(s) const name = cat ? (cat.names?.[locale] || cat.names?.el || s) : s return ( <span key={s} className="bg-purple-50 text-purple-700 text-sm px-3 py-1 rounded-full"> {cat?.icon ? ( <span className="mr-1 inline-flex align-middle"> <CategoryIcon icon={cat.icon} alt={name} className={isCategoryIconUrl(cat.icon) ? 'h-4 w-4 object-contain' : 'text-sm leading-none'} fallback="📦" /> </span> ) : null} {name} </span> ) })} </div> </div> )} {/* Geography */} {card.locations && card.locations.length > 0 && ( <div> <p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">{t('user.work_area')}</p> <div className="space-y-2"> <div className="flex flex-wrap gap-2"> {locationGroups.map((group) => { const expanded = expandedAreas.includes(group.rootSlug) const canExpand = group.districts.length > 0 return ( <button key={group.rootSlug} type="button" onClick={() => { if (!canExpand) return setExpandedAreas((prev) => prev.includes(group.rootSlug) ? prev.filter((slug) => slug !== group.rootSlug) : [...prev, group.rootSlug], ) }} className={`inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-full border transition ${ expanded ? 'bg-emerald-100 text-emerald-800 border-emerald-300' : 'bg-emerald-50 text-emerald-700 border-emerald-200 hover:bg-emerald-100' } ${canExpand ? 'cursor-pointer' : 'cursor-default'}`} > <span>📍</span> <span>{getLocName(group.root, group.rootSlug)}</span> {canExpand && ( <span className="text-[11px] font-semibold px-1.5 py-0.5 rounded-full bg-white/80 text-emerald-700 border border-emerald-200"> {expanded ? '−' : `+${group.districts.length}`} </span> )} </button> ) })} </div> {locationGroups .filter((group) => expandedAreas.includes(group.rootSlug) && group.districts.length > 0) .map((group) => ( <div key={`${group.rootSlug}-children`} className="rounded-xl border border-emerald-100 bg-emerald-50/40 p-3"> <p className="text-xs text-emerald-700 font-medium mb-2"> {getLocName(group.root, group.rootSlug)} </p> <div className="flex flex-wrap gap-1.5"> {group.districts.map((district) => ( <span key={district.slug} className="text-xs px-2.5 py-1 rounded-full bg-white border border-emerald-200 text-emerald-700"> {getLocName(district, district.slug)} </span> ))} </div> </div> ))} </div> </div> )} {/* Price list */} {priceList.length > 0 && ( <div> <p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">{t('profile.card.tab.pricelist')}</p> <div className="space-y-3"> {priceList.map((group) => ( <div key={group.id} className="border border-gray-200 rounded-xl overflow-hidden"> <div className="bg-gray-50 px-4 py-2 border-b border-gray-200"> <span className="font-semibold text-sm text-gray-800">{group.title}</span> </div> <div className="divide-y divide-gray-100"> {group.items.map((item) => ( <div key={item.id} className="flex items-center gap-3 px-4 py-2.5 text-sm"> <div className="flex-1 min-w-0"> <p className="font-medium text-gray-800">{item.name}</p> {item.description && <p className="text-xs text-gray-500">{item.description}</p>} </div> {(item.price || item.unit) && ( <span className="text-green-700 font-semibold whitespace-nowrap"> {item.price ? `€${item.price}` : ''}{item.unit ? ` ${item.unit}` : ''} </span> )} </div> ))} </div> </div> ))} </div> </div> )} {/* Portfolio */} {card.portfolio && card.portfolio.length > 0 && ( <div> <p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">{t('card.portfolio')}</p> <div className="grid grid-cols-2 sm:grid-cols-3 gap-3"> {card.portfolio.map((item) => ( <button key={item.id} type="button" onClick={() => setLightbox(item)} className="group relative rounded-xl overflow-hidden border border-gray-200 bg-gray-50 text-left"> <img src={item.imageUrl} alt={item.title ?? ''} className="w-full aspect-square object-cover group-hover:scale-105 transition-transform duration-200"/> {item.title && <div className="px-2 py-1.5"><p className="text-xs font-medium text-gray-700 truncate">{item.title}</p></div>} </button> ))} </div> </div> )} </div> {/* Lightbox */} {lightbox && ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 px-4" onClick={() => setLightbox(null)}> <div className="bg-white rounded-2xl overflow-hidden max-w-lg w-full shadow-2xl" onClick={(e) => e.stopPropagation()}> <img src={lightbox.imageUrl} alt={lightbox.title ?? ''} className="w-full max-h-[60vh] object-contain bg-gray-900"/> {(lightbox.title || lightbox.description) && ( <div className="p-4"> {lightbox.title && <p className="font-semibold text-gray-800 mb-1">{lightbox.title}</p>} {lightbox.description && <p className="text-sm text-gray-600">{lightbox.description}</p>} </div> )} <div className="flex justify-end px-4 pb-4"> <button type="button" onClick={() => setLightbox(null)} className="text-sm text-gray-500 hover:text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-100"> {t('card.close')} </button> </div> </div> </div> )} </div> ) }
Save
cmd:
run