/opt/canhelp/apps/web/src/app/dashboard
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 }) => (
))}
{tasks.length > 0 ? t('dashboard.tasks.add_new') : t('dashboard.tasks.empty.cta')} โ
>
)}
)
}