From 11cf3e45869e0b1ecf02d4a4e467d8e5204e633a Mon Sep 17 00:00:00 2001 From: smilevictory Date: Thu, 11 Sep 2025 10:30:43 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=9C=A0=EC=82=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=B6=80=20=EC=9D=8C=EC=84=B1=20=EB=85=B9=EC=9D=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/Register/RegisterPage.tsx | 576 +++--------------- .../Register/components/AudioRecorder.tsx | 161 +++++ .../Register/components/SignaturePad.tsx | 115 ++++ .../Features/Register/steps/Step1Consent.tsx | 59 ++ .../Features/Register/steps/Step2Amount.tsx | 135 ++++ .../Features/Register/steps/Step3BankLink.tsx | 118 ++++ .../Register/steps/Step4Destinations.tsx | 82 +++ .../Features/Register/steps/Step5Review.tsx | 88 +++ .../sections/Features/Register/types.ts | 19 + .../sections/Features/Register/utils.ts | 13 + .../sections/Features/Register/utils/audio.ts | 179 ++++++ ssh/src/routes/routing.tsx | 3 +- ssh/tsconfig.node.json | 11 +- 13 files changed, 1050 insertions(+), 509 deletions(-) create mode 100644 ssh/src/pages/Donation/sections/Features/Register/components/AudioRecorder.tsx create mode 100644 ssh/src/pages/Donation/sections/Features/Register/components/SignaturePad.tsx create mode 100644 ssh/src/pages/Donation/sections/Features/Register/steps/Step1Consent.tsx create mode 100644 ssh/src/pages/Donation/sections/Features/Register/steps/Step2Amount.tsx create mode 100644 ssh/src/pages/Donation/sections/Features/Register/steps/Step3BankLink.tsx create mode 100644 ssh/src/pages/Donation/sections/Features/Register/steps/Step4Destinations.tsx create mode 100644 ssh/src/pages/Donation/sections/Features/Register/steps/Step5Review.tsx create mode 100644 ssh/src/pages/Donation/sections/Features/Register/types.ts create mode 100644 ssh/src/pages/Donation/sections/Features/Register/utils.ts create mode 100644 ssh/src/pages/Donation/sections/Features/Register/utils/audio.ts diff --git a/ssh/src/pages/Donation/sections/Features/Register/RegisterPage.tsx b/ssh/src/pages/Donation/sections/Features/Register/RegisterPage.tsx index 2c4b0c3..b50c01b 100644 --- a/ssh/src/pages/Donation/sections/Features/Register/RegisterPage.tsx +++ b/ssh/src/pages/Donation/sections/Features/Register/RegisterPage.tsx @@ -1,19 +1,17 @@ -import { useMemo, useState, useRef, useEffect } from 'react'; +import { useMemo, useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; -import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { Input } from '@/components/ui/input'; -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Switch } from '@/components/ui/switch'; -import { ShieldCheck, HandCoins, FileSignature, Building2, ReceiptText } from 'lucide-react'; - -type AmountType = 'fixed' | 'ratio'; +import Step1Consent from './steps/Step1Consent'; +import Step2Amount from './steps/Step2Amount'; +import Step3BankLink from './steps/Step3BankLink'; +import Step4Destinations from './steps/Step4Destinations'; +import Step5Review from './steps/Step5Review'; +import type { AmountType, RegisterPayload } from './types'; export default function RegisterPage() { const navigate = useNavigate(); + const [voiceNote, setVoiceNote] = useState(null); const [agreed, setAgreed] = useState(false); const [amountType, setAmountType] = useState('fixed'); const [fixedAmount, setFixedAmount] = useState(''); @@ -43,8 +41,8 @@ export default function RegisterPage() { const next = () => setStep((s) => Math.min(maxStep, s + 1)); const prev = () => setStep((s) => Math.max(1, s - 1)); - const submit = () => { - const payload = { + const submit = async () => { + const payload: RegisterPayload = { agreed, amount: amountType === 'fixed' @@ -55,180 +53,35 @@ export default function RegisterPage() { destinations, memo, }; - console.log('submit payload', payload); + + if (voiceNote) { + const fd = new FormData(); + fd.append('meta', new Blob([JSON.stringify(payload)], { type: 'application/json' })); + const ext = voiceNote.type.includes('wav') + ? 'wav' + : voiceNote.type.includes('mp4') + ? 'm4a' + : voiceNote.type.includes('webm') + ? 'webm' + : 'dat'; + fd.append( + 'voiceNote', + new File([voiceNote], `voice-note.${ext}`, { + type: voiceNote.type || 'audio/wav', + }), + ); + + console.log('FormData ready (payload + voiceNote)', payload, voiceNote); + } else { + console.log('submit payload (no voiceNote)', payload); + } + alert('기부 의사 등록이 완료되었습니다.'); navigate('/donation'); }; - const StepBadge = ({ n, active }: { n: number; active: boolean }) => ( -
- {n} -
- ); - - const StepHeader = ({ - n, - icon: Icon, - title, - desc, - active, - }: { - n: number; - icon: any; - title: string; - desc: string; - active: boolean; - }) => ( -
- -
- -
-

{title}

-

{desc}

-
-
-
- ); - - const PRESET_AMOUNTS = [ - { label: '만원', value: 10_000 }, - { label: '십만원', value: 100_000 }, - { label: '백만원', value: 1_000_000 }, - { label: '천만원', value: 10_000_000 }, - { label: '억원', value: 100_000_000 }, - ]; - - const MAX_AMOUNT = 1_000_000_000_000; - const clamp = (n: number) => Math.max(0, Math.min(n, MAX_AMOUNT)); - const toNum = (s: string) => Number(s || '0'); - const fmt = (v: number) => v.toLocaleString('ko-KR') + '원'; - - function SignaturePad({ - height = 200, - lineWidth = 3, - onSave, - }: { - height?: number; - lineWidth?: number; - onSave: (dataUrl: string) => void; - }) { - const containerRef = useRef(null); - const canvasRef = useRef(null); - const ctxRef = useRef(null); - const drawingRef = useRef(false); - - useEffect(() => { - const canvas = canvasRef.current!; - const container = containerRef.current!; - const ctx = canvas.getContext('2d')!; - ctxRef.current = ctx; - - const resize = () => { - const dpr = Math.max(1, window.devicePixelRatio || 1); - const width = container.clientWidth; - const cssHeight = height; - - canvas.width = Math.floor(width * dpr); - canvas.height = Math.floor(cssHeight * dpr); - - canvas.style.width = `${width}px`; - canvas.style.height = `${cssHeight}px`; - - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.scale(dpr, dpr); - - ctx.lineWidth = lineWidth; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - ctx.strokeStyle = '#111827'; - }; - - resize(); - const ro = new ResizeObserver(resize); - ro.observe(container); - return () => ro.disconnect(); - }, [height, lineWidth]); - - const getPos = (e: React.PointerEvent) => { - const rect = e.currentTarget.getBoundingClientRect(); - return { x: e.clientX - rect.left, y: e.clientY - rect.top }; - }; - - const onPointerDown = (e: React.PointerEvent) => { - const ctx = ctxRef.current!; - const { x, y } = getPos(e); - drawingRef.current = true; - e.currentTarget.setPointerCapture(e.pointerId); - ctx.beginPath(); - ctx.moveTo(x, y); - }; - - const onPointerMove = (e: React.PointerEvent) => { - if (!drawingRef.current) return; - const ctx = ctxRef.current!; - const { x, y } = getPos(e); - ctx.lineTo(x, y); - ctx.stroke(); - }; - - const onPointerUp = (e: React.PointerEvent) => { - if (!drawingRef.current) return; - drawingRef.current = false; - const ctx = ctxRef.current!; - ctx.closePath(); - e.currentTarget.releasePointerCapture(e.pointerId); - }; - - const clear = () => { - const canvas = canvasRef.current!; - const ctx = ctxRef.current!; - ctx.save(); - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.restore(); - }; - - const save = () => { - const dataUrl = canvasRef.current!.toDataURL('image/png'); - onSave(dataUrl); - }; - - return ( -
-
- 서명을 입력해 주세요. -
- - -
-
- -
- -
-

팁: 마우스/트랙패드/터치 모두 지원합니다.

-
- ); - } - return ( -
+

기부 의사 등록

- {/* STEP 1 */} - {step === 1 && ( - - - - - - - -

- 약관과 처리방침은{' '} - - 은행 연동 안내 - - 에서 확인할 수 있습니다. -

-
-
- )} - - {/* STEP 2 */} + {/* 단계 */} + {step === 1 && } {step === 2 && ( - - - - - - - setAmountType(v as AmountType)} - className="grid gap-4 md:grid-cols-2" - > - - - - - {amountType === 'fixed' ? ( -
- - -
- {PRESET_AMOUNTS.map((p) => ( - - ))} -
- - { - const raw = e.target.value.replace(/[^0-9]/g, ''); - setFixedAmount(String(clamp(Number(raw)))); - }} - /> - -
- - - 팁: 버튼을 누를 때마다 금액이 누적됩니다. - -
- -

- 현재 금액: {fmt(toNum(fixedAmount))} -

-
- ) : ( -
- - setRatio(e.target.value.replace(/[^0-9]/g, ''))} - /> -

1–100 사이의 정수를 권장합니다.

-
- )} -
-
+ )} - - {/* STEP 3 */} {step === 3 && ( - - - - - -
-

- 실제 환경에서는 KB국민은행의 공식 연동 절차(로그인/전자서명)를 진행합니다. -

- -
- - {bankLinked && ✔ 연결되었습니다.} -
- -
- - -
- - {esignOpen && ( -
- { - setSignatureDataUrl(dataUrl); - }} - /> -
- - {signatureDataUrl && ( - <> - 서명 미리보기 - - - )} -
-
- )} - -

- 자세한 절차는{' '} - - 은행 연동 안내 - - 를 참고하세요. -

-
-
-
+ )} - - {/* STEP 4 */} {step === 4 && ( - - - - - -
- {[ - '국내 병원', - '장학재단/학교', - '사회복지/비영리', - '국제구호', - '환경/동물보호', - '문화/예술', - ].map((name) => ( - - ))} -
- -
- - setMemo(e.target.value)} - maxLength={120} - placeholder="예: ○○대학교 장학금, ○○병원 소아암센터 등" - className=" - h-20 px-6 - text-3xl md:text-4xl font-semibold tracking-tight leading-tight - placeholder:text-3xl md:placeholder:text-4xl placeholder:font-semibold placeholder:tracking-tight placeholder:leading-tight - " - /> -
-
-
+ )} - - {/* STEP 5 */} {step === 5 && ( - - - - - -
-
요약
-
    -
  • 동의 여부: {agreed ? '동의함' : '미동의'}
  • -
  • - 지정 방식:{' '} - {amountType === 'fixed' - ? `정액 ${Number(fixedAmount || 0).toLocaleString()}원` - : `비율 ${ratio || 0}%`} -
  • -
  • - 은행 연동: {bankLinked ? '완료' : '미완료'} ({sandboxMode ? '샌드박스' : '실거래'} - ) -
  • -
  • 기부처: {destinations.length ? destinations.join(', ') : '선택 없음'}
  • -
  • 메모: {memo || '미등록'}
  • -
-

- 제출 후에도 생전에는 언제든 수정·철회가 - 가능합니다. -

-
-
-
+ )}
diff --git a/ssh/src/pages/Donation/sections/Features/Register/components/AudioRecorder.tsx b/ssh/src/pages/Donation/sections/Features/Register/components/AudioRecorder.tsx new file mode 100644 index 0000000..7eaeae2 --- /dev/null +++ b/ssh/src/pages/Donation/sections/Features/Register/components/AudioRecorder.tsx @@ -0,0 +1,161 @@ +import { useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { blobToWav16kMono } from '../utils/audio'; + +export default function AudioRecorder({ + onChange, + maxSeconds = 120, +}: { + onChange: (blob: Blob | null) => void; + maxSeconds?: number; +}) { + const [isRecording, setIsRecording] = useState(false); + const [seconds, setSeconds] = useState(0); + const [blob, setBlob] = useState(null); + const [error, setError] = useState(null); + + const mediaRef = useRef(null); + const chunksRef = useRef([]); + const timerRef = useRef(null); + const streamRef = useRef(null); + + const pickMime = () => { + const MR = (window as any).MediaRecorder; + if (!MR) return ''; + if (MR.isTypeSupported('audio/webm')) return 'audio/webm'; + if (MR.isTypeSupported('audio/mp4')) return 'audio/mp4'; + if (MR.isTypeSupported('audio/ogg')) return 'audio/ogg'; + return ''; + }; + + const start = async () => { + try { + setError(null); + if (!navigator.mediaDevices?.getUserMedia) { + setError('이 브라우저는 녹음을 지원하지 않습니다.'); + return; + } + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + streamRef.current = stream; + chunksRef.current = []; + + const mimeType = pickMime() || undefined; + const mr = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); + mediaRef.current = mr; + + mr.ondataavailable = (e) => e.data?.size && chunksRef.current.push(e.data); + mr.onstop = async () => { + const raw = new Blob(chunksRef.current, { type: mr.mimeType || 'audio/webm' }); + try { + // 여기서 16kHz 모노 WAV로 변환 + const wav16k = await blobToWav16kMono(raw, 16000); + setBlob(wav16k); + onChange(wav16k); + } catch (err) { + setError('16kHz 변환에 실패하여 원본 포맷으로 전달합니다.'); + setBlob(raw); + onChange(raw); + } finally { + stopTimer(); + stopTracks(); + } + }; + + setIsRecording(true); + setSeconds(0); + mr.start(100); + startTimer(); + } catch (e: any) { + setError(e?.message ?? '마이크 권한을 확인해 주세요.'); + } + }; + + const stop = () => { + mediaRef.current?.stop(); + setIsRecording(false); + }; + + const reset = () => { + setIsRecording(false); + setSeconds(0); + setBlob(null); + onChange(null); + stopTimer(); + stopTracks(); + chunksRef.current = []; + setError(null); + }; + + const startTimer = () => { + stopTimer(); + timerRef.current = window.setInterval(() => { + setSeconds((s) => { + if (s + 1 >= maxSeconds) stop(); + return s + 1; + }); + }, 1000); + }; + const stopTimer = () => { + if (timerRef.current) { + window.clearInterval(timerRef.current); + timerRef.current = null; + } + }; + const stopTracks = () => { + streamRef.current?.getTracks().forEach((t) => t.stop()); + streamRef.current = null; + }; + + useEffect( + () => () => { + stopTimer(); + stopTracks(); + }, + [], + ); + + const mm = String(Math.floor(seconds / 60)).padStart(2, '0'); + const ss = String(seconds % 60).padStart(2, '0'); + + return ( +
+
+ + 음성 메모(최대 {Math.floor(maxSeconds / 60)}분) — 출력: WAV 16kHz 모노 + + {!isRecording ? ( + + ) : ( + + )} +
+ +
+
+ 경과 시간: {mm}:{ss} +
+ {blob ? ( +
+
+ ) : ( +
+ {isRecording ? '녹음 중… 마이크를 가까이 사용해 주세요.' : '아직 녹음 파일이 없습니다.'} +
+ )} +
+ + {error &&

{error}

} +

+ 일부 브라우저에서 OfflineAudioContext가 없으면 선형 보간으로 변환합니다. +

+
+ ); +} diff --git a/ssh/src/pages/Donation/sections/Features/Register/components/SignaturePad.tsx b/ssh/src/pages/Donation/sections/Features/Register/components/SignaturePad.tsx new file mode 100644 index 0000000..c376e59 --- /dev/null +++ b/ssh/src/pages/Donation/sections/Features/Register/components/SignaturePad.tsx @@ -0,0 +1,115 @@ +import { useEffect, useRef } from 'react'; +import { Button } from '@/components/ui/button'; + +export default function SignaturePad({ + height = 200, + lineWidth = 3, + onSave, +}: { + height?: number; + lineWidth?: number; + onSave: (dataUrl: string) => void; +}) { + const containerRef = useRef(null); + const canvasRef = useRef(null); + const ctxRef = useRef(null); + const drawingRef = useRef(false); + + useEffect(() => { + const canvas = canvasRef.current!; + const container = containerRef.current!; + const ctx = canvas.getContext('2d')!; + ctxRef.current = ctx; + + const resize = () => { + const dpr = Math.max(1, window.devicePixelRatio || 1); + const width = container.clientWidth; + const cssHeight = height; + canvas.width = Math.floor(width * dpr); + canvas.height = Math.floor(cssHeight * dpr); + canvas.style.width = `${width}px`; + canvas.style.height = `${cssHeight}px`; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.scale(dpr, dpr); + ctx.lineWidth = lineWidth; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.strokeStyle = '#111827'; + }; + + resize(); + const ro = new ResizeObserver(resize); + ro.observe(container); + return () => ro.disconnect(); + }, [height, lineWidth]); + + const getPos = (e: React.PointerEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + }; + + const onPointerDown = (e: React.PointerEvent) => { + const ctx = ctxRef.current!; + const { x, y } = getPos(e); + drawingRef.current = true; + e.currentTarget.setPointerCapture(e.pointerId); + ctx.beginPath(); + ctx.moveTo(x, y); + }; + const onPointerMove = (e: React.PointerEvent) => { + if (!drawingRef.current) return; + const ctx = ctxRef.current!; + const { x, y } = getPos(e); + ctx.lineTo(x, y); + ctx.stroke(); + }; + const onPointerUp = (e: React.PointerEvent) => { + if (!drawingRef.current) return; + drawingRef.current = false; + const ctx = ctxRef.current!; + ctx.closePath(); + e.currentTarget.releasePointerCapture(e.pointerId); + }; + + const clear = () => { + const canvas = canvasRef.current!; + const ctx = ctxRef.current!; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.restore(); + }; + + const save = () => { + const dataUrl = canvasRef.current!.toDataURL('image/png'); + onSave(dataUrl); + }; + + return ( +
+
+ 서명을 입력해 주세요. +
+ + +
+
+ +
+ +
+

팁: 마우스/트랙패드/터치 모두 지원합니다.

+
+ ); +} diff --git a/ssh/src/pages/Donation/sections/Features/Register/steps/Step1Consent.tsx b/ssh/src/pages/Donation/sections/Features/Register/steps/Step1Consent.tsx new file mode 100644 index 0000000..078f6c7 --- /dev/null +++ b/ssh/src/pages/Donation/sections/Features/Register/steps/Step1Consent.tsx @@ -0,0 +1,59 @@ +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Link } from 'react-router-dom'; +import { ShieldCheck } from 'lucide-react'; + +function StepHeader() { + return ( +
+
+ 1 +
+
+ +
+

본인 확인 및 동의

+

+ 전용 로그인(또는 본인인증) 후 진행됩니다. +

+
+
+
+ ); +} + +export default function Step1Consent({ + agreed, + setAgreed, +}: { + agreed: boolean; + setAgreed: (v: boolean) => void; +}) { + return ( + + + + + + +

+ 약관과 처리방침은{' '} + + 은행 연동 안내 + + 에서 확인할 수 있습니다. +

+
+
+ ); +} diff --git a/ssh/src/pages/Donation/sections/Features/Register/steps/Step2Amount.tsx b/ssh/src/pages/Donation/sections/Features/Register/steps/Step2Amount.tsx new file mode 100644 index 0000000..294520e --- /dev/null +++ b/ssh/src/pages/Donation/sections/Features/Register/steps/Step2Amount.tsx @@ -0,0 +1,135 @@ +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { HandCoins } from 'lucide-react'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import type { AmountType } from '../types'; +import { PRESET_AMOUNTS, clampAmount, fmtKRW, toNum } from '../utils'; + +function StepHeader() { + return ( +
+
+ 2 +
+
+ +
+

금액 · 비율 지정

+

+ 정액 또는 비율로 기부 범위를 설정합니다. 생전 언제든 수정·철회 가능합니다. +

+
+
+
+ ); +} + +export default function Step2Amount({ + amountType, + setAmountType, + fixedAmount, + setFixedAmount, + ratio, + setRatio, +}: { + amountType: AmountType; + setAmountType: (v: AmountType) => void; + fixedAmount: string; + setFixedAmount: (v: string) => void; + ratio: string; + setRatio: (v: string) => void; +}) { + return ( + + + + + + setAmountType(v as AmountType)} + className="grid gap-4 md:grid-cols-2" + > + + + + + {amountType === 'fixed' ? ( +
+ + +
+ {PRESET_AMOUNTS.map((p) => ( + + ))} +
+ + { + const raw = e.target.value.replace(/[^0-9]/g, ''); + setFixedAmount(String(clampAmount(Number(raw)))); + }} + /> + +
+ + + 팁: 버튼을 누를 때마다 금액이 누적됩니다. + +
+ +

현재 금액: {fmtKRW(toNum(fixedAmount))}

+
+ ) : ( +
+ + setRatio(e.target.value.replace(/[^0-9]/g, ''))} + /> +

1–100 사이의 정수를 권장합니다.

+
+ )} +
+
+ ); +} diff --git a/ssh/src/pages/Donation/sections/Features/Register/steps/Step3BankLink.tsx b/ssh/src/pages/Donation/sections/Features/Register/steps/Step3BankLink.tsx new file mode 100644 index 0000000..d105b7e --- /dev/null +++ b/ssh/src/pages/Donation/sections/Features/Register/steps/Step3BankLink.tsx @@ -0,0 +1,118 @@ +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { FileSignature } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { Link } from 'react-router-dom'; +import SignaturePad from '../components/SignaturePad'; + +function StepHeader() { + return ( +
+
+ 3 +
+
+ +
+

은행 기부 기능 연동

+

+ KB국민은행 ‘공식 기부 기능’과 연동되어 안전하게 등록됩니다. +

+
+
+
+ ); +} + +export default function Step3BankLink({ + bankLinked, + setBankLinked, + esignOpen, + setEsignOpen, + signatureDataUrl, + setSignatureDataUrl, +}: { + bankLinked: boolean; + setBankLinked: (v: boolean) => void; + esignOpen: boolean; + setEsignOpen: (v: boolean) => void; + signatureDataUrl: string | null; + setSignatureDataUrl: (v: string | null) => void; +}) { + return ( + + + + + +
+

+ 실제 환경에서는 KB국민은행의 공식 연동 절차(로그인/전자서명)를 진행합니다. +

+ +
+ + {bankLinked && ✔ 연결되었습니다.} +
+ +
+ + +
+ + {esignOpen && ( +
+ +
+ + {signatureDataUrl && ( + <> + 서명 미리보기 + + + )} +
+
+ )} + +

+ 자세한 절차는{' '} + + 은행 연동 안내 + + 를 참고하세요. +

+
+
+
+ ); +} diff --git a/ssh/src/pages/Donation/sections/Features/Register/steps/Step4Destinations.tsx b/ssh/src/pages/Donation/sections/Features/Register/steps/Step4Destinations.tsx new file mode 100644 index 0000000..0ffce91 --- /dev/null +++ b/ssh/src/pages/Donation/sections/Features/Register/steps/Step4Destinations.tsx @@ -0,0 +1,82 @@ +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Building2 } from 'lucide-react'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { DESTINATION_OPTIONS } from '../types'; + +function StepHeader() { + return ( +
+
+ 4 +
+
+ +
+

기부처 선택

+

+ 병원·학교·비영리단체 등 원하는 곳을 고릅니다. +

+
+
+
+ ); +} + +export default function Step4Destinations({ + destinations, + setDestinations, + memo, + setMemo, +}: { + destinations: string[]; + setDestinations: (v: string[]) => void; + memo: string; + setMemo: (v: string) => void; +}) { + return ( + + + + + +
+ {DESTINATION_OPTIONS.map((name) => ( + + ))} +
+ +
+ + setMemo(e.target.value)} + maxLength={120} + placeholder="예: ○○대학교 장학금, ○○병원 소아암센터 등" + className="h-20 px-6 text-3xl md:text-4xl font-semibold tracking-tight leading-tight + placeholder:text-3xl md:placeholder:text-4xl placeholder:font-semibold placeholder:tracking-tight placeholder:leading-tight" + /> +
+
+
+ ); +} diff --git a/ssh/src/pages/Donation/sections/Features/Register/steps/Step5Review.tsx b/ssh/src/pages/Donation/sections/Features/Register/steps/Step5Review.tsx new file mode 100644 index 0000000..3bdff34 --- /dev/null +++ b/ssh/src/pages/Donation/sections/Features/Register/steps/Step5Review.tsx @@ -0,0 +1,88 @@ +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { ReceiptText } from 'lucide-react'; +import AudioRecorder from '../components/AudioRecorder'; +import type { AmountType } from '../types'; + +function StepHeader() { + return ( +
+
+ 5 +
+
+ +
+

검토 및 제출

+

+ 지정한 내용대로 기부가 집행되고, 영수증이 발급됩니다. +

+
+
+
+ ); +} + +export default function Step5Review({ + agreed, + amountType, + fixedAmount, + ratio, + bankLinked, + sandboxMode, + destinations, + memo, + voiceNote, + setVoiceNote, +}: { + agreed: boolean; + amountType: AmountType; + fixedAmount: string; + ratio: string; + bankLinked: boolean; + sandboxMode: boolean; + destinations: string[]; + memo: string; + voiceNote: Blob | null; + setVoiceNote: (b: Blob | null) => void; +}) { + return ( + + + + + +
+
요약
+
    +
  • 동의 여부: {agreed ? '동의함' : '미동의'}
  • +
  • + 지정 방식:{' '} + {amountType === 'fixed' + ? `정액 ${Number(fixedAmount || 0).toLocaleString()}원` + : `비율 ${ratio || 0}%`} +
  • +
  • + 은행 연동: {bankLinked ? '완료' : '미완료'} ({sandboxMode ? '샌드박스' : '실거래'}) +
  • +
  • 기부처: {destinations.length ? destinations.join(', ') : '선택 없음'}
  • +
  • 메모: {memo || '미등록'}
  • +
+

+ 제출 후에도 생전에는 언제든 수정·철회가 가능합니다. +

+
+ +
+
음성 메모(선택)
+ + {voiceNote && ( +

+ 첨부 예정 파일 크기: {(voiceNote.size / 1024).toFixed(1)} KB, 타입:{' '} + {voiceNote.type || 'N/A'} +

+ )} +
+
+
+ ); +} diff --git a/ssh/src/pages/Donation/sections/Features/Register/types.ts b/ssh/src/pages/Donation/sections/Features/Register/types.ts new file mode 100644 index 0000000..2a623c7 --- /dev/null +++ b/ssh/src/pages/Donation/sections/Features/Register/types.ts @@ -0,0 +1,19 @@ +export type AmountType = 'fixed' | 'ratio'; + +export type RegisterPayload = { + agreed: boolean; + amount: { type: 'fixed'; value: number } | { type: 'ratio'; value: number }; + bankLinked: boolean; + sandboxMode: boolean; + destinations: string[]; + memo: string; +}; + +export const DESTINATION_OPTIONS = [ + '국내 병원', + '장학재단/학교', + '사회복지/비영리', + '국제구호', + '환경/동물보호', + '문화/예술', +] as const; diff --git a/ssh/src/pages/Donation/sections/Features/Register/utils.ts b/ssh/src/pages/Donation/sections/Features/Register/utils.ts new file mode 100644 index 0000000..767da20 --- /dev/null +++ b/ssh/src/pages/Donation/sections/Features/Register/utils.ts @@ -0,0 +1,13 @@ +export const PRESET_AMOUNTS = [ + { label: '만원', value: 10_000 }, + { label: '십만원', value: 100_000 }, + { label: '백만원', value: 1_000_000 }, + { label: '천만원', value: 10_000_000 }, + { label: '억원', value: 100_000_000 }, +]; + +const MAX_AMOUNT = 1_000_000_000_000; + +export const clampAmount = (n: number) => Math.max(0, Math.min(n, MAX_AMOUNT)); +export const toNum = (s: string) => Number(s || '0'); +export const fmtKRW = (v: number) => v.toLocaleString('ko-KR') + '원'; diff --git a/ssh/src/pages/Donation/sections/Features/Register/utils/audio.ts b/ssh/src/pages/Donation/sections/Features/Register/utils/audio.ts new file mode 100644 index 0000000..dad55e3 --- /dev/null +++ b/ssh/src/pages/Donation/sections/Features/Register/utils/audio.ts @@ -0,0 +1,179 @@ +/** Blob(webm/mp4/ogg) -> AudioBuffer */ +export async function decodeToAudioBuffer(blob: Blob): Promise { + const arrayBuf = await blob.arrayBuffer(); + const AC = (window.AudioContext || (window as any).webkitAudioContext) as typeof AudioContext; + const ctx = new AC(); + const audioBuffer = await new Promise((resolve, reject) => { + // Safari 호환: 콜백 형태 사용 + ctx.decodeAudioData(arrayBuf.slice(0), resolve, reject); + }); + ctx.close?.(); + return audioBuffer; +} + +/** AudioBuffer -> WAV(16-bit PCM, LE) */ +export function audioBufferToWavBlob(audioBuffer: AudioBuffer): Blob { + const numChannels = audioBuffer.numberOfChannels; + const sampleRate = audioBuffer.sampleRate; + const numFrames = audioBuffer.length; + const bytesPerSample = 2; // 16-bit + const blockAlign = numChannels * bytesPerSample; + const byteRate = sampleRate * blockAlign; + const dataSize = numFrames * blockAlign; + + const buffer = new ArrayBuffer(44 + dataSize); + const view = new DataView(buffer); + + let offset = 0; + const writeStr = (s: string) => { + for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i)); + offset += s.length; + }; + + writeStr('RIFF'); + view.setUint32(offset, 36 + dataSize, true); + offset += 4; + writeStr('WAVE'); + writeStr('fmt '); + view.setUint32(offset, 16, true); + offset += 4; + view.setUint16(offset, 1, true); + offset += 2; // PCM + view.setUint16(offset, numChannels, true); + offset += 2; + view.setUint32(offset, sampleRate, true); + offset += 4; + view.setUint32(offset, byteRate, true); + offset += 4; + view.setUint16(offset, blockAlign, true); + offset += 2; + view.setUint16(offset, 16, true); + offset += 2; // 16-bit + writeStr('data'); + view.setUint32(offset, dataSize, true); + offset += 4; + + const channels: Float32Array[] = []; + for (let ch = 0; ch < numChannels; ch++) channels.push(audioBuffer.getChannelData(ch)); + + for (let i = 0; i < numFrames; i++) { + for (let ch = 0; ch < numChannels; ch++) { + let s = channels[ch][i]; + s = Math.max(-1, Math.min(1, s)); + const v = s < 0 ? s * 0x8000 : s * 0x7fff; + view.setInt16(offset, v, true); + offset += 2; + } + } + return new Blob([view], { type: 'audio/wav' }); +} + +/** 다채널 -> 모노 믹스다운(Float32) */ +function mixToMonoFloat(buf: AudioBuffer): Float32Array { + const n = buf.length; + const chs = buf.numberOfChannels; + if (chs === 1) return buf.getChannelData(0).slice(0); + const out = new Float32Array(n); + for (let ch = 0; ch < chs; ch++) { + const data = buf.getChannelData(ch); + for (let i = 0; i < n; i++) out[i] += data[i] / chs; + } + return out; +} + +/** 선형 보간 리샘플(모노) */ +function linearResampleMono(input: Float32Array, srcRate: number, dstRate: number): Float32Array { + if (srcRate === dstRate) return input.slice(0); + const ratio = srcRate / dstRate; + const outLen = Math.round(input.length / ratio); + const out = new Float32Array(outLen); + for (let i = 0; i < outLen; i++) { + const srcPos = i * ratio; + const i0 = Math.floor(srcPos); + const i1 = Math.min(i0 + 1, input.length - 1); + const t = srcPos - i0; + out[i] = input[i0] + (input[i1] - input[i0]) * t; + } + return out; +} + +/** 모노 Float32 -> WAV Blob (16-bit PCM) */ +function encodeMonoFloatToWav(floatMono: Float32Array, sampleRate: number): Blob { + const numFrames = floatMono.length; + const bytesPerSample = 2; + const blockAlign = 1 * bytesPerSample; + const byteRate = sampleRate * blockAlign; + const dataSize = numFrames * blockAlign; + const buffer = new ArrayBuffer(44 + dataSize); + const view = new DataView(buffer); + + let offset = 0; + const writeStr = (s: string) => { + for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i)); + offset += s.length; + }; + + writeStr('RIFF'); + view.setUint32(offset, 36 + dataSize, true); + offset += 4; + writeStr('WAVE'); + writeStr('fmt '); + view.setUint32(offset, 16, true); + offset += 4; + view.setUint16(offset, 1, true); + offset += 2; // PCM + view.setUint16(offset, 1, true); + offset += 2; // channels = 1 + view.setUint32(offset, sampleRate, true); + offset += 4; + view.setUint32(offset, byteRate, true); + offset += 4; + view.setUint16(offset, blockAlign, true); + offset += 2; + view.setUint16(offset, 16, true); + offset += 2; // 16-bit + writeStr('data'); + view.setUint32(offset, dataSize, true); + offset += 4; + + for (let i = 0; i < numFrames; i++) { + let s = Math.max(-1, Math.min(1, floatMono[i])); + const v = s < 0 ? s * 0x8000 : s * 0x7fff; + view.setInt16(offset, v, true); + offset += 2; + } + return new Blob([view], { type: 'audio/wav' }); +} + +/** OfflineAudioContext로 리샘플(가급적 우선 사용) */ +async function resampleWithOfflineContext(buf: AudioBuffer, dstRate: number): Promise { + const Offline = (window as any).OfflineAudioContext || (window as any).webkitOfflineAudioContext; + if (!Offline) throw new Error('OfflineAudioContext not supported'); + + // 출력 1채널(모노) / 샘플레이트 dstRate / 프레임 수 = duration * dstRate + const length = Math.ceil(buf.duration * dstRate); + const offline = new Offline(1, length, dstRate); + + const src = offline.createBufferSource(); + src.buffer = buf; + src.connect(offline.destination); // 자동 downmix 규칙에 따라 모노로 합쳐짐 + src.start(0); + + return await offline.startRendering(); +} + +/** Blob(webm/mp4/ogg) -> 16kHz 모노 WAV Blob (최우선: OfflineAudioContext, 폴백: 선형보간) */ +export async function blobToWav16kMono(blob: Blob, targetSampleRate = 16000): Promise { + const original = await decodeToAudioBuffer(blob); + + // 1) 품질 우선: OfflineAudioContext 사용 + try { + const rendered = await resampleWithOfflineContext(original, targetSampleRate); + return audioBufferToWavBlob(rendered); // 1채널/16k로 렌더된 AudioBuffer + } catch { + // 2) 폴백: 직접 모노 믹스 + 선형 리샘플 + const mono = mixToMonoFloat(original); + const resampled = linearResampleMono(mono, original.sampleRate, targetSampleRate); + return encodeMonoFloatToWav(resampled, targetSampleRate); + } +} diff --git a/ssh/src/routes/routing.tsx b/ssh/src/routes/routing.tsx index 4daa491..42efb9c 100644 --- a/ssh/src/routes/routing.tsx +++ b/ssh/src/routes/routing.tsx @@ -26,12 +26,13 @@ export const routers = createBrowserRouter([ { path: ROUTING_PATH.atmmap, element: }, { path: ROUTING_PATH.voicephishing, element: }, { path: ROUTING_PATH.donation, element: }, + { path: `${ROUTING_PATH.donation}/register`, element: }, { element: , children: [ { path: ROUTING_PATH.setting, element: }, - { path: `${ROUTING_PATH.donation}/register`, element: }, + // { path: `${ROUTING_PATH.donation}/register`, element: }, { path: `${ROUTING_PATH.donation}/banks`, element: }, ], }, diff --git a/ssh/tsconfig.node.json b/ssh/tsconfig.node.json index f85a399..0e64321 100644 --- a/ssh/tsconfig.node.json +++ b/ssh/tsconfig.node.json @@ -1,18 +1,25 @@ { "compilerOptions": { + "composite": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", "lib": ["ES2023"], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ - "moduleResolution": "bundler", + "moduleResolution": "Bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, + /* Types */ + "types": ["node"], + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + /* Linting */ "strict": true, "noUnusedLocals": true, @@ -21,5 +28,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vitest.config.ts", "scripts/**/*.ts"] } From 58dda545f87bae396fbee33f25c76e951298e9ac Mon Sep 17 00:00:00 2001 From: smilevictory Date: Thu, 11 Sep 2025 10:35:36 +0900 Subject: [PATCH 2/2] =?UTF-8?q?design:=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sections/Features/Register/components/AudioRecorder.tsx | 2 +- .../Donation/sections/Features/Register/steps/Step5Review.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ssh/src/pages/Donation/sections/Features/Register/components/AudioRecorder.tsx b/ssh/src/pages/Donation/sections/Features/Register/components/AudioRecorder.tsx index 7eaeae2..087113b 100644 --- a/ssh/src/pages/Donation/sections/Features/Register/components/AudioRecorder.tsx +++ b/ssh/src/pages/Donation/sections/Features/Register/components/AudioRecorder.tsx @@ -121,7 +121,7 @@ export default function AudioRecorder({
- 음성 메모(최대 {Math.floor(maxSeconds / 60)}분) — 출력: WAV 16kHz 모노 + 음성 녹음(최대 {Math.floor(maxSeconds / 60)}분) {!isRecording ? (
-
음성 메모(선택)
+
음성 녹음
{voiceNote && (