PageSpeed monitoring, Polar billing, sidebar polish, frontend consistency audit #68
@@ -135,11 +135,11 @@ export default function AdminOrgDetailPage() {
|
||||
{org.current_period_end ? formatDateTime(new Date(org.current_period_end)) : '-'}
|
||||
</span>
|
||||
|
||||
<span className="text-neutral-500">Stripe Cust:</span>
|
||||
<span className="font-mono text-xs">{org.stripe_customer_id || '-'}</span>
|
||||
|
||||
<span className="text-neutral-500">Stripe Sub:</span>
|
||||
<span className="font-mono text-xs">{org.stripe_subscription_id || '-'}</span>
|
||||
<span className="text-neutral-500">Customer ID:</span>
|
||||
<span className="font-mono text-xs">{org.billing_customer_id || '-'}</span>
|
||||
|
||||
<span className="text-neutral-500">Subscription ID:</span>
|
||||
<span className="font-mono text-xs">{org.billing_subscription_id || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
17
app/page.tsx
17
app/page.tsx
@@ -334,7 +334,7 @@ export default function HomePage() {
|
||||
return `${label} Plan`
|
||||
})()}
|
||||
</p>
|
||||
{(typeof subscription.sites_count === 'number' || (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number') || (subscription.next_invoice_amount_due != null && subscription.next_invoice_currency && !subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing'))) && (
|
||||
{(typeof subscription.sites_count === 'number' || (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number') || (!subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing'))) && (
|
||||
<p className="text-sm text-neutral-400 mt-1">
|
||||
{typeof subscription.sites_count === 'number' && (
|
||||
<span>Sites: {(() => {
|
||||
@@ -346,20 +346,9 @@ export default function HomePage() {
|
||||
{subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && (
|
||||
<span>Pageviews: {subscription.pageview_usage.toLocaleString()}/{subscription.pageview_limit.toLocaleString()}</span>
|
||||
)}
|
||||
{subscription.next_invoice_amount_due != null && subscription.next_invoice_currency && !subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing') && (
|
||||
{!subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing') && subscription.current_period_end && (
|
||||
<span className="block mt-1">
|
||||
Renews {(() => {
|
||||
const ts = subscription.next_invoice_period_end ?? subscription.current_period_end
|
||||
const d = ts ? new Date(typeof ts === 'number' ? ts * 1000 : ts) : null
|
||||
const dateStr = d && !Number.isNaN(d.getTime()) && d.getTime() !== 0
|
||||
? formatDate(d)
|
||||
: null
|
||||
const amount = (subscription.next_invoice_amount_due / 100).toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: subscription.next_invoice_currency.toUpperCase(),
|
||||
})
|
||||
return dateStr ? `${dateStr} for ${amount}` : amount
|
||||
})()}
|
||||
Renews {formatDate(new Date(subscription.current_period_end))}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -109,7 +109,7 @@ export default function PricingSection() {
|
||||
const [loadingPlan, setLoadingPlan] = useState<string | null>(null)
|
||||
const { user } = useAuth()
|
||||
|
||||
// * Show toast when redirected from Stripe Checkout with canceled=true
|
||||
// * Show toast when redirected from Polar Checkout with canceled=true
|
||||
useEffect(() => {
|
||||
if (searchParams.get('canceled') === 'true') {
|
||||
toast.info('Checkout was canceled. You can try again whenever you’re ready.')
|
||||
@@ -196,7 +196,7 @@ export default function PricingSection() {
|
||||
limit,
|
||||
})
|
||||
|
||||
// 3. Redirect to Stripe Checkout
|
||||
// 3. Redirect to Polar Checkout
|
||||
if (url) {
|
||||
window.location.href = url
|
||||
} else {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
OrganizationInvitation,
|
||||
Organization
|
||||
} from '@/lib/api/organization'
|
||||
import { getSubscription, createPortalSession, getInvoices, cancelSubscription, resumeSubscription, changePlan, previewInvoice, createCheckoutSession, SubscriptionDetails, Invoice, PreviewInvoiceResult } from '@/lib/api/billing'
|
||||
import { getSubscription, createPortalSession, getOrders, cancelSubscription, resumeSubscription, changePlan, createCheckoutSession, SubscriptionDetails, Order } from '@/lib/api/billing'
|
||||
import { TRAFFIC_TIERS, PLAN_ID_SOLO, PLAN_ID_TEAM, PLAN_ID_BUSINESS, getTierIndexForLimit, getLimitForTierIndex, getSitesLimitForPlan } from '@/lib/plans'
|
||||
import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit'
|
||||
import { getNotificationSettings, updateNotificationSettings } from '@/lib/api/notification-settings'
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
XIcon,
|
||||
Captcha,
|
||||
BookOpenIcon,
|
||||
DownloadIcon,
|
||||
ExternalLinkIcon,
|
||||
LayoutDashboardIcon,
|
||||
Spinner,
|
||||
@@ -93,10 +92,8 @@ export default function OrganizationSettings() {
|
||||
const [changePlanId, setChangePlanId] = useState<string>(PLAN_ID_SOLO)
|
||||
const [changePlanTierIndex, setChangePlanTierIndex] = useState(2)
|
||||
const [changePlanYearly, setChangePlanYearly] = useState(false)
|
||||
const [invoicePreview, setInvoicePreview] = useState<PreviewInvoiceResult | null>(null)
|
||||
const [isLoadingPreview, setIsLoadingPreview] = useState(false)
|
||||
const [isChangingPlan, setIsChangingPlan] = useState(false)
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
const [orders, setOrders] = useState<Order[]>([])
|
||||
const [isLoadingInvoices, setIsLoadingInvoices] = useState(false)
|
||||
|
||||
// Invite State
|
||||
@@ -195,14 +192,14 @@ export default function OrganizationSettings() {
|
||||
}
|
||||
}, [currentOrgId])
|
||||
|
||||
const loadInvoices = useCallback(async () => {
|
||||
const loadOrders = useCallback(async () => {
|
||||
if (!currentOrgId) return
|
||||
setIsLoadingInvoices(true)
|
||||
try {
|
||||
const invs = await getInvoices()
|
||||
setInvoices(invs)
|
||||
const ords = await getOrders()
|
||||
setOrders(ords)
|
||||
} catch (error) {
|
||||
logger.error('Failed to load invoices:', error)
|
||||
logger.error('Failed to load orders:', error)
|
||||
} finally {
|
||||
setIsLoadingInvoices(false)
|
||||
}
|
||||
@@ -231,9 +228,9 @@ export default function OrganizationSettings() {
|
||||
useEffect(() => {
|
||||
if (activeTab === 'billing' && currentOrgId) {
|
||||
loadSubscription()
|
||||
loadInvoices()
|
||||
loadOrders()
|
||||
}
|
||||
}, [activeTab, currentOrgId, loadSubscription, loadInvoices])
|
||||
}, [activeTab, currentOrgId, loadSubscription, loadOrders])
|
||||
|
||||
const loadAudit = useCallback(async () => {
|
||||
if (!currentOrgId) return
|
||||
@@ -307,19 +304,8 @@ export default function OrganizationSettings() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!showChangePlanModal || !hasActiveSubscription) {
|
||||
setInvoicePreview(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setIsLoadingPreview(true)
|
||||
setInvoicePreview(null)
|
||||
const interval = changePlanYearly ? 'year' : 'month'
|
||||
const limit = getLimitForTierIndex(changePlanTierIndex)
|
||||
previewInvoice({ plan_id: changePlanId, interval, limit })
|
||||
.then((res) => { if (!cancelled) setInvoicePreview(res ?? null) })
|
||||
.catch(() => { if (!cancelled) { setInvoicePreview(null) } })
|
||||
.finally(() => { if (!cancelled) setIsLoadingPreview(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [showChangePlanModal, hasActiveSubscription, changePlanId, changePlanTierIndex, changePlanYearly])
|
||||
|
||||
// If no org ID, we are in personal organization context, so don't show org settings
|
||||
@@ -382,7 +368,6 @@ export default function OrganizationSettings() {
|
||||
setChangePlanTierIndex(2)
|
||||
}
|
||||
setChangePlanYearly(subscription?.billing_interval === 'year')
|
||||
setInvoicePreview(null)
|
||||
setShowChangePlanModal(true)
|
||||
}
|
||||
|
||||
@@ -954,19 +939,15 @@ export default function OrganizationSettings() {
|
||||
Change plan
|
||||
</Button>
|
||||
</div>
|
||||
{(subscription.business_name || (subscription.tax_ids && subscription.tax_ids.length > 0)) && (
|
||||
{(subscription.business_name || subscription.tax_id) && (
|
||||
<div className="px-6 pb-2 -mt-2 space-y-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{subscription.business_name && (
|
||||
<div>Billing for: {subscription.business_name}</div>
|
||||
)}
|
||||
{subscription.tax_ids && subscription.tax_ids.length > 0 && (
|
||||
<div>
|
||||
Tax ID{subscription.tax_ids.length > 1 ? 's' : ''}:{' '}
|
||||
{subscription.tax_ids.map((t) => {
|
||||
const label = t.type === 'eu_vat' ? 'VAT' : t.type === 'us_ein' ? 'EIN' : t.type.replace(/_/g, ' ').toUpperCase()
|
||||
return `${label} ${t.value}${t.country ? ` (${t.country})` : ''}`
|
||||
}).join(', ')}
|
||||
</div>
|
||||
{subscription.tax_id && (
|
||||
<span>
|
||||
Tax ID: {subscription.tax_id.value} ({subscription.tax_id.type})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1014,18 +995,11 @@ export default function OrganizationSettings() {
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
{(() => {
|
||||
const ts = subscription.next_invoice_period_end ?? subscription.current_period_end
|
||||
const d = ts ? new Date(typeof ts === 'number' ? ts * 1000 : ts) : null
|
||||
const dateStr = d && !Number.isNaN(d.getTime()) && d.getTime() !== 0
|
||||
const ts = subscription.current_period_end
|
||||
const d = ts ? new Date(ts) : null
|
||||
return d && !Number.isNaN(d.getTime()) && d.getTime() !== 0
|
||||
? formatDate(d)
|
||||
: '—'
|
||||
const amount = subscription.next_invoice_amount_due != null && subscription.next_invoice_currency
|
||||
? (subscription.next_invoice_amount_due / 100).toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: subscription.next_invoice_currency.toUpperCase(),
|
||||
})
|
||||
: null
|
||||
return amount && dateStr !== '—' ? `${dateStr} for ${amount}` : dateStr
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1062,57 +1036,38 @@ export default function OrganizationSettings() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invoice History */}
|
||||
{/* Order History */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-3">Recent invoices</h3>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-3">Recent orders</h3>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{isLoadingInvoices ? (
|
||||
<InvoicesListSkeleton />
|
||||
) : invoices.length === 0 ? (
|
||||
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">No invoices found.</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">No orders found.</div>
|
||||
) : (
|
||||
<>
|
||||
{invoices.map((invoice) => (
|
||||
<div key={invoice.id} className="px-4 py-3 flex items-center justify-between hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors">
|
||||
{orders.map((order) => (
|
||||
<div key={order.id} className="px-4 py-3 flex items-center justify-between hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<span className="font-medium text-sm text-neutral-900 dark:text-white">
|
||||
{(invoice.amount_paid / 100).toLocaleString('en-US', { style: 'currency', currency: invoice.currency.toUpperCase() })}
|
||||
{(order.total_amount / 100).toLocaleString('en-US', { style: 'currency', currency: order.currency.toUpperCase() })}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500 ml-2">
|
||||
{formatDate(new Date(invoice.created * 1000))}
|
||||
{formatDate(new Date(order.created_at))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium capitalize ${
|
||||
invoice.status === 'paid'
|
||||
order.status === 'paid'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
: invoice.status === 'open'
|
||||
: order.status === 'open'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300'
|
||||
}`}>
|
||||
{invoice.status}
|
||||
{order.status}
|
||||
</span>
|
||||
{invoice.invoice_pdf && (
|
||||
<a href={invoice.invoice_pdf} target="_blank" rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-2.5 py-1.5 text-xs font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange" title="Download PDF">
|
||||
<DownloadIcon className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Download</span> PDF
|
||||
</a>
|
||||
)}
|
||||
{invoice.hosted_invoice_url && (
|
||||
<a href={invoice.hosted_invoice_url} target="_blank" rel="noopener noreferrer"
|
||||
className={`inline-flex items-center gap-2 px-2.5 py-1.5 text-xs font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange ${
|
||||
invoice.status === 'open'
|
||||
? 'bg-brand-orange text-white hover:bg-brand-orange-hover'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||
}`}
|
||||
title={invoice.status === 'open' ? 'Pay now' : 'View invoice'}>
|
||||
<ExternalLinkIcon className="w-3.5 h-3.5" />
|
||||
{invoice.status === 'open' ? 'Pay now' : <><span className="hidden sm:inline">View </span>Invoice</>}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1595,26 +1550,9 @@ export default function OrganizationSettings() {
|
||||
</div>
|
||||
{hasActiveSubscription && (
|
||||
<div className="mt-4 p-4 rounded-lg bg-neutral-100 dark:bg-neutral-800/50 border border-neutral-200 dark:border-neutral-700">
|
||||
{isLoadingPreview ? (
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<Spinner className="w-4 h-4" />
|
||||
Calculating next invoice…
|
||||
</div>
|
||||
) : invoicePreview ? (
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Next invoice:{' '}
|
||||
{(invoicePreview.amount_due / 100).toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: invoicePreview.currency.toUpperCase(),
|
||||
})}{' '}
|
||||
on {formatDate(new Date(invoicePreview.period_end * 1000))}{' '}
|
||||
<span className="text-neutral-500">(prorated)</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Unable to calculate preview. Your next invoice will reflect prorations.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Your plan will be updated. Any prorations will be reflected on your next invoice.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 mt-6">
|
||||
|
||||
@@ -2,8 +2,8 @@ import { authFetch } from './client'
|
||||
|
||||
export interface AdminOrgSummary {
|
||||
organization_id: string
|
||||
stripe_customer_id: string
|
||||
stripe_subscription_id: string
|
||||
billing_customer_id: string
|
||||
billing_subscription_id: string
|
||||
plan_id: string
|
||||
billing_interval: string
|
||||
pageview_limit: number
|
||||
|
||||
@@ -19,16 +19,10 @@ export interface SubscriptionDetails {
|
||||
sites_count?: number
|
||||
/** Pageviews in current billing period (when pageview_limit > 0). Present when backend supports usage API. */
|
||||
pageview_usage?: number
|
||||
/** Business name from Stripe Tax ID collection / business purchase flow (optional). */
|
||||
/** Business name from billing (optional). */
|
||||
business_name?: string
|
||||
/** Tax IDs collected on the Stripe customer (VAT, EIN, etc.) for invoice verification. */
|
||||
tax_ids?: TaxID[]
|
||||
/** Next invoice amount in cents (for "Renews on X for €Y" display). */
|
||||
next_invoice_amount_due?: number
|
||||
/** Currency for next invoice (e.g. eur). */
|
||||
next_invoice_currency?: string
|
||||
/** Unix timestamp when next invoice period ends. */
|
||||
next_invoice_period_end?: number
|
||||
/** Tax ID collected on the billing customer (VAT, EIN, etc.). */
|
||||
tax_id?: TaxID | null
|
||||
}
|
||||
|
||||
export async function getSubscription(): Promise<SubscriptionDetails> {
|
||||
@@ -66,22 +60,6 @@ export interface ChangePlanParams {
|
||||
limit: number
|
||||
}
|
||||
|
||||
export interface PreviewInvoiceResult {
|
||||
amount_due: number
|
||||
currency: string
|
||||
period_end: number
|
||||
}
|
||||
|
||||
export async function previewInvoice(params: ChangePlanParams): Promise<PreviewInvoiceResult | null> {
|
||||
const res = await apiRequest<PreviewInvoiceResult | Record<string, never>>('/api/billing/preview-invoice', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
if (res && typeof res === 'object' && 'amount_due' in res && typeof (res as PreviewInvoiceResult).amount_due === 'number') {
|
||||
return res as PreviewInvoiceResult
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function changePlan(params: ChangePlanParams): Promise<{ ok: boolean }> {
|
||||
return apiRequest<{ ok: boolean }>('/api/billing/change-plan', {
|
||||
@@ -103,17 +81,18 @@ export async function createCheckoutSession(params: CreateCheckoutParams): Promi
|
||||
})
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
export interface Order {
|
||||
id: string
|
||||
amount_paid: number
|
||||
amount_due: number
|
||||
total_amount: number
|
||||
subtotal_amount: number
|
||||
tax_amount: number
|
||||
currency: string
|
||||
status: string
|
||||
created: number
|
||||
hosted_invoice_url: string
|
||||
invoice_pdf: string
|
||||
created_at: string
|
||||
paid: boolean
|
||||
invoice_number: string
|
||||
}
|
||||
|
||||
export async function getInvoices(): Promise<Invoice[]> {
|
||||
return apiRequest<Invoice[]>('/api/billing/invoices')
|
||||
export async function getOrders(): Promise<Order[]> {
|
||||
return apiRequest<Order[]>('/api/billing/invoices')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user