d3ro-voice/apps/web/src/components/billing/portal-button.tsx
Yun Chan b6fe588a7c 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.
2026-09-26 15:48:30 +09:00

82 lines
2.6 KiB
TypeScript

'use client'
// apps/web/src/components/billing/portal-button.tsx
// Stripe Customer Portal — 활성 구독 사용자가 결제 수단/취소 등을 관리
import { useState } from 'react'
import { Button, CircularProgress, Alert, Box } from '@mui/material'
import SettingsIcon from '@mui/icons-material/Settings'
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
import { browserBillingUrl } from '@/lib/web-app-url'
export function PortalButton(): React.ReactElement {
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleOpenPortal(): Promise<void> {
setError(null)
setBusy(true)
try {
const supabase = getSupabaseBrowserClient()
const {
data: { session },
error: sessionError
} = await supabase.auth.getSession()
if (sessionError || !session) {
setError('로그인이 필요합니다')
return
}
const baseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim()
if (!baseUrl) throw new Error('결제 서버 설정이 완료되지 않았습니다')
const response = await fetch(
new URL('/functions/v1/stripe-portal', baseUrl).toString(),
{
method: 'POST',
headers: {
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
return_url: browserBillingUrl()
})
}
)
const data = await response.json().catch(() => null) as { url?: unknown } | null
if (!response.ok) throw new Error('Stripe 구독 관리 페이지를 열지 못했습니다')
if (typeof data?.url !== 'string') throw new Error('구독 관리 URL을 받지 못했습니다')
const portalUrl = new URL(data.url)
if (portalUrl.protocol !== 'https:' || portalUrl.hostname !== 'billing.stripe.com') {
throw new Error('구독 관리 URL을 신뢰할 수 없습니다')
}
window.location.assign(portalUrl.toString())
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error')
} finally {
setBusy(false)
}
}
return (
<Box>
<Button
variant="outlined"
size="small"
data-testid="stripe-portal-open"
startIcon={busy ? <CircularProgress size={14} /> : <SettingsIcon />}
onClick={() => void handleOpenPortal()}
disabled={busy}
>
구독 관리
</Button>
{error && (
<Alert severity="error" variant="outlined" sx={{ mt: 1, fontSize: 11 }}>
{error}
</Alert>
)}
</Box>
)
}