fix(support): stop faking refund approvals and ticket submissions in the support modal

This commit is contained in:
Yun Chan 2026-09-28 00:54:02 +09:00
parent e243e5282c
commit 819bc9d789
6 changed files with 580 additions and 355 deletions

View file

@ -0,0 +1,175 @@
// src/renderer/components/support/SupportContactPanel.tsx
// 지원 모달 — 이메일 문의 패널.
// 서버 쪽 티켓 시스템이 없으므로 mailto 초안만 연다. 전송 여부는 앱이 알 수 없어서
// "접수됐다"는 문구·티켓 번호를 만들지 않는다.
import React, { useEffect, useState } from 'react'
import { Box, Button, MenuItem, TextField, Typography } from '@mui/material'
import { Activity, Mail } from 'lucide-react'
import { d3roPalette, d3roFontSans, d3roFontMono } from '@d3ro/ui/theme'
import {
SUPPORT_CATEGORIES,
SUPPORT_EMAIL,
buildSupportMailto,
formatSupportDiagnostics,
isSupportCategory,
validateSupportDraft,
type SupportChannel,
type SupportDiagnostics,
type SupportDraft,
type SupportDraftField,
} from './support-contact'
import { SUPPORT_CATEGORY_LABELS, SUPPORT_COPY } from './support-copy'
interface SupportContactPanelProps {
channel: SupportChannel
}
type OpenStatus = 'idle' | 'opening' | 'opened' | 'failed'
const EMPTY_DRAFT: SupportDraft = { category: 'general', subject: '', details: '' }
const UNKNOWN_DIAGNOSTICS: SupportDiagnostics = { appVersion: null, platform: null }
const FIELD_SX = { '& .MuiInputBase-root': { fontSize: '12px' } } as const
export function SupportContactPanel({ channel }: SupportContactPanelProps): React.ReactElement {
const [draft, setDraft] = useState<SupportDraft>(EMPTY_DRAFT)
const [missing, setMissing] = useState<SupportDraftField[]>([])
const [diagnostics, setDiagnostics] = useState<SupportDiagnostics>(UNKNOWN_DIAGNOSTICS)
const [status, setStatus] = useState<OpenStatus>('idle')
useEffect(() => {
let cancelled = false
void channel.getDiagnostics().then((diag) => {
if (!cancelled) setDiagnostics(diag)
})
return () => {
cancelled = true
}
}, [channel])
const updateDraft = (patch: Partial<SupportDraft>): void => {
setDraft((prev) => ({ ...prev, ...patch }))
setStatus('idle')
}
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault()
const validation = validateSupportDraft(draft)
if (!validation.ok) {
setMissing(validation.missing)
return
}
setMissing([])
setStatus('opening')
const opened = await channel.openMail(buildSupportMailto(draft, diagnostics))
setStatus(opened ? 'opened' : 'failed')
}
const subjectMissing = missing.includes('subject')
const detailsMissing = missing.includes('details')
return (
<Box sx={{ height: 420, overflowY: 'auto' }}>
<form onSubmit={(e) => void handleSubmit(e)} noValidate style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '12px', color: d3roPalette.text.secondary, lineHeight: 1.6 }}>
{SUPPORT_COPY.contactIntro}
</Typography>
<TextField
select
size="small"
label={SUPPORT_COPY.fieldCategory}
value={draft.category}
onChange={(e) => {
if (isSupportCategory(e.target.value)) updateDraft({ category: e.target.value })
}}
sx={FIELD_SX}
>
{SUPPORT_CATEGORIES.map((category) => (
<MenuItem key={category} value={category}>
{SUPPORT_CATEGORY_LABELS[category]}
</MenuItem>
))}
</TextField>
<TextField
size="small"
label={SUPPORT_COPY.fieldSubject}
placeholder={SUPPORT_COPY.fieldSubjectPlaceholder}
value={draft.subject}
onChange={(e) => updateDraft({ subject: e.target.value })}
required
error={subjectMissing}
helperText={subjectMissing ? SUPPORT_COPY.requiredError : undefined}
sx={FIELD_SX}
/>
<TextField
multiline
rows={4}
size="small"
label={SUPPORT_COPY.fieldDetails}
placeholder={SUPPORT_COPY.fieldDetailsPlaceholder}
value={draft.details}
onChange={(e) => updateDraft({ details: e.target.value })}
required
error={detailsMissing}
helperText={detailsMissing ? SUPPORT_COPY.requiredError : undefined}
sx={FIELD_SX}
/>
<Box
sx={{
p: 1.5,
borderRadius: '8px',
bgcolor: 'var(--d3-accent-glow)',
border: `1px solid ${d3roPalette.accent.dim}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Activity size={14} color={d3roPalette.accent.light} />
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: d3roPalette.accent.light }}>
{SUPPORT_COPY.diagnosticsLabel}
</Typography>
</Box>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '10px', color: d3roPalette.text.dimLabel }}>
{formatSupportDiagnostics(diagnostics)}
</Typography>
</Box>
<Button
type="submit"
disabled={status === 'opening'}
startIcon={<Mail size={14} />}
sx={{
py: 1,
bgcolor: d3roPalette.tag.purple,
color: 'var(--d3-text-inverse)',
fontWeight: 500,
fontSize: '12px',
borderRadius: '10px',
'&:hover': { bgcolor: 'var(--d3-tag-purple)' },
}}
>
{SUPPORT_COPY.submit}
</Button>
{status === 'opened' && (
<Typography role="status" sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: d3roPalette.text.secondary }}>
{SUPPORT_COPY.openedNotice}
</Typography>
)}
{status === 'failed' && (
<Typography role="alert" sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: d3roPalette.text.secondary }}>
{SUPPORT_COPY.openFailed}
</Typography>
)}
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: d3roPalette.text.dimLabel }}>
{SUPPORT_COPY.directAddress}: {SUPPORT_EMAIL}
</Typography>
</form>
</Box>
)
}

View file

@ -0,0 +1,61 @@
// src/renderer/components/support/SupportFaqPanel.tsx
// 지원 모달 — FAQ 검색 패널 (정적 안내만, IO 없음)
import React, { useMemo, useState } from 'react'
import { Box, TextField, Typography } from '@mui/material'
import { Search } from 'lucide-react'
import { d3roPalette, d3roFontSans, d3roRadius } from '@d3ro/ui/theme'
import { SUPPORT_COPY, SUPPORT_FAQS } from './support-copy'
export function SupportFaqPanel(): React.ReactElement {
const [searchQuery, setSearchQuery] = useState('')
const filteredFaqs = useMemo(() => {
const query = searchQuery.trim().toLowerCase()
if (!query) return SUPPORT_FAQS
return SUPPORT_FAQS.filter((f) => f.q.toLowerCase().includes(query) || f.a.toLowerCase().includes(query))
}, [searchQuery])
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: 420, gap: 1.5 }}>
<TextField
size="small"
placeholder={SUPPORT_COPY.faqSearchPlaceholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
InputProps={{
startAdornment: <Search size={14} color={d3roPalette.text.dimLabel} style={{ marginRight: 8 }} />,
}}
sx={{
'& .MuiInputBase-root': {
bgcolor: 'var(--d3-bg-inset-soft)',
color: 'var(--d3-text-inverse)',
fontSize: '12px',
borderRadius: '10px',
},
}}
/>
<Box sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 1.5, pr: 0.5 }}>
{filteredFaqs.length === 0 && (
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '12px', color: d3roPalette.text.dimLabel }}>
{SUPPORT_COPY.faqEmpty}
</Typography>
)}
{filteredFaqs.map((faq) => (
<Box
key={faq.q}
sx={{ p: 2, borderRadius: d3roRadius.inner, bgcolor: 'var(--d3-bg-card-soft)', border: `1px solid ${d3roPalette.glass.hairline}` }}
>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '13px', fontWeight: 500, color: d3roPalette.text.primary, mb: 0.75 }}>
Q. {faq.q}
</Typography>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: d3roPalette.text.secondary, lineHeight: 1.6, whiteSpace: 'pre-line' }}>
{faq.a}
</Typography>
</Box>
))}
</Box>
</Box>
)
}

View file

@ -1,109 +1,34 @@
// src/renderer/components/support/SupportModal.tsx
// In-App Customer Assistance (CA/CS) & Diagnostics Modal
// 고객지원 모달 — FAQ + 이메일 문의.
//
// 환불 자격 조회·결제 취소·티켓 접수 백엔드가 없으므로 그런 기능을 흉내 내지 않는다.
// 문의는 사용자의 메일 앱(mailto:)으로만 넘기고, 서버가 확인하지 않은 성공은 표시하지 않는다.
import React, { useState } from 'react'
import {
Modal,
Box,
Typography,
Button,
IconButton,
Tabs,
Tab,
TextField,
MenuItem,
CircularProgress,
} from '@mui/material'
import {
Headphones,
Bot,
Activity,
Ticket,
RefreshCw,
Search,
X,
Send,
CheckCircle,
HelpCircle,
} from 'lucide-react'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import React, { useMemo, useState } from 'react'
import { Modal, Box, Typography, IconButton, Tabs, Tab } from '@mui/material'
import { Headphones, HelpCircle, Mail, X } from 'lucide-react'
import { d3roPalette, d3roFontSans, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { createElectronSupportChannel, type SupportChannel } from './support-contact'
import { SUPPORT_COPY } from './support-copy'
import { SupportFaqPanel } from './SupportFaqPanel'
import { SupportContactPanel } from './SupportContactPanel'
interface SupportModalProps {
open: boolean
onClose: () => void
/** 테스트·다른 셸에서 IO를 바꿔 끼우는 포트. 기본은 Electron preload. */
channel?: SupportChannel
}
const FAQS = [
{
q: '마이크 음성 인식이 작동하지 않거나 소리가 입력되지 않습니다.',
a: '1. Windows 설정 > 개인 정보 및 보안 > 마이크에서 "데스크톱 앱이 마이크에 액세스하도록 허용"이 켜져 있는지 확인하세요.\n2. D3RO Voice 설정 > STT 탭에서 사용 중인 실제 입력 마이크 장치(Realtek 또는 USB 인터페이스)가 선택되었는지 확인합니다.\n3. 시스템 진단 탭에서 텔레메트리 로그를 확인하세요.',
},
{
q: 'NVIDIA CUDA GPU 가속을 어떻게 활성화하나요?',
a: 'NVIDIA GTX 1060 / RTX 2060 이상 그래픽 카드가 장착되어 있다면 faster-whisper CTranslate2 엔진이 자동으로 CUDA v12 런타임을 감지하여 GPU 모드로 동작합니다. VRAM 4GB 이상 시 large-v3-turbo 모델에서 130ms 초저지연을 지원합니다.',
},
{
q: '7일 이내 환불 규정 및 자동 환불 신청 방법은 어떻게 되나요?',
a: '결제일로부터 7일 이내 및 클라우드 AI 토큰 사용량이 10% 미만인 경우 무조건 100% 전액 환불이 가능합니다. 이 모달의 "7일 자동 환불 확인" 탭에서 즉시 자격을 조회하고 취소할 수 있습니다.',
},
{
q: '법인 전자세금계산서 발행은 어떻게 신청하나요?',
a: '토스페이먼츠 또는 포트원 결제 시 사업자등록번호를 기재하시면 결제 즉시 국세청 홈택스로 전자세금계산서가 자동 발행됩니다. 사업자 정보 변경이 필요하신 경우 티켓을 접수해 주세요.',
},
]
type SupportTab = 'faq' | 'contact'
export function SupportModal({ open, onClose }: SupportModalProps): React.ReactElement {
const [tabIndex, setTabIndex] = useState(0)
const [searchQuery, setSearchQuery] = useState('')
const [chatMessages, setChatMessages] = useState<Array<{ role: 'ai' | 'user'; text: string }>>([
{
role: 'ai',
text: '안녕하세요! D3RO Voice 고객지원 AI 어시스턴트입니다. 마이크 설정, 모델 구동, 결제/환불 등 무엇이든 질문해 주세요.',
},
])
const [inputQuestion, setInputQuestion] = useState('')
const [isAiThinking, setIsAiThinking] = useState(false)
const [ticketSubmitted, setTicketSubmitted] = useState<string | null>(null)
const [refundStatus, setRefundStatus] = useState<string | null>(null)
const handleAskAI = (questionText?: string) => {
const q = questionText ?? inputQuestion
if (!q.trim()) return
setChatMessages((prev) => [...prev, { role: 'user', text: q }])
if (!questionText) setInputQuestion('')
setIsAiThinking(true)
setTimeout(() => {
let answer = '문의해 주셔서 감사합니다. 상세한 진단 분석을 위해 시스템 텔레메트리 로그를 수집했습니다. 추가 도움이 필요하시면 [티켓 접수] 탭을 통해 기술 지원팀 엔지니어에게 바로 연결해 드릴 수 있습니다.'
if (q.includes('마이크') || q.includes('소리') || q.includes('인식')) {
answer = '마이크 입력 진단 결과:\n1. Windows 마이크 개인정보 권한이 활성화되어 있는지 확인해 주세요.\n2. D3RO 설정 > STT에서 올바른 입력 마이크를 선택했는지 점검하세요.\n3. 노이즈 억제 필터를 기본값으로 재설정해보시기 바랍니다.'
} else if (q.includes('환불') || q.includes('취소')) {
answer = '환불 규정 안내: 결제 후 7일 이내, 클라우드 사용량 10% 미만일 경우 100% 자동 환불이 가능합니다. 상단 "7일 자동 환불" 탭에서 원클릭으로 검증 후 즉시 취소할 수 있습니다.'
} else if (q.includes('gpu') || q.includes('cuda') || q.includes('속도')) {
answer = 'CUDA 가속 안내: faster-whisper CTranslate2 엔진이 로컬 GPU를 사용하여 대기시간을 140ms 이하로 단축합니다. VRAM 사용량이 4GB 이상 확보되었는지 확인하세요.'
}
setChatMessages((prev) => [...prev, { role: 'ai', text: answer }])
setIsAiThinking(false)
}, 400)
}
const handleRefundCheck = () => {
setRefundStatus('checking')
setTimeout(() => {
setRefundStatus('eligible')
}, 800)
}
const handleTicketSubmit = (e: React.FormEvent) => {
e.preventDefault()
const tckId = 'TCK-' + Math.floor(1000 + Math.random() * 9000)
setTicketSubmitted(tckId)
}
const filteredFaqs = FAQS.filter(
(f) => f.q.toLowerCase().includes(searchQuery.toLowerCase()) || f.a.toLowerCase().includes(searchQuery.toLowerCase())
export function SupportModal({ open, onClose, channel }: SupportModalProps): React.ReactElement {
const { t } = useI18n()
const [tab, setTab] = useState<SupportTab>('faq')
const supportChannel = useMemo(
() => channel ?? createElectronSupportChannel(window.electronAPI.system),
[channel],
)
return (
@ -134,13 +59,13 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Headphones size={20} color={d3roPalette.tag.purple} />
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '17px', fontWeight: 600, color: d3roPalette.text.primary }}>
D3RO Voice Customer Assistance (CA/CS)
{SUPPORT_COPY.title}
</Typography>
</Box>
<IconButton
size="small"
onClick={onClose}
aria-label="닫기"
aria-label={t('common.close')}
sx={{ cursor: 'pointer', color: d3roPalette.text.dimLabel, '&:hover': { color: d3roPalette.text.primary } }}
>
<X size={18} />
@ -149,8 +74,8 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
{/* Navigation Tabs */}
<Tabs
value={tabIndex}
onChange={(_, v) => setTabIndex(v)}
value={tab}
onChange={(_, v: SupportTab) => setTab(v)}
sx={{
minHeight: 'auto',
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
@ -168,262 +93,12 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
'& .MuiTabs-indicator': { bgcolor: d3roPalette.tag.purple },
}}
>
<Tab icon={<Bot size={14} />} iconPosition="start" label="AI 어시스턴트" />
<Tab icon={<HelpCircle size={14} />} iconPosition="start" label="자주 묻는 질문 (FAQ)" />
<Tab icon={<Ticket size={14} />} iconPosition="start" label="티켓 접수 & 진단" />
<Tab icon={<RefreshCw size={14} />} iconPosition="start" label="7일 자동 환불 확인" />
<Tab value="faq" icon={<HelpCircle size={14} />} iconPosition="start" label={SUPPORT_COPY.tabFaq} />
<Tab value="contact" icon={<Mail size={14} />} iconPosition="start" label={SUPPORT_COPY.tabContact} />
</Tabs>
{/* TAB 0: AI BOT CHAT */}
{tabIndex === 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', height: 420, gap: 1.5 }}>
<Box
sx={{
flex: 1,
overflowY: 'auto',
p: 2,
borderRadius: d3roRadius.inner,
bgcolor: 'var(--d3-bg-inset-soft)',
border: `1px solid ${d3roPalette.glass.hairline}`,
display: 'flex',
flexDirection: 'column',
gap: 1.5,
}}
>
{chatMessages.map((msg, idx) => (
<Box
key={idx}
sx={{
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
maxWidth: '85%',
p: 1.5,
borderRadius: msg.role === 'user' ? '14px 14px 2px 14px' : '14px 14px 14px 2px',
bgcolor: msg.role === 'user' ? d3roPalette.accent.main : 'var(--d3-bg-raised-soft)',
color: 'var(--d3-text-inverse)',
fontSize: '12px',
fontFamily: d3roFontSans,
lineHeight: 1.5,
whiteSpace: 'pre-line',
boxShadow: d3roShadow.card,
}}
>
{msg.text}
</Box>
))}
{isAiThinking && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: d3roPalette.text.dimLabel, fontSize: '11px', fontFamily: d3roFontMono }}>
<CircularProgress size={12} color="inherit" /> AI 어시스턴트가 답변을 생성하고 있습니다...
</Box>
)}
</Box>
{/* Quick Prompts */}
<Box sx={{ display: 'flex', gap: 1, overflowX: 'auto', pb: 0.5 }}>
<Button
size="small"
onClick={() => handleAskAI('마이크 인식이 안 돼요')}
sx={{ fontSize: '10px', textTransform: 'none', color: d3roPalette.text.secondary, bgcolor: 'var(--d3-overlay-strong)', borderRadius: '999px', px: 1.5, flexShrink: 0 }}
>
🎤 마이크 인식 오류
</Button>
<Button
size="small"
onClick={() => handleAskAI('CUDA GPU 가속 설정 방법')}
sx={{ fontSize: '10px', textTransform: 'none', color: d3roPalette.text.secondary, bgcolor: 'var(--d3-overlay-strong)', borderRadius: '999px', px: 1.5, flexShrink: 0 }}
>
⚡ CUDA GPU 가속
</Button>
<Button
size="small"
onClick={() => handleAskAI('환불 신청 규정')}
sx={{ fontSize: '10px', textTransform: 'none', color: d3roPalette.text.secondary, bgcolor: 'var(--d3-overlay-strong)', borderRadius: '999px', px: 1.5, flexShrink: 0 }}
>
💳 환불 신청 규정
</Button>
</Box>
{/* Input Bar */}
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
fullWidth
size="small"
placeholder="문의하실 내용을 입력하세요..."
value={inputQuestion}
onChange={(e) => setInputQuestion(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAskAI()}
sx={{
'& .MuiInputBase-root': {
bgcolor: 'var(--d3-bg-inset-soft)',
color: 'var(--d3-text-inverse)',
fontSize: '12px',
fontFamily: d3roFontSans,
borderRadius: '10px',
},
}}
/>
<Button
onClick={() => handleAskAI()}
sx={{
bgcolor: d3roPalette.tag.purple,
color: 'var(--d3-text-inverse)',
px: 2.5,
borderRadius: '10px',
fontFamily: d3roFontSans,
fontWeight: 500,
fontSize: '12px',
'&:hover': { bgcolor: 'var(--d3-tag-purple)' },
}}
>
<Send size={14} />
</Button>
</Box>
</Box>
)}
{/* TAB 1: FAQS */}
{tabIndex === 1 && (
<Box sx={{ display: 'flex', flexDirection: 'column', height: 420, gap: 1.5 }}>
<TextField
size="small"
placeholder="자주 묻는 질문 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
InputProps={{
startAdornment: <Search size={14} color={d3roPalette.text.dimLabel} style={{ marginRight: 8 }} />,
}}
sx={{
'& .MuiInputBase-root': {
bgcolor: 'var(--d3-bg-inset-soft)',
color: 'var(--d3-text-inverse)',
fontSize: '12px',
borderRadius: '10px',
},
}}
/>
<Box sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 1.5, pr: 0.5 }}>
{filteredFaqs.map((faq, idx) => (
<Box key={idx} sx={{ p: 2, borderRadius: d3roRadius.inner, bgcolor: 'var(--d3-bg-card-soft)', border: `1px solid ${d3roPalette.glass.hairline}` }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '13px', fontWeight: 500, color: d3roPalette.text.primary, mb: 0.75 }}>
Q. {faq.q}
</Typography>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: d3roPalette.text.secondary, lineHeight: 1.6, whiteSpace: 'pre-line' }}>
{faq.a}
</Typography>
</Box>
))}
</Box>
</Box>
)}
{/* TAB 2: TICKET SUBMISSION */}
{tabIndex === 2 && (
<Box sx={{ height: 420, overflowY: 'auto' }}>
{ticketSubmitted ? (
<Box sx={{ textAlign: 'center', py: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1.5 }}>
<CheckCircle size={48} color={d3roPalette.tag.green} />
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '18px', fontWeight: 600, color: 'var(--d3-text-inverse)' }}>
티켓이 성공적으로 접수되었습니다!
</Typography>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '12px', color: d3roPalette.accent.light }}>
Ticket ID: {ticketSubmitted}
</Typography>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '12px', color: d3roPalette.text.secondary, maxWidth: 400 }}>
시스템 진단 로그와 함께 기술 지원팀에 전달되었습니다. 30분 이내에 이메일로 답변을 전송해 드립니다.
</Typography>
<Button onClick={() => setTicketSubmitted(null)} sx={{ mt: 2, fontSize: '11px', color: d3roPalette.accent.light }}>
다른 문의 작성하기
</Button>
</Box>
) : (
<form onSubmit={handleTicketSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
<TextField select size="small" label="카테고리" defaultValue="hardware_audio" sx={{ '& .MuiInputBase-root': { fontSize: '12px' } }}>
<MenuItem value="hardware_audio">마이크 / 오디오 하드웨어</MenuItem>
<MenuItem value="cuda_gpu">CUDA GPU / 모델 로딩 오류</MenuItem>
<MenuItem value="billing">결제 / 세금계산서 / 환불</MenuItem>
<MenuItem value="general">기타 기술 문의</MenuItem>
</TextField>
<TextField select size="small" label="우선순위" defaultValue="high" sx={{ '& .MuiInputBase-root': { fontSize: '12px' } }}>
<MenuItem value="urgent">긴급 (SLA: 30분)</MenuItem>
<MenuItem value="high">높음 (SLA: 2시간)</MenuItem>
<MenuItem value="normal">보통 (SLA: 24시간)</MenuItem>
</TextField>
</Box>
<TextField size="small" label="회신받을 이메일" placeholder="your-email@company.com" required sx={{ '& .MuiInputBase-root': { fontSize: '12px' } }} />
<TextField size="small" label="문의 제목" placeholder="문제를 요약해 주세요" required sx={{ '& .MuiInputBase-root': { fontSize: '12px' } }} />
<TextField multiline rows={3} size="small" label="상세 내용" placeholder="오류 발생 상황을 기재해 주세요..." required sx={{ '& .MuiInputBase-root': { fontSize: '12px' } }} />
<Box sx={{ p: 1.5, borderRadius: '8px', bgcolor: 'var(--d3-accent-glow)', border: `1px solid ${d3roPalette.accent.dim}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Activity size={14} color={d3roPalette.accent.light} />
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: d3roPalette.accent.light }}>
로컬 시스템 진단 텔레메트리 자동 첨부됨
</Typography>
</Box>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '10px', color: d3roPalette.text.dimLabel }}>
Windows 11 • RTX 3080 • faster-whisper
</Typography>
</Box>
<Button type="submit" sx={{ py: 1, bgcolor: d3roPalette.tag.purple, color: 'var(--d3-text-inverse)', fontWeight: 500, fontSize: '12px', borderRadius: '10px', '&:hover': { bgcolor: 'var(--d3-tag-purple)' } }}>
티켓 접수하기
</Button>
</form>
)}
</Box>
)}
{/* TAB 3: 7-DAY REFUND CHECKER */}
{tabIndex === 3 && (
<Box sx={{ height: 420, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', p: 2, bgcolor: 'var(--d3-bg-inset-soft)', borderRadius: d3roRadius.inner }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 500, color: d3roPalette.text.primary }}>
7일 이내 무조건 100% 자동 환불 확인
</Typography>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '12px', color: d3roPalette.text.secondary, lineHeight: 1.6 }}>
D3RO Voice는 결제일로부터 7일 이내 및 클라우드 AI 정제 토큰 사용량이 10% 미만인 경우 상담원 연결 없이 원클릭으로 카드 승인 취소 및 전액 환불을 보장합니다.
</Typography>
{refundStatus === 'eligible' && (
<Box sx={{ p: 2, borderRadius: '10px', bgcolor: 'var(--d3-status-success)', border: '1px solid var(--d3-status-success)', display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '13px', fontWeight: 500, color: d3roPalette.tag.green }}>
✓ 100% 전액 환불 대상입니다!
</Typography>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: 'var(--d3-text-inverse)' }}>
• 결제일: 최근 2일 전 (자격 충족)
</Typography>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: 'var(--d3-text-inverse)' }}>
• 클라우드 사용량: 0% (자격 충족)
</Typography>
<Button
onClick={() => alert('Payple 결제 취소가 접수되었습니다. 영업일 기준 2~3일 내 카드사 취소 처리됩니다.')}
sx={{ mt: 1, bgcolor: d3roPalette.tag.green, color: 'var(--d3-scrim)', fontWeight: 500, fontSize: '12px', borderRadius: '8px' }}
>
즉시 전액 환불 승인
</Button>
</Box>
)}
</Box>
{!refundStatus && (
<Button
onClick={handleRefundCheck}
sx={{
py: 1.2,
bgcolor: d3roPalette.accent.main,
color: 'var(--d3-text-inverse)',
fontWeight: 500,
fontSize: '12px',
borderRadius: '10px',
'&:hover': { bgcolor: d3roPalette.accent.light },
}}
>
환불 자격 자동 조회하기
</Button>
)}
</Box>
)}
{tab === 'faq' && <SupportFaqPanel />}
{tab === 'contact' && <SupportContactPanel channel={supportChannel} />}
</Box>
</Modal>
)

