/
opt
/
canhelp
/
apps
/
web
/
src
/
app
/
pricing
/
/opt/canhelp/apps/web/src/app/pricing
mkdir
upload
Name
Size
Mode
Actions
page.tsx
18105
0644
edit
dl
rm
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 ( <svg viewBox="0 0 20 20" fill="currentColor" className="w-5 h-5 text-green-500 mx-auto"> <path fillRule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clipRule="evenodd" /> </svg> ) } function XIcon() { return ( <svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4 text-gray-300 mx-auto"> <path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" /> </svg> ) } const tierOrder: PlanTier[] = ['free', 'pro', 'ultimate'] const tierHeaderColors: Record<PlanTier, string> = { 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<PlanTier, string> = { free: 'text-gray-700', pro: 'text-green-700', ultimate: 'text-amber-600', } const tierBtnColors: Record<PlanTier, string> = { 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<Plan[]>([]) const [currentPlanId, setCurrentPlanId] = useState<string | null>(null) const [loading, setLoading] = useState(true) const [confirmPlan, setConfirmPlan] = useState<Plan | null>(null) const [preview, setPreview] = useState<PlanPreview | null>(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 ( <main className="min-h-screen py-12 px-4"> <div className="max-w-5xl mx-auto"> {/* Header */} <div className="text-center mb-10"> <h1 className="text-4xl font-bold text-white mb-3">{t('pricing.title', 'Pricing')}</h1> <p className="text-white/70 text-lg">{t('pricing.subtitle')}</p> </div> {loading ? ( <div className="text-center text-white/60 py-20">{t('common.loading', 'Loading...')}</div> ) : ( <> {/* Plan cards */} <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12"> {plans.map((plan) => { const isCurrentPlan = plan.id === currentPlanId const isFree = plan.tier === 'free' return ( <div key={plan.id} className={`rounded-2xl border-2 p-6 bg-white flex flex-col transition ${ isCurrentPlan ? 'ring-2 ring-green-500 ring-offset-2' : '' } ${plan.tier === 'ultimate' ? 'border-amber-300 shadow-lg shadow-amber-100' : 'border-gray-200'}`} > {/* Tier badge + current label */} <div className="flex items-center gap-2 mb-4"> <PlanBadge tier={plan.tier} size="md" /> {isCurrentPlan && ( <span className="text-xs font-medium text-green-600 bg-green-50 border border-green-200 px-2 py-0.5 rounded-full"> {t('pricing.current_plan', 'Your plan')} </span> )} {plan.tier === 'pro' && !isCurrentPlan && ( <span className="text-xs font-medium text-green-600 bg-green-50 border border-green-100 px-2 py-0.5 rounded-full"> {t('pricing.popular', 'Popular')} </span> )} </div> <h2 className="text-xl font-bold text-gray-900 mb-1">{plan.name}</h2> <p className="text-gray-500 text-sm mb-4 min-h-[40px]">{plan.description}</p> {/* Price */} <div className={`text-3xl font-extrabold mb-1 ${tierPriceColors[plan.tier]}`}> {isFree ? t('pricing.free', 'Free') : ( <> {plan.oldPrice && ( <span className="text-lg font-normal text-gray-400 line-through mr-2">€{plan.oldPrice}</span> )} €{plan.price} </> )} {!isFree && ( <span className="text-base font-normal text-gray-400"> /{t('pricing.per_month', 'month')} </span> )} </div> {plan.durationDays && !isFree && ( <p className="text-xs text-gray-400 mb-5"> {t('pricing.duration', 'Duration')}: {plan.durationDays} {t('pricing.days', 'days')} </p> )} {isFree && <div className="mb-5" />} {/* CTA */} <div className="mt-auto"> {isCurrentPlan ? ( <div className="w-full py-2.5 text-center text-sm font-semibold text-green-700 bg-green-50 border border-green-200 rounded-xl"> {t('pricing.your_plan', 'Current plan')} </div> ) : isFree ? ( <div className="w-full py-2.5 text-center text-sm font-medium text-gray-400 bg-gray-50 border border-gray-200 rounded-xl"> {t('pricing.free_default', 'Default')} </div> ) : ( <button onClick={() => handleSelectPlan(plan)} className={`block w-full py-2.5 text-center text-sm font-semibold rounded-xl transition ${tierBtnColors[plan.tier]}`} > {t('pricing.choose', 'Choose')} {plan.name} </button> )} </div> </div> ) })} </div> {/* Feature comparison table */} <div className="bg-white rounded-2xl border border-gray-200 overflow-hidden"> <div className="px-6 py-4 border-b border-gray-100"> <h3 className="font-semibold text-gray-900">{t('pricing.compare', 'Feature comparison')}</h3> </div> <div className="overflow-x-auto"> <table className="w-full text-sm"> <thead> <tr className="border-b border-gray-100"> <th className="text-left px-6 py-3 text-gray-500 font-medium w-1/2"> {t('pricing.feature', 'Feature')} </th> {plans.map((plan) => ( <th key={plan.id} className="text-center px-4 py-3 font-medium text-gray-700"> <div className="flex flex-col items-center gap-1"> <PlanBadge tier={plan.tier} size="sm" /> <span className="text-xs text-gray-400"> {plan.tier === 'free' ? t('pricing.free', 'Free') : `€${plan.price}`} </span> </div> </th> ))} </tr> </thead> <tbody> {visibleFeatures.map(({ key, labelKey, format }) => ( <tr key={key} className="border-b border-gray-50 hover:bg-gray-50/50 transition"> <td className="px-6 py-3 text-gray-600">{t(labelKey, labelKey)}</td> {plans.map((plan) => { const val = plan[key] let cell: React.ReactNode if (format) { cell = <span className="text-gray-700 font-medium">{format(val)}</span> } else if (typeof val === 'boolean') { cell = val ? <CheckIcon /> : <XIcon /> } else { cell = <span className="text-gray-700">{val === null ? '∞' : String(val ?? '—')}</span> } return ( <td key={plan.id} className="text-center px-4 py-3"> {cell} </td> ) })} </tr> ))} </tbody> </table> </div> </div> {/* FAQ / footer note */} <p className="text-center text-white/50 text-sm mt-8"> {t('pricing.faq_note', 'Payment is deducted from your CanHelp balance. Top up is available in Payments.')} </p> </> )} </div> {/* ── Confirm plan modal ──────────────────────────────────────────────── */} {confirmPlan && ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"> <div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-6"> <h2 className="text-lg font-bold text-gray-900 mb-4"> {t('pricing.confirm.title', 'Confirm plan selection')} </h2> {previewLoading ? ( <div className="py-10 text-center text-gray-400">{t('common.loading', 'Loading...')}</div> ) : preview ? ( <div className="space-y-3 text-sm text-gray-700"> <div className="flex justify-between"> <span className="text-gray-500">{t('pricing.confirm.plan', 'Plan')}</span> <span className="font-semibold">{confirmPlan.name}</span> </div> {preview.type === 'upgrade' && Number(preview.prorated) > 0 && ( <div className="flex justify-between"> <span className="text-gray-500">{t('pricing.confirm.prorated', 'Prorated credit')}</span> <span className="text-green-600 font-medium">−€{preview.prorated}</span> </div> )} {preview.type !== 'downgrade' && ( <div className="flex justify-between"> <span className="text-gray-500">{t('pricing.confirm.effective_price', 'To pay')}</span> <span className="font-bold text-gray-900">€{preview.effectivePrice}</span> </div> )} {preview.type !== 'downgrade' && ( <div className="flex justify-between"> <span className="text-gray-500">{t('pricing.confirm.your_balance', 'Your balance')}</span> <span className={preview.canPayFromBalance ? 'text-green-600' : 'text-red-500'}> €{preview.currentBalance} </span> </div> )} {preview.type === 'downgrade' && ( <div className="bg-orange-50 border border-orange-200 rounded-xl p-3 text-orange-800 text-xs"> {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')}</> )} </div> )} {preview.type !== 'downgrade' && !preview.canPayFromBalance && ( <div className="bg-red-50 border border-red-200 rounded-xl p-3 text-red-700 text-xs"> {t('pricing.confirm.insufficient', 'Insufficient balance.')} {' '} <Link href="/payment" className="underline font-medium" onClick={() => setConfirmPlan(null)}> {t('pricing.confirm.top_up', 'Top up')} </Link> </div> )} <div className="flex gap-3 pt-2"> <button onClick={() => { setConfirmPlan(null); setPreview(null) }} className="flex-1 py-2.5 rounded-xl border border-gray-200 text-sm font-medium text-gray-600 hover:bg-gray-50 transition" > {t('common.cancel', 'Cancel')} </button> {(preview.type === 'downgrade' || preview.canPayFromBalance) && ( <button onClick={handleConfirm} disabled={purchasing} className={`flex-1 py-2.5 rounded-xl text-sm font-semibold text-white transition ${ preview.type === 'downgrade' ? 'bg-orange-500 hover:bg-orange-600' : tierBtnColors[confirmPlan.tier].replace('block w-full', '').trim() } disabled:opacity-50`} > {purchasing ? t('common.loading', 'Loading...') : preview.type === 'downgrade' ? t('pricing.confirm.schedule_downgrade', 'Schedule switch') : t('pricing.confirm.pay', 'Pay')} </button> )} </div> </div> ) : null} </div> </div> )} </main> ) }
Save
cmd:
run