Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
576 changes: 70 additions & 506 deletions ssh/src/pages/Donation/sections/Features/Register/RegisterPage.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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<Blob | null>(null);
const [error, setError] = useState<string | null>(null);

const mediaRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const timerRef = useRef<number | null>(null);
const streamRef = useRef<MediaStream | null>(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 (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
음성 녹음(최대 {Math.floor(maxSeconds / 60)}분)
</span>
{!isRecording ? (
<Button type="button" className="h-9 px-4" onClick={start}>
녹음 시작
</Button>
) : (
<Button type="button" variant="destructive" className="h-9 px-4" onClick={stop}>
녹음 종료
</Button>
)}
</div>

<div className="rounded-lg border p-3">
<div className="mb-2 text-sm text-muted-foreground">
경과 시간: {mm}:{ss}
</div>
{blob ? (
<div className="flex items-center gap-3">
<audio controls src={URL.createObjectURL(blob)} className="w-full" />
<Button type="button" variant="outline" className="h-9 px-3" onClick={reset}>
다시 녹음
</Button>
</div>
) : (
<div className="text-sm text-muted-foreground">
{isRecording ? '녹음 중… 마이크를 가까이 사용해 주세요.' : '아직 녹음 파일이 없습니다.'}
</div>
)}
</div>

{error && <p className="text-sm text-red-600">{error}</p>}
<p className="text-xs text-muted-foreground">
일부 브라우저에서 OfflineAudioContext가 없으면 선형 보간으로 변환합니다.
</p>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(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<HTMLCanvasElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
};

const onPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
if (!drawingRef.current) return;
const ctx = ctxRef.current!;
const { x, y } = getPos(e);
ctx.lineTo(x, y);
ctx.stroke();
};
const onPointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
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 (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">서명을 입력해 주세요.</span>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" className="h-9 px-3" onClick={clear}>
지우기
</Button>
<Button type="button" className="h-9 px-4" onClick={save}>
저장
</Button>
</div>
</div>

<div ref={containerRef} className="rounded-md border bg-white">
<canvas
ref={canvasRef}
className="block touch-none rounded-md"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
/>
</div>
<p className="text-xs text-muted-foreground">팁: 마우스/트랙패드/터치 모두 지원합니다.</p>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground text-base font-bold">
1
</div>
<div className="flex items-start gap-3">
<ShieldCheck className="mt-1 h-7 w-7 text-primary" />
<div>
<h2 className="text-2xl font-semibold md:text-3xl">본인 확인 및 동의</h2>
<p className="mt-2 text-lg text-muted-foreground md:text-xl">
전용 로그인(또는 본인인증) 후 진행됩니다.
</p>
</div>
</div>
</div>
);
}

export default function Step1Consent({
agreed,
setAgreed,
}: {
agreed: boolean;
setAgreed: (v: boolean) => void;
}) {
return (
<Card className="shadow-lg">
<CardHeader>
<StepHeader />
</CardHeader>
<CardContent className="space-y-6 md:space-y-7">
<label className="flex cursor-pointer items-center gap-4">
<Checkbox
id="agree"
className="h-5 w-5"
checked={agreed}
onCheckedChange={(v) => setAgreed(Boolean(v))}
/>
<span className="text-xl">
사전기부 안내 및 개인정보 처리에 <b>동의</b>합니다.
</span>
</label>
<p className="text-base text-muted-foreground">
약관과 처리방침은{' '}
<Link to="/donation/banks" className="text-primary underline">
은행 연동 안내
</Link>
에서 확인할 수 있습니다.
</p>
</CardContent>
</Card>
);
}
Loading