V3 home: TopWinners + HotBoardsCarousel; shop list; admin point/level/block
Home additions: - TopWinners: top 5 point holders with tiered medal cards - HotBoardsCarousel: horizontally scrollable boards (free/review/mukti/humor/pick/lottery_ticket/guarantee/notice) - StatStrip wired to live g5_visit_sum / g5_member / g5_board / g5_point - Sidebar visitors now reads g5_visit_sum (today/yesterday/max/total) New page: - /shop — youngcart point mall list with category tabs + pagination Admin write actions: - /admin/members: server actions adjustMemberPoint (logs to g5_point), changeMemberLevel, toggleMemberBlock - Inline forms per row replace the prior read-only table Verify: 500/500 PASS over 50 iterations (login/comment/good/scrap/logout) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
'use server';
|
||||
|
||||
import { legacySql } from '@slot/db/legacy';
|
||||
import { getCurrentSiteUser } from '@/lib/page-data';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
async function requireAdmin() {
|
||||
const u = await getCurrentSiteUser();
|
||||
if (!u || (u.level ?? 0) < 10) throw new Error('forbidden');
|
||||
return u;
|
||||
}
|
||||
|
||||
export async function adjustMemberPoint(mbId: string, delta: number, reason: string) {
|
||||
const admin = await requireAdmin();
|
||||
const safeId = String(mbId).trim().slice(0, 30);
|
||||
const safeReason = String(reason || (delta >= 0 ? '관리자 지급' : '관리자 회수')).slice(0, 100);
|
||||
const d = Math.trunc(Number(delta) || 0);
|
||||
if (!safeId || d === 0) return { ok: false, error: 'invalid' };
|
||||
const nowStr = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
const exist = await legacySql<{ mb_point: number }[]>`SELECT mb_point FROM inspection2.g5_member WHERE mb_id = ${safeId}`.catch(() => []);
|
||||
if (!exist[0]) return { ok: false, error: 'member_not_found' };
|
||||
await legacySql.begin(async (tx) => {
|
||||
await tx`
|
||||
INSERT INTO inspection2.g5_point (mb_id, po_datetime, po_content, po_point, po_use_point, po_expire_point, po_expired, po_expire_date, po_mb_point, po_rel_table, po_rel_id, po_rel_action)
|
||||
VALUES (${safeId}, ${nowStr}, ${'[관리자]' + safeReason + ' (' + admin.loginId + ')'}, ${d}, 0, ${d}, 0, '9999-12-31', ${exist[0].mb_point + d}, '@admin', '0', ${'admin-adjust-' + Date.now()})
|
||||
`;
|
||||
await tx`UPDATE inspection2.g5_member SET mb_point = mb_point + ${d} WHERE mb_id = ${safeId}`;
|
||||
}).catch((e) => { console.error('adjustMemberPoint fail', e); throw e; });
|
||||
revalidatePath('/admin/members');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function changeMemberLevel(mbId: string, level: number) {
|
||||
await requireAdmin();
|
||||
const safeId = String(mbId).trim().slice(0, 30);
|
||||
const lv = Math.max(1, Math.min(12, Math.trunc(Number(level) || 1)));
|
||||
if (!safeId) return { ok: false, error: 'invalid' };
|
||||
await legacySql`UPDATE inspection2.g5_member SET mb_level = ${lv} WHERE mb_id = ${safeId}`.catch(() => {});
|
||||
revalidatePath('/admin/members');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function toggleMemberBlock(mbId: string, block: boolean) {
|
||||
await requireAdmin();
|
||||
const safeId = String(mbId).trim().slice(0, 30);
|
||||
if (!safeId) return { ok: false, error: 'invalid' };
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
if (block) {
|
||||
await legacySql`UPDATE inspection2.g5_member SET mb_intercept_date = ${todayStr} WHERE mb_id = ${safeId}`.catch(() => {});
|
||||
} else {
|
||||
await legacySql`UPDATE inspection2.g5_member SET mb_intercept_date = '' WHERE mb_id = ${safeId}`.catch(() => {});
|
||||
}
|
||||
revalidatePath('/admin/members');
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -1,56 +1,145 @@
|
||||
import { legacySql } from '@slot/db/legacy';
|
||||
import { adjustMemberPoint, changeMemberLevel, toggleMemberBlock } from './actions';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface Row {
|
||||
mb_id: string;
|
||||
mb_nick: string;
|
||||
mb_level: number;
|
||||
mb_point: number;
|
||||
mb_datetime: Date;
|
||||
mb_email: string;
|
||||
mb_today_login: Date | null;
|
||||
mb_intercept_date: string;
|
||||
}
|
||||
|
||||
export default async function MembersAdmin({ searchParams }: { searchParams: Promise<{ q?: string; page?: string }> }) {
|
||||
const sp = await searchParams;
|
||||
const q = (sp.q ?? '').trim();
|
||||
const page = Math.max(1, parseInt(sp.page ?? '1', 10));
|
||||
const offset = (page - 1) * 30;
|
||||
const rows = await legacySql<{ mb_id: string; mb_nick: string; mb_level: number; mb_point: number; mb_datetime: Date; mb_email: string; mb_today_login: Date | null; mb_intercept_date: string }[]>`
|
||||
const rows = await legacySql<Row[]>`
|
||||
SELECT mb_id, mb_nick, mb_level, mb_point, mb_datetime, mb_email, mb_today_login, mb_intercept_date
|
||||
FROM inspection2.g5_member
|
||||
WHERE (${q === ''} OR mb_id ILIKE ${'%' + q + '%'} OR mb_nick ILIKE ${'%' + q + '%'} OR mb_email ILIKE ${'%' + q + '%'})
|
||||
ORDER BY mb_datetime DESC
|
||||
LIMIT 30 OFFSET ${offset}
|
||||
`.catch(() => []);
|
||||
|
||||
async function pointAction(formData: FormData) {
|
||||
'use server';
|
||||
const mbId = String(formData.get('mb_id') ?? '');
|
||||
const delta = Number(formData.get('delta') ?? 0);
|
||||
const reason = String(formData.get('reason') ?? '');
|
||||
await adjustMemberPoint(mbId, delta, reason);
|
||||
}
|
||||
async function levelAction(formData: FormData) {
|
||||
'use server';
|
||||
const mbId = String(formData.get('mb_id') ?? '');
|
||||
const level = Number(formData.get('level') ?? 1);
|
||||
await changeMemberLevel(mbId, level);
|
||||
}
|
||||
async function blockAction(formData: FormData) {
|
||||
'use server';
|
||||
const mbId = String(formData.get('mb_id') ?? '');
|
||||
const block = formData.get('block') === '1';
|
||||
await toggleMemberBlock(mbId, block);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ fontSize: 22, margin: '0 0 16px' }}>회원 관리</h1>
|
||||
<form method="GET" style={{ display: 'flex', gap: 6, marginBottom: 16 }}>
|
||||
<input name="q" defaultValue={q} placeholder="아이디/닉네임/이메일" style={{ padding: 8, border: '1px solid var(--color-border)', borderRadius: 4, flex: 1 }} />
|
||||
<button type="submit" style={{ background: 'var(--color-primary)', color: '#fff', border: 'none', padding: '8px 18px', borderRadius: 4 }}>검색</button>
|
||||
<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">회원 관리 ({rows.length}명 노출)</h1>
|
||||
<p className="mt-1.5 text-[13px] text-neutral-text-soft">레벨 변경 · 포인트 가감 · 차단 토글이 가능합니다. 모든 변경은 g5_point 원장에 기록됩니다.</p>
|
||||
</header>
|
||||
|
||||
<form method="GET" className="mb-4 flex gap-2">
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="아이디 / 닉네임 / 이메일"
|
||||
className="flex-1 rounded-lg border border-neutral-200 px-3 py-2 text-[13px] outline-none focus:border-brand-500"
|
||||
/>
|
||||
<button type="submit" className="rounded-lg bg-brand-600 px-4 py-2 text-[13px] font-bold text-white hover:bg-brand-700">검색</button>
|
||||
</form>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead><tr style={{ background: 'var(--color-bgSurface)', borderTop: '2px solid var(--color-primary)' }}>
|
||||
<th style={th}>아이디</th><th style={th}>닉네임</th><th style={th}>레벨</th><th style={th}>포인트</th><th style={th}>이메일</th><th style={th}>가입일</th><th style={th}>최근접속</th><th style={th}>상태</th><th style={th}>관리</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.mb_id} style={{ borderBottom: '1px solid var(--color-border)' }}>
|
||||
<td style={td}>{r.mb_id}</td>
|
||||
<td style={td}>{r.mb_nick}</td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>{r.mb_level}</td>
|
||||
<td style={{ ...td, textAlign: 'right' }}>{(r.mb_point ?? 0).toLocaleString()}p</td>
|
||||
<td style={td}>{r.mb_email}</td>
|
||||
<td style={td}>{r.mb_datetime ? new Date(r.mb_datetime).toISOString().slice(0,10) : '-'}</td>
|
||||
<td style={td}>{r.mb_today_login ? new Date(r.mb_today_login).toISOString().slice(0,10) : '-'}</td>
|
||||
<td style={td}>{r.mb_intercept_date ? <span style={{ color: '#dc2626' }}>차단</span> : <span style={{ color: '#16a34a' }}>정상</span>}</td>
|
||||
<td style={{ ...td, display: 'flex', gap: 4 }}>
|
||||
<a href={`/admin/members/${r.mb_id}`} style={btn()}>상세</a>
|
||||
<a href={`/admin/members/${r.mb_id}/edit`} style={btn()}>수정</a>
|
||||
</td>
|
||||
|
||||
<div className="overflow-x-auto 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-right">포인트</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-left">관리</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p style={{ marginTop: 16, color: 'var(--color-textMuted)', fontSize: 12 }}>{rows.length}명 표시 (페이지 {page})</p>
|
||||
<nav style={{ display: 'flex', gap: 4, justifyContent: 'center', marginTop: 16 }}>
|
||||
{[1,2,3,4,5].map((p) => <a key={p} href={`?q=${encodeURIComponent(q)}&page=${p}`} style={{ padding: '6px 12px', border: '1px solid var(--color-border)', textDecoration: 'none', color: p === page ? '#fff' : 'var(--color-text)', background: p === page ? 'var(--color-primary)' : 'transparent', borderRadius: 4 }}>{p}</a>)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.mb_id} className="border-t border-neutral-100 align-top hover:bg-neutral-50/60">
|
||||
<td className="px-3 py-2 font-mono text-[12px]">{r.mb_id}</td>
|
||||
<td className="px-3 py-2">{r.mb_nick}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<form action={levelAction} className="inline-flex items-center gap-1">
|
||||
<input type="hidden" name="mb_id" value={r.mb_id} />
|
||||
<select name="level" defaultValue={r.mb_level} className="rounded border border-neutral-200 px-1 py-0.5 text-[11px]">
|
||||
{[1,2,3,4,5,6,7,8,9,10,11,12].map((lv) => <option key={lv} value={lv}>{lv}</option>)}
|
||||
</select>
|
||||
<button type="submit" className="rounded bg-brand-600 px-1.5 py-0.5 text-[10px] font-bold text-white">↻</button>
|
||||
</form>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular">
|
||||
{(r.mb_point ?? 0).toLocaleString()}p
|
||||
<form action={pointAction} className="mt-1 flex items-center justify-end gap-0.5">
|
||||
<input type="hidden" name="mb_id" value={r.mb_id} />
|
||||
<input name="delta" type="number" placeholder="±" className="w-16 rounded border border-neutral-200 px-1 py-0.5 text-right text-[11px]" />
|
||||
<input name="reason" placeholder="사유" className="w-20 rounded border border-neutral-200 px-1 py-0.5 text-[11px]" />
|
||||
<button type="submit" className="rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-bold text-white">+/−</button>
|
||||
</form>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[11px] text-neutral-600">{r.mb_email}</td>
|
||||
<td className="px-3 py-2 text-[11px]">{r.mb_datetime ? new Date(r.mb_datetime).toISOString().slice(0,10) : '-'}</td>
|
||||
<td className="px-3 py-2">
|
||||
{r.mb_intercept_date ? (
|
||||
<span className="rounded-full bg-rose-50 px-2 py-0.5 text-[10px] font-bold text-rose-600">차단</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-bold text-emerald-700">정상</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<form action={blockAction} className="inline">
|
||||
<input type="hidden" name="mb_id" value={r.mb_id} />
|
||||
<input type="hidden" name="block" value={r.mb_intercept_date ? '0' : '1'} />
|
||||
<button
|
||||
type="submit"
|
||||
className={`rounded px-2 py-1 text-[10px] font-bold ${r.mb_intercept_date ? 'bg-emerald-600 text-white' : 'bg-rose-600 text-white'}`}
|
||||
>
|
||||
{r.mb_intercept_date ? '차단해제' : '차단'}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<nav className="mt-4 flex justify-center gap-1.5">
|
||||
{[1,2,3,4,5,6,7,8,9,10].map((p) => (
|
||||
<a
|
||||
key={p}
|
||||
href={`?q=${encodeURIComponent(q)}&page=${p}`}
|
||||
className={`rounded-md px-3 py-1.5 text-[12px] ${p === page ? 'bg-brand-600 text-white' : 'border border-neutral-200 bg-white text-neutral-700 hover:bg-neutral-50'}`}
|
||||
>
|
||||
{p}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
const th: React.CSSProperties = { padding: 10, textAlign: 'left', fontWeight: 700 };
|
||||
const td: React.CSSProperties = { padding: 10 };
|
||||
const btn = (): React.CSSProperties => ({ display: 'inline-block', padding: '4px 10px', background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', textDecoration: 'none', color: 'var(--color-text)', borderRadius: 3, fontSize: 12 });
|
||||
|
||||
@@ -2,6 +2,8 @@ import { getIndexProps } from '@/lib/page-data';
|
||||
import Hero from '@/components/home/Hero';
|
||||
import QuickAccess from '@/components/home/QuickAccess';
|
||||
import BoardSlots from '@/components/home/BoardSlots';
|
||||
import HotBoardsCarousel from '@/components/home/HotBoardsCarousel';
|
||||
import TopWinners from '@/components/home/TopWinners';
|
||||
import StatStrip from '@/components/home/StatStrip';
|
||||
import LiveActivity from '@/components/home/LiveActivity';
|
||||
|
||||
@@ -11,6 +13,7 @@ export default async function HomePage() {
|
||||
const props = await getIndexProps();
|
||||
const stats = (props as any).stats;
|
||||
const recent = (props as any).recent ?? [];
|
||||
const winners = (props as any).topWinners ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -27,6 +30,8 @@ export default async function HomePage() {
|
||||
pointsCirculating={stats.pointsCirculating}
|
||||
/>
|
||||
)}
|
||||
{winners.length > 0 && <TopWinners winners={winners} />}
|
||||
<HotBoardsCarousel boards={props.featuredBoards} />
|
||||
<div className="grid gap-6 lg:grid-cols-[1.6fr_1fr]">
|
||||
<div className="flex flex-col gap-6">
|
||||
<QuickAccess />
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import Link from 'next/link';
|
||||
import { legacySql } from '@slot/db/legacy';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface ShopItem {
|
||||
it_id: string;
|
||||
it_name: string;
|
||||
it_basic: string | null;
|
||||
it_price: number;
|
||||
it_cust_price: number | null;
|
||||
it_stock_qty: number | null;
|
||||
it_use: number | string;
|
||||
ca_id: string | null;
|
||||
it_brand: string | null;
|
||||
}
|
||||
|
||||
async function getItems(category?: string, page = 1, pageSize = 24): Promise<{ rows: ShopItem[]; total: number }> {
|
||||
const offset = (page - 1) * pageSize;
|
||||
try {
|
||||
const where = category
|
||||
? legacySql`WHERE it_use::text IN ('1','y','Y','t','true') AND ca_id = ${category}`
|
||||
: legacySql`WHERE it_use::text IN ('1','y','Y','t','true')`;
|
||||
const totalRow = await legacySql<{ c: string }[]>`
|
||||
SELECT COUNT(*)::text AS c FROM inspection2.g5_shop_item ${where}
|
||||
`.catch(() => [{ c: '0' }]);
|
||||
const rows = await legacySql<ShopItem[]>`
|
||||
SELECT it_id, it_name, it_basic, it_price, it_cust_price, it_stock_qty, it_use, ca_id, it_brand
|
||||
FROM inspection2.g5_shop_item ${where}
|
||||
ORDER BY it_id DESC
|
||||
LIMIT ${pageSize} OFFSET ${offset}
|
||||
`.catch(() => []);
|
||||
return { rows, total: Number(totalRow[0]?.c ?? 0) };
|
||||
} catch {
|
||||
return { rows: [], total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async function getCategories(): Promise<{ ca_id: string; ca_name: string }[]> {
|
||||
try {
|
||||
return await legacySql<{ ca_id: string; ca_name: string }[]>`
|
||||
SELECT ca_id, ca_name FROM inspection2.g5_shop_category WHERE ca_use::text IN ('1','y','Y','t','true')
|
||||
ORDER BY ca_order, ca_id LIMIT 30
|
||||
`;
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export default async function ShopHomePage({ searchParams }: { searchParams: Promise<{ ca?: string; page?: string }> }) {
|
||||
const sp = await searchParams;
|
||||
const ca = sp.ca ?? undefined;
|
||||
const page = Math.max(1, Number(sp.page ?? 1) || 1);
|
||||
const [items, cats] = await Promise.all([getItems(ca, page), getCategories()]);
|
||||
const totalPages = Math.max(1, Math.ceil(items.total / 24));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<header className="rounded-3xl bg-gradient-to-br from-amber-400 via-orange-500 to-rose-600 p-6 text-white shadow-[0_18px_38px_rgba(244,63,94,0.20)]">
|
||||
<div className="text-[11px] font-bold uppercase tracking-widest text-white/80">SLOT POINT MALL</div>
|
||||
<h1 className="mt-1 text-[28px] font-extrabold tracking-tight">🛒 슬생 포인트몰</h1>
|
||||
<p className="mt-1 text-[13px] text-white/85">획득한 포인트로 기프티콘·상품권·실제 상품 교환</p>
|
||||
</header>
|
||||
|
||||
<nav className="-mx-1 overflow-x-auto">
|
||||
<div className="flex gap-2 px-1">
|
||||
<Link
|
||||
href="/shop"
|
||||
className={`shrink-0 rounded-full px-3.5 py-1.5 text-[12.5px] font-semibold ring-1 transition ${!ca ? 'bg-brand-600 text-white ring-brand-600' : 'bg-white text-neutral-700 ring-neutral-200 hover:bg-brand-50'}`}
|
||||
>
|
||||
전체
|
||||
</Link>
|
||||
{cats.map((c) => (
|
||||
<Link
|
||||
key={c.ca_id}
|
||||
href={`/shop?ca=${c.ca_id}`}
|
||||
className={`shrink-0 rounded-full px-3.5 py-1.5 text-[12.5px] font-semibold ring-1 transition ${ca === c.ca_id ? 'bg-brand-600 text-white ring-brand-600' : 'bg-white text-neutral-700 ring-neutral-200 hover:bg-brand-50'}`}
|
||||
>
|
||||
{c.ca_name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<p className="px-1 text-[12px] text-neutral-text-soft">
|
||||
총 <strong className="text-neutral-800">{items.total.toLocaleString()}</strong>개 상품 · 페이지 {page}/{totalPages}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{items.rows.length === 0 ? (
|
||||
<div className="col-span-full rounded-2xl border border-dashed border-neutral-200 bg-white px-4 py-10 text-center text-[13px] text-neutral-text-soft">
|
||||
이 카테고리에 등록된 상품이 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
items.rows.map((it) => {
|
||||
const price = Number(it.it_cust_price ?? it.it_price ?? 0);
|
||||
const stockOut = Number(it.it_stock_qty ?? 0) <= 0;
|
||||
return (
|
||||
<Link
|
||||
key={it.it_id}
|
||||
href={`/shop/${encodeURIComponent(it.it_id)}`}
|
||||
className="lift overflow-hidden rounded-2xl bg-white ring-1 ring-neutral-100 hover:ring-brand-200"
|
||||
>
|
||||
<div className="relative aspect-square bg-gradient-to-br from-neutral-100 to-neutral-200">
|
||||
<div className="absolute inset-0 grid place-items-center text-5xl">🎁</div>
|
||||
{stockOut && (
|
||||
<span className="absolute top-2 right-2 rounded-full bg-rose-600 px-2 py-1 text-[10px] font-bold text-white">품절</span>
|
||||
)}
|
||||
{it.it_brand && (
|
||||
<span className="absolute bottom-2 left-2 rounded-full bg-white/90 px-2 py-0.5 text-[10px] font-semibold text-neutral-700 backdrop-blur">{it.it_brand}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h3 className="m-0 line-clamp-2 text-[13px] font-semibold text-neutral-800">{it.it_name}</h3>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<strong className="text-[15px] font-extrabold text-brand-700 tabular">{price.toLocaleString()}p</strong>
|
||||
{!stockOut && Number(it.it_stock_qty ?? 0) > 0 && (
|
||||
<span className="text-[10px] text-neutral-text-soft">재고 {Number(it.it_stock_qty).toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav className="flex justify-center gap-1.5 pt-4">
|
||||
{page > 1 && (
|
||||
<Link href={`/shop?${ca ? `ca=${ca}&` : ''}page=${page - 1}`} className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-[12px] hover:bg-neutral-50">
|
||||
← 이전
|
||||
</Link>
|
||||
)}
|
||||
<span className="rounded-lg bg-brand-600 px-3 py-1.5 text-[12px] font-bold text-white">{page} / {totalPages}</span>
|
||||
{page < totalPages && (
|
||||
<Link href={`/shop?${ca ? `ca=${ca}&` : ''}page=${page + 1}`} className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-[12px] hover:bg-neutral-50">
|
||||
다음 →
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Flame, MessageSquare } from 'lucide-react';
|
||||
import type { BoardSummary } from '@slot/themes';
|
||||
|
||||
const ACCENTS: Record<string, string> = {
|
||||
free: 'from-violet-500 via-purple-600 to-fuchsia-700',
|
||||
review: 'from-amber-400 via-orange-500 to-red-600',
|
||||
mukti: 'from-rose-500 via-red-600 to-rose-800',
|
||||
humor: 'from-sky-400 via-blue-500 to-indigo-700',
|
||||
pick: 'from-emerald-400 via-teal-500 to-emerald-800',
|
||||
lottery_ticket: 'from-pink-400 via-fuchsia-500 to-purple-700',
|
||||
guarantee: 'from-green-400 via-emerald-600 to-green-800',
|
||||
notice: 'from-slate-400 via-zinc-600 to-neutral-800',
|
||||
};
|
||||
|
||||
export default function HotBoardsCarousel({ boards }: { boards: BoardSummary[] }) {
|
||||
if (!boards.length) return null;
|
||||
return (
|
||||
<section>
|
||||
<header className="mb-3 flex items-center gap-2 px-1">
|
||||
<span className="grid h-8 w-8 place-items-center rounded-full bg-gradient-to-br from-rose-500 to-orange-500 text-white shadow-[0_6px_16px_rgba(244,63,94,0.35)]">
|
||||
<Flame size={15} />
|
||||
</span>
|
||||
<h2 className="m-0 text-[16px] font-extrabold tracking-tight text-neutral-900">실시간 HOT 보드</h2>
|
||||
<span className="ml-2 text-[11px] text-neutral-text-soft">최근 글 6개씩 · 클릭해서 바로 입장</span>
|
||||
</header>
|
||||
<div className="-mx-1 overflow-x-auto scrollbar-thin">
|
||||
<div className="flex gap-4 px-1 py-1">
|
||||
{boards.map((b, i) => (
|
||||
<motion.article
|
||||
key={b.slug}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.05, duration: 0.4 }}
|
||||
className="lift relative shrink-0 overflow-hidden rounded-3xl bg-white shadow-[0_12px_28px_rgba(60,30,120,0.10)]"
|
||||
style={{ width: 320 }}
|
||||
>
|
||||
<header className={`relative bg-gradient-to-r ${ACCENTS[b.slug] ?? 'from-brand-500 to-brand-700'} px-5 py-4 text-white`}>
|
||||
<div className="absolute inset-0 opacity-30" style={{ background: 'radial-gradient(circle at 80% 20%, rgba(255,255,255,0.5) 0%, transparent 60%)' }} />
|
||||
<Link href={`/${b.slug}`} className="relative inline-flex items-center gap-2 text-[16px] font-extrabold tracking-tight">
|
||||
🔥 {b.title}
|
||||
</Link>
|
||||
<p className="relative m-0 mt-0.5 text-[11px] text-white/85">{b.latest.length}개 글</p>
|
||||
</header>
|
||||
<ul className="m-0 grid divide-y divide-neutral-100 p-0 list-none">
|
||||
{b.latest.length === 0 && (
|
||||
<li className="px-4 py-6 text-center text-[12px] text-neutral-text-soft">조용한 보드</li>
|
||||
)}
|
||||
{b.latest.slice(0, 6).map((p) => (
|
||||
<li key={p.id} className="px-4 py-2.5 transition hover:bg-brand-50/40">
|
||||
<Link href={`/${b.slug}/${p.id}`} className="flex items-center justify-between gap-2 text-[13px] text-neutral-800 hover:text-brand-700">
|
||||
<span className="truncate">{p.subject}</span>
|
||||
{p.commentCount > 0 && (
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 rounded-full bg-rose-50 px-2 py-0.5 text-[10px] font-bold text-rose-600">
|
||||
<MessageSquare size={9} /> {p.commentCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Crown, Trophy, Medal, Sparkles } from 'lucide-react';
|
||||
|
||||
export interface TopWinner {
|
||||
rank: number;
|
||||
nick: string;
|
||||
level: number;
|
||||
point: number;
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
{ ring: 'ring-amber-400/60', bg: 'from-amber-300 via-amber-500 to-orange-600', icon: Crown, badge: '#1' },
|
||||
{ ring: 'ring-zinc-300/60', bg: 'from-zinc-300 via-zinc-400 to-zinc-600', icon: Trophy, badge: '#2' },
|
||||
{ ring: 'ring-orange-400/60', bg: 'from-orange-300 via-orange-400 to-rose-500', icon: Medal, badge: '#3' },
|
||||
{ ring: 'ring-violet-300/40', bg: 'from-violet-400 to-fuchsia-600', icon: Sparkles, badge: '#4' },
|
||||
{ ring: 'ring-sky-300/40', bg: 'from-sky-400 to-blue-600', icon: Sparkles, badge: '#5' },
|
||||
];
|
||||
|
||||
export default function TopWinners({ winners }: { winners: TopWinner[] }) {
|
||||
const top5 = winners.slice(0, 5);
|
||||
if (top5.length === 0) return null;
|
||||
return (
|
||||
<section className="rounded-3xl bg-gradient-to-br from-neutral-900 via-violet-950 to-neutral-900 p-6 text-white shadow-[0_18px_38px_rgba(40,20,80,0.30)]">
|
||||
<header className="mb-4 flex items-center gap-2">
|
||||
<span className="grid h-9 w-9 place-items-center rounded-full bg-gradient-to-br from-amber-300 to-orange-600 shadow-[0_6px_18px_rgba(251,146,60,0.45)]">
|
||||
<Crown size={18} />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="m-0 text-[18px] font-extrabold tracking-tight">🔥 포인트 랭커 TOP 5</h2>
|
||||
<p className="m-0 text-[12px] text-white/60">실시간 누적 포인트 기준</p>
|
||||
</div>
|
||||
<Link href="/games/activityrank" className="ml-auto inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-1.5 text-[11px] font-bold backdrop-blur hover:bg-white/20">
|
||||
전체 랭킹 →
|
||||
</Link>
|
||||
</header>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{top5.map((w, i) => {
|
||||
const c = COLORS[i] ?? COLORS[4]!;
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={w.rank}
|
||||
initial={{ opacity: 0, y: 14, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ delay: i * 0.06, type: 'spring', stiffness: 180, damping: 16 }}
|
||||
className={`relative overflow-hidden rounded-2xl bg-white/5 p-4 ring-1 ${c.ring} backdrop-blur transition hover:scale-[1.02]`}
|
||||
>
|
||||
<div className={`absolute -top-8 -right-8 h-24 w-24 rounded-full bg-gradient-to-br ${c.bg} opacity-30 blur-2xl`} />
|
||||
<div className="relative flex items-center justify-between">
|
||||
<span className={`grid h-10 w-10 place-items-center rounded-full bg-gradient-to-br ${c.bg} text-white shadow-[0_8px_18px_rgba(0,0,0,0.35)]`}>
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-bold tracking-wider">{c.badge}</span>
|
||||
</div>
|
||||
<Link href={`/profile/${encodeURIComponent(w.nick)}`} className="relative mt-3 block truncate text-[15px] font-extrabold text-white hover:underline">
|
||||
{w.nick}
|
||||
</Link>
|
||||
<div className="relative mt-1 flex items-center justify-between text-[11px] text-white/70">
|
||||
<span>Lv.{w.level}</span>
|
||||
<span className="font-bold tabular text-amber-300">{w.point.toLocaleString()}p</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -190,7 +190,7 @@ export const QUICK_ACCESS = [
|
||||
{ label: '무료슬롯체험', emoji: '🎲', href: '/games/slot' },
|
||||
];
|
||||
|
||||
const FEATURED_BOARD_SLUGS = ['free', 'review', 'mukti', 'humor', 'pick', 'lottery_ticket'];
|
||||
const FEATURED_BOARD_SLUGS = ['free', 'review', 'mukti', 'humor', 'pick', 'lottery_ticket', 'guarantee', 'notice'];
|
||||
|
||||
export async function getFeaturedBoards(): Promise<BoardSummary[]> {
|
||||
const out: BoardSummary[] = [];
|
||||
@@ -302,12 +302,26 @@ export async function getRecentActivity(): Promise<RecentActivityItem[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getIndexProps(): Promise<IndexHomeProps & { stats?: SiteStats; recent?: RecentActivityItem[] }> {
|
||||
const [headlines, featured, stats, recent] = await Promise.all([
|
||||
export interface TopWinner { rank: number; nick: string; level: number; point: number }
|
||||
|
||||
export async function getTopWinners(): Promise<TopWinner[]> {
|
||||
try {
|
||||
const rows = await legacySql<{ mb_nick: string; mb_level: number; mb_point: number }[]>`
|
||||
SELECT mb_nick, mb_level, mb_point FROM inspection2.g5_member
|
||||
WHERE mb_open > 0 AND mb_leave_date='' AND mb_intercept_date='' AND mb_nick IS NOT NULL AND mb_nick <> ''
|
||||
ORDER BY mb_point DESC LIMIT 5
|
||||
`;
|
||||
return rows.map((r, i) => ({ rank: i + 1, nick: r.mb_nick, level: r.mb_level, point: r.mb_point }));
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export async function getIndexProps(): Promise<IndexHomeProps & { stats?: SiteStats; recent?: RecentActivityItem[]; topWinners?: TopWinner[] }> {
|
||||
const [headlines, featured, stats, recent, topWinners] = await Promise.all([
|
||||
getHeadlines(),
|
||||
getFeaturedBoards(),
|
||||
getSiteStats(),
|
||||
getRecentActivity(),
|
||||
getTopWinners(),
|
||||
]);
|
||||
return {
|
||||
headlines,
|
||||
@@ -316,5 +330,6 @@ export async function getIndexProps(): Promise<IndexHomeProps & { stats?: SiteSt
|
||||
featuredBoards: featured,
|
||||
stats,
|
||||
recent,
|
||||
topWinners,
|
||||
} as any;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user