diff --git a/next-app/apps/web/src/app/admin/eyoom/yellowcard/page.tsx b/next-app/apps/web/src/app/admin/eyoom/yellowcard/page.tsx new file mode 100644 index 0000000..f4976a3 --- /dev/null +++ b/next-app/apps/web/src/app/admin/eyoom/yellowcard/page.tsx @@ -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`SELECT id, mb_id, reason, datetime FROM inspection2.g5_eyoom_yellowcard ORDER BY id DESC LIMIT 100`.catch(() => []); + return ( +
+
+
이윰빌더
+

옐로카드 (경고) 관리

+

회원에게 경고 발급/철회. 누적 시 자동 정지 정책에 활용.

+
+
+ + + +
+
+ + + + + + {rows.map((r) => ( + + + + + + + + ))} + {rows.length === 0 && } + +
ID회원사유발급일철회
{r.id}{r.mb_id}{r.reason}{r.datetime && new Date(r.datetime).toISOString().slice(0,16).replace('T',' ')} +
+ + +
+
발급된 옐로카드 없음
+
+
+ ); +} diff --git a/next-app/apps/web/src/app/admin/plugin/sns/page.tsx b/next-app/apps/web/src/app/admin/plugin/sns/page.tsx new file mode 100644 index 0000000..c7734bd --- /dev/null +++ b/next-app/apps/web/src/app/admin/plugin/sns/page.tsx @@ -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 ( +
+
+
플러그인
+

소셜 로그인 설정

+

Naver/Kakao/Facebook/Google OAuth 키 관리. M9에서 실 OAuth flow와 연동.

+
+
+ {PROVIDERS.map((p) => { + const cur = map.get('social_' + p.id) ?? {}; + return ( +
+
+ {p.label[0]} +

{p.label}

+
+ + + + + + + +
+ ); + })} +
+
+ ); +} diff --git a/next-app/apps/web/src/app/admin/sms/write/page.tsx b/next-app/apps/web/src/app/admin/sms/write/page.tsx new file mode 100644 index 0000000..8b14199 --- /dev/null +++ b/next-app/apps/web/src/app/admin/sms/write/page.tsx @@ -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` + SELECT id, send_hp, send_msg, send_state, send_date + FROM inspection2.sms5_history ORDER BY id DESC LIMIT 30 + `.catch(() => []); + return ( +
+
+
SMS 관리
+

문자 보내기

+

Mock 발송: sms5_history에 기록만 남깁니다 (실 발송은 M9).

+
+ {sp.status === 'ok' &&
✅ 메시지가 큐에 저장되었습니다.
} +
+ + + +
+

최근 30건

+
+ + + + + + {recent.map((r) => ( + + + + + + + ))} + {recent.length === 0 && } + +
번호메시지상태발송일
{r.send_hp}{r.send_msg}{r.send_state ?? 'queued'}{r.send_date && new Date(r.send_date).toISOString().slice(0,16).replace('T',' ')}
발송 내역 없음
+
+
+ ); +} diff --git a/next-app/apps/web/src/app/globals.css b/next-app/apps/web/src/app/globals.css index ed1bc1f..42cd137 100644 --- a/next-app/apps/web/src/app/globals.css +++ b/next-app/apps/web/src/app/globals.css @@ -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[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; } diff --git a/next-app/apps/web/src/app/memo/page.tsx b/next-app/apps/web/src/app/memo/page.tsx index 761f1c8..5c70d46 100644 --- a/next-app/apps/web/src/app/memo/page.tsx +++ b/next-app/apps/web/src/app/memo/page.tsx @@ -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` + 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` + 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 ( - - -

받은 쪽지가 없습니다.

-
+
+
+
MEMO
+

✉️ 쪽지함

+
+ + +
+ + + +
+ + {rows.length === 0 ? ( +

{tab === 'recv' ? '받은 쪽지가 없습니다.' : '보낸 쪽지가 없습니다.'}

+ ) : ( +
    + {rows.map((m) => ( +
  • + {tab === 'recv' ? '📥' : '📤'} +
    +
    + {tab === 'recv' ? `${m.me_send_mb_id} 님` : `→ ${m.me_recv_mb_id}`} + {m.me_send_datetime && new Date(m.me_send_datetime).toLocaleString('ko-KR')} +
    +

    {m.me_memo}

    +
    +
    + + +
    +
  • + ))} +
+ )} +
); } diff --git a/next-app/apps/web/src/lib/game-engine.ts b/next-app/apps/web/src/lib/game-engine.ts index 34b4ad1..8abd70c 100644 --- a/next-app/apps/web/src/lib/game-engine.ts +++ b/next-app/apps/web/src/lib/game-engine.ts @@ -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 = { 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 }> {