d3ro-voice/apps/desktop/src/renderer/components/support/support-contact.ts

115 lines
4.2 KiB
TypeScript

// src/renderer/components/support/support-contact.ts
// 고객지원 문의 정책(순수 함수)과 IO 포트.
//
// 앱에는 환불 자격 조회·결제 취소·티켓 접수 백엔드가 없다. 그래서 지원 모달은 "처리됐다"고
// 주장하지 않고, 사용자가 직접 보내는 이메일(mailto:)로만 문의를 넘긴다. 서버가 확인한 성공이
// 없으므로 성공 문구·티켓 번호·환불 처리 결과는 만들지 않는다.
import type { IPCResult } from '@d3ro/core/errors'
/** 지원 문의 수신 주소. 사이트의 개인정보·약관 문의 주소와 같다 (site/public/terms). */
export const SUPPORT_EMAIL = 'yunchan8804@gmail.com'
export const SUPPORT_CATEGORIES = ['audio', 'gpu', 'billing', 'general'] as const
export type SupportCategory = (typeof SUPPORT_CATEGORIES)[number]
export interface SupportDraft {
category: SupportCategory
subject: string
details: string
}
export interface SupportDiagnostics {
appVersion: string | null
platform: string | null
}
export type SupportDraftField = 'subject' | 'details'
export type SupportDraftValidation =
| { ok: true }
| { ok: false; missing: SupportDraftField[] }
/** Windows 셸이 mailto 인자를 잘라내지 않도록 본문 길이를 제한한다. */
export const MAX_MAILTO_BODY_CHARS = 1500
export function isSupportCategory(value: string): value is SupportCategory {
return (SUPPORT_CATEGORIES as readonly string[]).includes(value)
}
export function validateSupportDraft(draft: SupportDraft): SupportDraftValidation {
const missing: SupportDraftField[] = []
if (!draft.subject.trim()) missing.push('subject')
if (!draft.details.trim()) missing.push('details')
return missing.length === 0 ? { ok: true } : { ok: false, missing }
}
export function formatSupportDiagnostics(diag: SupportDiagnostics): string {
const version = diag.appVersion?.trim() || 'unknown'
const platform = diag.platform?.trim() || 'unknown'
return `D3RO Voice ${version} • ${platform}`
}
/**
* 문의 초안 → mailto: URL. 수신·전송은 사용자의 메일 앱이 한다.
* 본문 라벨은 지원 담당자가 읽는 기술 정보라 영어로 고정한다.
*/
export function buildSupportMailto(draft: SupportDraft, diag: SupportDiagnostics): string {
const subject = `[D3RO Voice][${draft.category}] ${draft.subject.trim()}`
const details = draft.details.trim()
const trimmedDetails =
details.length > MAX_MAILTO_BODY_CHARS ? `${details.slice(0, MAX_MAILTO_BODY_CHARS)}…` : details
const body = [
trimmedDetails,
'',
'---',
`Category: ${draft.category}`,
`Diagnostics: ${formatSupportDiagnostics(diag)}`,
].join('\n')
return `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`
}
// ── 포트 ────────────────────────────────────────────────
/** 지원 모달이 의존하는 IO 경계. UI는 이 인터페이스만 안다. */
export interface SupportChannel {
/** 메일 앱 열기를 시도한다. 메인 프로세스가 성공을 돌려줄 때만 true. */
openMail(url: string): Promise<boolean>
getDiagnostics(): Promise<SupportDiagnostics>
}
/** preload `electronAPI.system` 중 이 기능이 쓰는 최소 표면. */
export interface SupportSystemApi {
openExternal(params: { url: string }): Promise<IPCResult<void>>
getVersion(): Promise<IPCResult<string>>
getPlatform(): Promise<IPCResult<string>>
}
async function readResult<T>(call: () => Promise<IPCResult<T>>): Promise<T | null> {
try {
const result = await call()
return result.success ? result.data : null
} catch {
return null
}
}
export function createElectronSupportChannel(api: SupportSystemApi): SupportChannel {
return {
async openMail(url) {
try {
const result = await api.openExternal({ url })
return result.success
} catch {
return false
}
},
async getDiagnostics() {
const [appVersion, platform] = await Promise.all([
readResult(() => api.getVersion()),
readResult(() => api.getPlatform()),
])
return { appVersion, platform }
},
}
}