/
opt
/
canhelp
/
apps
/
web
/
src
/
app
/
profile
/
/opt/canhelp/apps/web/src/app/profile
mkdir
upload
Name
Size
Mode
Actions
page.tsx
143381
0644
edit
dl
rm
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<CardTab>('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<string[]>(card.skills ?? []) const [skillInput, setSkillInput] = useState('') const [localCategories, setLocalCategories] = useState<string[]>(card.categories ?? []) const [localLocations, setLocalLocations] = useState<string[]>(card.locations ?? []) const [localPublicationStatus, setLocalPublicationStatus] = useState<'pending' | 'active' | 'inactive'>( card.publicationStatus ?? (card.isActive ? 'active' : 'inactive'), ) const [apiSkillSugs, setApiSkillSugs] = useState<api.SkillSuggestion[]>([]) const [portfolio, setPortfolio] = useState<PortfolioItem[]>(card.portfolio ?? []) const [portfolioUploading, setPortfolioUploading] = useState(false) const [editingItem, setEditingItem] = useState<PortfolioItem | null>(null) const [editTitle, setEditTitle] = useState('') const [editDesc, setEditDesc] = useState('') const portfolioFileRef = useRef<HTMLInputElement>(null) // Price list state const [priceList, setPriceList] = useState<PriceListGroup[]>([]) const [priceListLoading, setPriceListLoading] = useState(false) const [newGroupTitle, setNewGroupTitle] = useState('') const [addingGroupId, setAddingGroupId] = useState<string | null>(null) const [newItem, setNewItem] = useState<{ name: string; description: string; price: string; unit: string }>({ name: '', description: '', price: '', unit: '' }) const [editingGroupId, setEditingGroupId] = useState<string | null>(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<HTMLInputElement>) { 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 ( <div className="bg-white rounded-xl border border-gray-200 overflow-hidden"> {/* Card header */} <div className={`flex items-center gap-3 px-4 py-3 border-b border-gray-100 ${hasVisibilityIssues ? 'bg-amber-50' : ''}`}> <button type="button" onClick={onToggle} className="flex-1 flex items-center gap-3 text-left"> <span className="text-sm font-semibold text-gray-800 flex-1">{localTitle || card.title}</span> {hasVisibilityIssues && ( <span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-amber-500 text-white text-xs font-bold" title={t('profile.card.visibility_issue', 'Card is not shown in offers')} > ! </span> )} <span className="text-gray-400 text-xs">{isOpen ? '▲' : '▼'}</span> </button> <button type="button" onClick={() => 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')} </button> {/* Delete */} <button type="button" onClick={onDelete} className="text-red-400 hover:text-red-600 text-sm px-1 flex-shrink-0" title={t('profile.card.delete')}>🗑</button> </div> {isOpen && ( <div> {hasVisibilityIssues && ( <div className="mx-4 mt-4 mb-2 rounded-lg border border-amber-300 bg-amber-50 p-3"> <p className="text-xs font-semibold text-amber-900"> {t('profile.card.visibility_issue', 'Card is not shown in offers')} </p> <ul className="mt-1.5 list-disc pl-4 text-xs text-amber-900 space-y-0.5"> {visibilityIssues.map((issue) => ( <li key={issue}>{issue}</li> ))} </ul> </div> )} {effectiveModerationNotice && ( <div className={`mx-4 mt-4 mb-2 rounded-lg border p-3 ${effectiveModerationNotice.tone === 'green' ? 'border-green-300 bg-green-50' : effectiveModerationNotice.tone === 'red' ? 'border-red-300 bg-red-50' : 'border-amber-300 bg-amber-50'}`}> <p className={`text-xs font-semibold ${effectiveModerationNotice.tone === 'green' ? 'text-green-900' : effectiveModerationNotice.tone === 'red' ? 'text-red-900' : 'text-amber-900'}`}> {effectiveModerationNotice.title} </p> {effectiveModerationNotice.body && ( <p className={`mt-1 text-xs ${effectiveModerationNotice.tone === 'green' ? 'text-green-800' : effectiveModerationNotice.tone === 'red' ? 'text-red-800' : 'text-amber-800'}`}> {effectiveModerationNotice.body} </p> )} </div> )} {/* Inner tabs */} <div className="flex overflow-x-auto border-b border-gray-100 bg-gray-50 gap-0"> {cardTabs.filter((t) => !t.hidden).map((t) => ( <button key={t.id} type="button" onClick={() => 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} </button> ))} </div> <div className="p-4"> {/* ── MAIN ── */} {cardTab === 'main' && ( <div className="space-y-3"> <input value={localTitle} onChange={(e) => 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<string, string> = { 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 ( <div> <label className="flex items-center gap-1.5 text-xs font-medium text-gray-500 mb-1"> {flags[activeLocale]} {t('profile.card.description', 'Description')} </label> <textarea value={val} onChange={(e) => 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 && ( <p className="text-xs text-gray-400 mt-1"> {t('profile.card.desc_auto_translate', 'Will be automatically translated into other languages on save')} </p> )} {otherHasContent && ( <div className="flex items-center gap-1.5 mt-1.5"> <span className="text-xs text-gray-400">{t('profile.card.desc_translated', 'Translations available:')}</span> {otherLocales.map(l => ( <span key={l} className={`text-xs px-1.5 py-0.5 rounded-full ${descMap[l][0] ? 'bg-green-50 text-green-600' : 'bg-gray-100 text-gray-400'}`}> {flags[l]} </span> ))} </div> )} </div> ) })()} </div> )} {/* ── SKILLS ── */} {cardTab === 'skills' && ( <div className="space-y-3"> {localSkills.length > 0 && ( <div className="flex flex-wrap gap-2"> {localSkills.map((s) => ( <span key={s} className="flex items-center gap-1.5 bg-green-50 text-green-700 text-sm px-3 py-1 rounded-full"> {s} <button type="button" onClick={() => setLocalSkills(localSkills.filter((x) => x !== s))} className="text-gray-400 hover:text-green-700 leading-none">×</button> </span> ))} </div> )} <div className="flex gap-2"> <input value={skillInput} onChange={(e) => 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" /> <button type="button" onClick={() => addSkill(skillInput)} className="px-3 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">+</button> </div> {apiSkillSugs.length > 0 && ( <div className="flex flex-wrap gap-1.5"> {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) => ( <button key={name} type="button" onClick={() => 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} </button> ))} </div> )} </div> )} {/* ── CATEGORIES ── */} {cardTab === 'categories' && ( <div className="space-y-3"> {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 ( <div key={cat.id}> <label className="flex items-center gap-2.5 cursor-pointer"> <input type="checkbox" checked={localCategories.includes(cat.slug)} onChange={() => toggleCategory(cat.slug, childSlugs)} className="w-4 h-4 rounded border-gray-300 text-green-600 focus:ring-blue-400"/> <span className="text-sm font-medium text-gray-800 inline-flex items-center gap-1"> {cat.icon && ( <CategoryIcon icon={cat.icon} alt={catName} className={isCategoryIconUrl(cat.icon) ? 'h-4 w-4 object-contain' : 'text-sm leading-none'} fallback="📦" /> )} {catName} </span> </label> {children.length > 0 && ( <div className="ml-6 mt-2 space-y-2"> {children.map((sub: any) => { const subName = sub.names?.[catLocale] ?? sub.names?.el ?? sub.slug return ( <label key={sub.id} className="flex items-center gap-2.5 cursor-pointer"> <input type="checkbox" checked={localCategories.includes(sub.slug)} onChange={() => toggleCategory(sub.slug)} className="w-4 h-4 rounded border-gray-300 text-green-600 focus:ring-blue-400"/> <span className="text-sm text-gray-600">{subName}</span> </label> ) })} </div> )} </div> ) })} </div> )} {/* ── GEOGRAPHY ── */} {cardTab === 'geography' && ( <div className="space-y-3"> {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 ( <div key={city.id}> <label className="flex items-center gap-2.5 cursor-pointer"> <input type="checkbox" checked={localLocations.includes(city.slug)} onChange={() => toggleLocation(city.slug, districtSlugs)} className="w-4 h-4 rounded border-gray-300 text-green-600 focus:ring-blue-400"/> <span className="text-sm font-medium text-gray-800">{cityName}</span> </label> {districts.length > 0 && ( <div className="ml-6 mt-2 space-y-2"> {districts.map((d: any) => { const dName = d.names?.[catLocale] ?? d.names?.el ?? d.slug return ( <label key={d.id} className="flex items-center gap-2.5 cursor-pointer"> <input type="checkbox" checked={localLocations.includes(d.slug)} onChange={() => toggleLocation(d.slug)} className="w-4 h-4 rounded border-gray-300 text-green-600 focus:ring-blue-400"/> <span className="text-sm text-gray-600">{dName}</span> </label> ) })} </div> )} </div> ) })} </div> )} {/* ── PRICE LIST ── */} {cardTab === 'pricelist' && ( <div className="space-y-4"> {priceListLoading ? ( <div className="text-center py-6 text-gray-400 text-sm">{t('common.loading', 'Loading...')}</div> ) : ( <> {priceList.length === 0 && ( <div className="text-center py-6 text-gray-400 text-sm border-2 border-dashed border-gray-200 rounded-xl"> {t('profile.card.pricelist.empty')} </div> )} {priceList.map((group) => ( <div key={group.id} className="border border-gray-200 rounded-xl overflow-hidden"> {/* Group header */} <div className="flex items-center gap-2 bg-gray-50 px-4 py-2.5 border-b border-gray-200"> {editingGroupId === group.id ? ( <> <input value={editGroupTitle} onChange={(e) => 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 /> <button type="button" onClick={() => 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')} </button> <button type="button" onClick={() => setEditingGroupId(null)} className="px-2 py-1.5 text-gray-500 hover:text-gray-700 text-xs">✕</button> </> ) : ( <> <span className="flex-1 font-semibold text-sm text-gray-800">{group.title}</span> <button type="button" onClick={() => { 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">✏️</button> <button type="button" onClick={() => 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')} </button> </> )} </div> {/* Items */} <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 group"> <div className="flex-1 min-w-0"> <p className="font-medium text-gray-800 truncate">{item.name}</p> {item.description && <p className="text-xs text-gray-500 truncate">{item.description}</p>} </div> {(item.price || item.unit) && ( <span className="text-green-700 font-semibold text-sm whitespace-nowrap"> {item.price ? `€${item.price}` : ''}{item.unit ? ` ${item.unit}` : ''} </span> )} <button type="button" onClick={() => 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')} </button> </div> ))} </div> {/* Add item */} {addingGroupId === group.id ? ( <div className="p-3 bg-green-50 border-t border-green-100 space-y-2"> <input placeholder={t('profile.card.pricelist.item_name')} value={newItem.name} onChange={(e) => setNewItem((p) => ({ ...p, name: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" /> <input placeholder={t('profile.card.pricelist.item_desc')} value={newItem.description} onChange={(e) => setNewItem((p) => ({ ...p, description: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" /> <div className="flex gap-2"> <input placeholder={t('profile.card.pricelist.item_price')} value={newItem.price} onChange={(e) => setNewItem((p) => ({ ...p, price: e.target.value }))} className="w-28 border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" /> <input placeholder={t('profile.card.pricelist.item_unit')} value={newItem.unit} onChange={(e) => setNewItem((p) => ({ ...p, unit: 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" /> </div> <div className="flex gap-2"> <button type="button" onClick={() => handleAddItem(group.id)} className="px-4 py-1.5 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700"> {t('profile.card.pricelist.save_item')} </button> <button type="button" onClick={() => { setAddingGroupId(null); setNewItem({ name: '', description: '', price: '', unit: '' }) }} className="px-3 py-1.5 text-gray-500 hover:text-gray-700 text-sm">✕</button> </div> </div> ) : ( <div className="px-4 py-2 border-t border-gray-100"> <button type="button" onClick={() => setAddingGroupId(group.id)} className="text-green-600 hover:text-gray-800 text-sm font-medium"> + {t('profile.card.pricelist.add_item')} </button> </div> )} </div> ))} {/* Add group */} <div className="flex gap-2 pt-1"> <input placeholder={t('profile.card.pricelist.group_title')} value={newGroupTitle} onChange={(e) => 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" /> <button type="button" onClick={handleAddGroup} className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-medium"> {t('profile.card.pricelist.add_group')} </button> </div> </> )} </div> )} {/* ── PORTFOLIO ── */} {cardTab === 'portfolio' && ( <div className="space-y-3"> {portfolio.length < 20 && ( <div> <button type="button" onClick={() => 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 ? <><span className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />{t('profile.card.photo.uploading')}</> : <>📷 {t('profile.card.photo.add')}</>} </button> <input ref={portfolioFileRef} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={handlePortfolioUpload} /> </div> )} {portfolio.length === 0 ? ( <div className="text-center py-8 text-gray-400 text-sm border-2 border-dashed border-gray-200 rounded-xl"> {t('profile.card.photo.empty')} </div> ) : ( <div className="grid grid-cols-2 sm:grid-cols-3 gap-3"> {portfolio.map((item) => ( <div key={item.id} className="group relative rounded-xl overflow-hidden border border-gray-200 bg-gray-50"> <img src={item.imageUrl} alt={item.title ?? ''} className="w-full aspect-square object-cover" /> <div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-all flex flex-col justify-between p-2"> <button type="button" onClick={() => 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">×</button> <button type="button" onClick={() => { 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')} </button> </div> {item.title && <div className="px-2 py-1.5"><p className="text-xs font-medium text-gray-700 truncate">{item.title}</p></div>} </div> ))} </div> )} </div> )} {/* Single save button (not for portfolio or pricelist) */} {cardTab !== 'portfolio' && cardTab !== 'pricelist' && ( <div className="flex items-center gap-3 mt-4 pt-4 border-t border-gray-100"> <button type="button" disabled={saving} onClick={saveAll} className="px-5 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-semibold disabled:opacity-50" > {saving ? '...' : t('profile.card.save')} </button> {savedOk && ( <span className="text-sm text-green-600">✓ {t('profile.card.saved')}</span> )} </div> )} </div> </div> )} {/* Portfolio edit modal */} {editingItem && ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"> <div className="bg-white rounded-2xl shadow-xl p-6 w-full max-w-md"> <h3 className="font-semibold text-gray-800 mb-4">{t('profile.card.item.edit')}</h3> <img src={editingItem.imageUrl} alt="" className="w-full h-40 object-cover rounded-xl mb-4" /> <div className="space-y-3"> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('profile.card.item.title')}</label> <input value={editTitle} onChange={(e) => setEditTitle(e.target.value)} maxLength={200} 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" /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('profile.card.item.desc')}</label> <textarea value={editDesc} onChange={(e) => setEditDesc(e.target.value)} rows={3} maxLength={1000} 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" /> </div> </div> <div className="flex gap-3 mt-5"> <button type="button" onClick={() => 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')} </button> <button type="button" onClick={() => 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')} </button> </div> </div> </div> )} </div> ) } // ─── 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<import('@/lib/api').AutoResponseSettings>) => Promise<void> t: (k: string, fb?: string) => string catLocale: string }) { const { useEffect: _ue, useState: _us } = { useEffect, useState } const [enabled, setEnabled] = _us(false) const [minBudget, setMinBudget] = _us<string>('') const [maxBudget, setMaxBudget] = _us<string>('') const [defaultPrice, setDefaultPrice] = _us<string>('') const [defaultMessage, setDefaultMessage] = _us<string>('') const [maxPerDay, setMaxPerDay] = _us<string>('') const [selCategories, setSelCategories] = _us<string[]>([]) const [selLocations, setSelLocations] = _us<string[]>([]) _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 <div className="text-center py-10 text-white/60">{t('common.loading')}</div> 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 ( <div className="space-y-4"> {/* Enable toggle */} <div className="bg-white rounded-xl border border-gray-200 p-5"> <label className="flex items-center justify-between cursor-pointer"> <div> <p className="font-medium text-gray-800">{t('auto_response.title', 'Auto response')}</p> <p className="text-xs text-gray-400 mt-0.5">{t('auto_response.desc', 'Automatically respond to matching tasks')}</p> </div> <button type="button" onClick={() => setEnabled(v => !v)} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? 'bg-green-500' : 'bg-gray-300'}`} > <span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`} /> </button> </label> </div> {/* Budget range */} <div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4"> <h3 className="font-medium text-gray-800 text-sm">{t('auto_response.budget_range', 'Task budget')}</h3> <div className="grid grid-cols-2 gap-3"> <div> <label className="block text-xs text-gray-500 mb-1">{t('auto_response.min_budget', 'Min €')}</label> <input type="number" min="0" value={minBudget} onChange={e => setMinBudget(e.target.value)} placeholder="0" 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" /> </div> <div> <label className="block text-xs text-gray-500 mb-1">{t('auto_response.max_budget', 'Max €')}</label> <input type="number" min="0" value={maxBudget} onChange={e => setMaxBudget(e.target.value)} 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" /> </div> </div> <div> <label className="block text-xs text-gray-500 mb-1">{t('auto_response.default_price', 'Default price €')}</label> <input type="number" min="0" value={defaultPrice} onChange={e => setDefaultPrice(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" /> </div> <div> <label className="block text-xs text-gray-500 mb-1">{t('auto_response.max_per_day', 'Max auto-offers/day')}</label> <input type="number" min="1" max="50" value={maxPerDay} onChange={e => setMaxPerDay(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" /> </div> </div> {/* Default message */} <div className="bg-white rounded-xl border border-gray-200 p-5"> <label className="block text-xs text-gray-500 mb-1">{t('auto_response.default_message', 'Auto-response text')}</label> <textarea value={defaultMessage} onChange={e => 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" /> </div> {/* Categories filter */} {categories.length > 0 && ( <div className="bg-white rounded-xl border border-gray-200 p-5"> <h3 className="font-medium text-gray-800 text-sm mb-3">{t('auto_response.categories', 'Task categories')}</h3> <div className="flex flex-wrap gap-2 max-h-40 overflow-y-auto"> {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 ( <button key={cat.slug} type="button" onClick={() => 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} </button> ) })} </div> </div> )} {/* Save */} <button type="button" disabled={saving} onClick={() => 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')} </button> </div> ) } export default function ProfilePage() { return ( <Suspense fallback={null}> <ProfilePageInner /> </Suspense> ) } 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<Tab>(validTabs.includes(initialTab) ? initialTab : 'info') const [user, setUser] = useState<any>(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<string[]>([]) // Specialist cards const [cards, setCards] = useState<SpecialistCard[]>([]) const [maxCards, setMaxCards] = useState<number | null>(1) const [cardsLoading, setCardsLoading] = useState(false) const [cardNotifications, setCardNotifications] = useState<any[]>([]) const [openCardId, setOpenCardId] = useState<string | null>(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<any[]>([]) const [locationTree, setLocationTree] = useState<any[]>([]) // Become specialist const [becomingSpecialist, setBecomingSpecialist] = useState(false) const [avatarUploading, setAvatarUploading] = useState(false) const [backgroundUploading, setBackgroundUploading] = useState(false) const fileRef = useRef<HTMLInputElement>(null) const backgroundFileRef = useRef<HTMLInputElement>(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<string | null>(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<number | string>(10) const [topUpLoading, setTopUpLoading] = useState(false) const [topUpError, setTopUpError] = useState('') const [balanceHistory, setBalanceHistory] = useState<BalanceTransaction[]>([]) const [planHistory, setPlanHistory] = useState<PlanHistoryEntry[]>([]) // Plan data + statuses const [dashData, setDashData] = useState<api.DashboardData | null>(null) const showPricing = dashData?.showPricing ?? true const [myStatuses, setMyStatuses] = useState<string[]>([]) const [statusToggling, setStatusToggling] = useState<string | null>(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<api.AutoResponseSettings | null>(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<Array<{ review: any; author: any }> | null>(null) const [reviewsLoading, setReviewsLoading] = useState(false) const [myRating, setMyRating] = useState<string | null>(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<any>('/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<HTMLFormElement>) { e.preventDefault() setError('') setSaving(true) try { const updated = await api.request<any>('/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<any>('/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<any>('/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<HTMLInputElement>) { 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<HTMLInputElement>) { 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 <div className="text-center py-20 text-white">{t('common.loading')}</div> } 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<string, { title: string; body: string | null; tone: 'amber' | 'green' | 'red' }>) 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<Record<Tab, boolean>> = { info: !profileBio, cards: listingMissing.length > 0, } const tabIcons: Record<Tab, React.ReactNode> = { info: ( <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" /> </svg> ), finance: ( <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> </svg> ), cards: ( <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> <path strokeLinecap="round" strokeLinejoin="round" d="M20.25 14.15v4.073a2.25 2.25 0 01-2.25 2.25h-12a2.25 2.25 0 01-2.25-2.25V6a2.25 2.25 0 012.25-2.25h4.073M15.75 3h5.25v5.25M15.75 3l-9 9" /> </svg> ), notifications: ( <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> <path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" /> </svg> ), reviews: ( <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> <path strokeLinecap="round" strokeLinejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.562.562 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.562.562 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" /> </svg> ), referral: ( <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" /> </svg> ), auto_response: ( <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> <path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" /> </svg> ), site: ( <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> <path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" /> </svg> ), } 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 ( <div className="max-w-4xl mx-auto px-4 py-8"> {/* Header with avatar + title + rating */} <div className="flex items-center gap-4 mb-6"> {/* Avatar */} <div className="w-14 h-14 rounded-full flex-shrink-0 overflow-hidden border-2 border-white/40 flex items-center justify-center text-green-700 text-xl font-bold bg-green-100 cursor-pointer" onClick={() => { setActiveTab('info'); fileRef.current?.click() }} title={t('profile.photo.change')} > {user?.image ? ( <img src={user.image} alt={user.name} className="w-full h-full object-cover" /> ) : ( initials )} </div> <div className="flex-1 min-w-0"> <h1 className="text-xl font-bold text-white leading-tight"> {user?.firstName ? `${user.firstName} ${user.lastName ?? ''}`.trim() : (user?.name ?? t('nav.profile'))} </h1> {myRating && ( <div className="flex items-center gap-1 mt-0.5"> <span className="text-yellow-300">★</span> <span className="font-semibold text-white text-sm">{Number(myRating).toFixed(1)}</span> {myReviews !== null && myReviews.length > 0 && ( <span className="text-white/60 text-xs">({myReviews.length})</span> )} </div> )} </div> </div> {!cardsLoading && cards.length === 0 && ( <div className="mb-6 rounded-2xl border border-amber-300 bg-amber-50/95 p-4 sm:p-5 shadow-sm"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div> <p className="text-sm font-semibold text-amber-950"> {t('profile.cards.empty', 'No offers yet')} </p> <p className="text-xs leading-relaxed text-amber-800 mt-1 max-w-2xl"> {t( 'profile.cards.empty_hint', 'Создайте первую карточку, чтобы показать услуги клиентам.' )} </p> </div> <button type="button" onClick={() => { 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')} </button> </div> </div> )} {/* Two-column layout: sidebar nav + content */} <div className="flex gap-6 items-start"> {/* Sidebar nav — visible on sm+ */} {tabs.length > 1 && ( <aside className="w-52 shrink-0 hidden sm:block"> <nav className="rounded-2xl overflow-hidden bg-white/10 backdrop-blur-sm"> {tabs.map((tab) => ( <button key={tab.id} type="button" onClick={() => 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]} <span className="flex items-center gap-1.5"> {tab.label} {tabNeedsAttention[tab.id] && ( <span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-amber-500 text-white text-[10px] font-bold">!</span> )} </span> </button> ))} </nav> </aside> )} {/* Main content */} <div className="flex-1 min-w-0"> {/* Mobile horizontal tabs */} {tabs.length > 1 && ( <div className="flex sm:hidden bg-white/15 backdrop-blur rounded-xl p-1 mb-4 gap-1 overflow-x-auto"> {tabs.map((tab) => ( <button key={tab.id} type="button" onClick={() => 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]} <span className="flex items-center gap-1.5"> {tab.label} {tabNeedsAttention[tab.id] && ( <span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-amber-500 text-white text-[10px] font-bold">!</span> )} </span> </button> ))} </div> )} {/* ── CARDS TAB (outside form — has its own save logic) ── */} {activeTab === 'cards' && ( <div className="space-y-4"> {error && <div className="bg-red-50 text-red-700 p-3 rounded-lg text-sm">{error}</div>} {listingMissing.length > 0 && ( <div className="bg-amber-50 border border-amber-300 rounded-xl p-4"> <p className="text-sm font-semibold text-amber-900"> {t('profile.listing_requirements.title', 'Your profile is not shown yet in offers')} </p> <p className="text-xs text-amber-800 mt-1"> {t('profile.listing_requirements.hint', 'To make the card visible, complete these conditions:')} </p> <ul className="mt-2 list-disc pl-5 text-xs text-amber-900 space-y-0.5"> {listingMissing.map((item) => ( <li key={item}>{item}</li> ))} </ul> </div> )} {/* Header */} <div className="flex items-center justify-between"> <p className="text-sm text-white/70"> {t('profile.cards.count')}: {cards.length} / {maxCards === null ? '∞' : maxCards} </p> <button type="button" onClick={() => { 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')} </button> </div> {/* New card form */} {showNewCardForm && ( <div className="bg-white rounded-xl border border-green-200 p-4 space-y-3"> <p className="text-sm font-medium text-gray-800"> {t('profile.cards.new_title')} </p> <input value={newCardTitle} onChange={(e) => 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" /> <div className="flex gap-2"> <button type="button" onClick={handleCreateCard} disabled={creatingCard || !newCardTitle.trim()} className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-medium disabled:opacity-50"> {creatingCard ? '...' : t('profile.cards.create')} </button> <button type="button" onClick={() => { 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')} </button> </div> </div> )} {cardsLoading && <div className="text-center py-10 text-white/70">{t('common.loading')}</div>} {!cardsLoading && cards.length === 0 && !showNewCardForm && ( <div className="text-center py-14 border-2 border-dashed border-white/30 rounded-xl"> <p className="text-white/70 text-sm mb-3"> {t('profile.cards.empty')} </p> <button type="button" onClick={() => setShowNewCardForm(true)} className="text-sm text-white font-medium hover:underline"> + {t('profile.cards.create_first')} </button> </div> )} {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 ( <SpecialistCardEditor key={card.id} card={card} isOpen={openCardId === card.id} onToggle={() => 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} /> ) })} </div> )} {/* ── SITE TAB ── */} {activeTab === 'site' && ( <div className="space-y-5"> {/* URL configuration */} <div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4"> <div> <h2 className="font-semibold text-gray-800 mb-1">{t('profile.site.title')}</h2> <p className="text-xs text-gray-400">{t('profile.site_slug_hint')}</p> </div> <div> <label className="block text-xs font-medium text-gray-500 mb-1.5">{t('profile.site.url_label')}</label> <div className="flex items-center gap-2 flex-wrap"> <span className="text-sm text-gray-400 shrink-0">canhelp.gr/site/</span> <input value={personalSiteSlug} onChange={(e) => setPersonalSiteSlug(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ''))} placeholder="my-slug" maxLength={50} className="flex-1 min-w-32 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" /> </div> </div> <div className="flex items-center gap-3 flex-wrap"> <button type="button" onClick={handleSaveSiteSlug} disabled={slugSaving} className="px-5 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50" > {slugSaving ? t('profile.saving') : t('common.save')} </button> {slugSaved && <span className="text-sm text-green-600">✓ {t('profile.saved')}</span>} {personalSiteSlug && ( <> <button type="button" onClick={() => { 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" > <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /> </svg> {slugCopied ? t('profile.site.copied') : t('profile.site.copy')} </button> <a href={`/site/${personalSiteSlug}`} target="_blank" rel="noopener noreferrer" className="px-3 py-2 border border-green-200 bg-green-50 rounded-lg text-sm text-green-600 hover:bg-green-100 flex items-center gap-1.5" > {t('profile.site_open')} <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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> </> )} </div> {slugError && <p className="text-xs text-red-500">{slugError}</p>} </div> {/* What's shown on the public page — interactive checkboxes */} <div className="bg-white rounded-xl border border-gray-200 p-6"> <div className="flex items-center justify-between mb-4"> <h3 className="font-semibold text-gray-800">{t('profile.site.visible_title')}</h3> {siteSettingsSaving && ( <span className="text-xs text-gray-400">{t('profile.saving', 'Saving...')}</span> )} </div> <div className="space-y-3"> {([ { 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 ( <label key={key} className="flex items-center gap-3 cursor-pointer group"> <div className={`w-5 h-5 rounded border-2 flex items-center justify-center transition shrink-0 ${ checked ? 'bg-green-600 border-green-600' : 'bg-white border-gray-300 group-hover:border-green-400' }`} onClick={() => { const next = { ...siteSettings, [key]: !checked } handleSaveSiteSettings(next) }} > {checked && ( <svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}> <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" /> </svg> )} </div> <span className="text-sm text-gray-700 select-none" onClick={() => { const next = { ...siteSettings, [key]: !checked } handleSaveSiteSettings(next) }} > {label} </span> </label> ) })} </div> <p className="text-xs text-gray-400 mt-4">{t('profile.site.edit_bio_hint', 'Name and photo are always visible')}</p> {/* Theme selector */} <div className="mt-6 pt-4 border-t border-gray-100"> <label className="block text-sm font-medium text-gray-700 mb-2">{t('profile.site.theme', 'Theme')}</label> <div className="flex gap-3"> {([ { 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 ( <button key={opt.value} onClick={() => 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' }`} > <span>{opt.icon}</span> {opt.label} </button> ) })} </div> </div> </div> {/* Site Analytics */} <div className="bg-white rounded-xl border border-gray-200 p-6"> <div className="flex items-center justify-between mb-4"> <div> <h3 className="font-semibold text-gray-800">{t('profile.site.stats.title', 'Site analytics')}</h3> <p className="text-xs text-gray-400 mt-0.5">{t('profile.site.stats.days', 'last 30 days')}</p> </div> </div> {!personalSiteSlug ? ( <p className="text-sm text-gray-400 py-4 text-center">{t('profile.site.stats.no_slug', 'Set your site URL to view statistics')}</p> ) : siteStatsLoading || siteStats === null ? ( <p className="text-sm text-gray-400 py-4 text-center">{t('profile.site.stats.loading', 'Loading analytics...')}</p> ) : (() => { 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 */} <div className="grid grid-cols-3 gap-3 mb-5"> {[ { 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) => ( <div key={card.label} className={`${card.bg} rounded-xl p-3 text-center`}> <div className={`text-2xl font-bold ${card.color}`}>{card.value}</div> <div className="text-xs text-gray-500 mt-0.5">{card.label}</div> </div> ))} </div> {/* Legend */} <div className="flex items-center gap-4 mb-2"> {seriesDef.map((s) => ( <span key={s.label} className="flex items-center gap-1.5 text-xs text-gray-500"> <span className="w-3 h-0.5 inline-block rounded" style={{ background: s.color }} /> {s.label} </span> ))} </div> {/* Area chart */} <div className="overflow-x-auto"> <svg viewBox={`0 0 ${W} ${H + 12}`} className="w-full" style={{ minWidth: 320 }}> {/* Horizontal grid + Y labels */} {yTicks.map((v, idx) => { const y = chartH - (v / maxVal) * chartH return ( <g key={`y-${idx}-${v}`}> <line x1={padL} y1={y} x2={W - 4} y2={y} stroke="#f3f4f6" strokeWidth={1} /> <text x={padL - 5} y={y + 3.5} textAnchor="end" fontSize={9} fill="#9ca3af">{fmtY(v)}</text> </g> ) })} {/* Vertical grid */} {xTicks.map(({ i }) => ( <line key={`vg-${i}`} x1={padL + i * stepX} y1={0} x2={padL + i * stepX} y2={chartH} stroke="#f9fafb" strokeWidth={1} /> ))} {/* X labels */} {xTicks.map(({ i, label }) => ( <text key={i} x={padL + i * stepX} y={H + 10} textAnchor="middle" fontSize={9} fill="#9ca3af">{label}</text> ))} {/* Area fills */} {seriesDef.map((s, si) => ( <path key={`fill-${si}`} d={areaPath(s.vals)} fill={s.fill} /> ))} {/* Lines */} {seriesDef.map((s, si) => ( <path key={`line-${si}`} d={smoothPath(pts(s.vals))} fill="none" stroke={s.color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /> ))} {/* Last-point dots */} {seriesDef.map((s, si) => { const points = pts(s.vals) const last = points[points.length - 1] return <circle key={`dot-${si}`} cx={last[0]} cy={last[1]} r={4} fill="white" stroke={s.color} strokeWidth={2} /> })} {/* Bottom axis */} <line x1={padL} y1={chartH} x2={W - 4} y2={chartH} stroke="#e5e7eb" strokeWidth={1} /> </svg> </div> </> ) })()} </div> </div> )} {/* ── REVIEWS TAB ── */} {activeTab === 'reviews' && ( <div className="space-y-4"> {myReviews === null ? ( <div className="text-center py-10 text-white/70">{t('common.loading')}</div> ) : myReviews.length === 0 ? ( <div className="text-center py-20 text-white/60"> <p className="text-4xl mb-3">⭐</p> <p>{t('profile.no_reviews')}</p> </div> ) : ( myReviews.map(({ review, author }) => ( <div key={review.id} className="bg-white rounded-xl border border-gray-200 p-4"> <div className="flex items-start gap-3"> <div className="w-9 h-9 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">{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()} </p> </div> </div> </div> )) )} </div> )} {/* ── FINANCE TAB ── */} {showPricing && activeTab === 'finance' && ( <div className="space-y-5"> {/* Balance */} <div className="bg-white rounded-xl border border-gray-200 p-6"> <div className="flex items-center justify-between mb-3"> <h2 className="font-semibold text-gray-800">{t('profile.balance')}</h2> <button onClick={() => { 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" > <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /> </svg> {t('profile.balance.topup')} </button> </div> <div className="flex items-center gap-3 p-4 bg-green-50 rounded-xl border border-green-100"> <svg className="w-6 h-6 text-green-600 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> <path strokeLinecap="round" strokeLinejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> </svg> <span className="text-xl font-bold text-green-700"> {parseFloat(user?.balance ?? '0').toFixed(2)} EUR </span> </div> <div className="mt-4"> <div className="flex items-center justify-between mb-2"> <h3 className="text-sm font-semibold text-gray-800">{t('profile.balance.history', 'Operation history')}</h3> <span className="text-xs text-gray-400">{t('profile.balance.history.last', 'Latest operations')}</span> </div> {balanceHistory.length === 0 ? ( <div className="rounded-xl border border-dashed border-gray-200 px-4 py-6 text-sm text-gray-400 text-center"> {t('profile.balance.history.empty', 'No operations yet')} </div> ) : ( <div className="space-y-2"> {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 ( <div key={entry.id} className="rounded-xl border border-gray-200 px-4 py-3"> <div className="flex items-start justify-between gap-3"> <div className="min-w-0"> <div className="text-sm font-medium text-gray-900">{entry.description || title}</div> <div className="text-xs text-gray-500 mt-1"> {new Date(entry.createdAt).toLocaleString(dateLocale)} </div> </div> <div className={`shrink-0 rounded-full border px-2.5 py-1 text-xs font-semibold ${badgeClass}`}> {isCredit ? '+' : '-'}{amount} {entry.currency} </div> </div> <div className="mt-2 text-xs text-gray-500"> {t('profile.balance.history.balance_after', 'Balance after')}: {Number(entry.balanceAfter).toFixed(2)} {entry.currency} </div> </div> ) })} </div> )} </div> </div> {/* Plan history */} <div className="bg-white rounded-xl border border-gray-200 p-6"> <h2 className="font-semibold text-gray-800 mb-4">{t('profile.plan_history.title', 'Plan history')}</h2> {planHistory.length === 0 ? ( <div className="rounded-xl border border-dashed border-gray-200 px-4 py-6 text-sm text-gray-400 text-center"> {t('profile.plan_history.empty', 'Plan change history is empty')} </div> ) : ( <div className="space-y-2"> {planHistory.map((entry) => { const eventColors: Record<string, string> = { 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<string, string> = { 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 ( <div key={entry.id} className="rounded-xl border border-gray-200 px-4 py-3"> <div className="flex items-start justify-between gap-3"> <div className="min-w-0"> <div className="flex items-center gap-2 flex-wrap"> <span className="text-sm font-semibold text-gray-900">{entry.planName}</span> {entry.previousPlanName && entry.previousPlanName !== entry.planName && ( <span className="text-xs text-gray-400">← {entry.previousPlanName}</span> )} </div> <div className="text-xs text-gray-500 mt-1"> {new Date(entry.createdAt).toLocaleString(dateLocale)} </div> {entry.expiresAt && ( <div className="text-xs text-gray-500 mt-0.5"> {t('profile.plan_history.expires', 'Until')}: {new Date(entry.expiresAt).toLocaleDateString(dateLocale)} </div> )} {entry.daysAdded && entry.event === 'referral_reward' && ( <div className="text-xs text-amber-600 mt-0.5"> +{entry.daysAdded} {t('profile.plan_history.days', 'days')} </div> )} {entry.note && ( <div className="text-xs text-gray-400 mt-0.5">{entry.note}</div> )} </div> <span className={`shrink-0 rounded-full border px-2.5 py-1 text-xs font-semibold ${badgeClass}`}> {label} </span> </div> </div> ) })} </div> )} </div> {/* Top-up modal */} {showTopUpModal && ( <div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-black/40 backdrop-blur-sm"> <div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm p-6"> <div className="flex items-center justify-between mb-5"> <h3 className="text-lg font-semibold text-gray-900">{t('profile.balance.topup.title')}</h3> <button onClick={() => setShowTopUpModal(false)} className="text-gray-400 hover:text-gray-600"> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> </svg> </button> </div> <p className="text-sm text-gray-500 mb-3">{t('profile.balance.topup.hint')}</p> <div className="grid grid-cols-4 gap-2 mb-4"> {[5, 10, 20, 50].map((v) => ( <button key={v} onClick={() => 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} </button> ))} </div> <div className="mb-5"> <label className="block text-sm font-medium text-gray-700 mb-1"> {t('profile.balance.topup.amount')} </label> <div className="relative"> <span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm">€</span> <input type="number" min="1" max="9999" step="1" value={topUpAmount} onChange={(e) => setTopUpAmount(e.target.value)} className="w-full pl-7 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" /> </div> </div> {topUpError && ( <p className="text-sm text-red-600 mb-3">{topUpError}</p> )} <button onClick={handleTopUp} disabled={topUpLoading || !Number(topUpAmount) || Number(topUpAmount) < 1} className="w-full py-3 bg-green-600 hover:bg-green-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white font-semibold rounded-xl transition-colors flex items-center justify-center gap-2" > {topUpLoading ? ( <> <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/> </svg> {t('profile.balance.topup.loading')} </> ) : ( t('profile.balance.topup.submit') )} </button> </div> </div> )} {/* Plan / Tariff */} <div className="bg-white rounded-xl border border-gray-200 p-6"> <h2 className="font-semibold text-gray-800 mb-3"> {t('profile.plan.title')} </h2> {user?.plan ? ( <div className="flex items-start gap-3 p-4 bg-green-50 rounded-xl border border-green-100"> <svg className="w-6 h-6 text-gray-500 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" /> </svg> <div className="flex-1 min-w-0"> <p className="font-semibold text-gray-900">{user.plan.name}</p> {user.plan.description && <p className="text-sm text-gray-500 mt-0.5">{user.plan.description}</p>} <div className="flex flex-wrap gap-3 mt-2 text-xs text-gray-500"> <span className="font-medium text-green-700">{user.plan.price} {user.plan.currency}</span> {user.plan.maxCards != null ? ( <span>{t('profile.plan.cards_up_to')} {user.plan.maxCards}</span> ) : ( <span>{t('profile.plan.cards_unlimited')}</span> )} {user.plan.maxTasks != null && ( <span>{t('profile.plan.tasks_up_to')} {user.plan.maxTasks}</span> )} {user.plan.maxOffers != null && ( <span>{t('profile.plan.offers_up_to')} {user.plan.maxOffers}</span> )} <span className={(user as any).planExpiresAt && new Date((user as any).planExpiresAt) < new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) ? 'text-red-500 font-medium' : ''}> {(user as any).planExpiresAt ? `${t('profile.plan.valid_until')} ${new Date((user as any).planExpiresAt).toLocaleDateString()}` : t('profile.plan.no_expiry')} </span> </div> {(user.plan.features ?? []).length > 0 && ( <ul className="mt-2 space-y-0.5"> {user.plan.features.map((f: string, i: number) => ( <li key={i} className="text-xs text-gray-600 flex items-center gap-1.5"> <span className="text-green-500">✓</span> {f} </li> ))} </ul> )} </div> </div> ) : ( <div className="flex items-center gap-3 p-4 bg-gray-50 rounded-xl border border-gray-100"> <svg className="w-5 h-5 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> <path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> </svg> <div> <p className="text-sm font-medium text-gray-700"> {t('profile.plan.basic')} </p> <p className="text-xs text-gray-400 mt-0.5"> {t('profile.plan.contact_upgrade')} </p> </div> </div> )} </div> {/* Link to pricing */} {showPricing && ( <div className="text-center"> <a href="/pricing" className="inline-flex items-center gap-1.5 text-sm font-medium text-white/70 hover:text-white transition"> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" /> </svg> {t('pricing.title', 'Pricing')} </a> </div> )} </div> )} {activeTab !== 'cards' && activeTab !== 'reviews' && activeTab !== 'finance' && activeTab !== 'site' && ( <form onSubmit={handleSubmit} className="space-y-6"> {error && <div className="bg-red-50 text-red-700 p-3 rounded-lg text-sm">{error}</div>} {saved && <div className="bg-green-50 text-green-700 p-3 rounded-lg text-sm">✓ {t('profile.saved')}</div>} {/* ── NOTIFICATIONS TAB ── */} {activeTab === 'notifications' && ( <div className="bg-white rounded-xl border border-gray-200 p-6 space-y-6"> <h2 className="font-semibold text-gray-800">{t('notifications.title')}</h2> {notifySaved && ( <div className="bg-green-50 text-green-700 p-3 rounded-lg text-sm">✓ {t('notifications.saved')}</div> )} {/* Messages notification toggle — all users */} <div className="flex items-start justify-between gap-4"> <div className="flex-1"> <p className="font-medium text-gray-800 text-sm">{t('chat.notif_messages')}</p> <p className="text-xs text-gray-500 mt-0.5">{t('notifications.messages.desc')}</p> </div> <button type="button" disabled={notifySaving} onClick={async () => { 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} > <span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${ notifMessages ? 'translate-x-6' : 'translate-x-1' }`} /> </button> </div> {/* Email for new tasks toggle — specialists only */} {showSpecialistTabs && ( <div className={`flex items-start justify-between gap-4 ${!user?.plan?.notifyNewTasks ? 'opacity-60' : ''}`}> <div className="flex-1"> <p className="font-medium text-gray-800 text-sm">{t('notifications.email.new_tasks')}</p> <p className="text-xs text-gray-500 mt-0.5">{t('notifications.email.new_tasks.desc')}</p> {!user?.plan?.notifyNewTasks && ( <p className="text-xs text-amber-600 mt-1">🔒 {t('notifications.plan_required')}</p> )} </div> <button type="button" disabled={notifySaving || !user?.plan?.notifyNewTasks} onClick={async () => { 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} > <span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${ notifyNewTasks ? 'translate-x-6' : 'translate-x-1' }`} /> </button> </div> )} </div> )} {/* ── REFERRAL TAB ── */} {activeTab === 'referral' && ( <div className="bg-white rounded-xl border border-gray-200 p-6 space-y-5"> <h2 className="font-semibold text-gray-800">{t('profile.referral.title')}</h2> <p className="text-sm text-gray-500">{t('profile.referral.desc')}</p> {!referralLoaded ? ( <div className="text-sm text-gray-400">{t('common.loading')}</div> ) : referralCode ? ( <> <div> <p className="text-xs font-medium text-gray-500 mb-1.5">{t('profile.referral.link_label')}</p> <div className="flex items-center gap-2"> <input readOnly value={`${typeof window !== 'undefined' ? window.location.origin : ''}/register?ref=${referralCode}`} className="flex-1 text-sm border border-gray-200 rounded-lg px-3 py-2 bg-gray-50 text-gray-700 focus:outline-none" /> <button type="button" onClick={() => { navigator.clipboard.writeText( `${window.location.origin}/register?ref=${referralCode}` ) setReferralCopied(true) setTimeout(() => setReferralCopied(false), 2000) }} className="px-3 py-2 rounded-lg border border-gray-200 text-sm font-medium hover:bg-gray-50 transition whitespace-nowrap" > {referralCopied ? t('profile.referral.copied') : t('profile.referral.copy')} </button> </div> </div> <div className="flex items-center gap-3 p-4 bg-green-50 rounded-xl"> <div className="text-2xl font-bold text-green-700">{referralCount}</div> <p className="text-sm text-green-700">{t('profile.referral.invited_count')}</p> </div> </> ) : ( <div className="text-sm text-gray-400">{t('profile.referral.no_code')}</div> )} </div> )} {/* ── AUTO-RESPONSE TAB ── */} {activeTab === 'auto_response' && ( <AutoResponseTab categories={categories} locationTree={locationTree} loaded={arLoaded} onLoad={() => { 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 */} <div className="bg-white rounded-xl border border-gray-200 p-6"> <h2 className="font-semibold text-gray-800 mb-4">{t('profile.photo')}</h2> <div className="flex items-center gap-5"> <div className="relative"> {user?.image ? ( <img src={user.image} alt={user.name} className="w-20 h-20 rounded-full object-cover border-2 border-gray-200" /> ) : ( <div className="w-20 h-20 rounded-full bg-green-100 flex items-center justify-center text-green-700 text-2xl font-bold border-2 border-gray-200"> {initials} </div> )} {avatarUploading && ( <div className="absolute inset-0 rounded-full bg-black/40 flex items-center justify-center"> <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" /> </div> )} </div> <div> <button type="button" onClick={() => 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')} </button> <p className="text-xs text-gray-400 mt-1">{t('profile.photo.hint')}</p> <input ref={fileRef} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={handleAvatarChange} /> <div className="mt-3 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2.5"> {isSpecialist ? ( dashData?.features?.hasCanHelpNowStatus ? ( <label className="flex items-center justify-between cursor-pointer gap-3"> <span className="text-xs font-medium text-gray-700">{t('status.can_help_now', 'Available now')}</span> <button type="button" disabled={statusToggling === 'canhelp_now'} onClick={async () => { 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`} > <span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${ myStatuses.includes('canhelp_now') ? 'translate-x-5.5' : 'translate-x-1' }`} /> </button> </label> ) : ( <p className="text-xs text-amber-700">{t('status.plan_required', 'Status is unavailable on your current plan')}</p> ) ) : !isAdmin ? ( dashData?.features?.hasNeedHelpStatus ? ( <label className="flex items-center justify-between cursor-pointer gap-3"> <span className="text-xs font-medium text-gray-700">{t('status.need_help', 'Need help')}</span> <button type="button" disabled={statusToggling === 'need_help'} onClick={async () => { 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`} > <span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${ myStatuses.includes('need_help') ? 'translate-x-5.5' : 'translate-x-1' }`} /> </button> </label> ) : ( <p className="text-xs text-amber-700">{t('status.plan_required', 'Status is unavailable on your current plan')}</p> ) ) : ( <p className="text-xs text-gray-600">{t('status.admin_not_applicable', 'User status is not applicable to admin accounts')}</p> )} </div> </div> </div> </div> {/* Header background */} <div className="bg-white rounded-xl border border-gray-200 p-6"> <h2 className="font-semibold text-gray-800 mb-4"> {t('profile.background.title', 'Profile header background')} </h2> <div className="w-full h-40 rounded-2xl border border-gray-200 overflow-hidden relative" style={{ backgroundImage: siteSettings.profileBackgroundImage ? `linear-gradient(135deg, rgba(22,163,74,0.45), rgba(21,128,61,0.65)), url(${siteSettings.profileBackgroundImage})` : 'linear-gradient(135deg, #22c55e 0%, #16a34a 45%, #166534 100%)', backgroundSize: 'cover', backgroundPosition: 'center', }} > {backgroundUploading && ( <div className="absolute inset-0 bg-black/35 flex items-center justify-center"> <div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" /> </div> )} </div> <div className="mt-4 flex flex-wrap gap-2"> <button type="button" onClick={() => 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')} </button> {siteSettings.profileBackgroundImage && ( <button type="button" onClick={handleRemoveBackground} disabled={backgroundUploading} className="bg-white hover:bg-gray-50 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 disabled:opacity-50" > {t('profile.background.remove', 'Remove background')} </button> )} <input ref={backgroundFileRef} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={handleBackgroundChange} /> </div> <p className="text-xs text-gray-400 mt-2"> {t('profile.background.hint', 'Shown in the public profile header on web and mobile app.')} </p> </div> {/* Basic info */} <div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4"> <h2 className="font-semibold text-gray-800">{t('profile.basic_info')}</h2> <div className="grid grid-cols-2 gap-4"> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('register.first_name')}</label> <input value={formData.firstName} onChange={(e) => setFormData({ ...formData, firstName: e.target.value })} required 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" /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('register.last_name')}</label> <input value={formData.lastName} onChange={(e) => setFormData({ ...formData, lastName: e.target.value })} required 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" /> </div> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">Email</label> <input value={user?.email} disabled className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-400 bg-gray-50 cursor-not-allowed" /> <div className="flex items-center justify-between mt-1.5"> {user?.emailVerified ? ( <span className="flex items-center gap-1 text-xs text-green-600"> <span className="inline-block w-1.5 h-1.5 rounded-full bg-green-500" /> {t('profile.email.verified')} </span> ) : ( <span className="flex items-center gap-1 text-xs text-amber-600"> <span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-500" /> {t('profile.email.not_verified')} </span> )} {!user?.emailVerified && ( <button type="button" disabled={resendState !== 'idle'} onClick={handleResendVerification} className="text-xs text-green-600 hover:underline disabled:opacity-60 disabled:no-underline" > {resendState === 'sending' ? t('login.unverified.sending') : resendState === 'sent' ? t('login.unverified.sent') : t('login.unverified.resend')} </button> )} </div> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('profile.phone')}</label> <input type="tel" value={formData.phone} onChange={(e) => 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" /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('profile.bio')}</label> <textarea value={formData.bio} onChange={(e) => 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" /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('profile.language')}</label> <p className="text-xs text-gray-400 mb-2">{t('profile.language.hint')}</p> <select value={formData.locale} onChange={(e) => 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" > <option value="el">Ελληνικά</option> <option value="en">English</option> <option value="ru">Русский</option> <option value="uk">Українська</option> </select> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('profile.languages')}</label> <p className="text-xs text-gray-400 mb-2">{t('profile.languages.hint')}</p> <div className="flex flex-wrap gap-2"> {[ { 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 ( <button key={code} type="button" onClick={() => 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' }`} > <span>{flag}</span> <span>{label}</span> </button> ) })} </div> </div> </div> {!isSpecialist && !isAdmin && ( <div className="bg-white rounded-xl border border-gray-200 p-6"> <div className="p-4 bg-purple-50 rounded-xl"> <p className="text-sm text-gray-600 mb-3">{t('profile.become_specialist.desc')}</p> <button type="button" onClick={handleBecomeSpecialist} disabled={becomingSpecialist} className="bg-purple-600 hover:bg-purple-700 text-white px-5 py-2 rounded-lg text-sm font-semibold disabled:opacity-50" > {becomingSpecialist ? t('common.loading') : t('profile.become_specialist')} </button> </div> </div> )} {/* Show contact info toggle */} {isSpecialist && (dashData?.plan?.tier === 'pro' || dashData?.plan?.tier === 'ultimate') && ( <div className="bg-white rounded-xl border border-gray-200 p-6"> <h2 className="font-semibold text-gray-800 mb-4">{t('profile.show_contact', 'Show contacts')}</h2> <label className="flex items-center justify-between cursor-pointer gap-3"> <div> <p className="text-sm font-medium text-gray-800">{t('profile.show_contact_hint', 'Clients can see your phone number')}</p> <p className="text-xs text-gray-400 mt-0.5">{t('profile.show_contact_desc', 'Lets clients contact you directly')}</p> </div> <button type="button" onClick={() => setShowContactInfo(v => !v)} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${ showContactInfo ? 'bg-green-500' : 'bg-gray-300' }`} > <span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${ showContactInfo ? 'translate-x-6' : 'translate-x-1' }`} /> </button> </label> </div> )} {/* personal site slug moved to Site tab */} </> )} <button type="submit" disabled={saving} className={`w-full bg-green-600 text-white py-3 rounded-xl font-semibold hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed ${activeTab === 'notifications' || activeTab === 'referral' || activeTab === 'auto_response' ? 'hidden' : ''}`} > {saving ? t('profile.saving') : t('common.save')} </button> </form> )} {/* ── Upgrade Plan Modal ── */} {showUpgradeModal && ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"> <div className="bg-white rounded-2xl shadow-xl p-6 w-full max-w-md"> <div className="text-center mb-5"> <span className="text-5xl">🚀</span> </div> <h2 className="text-lg font-bold text-gray-900 text-center mb-2"> {t('profile.upgrade.cards_limit')} </h2> <p className="text-sm text-gray-600 text-center mb-4"> {user?.plan?.name ?? t('profile.plan.basic')} — {t('profile.plan.cards_up_to')} {maxCards} </p> {user?.plan && ( <div className="bg-gray-50 rounded-xl p-4 mb-5 text-sm text-gray-600 text-center"> <span className="font-medium text-gray-800">{t('profile.upgrade.plan')}</span>{' '} {user.plan.name} — {user.plan.price} {user.plan.currency} </div> )} <p className="text-xs text-gray-400 text-center mb-5"> {t('profile.upgrade.contact')} </p> <button onClick={() => 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')} </button> </div> </div> )} </div> </div> </div> ) }
Save
cmd:
run