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.
82 lines
2.6 KiB
TypeScript
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>
|
|
)
|
|
}
|