Skip to content

Commit da10e34

Browse files
authored
Merge pull request #243 from CSE-Shaco/develop
feat(guestbook, luckydraw): 방명록 및 방명록 연계 추첨 기능 추가
2 parents 754d748 + 59f7bf6 commit da10e34

File tree

4 files changed

+554
-0
lines changed

4 files changed

+554
-0
lines changed
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
'use client';
2+
3+
import { useAuthenticatedApi } from "@/hooks/useAuthenticatedApi";
4+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5+
6+
const REFRESH_INTERVAL = 10000;
7+
8+
const hashString = (value) => {
9+
let hash = 0;
10+
for (let i = 0; i < value.length; i += 1) {
11+
hash = value.charCodeAt(i) + ((hash << 5) - hash);
12+
}
13+
return hash;
14+
};
15+
16+
const mapToRange = (value, min, max) => {
17+
const normalized = Math.abs(value % 1000) / 1000;
18+
return min + normalized * (max - min);
19+
};
20+
21+
export default function GuestbookAdminPage() {
22+
const { apiClient } = useAuthenticatedApi();
23+
const [entries, setEntries] = useState([]);
24+
const [formValues, setFormValues] = useState({ wristbandSerial: "", name: "" });
25+
const [isLoading, setIsLoading] = useState(true);
26+
const [isSubmitting, setIsSubmitting] = useState(false);
27+
const [error, setError] = useState("");
28+
const [statusMessage, setStatusMessage] = useState("");
29+
const [lastSyncedAt, setLastSyncedAt] = useState(null);
30+
const statusTimerRef = useRef(null);
31+
32+
const fetchEntries = useCallback(async () => {
33+
try {
34+
setError("");
35+
const res = await apiClient.get("/guestbook/entries");
36+
setEntries(res?.data?.data ?? []);
37+
setLastSyncedAt(new Date());
38+
} catch (err) {
39+
console.error("방명록 목록 조회 실패", err);
40+
setError("방명록 목록을 불러오지 못했습니다.");
41+
} finally {
42+
setIsLoading(false);
43+
}
44+
}, [apiClient]);
45+
46+
useEffect(() => {
47+
fetchEntries();
48+
const interval = setInterval(fetchEntries, REFRESH_INTERVAL);
49+
50+
return () => {
51+
clearInterval(interval);
52+
};
53+
}, [fetchEntries]);
54+
55+
const handleChange = (event) => {
56+
const { name, value } = event.target;
57+
setFormValues((prev) => ({ ...prev, [name]: value }));
58+
};
59+
60+
const resetStatusTimer = useCallback(() => {
61+
if (statusTimerRef.current) {
62+
clearTimeout(statusTimerRef.current);
63+
}
64+
statusTimerRef.current = setTimeout(() => setStatusMessage(""), 2500);
65+
}, []);
66+
67+
const handleSubmit = async (event) => {
68+
event.preventDefault();
69+
if (!formValues.wristbandSerial.trim() || !formValues.name.trim()) {
70+
return;
71+
}
72+
73+
try {
74+
setIsSubmitting(true);
75+
await apiClient.post("/guestbook/entries", {
76+
wristbandSerial: formValues.wristbandSerial.trim(),
77+
name: formValues.name.trim(),
78+
});
79+
setFormValues({ wristbandSerial: "", name: "" });
80+
setStatusMessage("입장 등록이 완료되었습니다.");
81+
resetStatusTimer();
82+
await fetchEntries();
83+
} catch (err) {
84+
console.error("방명록 등록 실패", err);
85+
const message = err?.response?.data?.message || "입장 등록에 실패했습니다.";
86+
setStatusMessage(message);
87+
resetStatusTimer();
88+
} finally {
89+
setIsSubmitting(false);
90+
}
91+
};
92+
93+
useEffect(() => {
94+
return () => {
95+
if (statusTimerRef.current) {
96+
clearTimeout(statusTimerRef.current);
97+
}
98+
};
99+
}, []);
100+
101+
const words = useMemo(() => {
102+
if (!entries.length) {
103+
return [];
104+
}
105+
106+
const recentThreshold = Math.max(entries.length - 5, 0);
107+
108+
return entries.map((entry, idx) => {
109+
const key = entry.id ?? `${entry.wristbandSerial ?? "unknown"}-${idx}`;
110+
const baseHash = hashString(`${key}-${entry.name}`);
111+
const top = mapToRange(baseHash, 8, 92);
112+
const left = mapToRange(baseHash * 3, 12, 88);
113+
const fontSize = mapToRange(baseHash * 5, 0.9, 2.6);
114+
const rotate = mapToRange(baseHash * 7, -12, 12);
115+
const opacity = mapToRange(baseHash * 11, 0.35, 0.85);
116+
117+
return {
118+
key,
119+
label: entry.name,
120+
isRecent: idx >= recentThreshold,
121+
style: {
122+
top: `${top}%`,
123+
left: `${left}%`,
124+
fontSize: `${fontSize}rem`,
125+
opacity,
126+
transform: `translate(-50%, -50%) rotate(${rotate}deg)`,
127+
},
128+
};
129+
});
130+
}, [entries]);
131+
132+
const isSubmitDisabled = isSubmitting || !formValues.wristbandSerial.trim() || !formValues.name.trim();
133+
134+
return (
135+
<div className="min-h-screen bg-gradient-to-b from-[#F5F9FF] via-[#FFFFFF] to-[#EEF2FF] text-slate-900 flex flex-col">
136+
<section className="relative flex-1 overflow-hidden">
137+
<div
138+
className="absolute inset-0 opacity-60 pointer-events-none"
139+
style={{
140+
background:
141+
"radial-gradient(circle at 25% 20%, rgba(66,133,244,0.25), transparent 40%), " +
142+
"radial-gradient(circle at 75% 15%, rgba(251,188,5,0.20), transparent 35%), " +
143+
"radial-gradient(circle at 50% 70%, rgba(52,168,83,0.18), transparent 45%)",
144+
}}
145+
/>
146+
<div className="absolute inset-0 pointer-events-none select-none">
147+
{words.length ? (
148+
words.map((word) => (
149+
<span
150+
key={word.key}
151+
style={word.style}
152+
className={`absolute font-semibold tracking-wide drop-shadow-[0_2px_12px_rgba(15,23,42,0.08)] transition-all duration-700 ease-in-out ${word.isRecent ? "text-cblue" : "text-slate-500"}`}
153+
>
154+
{word.label}
155+
</span>
156+
))
157+
) : (
158+
!isLoading && (
159+
<div className="w-full h-full flex items-center justify-center text-slate-400 text-lg">
160+
아직 등록된 입장 정보가 없습니다.
161+
</div>
162+
)
163+
)}
164+
</div>
165+
</section>
166+
167+
<section className="relative z-10 w-full px-4 pb-12">
168+
<form
169+
onSubmit={handleSubmit}
170+
className="mx-auto w-full max-w-4xl bg-white border border-slate-200 rounded-[32px] p-8 flex flex-col gap-6 shadow-xl shadow-slate-100"
171+
>
172+
<div className="flex flex-col gap-3 text-slate-700">
173+
<h2 className="text-2xl font-semibold text-slate-900">입장 등록</h2>
174+
<p className="text-sm">현재 {entries.length}명의 게스트가 입장했습니다.</p>
175+
</div>
176+
<div className="flex flex-col gap-4 md:flex-row">
177+
<label className="flex-1 text-sm text-slate-600">
178+
<span className="block mb-2 font-medium text-slate-800">손목밴드 번호</span>
179+
<input
180+
name="wristbandSerial"
181+
value={formValues.wristbandSerial}
182+
onChange={handleChange}
183+
placeholder="예: 12"
184+
className="w-full rounded-2xl border border-slate-200 bg-slate-50 px-5 py-3 text-base text-slate-900 placeholder-slate-400 focus:outline-none focus:border-cblue focus:bg-white transition-all"
185+
autoComplete="off"
186+
inputMode="numeric"
187+
/>
188+
</label>
189+
<label className="flex-1 text-sm text-slate-600">
190+
<span className="block mb-2 font-medium text-slate-800">이름</span>
191+
<input
192+
name="name"
193+
value={formValues.name}
194+
onChange={handleChange}
195+
placeholder="이름을 입력해주세요"
196+
className="w-full rounded-2xl border border-slate-200 bg-slate-50 px-5 py-3 text-base text-slate-900 placeholder-slate-400 focus:outline-none focus:border-cblue focus:bg-white transition-all"
197+
autoComplete="off"
198+
/>
199+
</label>
200+
</div>
201+
<button
202+
type="submit"
203+
disabled={isSubmitDisabled}
204+
className="w-full rounded-2xl bg-gradient-to-r from-[#60A5FA] to-[#34D399] py-4 text-lg font-semibold text-white transition disabled:from-slate-200 disabled:to-slate-200 disabled:text-slate-400"
205+
>
206+
{isSubmitting ? "등록 중..." : "방명록 등록"}
207+
</button>
208+
<div className="text-sm text-slate-600 min-h-[20px]">
209+
{statusMessage && <p className="text-cgreen">{statusMessage}</p>}
210+
{lastSyncedAt && (
211+
<p className="text-slate-400 mt-1">
212+
마지막 동기화: {lastSyncedAt.toLocaleTimeString("ko-KR", { hour12: false })}
213+
</p>
214+
)}
215+
{error && <p className="text-cred mt-1">{error}</p>}
216+
</div>
217+
</form>
218+
</section>
219+
</div>
220+
);
221+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export default function HomecomingAdminLayout({children}) {
2+
return children;
3+
// <ApiCodeGuard requiredRole="ORGANIZER" nextOverride="/event/homecoming/admin">
4+
5+
// </ApiCodeGuard>
6+
}

0 commit comments

Comments
 (0)