fix(support): stop faking refund approvals and ticket submissions in the support modal
This commit is contained in:
parent
e243e5282c
commit
819bc9d789
6 changed files with 580 additions and 355 deletions
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,109 +1,34 @@
|
||||||
// src/renderer/components/support/SupportModal.tsx
|
// src/renderer/components/support/SupportModal.tsx
|
||||||
// In-App Customer Assistance (CA/CS) & Diagnostics Modal
|
// 고객지원 모달 — FAQ + 이메일 문의.
|
||||||
|
//
|
||||||
|
// 환불 자격 조회·결제 취소·티켓 접수 백엔드가 없으므로 그런 기능을 흉내 내지 않는다.
|
||||||
|
// 문의는 사용자의 메일 앱(mailto:)으로만 넘기고, 서버가 확인하지 않은 성공은 표시하지 않는다.
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useMemo, useState } from 'react'
|
||||||
import {
|
import { Modal, Box, Typography, IconButton, Tabs, Tab } from '@mui/material'
|
||||||
Modal,
|
import { Headphones, HelpCircle, Mail, X } from 'lucide-react'
|
||||||
Box,
|
import { d3roPalette, d3roFontSans, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
|
||||||
Typography,
|
import { useI18n } from '@d3ro/i18n'
|
||||||
Button,
|
import { createElectronSupportChannel, type SupportChannel } from './support-contact'
|
||||||
IconButton,
|
import { SUPPORT_COPY } from './support-copy'
|
||||||
Tabs,
|
import { SupportFaqPanel } from './SupportFaqPanel'
|
||||||
Tab,
|
import { SupportContactPanel } from './SupportContactPanel'
|
||||||
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'
|
|
||||||
|
|
||||||
interface SupportModalProps {
|
interface SupportModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
/** 테스트·다른 셸에서 IO를 바꿔 끼우는 포트. 기본은 Electron preload. */
|
||||||
|
channel?: SupportChannel
|
||||||
}
|
}
|
||||||
|
|
||||||
const FAQS = [
|
type SupportTab = 'faq' | 'contact'
|
||||||
{
|
|
||||||
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: '토스페이먼츠 또는 포트원 결제 시 사업자등록번호를 기재하시면 결제 즉시 국세청 홈택스로 전자세금계산서가 자동 발행됩니다. 사업자 정보 변경이 필요하신 경우 티켓을 접수해 주세요.',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export function SupportModal({ open, onClose }: SupportModalProps): React.ReactElement {
|
export function SupportModal({ open, onClose, channel }: SupportModalProps): React.ReactElement {
|
||||||
const [tabIndex, setTabIndex] = useState(0)
|
const { t } = useI18n()
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [tab, setTab] = useState<SupportTab>('faq')
|
||||||
const [chatMessages, setChatMessages] = useState<Array<{ role: 'ai' | 'user'; text: string }>>([
|
const supportChannel = useMemo(
|
||||||
{
|
() => channel ?? createElectronSupportChannel(window.electronAPI.system),
|
||||||
role: 'ai',
|
[channel],
|
||||||
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())
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -134,13 +59,13 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
<Headphones size={20} color={d3roPalette.tag.purple} />
|
<Headphones size={20} color={d3roPalette.tag.purple} />
|
||||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '17px', fontWeight: 600, color: d3roPalette.text.primary }}>
|
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '17px', fontWeight: 600, color: d3roPalette.text.primary }}>
|
||||||
D3RO Voice Customer Assistance (CA/CS)
|
{SUPPORT_COPY.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="닫기"
|
aria-label={t('common.close')}
|
||||||
sx={{ cursor: 'pointer', color: d3roPalette.text.dimLabel, '&:hover': { color: d3roPalette.text.primary } }}
|
sx={{ cursor: 'pointer', color: d3roPalette.text.dimLabel, '&:hover': { color: d3roPalette.text.primary } }}
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
|
|
@ -149,8 +74,8 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
|
||||||
|
|
||||||
{/* Navigation Tabs */}
|
{/* Navigation Tabs */}
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tabIndex}
|
value={tab}
|
||||||
onChange={(_, v) => setTabIndex(v)}
|
onChange={(_, v: SupportTab) => setTab(v)}
|
||||||
sx={{
|
sx={{
|
||||||
minHeight: 'auto',
|
minHeight: 'auto',
|
||||||
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
|
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
|
||||||
|
|
@ -168,262 +93,12 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
|
||||||
'& .MuiTabs-indicator': { bgcolor: d3roPalette.tag.purple },
|
'& .MuiTabs-indicator': { bgcolor: d3roPalette.tag.purple },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tab icon={<Bot size={14} />} iconPosition="start" label="AI 어시스턴트" />
|
<Tab value="faq" icon={<HelpCircle size={14} />} iconPosition="start" label={SUPPORT_COPY.tabFaq} />
|
||||||
<Tab icon={<HelpCircle size={14} />} iconPosition="start" label="자주 묻는 질문 (FAQ)" />
|
<Tab value="contact" icon={<Mail size={14} />} iconPosition="start" label={SUPPORT_COPY.tabContact} />
|
||||||
<Tab icon={<Ticket size={14} />} iconPosition="start" label="티켓 접수 & 진단" />
|
|
||||||
<Tab icon={<RefreshCw size={14} />} iconPosition="start" label="7일 자동 환불 확인" />
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* TAB 0: AI BOT CHAT */}
|
{tab === 'faq' && <SupportFaqPanel />}
|
||||||
{tabIndex === 0 && (
|
{tab === 'contact' && <SupportContactPanel channel={supportChannel} />}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
115
apps/desktop/src/renderer/components/support/support-contact.ts
Normal file
115
apps/desktop/src/renderer/components/support/support-contact.ts
Normal 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 }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
60
apps/desktop/src/renderer/components/support/support-copy.ts
Normal file
60
apps/desktop/src/renderer/components/support/support-copy.ts
Normal 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
|
||||||
139
apps/desktop/tests/unit/support-contact-redteam-r1-27.test.ts
Normal file
139
apps/desktop/tests/unit/support-contact-redteam-r1-27.test.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
// tests/unit/support-contact-redteam-r1-27.test.ts
|
||||||
|
// 고객지원 모달이 가짜 환불 승인·가짜 티켓 접수를 표시하던 결함의 회귀 테스트.
|
||||||
|
// - 모달 소스에 가짜 흐름(무조건 eligible, alert 환불, 랜덤 티켓 번호, 하드코딩 진단)이 없어야 한다.
|
||||||
|
// - 문의는 mailto 초안으로만 나가고, 메인 프로세스가 실패를 돌려주면 성공으로 보지 않는다.
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { readFileSync, readdirSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import {
|
||||||
|
MAX_MAILTO_BODY_CHARS,
|
||||||
|
SUPPORT_EMAIL,
|
||||||
|
buildSupportMailto,
|
||||||
|
createElectronSupportChannel,
|
||||||
|
formatSupportDiagnostics,
|
||||||
|
isSupportCategory,
|
||||||
|
validateSupportDraft,
|
||||||
|
type SupportSystemApi,
|
||||||
|
} from '../../src/renderer/components/support/support-contact'
|
||||||
|
|
||||||
|
const SUPPORT_DIR = join(__dirname, '../../src/renderer/components/support')
|
||||||
|
|
||||||
|
function supportSources(): Array<{ name: string; text: string }> {
|
||||||
|
return readdirSync(SUPPORT_DIR)
|
||||||
|
.filter((name) => /\.(ts|tsx)$/.test(name))
|
||||||
|
.map((name) => ({ name, text: readFileSync(join(SUPPORT_DIR, name), 'utf8') }))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SupportModal — 가짜 처리 흐름 제거', () => {
|
||||||
|
it('환불 승인·티켓 번호·하드코딩 진단을 만들어내는 코드가 없다', () => {
|
||||||
|
const forbidden: RegExp[] = [
|
||||||
|
/\balert\s*\(/,
|
||||||
|
/Math\.random/,
|
||||||
|
/'eligible'/,
|
||||||
|
/TCK-/,
|
||||||
|
/RTX 3080/,
|
||||||
|
/환불 승인/,
|
||||||
|
/전액 환불 대상/,
|
||||||
|
/성공적으로 접수/,
|
||||||
|
/Payple/,
|
||||||
|
]
|
||||||
|
for (const { name, text } of supportSources()) {
|
||||||
|
for (const pattern of forbidden) {
|
||||||
|
expect(pattern.test(text), `${name} contains ${pattern}`).toBe(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('모달은 IO 포트를 통해 실제 시스템 API로 연결된다', () => {
|
||||||
|
const modal = readFileSync(join(SUPPORT_DIR, 'SupportModal.tsx'), 'utf8')
|
||||||
|
expect(modal).toContain('createElectronSupportChannel(window.electronAPI.system)')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('support-contact 정책', () => {
|
||||||
|
const diag = { appVersion: '1.9.0', platform: 'win32' }
|
||||||
|
|
||||||
|
it('제목·내용이 비면 검증에 실패한다', () => {
|
||||||
|
expect(validateSupportDraft({ category: 'general', subject: ' ', details: '' })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
missing: ['subject', 'details'],
|
||||||
|
})
|
||||||
|
expect(validateSupportDraft({ category: 'billing', subject: 'a', details: 'b' })).toEqual({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mailto URL에 수신 주소·분류·실제 진단 정보를 인코딩해 담는다', () => {
|
||||||
|
const url = buildSupportMailto({ category: 'billing', subject: '환불 & 문의', details: 'line1\nline2' }, diag)
|
||||||
|
expect(url.startsWith(`mailto:${SUPPORT_EMAIL}?subject=`)).toBe(true)
|
||||||
|
const params = new URLSearchParams(url.slice(url.indexOf('?') + 1))
|
||||||
|
expect(params.get('subject')).toBe('[D3RO Voice][billing] 환불 & 문의')
|
||||||
|
const body = params.get('body') ?? ''
|
||||||
|
expect(body).toContain('line1\nline2')
|
||||||
|
expect(body).toContain('Category: billing')
|
||||||
|
expect(body).toContain('D3RO Voice 1.9.0 • win32')
|
||||||
|
expect(url).not.toContain(' ')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('긴 본문은 잘라서 mailto 길이를 제한한다', () => {
|
||||||
|
const url = buildSupportMailto({ category: 'general', subject: 's', details: 'x'.repeat(10_000) }, diag)
|
||||||
|
const body = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('body') ?? ''
|
||||||
|
expect(body.length).toBeLessThan(MAX_MAILTO_BODY_CHARS + 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('진단 정보를 모르면 unknown으로 표시한다 (가짜 하드웨어 문구 금지)', () => {
|
||||||
|
expect(formatSupportDiagnostics({ appVersion: null, platform: null })).toBe('D3RO Voice unknown • unknown')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('분류 값 가드', () => {
|
||||||
|
expect(isSupportCategory('billing')).toBe(true)
|
||||||
|
expect(isSupportCategory('refund_auto')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createElectronSupportChannel', () => {
|
||||||
|
function fakeApi(overrides: Partial<SupportSystemApi> = {}): SupportSystemApi {
|
||||||
|
return {
|
||||||
|
openExternal: vi.fn(async () => ({ success: true as const, data: undefined })),
|
||||||
|
getVersion: vi.fn(async () => ({ success: true as const, data: '1.9.0' })),
|
||||||
|
getPlatform: vi.fn(async () => ({ success: true as const, data: 'win32' })),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('openExternal 성공일 때만 true', async () => {
|
||||||
|
const api = fakeApi()
|
||||||
|
const channel = createElectronSupportChannel(api)
|
||||||
|
await expect(channel.openMail('mailto:x@example.com')).resolves.toBe(true)
|
||||||
|
expect(api.openExternal).toHaveBeenCalledWith({ url: 'mailto:x@example.com' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('메인 프로세스가 거절하거나 예외가 나면 false (성공을 꾸며내지 않는다)', async () => {
|
||||||
|
const rejected = createElectronSupportChannel(
|
||||||
|
fakeApi({
|
||||||
|
openExternal: async () => ({ success: false as const, error: { code: 1, message: 'nope' } }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await expect(rejected.openMail('mailto:x@example.com')).resolves.toBe(false)
|
||||||
|
|
||||||
|
const thrown = createElectronSupportChannel(
|
||||||
|
fakeApi({
|
||||||
|
openExternal: async () => {
|
||||||
|
throw new Error('ipc down')
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await expect(thrown.openMail('mailto:x@example.com')).resolves.toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('진단 조회 실패는 null로 둔다', async () => {
|
||||||
|
const channel = createElectronSupportChannel(
|
||||||
|
fakeApi({
|
||||||
|
getVersion: async () => {
|
||||||
|
throw new Error('boom')
|
||||||
|
},
|
||||||
|
getPlatform: async () => ({ success: false as const, error: { code: 1, message: 'x' } }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await expect(channel.getDiagnostics()).resolves.toEqual({ appVersion: null, platform: null })
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue