/opt/canhelp/apps/web/src/app/pricing
NameSizeModeActions
page.tsx181050644editdlrm
Edit: /opt/canhelp/apps/web/src/app/pricing/page.tsx (18105B)
'use client' import { useState, useEffect } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useSession } from '@/lib/auth' import { useLocale } from '@/context/locale' import { getPlans, getMyDashboard, getPublicSettings, previewPlanChange, purchasePlan, type Plan, type PlanPreview, type PlanTier } from '@/lib/api' import { PlanBadge } from '@/components/PlanBadge' const FEATURE_ROWS: { key: keyof Plan; labelKey: string; format?: (v: any) => string }[] = [ { key: 'maxMessagesPerDay', labelKey: 'pricing.feat.messages', format: (v) => v === null ? '∞' : String(v) }, { key: 'maxOrdersPerDay', labelKey: 'pricing.feat.orders', format: (v) => v === null ? '∞' : String(v) }, { key: 'canContactAll', labelKey: 'pricing.feat.contact_all' }, { key: 'canViewPhone', labelKey: 'pricing.feat.view_phone' }, { key: 'canShowContactInfo', labelKey: 'pricing.feat.show_contact' }, { key: 'hasFavorites', labelKey: 'pricing.feat.favorites' }, { key: 'hasVerifiedBadge', labelKey: 'pricing.feat.verified_badge' }, { key: 'hasGoogleCalendar', labelKey: 'pricing.feat.google_calendar' }, { key: 'hasPersonalSite', labelKey: 'pricing.feat.personal_site' }, { key: 'hasAutoResponse', labelKey: 'pricing.feat.auto_response' }, { key: 'hasAutoMatch', labelKey: 'pricing.feat.auto_match' }, { key: 'highlightedReviews', labelKey: 'pricing.feat.highlighted_reviews' }, { key: 'maxCards', labelKey: 'pricing.feat.cards', format: (v) => v === null ? '∞' : String(v) }, { key: 'notifyNewTasks', labelKey: 'pricing.feat.notify_tasks' }, { key: 'canUploadVideo', labelKey: 'pricing.feat.video' }, ] function CheckIcon() { return ( ) } function XIcon() { return ( ) } const tierOrder: PlanTier[] = ['free', 'pro', 'ultimate'] const tierHeaderColors: Record = { free: 'bg-gray-50 border-gray-200', pro: 'bg-green-50 border-green-200', ultimate: 'bg-gradient-to-br from-amber-50 to-yellow-50 border-amber-300', } const tierPriceColors: Record = { free: 'text-gray-700', pro: 'text-green-700', ultimate: 'text-amber-600', } const tierBtnColors: Record = { free: 'bg-gray-100 text-gray-700 hover:bg-gray-200', pro: 'bg-green-600 text-white hover:bg-green-700', ultimate: 'bg-gradient-to-r from-amber-500 to-yellow-500 text-white hover:from-amber-600 hover:to-yellow-600', } export default function PricingPage() { const { data: session } = useSession() const { t } = useLocale() const router = useRouter() const [plans, setPlans] = useState([]) const [currentPlanId, setCurrentPlanId] = useState(null) const [loading, setLoading] = useState(true) const [confirmPlan, setConfirmPlan] = useState(null) const [preview, setPreview] = useState(null) const [previewLoading, setPreviewLoading] = useState(false) const [purchasing, setPurchasing] = useState(false) useEffect(() => { getPublicSettings() .then((settings) => { if (!settings.showPricing) router.replace('/') }) .catch(() => {}) }, [router]) // Fetch plans with abort to prevent stale race conditions useEffect(() => { let aborted = false setLoading(true) getPlans() .then((data) => { if (!aborted) setPlans(data.sort((a, b) => tierOrder.indexOf(a.tier) - tierOrder.indexOf(b.tier))) }) .catch(() => {}) .finally(() => { if (!aborted) setLoading(false) }) return () => { aborted = true } }, []) const fetchCurrentPlan = () => { if (!session) return getMyDashboard() .then((d) => setCurrentPlanId(d.plan?.id ?? null)) .catch(() => {}) } useEffect(fetchCurrentPlan, [session]) // Refresh active plan when admin widget changes it useEffect(() => { const handler = () => fetchCurrentPlan() window.addEventListener('canhelp:plan-changed', handler) document.addEventListener('visibilitychange', handler) return () => { window.removeEventListener('canhelp:plan-changed', handler) document.removeEventListener('visibilitychange', handler) } }, [session]) async function handleSelectPlan(plan: Plan) { if (!session) { router.push('/sign-in'); return } setConfirmPlan(plan) setPreview(null) setPreviewLoading(true) try { const p = await previewPlanChange(plan.id) setPreview(p) } catch { setConfirmPlan(null) } finally { setPreviewLoading(false) } } async function handleConfirm() { if (!confirmPlan || !preview) return setPurchasing(true) try { const result = await purchasePlan(confirmPlan.id, preview.canPayFromBalance) if (result.url && result.params) { const form = document.createElement('form') form.method = 'POST' form.action = result.url Object.entries(result.params).forEach(([k, v]) => { const inp = document.createElement('input') inp.type = 'hidden' inp.name = k inp.value = v form.appendChild(inp) }) document.body.appendChild(form) form.submit() return } setCurrentPlanId(confirmPlan.id) setConfirmPlan(null) setPreview(null) } catch { // ignore } finally { setPurchasing(false) } } const visibleFeatures = FEATURE_ROWS return (
{/* Header */}

{t('pricing.title', 'Pricing')}

{t('pricing.subtitle')}

{loading ? (
{t('common.loading', 'Loading...')}
) : ( <> {/* Plan cards */}
{plans.map((plan) => { const isCurrentPlan = plan.id === currentPlanId const isFree = plan.tier === 'free' return (
{/* Tier badge + current label */}
{isCurrentPlan && ( {t('pricing.current_plan', 'Your plan')} )} {plan.tier === 'pro' && !isCurrentPlan && ( {t('pricing.popular', 'Popular')} )}

{plan.name}

{plan.description}

{/* Price */}
{isFree ? t('pricing.free', 'Free') : ( <> {plan.oldPrice && ( €{plan.oldPrice} )} €{plan.price} )} {!isFree && ( /{t('pricing.per_month', 'month')} )}
{plan.durationDays && !isFree && (

{t('pricing.duration', 'Duration')}: {plan.durationDays} {t('pricing.days', 'days')}

)} {isFree &&
} {/* CTA */}
{isCurrentPlan ? (
{t('pricing.your_plan', 'Current plan')}
) : isFree ? (
{t('pricing.free_default', 'Default')}
) : ( )}
) })}
{/* Feature comparison table */}

{t('pricing.compare', 'Feature comparison')}

{plans.map((plan) => ( ))} {visibleFeatures.map(({ key, labelKey, format }) => ( {plans.map((plan) => { const val = plan[key] let cell: React.ReactNode if (format) { cell = {format(val)} } else if (typeof val === 'boolean') { cell = val ? : } else { cell = {val === null ? '∞' : String(val ?? '—')} } return ( ) })} ))}
{t('pricing.feature', 'Feature')}
{plan.tier === 'free' ? t('pricing.free', 'Free') : `€${plan.price}`}
{t(labelKey, labelKey)} {cell}
{/* FAQ / footer note */}

{t('pricing.faq_note', 'Payment is deducted from your CanHelp balance. Top up is available in Payments.')}

)}
{/* ── Confirm plan modal ──────────────────────────────────────────────── */} {confirmPlan && (

{t('pricing.confirm.title', 'Confirm plan selection')}

{previewLoading ? (
{t('common.loading', 'Loading...')}
) : preview ? (
{t('pricing.confirm.plan', 'Plan')} {confirmPlan.name}
{preview.type === 'upgrade' && Number(preview.prorated) > 0 && (
{t('pricing.confirm.prorated', 'Prorated credit')} −€{preview.prorated}
)} {preview.type !== 'downgrade' && (
{t('pricing.confirm.effective_price', 'To pay')} €{preview.effectivePrice}
)} {preview.type !== 'downgrade' && (
{t('pricing.confirm.your_balance', 'Your balance')} €{preview.currentBalance}
)} {preview.type === 'downgrade' && (
{t('pricing.confirm.downgrade_info', 'The switch to the new plan will happen automatically after the current period ends.')} {preview.scheduledDate && ( <> {new Date(preview.scheduledDate).toLocaleDateString('ru-RU')} )}
)} {preview.type !== 'downgrade' && !preview.canPayFromBalance && (
{t('pricing.confirm.insufficient', 'Insufficient balance.')} {' '} setConfirmPlan(null)}> {t('pricing.confirm.top_up', 'Top up')}
)}
{(preview.type === 'downgrade' || preview.canPayFromBalance) && ( )}
) : null}
)}
) }