/opt/canhelp/apps/web/src/app/profile
Edit: /opt/canhelp/apps/web/src/app/profile/page.tsx (143381B)
'use client'
import { useState, useEffect, useRef, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useSession, authClient } from '@/lib/auth'
import { useLocale } from '@/context/locale'
import * as api from '@/lib/api'
import type { BalanceTransaction, PlanHistoryEntry, PortfolioItem, SpecialistCard, PriceListGroup } from '@/lib/api'
import { CategoryIcon, isCategoryIconUrl } from '@/components/CategoryIcon'
type Tab = 'info' | 'finance' | 'cards' | 'notifications' | 'reviews' | 'referral' | 'auto_response' | 'site'
// ─── SpecialistCardEditor ──────────────────────────────────────────────────
type CardTab = 'main' | 'skills' | 'categories' | 'geography' | 'portfolio' | 'pricelist'
function SpecialistCardEditor({
card,
isOpen,
onToggle,
onUpdate,
onDelete,
categories,
locationTree,
catLocale,
locale,
hasPriceList,
visibilityIssues = [],
moderationNotice = null,
}: {
card: SpecialistCard
isOpen: boolean
onToggle: () => void
onUpdate: (card: SpecialistCard) => void
onDelete: () => void
categories: any[]
locationTree: any[]
catLocale: string
locale: string
hasPriceList: boolean
visibilityIssues?: string[]
moderationNotice?: { title: string; body: string | null; tone: 'amber' | 'green' | 'red' } | null
}) {
const { t } = useLocale()
const hasVisibilityIssues = visibilityIssues.length > 0
const [cardTab, setCardTab] = useState
('main')
const [saving, setSaving] = useState(false)
const [savedOk, setSavedOk] = useState(false)
const [localTitle, setLocalTitle] = useState(card.title)
const [localDescEl, setLocalDescEl] = useState(card.descriptionEl ?? '')
const [localDescEn, setLocalDescEn] = useState(card.descriptionEn ?? '')
const [localDescRu, setLocalDescRu] = useState(card.descriptionRu ?? '')
const [localDescUk, setLocalDescUk] = useState(card.descriptionUk ?? '')
const [localSkills, setLocalSkills] = useState(card.skills ?? [])
const [skillInput, setSkillInput] = useState('')
const [localCategories, setLocalCategories] = useState(card.categories ?? [])
const [localLocations, setLocalLocations] = useState(card.locations ?? [])
const [localPublicationStatus, setLocalPublicationStatus] = useState<'pending' | 'active' | 'inactive'>(
card.publicationStatus ?? (card.isActive ? 'active' : 'inactive'),
)
const [apiSkillSugs, setApiSkillSugs] = useState([])
const [portfolio, setPortfolio] = useState(card.portfolio ?? [])
const [portfolioUploading, setPortfolioUploading] = useState(false)
const [editingItem, setEditingItem] = useState(null)
const [editTitle, setEditTitle] = useState('')
const [editDesc, setEditDesc] = useState('')
const portfolioFileRef = useRef(null)
// Price list state
const [priceList, setPriceList] = useState([])
const [priceListLoading, setPriceListLoading] = useState(false)
const [newGroupTitle, setNewGroupTitle] = useState('')
const [addingGroupId, setAddingGroupId] = useState(null)
const [newItem, setNewItem] = useState<{ name: string; description: string; price: string; unit: string }>({ name: '', description: '', price: '', unit: '' })
const [editingGroupId, setEditingGroupId] = useState(null)
const [editGroupTitle, setEditGroupTitle] = useState('')
useEffect(() => {
if (cardTab !== 'pricelist' || priceList.length > 0 || priceListLoading) return
setPriceListLoading(true)
api.getPriceList(card.id, locale)
.then(setPriceList)
.catch(() => {})
.finally(() => setPriceListLoading(false))
}, [cardTab, locale])
async function handleAddGroup() {
if (!newGroupTitle.trim()) return
const group = await api.createPriceListGroup(card.id, {
title: newGroupTitle.trim(),
locale: (['el', 'en', 'ru', 'uk'] as const).includes(locale as any) ? (locale as 'el' | 'en' | 'ru' | 'uk') : 'el',
autoTranslate: true,
})
setPriceList((prev) => [...prev, group])
setNewGroupTitle('')
}
async function handleDeleteGroup(groupId: string) {
await api.deletePriceListGroup(groupId)
setPriceList((prev) => prev.filter((g) => g.id !== groupId))
}
async function handleSaveGroupTitle(groupId: string) {
const updated = await api.updatePriceListGroup(groupId, {
title: editGroupTitle.trim(),
locale: (['el', 'en', 'ru', 'uk'] as const).includes(locale as any) ? (locale as 'el' | 'en' | 'ru' | 'uk') : 'el',
autoTranslate: true,
})
setPriceList((prev) => prev.map((g) => g.id === groupId ? { ...g, title: updated.title } : g))
setEditingGroupId(null)
}
async function handleAddItem(groupId: string) {
if (!newItem.name.trim()) return
const item = await api.createPriceListItem(groupId, {
name: newItem.name.trim(),
description: newItem.description.trim() || null,
price: newItem.price.trim() || null,
unit: newItem.unit.trim() || null,
locale: (['el', 'en', 'ru', 'uk'] as const).includes(locale as any) ? (locale as 'el' | 'en' | 'ru' | 'uk') : 'el',
autoTranslate: true,
})
setPriceList((prev) => prev.map((g) => g.id === groupId ? { ...g, items: [...g.items, item] } : g))
setNewItem({ name: '', description: '', price: '', unit: '' })
setAddingGroupId(null)
}
async function handleDeleteItem(itemId: string, groupId: string) {
await api.deletePriceListItem(itemId)
setPriceList((prev) => prev.map((g) => g.id === groupId ? { ...g, items: g.items.filter((i) => i.id !== itemId) } : g))
}
useEffect(() => {
if (cardTab !== 'skills') return
api
.getSkillSuggestions(localCategories.length > 0 ? localCategories : undefined)
.then(setApiSkillSugs)
.catch(() => setApiSkillSugs([]))
}, [localCategories, cardTab])
const lbl = (ru: string, en: string, el: string) =>
locale === 'ru' ? ru : locale === 'en' ? en : el
async function saveAll() {
setSaving(true)
try {
const activeLocale = (['el', 'en', 'ru', 'uk'] as const).includes(locale as any)
? (locale as 'el' | 'en' | 'ru' | 'uk')
: 'el'
const cap = activeLocale.charAt(0).toUpperCase() + activeLocale.slice(1) as 'El' | 'En' | 'Ru' | 'Uk'
const descField = `description${cap}` as const
const descValue = { el: localDescEl, en: localDescEn, ru: localDescRu, uk: localDescUk }[activeLocale] || null
const updated = await api.updateSpecialistCard(card.id, {
title: localTitle,
[descField]: descValue,
descriptionLocale: activeLocale,
autoTranslate: !!descValue,
skills: localSkills,
categories: localCategories,
locations: localLocations,
publicationStatus: localPublicationStatus,
isActive: localPublicationStatus === 'active',
})
onUpdate({ ...updated, portfolio })
setSavedOk(true)
setTimeout(() => setSavedOk(false), 2000)
} catch {}
finally { setSaving(false) }
}
async function updatePublicationStatus(next: 'pending' | 'active' | 'inactive') {
setLocalPublicationStatus(next)
try {
await api.updateSpecialistCard(card.id, {
publicationStatus: next,
isActive: next === 'active',
})
} catch {}
}
function addSkill(s: string) {
const trimmed = s.trim()
if (trimmed && !localSkills.includes(trimmed)) setLocalSkills([...localSkills, trimmed])
setSkillInput('')
}
function toggleCategory(slug: string, childSlugs: string[] = []) {
if (localCategories.includes(slug)) {
setLocalCategories(localCategories.filter((c) => c !== slug && !childSlugs.includes(c)))
} else {
setLocalCategories([...new Set([...localCategories, slug, ...childSlugs])])
}
}
function toggleLocation(slug: string, childSlugs: string[] = []) {
if (localLocations.includes(slug)) {
setLocalLocations(localLocations.filter((c) => c !== slug && !childSlugs.includes(c)))
} else {
setLocalLocations([...new Set([...localLocations, slug, ...childSlugs])])
}
}
async function handlePortfolioUpload(e: React.ChangeEvent) {
const file = e.target.files?.[0]
if (!file) return
setPortfolioUploading(true)
try {
const form = new FormData()
form.append('file', file)
form.append('cardId', card.id)
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'}/api/portfolio`,
{ method: 'POST', credentials: 'include', body: form },
)
if (!res.ok) throw new Error('Upload failed')
const item: PortfolioItem = await res.json()
const next = [...portfolio, item]
setPortfolio(next)
onUpdate({ ...card, portfolio: next })
} catch {}
finally {
setPortfolioUploading(false)
if (portfolioFileRef.current) portfolioFileRef.current.value = ''
}
}
async function handlePortfolioDelete(id: string) {
try {
await api.deletePortfolioItem(id)
const next = portfolio.filter((p) => p.id !== id)
setPortfolio(next)
onUpdate({ ...card, portfolio: next })
} catch {}
}
async function handlePortfolioSave(id: string) {
try {
const item = await api.updatePortfolioItem(id, { title: editTitle, description: editDesc })
setPortfolio((prev) => prev.map((p) => (p.id === id ? item : p)))
setEditingItem(null)
} catch {}
}
const cardTabs: { id: CardTab; label: string; hidden?: boolean }[] = [
{ id: 'main', label: t('profile.card.tab.main') },
{ id: 'categories', label: t('profile.card.tab.categories') },
{ id: 'skills', label: t('profile.card.tab.skills') },
{ id: 'geography', label: t('profile.card.tab.geography') },
{ id: 'pricelist', label: t('profile.card.tab.pricelist'), hidden: !hasPriceList },
{ id: 'portfolio', label: t('profile.card.tab.portfolio') },
]
const pendingReviewNotice = localPublicationStatus === 'pending'
? {
title: t('profile.card.review_pending', 'Pending approval'),
body: t('profile.card.review_pending_hint', 'Your card is being reviewed by the admin team.'),
tone: 'amber' as const,
}
: null
const effectiveModerationNotice = moderationNotice ?? pendingReviewNotice
return (
{/* Card header */}
{localTitle || card.title}
{hasVisibilityIssues && (
!
)}
{isOpen ? '▲' : '▼'}
updatePublicationStatus(
localPublicationStatus === 'active'
? 'inactive'
: localPublicationStatus === 'pending'
? 'inactive'
: 'pending',
)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap ${
localPublicationStatus === 'active'
? 'bg-red-50 text-red-700 hover:bg-red-100'
: localPublicationStatus === 'pending'
? 'bg-amber-50 text-amber-700 hover:bg-amber-100'
: 'bg-green-50 text-green-700 hover:bg-green-100'
}`}
>
{localPublicationStatus === 'active'
? t('profile.card.unpublish', 'Unpublish')
: localPublicationStatus === 'pending'
? t('profile.card.withdraw_review', 'Withdraw request')
: t('profile.card.submit_review', 'Submit for review')}
{/* Delete */}
🗑
{isOpen && (
{hasVisibilityIssues && (
{t('profile.card.visibility_issue', 'Card is not shown in offers')}
{visibilityIssues.map((issue) => (
{issue}
))}
)}
{effectiveModerationNotice && (
{effectiveModerationNotice.title}
{effectiveModerationNotice.body && (
{effectiveModerationNotice.body}
)}
)}
{/* Inner tabs */}
{cardTabs.filter((t) => !t.hidden).map((t) => (
setCardTab(t.id)}
className={`px-4 py-2.5 text-xs font-medium whitespace-nowrap border-b-2 transition -mb-px ${
cardTab === t.id
? 'border-green-500 text-green-600 bg-white'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{t.label}
))}
{/* ── MAIN ── */}
{cardTab === 'main' && (
setLocalTitle(e.target.value)}
maxLength={200}
placeholder={t('profile.cards.new_title')}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
/>
{/* Single-locale description — auto-translated to other languages on save */}
{(() => {
const activeLocale = (['el', 'en', 'ru', 'uk'] as const).includes(locale as any)
? (locale as 'el' | 'en' | 'ru' | 'uk') : 'el'
const descMap = { el: [localDescEl, setLocalDescEl] as const, en: [localDescEn, setLocalDescEn] as const, ru: [localDescRu, setLocalDescRu] as const, uk: [localDescUk, setLocalDescUk] as const }
const [val, setVal] = descMap[activeLocale]
const flags: Record
= { el: '🇬🇷', en: '🇬🇧', ru: '🇷🇺', uk: '🇺🇦' }
const otherLocales = (['el', 'en', 'ru', 'uk'] as const).filter(l => l !== activeLocale)
const otherHasContent = otherLocales.some(l => descMap[l][0])
return (
{flags[activeLocale]} {t('profile.card.description', 'Description')}
setVal(e.target.value)}
rows={4}
maxLength={2000}
placeholder={t('profile.bio.placeholder')}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 resize-none"
/>
{val && (
{t('profile.card.desc_auto_translate', 'Will be automatically translated into other languages on save')}
)}
{otherHasContent && (
{t('profile.card.desc_translated', 'Translations available:')}
{otherLocales.map(l => (
{flags[l]}
))}
)}
)
})()}
)}
{/* ── SKILLS ── */}
{cardTab === 'skills' && (
{localSkills.length > 0 && (
{localSkills.map((s) => (
{s}
setLocalSkills(localSkills.filter((x) => x !== s))}
className="text-gray-400 hover:text-green-700 leading-none">×
))}
)}
setSkillInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSkill(skillInput) } }}
placeholder={t('profile.card.skill.add')}
className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
/>
addSkill(skillInput)}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">+
{apiSkillSugs.length > 0 && (
{apiSkillSugs
.map((s) => s.names[catLocale as 'el' | 'en' | 'uk' | 'ru'] ?? s.names.el)
.filter((name, i, arr) => arr.indexOf(name) === i && !localSkills.includes(name))
.slice(0, 16)
.map((name) => (
addSkill(name)}
className="text-xs px-2.5 py-1 border border-gray-200 rounded-full text-gray-500 hover:border-green-400 hover:text-green-600 transition">
+ {name}
))}
)}
)}
{/* ── CATEGORIES ── */}
{cardTab === 'categories' && (
{categories.map((cat: any) => {
const catName = cat.names?.[catLocale] ?? cat.names?.el ?? cat.slug
const children: any[] = cat.children ?? []
const childSlugs = children.map((c: any) => c.slug)
return (
)
})}
)}
{/* ── GEOGRAPHY ── */}
{cardTab === 'geography' && (
{locationTree.map((city: any) => {
const cityName = city.names?.[catLocale] ?? city.names?.el ?? city.slug
const districts: any[] = city.children ?? []
const districtSlugs = districts.map((d: any) => d.slug)
return (
)
})}
)}
{/* ── PRICE LIST ── */}
{cardTab === 'pricelist' && (
{priceListLoading ? (
{t('common.loading', 'Loading...')}
) : (
<>
{priceList.length === 0 && (
{t('profile.card.pricelist.empty')}
)}
{priceList.map((group) => (
{/* Group header */}
{editingGroupId === group.id ? (
<>
setEditGroupTitle(e.target.value)}
className="flex-1 border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveGroupTitle(group.id) }}
autoFocus
/>
handleSaveGroupTitle(group.id)}
className="px-3 py-1.5 bg-green-600 text-white rounded-lg text-xs font-medium hover:bg-green-700">
{t('profile.card.pricelist.save_group')}
setEditingGroupId(null)}
className="px-2 py-1.5 text-gray-500 hover:text-gray-700 text-xs">✕
>
) : (
<>
{group.title}
{ setEditingGroupId(group.id); setEditGroupTitle(group.title) }}
className="text-gray-400 hover:text-green-600 text-xs px-2 py-1 rounded hover:bg-green-50">✏️
handleDeleteGroup(group.id)}
className="text-gray-400 hover:text-red-500 text-xs px-2 py-1 rounded hover:bg-red-50">
{t('profile.card.pricelist.delete_group')}
>
)}
{/* Items */}
{group.items.map((item) => (
{item.name}
{item.description &&
{item.description}
}
{(item.price || item.unit) && (
{item.price ? `€${item.price}` : ''}{item.unit ? ` ${item.unit}` : ''}
)}
handleDeleteItem(item.id, group.id)}
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 text-xs px-1 transition">
{t('profile.card.pricelist.delete_item')}
))}
{/* Add item */}
{addingGroupId === group.id ? (
) : (
setAddingGroupId(group.id)}
className="text-green-600 hover:text-gray-800 text-sm font-medium">
+ {t('profile.card.pricelist.add_item')}
)}
))}
{/* Add group */}
setNewGroupTitle(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddGroup() }}
className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
/>
{t('profile.card.pricelist.add_group')}
>
)}
)}
{/* ── PORTFOLIO ── */}
{cardTab === 'portfolio' && (
{portfolio.length < 20 && (
portfolioFileRef.current?.click()}
disabled={portfolioUploading}
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-medium disabled:opacity-50">
{portfolioUploading
? <> {t('profile.card.photo.uploading')}>
: <>📷 {t('profile.card.photo.add')}>}
)}
{portfolio.length === 0 ? (
{t('profile.card.photo.empty')}
) : (
{portfolio.map((item) => (
handlePortfolioDelete(item.id)}
className="self-end opacity-0 group-hover:opacity-100 transition w-7 h-7 rounded-full bg-red-500 text-white text-sm flex items-center justify-center hover:bg-red-600">×
{ setEditingItem(item); setEditTitle(item.title ?? ''); setEditDesc(item.description ?? '') }}
className="opacity-0 group-hover:opacity-100 transition text-xs text-white bg-black/50 hover:bg-black/70 rounded-lg px-2 py-1">
✏️ {t('profile.card.item.edit')}
{item.title &&
}
))}
)}
)}
{/* Single save button (not for portfolio or pricelist) */}
{cardTab !== 'portfolio' && cardTab !== 'pricelist' && (
{saving ? '...' : t('profile.card.save')}
{savedOk && (
✓ {t('profile.card.saved')}
)}
)}
)}
{/* Portfolio edit modal */}
{editingItem && (
{t('profile.card.item.edit')}
handlePortfolioSave(editingItem.id)}
className="flex-1 bg-green-600 hover:bg-green-700 text-white py-2 rounded-lg text-sm font-medium">
{t('profile.card.item.save')}
setEditingItem(null)}
className="flex-1 bg-gray-100 hover:bg-gray-200 text-gray-700 py-2 rounded-lg text-sm font-medium">
{t('profile.card.item.cancel')}
)}
)
}
// ─── AutoResponseTab ───────────────────────────────────────────────────────
function AutoResponseTab({
categories,
locationTree,
loaded,
onLoad,
settings,
saving,
saved,
onSave,
t,
catLocale,
}: {
categories: any[]
locationTree: any[]
loaded: boolean
onLoad: () => void
settings: import('@/lib/api').AutoResponseSettings | null
saving: boolean
saved: boolean
onSave: (data: Partial) => Promise
t: (k: string, fb?: string) => string
catLocale: string
}) {
const { useEffect: _ue, useState: _us } = { useEffect, useState }
const [enabled, setEnabled] = _us(false)
const [minBudget, setMinBudget] = _us('')
const [maxBudget, setMaxBudget] = _us('')
const [defaultPrice, setDefaultPrice] = _us('')
const [defaultMessage, setDefaultMessage] = _us('')
const [maxPerDay, setMaxPerDay] = _us('')
const [selCategories, setSelCategories] = _us([])
const [selLocations, setSelLocations] = _us([])
_ue(() => { onLoad() }, [])
_ue(() => {
if (!settings) return
setEnabled(settings.enabled)
setMinBudget(settings.minBudget != null ? String(settings.minBudget) : '')
setMaxBudget(settings.maxBudget != null ? String(settings.maxBudget) : '')
setDefaultPrice(settings.defaultPrice != null ? String(settings.defaultPrice) : '')
setDefaultMessage(settings.defaultMessage ?? '')
setMaxPerDay(settings.maxAutoOffersPerDay != null ? String(settings.maxAutoOffersPerDay) : '')
setSelCategories(settings.categories ?? [])
setSelLocations(settings.locations ?? [])
}, [settings])
if (!loaded) return {t('common.loading')}
const flatLocations: { id: string; name: string }[] = []
function flatten(nodes: any[]) {
for (const n of nodes) {
flatLocations.push({ id: n.id, name: n.name })
if (n.children?.length) flatten(n.children)
}
}
flatten(locationTree)
return (
{/* Enable toggle */}
{t('auto_response.title', 'Auto response')}
{t('auto_response.desc', 'Automatically respond to matching tasks')}
setEnabled(v => !v)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? 'bg-green-500' : 'bg-gray-300'}`}
>
{/* Budget range */}
{/* Default message */}
{t('auto_response.default_message', 'Auto-response text')}
setDefaultMessage(e.target.value)}
rows={4}
maxLength={2000}
placeholder={t('auto_response.message_placeholder', 'Hello! I can help with this task...')}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 resize-none"
/>
{/* Categories filter */}
{categories.length > 0 && (
{t('auto_response.categories', 'Task categories')}
{categories.flatMap((cat: any) => [cat, ...(cat.children ?? [])]).map((cat: any) => {
const name = cat.names?.[catLocale] ?? cat.names?.el ?? cat.slug
const sel = selCategories.includes(cat.slug)
return (
setSelCategories(sel ? selCategories.filter(c => c !== cat.slug) : [...selCategories, cat.slug])}
className={`px-3 py-1.5 rounded-lg text-xs border transition ${sel ? 'bg-green-600 border-green-600 text-white' : 'bg-white border-gray-300 text-gray-700 hover:border-green-400'}`}
>
{name}
)
})}
)}
{/* Save */}
onSave({
enabled,
minBudget: minBudget ? Number(minBudget) : null,
maxBudget: maxBudget ? Number(maxBudget) : null,
defaultPrice: defaultPrice ? Number(defaultPrice) : null,
defaultMessage: defaultMessage || null,
maxAutoOffersPerDay: maxPerDay ? Number(maxPerDay) : null,
categories: selCategories.length > 0 ? selCategories : null,
locations: selLocations.length > 0 ? selLocations : null,
})}
className="w-full bg-green-600 text-white py-3 rounded-xl font-semibold hover:bg-green-700 disabled:opacity-50"
>
{saving ? t('common.loading') : saved ? t('auto_response.saved', 'Saved ✓') : t('common.save')}
)
}
export default function ProfilePage() {
return (
)
}
function ProfilePageInner() {
const router = useRouter()
const searchParams = useSearchParams()
const { data: session, isPending } = useSession()
const { t, locale } = useLocale()
const validTabs: Tab[] = ['info', 'finance', 'cards', 'notifications', 'reviews', 'referral', 'auto_response', 'site']
const initialTab = (searchParams.get('tab') ?? 'info') as Tab
const [activeTab, setActiveTab] = useState(validTabs.includes(initialTab) ? initialTab : 'info')
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState('')
const [formData, setFormData] = useState({
firstName: '', lastName: '', phone: '', bio: '', locale: 'el',
})
const [selectedLanguages, setSelectedLanguages] = useState([])
// Specialist cards
const [cards, setCards] = useState([])
const [maxCards, setMaxCards] = useState(1)
const [cardsLoading, setCardsLoading] = useState(false)
const [cardNotifications, setCardNotifications] = useState([])
const [openCardId, setOpenCardId] = useState(null)
const [creatingCard, setCreatingCard] = useState(false)
const [newCardTitle, setNewCardTitle] = useState('')
const [showNewCardForm, setShowNewCardForm] = useState(false)
const [showUpgradeModal, setShowUpgradeModal] = useState(false)
// Reference data
const [categories, setCategories] = useState([])
const [locationTree, setLocationTree] = useState([])
// Become specialist
const [becomingSpecialist, setBecomingSpecialist] = useState(false)
const [avatarUploading, setAvatarUploading] = useState(false)
const [backgroundUploading, setBackgroundUploading] = useState(false)
const fileRef = useRef(null)
const backgroundFileRef = useRef(null)
// Notification preferences
const [notifyNewTasks, setNotifyNewTasks] = useState(false)
const [notifMessages, setNotifMessages] = useState(true)
const [notifySaving, setNotifySaving] = useState(false)
const [notifySaved, setNotifySaved] = useState(false)
// Email verification resend
const [resendState, setResendState] = useState<'idle' | 'sending' | 'sent'>('idle')
// Referral info
const [referralCode, setReferralCode] = useState(null)
const [referralCount, setReferralCount] = useState(0)
const [referralCopied, setReferralCopied] = useState(false)
const [referralLoaded, setReferralLoaded] = useState(false)
// Balance top-up modal
const [showTopUpModal, setShowTopUpModal] = useState(false)
const [topUpAmount, setTopUpAmount] = useState(10)
const [topUpLoading, setTopUpLoading] = useState(false)
const [topUpError, setTopUpError] = useState('')
const [balanceHistory, setBalanceHistory] = useState([])
const [planHistory, setPlanHistory] = useState([])
// Plan data + statuses
const [dashData, setDashData] = useState(null)
const showPricing = dashData?.showPricing ?? true
const [myStatuses, setMyStatuses] = useState([])
const [statusToggling, setStatusToggling] = useState(null)
const [personalSiteSlug, setPersonalSiteSlug] = useState('')
const [slugSaving, setSlugSaving] = useState(false)
const [slugSaved, setSlugSaved] = useState(false)
const [slugCopied, setSlugCopied] = useState(false)
const [slugError, setSlugError] = useState('')
const [showContactInfo, setShowContactInfo] = useState(false)
const [siteSettings, setSiteSettings] = useState<{
showBio?: boolean
showRating?: boolean
showServices?: boolean
showPriceList?: boolean
showPortfolio?: boolean
showReviews?: boolean
showPhone?: boolean
showStatus?: boolean
showFullLastName?: boolean
siteTheme?: 'dark' | 'light'
profileBackgroundImage?: string | null
}>({})
const [siteSettingsSaving, setSiteSettingsSaving] = useState(false)
const [siteStats, setSiteStats] = useState<{ date: string; views: number; unique: number; messages: number }[] | null>(null)
const [siteStatsLoading, setSiteStatsLoading] = useState(false)
const [autoResponse, setAutoResponse] = useState(null)
const [arLoaded, setArLoaded] = useState(false)
const [arSaving, setArSaving] = useState(false)
const [arSaved, setArSaved] = useState(false)
async function handleTopUp() {
const amount = Number(topUpAmount)
if (!amount || amount < 1) return
setTopUpLoading(true)
setTopUpError('')
try {
const { url, params } = await api.initiateTopUp(amount)
// Build and auto-submit form to ePay
const form = document.createElement('form')
form.method = 'POST'
form.action = url
Object.entries(params).forEach(([k, v]) => {
const input = document.createElement('input')
input.type = 'hidden'
input.name = k
input.value = v
form.appendChild(input)
})
document.body.appendChild(form)
form.submit()
} catch (e: any) {
setTopUpError(e?.message || t('profile.balance.topup.error'))
setTopUpLoading(false)
}
}
async function handleResendVerification() {
if (!user?.email || resendState !== 'idle') return
setResendState('sending')
const callbackURL = `${window.location.origin}/profile`
const result = await authClient.sendVerificationEmail({ email: user.email, callbackURL })
if (result?.error) {
setResendState('idle')
} else {
setResendState('sent')
}
}
useEffect(() => {
if (activeTab !== 'referral' || referralLoaded) return
api.getMyReferralInfo().then((r) => {
setReferralCode(r.referralCode)
setReferralCount(r.referralCount)
}).catch(() => {}).finally(() => setReferralLoaded(true))
}, [activeTab])
// Reviews
const [myReviews, setMyReviews] = useState | null>(null)
const [reviewsLoading, setReviewsLoading] = useState(false)
const [myRating, setMyRating] = useState(null)
useEffect(() => {
if (activeTab !== 'reviews' || myReviews !== null || reviewsLoading) return
setReviewsLoading(true)
api.getMyReviews().then(setMyReviews).catch(() => setMyReviews([])).finally(() => setReviewsLoading(false))
}, [activeTab])
useEffect(() => {
if (activeTab !== 'site' || siteStats !== null || siteStatsLoading) return
setSiteStatsLoading(true)
api.getSiteStats()
.then((res) => setSiteStats(res.days))
.catch(() => setSiteStats([]))
.finally(() => setSiteStatsLoading(false))
}, [activeTab])
useEffect(() => {
if (!isPending && !session) router.push('/login')
}, [session, isPending])
useEffect(() => {
if (!session) return
Promise.all([
api.request('/users/me'),
api.getCategories(),
api.getLocations(),
api.getMyDashboard().catch(() => null),
api.getMyStatuses().catch(() => [] as any[]),
api.getBalanceHistory(8).catch(() => []),
api.getPlanHistory(20).catch(() => []),
])
.then(([u, cats, locs, dash, statusRes, history, phist]) => {
setUser(u)
setFormData({
firstName: u.firstName ?? '',
lastName: u.lastName ?? '',
phone: u.phone ?? '',
bio: u.bio ?? '',
locale: u.locale ?? 'el',
})
setSelectedLanguages(u.languages ?? [])
setNotifyNewTasks(u.notifyNewTasks ?? false)
setNotifMessages(u.notifMessages ?? true)
setCategories(cats)
setLocationTree(locs)
setDashData(dash)
setMyStatuses(
(statusRes ?? []).filter((s: any) => s.isActive).map((s: any) => s.statusType as string)
)
setBalanceHistory(history)
setPlanHistory(phist)
setPersonalSiteSlug(u.personalSiteSlug ?? '')
setShowContactInfo(u.showContactInfo ?? false)
setSiteSettings((u as any).siteSettings ?? {})
loadCards()
api.getUserRating(u.id).then((r) => setMyRating(r.avg)).catch(() => {})
})
.catch(() => {})
.finally(() => setLoading(false))
}, [session])
useEffect(() => {
if (!showPricing && activeTab === 'finance') setActiveTab('info')
}, [activeTab, showPricing])
async function loadCards() {
setCardsLoading(true)
try {
const { cards: c, maxCards: m } = await api.getMySpecialistCards()
setCards(c)
setMaxCards(m)
setOpenCardId((prev) => (prev && c.some((card) => card.id === prev) ? prev : (c[0]?.id ?? null)))
api.getNotifications()
.then((rows) => setCardNotifications(rows.filter((row) => ['specialist_card_submitted', 'specialist_card_approved', 'specialist_card_rejected'].includes(row.type))))
.catch(() => setCardNotifications([]))
} catch {}
finally { setCardsLoading(false) }
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setSaving(true)
try {
const updated = await api.request('/users/me', {
method: 'PATCH',
body: JSON.stringify({
firstName: formData.firstName,
lastName: formData.lastName,
phone: formData.phone || undefined,
bio: formData.bio || undefined,
bioLocale: formData.locale,
autoTranslateBio: true,
locale: formData.locale,
languages: selectedLanguages,
showContactInfo,
}),
})
setUser(updated)
setSaved(true)
setTimeout(() => setSaved(false), 2500)
} catch (err: any) {
setError(err.message || t('common.error'))
} finally {
setSaving(false)
}
}
async function handleBecomeSpecialist() {
setBecomingSpecialist(true)
setError('')
try {
const updated = await api.request('/users/me/become-specialist', { method: 'POST' })
setUser(updated)
setActiveTab('cards')
loadCards()
} catch (err: any) {
setError(err.message || t('common.error'))
} finally {
setBecomingSpecialist(false)
}
}
async function handleSaveSiteSlug() {
setSlugSaving(true)
setSlugError('')
try {
const updated = await api.request('/users/me', {
method: 'PATCH',
body: JSON.stringify({ personalSiteSlug: personalSiteSlug || null }),
})
setUser(updated)
setPersonalSiteSlug(updated.personalSiteSlug ?? '')
setSlugSaved(true)
setTimeout(() => setSlugSaved(false), 2500)
} catch (err: any) {
const code = err?.code ?? ''
if (code === 'SLUG_TAKEN' || err.message === 'This URL is already taken') {
setSlugError(t('profile.site.slug_taken', 'This address is already taken'))
} else {
setSlugError(err.message || t('common.error'))
}
} finally {
setSlugSaving(false)
}
}
async function handleSaveSiteSettings(newSettings: typeof siteSettings) {
setSiteSettings(newSettings)
setSiteSettingsSaving(true)
try {
await api.request('/users/me', {
method: 'PATCH',
body: JSON.stringify({ siteSettings: newSettings }),
})
} catch {} finally {
setSiteSettingsSaving(false)
}
}
async function handleAvatarChange(e: React.ChangeEvent) {
const file = e.target.files?.[0]
if (!file) return
setAvatarUploading(true)
try {
const form = new FormData()
form.append('file', file)
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'}/api/uploads`,
{ method: 'POST', credentials: 'include', body: form },
)
if (!res.ok) throw new Error('Upload failed')
const { url } = await res.json()
const fullUrl = `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'}${url}`
await api.request('/users/me', {
method: 'PATCH',
body: JSON.stringify({ image: fullUrl }),
})
setUser((prev: any) => ({ ...prev, image: fullUrl }))
} catch {
setError(t('profile.upload.error'))
} finally {
setAvatarUploading(false)
}
}
async function handleBackgroundChange(e: React.ChangeEvent) {
const file = e.target.files?.[0]
if (!file) return
setBackgroundUploading(true)
try {
const form = new FormData()
form.append('file', file)
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'}/api/uploads`,
{ method: 'POST', credentials: 'include', body: form },
)
if (!res.ok) throw new Error('Upload failed')
const { url } = await res.json()
const fullUrl = `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'}${url}`
const nextSettings = {
...siteSettings,
profileBackgroundImage: fullUrl,
}
await api.request('/users/me', {
method: 'PATCH',
body: JSON.stringify({ siteSettings: nextSettings }),
})
setSiteSettings(nextSettings)
setSaved(true)
setTimeout(() => setSaved(false), 2500)
} catch {
setError(t('profile.upload.error'))
} finally {
setBackgroundUploading(false)
if (backgroundFileRef.current) backgroundFileRef.current.value = ''
}
}
async function handleRemoveBackground() {
setBackgroundUploading(true)
try {
const nextSettings = {
...siteSettings,
profileBackgroundImage: null,
}
await api.request('/users/me', {
method: 'PATCH',
body: JSON.stringify({ siteSettings: nextSettings }),
})
setSiteSettings(nextSettings)
setSaved(true)
setTimeout(() => setSaved(false), 2500)
} catch {
setError(t('profile.upload.error'))
} finally {
setBackgroundUploading(false)
}
}
async function handleCreateCard() {
if (!newCardTitle.trim()) return
setCreatingCard(true)
try {
const card = await api.createSpecialistCard({ title: newCardTitle.trim() })
setCards((prev) => [...prev, card])
setOpenCardId(card.id)
setNewCardTitle('')
setShowNewCardForm(false)
} catch (err: any) {
setError(err.message || t('common.error'))
} finally {
setCreatingCard(false)
}
}
async function handleDeleteCard(id: string) {
if (!confirm(t('profile.cards.delete_confirm'))) return
try {
await api.deleteSpecialistCard(id)
setCards((prev) => {
const next = prev.filter((c) => c.id !== id)
if (openCardId === id) setOpenCardId(next[0]?.id ?? null)
return next
})
} catch (err: any) {
setError(err.message || t('common.error'))
}
}
if (isPending || loading) {
return {t('common.loading')}
}
const catLocale = locale === 'en' ? 'en' : locale === 'ru' ? 'ru' : locale === 'uk' ? 'uk' : 'el'
const dateLocale = locale === 'en' ? 'en-US' : locale === 'ru' ? 'ru-RU' : 'el-GR'
const isSpecialist = user?.role === 'specialist'
const isAdmin = user?.role === 'admin'
const showSpecialistTabs = isSpecialist || isAdmin
const initials = user
? `${user.firstName?.[0] ?? ''}${user.lastName?.[0] ?? ''}`.toUpperCase()
: '?'
const hasAutoResponse = !!(dashData?.features?.hasAutoResponse)
const profileBio = String(formData.bio ?? user?.bio ?? '').trim()
const publishedCards = cards.filter((c) => (c.publicationStatus ?? (c.isActive ? 'active' : 'inactive')) === 'active')
const hasPublishedCard = publishedCards.length > 0
const hasCardCategories = publishedCards.some((c) => (c.categories?.length ?? 0) > 0)
const hasCardLocations = publishedCards.some((c) => (c.locations?.length ?? 0) > 0)
const moderationNoticeByCardId = cardNotifications.reduce((acc, row) => {
if (!row.referenceId || acc[row.referenceId]) return acc
const tone = row.type === 'specialist_card_approved' ? 'green' : row.type === 'specialist_card_rejected' ? 'red' : 'amber'
const localizedTitle = row.type === 'specialist_card_approved'
? t('profile.card.review_approved', 'Card approved')
: row.type === 'specialist_card_rejected'
? t('profile.card.review_rejected', 'Card rejected')
: t('profile.card.review_pending', 'Pending approval')
const localizedBody = row.type === 'specialist_card_approved'
? t('profile.card.review_approved_hint', 'Your card is now visible in offers.')
: row.type === 'specialist_card_rejected'
? t('profile.card.review_rejected_hint', 'Your card was not approved. Please update it and submit again.')
: t('profile.card.review_pending_hint', 'Your card is being reviewed by the admin team.')
acc[row.referenceId] = { title: localizedTitle, body: localizedBody, tone }
return acc
}, {} as Record)
const listingMissing: string[] = []
if (!profileBio) listingMissing.push(t('profile.listing_requirements.bio', 'Add a short bio in the main profile section'))
if (!hasPublishedCard) listingMissing.push(t('profile.listing_requirements.published_card', 'Publish at least one card'))
if (!hasCardCategories) listingMissing.push(t('profile.listing_requirements.categories', 'Add at least one category to the published card'))
if (!hasCardLocations) listingMissing.push(t('profile.listing_requirements.locations', 'Add at least one location to the published card'))
const tabNeedsAttention: Partial> = {
info: !profileBio,
cards: listingMissing.length > 0,
}
const tabIcons: Record = {
info: (
),
finance: (
),
cards: (
),
notifications: (
),
reviews: (
),
referral: (
),
auto_response: (
),
site: (
),
}
const tabs: { id: Tab; label: string }[] = showSpecialistTabs
? [
{ id: 'info', label: t('profile.tab.info') },
{ id: 'cards', label: t('profile.tab.cards') },
{ id: 'notifications', label: t('profile.tab.notifications') },
{ id: 'reviews', label: t('profile.tab.reviews') },
...(showPricing ? [{ id: 'finance' as Tab, label: t('profile.tab.finance') }] : []),
{ id: 'referral', label: t('profile.tab.referral') },
...(hasAutoResponse ? [{ id: 'auto_response' as Tab, label: t('profile.tab.auto_response', 'Auto response') }] : []),
...(dashData?.features?.hasPersonalSite ? [{ id: 'site' as Tab, label: t('profile.tab.site') }] : []),
]
: [
{ id: 'info', label: t('profile.tab.info') },
{ id: 'cards', label: t('profile.tab.cards') },
{ id: 'notifications', label: t('profile.tab.notifications') },
{ id: 'reviews', label: t('profile.tab.reviews') },
...(showPricing ? [{ id: 'finance' as Tab, label: t('profile.tab.finance') }] : []),
{ id: 'referral', label: t('profile.tab.referral') },
...(dashData?.features?.hasPersonalSite ? [{ id: 'site' as Tab, label: t('profile.tab.site') }] : []),
]
return (
{/* Header with avatar + title + rating */}
{/* Avatar */}
{ setActiveTab('info'); fileRef.current?.click() }}
title={t('profile.photo.change')}
>
{user?.image ? (
) : (
initials
)}
{user?.firstName ? `${user.firstName} ${user.lastName ?? ''}`.trim() : (user?.name ?? t('nav.profile'))}
{myRating && (
★
{Number(myRating).toFixed(1)}
{myReviews !== null && myReviews.length > 0 && (
({myReviews.length})
)}
)}
{!cardsLoading && cards.length === 0 && (
{t('profile.cards.empty', 'No offers yet')}
{t(
'profile.cards.empty_hint',
'Создайте первую карточку, чтобы показать услуги клиентам.'
)}
{
setActiveTab('cards')
setShowNewCardForm(true)
}}
className="inline-flex items-center justify-center rounded-xl bg-amber-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-amber-700"
>
{t('profile.cards.create_first')}
)}
{/* Two-column layout: sidebar nav + content */}
{/* Sidebar nav — visible on sm+ */}
{tabs.length > 1 && (
{tabs.map((tab) => (
setActiveTab(tab.id)}
className={`w-full text-left px-4 py-3 text-sm font-medium transition border-b border-white/10 last:border-0 flex items-center gap-3 ${
activeTab === tab.id
? 'bg-white text-green-700'
: 'text-white/80 hover:bg-white/10 hover:text-white'
}`}
>
{tabIcons[tab.id]}
{tab.label}
{tabNeedsAttention[tab.id] && (
!
)}
))}
)}
{/* Main content */}
{/* Mobile horizontal tabs */}
{tabs.length > 1 && (
{tabs.map((tab) => (
setActiveTab(tab.id)}
className={`flex-1 min-w-fit px-3 py-2 text-xs font-medium rounded-lg transition whitespace-nowrap flex flex-col items-center gap-0.5 ${
activeTab === tab.id
? 'bg-white text-green-700 shadow-sm'
: 'text-white/80 hover:text-white hover:bg-white/10'
}`}
>
{tabIcons[tab.id]}
{tab.label}
{tabNeedsAttention[tab.id] && (
!
)}
))}
)}
{/* ── CARDS TAB (outside form — has its own save logic) ── */}
{activeTab === 'cards' && (
{error &&
{error}
}
{listingMissing.length > 0 && (
{t('profile.listing_requirements.title', 'Your profile is not shown yet in offers')}
{t('profile.listing_requirements.hint', 'To make the card visible, complete these conditions:')}
{listingMissing.map((item) => (
{item}
))}
)}
{/* Header */}
{t('profile.cards.count')}: {cards.length} / {maxCards === null ? '∞' : maxCards}
{
if (maxCards !== null && cards.length >= maxCards) {
setShowUpgradeModal(true)
} else {
setShowNewCardForm(true)
}
}}
className="flex items-center gap-1.5 text-sm font-medium text-white hover:text-white/80"
>
+ {t('profile.cards.add')}
{/* New card form */}
{showNewCardForm && (
{t('profile.cards.new_title')}
setNewCardTitle(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateCard() }}
maxLength={200}
placeholder={t('profile.cards.placeholder')}
autoFocus
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
/>
{creatingCard ? '...' : t('profile.cards.create')}
{ setShowNewCardForm(false); setNewCardTitle('') }}
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg text-sm font-medium">
{t('profile.cards.cancel')}
)}
{cardsLoading &&
{t('common.loading')}
}
{!cardsLoading && cards.length === 0 && !showNewCardForm && (
{t('profile.cards.empty')}
setShowNewCardForm(true)}
className="text-sm text-white font-medium hover:underline">
+ {t('profile.cards.create_first')}
)}
{cards.map((card) => {
const cardIssues: string[] = []
if (!profileBio) {
cardIssues.push(t('profile.listing_requirements.bio', 'Add a short bio in the main profile section'))
}
const publicationStatus = card.publicationStatus ?? (card.isActive ? 'active' : 'inactive')
if (publicationStatus === 'pending') {
cardIssues.push(t('profile.listing_requirements.review_pending', 'Card is under review'))
} else if (publicationStatus !== 'active') {
cardIssues.push(t('profile.listing_requirements.card_inactive', 'Make the card active'))
}
if (!card.categories || card.categories.length === 0) {
cardIssues.push(t('profile.listing_requirements.categories', 'Add at least one category to the active card'))
}
if (!card.locations || card.locations.length === 0) {
cardIssues.push(t('profile.listing_requirements.locations', 'Add at least one location to the active card'))
}
return (
setOpenCardId(card.id)}
onUpdate={(updated) => setCards((prev) => prev.map((c) => c.id === updated.id ? updated : c))}
onDelete={() => handleDeleteCard(card.id)}
categories={categories}
locationTree={locationTree}
catLocale={catLocale}
locale={locale}
hasPriceList={!!(dashData?.features?.hasPriceList)}
visibilityIssues={cardIssues}
moderationNotice={moderationNoticeByCardId[card.id] ?? null}
/>
)
})}
)}
{/* ── SITE TAB ── */}
{activeTab === 'site' && (
{/* URL configuration */}
{t('profile.site.title')}
{t('profile.site_slug_hint')}
{slugSaving ? t('profile.saving') : t('common.save')}
{slugSaved &&
✓ {t('profile.saved')} }
{personalSiteSlug && (
<>
{
navigator.clipboard.writeText(`${window.location.origin}/site/${personalSiteSlug}`)
setSlugCopied(true)
setTimeout(() => setSlugCopied(false), 2000)
}}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-600 hover:bg-gray-50 flex items-center gap-1.5"
>
{slugCopied ? t('profile.site.copied') : t('profile.site.copy')}
{t('profile.site_open')}
>
)}
{slugError &&
{slugError}
}
{/* What's shown on the public page — interactive checkboxes */}
{t('profile.site.visible_title')}
{siteSettingsSaving && (
{t('profile.saving', 'Saving...')}
)}
{([
{ key: 'showBio', label: t('profile.site.visible.bio', 'Biography') },
{ key: 'showRating', label: t('profile.site.visible.rating', 'Rating and reviews') },
{ key: 'showReviews', label: t('profile.site.visible.reviews', 'Reviews section') },
{ key: 'showServices', label: t('profile.site.visible.services', 'Service cards') },
{ key: 'showPriceList', label: t('profile.site.visible.price_list', 'Price list') },
{ key: 'showPortfolio', label: t('profile.site.visible.portfolio', 'Portfolio') },
{ key: 'showPhone', label: t('profile.site.visible.contacts', 'Contact phone') },
{ key: 'showFullLastName', label: t('profile.site.visible.full_last_name', 'Show full last name') },
{ key: 'showStatus', label: t('profile.site.visible.status', 'Status "Available now"') },
] as const).map(({ key, label }) => {
const checked = siteSettings[key] !== false // undefined = true by default
return (
{
const next = { ...siteSettings, [key]: !checked }
handleSaveSiteSettings(next)
}}
>
{checked && (
)}
{
const next = { ...siteSettings, [key]: !checked }
handleSaveSiteSettings(next)
}}
>
{label}
)
})}
{t('profile.site.edit_bio_hint', 'Name and photo are always visible')}
{/* Theme selector */}
{t('profile.site.theme', 'Theme')}
{([
{ value: 'dark' as const, label: t('profile.site.theme.dark', 'Dark'), icon: '🌙' },
{ value: 'light' as const, label: t('profile.site.theme.light', 'Light'), icon: '☀️' },
] as const).map((opt) => {
const selected = (siteSettings.siteTheme ?? 'dark') === opt.value
return (
handleSaveSiteSettings({ ...siteSettings, siteTheme: opt.value })}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl border-2 text-sm font-medium transition ${
selected
? 'border-green-500 bg-green-50 text-green-700'
: 'border-gray-200 bg-white text-gray-500 hover:border-gray-300'
}`}
>
{opt.icon}
{opt.label}
)
})}
{/* Site Analytics */}
{t('profile.site.stats.title', 'Site analytics')}
{t('profile.site.stats.days', 'last 30 days')}
{!personalSiteSlug ? (
{t('profile.site.stats.no_slug', 'Set your site URL to view statistics')}
) : siteStatsLoading || siteStats === null ? (
{t('profile.site.stats.loading', 'Loading analytics...')}
) : (() => {
const totalViews = siteStats.reduce((s, d) => s + d.views, 0)
const totalUnique = siteStats.reduce((s, d) => s + d.unique, 0)
const totalMessages = siteStats.reduce((s, d) => s + d.messages, 0)
// Area chart helpers
const days = siteStats.map(d => d.date)
const seriesDef = [
{ vals: siteStats.map(d => d.views), color: '#3b82f6', fill: '#3b82f615', label: t('profile.site.stats.views', 'Views') },
{ vals: siteStats.map(d => d.unique), color: '#8b5cf6', fill: '#8b5cf610', label: t('profile.site.stats.unique', 'Unique') },
{ vals: siteStats.map(d => d.messages), color: '#10b981', fill: '#10b98110', label: t('profile.site.stats.messages', 'Messages') },
]
const maxVal = Math.max(...seriesDef.flatMap(s => s.vals), 1)
const W = 580; const H = 160; const padL = 36; const padB = 22
const chartW = W - padL - 4; const chartH = H - padB
const stepX = chartW / (days.length - 1)
function pts(vals: number[]): [number, number][] {
return vals.map((v, i) => [padL + i * stepX, chartH - (v / maxVal) * chartH])
}
function smoothPath(points: [number, number][]): string {
if (points.length < 2) return ''
let d = `M ${points[0][0].toFixed(1)} ${points[0][1].toFixed(1)}`
for (let i = 1; i < points.length; i++) {
const cpx = ((points[i-1][0] + points[i][0]) / 2).toFixed(1)
d += ` C ${cpx} ${points[i-1][1].toFixed(1)} ${cpx} ${points[i][1].toFixed(1)} ${points[i][0].toFixed(1)} ${points[i][1].toFixed(1)}`
}
return d
}
function areaPath(vals: number[]): string {
const points = pts(vals)
const line = smoothPath(points)
if (!line) return ''
const lastX = points[points.length - 1][0].toFixed(1)
return `${line} L ${lastX} ${chartH.toFixed(1)} L ${padL.toFixed(1)} ${chartH.toFixed(1)} Z`
}
function fmtY(v: number) { return v >= 1000 ? `${Math.round(v/1000)}K` : String(v) }
const yTicks = [0, Math.round(maxVal / 3), Math.round((maxVal * 2) / 3), maxVal]
const xTicks = days.map((d, i) => ({ i, label: d.slice(8) })).filter((_, i) => i % 7 === 0 || i === days.length - 1)
return (
<>
{/* Summary cards */}
{[
{ label: t('profile.site.stats.total_views', 'Views'), value: totalViews, color: 'text-green-600', bg: 'bg-green-50' },
{ label: t('profile.site.stats.total_unique', 'Unique visitors'), value: totalUnique, color: 'text-violet-600', bg: 'bg-violet-50' },
{ label: t('profile.site.stats.total_messages', 'Messages'), value: totalMessages, color: 'text-emerald-600', bg: 'bg-emerald-50' },
].map((card) => (
{card.value}
{card.label}
))}
{/* Legend */}
{seriesDef.map((s) => (
{s.label}
))}
{/* Area chart */}
{/* Horizontal grid + Y labels */}
{yTicks.map((v, idx) => {
const y = chartH - (v / maxVal) * chartH
return (
{fmtY(v)}
)
})}
{/* Vertical grid */}
{xTicks.map(({ i }) => (
))}
{/* X labels */}
{xTicks.map(({ i, label }) => (
{label}
))}
{/* Area fills */}
{seriesDef.map((s, si) => (
))}
{/* Lines */}
{seriesDef.map((s, si) => (
))}
{/* Last-point dots */}
{seriesDef.map((s, si) => {
const points = pts(s.vals)
const last = points[points.length - 1]
return
})}
{/* Bottom axis */}
>
)
})()}
)}
{/* ── REVIEWS TAB ── */}
{activeTab === 'reviews' && (
{myReviews === null ? (
{t('common.loading')}
) : myReviews.length === 0 ? (
⭐
{t('profile.no_reviews')}
) : (
myReviews.map(({ review, author }) => (
{author?.image ? (
) : (
author?.name?.[0]?.toUpperCase() ?? '?'
)}
{author?.name ?? '—'}
{'★'.repeat(review.rating)}{'☆'.repeat(5 - review.rating)}
{review.comment && (
{review.comment}
)}
{new Date(review.createdAt).toLocaleDateString()}
))
)}
)}
{/* ── FINANCE TAB ── */}
{showPricing && activeTab === 'finance' && (
{/* Balance */}
{t('profile.balance')}
{ setTopUpAmount(10); setTopUpError(''); setShowTopUpModal(true) }}
className="flex items-center gap-1.5 text-sm font-medium text-green-600 hover:text-green-700 hover:bg-green-50 px-3 py-1.5 rounded-lg transition-colors"
>
{t('profile.balance.topup')}
{parseFloat(user?.balance ?? '0').toFixed(2)} EUR
{t('profile.balance.history', 'Operation history')}
{t('profile.balance.history.last', 'Latest operations')}
{balanceHistory.length === 0 ? (
{t('profile.balance.history.empty', 'No operations yet')}
) : (
{balanceHistory.map((entry) => {
const isCredit = entry.direction === 'credit'
const amount = Number(entry.amount).toFixed(2)
const badgeClass = isCredit
? 'bg-emerald-50 text-emerald-700 border-emerald-100'
: 'bg-rose-50 text-rose-700 border-rose-100'
const title = entry.kind === 'topup'
? t('profile.balance.history.kind.topup', 'Balance top-up')
: entry.kind === 'plan_purchase'
? t('profile.balance.history.kind.plan_purchase', 'Plan charge')
: entry.kind === 'admin_topup'
? t('profile.balance.history.kind.admin_topup', 'Admin top-up')
: t('profile.balance.history.kind.admin_adjustment', 'Admin adjustment')
return (
{entry.description || title}
{new Date(entry.createdAt).toLocaleString(dateLocale)}
{isCredit ? '+' : '-'}{amount} {entry.currency}
{t('profile.balance.history.balance_after', 'Balance after')}: {Number(entry.balanceAfter).toFixed(2)} {entry.currency}
)
})}
)}
{/* Plan history */}
{t('profile.plan_history.title', 'Plan history')}
{planHistory.length === 0 ? (
{t('profile.plan_history.empty', 'Plan change history is empty')}
) : (
{planHistory.map((entry) => {
const eventColors: Record
= {
activated: 'bg-green-50 text-green-700 border-green-100',
renewed: 'bg-emerald-50 text-emerald-700 border-emerald-100',
downgraded: 'bg-rose-50 text-rose-700 border-rose-100',
referral_reward: 'bg-amber-50 text-amber-700 border-amber-100',
admin_change: 'bg-purple-50 text-purple-700 border-purple-100',
expired: 'bg-gray-100 text-gray-600 border-gray-200',
}
const eventLabels: Record = {
activated: t('profile.plan_history.event.activated', 'Activated'),
renewed: t('profile.plan_history.event.renewed', 'Renewed'),
downgraded: t('profile.plan_history.event.downgraded', 'Downgraded'),
referral_reward: t('profile.plan_history.event.referral_reward', 'Referral reward'),
admin_change: t('profile.plan_history.event.admin_change', 'Changed by admin'),
expired: t('profile.plan_history.event.expired', 'Expired'),
}
const badgeClass = eventColors[entry.event] ?? 'bg-gray-100 text-gray-600 border-gray-200'
const label = eventLabels[entry.event] ?? entry.event
return (
{entry.planName}
{entry.previousPlanName && entry.previousPlanName !== entry.planName && (
← {entry.previousPlanName}
)}
{new Date(entry.createdAt).toLocaleString(dateLocale)}
{entry.expiresAt && (
{t('profile.plan_history.expires', 'Until')}: {new Date(entry.expiresAt).toLocaleDateString(dateLocale)}
)}
{entry.daysAdded && entry.event === 'referral_reward' && (
+{entry.daysAdded} {t('profile.plan_history.days', 'days')}
)}
{entry.note && (
{entry.note}
)}
{label}
)
})}
)}
{/* Top-up modal */}
{showTopUpModal && (
{t('profile.balance.topup.title')}
setShowTopUpModal(false)} className="text-gray-400 hover:text-gray-600">
{t('profile.balance.topup.hint')}
{[5, 10, 20, 50].map((v) => (
setTopUpAmount(v)}
className={`py-2 rounded-xl text-sm font-semibold border transition-colors ${
Number(topUpAmount) === v
? 'bg-green-600 text-white border-green-600'
: 'border-gray-200 text-gray-700 hover:border-green-400'
}`}
>
€{v}
))}
{topUpError && (
{topUpError}
)}
{topUpLoading ? (
<>
{t('profile.balance.topup.loading')}
>
) : (
t('profile.balance.topup.submit')
)}
)}
{/* Plan / Tariff */}
{t('profile.plan.title')}
{user?.plan ? (
{user.plan.name}
{user.plan.description &&
{user.plan.description}
}
{user.plan.price} {user.plan.currency}
{user.plan.maxCards != null ? (
{t('profile.plan.cards_up_to')} {user.plan.maxCards}
) : (
{t('profile.plan.cards_unlimited')}
)}
{user.plan.maxTasks != null && (
{t('profile.plan.tasks_up_to')} {user.plan.maxTasks}
)}
{user.plan.maxOffers != null && (
{t('profile.plan.offers_up_to')} {user.plan.maxOffers}
)}
{(user as any).planExpiresAt
? `${t('profile.plan.valid_until')} ${new Date((user as any).planExpiresAt).toLocaleDateString()}`
: t('profile.plan.no_expiry')}
{(user.plan.features ?? []).length > 0 && (
{user.plan.features.map((f: string, i: number) => (
✓ {f}
))}
)}
) : (
{t('profile.plan.basic')}
{t('profile.plan.contact_upgrade')}
)}
{/* Link to pricing */}
{showPricing && (
)}
)}
{activeTab !== 'cards' && activeTab !== 'reviews' && activeTab !== 'finance' && activeTab !== 'site' && (
{error && {error}
}
{saved && ✓ {t('profile.saved')}
}
{/* ── NOTIFICATIONS TAB ── */}
{activeTab === 'notifications' && (
{t('notifications.title')}
{notifySaved && (
✓ {t('notifications.saved')}
)}
{/* Messages notification toggle — all users */}
{t('chat.notif_messages')}
{t('notifications.messages.desc')}
{
const next = !notifMessages
setNotifMessages(next)
setNotifySaving(true)
try {
await api.request('/users/me', {
method: 'PATCH',
body: JSON.stringify({ notifMessages: next }),
})
setNotifySaved(true)
setTimeout(() => setNotifySaved(false), 2500)
} catch {
setNotifMessages(!next)
} finally {
setNotifySaving(false)
}
}}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50 ${
notifMessages ? 'bg-green-600' : 'bg-gray-200'
}`}
role="switch"
aria-checked={notifMessages}
>
{/* Email for new tasks toggle — specialists only */}
{showSpecialistTabs && (
{t('notifications.email.new_tasks')}
{t('notifications.email.new_tasks.desc')}
{!user?.plan?.notifyNewTasks && (
🔒 {t('notifications.plan_required')}
)}
{
const next = !notifyNewTasks
setNotifyNewTasks(next)
setNotifySaving(true)
try {
await api.request('/users/me', {
method: 'PATCH',
body: JSON.stringify({ notifyNewTasks: next }),
})
setNotifySaved(true)
setTimeout(() => setNotifySaved(false), 2500)
} catch {
setNotifyNewTasks(!next)
} finally {
setNotifySaving(false)
}
}}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50 ${
notifyNewTasks ? 'bg-green-600' : 'bg-gray-200'
}`}
role="switch"
aria-checked={notifyNewTasks}
>
)}
)}
{/* ── REFERRAL TAB ── */}
{activeTab === 'referral' && (
{t('profile.referral.title')}
{t('profile.referral.desc')}
{!referralLoaded ? (
{t('common.loading')}
) : referralCode ? (
<>
{referralCount}
{t('profile.referral.invited_count')}
>
) : (
{t('profile.referral.no_code')}
)}
)}
{/* ── AUTO-RESPONSE TAB ── */}
{activeTab === 'auto_response' && (
{
if (arLoaded) return
api.getAutoResponseSettings()
.then((s) => { setAutoResponse(s); setArLoaded(true) })
.catch(() => setArLoaded(true))
}}
settings={autoResponse}
saving={arSaving}
saved={arSaved}
onSave={async (data) => {
setArSaving(true)
setArSaved(false)
try {
const updated = await api.updateAutoResponseSettings(data)
setAutoResponse(updated)
setArSaved(true)
setTimeout(() => setArSaved(false), 2500)
} catch {} finally { setArSaving(false) }
}}
t={t}
catLocale={catLocale}
/>
)}
{/* ── INFO TAB ── */}
{activeTab === 'info' && (
<>
{/* Avatar */}
{t('profile.photo')}
{user?.image ? (
) : (
{initials}
)}
{avatarUploading && (
)}
fileRef.current?.click()}
disabled={avatarUploading}
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{t('profile.photo.change')}
{t('profile.photo.hint')}
{isSpecialist ? (
dashData?.features?.hasCanHelpNowStatus ? (
{t('status.can_help_now', 'Available now')}
{
setStatusToggling('canhelp_now')
const nextActive = !myStatuses.includes('canhelp_now')
try {
const res = await api.toggleStatus('canhelp_now', nextActive)
setMyStatuses(prev =>
res.isActive
? [...prev.filter(s => s !== 'canhelp_now'), 'canhelp_now']
: prev.filter(s => s !== 'canhelp_now')
)
} catch {} finally { setStatusToggling(null) }
}}
className={`relative inline-flex h-5 w-10 items-center rounded-full transition-colors ${
myStatuses.includes('canhelp_now') ? 'bg-green-500' : 'bg-gray-300'
} disabled:opacity-50`}
>
) : (
{t('status.plan_required', 'Status is unavailable on your current plan')}
)
) : !isAdmin ? (
dashData?.features?.hasNeedHelpStatus ? (
{t('status.need_help', 'Need help')}
{
setStatusToggling('need_help')
const nextActive = !myStatuses.includes('need_help')
try {
const res = await api.toggleStatus('need_help', nextActive)
setMyStatuses(prev =>
res.isActive
? [...prev.filter(s => s !== 'need_help'), 'need_help']
: prev.filter(s => s !== 'need_help')
)
} catch {} finally { setStatusToggling(null) }
}}
className={`relative inline-flex h-5 w-10 items-center rounded-full transition-colors ${
myStatuses.includes('need_help') ? 'bg-green-500' : 'bg-gray-300'
} disabled:opacity-50`}
>
) : (
{t('status.plan_required', 'Status is unavailable on your current plan')}
)
) : (
{t('status.admin_not_applicable', 'User status is not applicable to admin accounts')}
)}
{/* Header background */}
{t('profile.background.title', 'Profile header background')}
{backgroundUploading && (
)}
backgroundFileRef.current?.click()}
disabled={backgroundUploading}
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{backgroundUploading
? t('profile.photo.uploading', 'Uploading...')
: t('profile.background.change', 'Change background')}
{siteSettings.profileBackgroundImage && (
{t('profile.background.remove', 'Remove background')}
)}
{t('profile.background.hint', 'Shown in the public profile header on web and mobile app.')}
{/* Basic info */}
{t('profile.basic_info')}
{t('profile.phone')}
setFormData({ ...formData, phone: e.target.value })}
placeholder={t('profile.phone.placeholder')}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400 text-sm"
/>
{t('profile.bio')}
setFormData({ ...formData, bio: e.target.value })}
rows={3}
maxLength={1000}
placeholder={t('profile.bio.placeholder')}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400 text-sm resize-none"
/>
{t('profile.language')}
{t('profile.language.hint')}
setFormData({ ...formData, locale: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
>
Ελληνικά
English
Русский
Українська
{t('profile.languages')}
{t('profile.languages.hint')}
{[
{ code: 'el', flag: '🇬🇷', label: 'Ελληνικά' },
{ code: 'en', flag: '🇬🇧', label: 'English' },
{ code: 'ru', flag: '🇷🇺', label: 'Русский' },
{ code: 'uk', flag: '🇺🇦', label: 'Українська' },
{ code: 'de', flag: '🇩🇪', label: 'Deutsch' },
{ code: 'fr', flag: '🇫🇷', label: 'Français' },
{ code: 'it', flag: '🇮🇹', label: 'Italiano' },
{ code: 'es', flag: '🇪🇸', label: 'Español' },
{ code: 'ar', flag: '🇸🇦', label: 'العربية' },
{ code: 'zh', flag: '🇨🇳', label: '中文' },
].map(({ code, flag, label }) => {
const selected = selectedLanguages.includes(code)
return (
setSelectedLanguages(
selected
? selectedLanguages.filter((l) => l !== code)
: [...selectedLanguages, code],
)
}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm border transition ${
selected
? 'bg-green-600 border-green-600 text-white'
: 'bg-white border-gray-300 text-gray-700 hover:border-green-400'
}`}
>
{flag}
{label}
)
})}
{!isSpecialist && !isAdmin && (
{t('profile.become_specialist.desc')}
{becomingSpecialist ? t('common.loading') : t('profile.become_specialist')}
)}
{/* Show contact info toggle */}
{isSpecialist && (dashData?.plan?.tier === 'pro' || dashData?.plan?.tier === 'ultimate') && (
{t('profile.show_contact', 'Show contacts')}
{t('profile.show_contact_hint', 'Clients can see your phone number')}
{t('profile.show_contact_desc', 'Lets clients contact you directly')}
setShowContactInfo(v => !v)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
showContactInfo ? 'bg-green-500' : 'bg-gray-300'
}`}
>
)}
{/* personal site slug moved to Site tab */}
>
)}
{saving ? t('profile.saving') : t('common.save')}
)}
{/* ── Upgrade Plan Modal ── */}
{showUpgradeModal && (
🚀
{t('profile.upgrade.cards_limit')}
{user?.plan?.name ?? t('profile.plan.basic')} — {t('profile.plan.cards_up_to')} {maxCards}
{user?.plan && (
{t('profile.upgrade.plan')} {' '}
{user.plan.name} — {user.plan.price} {user.plan.currency}
)}
{t('profile.upgrade.contact')}
setShowUpgradeModal(false)}
className="w-full bg-green-600 hover:bg-green-700 text-white py-2.5 rounded-xl text-sm font-semibold"
>
{t('profile.upgrade.ok')}
)}
)
}