View file

@ -0,0 +1,115 @@
// 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 }
},
}
}

View file

@ -0,0 +1,60 @@
// src/renderer/components/support/support-copy.ts
// 지원 모달 문구 모음.
//
// TODO(i18n): 공용 로케일(packages/i18n/src/locales/*.json)에 `support.*` 키가 아직 없다.
// 문구를 이 한 파일에 모아 두었으니, 키를 추가할 때 이 객체를 t() 호출로 옮기면 된다.
// 결제·환불·티켓 처리를 약속하는 문구는 넣지 않는다 (백엔드가 없다).
import type { SupportCategory } from './support-contact'
export interface SupportFaq {
q: string
a: string
}
export const SUPPORT_FAQS: readonly SupportFaq[] = [
{
q: '마이크 음성 인식이 작동하지 않거나 소리가 입력되지 않습니다.',
a: '1. Windows 설정 > 개인 정보 및 보안 > 마이크에서 "데스크톱 앱이 마이크에 액세스하도록 허용"이 켜져 있는지 확인하세요.\n2. D3RO Voice 설정 > STT 탭에서 실제로 쓰는 입력 마이크 장치가 선택되었는지 확인하세요.\n3. 그래도 해결되지 않으면 "문의하기" 탭에서 이메일로 알려 주세요.',
},
{
q: 'NVIDIA CUDA GPU 가속을 어떻게 활성화하나요?',
a: 'NVIDIA 그래픽 카드와 CUDA 런타임이 준비되어 있으면 faster-whisper(CTranslate2) 엔진이 GPU를 감지해 GPU 모드로 동작합니다. 큰 모델일수록 VRAM이 더 필요합니다.',
},
{
q: '환불은 어떻게 요청하나요?',
a: '환불과 결제 취소는 구매 화면에 안내된 조건과 해당 결제 처리자(Google Play 등)의 정책을 따릅니다. 앱 안에서 자동으로 환불되지는 않습니다. "문의하기" 탭에서 "결제 / 환불" 분류로 이메일을 보내 주시면 확인 후 답변드립니다.',
},
{
q: '법인 전자세금계산서 발행은 어떻게 요청하나요?',
a: '"문의하기" 탭에서 "결제 / 환불" 분류로 사업자 정보와 결제 내역을 적어 이메일을 보내 주세요.',
},
]
export const SUPPORT_CATEGORY_LABELS: Record<SupportCategory, string> = {
audio: '마이크 / 오디오',
gpu: 'CUDA GPU / 모델 로딩 오류',
billing: '결제 / 환불',
general: '기타 문의',
}
export const SUPPORT_COPY = {
title: '고객지원',
tabFaq: '자주 묻는 질문 (FAQ)',
tabContact: '문의하기',
faqSearchPlaceholder: '자주 묻는 질문 검색...',
faqEmpty: '일치하는 질문이 없습니다.',
contactIntro:
'문의는 이메일로 받습니다. 아래 내용을 채우고 버튼을 누르면 메일 앱에 초안이 열립니다. 메일 앱에서 직접 보내야 문의가 전달됩니다.',
fieldCategory: '분류',
fieldSubject: '문의 제목',
fieldSubjectPlaceholder: '문제를 요약해 주세요',
fieldDetails: '상세 내용',
fieldDetailsPlaceholder: '어떤 상황에서 문제가 생겼는지 적어 주세요...',
requiredError: '필수 입력 항목입니다.',
diagnosticsLabel: '메일 본문에 포함될 진단 정보',
submit: '메일 앱에서 문의 작성하기',
openedNotice: '메일 앱에 초안을 열었습니다. 메일 앱에서 보내기를 눌러야 문의가 전달됩니다.',
openFailed: '메일 앱을 열지 못했습니다. 아래 주소로 직접 이메일을 보내 주세요.',
directAddress: '지원 이메일',
} as const