feat(payment): add complete payment system with multi-provider support
Add a full payment and subscription system supporting EasyPay (Alipay/WeChat), Stripe, and direct Alipay/WeChat Pay providers with multi-instance load balancing.
This commit is contained in:
71
frontend/src/utils/apiError.ts
Normal file
71
frontend/src/utils/apiError.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Centralized API error message extraction
|
||||
*
|
||||
* The API client interceptor rejects with a plain object: { status, code, message, error }
|
||||
* This utility extracts the user-facing message from any error shape.
|
||||
*/
|
||||
|
||||
interface ApiErrorLike {
|
||||
status?: number
|
||||
code?: number | string
|
||||
message?: string
|
||||
error?: string
|
||||
reason?: string
|
||||
metadata?: Record<string, unknown>
|
||||
response?: {
|
||||
data?: {
|
||||
detail?: string
|
||||
message?: string
|
||||
code?: number | string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the error code from an API error object.
|
||||
*/
|
||||
export function extractApiErrorCode(err: unknown): string | undefined {
|
||||
if (!err || typeof err !== 'object') return undefined
|
||||
const e = err as ApiErrorLike
|
||||
const code = e.code ?? e.reason ?? e.response?.data?.code
|
||||
return code != null ? String(code) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a displayable error message from an API error.
|
||||
*
|
||||
* @param err - The caught error (unknown type)
|
||||
* @param fallback - Fallback message if none can be extracted (use t('common.error') or similar)
|
||||
* @param i18nMap - Optional map of error codes to i18n translated strings
|
||||
*/
|
||||
export function extractApiErrorMessage(
|
||||
err: unknown,
|
||||
fallback = 'Unknown error',
|
||||
i18nMap?: Record<string, string>,
|
||||
): string {
|
||||
if (!err) return fallback
|
||||
|
||||
// Try i18n mapping by error code first
|
||||
if (i18nMap) {
|
||||
const code = extractApiErrorCode(err)
|
||||
if (code && i18nMap[code]) return i18nMap[code]
|
||||
}
|
||||
|
||||
// Plain object from API client interceptor (most common case)
|
||||
if (typeof err === 'object' && err !== null) {
|
||||
const e = err as ApiErrorLike
|
||||
// Interceptor shape: { message, error }
|
||||
if (e.message) return e.message
|
||||
if (e.error) return e.error
|
||||
// Legacy axios shape: { response.data.detail }
|
||||
if (e.response?.data?.detail) return e.response.data.detail
|
||||
if (e.response?.data?.message) return e.response.data.message
|
||||
}
|
||||
|
||||
// Standard Error
|
||||
if (err instanceof Error) return err.message
|
||||
|
||||
// Last resort
|
||||
const str = String(err)
|
||||
return str === '[object Object]' ? fallback : str
|
||||
}
|
||||
165
frontend/src/utils/platformColors.ts
Normal file
165
frontend/src/utils/platformColors.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Centralized platform color definitions.
|
||||
*
|
||||
* All components that need platform-specific styling should import from here
|
||||
* instead of defining their own color mappings.
|
||||
*/
|
||||
|
||||
export type Platform = 'anthropic' | 'openai' | 'antigravity' | 'gemini'
|
||||
|
||||
// ── Badge (bg + text + border, for inline badges with border) ───────
|
||||
const BADGE: Record<Platform, string> = {
|
||||
anthropic: 'bg-orange-500/10 text-orange-600 border-orange-500/30 dark:text-orange-400',
|
||||
openai: 'bg-green-500/10 text-green-600 border-green-500/30 dark:text-green-400',
|
||||
antigravity: 'bg-purple-500/10 text-purple-600 border-purple-500/30 dark:text-purple-400',
|
||||
gemini: 'bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-400',
|
||||
}
|
||||
const BADGE_DEFAULT = 'bg-slate-500/10 text-slate-600 border-slate-500/30 dark:text-slate-400'
|
||||
|
||||
// ── Light badge (softer bg, no border) ──────────────────────────────
|
||||
const BADGE_LIGHT: Record<Platform, string> = {
|
||||
anthropic: 'bg-orange-500/10 text-orange-600 dark:bg-orange-500/10 dark:text-orange-300',
|
||||
openai: 'bg-green-500/10 text-green-600 dark:bg-green-500/10 dark:text-green-300',
|
||||
antigravity: 'bg-purple-500/10 text-purple-600 dark:bg-purple-500/10 dark:text-purple-300',
|
||||
gemini: 'bg-blue-500/10 text-blue-600 dark:bg-blue-500/10 dark:text-blue-300',
|
||||
}
|
||||
|
||||
// ── Border ──────────────────────────────────────────────────────────
|
||||
const BORDER: Record<Platform, string> = {
|
||||
anthropic: 'border-orange-500/20 dark:border-orange-500/20',
|
||||
openai: 'border-green-500/20 dark:border-green-500/20',
|
||||
antigravity: 'border-purple-500/20 dark:border-purple-500/20',
|
||||
gemini: 'border-blue-500/20 dark:border-blue-500/20',
|
||||
}
|
||||
const BORDER_DEFAULT = 'border-gray-200 dark:border-dark-700'
|
||||
|
||||
// ── Accent bar (gradient) ───────────────────────────────────────────
|
||||
const ACCENT_BAR: Record<Platform, string> = {
|
||||
anthropic: 'bg-gradient-to-r from-orange-400 to-orange-500',
|
||||
openai: 'bg-gradient-to-r from-emerald-400 to-emerald-500',
|
||||
antigravity: 'bg-gradient-to-r from-purple-400 to-purple-500',
|
||||
gemini: 'bg-gradient-to-r from-blue-400 to-blue-500',
|
||||
}
|
||||
const ACCENT_BAR_DEFAULT = 'bg-gradient-to-r from-primary-400 to-primary-500'
|
||||
|
||||
// ── Text (price, icon) ─────────────────────────────────────────────
|
||||
const TEXT: Record<Platform, string> = {
|
||||
anthropic: 'text-orange-600 dark:text-orange-400',
|
||||
openai: 'text-emerald-600 dark:text-emerald-400',
|
||||
antigravity: 'text-purple-600 dark:text-purple-400',
|
||||
gemini: 'text-blue-600 dark:text-blue-400',
|
||||
}
|
||||
const TEXT_DEFAULT = 'text-primary-600 dark:text-primary-400'
|
||||
|
||||
// ── Icon (check mark etc.) ──────────────────────────────────────────
|
||||
const ICON: Record<Platform, string> = {
|
||||
anthropic: 'text-orange-500 dark:text-orange-400',
|
||||
openai: 'text-emerald-500 dark:text-emerald-400',
|
||||
antigravity: 'text-purple-500 dark:text-purple-400',
|
||||
gemini: 'text-blue-500 dark:text-blue-400',
|
||||
}
|
||||
const ICON_DEFAULT = 'text-primary-500 dark:text-primary-400'
|
||||
|
||||
// ── Button (solid bg) ───────────────────────────────────────────────
|
||||
const BUTTON: Record<Platform, string> = {
|
||||
anthropic: 'bg-orange-500 text-white hover:bg-orange-600 active:bg-orange-700 dark:bg-orange-500/80 dark:hover:bg-orange-500',
|
||||
openai: 'bg-green-600 text-white hover:bg-green-700 active:bg-green-800 dark:bg-green-600/80 dark:hover:bg-green-600',
|
||||
antigravity: 'bg-purple-500 text-white hover:bg-purple-600 active:bg-purple-700 dark:bg-purple-500/80 dark:hover:bg-purple-500',
|
||||
gemini: 'bg-blue-500 text-white hover:bg-blue-600 active:bg-blue-700 dark:bg-blue-500/80 dark:hover:bg-blue-500',
|
||||
}
|
||||
const BUTTON_DEFAULT = 'bg-primary-500 text-white hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-500'
|
||||
|
||||
// ── Discount badge ──────────────────────────────────────────────────
|
||||
const DISCOUNT: Record<Platform, string> = {
|
||||
anthropic: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
|
||||
openai: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
antigravity: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
gemini: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
}
|
||||
const DISCOUNT_DEFAULT = 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
|
||||
|
||||
// ── Header gradient (subscription confirm) ─────────────────────────
|
||||
const GRADIENT: Record<Platform, string> = {
|
||||
anthropic: 'from-orange-500 to-orange-600',
|
||||
openai: 'from-emerald-500 to-emerald-600',
|
||||
antigravity: 'from-purple-500 to-purple-600',
|
||||
gemini: 'from-blue-500 to-blue-600',
|
||||
}
|
||||
const GRADIENT_DEFAULT = 'from-primary-500 to-primary-600'
|
||||
|
||||
// ── Header text (light text on gradient bg) ────────────────────────
|
||||
const GRADIENT_TEXT: Record<Platform, string> = {
|
||||
anthropic: 'text-orange-100',
|
||||
openai: 'text-emerald-100',
|
||||
antigravity: 'text-purple-100',
|
||||
gemini: 'text-blue-100',
|
||||
}
|
||||
const GRADIENT_TEXT_DEFAULT = 'text-primary-100'
|
||||
|
||||
const GRADIENT_SUBTEXT: Record<Platform, string> = {
|
||||
anthropic: 'text-orange-200',
|
||||
openai: 'text-emerald-200',
|
||||
antigravity: 'text-purple-200',
|
||||
gemini: 'text-blue-200',
|
||||
}
|
||||
const GRADIENT_SUBTEXT_DEFAULT = 'text-primary-200'
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────
|
||||
|
||||
function isPlatform(p: string): p is Platform {
|
||||
return p === 'anthropic' || p === 'openai' || p === 'antigravity' || p === 'gemini'
|
||||
}
|
||||
|
||||
export function platformBadgeClass(p: string): string {
|
||||
return isPlatform(p) ? BADGE[p] : BADGE_DEFAULT
|
||||
}
|
||||
|
||||
export function platformBadgeLightClass(p: string): string {
|
||||
return isPlatform(p) ? BADGE_LIGHT[p] : BADGE_DEFAULT
|
||||
}
|
||||
|
||||
export function platformBorderClass(p: string): string {
|
||||
return isPlatform(p) ? BORDER[p] : BORDER_DEFAULT
|
||||
}
|
||||
|
||||
export function platformAccentBarClass(p: string): string {
|
||||
return isPlatform(p) ? ACCENT_BAR[p] : ACCENT_BAR_DEFAULT
|
||||
}
|
||||
|
||||
export function platformTextClass(p: string): string {
|
||||
return isPlatform(p) ? TEXT[p] : TEXT_DEFAULT
|
||||
}
|
||||
|
||||
export function platformIconClass(p: string): string {
|
||||
return isPlatform(p) ? ICON[p] : ICON_DEFAULT
|
||||
}
|
||||
|
||||
export function platformButtonClass(p: string): string {
|
||||
return isPlatform(p) ? BUTTON[p] : BUTTON_DEFAULT
|
||||
}
|
||||
|
||||
export function platformDiscountClass(p: string): string {
|
||||
return isPlatform(p) ? DISCOUNT[p] : DISCOUNT_DEFAULT
|
||||
}
|
||||
|
||||
export function platformGradientClass(p: string): string {
|
||||
return isPlatform(p) ? GRADIENT[p] : GRADIENT_DEFAULT
|
||||
}
|
||||
|
||||
export function platformGradientTextClass(p: string): string {
|
||||
return isPlatform(p) ? GRADIENT_TEXT[p] : GRADIENT_TEXT_DEFAULT
|
||||
}
|
||||
|
||||
export function platformGradientSubtextClass(p: string): string {
|
||||
return isPlatform(p) ? GRADIENT_SUBTEXT[p] : GRADIENT_SUBTEXT_DEFAULT
|
||||
}
|
||||
|
||||
export function platformLabel(p: string): string {
|
||||
switch (p) {
|
||||
case 'anthropic': return 'Anthropic'
|
||||
case 'openai': return 'OpenAI'
|
||||
case 'antigravity': return 'Antigravity'
|
||||
case 'gemini': return 'Gemini'
|
||||
default: return p || 'API'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user