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
|
||||
// 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>
|
||||
)
|
||||
|
|
|
|||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue