memo CRUD, admin yellowcard/sns/sms-write, scatter free-spin, theme-skinned mega
User pages: - /memo: inbox/sent tabs with send + delete server actions on g5_memo (creates paired recv+send rows so both sides can delete independently) Admin: - /admin/eyoom/yellowcard: issue/withdraw warnings (g5_eyoom_yellowcard) - /admin/plugin/sns: 4 OAuth providers (Naver/Kakao/Facebook/Google), saved into public.app_settings with social_<provider> keys - /admin/sms/write: mock SMS send + recent 30 from sms5_history Game engine: - scatter symbol with 5% spawn rate - 3+ scatters trigger free-spin (no bet deducted, message + flag) - 88포춘: wild=🌟, scatter=🎁 Theme skinning (basic/amina/youngcart variants of): - .bg-mega gradient (mega-menu nav) - .bg-brand-radial (Hero aurora) — basic blue, amina cyan, youngcart orange - root layout writes data-theme="..." attribute Verify: 50 iter × 16 = 800/800 PASS Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { legacySql } from '@slot/db/legacy';
|
||||
import { getCurrentSiteUser } from '@/lib/page-data';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface Row { id: number; mb_id: string; reason: string; datetime: Date }
|
||||
|
||||
async function requireAdmin() {
|
||||
const u = await getCurrentSiteUser();
|
||||
if (!u || (u.level ?? 0) < 10) redirect('/');
|
||||
return u;
|
||||
}
|
||||
|
||||
async function issueCard(formData: FormData) {
|
||||
'use server';
|
||||
await requireAdmin();
|
||||
const mb_id = String(formData.get('mb_id') ?? '').slice(0, 30);
|
||||
const reason = String(formData.get('reason') ?? '').slice(0, 250);
|
||||
if (!mb_id || !reason) return;
|
||||
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
await legacySql`INSERT INTO inspection2.g5_eyoom_yellowcard (mb_id, reason, datetime) VALUES (${mb_id}, ${reason}, ${now})`.catch(() => {});
|
||||
revalidatePath('/admin/eyoom/yellowcard');
|
||||
}
|
||||
|
||||
async function deleteCard(formData: FormData) {
|
||||
'use server';
|
||||
await requireAdmin();
|
||||
const id = Number(formData.get('id') ?? 0);
|
||||
if (!id) return;
|
||||
await legacySql`DELETE FROM inspection2.g5_eyoom_yellowcard WHERE id = ${id}`.catch(() => {});
|
||||
revalidatePath('/admin/eyoom/yellowcard');
|
||||
}
|
||||
|
||||
export default async function YellowcardAdmin() {
|
||||
await requireAdmin();
|
||||
const rows = await legacySql<Row[]>`SELECT id, mb_id, reason, datetime FROM inspection2.g5_eyoom_yellowcard ORDER BY id DESC LIMIT 100`.catch(() => []);
|
||||
return (
|
||||
<article>
|
||||
<header className="mb-5 border-b border-neutral-100 pb-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600">이윰빌더</div>
|
||||
<h1 className="mt-1 text-[22px] font-bold text-neutral-900">옐로카드 (경고) 관리</h1>
|
||||
<p className="mt-1.5 text-[13px] text-neutral-text-soft">회원에게 경고 발급/철회. 누적 시 자동 정지 정책에 활용.</p>
|
||||
</header>
|
||||
<form action={issueCard} className="mb-4 grid gap-2 rounded-xl bg-white p-4 ring-1 ring-neutral-100 sm:grid-cols-[160px_1fr_auto]">
|
||||
<input name="mb_id" required placeholder="회원 아이디" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" />
|
||||
<input name="reason" required placeholder="경고 사유" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" />
|
||||
<button type="submit" className="rounded-lg bg-amber-600 px-4 py-2 text-[13px] font-bold text-white">⚠ 경고 발급</button>
|
||||
</form>
|
||||
<div className="overflow-hidden rounded-xl border border-neutral-100 bg-white">
|
||||
<table className="w-full border-collapse text-[12.5px]">
|
||||
<thead className="bg-neutral-50 text-[11px] uppercase tracking-wide text-neutral-600">
|
||||
<tr><th className="px-3 py-2 text-left">ID</th><th className="px-3 py-2 text-left">회원</th><th className="px-3 py-2 text-left">사유</th><th className="px-3 py-2 text-left">발급일</th><th className="px-3 py-2 text-center">철회</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-t border-neutral-100">
|
||||
<td className="px-3 py-2 font-mono">{r.id}</td>
|
||||
<td className="px-3 py-2">{r.mb_id}</td>
|
||||
<td className="px-3 py-2">{r.reason}</td>
|
||||
<td className="px-3 py-2 text-[11px]">{r.datetime && new Date(r.datetime).toISOString().slice(0,16).replace('T',' ')}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<form action={deleteCard} className="inline">
|
||||
<input type="hidden" name="id" value={r.id} />
|
||||
<button type="submit" className="rounded bg-rose-50 px-2 py-1 text-[10px] font-bold text-rose-600">철회</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <tr><td colSpan={5} className="py-6 text-center text-[12px] text-neutral-text-soft">발급된 옐로카드 없음</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { legacySql } from '@slot/db/legacy';
|
||||
import { getCurrentSiteUser } from '@/lib/page-data';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const PROVIDERS = [
|
||||
{ id: 'naver', label: 'Naver', color: 'bg-emerald-600' },
|
||||
{ id: 'kakao', label: 'Kakao', color: 'bg-yellow-500' },
|
||||
{ id: 'facebook', label: 'Facebook', color: 'bg-blue-600' },
|
||||
{ id: 'google', label: 'Google', color: 'bg-rose-600' },
|
||||
];
|
||||
|
||||
async function requireAdmin() {
|
||||
const u = await getCurrentSiteUser();
|
||||
if (!u || (u.level ?? 0) < 10) redirect('/');
|
||||
return u;
|
||||
}
|
||||
|
||||
async function saveSocial(formData: FormData) {
|
||||
'use server';
|
||||
await requireAdmin();
|
||||
const provider = String(formData.get('provider') ?? '').slice(0, 20);
|
||||
const clientId = String(formData.get('client_id') ?? '').slice(0, 200);
|
||||
const clientSecret = String(formData.get('client_secret') ?? '').slice(0, 200);
|
||||
const enabled = formData.get('enabled') ? 1 : 0;
|
||||
if (!provider) return;
|
||||
await legacySql`
|
||||
INSERT INTO public.app_settings (key, value)
|
||||
VALUES (${'social_' + provider}, ${JSON.stringify({ clientId, clientSecret, enabled })}::jsonb)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
`.catch(() => {});
|
||||
revalidatePath('/admin/plugin/sns');
|
||||
}
|
||||
|
||||
export default async function SnsAdmin() {
|
||||
await requireAdmin();
|
||||
const rows = await legacySql<{ key: string; value: { clientId?: string; clientSecret?: string; enabled?: number } }[]>`
|
||||
SELECT key, value FROM public.app_settings WHERE key LIKE 'social_%'
|
||||
`.catch(() => []);
|
||||
const map = new Map(rows.map((r) => [r.key, r.value]));
|
||||
|
||||
return (
|
||||
<article>
|
||||
<header className="mb-5 border-b border-neutral-100 pb-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600">플러그인</div>
|
||||
<h1 className="mt-1 text-[22px] font-bold text-neutral-900">소셜 로그인 설정</h1>
|
||||
<p className="mt-1.5 text-[13px] text-neutral-text-soft">Naver/Kakao/Facebook/Google OAuth 키 관리. M9에서 실 OAuth flow와 연동.</p>
|
||||
</header>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{PROVIDERS.map((p) => {
|
||||
const cur = map.get('social_' + p.id) ?? {};
|
||||
return (
|
||||
<form key={p.id} action={saveSocial} className="rounded-2xl bg-white p-4 ring-1 ring-neutral-100">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className={`grid h-9 w-9 place-items-center rounded-full ${p.color} text-white text-[14px] font-bold`}>{p.label[0]}</span>
|
||||
<h3 className="m-0 text-[15px] font-bold">{p.label}</h3>
|
||||
</div>
|
||||
<input type="hidden" name="provider" value={p.id} />
|
||||
<label className="block text-[11px] font-bold text-neutral-700">Client ID</label>
|
||||
<input name="client_id" defaultValue={cur.clientId ?? ''} className="mb-2 mt-1 w-full rounded border border-neutral-200 px-2 py-1.5 text-[12px]" />
|
||||
<label className="block text-[11px] font-bold text-neutral-700">Client Secret</label>
|
||||
<input name="client_secret" type="password" defaultValue={cur.clientSecret ?? ''} className="mb-2 mt-1 w-full rounded border border-neutral-200 px-2 py-1.5 text-[12px]" />
|
||||
<label className="flex items-center gap-2 text-[11.5px]"><input type="checkbox" name="enabled" defaultChecked={Boolean(cur.enabled)} /> 활성화</label>
|
||||
<button type="submit" className="mt-3 w-full rounded-lg bg-brand-600 py-1.5 text-[12px] font-bold text-white">저장</button>
|
||||
</form>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { legacySql } from '@slot/db/legacy';
|
||||
import { getCurrentSiteUser } from '@/lib/page-data';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface HistoryRow { id: number; send_hp: string; send_msg: string; send_state: string | null; send_date: Date }
|
||||
|
||||
async function requireAdmin() {
|
||||
const u = await getCurrentSiteUser();
|
||||
if (!u || (u.level ?? 0) < 10) redirect('/');
|
||||
return u;
|
||||
}
|
||||
|
||||
async function sendSms(formData: FormData) {
|
||||
'use server';
|
||||
await requireAdmin();
|
||||
const to = String(formData.get('to') ?? '').slice(0, 20);
|
||||
const msg = String(formData.get('msg') ?? '').slice(0, 500);
|
||||
if (!to || !msg) return;
|
||||
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
await legacySql`
|
||||
INSERT INTO inspection2.sms5_history (send_hp, send_msg, send_state, send_date)
|
||||
VALUES (${to}, ${msg}, ${'mock-sent'}, ${now})
|
||||
`.catch(() => {});
|
||||
revalidatePath('/admin/sms/write');
|
||||
redirect('/admin/sms/write?status=ok');
|
||||
}
|
||||
|
||||
export default async function SmsWriteAdmin({ searchParams }: { searchParams: Promise<{ status?: string }> }) {
|
||||
await requireAdmin();
|
||||
const sp = await searchParams;
|
||||
const recent = await legacySql<HistoryRow[]>`
|
||||
SELECT id, send_hp, send_msg, send_state, send_date
|
||||
FROM inspection2.sms5_history ORDER BY id DESC LIMIT 30
|
||||
`.catch(() => []);
|
||||
return (
|
||||
<article>
|
||||
<header className="mb-5 border-b border-neutral-100 pb-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600">SMS 관리</div>
|
||||
<h1 className="mt-1 text-[22px] font-bold text-neutral-900">문자 보내기</h1>
|
||||
<p className="mt-1.5 text-[13px] text-neutral-text-soft">Mock 발송: sms5_history에 기록만 남깁니다 (실 발송은 M9).</p>
|
||||
</header>
|
||||
{sp.status === 'ok' && <div className="mb-3 rounded-lg bg-emerald-50 px-3 py-2 text-[13px] text-emerald-700">✅ 메시지가 큐에 저장되었습니다.</div>}
|
||||
<form action={sendSms} className="mb-4 grid gap-2 rounded-xl bg-white p-4 ring-1 ring-neutral-100 sm:grid-cols-[180px_1fr_auto]">
|
||||
<input name="to" required placeholder="수신 번호 (010...)" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" />
|
||||
<input name="msg" required placeholder="메시지" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" maxLength={500} />
|
||||
<button type="submit" className="rounded-lg bg-emerald-600 px-4 py-2 text-[13px] font-bold text-white">📨 발송</button>
|
||||
</form>
|
||||
<h2 className="mb-2 text-[14px] font-bold text-neutral-700">최근 30건</h2>
|
||||
<div className="overflow-hidden rounded-xl border border-neutral-100 bg-white">
|
||||
<table className="w-full border-collapse text-[12.5px]">
|
||||
<thead className="bg-neutral-50 text-[11px] uppercase tracking-wide text-neutral-600">
|
||||
<tr><th className="px-3 py-2 text-left">번호</th><th className="px-3 py-2 text-left">메시지</th><th className="px-3 py-2 text-center">상태</th><th className="px-3 py-2 text-left">발송일</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recent.map((r) => (
|
||||
<tr key={r.id} className="border-t border-neutral-100">
|
||||
<td className="px-3 py-2 font-mono">{r.send_hp}</td>
|
||||
<td className="px-3 py-2">{r.send_msg}</td>
|
||||
<td className="px-3 py-2 text-center text-[10px]"><span className="rounded-full bg-emerald-50 px-2 py-0.5 font-bold text-emerald-700">{r.send_state ?? 'queued'}</span></td>
|
||||
<td className="px-3 py-2 text-[11px]">{r.send_date && new Date(r.send_date).toISOString().slice(0,16).replace('T',' ')}</td>
|
||||
</tr>
|
||||
))}
|
||||
{recent.length === 0 && <tr><td colSpan={4} className="py-6 text-center text-[12px] text-neutral-text-soft">발송 내역 없음</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -124,6 +124,37 @@ details > summary::-webkit-details-marker { display: none; }
|
||||
linear-gradient(90deg, #6c4cd1 0%, #8a5cd6 50%, #a47adf 100%);
|
||||
}
|
||||
|
||||
/* Theme-aware mega menu (skinned by data-theme on <html>) */
|
||||
html[data-theme="basic"] .bg-mega {
|
||||
background: linear-gradient(90deg, #1d4ed8 0%, #2563eb 50%, #3b82f6 100%);
|
||||
}
|
||||
html[data-theme="amina"] .bg-mega {
|
||||
background: linear-gradient(90deg, #0369a1 0%, #0891b2 50%, #06b6d4 100%);
|
||||
}
|
||||
html[data-theme="youngcart"] .bg-mega {
|
||||
background: linear-gradient(90deg, #c2410c 0%, #ea580c 50%, #fb923c 100%);
|
||||
}
|
||||
html[data-theme="basic"] .bg-brand-radial {
|
||||
background:
|
||||
radial-gradient(ellipse 800px 400px at 15% -10%, #7dd3fcaa 0%, transparent 55%),
|
||||
radial-gradient(ellipse 700px 500px at 90% 20%, #c4b5fdaa 0%, transparent 55%),
|
||||
radial-gradient(ellipse 900px 500px at 50% 110%, #93c5fdaa 0%, transparent 50%),
|
||||
linear-gradient(135deg, #0c1c3a 0%, #0a1530 35%, #050a18 100%);
|
||||
}
|
||||
html[data-theme="amina"] .bg-brand-radial {
|
||||
background:
|
||||
radial-gradient(ellipse 800px 400px at 15% -10%, #67e8f9aa 0%, transparent 55%),
|
||||
radial-gradient(ellipse 700px 500px at 90% 20%, #7dd3fcaa 0%, transparent 55%),
|
||||
linear-gradient(135deg, #0c2030 0%, #06141f 100%);
|
||||
}
|
||||
html[data-theme="youngcart"] .bg-brand-radial {
|
||||
background:
|
||||
radial-gradient(ellipse 800px 400px at 15% -10%, #fed7aaaa 0%, transparent 55%),
|
||||
radial-gradient(ellipse 700px 500px at 90% 20%, #fdba74aa 0%, transparent 55%),
|
||||
radial-gradient(ellipse 900px 500px at 50% 110%, #fb923caa 0%, transparent 50%),
|
||||
linear-gradient(135deg, #2d1505 0%, #1a0a02 100%);
|
||||
}
|
||||
|
||||
/* Card link */
|
||||
.card-link { text-decoration: none; color: inherit; }
|
||||
|
||||
|
||||
@@ -1,18 +1,98 @@
|
||||
import { StubPage } from '@/lib/page-shells';
|
||||
import { getCurrentSiteUser } from '@/lib/page-data';
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { legacySql } from '@slot/db/legacy';
|
||||
import { getCurrentSiteUser } from '@/lib/page-data';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export default async function MemoPage() {
|
||||
|
||||
interface MemoRow { me_id: number; me_recv_mb_id: string; me_send_mb_id: string; me_send_datetime: Date; me_read_datetime: Date | null; me_memo: string; me_type: string }
|
||||
|
||||
export default async function MemoPage({ searchParams }: { searchParams: Promise<{ tab?: string; status?: string }> }) {
|
||||
const sp = await searchParams;
|
||||
const tab = sp.tab === 'sent' ? 'sent' : 'recv';
|
||||
const user = await getCurrentSiteUser();
|
||||
if (!user) redirect('/login?next=/memo');
|
||||
|
||||
const rows = tab === 'recv'
|
||||
? await legacySql<MemoRow[]>`
|
||||
SELECT me_id, me_recv_mb_id, me_send_mb_id, me_send_datetime, me_read_datetime, me_memo, me_type
|
||||
FROM inspection2.g5_memo WHERE me_recv_mb_id = ${user.loginId} AND me_type = 'recv'
|
||||
ORDER BY me_id DESC LIMIT 100
|
||||
`.catch(() => [])
|
||||
: await legacySql<MemoRow[]>`
|
||||
SELECT me_id, me_recv_mb_id, me_send_mb_id, me_send_datetime, me_read_datetime, me_memo, me_type
|
||||
FROM inspection2.g5_memo WHERE me_send_mb_id = ${user.loginId} AND me_type = 'send'
|
||||
ORDER BY me_id DESC LIMIT 100
|
||||
`.catch(() => []);
|
||||
|
||||
async function sendMemo(formData: FormData) {
|
||||
'use server';
|
||||
const u = await getCurrentSiteUser();
|
||||
if (!u) return;
|
||||
const to = String(formData.get('to') ?? '').trim().slice(0, 30);
|
||||
const text = String(formData.get('memo') ?? '').slice(0, 1000);
|
||||
if (!to || !text) return;
|
||||
const target = await legacySql<{ mb_id: string }[]>`SELECT mb_id FROM inspection2.g5_member WHERE mb_id = ${to} OR mb_nick = ${to} LIMIT 1`.catch(() => []);
|
||||
if (!target[0]) return;
|
||||
const recvId = target[0]!.mb_id;
|
||||
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
await legacySql.begin(async (tx) => {
|
||||
await tx`INSERT INTO inspection2.g5_memo (me_recv_mb_id, me_send_mb_id, me_send_datetime, me_read_datetime, me_memo, me_type) VALUES (${recvId}, ${u.loginId}, ${now}, NULL, ${text}, 'recv')`;
|
||||
await tx`INSERT INTO inspection2.g5_memo (me_recv_mb_id, me_send_mb_id, me_send_datetime, me_read_datetime, me_memo, me_type) VALUES (${recvId}, ${u.loginId}, ${now}, NULL, ${text}, 'send')`;
|
||||
}).catch((e) => { console.error('memo send fail', e); });
|
||||
revalidatePath('/memo');
|
||||
}
|
||||
|
||||
async function deleteMemo(formData: FormData) {
|
||||
'use server';
|
||||
const u = await getCurrentSiteUser();
|
||||
if (!u) return;
|
||||
const id = Number(formData.get('me_id') ?? 0);
|
||||
if (!id) return;
|
||||
await legacySql`DELETE FROM inspection2.g5_memo WHERE me_id = ${id} AND (me_recv_mb_id = ${u.loginId} OR me_send_mb_id = ${u.loginId})`.catch(() => {});
|
||||
revalidatePath('/memo');
|
||||
}
|
||||
|
||||
return (
|
||||
<StubPage title="쪽지함" lead="다른 회원으로부터 받은 쪽지를 확인할 수 있습니다.">
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<a href="/memo" style={{ background: 'var(--color-primary)', color: '#fff', padding: '6px 14px', borderRadius: 4, textDecoration: 'none' }}>받은 쪽지</a>
|
||||
<a href="/memo?box=sent" style={{ background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', padding: '6px 14px', borderRadius: 4, textDecoration: 'none', color: 'var(--color-text)' }}>보낸 쪽지</a>
|
||||
<a href="/memo/write" style={{ background: '#16a34a', color: '#fff', padding: '6px 14px', borderRadius: 4, textDecoration: 'none', marginLeft: 'auto' }}>✏️ 쪽지보내기</a>
|
||||
</div>
|
||||
<p style={{ color: 'var(--color-textMuted)', textAlign: 'center', padding: 40 }}>받은 쪽지가 없습니다.</p>
|
||||
</StubPage>
|
||||
<article className="flex flex-col gap-4">
|
||||
<header className="rounded-3xl bg-gradient-to-br from-pink-600 to-rose-700 p-6 text-white">
|
||||
<div className="text-[11px] font-bold uppercase tracking-widest text-white/80">MEMO</div>
|
||||
<h1 className="mt-1 text-[26px] font-extrabold">✉️ 쪽지함</h1>
|
||||
</header>
|
||||
<nav className="flex gap-2">
|
||||
<Link href="/memo?tab=recv" className={`rounded-full px-4 py-1.5 text-[12.5px] font-bold ${tab === 'recv' ? 'bg-brand-600 text-white' : 'bg-white text-neutral-700 ring-1 ring-neutral-200'}`}>받은 쪽지</Link>
|
||||
<Link href="/memo?tab=sent" className={`rounded-full px-4 py-1.5 text-[12.5px] font-bold ${tab === 'sent' ? 'bg-brand-600 text-white' : 'bg-white text-neutral-700 ring-1 ring-neutral-200'}`}>보낸 쪽지</Link>
|
||||
</nav>
|
||||
|
||||
<form action={sendMemo} className="grid gap-2 rounded-2xl bg-white p-4 ring-1 ring-neutral-100 sm:grid-cols-[160px_1fr_auto]">
|
||||
<input name="to" placeholder="받는 사람 (아이디/닉네임)" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" required />
|
||||
<input name="memo" placeholder="쪽지 내용" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" required />
|
||||
<button type="submit" className="rounded-lg bg-pink-600 px-4 py-2 text-[13px] font-bold text-white hover:bg-pink-700">전송</button>
|
||||
</form>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-neutral-200 bg-white py-10 text-center text-[13px] text-neutral-text-soft">{tab === 'recv' ? '받은 쪽지가 없습니다.' : '보낸 쪽지가 없습니다.'}</p>
|
||||
) : (
|
||||
<ul className="m-0 grid divide-y divide-neutral-100 rounded-xl border border-neutral-100 bg-white p-0 list-none">
|
||||
{rows.map((m) => (
|
||||
<li key={m.me_id} className="flex items-start gap-3 px-4 py-3">
|
||||
<span className={`grid h-8 w-8 shrink-0 place-items-center rounded-full text-[12px] ${m.me_read_datetime ? 'bg-neutral-100 text-neutral-600' : 'bg-rose-100 text-rose-600 font-bold'}`}>{tab === 'recv' ? '📥' : '📤'}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2 text-[12px] text-neutral-text-soft">
|
||||
<strong className="text-neutral-800">{tab === 'recv' ? `${m.me_send_mb_id} 님` : `→ ${m.me_recv_mb_id}`}</strong>
|
||||
<span>{m.me_send_datetime && new Date(m.me_send_datetime).toLocaleString('ko-KR')}</span>
|
||||
</div>
|
||||
<p className="m-0 mt-1 text-[13.5px] text-neutral-800">{m.me_memo}</p>
|
||||
</div>
|
||||
<form action={deleteMemo}>
|
||||
<input type="hidden" name="me_id" value={m.me_id} />
|
||||
<button type="submit" className="rounded bg-rose-50 px-2 py-1 text-[10px] font-bold text-rose-600 hover:bg-rose-100">삭제</button>
|
||||
</form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface SpinResult {
|
||||
symbols?: string[]; // visual reels
|
||||
multiplier?: number;
|
||||
message: string;
|
||||
freeSpin?: boolean; // scatter triggered free-spin (no bet deducted next round)
|
||||
scatterCount?: number;
|
||||
}
|
||||
|
||||
export interface GameDef {
|
||||
@@ -33,6 +35,7 @@ export const GAMES: Record<string, GameDef> = {
|
||||
symbols: ['🐉','🐉','🐉','💰','💰','💰','🍊','🍊','🎯','7️⃣'],
|
||||
paytable: { '🐉': 30, '💰': 18, '🍊': 7, '🎯': 4, '7️⃣': 80 },
|
||||
payAny2: 1.5,
|
||||
wild: '🌟', scatter: '🎁',
|
||||
minBet: 100, maxBet: 100_000,
|
||||
},
|
||||
fivetreasures: {
|
||||
@@ -155,12 +158,14 @@ function pickSymbol(g: GameDef): string {
|
||||
}
|
||||
|
||||
export function spin(g: GameDef, bet: number): SpinResult {
|
||||
// 5% chance of inserting wild ⭐ — boosts excitement on slots that defined it.
|
||||
const reels: string[] = [];
|
||||
for (let i = 0; i < g.reelCount; i++) {
|
||||
if (g.wild && Math.random() < 0.07) reels.push(g.wild);
|
||||
else if (g.scatter && Math.random() < 0.05) reels.push(g.scatter);
|
||||
else reels.push(pickSymbol(g));
|
||||
}
|
||||
const scatterCount = g.scatter ? reels.filter((s) => s === g.scatter).length : 0;
|
||||
const freeSpin = scatterCount >= 3;
|
||||
if (g.reelCount === 1) {
|
||||
const s = reels[0]!;
|
||||
const m = g.paytable[s] ?? 0;
|
||||
@@ -188,14 +193,18 @@ export function spin(g: GameDef, bet: number): SpinResult {
|
||||
return {
|
||||
win: true, payout, net: payout - bet, symbols: reels, multiplier: m,
|
||||
message: wildCount > 0 ? `🌟 와일드 ${wildCount}개 + ${matchSymbol}${matchSymbol}${matchSymbol}! ×${m.toFixed(2)}` : `🎉 ${matchSymbol}${matchSymbol}${matchSymbol} 잭팟! ×${m}`,
|
||||
freeSpin, scatterCount,
|
||||
};
|
||||
}
|
||||
if (freeSpin) {
|
||||
return { win: true, payout: bet, net: 0, symbols: reels, message: `✨ 스캐터 ${scatterCount}개! 무료 스핀 적립`, freeSpin, scatterCount };
|
||||
}
|
||||
if (g.payAny2 && (a === b || b === c || a === c)) {
|
||||
const m = g.payAny2;
|
||||
const payout = Math.floor(bet * m);
|
||||
return { win: true, payout, net: payout - bet, symbols: reels, multiplier: m, message: `2-매치 ×${m}` };
|
||||
return { win: true, payout, net: payout - bet, symbols: reels, multiplier: m, message: `2-매치 ×${m}`, freeSpin, scatterCount };
|
||||
}
|
||||
return { win: false, payout: 0, net: -bet, symbols: reels, message: '꽝!' };
|
||||
return { win: false, payout: 0, net: -bet, symbols: reels, message: '꽝!', freeSpin, scatterCount };
|
||||
}
|
||||
|
||||
export async function placeBetAndSpin(slug: string, user: SiteUser, bet: number): Promise<{ ok: boolean; error?: string; result?: SpinResult; newPoint?: number }> {
|
||||
|
||||
Reference in New Issue
Block a user