/
opt
/
canhelp
/
apps
/
web
/
src
/
app
/
dashboard
/
/opt/canhelp/apps/web/src/app/dashboard
mkdir
upload
Name
Size
Mode
Actions
page.tsx
27054
0644
edit
dl
rm
Edit:
/opt/canhelp/apps/web/src/app/dashboard/page.tsx
(27054B)
'use client' import { useEffect, useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import Link from 'next/link' import { useSession } from '@/lib/auth' import { useLocale } from '@/context/locale' import { getMyTasks, getMyOffers, getMyReviews, getNotifications, getMyDashboard, getSpecialistFomoDashboard, getCustomerFomoDashboard, getMySpecialistCards, applyMyReferral, type DashboardData } from '@/lib/api' import { formatPrice, taskTitle } from '@/lib/taskLocale' import type { Notification } from '@canhelp/shared' import { PlanBadge } from '@/components/PlanBadge' import { UsageMeter } from '@/components/UsageMeter' /** Extract the quoted task title from a stored notification body (any locale) */ function extractNotifParam(body?: string | null): string | null { if (!body) return null const m = body.match(/[ยซ""](.+?)[ยป""]/) return m?.[1] ?? null } function PlanBlock({ dashboard, t }: { dashboard: DashboardData; t: (k: string, fb?: string) => string }) { const plan = dashboard.plan const expiry = dashboard.planExpiration const usage = dashboard.usage const stats = dashboard.stats const expiryPct = expiry ? Math.max(0, Math.min(100, Math.round((expiry.daysLeft / 30) * 100))) : 100 const expiryColor = expiry ? expiry.daysLeft <= 3 ? 'bg-red-500' : expiry.daysLeft <= 7 ? 'bg-amber-400' : 'bg-green-400' : 'bg-green-400' return ( <div className="bg-white rounded-2xl border border-gray-200 p-5 mb-6"> {/* Plan name + badge */} <div className="flex items-center justify-between mb-4"> <div className="flex items-center gap-2"> <h2 className="font-semibold text-gray-800 text-sm">{t('dashboard.plan.title', 'My Plan')}</h2> {plan ? ( <PlanBadge tier={plan.tier} size="sm" /> ) : ( <span className="text-xs text-gray-400">{t('dashboard.plan.no_plan', 'Free')}</span> )} </div> <Link href="/pricing" className="text-xs font-medium text-green-600 hover:underline"> {plan?.tier === 'ultimate' ? null : t('dashboard.plan.upgrade', 'Upgrade')} </Link> </div> {/* Expiry bar */} {expiry && ( <div className="mb-4"> <div className="flex justify-between text-xs text-gray-500 mb-1"> <span>{t('dashboard.plan.expires', 'Expires in {n} days').replace('{n}', String(expiry.daysLeft))}</span> <span>{expiry.daysLeft} {t('pricing.days', 'days')}</span> </div> <div className="h-1.5 rounded-full bg-gray-100"> <div className={`h-full rounded-full transition-all ${expiryColor}`} style={{ width: `${expiryPct}%` }} /> </div> </div> )} {/* Daily limits */} {(usage.messagesSent !== undefined || usage.ordersMade !== undefined) && ( <div className="space-y-2 mb-4"> <p className="text-xs font-medium text-gray-500 uppercase tracking-wide">{t('dashboard.plan.limits', 'Daily limits')}</p> <UsageMeter current={usage.messagesSent} limit={usage.messagesLeft !== null ? usage.messagesSent + usage.messagesLeft : null} label={t('dashboard.plan.messages', 'Messages')} /> <UsageMeter current={usage.ordersMade} limit={usage.ordersLeft !== null ? usage.ordersMade + usage.ordersLeft : null} label={t('dashboard.plan.orders', 'Orders')} /> </div> )} {/* Stats */} {Object.keys(stats).length > 0 && ( <div className="grid grid-cols-2 gap-2"> {Object.entries(stats).map(([key, val]) => ( <div key={key} className="bg-gray-50 rounded-xl px-3 py-2 text-center"> <p className="text-lg font-bold text-gray-800">{val}</p> <p className="text-[10px] text-gray-400 uppercase tracking-wide">{t(`dashboard.stats.${key}`, key)}</p> </div> ))} </div> )} </div> ) } export default function DashboardPage() { const { data: session, isPending } = useSession() const router = useRouter() const searchParams = useSearchParams() const { t, locale } = useLocale() const [tasks, setTasks] = useState<any[]>([]) const [offers, setOffers] = useState<any[]>([]) const [myReviews, setMyReviews] = useState<any[]>([]) const [invitations, setInvitations] = useState<Notification[]>([]) const [loading, setLoading] = useState(true) const [dashboard, setDashboard] = useState<DashboardData | null>(null) const [fomoData, setFomoData] = useState<any>(null) const [hasHelperCard, setHasHelperCard] = useState<boolean | null>(null) useEffect(() => { if (!isPending && !session) router.push('/login') }, [session, isPending]) // Apply pending referral code after Google OAuth redirect (?ref=CODE) useEffect(() => { if (!session) return const refCode = searchParams.get('ref') if (!refCode) return applyMyReferral(refCode).catch(() => {}) // Remove the ref param from the URL without reloading const params = new URLSearchParams(searchParams.toString()) params.delete('ref') const newUrl = params.toString() ? `?${params.toString()}` : window.location.pathname router.replace(newUrl) }, [session]) // Detect whether the user has completed their helper profile (has a card). // Used to show the getting-started onboarding card to users who haven't yet. useEffect(() => { if (!session) return getMySpecialistCards() .then(({ cards }) => setHasHelperCard(cards.length > 0)) .catch(() => setHasHelperCard(null)) }, [session]) useEffect(() => { if (!session) return Promise.all([getMyTasks(), getMyOffers(), getMyReviews(), getNotifications(), getMyDashboard()]) .then(([t, o, r, notifs, dash]) => { setTasks(t) setOffers(o) setMyReviews(r) setInvitations((notifs as Notification[]).filter((n) => n.type === 'task_invite')) setDashboard(dash) }) .catch(() => {}) .finally(() => setLoading(false)) }, [session]) useEffect(() => { if (!session) return const role = (session.user as any)?.role if (role === 'specialist') { getSpecialistFomoDashboard().then(setFomoData).catch(() => {}) } else { getCustomerFomoDashboard().then(setFomoData).catch(() => {}) } }, [session]) if (isPending || loading) return <div className="text-center py-20 text-white">{t('common.loading')}</div> const dateLocale = locale === 'ru' ? 'ru-RU' : locale === 'uk' ? 'uk-UA' : locale === 'en' ? 'en-US' : 'el-GR' const activeTasks = tasks.filter((task) => task.status !== 'completed' && task.status !== 'cancelled') const completedTasks = tasks.filter((task) => task.status === 'completed' || task.status === 'cancelled') const activeOffers = offers.filter((row: any) => { const offer = row.offer || row const task = row.task return offer.status !== 'other_accepted' && task?.status !== 'completed' && task?.status !== 'cancelled' }) const completedOffers = offers.filter((row: any) => { const offer = row.offer || row const task = row.task return offer.status === 'other_accepted' || task?.status === 'completed' || task?.status === 'cancelled' }) function TaskCard({ task }: { task: any }) { const offerCount: number = task.offerCount ?? 0 const declinedCount: number = task.declinedCount ?? 0 const activeOfferCount = offerCount - declinedCount return ( <Link href={`/tasks/${task.id}`} className="block bg-white rounded-xl border border-gray-200 p-4 hover:shadow-sm hover:border-green-300 transition" > <div className="flex justify-between items-start"> <div className="flex-1 min-w-0"> <p className="font-medium text-gray-900 truncate">{taskTitle(task, locale)}</p> <p className="text-xs text-gray-400 mt-1"> {new Date(task.createdAt).toLocaleDateString(dateLocale)} </p> {offerCount > 0 && ( <div className="flex items-center gap-2 mt-1.5"> <span className="text-xs text-green-600 font-medium">๐ผ {offerCount} {t('dashboard.offers_count', 'offers')}</span> {declinedCount > 0 && ( <span className="text-xs text-red-400">โ {declinedCount} {t('dashboard.declined_count', 'declined')}</span> )} </div> )} </div> <div className="ml-3 text-right shrink-0"> <span className={`text-xs px-2 py-0.5 rounded-full ${ task.status === 'open' ? 'bg-green-100 text-green-700' : task.status === 'in_progress' ? 'bg-green-100 text-green-700' : task.status === 'completed' ? 'bg-gray-100 text-gray-600' : 'bg-red-100 text-red-600' }`}> {t(`task.status.${task.status}`, task.status)} </span> {task.budget && ( <p className="text-sm font-bold text-green-600 mt-1">{formatPrice(task.budget)}โฌ</p> )} </div> </div> </Link> ) } function OfferCard({ row }: { row: any }) { const offer = row.offer || row const task = row.task const reviewReceived = task?.status === 'completed' && offer.status === 'accepted' ? myReviews.find((r) => r.review?.taskId === task?.id)?.review ?? null : null return ( <Link href={task ? `/tasks/${task.id}` : '#'} className="block bg-white rounded-xl border border-gray-200 p-4 hover:shadow-sm hover:border-green-300 transition" > <div className="flex justify-between items-start"> <div className="flex-1 min-w-0"> <p className="font-medium text-gray-900 truncate"> {task?.title || t('chat.task_fallback')} </p> <p className="text-xs text-gray-400 mt-1"> {new Date(offer.createdAt).toLocaleDateString(dateLocale)} </p> </div> <div className="ml-3 text-right shrink-0"> <p className="font-bold text-green-600">{formatPrice(offer.price)}โฌ</p> <span className={`text-xs px-2 py-0.5 rounded-full mt-1 inline-block ${ offer.status === 'accepted' ? 'bg-green-100 text-green-700' : offer.status === 'declined' ? 'bg-red-100 text-red-700' : offer.status === 'other_accepted' ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-700' }`}> {t(`offer.status.${offer.status}`, offer.status)} </span> </div> </div> {reviewReceived && ( <div className="mt-3 pt-3 border-t border-gray-100 flex items-center gap-2"> <div className="flex gap-0.5"> {[1, 2, 3, 4, 5].map((star) => ( <span key={star} className={`text-sm ${ star <= reviewReceived.rating ? 'text-yellow-400' : 'text-gray-200' }`}>โ </span> ))} </div> <span className="text-sm font-semibold text-gray-700">{reviewReceived.rating}/5</span> {reviewReceived.comment && ( <span className="text-xs text-gray-500 truncate">{reviewReceived.comment}</span> )} </div> )} {task?.status === 'completed' && offer.status === 'accepted' && !reviewReceived && ( <p className="mt-2 text-xs text-gray-400">{t('dashboard.review_pending')}</p> )} </Link> ) } const isSpecialist = (session?.user as any)?.role === 'specialist' return ( <div className="max-w-5xl mx-auto px-4 py-8"> <div className="flex justify-between items-center mb-8"> <h1 className="text-2xl font-bold text-white">{t('dashboard.title')}</h1> <Link href="/tasks/new" className="bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-green-700"> {t('dashboard.new_task')} </Link> </div> {/* Getting-started onboarding โ shown until the user completes their helper profile */} {hasHelperCard === false && ( <div className="mb-6 bg-white rounded-2xl border border-gray-200 overflow-hidden"> <div className="px-5 pt-5 pb-3"> <h2 className="text-lg font-bold text-gray-900">{t('dashboard.onboarding.title')}</h2> <p className="text-sm text-gray-500 mt-1">{t('dashboard.onboarding.subtitle')}</p> </div> <div className="grid sm:grid-cols-2 gap-3 p-5 pt-2"> {/* Helper path (primary) */} <Link href="/specialist-setup" className="group flex flex-col rounded-xl border-2 border-green-500 bg-green-50 p-4 hover:bg-green-100 transition" > <div className="flex items-center gap-2 mb-1"> <span className="text-xl">๐ ๏ธ</span> <span className="font-semibold text-gray-800">{t('dashboard.onboarding.helper.title')}</span> </div> <p className="text-sm text-green-700/80 mb-3 flex-1">{t('dashboard.onboarding.helper.desc')}</p> <span className="inline-flex items-center gap-1 text-sm font-semibold text-green-700"> {t('dashboard.onboarding.helper.cta')} โ </span> </Link> {/* Requester path */} <Link href="/tasks/new" className="group flex flex-col rounded-xl border border-gray-200 bg-gray-50 p-4 hover:bg-gray-100 transition" > <div className="flex items-center gap-2 mb-1"> <span className="text-xl">๐</span> <span className="font-semibold text-gray-800">{t('dashboard.onboarding.requester.title')}</span> </div> <p className="text-sm text-gray-500 mb-3 flex-1">{t('dashboard.onboarding.requester.desc')}</p> <span className="inline-flex items-center gap-1 text-sm font-semibold text-gray-700"> {t('dashboard.onboarding.requester.cta')} โ </span> </Link> </div> </div> )} {/* Plan + limits block */} {dashboard && <PlanBlock dashboard={dashboard} t={t} />} {/* FOMO insight block */} {fomoData && ( isSpecialist ? ( (fomoData.missedCount > 0 || fomoData.missedEarnings > 0) && ( <div className="mb-6 bg-amber-50 border border-amber-200 rounded-xl px-5 py-4 flex items-start gap-3"> <div className="shrink-0 mt-0.5"> <svg className="w-5 h-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /> </svg> </div> <div className="flex-1 min-w-0"> <p className="text-base font-semibold text-amber-800"> {t('fomo.specialist_missed', 'You missed {n} tasks').replace('{n}', String(fomoData.missedCount ?? 0))} {fomoData.missedEarnings > 0 && ( <span className="ml-1 text-amber-600">for โฌ{fomoData.missedEarnings}</span> )} </p> {fomoData.topCategories?.length > 0 && ( <p className="text-sm text-amber-600 mt-0.5"> {t('fomo.top_categories', 'Categories')}: {fomoData.topCategories.slice(0, 3).join(', ')} </p> )} </div> <Link href="/pricing" className="shrink-0 text-xs font-medium text-amber-700 bg-amber-100 px-3 py-1.5 rounded-lg hover:bg-amber-200 transition"> {t('dashboard.plan.upgrade')} </Link> </div> ) ) : ( fomoData.totalTasks > 0 && ( <div className="mb-6 bg-green-50 border border-green-200 rounded-xl px-5 py-4"> <div className="flex items-center justify-between gap-4 flex-wrap"> <div className="flex items-center gap-3"> <div className="shrink-0 bg-green-100 rounded-xl p-2"> <svg className="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /> </svg> </div> <div> <p className="text-base font-semibold text-gray-800"> {fomoData.totalOffers} {t('fomo.offers_received', 'offers received')} </p> {fomoData.avgOffersPerTask > 0 && ( <p className="text-sm text-gray-500"> {t('fomo.avg_offers', 'Average {n}/task').replace('{n}', Number(fomoData.avgOffersPerTask).toFixed(1))} </p> )} </div> </div> {fomoData.completionRate !== undefined && ( <div className="text-right"> <p className="text-2xl font-bold text-green-700">{Math.round(fomoData.completionRate * 100)}%</p> <p className="text-sm text-gray-400">{t('fomo.completion_rate', 'completed')}</p> </div> )} </div> </div> ) ) )} {isSpecialist && ( <Link href="/tasks" className="flex items-center justify-between gap-4 bg-green-600 text-white rounded-xl px-5 py-4 mb-8 hover:bg-green-700 transition" > <div className="flex items-center gap-4"> <div className="shrink-0 bg-white/20 rounded-xl p-2.5"> <svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <circle cx="11" cy="11" r="7" /> <path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35" /> </svg> </div> <div> <p className="font-semibold text-base">{t('find_tasks.title')}</p> <p className="text-green-100 text-sm mt-0.5">{t('find_tasks.banner_hint')}</p> </div> </div> <svg className="w-5 h-5 shrink-0 text-white/70" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /> </svg> </Link> )} <div className="grid md:grid-cols-2 gap-8"> {/* โโ LEFT COLUMN: Tasks โโ */} <div className="space-y-8"> {/* Active tasks */} <div> <h2 className="text-lg font-semibold text-white mb-4"> {t('dashboard.my_tasks')} <span className="text-white/60 font-normal text-base">({activeTasks.length})</span> </h2> {activeTasks.length === 0 ? ( <div className="bg-white rounded-xl border border-gray-200 p-6 text-center text-gray-500"> <p>{t('dashboard.tasks.empty')}</p> <Link href="/tasks/new" className="text-green-600 hover:underline text-sm mt-2 inline-block"> {t('dashboard.tasks.empty.cta')} </Link> </div> ) : ( <div className="space-y-3"> {activeTasks.map((task) => <TaskCard key={task.id} task={task} />)} </div> )} </div> {/* Completed tasks */} {completedTasks.length > 0 && ( <div> <h2 className="text-lg font-semibold text-white/70 mb-4"> {t('dashboard.completed_tasks')} <span className="font-normal text-base text-white/50">({completedTasks.length})</span> </h2> <div className="space-y-3 opacity-75"> {completedTasks.map((task) => <TaskCard key={task.id} task={task} />)} </div> </div> )} </div> {/* โโ RIGHT COLUMN: Role-specific โโ */} <div className="space-y-8"> {isSpecialist ? ( <> {/* Task invitations */} {invitations.length > 0 && ( <div> <h2 className="text-lg font-semibold text-white mb-4"> {t('dashboard.invitations')} <span className="text-white/60 font-normal text-base">({invitations.length})</span> </h2> <div className="space-y-3"> {invitations.map((inv) => ( <Link key={inv.id} href={inv.referenceId ? `/tasks/${inv.referenceId}` : '/tasks'} className={`flex items-start gap-3 p-4 rounded-xl border transition cursor-pointer ${ inv.isRead ? 'bg-white/90 border-white/20 hover:bg-white' : 'bg-white border-amber-300 shadow-sm hover:bg-amber-50' }`} > <span className="text-2xl">๐</span> <div className="flex-1 min-w-0"> <p className={`font-medium truncate ${inv.isRead ? 'text-gray-600' : 'text-gray-900'}`}> {t('notif.type.task_invite', inv.title)} </p> {inv.body && ( <p className="text-sm text-gray-500 mt-0.5 truncate"> {(() => { const title = extractNotifParam(inv.body) const tpl = t('notif.body.task_invite', '') return title && tpl ? tpl.replace('{title}', title) : inv.body })()} </p> )} <p className="text-xs text-gray-400 mt-1">{new Date(inv.createdAt).toLocaleDateString(dateLocale)}</p> </div> <div className="flex flex-col items-end gap-1.5 shrink-0"> {!inv.isRead && <span className="w-2 h-2 bg-amber-400 rounded-full mt-2" />} <span className="text-xs text-gray-400">โ</span> </div> </Link> ))} </div> </div> )} {/* Active offers */} <div> <h2 className="text-lg font-semibold text-white mb-4"> {t('dashboard.my_offers')} <span className="text-white/60 font-normal text-base">({activeOffers.length})</span> </h2> {activeOffers.length === 0 ? ( <div className="bg-white rounded-xl border border-gray-200 p-6 text-center text-gray-500"> <p>{t('dashboard.offers.empty')}</p> <Link href="/tasks" className="text-green-600 hover:underline text-sm mt-2 inline-block"> {t('dashboard.offers.empty.cta')} </Link> </div> ) : ( <div className="space-y-3"> {activeOffers.map((row: any) => <OfferCard key={(row.offer || row).id} row={row} />)} </div> )} </div> {/* Completed offers */} {completedOffers.length > 0 && ( <div> <h2 className="text-lg font-semibold text-white/70 mb-4"> {t('dashboard.completed_offers')} <span className="font-normal text-base text-white/50">({completedOffers.length})</span> </h2> <div className="space-y-3 opacity-75"> {completedOffers.map((row: any) => <OfferCard key={(row.offer || row).id} row={row} />)} </div> </div> )} </> ) : ( <> {/* Customer: Find specialist promo */} <Link href="/specialists" className="flex items-center justify-between gap-4 bg-green-600 text-white rounded-xl px-5 py-4 hover:bg-green-700 transition"> <div className="flex items-center gap-4"> <div className="shrink-0 bg-white/20 rounded-xl p-2.5"> <svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> </div> <div> <p className="font-semibold text-base">{t('dashboard.customer.specialists_title')}</p> <p className="text-green-100 text-sm mt-0.5">{t('dashboard.customer.specialists_hint')}</p> </div> </div> <svg className="w-5 h-5 shrink-0 text-white/70" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg> </Link> {/* Customer: How it works */} <div className="bg-white rounded-xl border border-gray-200 p-5"> <h2 className="text-base font-semibold text-gray-800 mb-4">{t('dashboard.customer.how_title')}</h2> <div className="space-y-3"> {[ { num: '1', text: t('dashboard.customer.step1'), icon: 'M12 4v16m8-8H4' }, { num: '2', text: t('dashboard.customer.step2'), icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' }, { num: '3', text: t('dashboard.customer.step3'), icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' }, ].map(({ num, text, icon }) => ( <div key={num} className="flex items-start gap-3"> <span className="shrink-0 w-7 h-7 rounded-full bg-green-100 text-green-600 text-sm font-bold flex items-center justify-center">{num}</span> <p className="text-sm text-gray-600 pt-1">{text}</p> </div> ))} </div> <Link href="/tasks/new" className="mt-4 inline-block text-sm font-medium text-green-600 hover:underline"> {tasks.length > 0 ? t('dashboard.tasks.add_new') : t('dashboard.tasks.empty.cta')} โ </Link> </div> </> )} </div> </div> </div> ) }
Save
cmd:
run