/opt/canhelp/apps/web/src/app/dashboard
NameSizeModeActions
page.tsx270540644editdlrm
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 (
{/* Plan name + badge */}

{t('dashboard.plan.title', 'My Plan')}

{plan ? ( ) : ( {t('dashboard.plan.no_plan', 'Free')} )}
{plan?.tier === 'ultimate' ? null : t('dashboard.plan.upgrade', 'Upgrade')}
{/* Expiry bar */} {expiry && (
{t('dashboard.plan.expires', 'Expires in {n} days').replace('{n}', String(expiry.daysLeft))} {expiry.daysLeft} {t('pricing.days', 'days')}
)} {/* Daily limits */} {(usage.messagesSent !== undefined || usage.ordersMade !== undefined) && (

{t('dashboard.plan.limits', 'Daily limits')}

)} {/* Stats */} {Object.keys(stats).length > 0 && (
{Object.entries(stats).map(([key, val]) => (

{val}

{t(`dashboard.stats.${key}`, key)}

))}
)}
) } export default function DashboardPage() { const { data: session, isPending } = useSession() const router = useRouter() const searchParams = useSearchParams() const { t, locale } = useLocale() const [tasks, setTasks] = useState([]) const [offers, setOffers] = useState([]) const [myReviews, setMyReviews] = useState([]) const [invitations, setInvitations] = useState([]) const [loading, setLoading] = useState(true) const [dashboard, setDashboard] = useState(null) const [fomoData, setFomoData] = useState(null) const [hasHelperCard, setHasHelperCard] = useState(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
{t('common.loading')}
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 (

{taskTitle(task, locale)}

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

{offerCount > 0 && (
๐Ÿ’ผ {offerCount} {t('dashboard.offers_count', 'offers')} {declinedCount > 0 && ( โœ• {declinedCount} {t('dashboard.declined_count', 'declined')} )}
)}
{t(`task.status.${task.status}`, task.status)} {task.budget && (

{formatPrice(task.budget)}โ‚ฌ

)}
) } 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 (

{task?.title || t('chat.task_fallback')}

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

{formatPrice(offer.price)}โ‚ฌ

{t(`offer.status.${offer.status}`, offer.status)}
{reviewReceived && (
{[1, 2, 3, 4, 5].map((star) => ( โ˜… ))}
{reviewReceived.rating}/5 {reviewReceived.comment && ( {reviewReceived.comment} )}
)} {task?.status === 'completed' && offer.status === 'accepted' && !reviewReceived && (

{t('dashboard.review_pending')}

)} ) } const isSpecialist = (session?.user as any)?.role === 'specialist' return (

{t('dashboard.title')}

{t('dashboard.new_task')}
{/* Getting-started onboarding โ€” shown until the user completes their helper profile */} {hasHelperCard === false && (

{t('dashboard.onboarding.title')}

{t('dashboard.onboarding.subtitle')}

{/* Helper path (primary) */}
๐Ÿ› ๏ธ {t('dashboard.onboarding.helper.title')}

{t('dashboard.onboarding.helper.desc')}

{t('dashboard.onboarding.helper.cta')} โ†’ {/* Requester path */}
๐Ÿ“ {t('dashboard.onboarding.requester.title')}

{t('dashboard.onboarding.requester.desc')}

{t('dashboard.onboarding.requester.cta')} โ†’
)} {/* Plan + limits block */} {dashboard && } {/* FOMO insight block */} {fomoData && ( isSpecialist ? ( (fomoData.missedCount > 0 || fomoData.missedEarnings > 0) && (

{t('fomo.specialist_missed', 'You missed {n} tasks').replace('{n}', String(fomoData.missedCount ?? 0))} {fomoData.missedEarnings > 0 && ( for โ‚ฌ{fomoData.missedEarnings} )}

{fomoData.topCategories?.length > 0 && (

{t('fomo.top_categories', 'Categories')}: {fomoData.topCategories.slice(0, 3).join(', ')}

)}
{t('dashboard.plan.upgrade')}
) ) : ( fomoData.totalTasks > 0 && (

{fomoData.totalOffers} {t('fomo.offers_received', 'offers received')}

{fomoData.avgOffersPerTask > 0 && (

{t('fomo.avg_offers', 'Average {n}/task').replace('{n}', Number(fomoData.avgOffersPerTask).toFixed(1))}

)}
{fomoData.completionRate !== undefined && (

{Math.round(fomoData.completionRate * 100)}%

{t('fomo.completion_rate', 'completed')}

)}
) ) )} {isSpecialist && (

{t('find_tasks.title')}

{t('find_tasks.banner_hint')}

)}
{/* โ”€โ”€ LEFT COLUMN: Tasks โ”€โ”€ */}
{/* Active tasks */}

{t('dashboard.my_tasks')} ({activeTasks.length})

{activeTasks.length === 0 ? (

{t('dashboard.tasks.empty')}

{t('dashboard.tasks.empty.cta')}
) : (
{activeTasks.map((task) => )}
)}
{/* Completed tasks */} {completedTasks.length > 0 && (

{t('dashboard.completed_tasks')} ({completedTasks.length})

{completedTasks.map((task) => )}
)}
{/* โ”€โ”€ RIGHT COLUMN: Role-specific โ”€โ”€ */}
{isSpecialist ? ( <> {/* Task invitations */} {invitations.length > 0 && (

{t('dashboard.invitations')} ({invitations.length})

{invitations.map((inv) => ( ๐Ÿ“‹

{t('notif.type.task_invite', inv.title)}

{inv.body && (

{(() => { const title = extractNotifParam(inv.body) const tpl = t('notif.body.task_invite', '') return title && tpl ? tpl.replace('{title}', title) : inv.body })()}

)}

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

{!inv.isRead && } โ†’
))}
)} {/* Active offers */}

{t('dashboard.my_offers')} ({activeOffers.length})

{activeOffers.length === 0 ? (

{t('dashboard.offers.empty')}

{t('dashboard.offers.empty.cta')}
) : (
{activeOffers.map((row: any) => )}
)}
{/* Completed offers */} {completedOffers.length > 0 && (

{t('dashboard.completed_offers')} ({completedOffers.length})

{completedOffers.map((row: any) => )}
)} ) : ( <> {/* Customer: Find specialist promo */}

{t('dashboard.customer.specialists_title')}

{t('dashboard.customer.specialists_hint')}

{/* Customer: How it works */}

{t('dashboard.customer.how_title')}

{[ { 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 }) => (
{num}

{text}

))}
{tasks.length > 0 ? t('dashboard.tasks.add_new') : t('dashboard.tasks.empty.cta')} โ†’
)}
) }