feat(web): serve the web app under /app and send every billing link there (WS-B)
apps/web was never deployed, so /billing on the public domain returned the landing page and d3ro.dev (desktop "upgrade") did not resolve. - apps/web runs with basePath /app and output standalone; /download and /releases redirect to the site's #download. A Dockerfile and a d3ro-web compose service (port 3002) deploy it to the NAS with the other images. - The site bridge worker forwards /app/* to WEB_APP_ORIGIN (the tunnel host) and rewrites upstream redirects; everything else still goes to Pages. With no origin configured /app answers 503 instead of the landing page. - Desktop upgrade, desktop Stripe return, mobile subscription management, the web checkout/portal returns and the site all use billingUrl(); the return query is success=1 / canceled=1, which the billing page reads. The billing page highlights ?tier=pro|pro_plus, and signing in from a billing link returns to the same plan. - auth/callback pins the redirect origin in production and rejects protocol-relative next= values (open redirect). - Mobile legal links use SITE_URLS (fixes the missing slash on /terms). - Compose drops the unused NEXT_PUBLIC_API_URL and the dead wwwroot legal mounts; deploy scripts add the web image and the SUPABASE_* values the NAS compose already required; .dockerignore keeps app .env files out of images. - Supabase auth redirects allow /app/** (remote dashboard must match). Policy: docs/REFACTOR_POLICY.md Wave 3, W3-3 and W3-4.
This commit is contained in:
parent
88f24d84a1
commit
b6fe588a7c
30 changed files with 493 additions and 95 deletions
|
|
@ -2,6 +2,7 @@ import Link from 'next/link'
|
|||
import { Alert, Box, Button, Chip, Stack, Typography } from '@mui/material'
|
||||
import { Check } from 'lucide-react'
|
||||
import type { Subscription, SubscriptionTier } from '@d3ro/api-client'
|
||||
import type { PaidPlanTier } from '@d3ro/core/plan-catalog'
|
||||
import { MetalCard, TactileBadge } from '@d3ro/ui/components/ds'
|
||||
import { d3roFontMono } from '@d3ro/ui/theme'
|
||||
import { BillingCheckoutOptions } from '@/components/billing/billing-checkout-options'
|
||||
|
|
@ -82,6 +83,11 @@ const PLANS: Plan[] = [
|
|||
}
|
||||
]
|
||||
|
||||
/** 데스크톱·모바일·사이트가 billingUrl({ tier }) 로 넘긴 요금제. 모르는 값은 무시한다. */
|
||||
function selectedTierFrom(value: string | string[] | undefined): PaidPlanTier | null {
|
||||
return value === 'pro' || value === 'pro_plus' ? value : null
|
||||
}
|
||||
|
||||
function tierLabel(tier: SubscriptionTier): string {
|
||||
return tier === 'pro_plus' ? 'PRO+' : tier.toUpperCase()
|
||||
}
|
||||
|
|
@ -160,6 +166,7 @@ export default async function BillingPage({ searchParams }: BillingPageProps): P
|
|||
const periodEnd = formatDate(subscription.current_period_end)
|
||||
const stripeCanceled = params['canceled'] === '1'
|
||||
const stripeReturned = params['success'] === '1'
|
||||
const selectedTier = selectedTierFrom(params['tier'])
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 1120, mx: 'auto', p: { xs: 2.5, md: 4 }, pt: 4, pb: 10 }}>
|
||||
|
|
@ -244,6 +251,7 @@ export default async function BillingPage({ searchParams }: BillingPageProps): P
|
|||
currentTier={subscription.tier}
|
||||
canPurchase={canPurchase}
|
||||
catalog={state.catalog}
|
||||
selectedTier={selectedTier}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
|
@ -259,14 +267,19 @@ function PlanCard({
|
|||
plan,
|
||||
currentTier,
|
||||
canPurchase,
|
||||
catalog
|
||||
catalog,
|
||||
selectedTier
|
||||
}: {
|
||||
plan: Plan
|
||||
currentTier: SubscriptionTier
|
||||
canPurchase: boolean
|
||||
catalog: BillingCatalog | null
|
||||
selectedTier: PaidPlanTier | null
|
||||
}): React.ReactElement {
|
||||
const active = plan.tier === currentTier
|
||||
// 외부에서 고른 요금제가 있으면 그 카드만, 없으면 기본 추천 카드를 강조한다.
|
||||
const selected = !active && selectedTier === plan.tier
|
||||
const recommended = !active && !selectedTier && Boolean(plan.highlight)
|
||||
const catalogPrices: BillingCatalogPrice[] = plan.tier === 'free' || !catalog
|
||||
? []
|
||||
: catalog.plans[plan.tier]
|
||||
|
|
@ -274,21 +287,23 @@ function PlanCard({
|
|||
return (
|
||||
<MetalCard
|
||||
data-testid={`billing-plan-${plan.tier}`}
|
||||
data-selected={selected ? 'true' : undefined}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
sx={{
|
||||
p: 3,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 390,
|
||||
border: active
|
||||
border: active || selected
|
||||
? '2px solid var(--d3-accent-main)'
|
||||
: plan.highlight
|
||||
: recommended
|
||||
? '1px solid var(--d3-accent-glow)'
|
||||
: undefined
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: 11, color: active ? 'var(--d3-accent-main)' : 'var(--d3-text-label)', mb: 0.75 }}>
|
||||
{active ? 'CURRENT PLAN' : plan.highlight ? 'RECOMMENDED' : 'PLAN'}
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: 11, color: active || selected ? 'var(--d3-accent-main)' : 'var(--d3-text-label)', mb: 0.75 }}>
|
||||
{active ? 'CURRENT PLAN' : selected ? 'SELECTED PLAN' : recommended ? 'RECOMMENDED' : 'PLAN'}
|
||||
</Typography>
|
||||
<Typography component="h2" sx={{ fontSize: 24, fontWeight: 600, color: 'var(--d3-text-inverse)' }}>{plan.name}</Typography>
|
||||
<Typography sx={{ mt: 0.5, mb: 3, color: plan.tier === 'free' ? 'var(--d3-text-label)' : 'var(--d3-accent-main)', fontFamily: d3roFontMono, fontSize: 16 }}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue