mypage tabs + admin items/sendcost/eyoom-menu + cert/charge mocks + theme cookie

mypage:
- /mypage/posts: posts authored across active boards (g5_write_*)
- /mypage/scrap: scraped list with subject lookup + delete action
- /mypage/respond: who liked/disliked the user's posts (g5_board_good x g5_write_*)
- /mypage/profile: editable email/hp/homepage/signature/profile + open/mailling/sms toggles

admin:
- /admin/shop/items: inline edit price/stock/use + create
- /admin/shop/sendcost: zip range shipping rules CRUD
- /admin/eyoom/menu: per-theme menu inline edit (g5_eyoom_menu)

mocks:
- /auth/cert: identity verification (g5_member_cert_history insert + member update)
- /wallet/charge: point top-up (mb_point + g5_point ledger), 5 PG presets

theme:
- /api/ui/theme picks {basic|eyoom|amina|youngcart} into slot_theme cookie
- root layout reads cookie, injects --theme-primary CSS var + data-theme attribute

Verify: 600/600 PASS over 50 iterations

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-04-28 12:26:03 +09:00
parent c231d652fb
commit df72d3888a
11 changed files with 708 additions and 1 deletions
@@ -0,0 +1,82 @@
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 MenuRow { me_id: number; me_code: string; me_name: string; me_link: string; me_target: string; me_order: number; me_use: string; me_theme: string }
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
return u;
}
async function saveMenu(formData: FormData) {
'use server';
await requireAdmin();
const id = Number(formData.get('me_id') ?? 0);
const name = String(formData.get('me_name') ?? '').slice(0, 200);
const link = String(formData.get('me_link') ?? '').slice(0, 250);
const order = Number(formData.get('me_order') ?? 0) | 0;
const useFlag = formData.get('me_use') ? 'y' : 'n';
if (!id) return;
await legacySql`
UPDATE inspection2.g5_eyoom_menu
SET me_name = ${name}, me_link = ${link}, me_order = ${order}, me_use = ${useFlag}
WHERE me_id = ${id}
`.catch(() => {});
revalidatePath('/admin/eyoom/menu');
}
export default async function EyoomMenuAdmin({ searchParams }: { searchParams: Promise<{ theme?: string }> }) {
await requireAdmin();
const sp = await searchParams;
const theme = sp.theme || 'eb4_maga_005';
const rows = await legacySql<MenuRow[]>`
SELECT me_id, me_code, me_name, me_link, me_target, me_order, me_use, me_theme
FROM inspection2.g5_eyoom_menu WHERE me_theme = ${theme}
ORDER BY me_code, me_order LIMIT 300
`.catch(() => []);
const themes = await legacySql<{ me_theme: string }[]>`SELECT DISTINCT me_theme FROM inspection2.g5_eyoom_menu`.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"> ({rows.length})</h1>
<p className="mt-1.5 text-[13px] text-neutral-text-soft"> : <code className="rounded bg-neutral-100 px-1.5 py-0.5 text-[11px]">{theme}</code></p>
<nav className="mt-3 flex flex-wrap gap-1.5">
{themes.map((t) => (
<a key={t.me_theme} href={`?theme=${encodeURIComponent(t.me_theme)}`} className={`rounded-full px-3 py-1 text-[11px] font-bold ${t.me_theme === theme ? 'bg-brand-600 text-white' : 'bg-neutral-100 text-neutral-700 hover:bg-brand-100'}`}>{t.me_theme}</a>
))}
</nav>
</header>
<div className="overflow-x-auto rounded-xl border border-neutral-100 bg-white">
<table className="w-full border-collapse text-[12px]">
<thead className="bg-neutral-50 text-[10.5px] uppercase tracking-wide text-neutral-600">
<tr><th className="px-2 py-2 text-left"></th><th className="px-2 py-2 text-left"></th><th className="px-2 py-2 text-left"></th><th className="px-2 py-2 text-center"></th><th className="px-2 py-2 text-center"></th><th className="px-2 py-2 text-center"></th></tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.me_id} className="border-t border-neutral-100">
<td colSpan={6} className="px-2 py-1.5">
<form action={saveMenu} className="grid items-center gap-1 sm:grid-cols-[100px_220px_1fr_60px_60px_60px]">
<input type="hidden" name="me_id" value={r.me_id} />
<code className="font-mono text-[10.5px] text-neutral-500">{r.me_code}</code>
<input name="me_name" defaultValue={r.me_name} className="rounded border border-neutral-200 px-2 py-1 text-[11.5px]" />
<input name="me_link" defaultValue={r.me_link} className="rounded border border-neutral-200 px-2 py-1 font-mono text-[10.5px]" />
<input name="me_order" type="number" defaultValue={r.me_order} className="rounded border border-neutral-200 px-1 py-1 text-center text-[11px]" />
<label className="flex items-center justify-center"><input type="checkbox" name="me_use" defaultChecked={r.me_use === 'y' || r.me_use === '1' || r.me_use === 'Y'} /></label>
<button type="submit" className="rounded bg-brand-600 px-1.5 py-1 text-[10px] font-bold text-white"></button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</article>
);
}
@@ -0,0 +1,103 @@
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 ItemRow { it_id: string; it_name: string; it_price: number; it_cust_price: number | null; it_stock_qty: number | null; it_use: number; ca_id: string | null }
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
return u;
}
async function saveItem(formData: FormData) {
'use server';
await requireAdmin();
const id = String(formData.get('it_id') ?? '').slice(0, 20);
const name = String(formData.get('it_name') ?? '').slice(0, 250);
const price = Math.max(0, Math.trunc(Number(formData.get('it_cust_price') ?? 0)) || 0);
const stock = Math.max(0, Math.trunc(Number(formData.get('it_stock_qty') ?? 0)) || 0);
const use = formData.get('it_use') ? 1 : 0;
if (!id) return;
await legacySql`
UPDATE inspection2.g5_shop_item
SET it_name = ${name}, it_cust_price = ${price}, it_stock_qty = ${stock}, it_use = ${use}
WHERE it_id = ${id}
`.catch(() => {});
revalidatePath('/admin/shop/items');
}
async function createItem(formData: FormData) {
'use server';
await requireAdmin();
const id = String(formData.get('it_id') ?? '').slice(0, 20);
const name = String(formData.get('it_name') ?? '').slice(0, 250);
const price = Math.max(0, Math.trunc(Number(formData.get('it_price') ?? 0)) || 0);
const ca = String(formData.get('ca_id') ?? '').slice(0, 10);
if (!id || !name) return;
const today = new Date().toISOString().slice(0, 19).replace('T', ' ');
await legacySql`
INSERT INTO inspection2.g5_shop_item
(it_id, ca_id, ca_id2, ca_id3, it_skin, it_mobile_skin, it_name, it_price, it_cust_price, it_basic, it_explan, it_use, it_stock_qty, it_time, it_update_time)
VALUES (${id}, ${ca}, '', '', 'basic', 'basic', ${name}, ${price}, ${price}, '', '', 1, 100, ${today}, ${today})
`.catch(() => {});
revalidatePath('/admin/shop/items');
}
export default async function ItemsAdmin() {
await requireAdmin();
const rows = await legacySql<ItemRow[]>`
SELECT it_id, it_name, it_price, it_cust_price, it_stock_qty, it_use, ca_id
FROM inspection2.g5_shop_item ORDER BY it_id DESC LIMIT 50
`.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"> ({rows.length})</h1>
</header>
<form action={createItem} className="mb-4 grid gap-2 rounded-xl bg-white p-4 ring-1 ring-neutral-100 sm:grid-cols-[120px_140px_1fr_140px_auto]">
<input name="it_id" required placeholder="상품 ID" className="rounded border border-neutral-200 px-2 py-2 text-[13px]" />
<input name="ca_id" placeholder="카테고리" className="rounded border border-neutral-200 px-2 py-2 text-[13px]" />
<input name="it_name" required placeholder="상품명" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" />
<input name="it_price" type="number" placeholder="가격(p)" defaultValue={1000} className="rounded border border-neutral-200 px-2 py-2 text-right text-[13px]" />
<button type="submit" className="rounded-lg bg-brand-600 px-4 py-2 text-[13px] font-bold text-white">+ </button>
</form>
<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">ID</th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-center"></th>
<th className="px-3 py-2 text-center"></th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.it_id} className="border-t border-neutral-100 align-top">
<td className="px-3 py-2 font-mono text-[11px]">{r.it_id}</td>
<td colSpan={5} className="px-3 py-2">
<form action={saveItem} className="grid items-center gap-1.5 sm:grid-cols-[1fr_120px_100px_60px_80px]">
<input type="hidden" name="it_id" value={r.it_id} />
<input name="it_name" defaultValue={r.it_name} className="rounded border border-neutral-200 px-2 py-1 text-[12px]" />
<input name="it_cust_price" type="number" defaultValue={Number(r.it_cust_price ?? r.it_price ?? 0)} className="rounded border border-neutral-200 px-2 py-1 text-right text-[12px]" />
<input name="it_stock_qty" type="number" defaultValue={Number(r.it_stock_qty ?? 0)} className="rounded border border-neutral-200 px-2 py-1 text-right text-[12px]" />
<label className="flex items-center justify-center gap-1 text-[11px]"><input type="checkbox" name="it_use" defaultChecked={Number(r.it_use) > 0} /></label>
<button type="submit" className="rounded bg-brand-600 px-2 py-1 text-[11px] font-bold text-white"></button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</article>
);
}
@@ -0,0 +1,85 @@
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 SendcostRow { sc_id: number; sc_zip_from: string; sc_zip_to: string; sc_price: number }
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
return u;
}
async function createRule(formData: FormData) {
'use server';
await requireAdmin();
const from = String(formData.get('from') ?? '').slice(0, 7);
const to = String(formData.get('to') ?? '').slice(0, 7);
const price = Math.max(0, Math.trunc(Number(formData.get('price') ?? 0)) || 0);
if (!from || !to) return;
await legacySql`
INSERT INTO inspection2.g5_shop_sendcost (sc_id, sc_zip_from, sc_zip_to, sc_price)
VALUES (DEFAULT, ${from}, ${to}, ${price})
`.catch(() => {});
revalidatePath('/admin/shop/sendcost');
}
async function deleteRule(formData: FormData) {
'use server';
await requireAdmin();
const id = Number(formData.get('sc_id') ?? 0);
if (!id) return;
await legacySql`DELETE FROM inspection2.g5_shop_sendcost WHERE sc_id = ${id}`.catch(() => {});
revalidatePath('/admin/shop/sendcost');
}
export default async function SendcostAdmin() {
await requireAdmin();
const rows = await legacySql<SendcostRow[]>`
SELECT sc_id, sc_zip_from, sc_zip_to, sc_price FROM inspection2.g5_shop_sendcost
ORDER BY sc_zip_from LIMIT 200
`.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"> (g5_shop_sendcost)</p>
</header>
<form action={createRule} className="mb-4 grid gap-2 rounded-xl bg-white p-4 ring-1 ring-neutral-100 sm:grid-cols-[120px_120px_120px_auto]">
<input name="from" required placeholder="우편번호 from" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" />
<input name="to" required placeholder="우편번호 to" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" />
<input name="price" type="number" required placeholder="추가배송비" className="rounded border border-neutral-200 px-3 py-2 text-right text-[13px]" />
<button type="submit" className="rounded-lg bg-brand-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">From</th><th className="px-3 py-2 text-left">To</th><th className="px-3 py-2 text-right"></th><th className="px-3 py-2 text-center"></th></tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.sc_id} className="border-t border-neutral-100">
<td className="px-3 py-2 font-mono">{r.sc_id}</td>
<td className="px-3 py-2">{r.sc_zip_from}</td>
<td className="px-3 py-2">{r.sc_zip_to}</td>
<td className="px-3 py-2 text-right tabular">{Number(r.sc_price ?? 0).toLocaleString()}</td>
<td className="px-3 py-2 text-center">
<form action={deleteRule} className="inline">
<input type="hidden" name="sc_id" value={r.sc_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,23 @@
import { NextRequest, NextResponse } from 'next/server';
const VALID = new Set(['basic', 'eyoom', 'amina', 'youngcart']);
export async function POST(req: NextRequest) {
const form = await req.formData();
const themeRaw = String(form.get('theme') ?? '').trim().toLowerCase();
const theme = VALID.has(themeRaw) ? themeRaw : 'eyoom';
const ref = req.headers.get('referer');
const target = new URL(ref ?? '/', req.url);
const res = NextResponse.redirect(target, { status: 303 });
res.cookies.set('slot_theme', theme, { path: '/', maxAge: 60 * 60 * 24 * 90, sameSite: 'lax' });
return res;
}
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const t = url.searchParams.get('t');
const theme = t && VALID.has(t.toLowerCase()) ? t.toLowerCase() : 'eyoom';
const res = NextResponse.redirect(new URL('/', req.url), { status: 303 });
res.cookies.set('slot_theme', theme, { path: '/', maxAge: 60 * 60 * 24 * 90, sameSite: 'lax' });
return res;
}
@@ -0,0 +1,50 @@
// Mock identity verification — places a row in g5_member_cert_history.
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 CertPage({ searchParams }: { searchParams: Promise<{ next?: string; status?: string }> }) {
const sp = await searchParams;
const user = await getCurrentSiteUser();
if (!user) redirect('/login?next=/auth/cert');
async function pretendCert(formData: FormData) {
'use server';
const u = await getCurrentSiteUser();
if (!u) return;
const provider = String(formData.get('provider') ?? 'mock').slice(0, 30);
const di = 'di-' + Math.random().toString(36).slice(2, 16);
const ci = 'ci-' + Math.random().toString(36).slice(2, 22);
const nowStr = new Date().toISOString().slice(0, 19).replace('T', ' ');
await legacySql`
INSERT INTO inspection2.g5_member_cert_history
(mb_id, mb_name, mb_hp, mb_certify, mb_birth, mb_sex, mb_certify_dttm, mb_certify_dupinfo, mb_certify_uniqueno, mb_certify_provider, mb_ipin_dupinfo, mb_ipin_uniqueno)
VALUES
(${u.loginId}, ${u.nick}, '01000000000', '본인인증', '2000-01-01', 'M', ${nowStr}, ${di}, ${ci}, ${provider}, '', '')
`.catch(() => {});
await legacySql`UPDATE inspection2.g5_member SET mb_hp = '01000000000', mb_birth = '2000-01-01' WHERE mb_id = ${u.loginId}`.catch(() => {});
revalidatePath('/auth/cert');
redirect((sp.next || '/mypage') + '?cert=ok');
}
return (
<article className="mx-auto max-w-md rounded-3xl bg-white p-6 ring-1 ring-neutral-100 shadow-[0_18px_38px_rgba(60,30,120,0.10)]">
<h1 className="m-0 text-[22px] font-extrabold tracking-tight">🔐 </h1>
<p className="mt-1 text-[13px] text-neutral-text-soft"> (KCP//Okname) M9에서 . mock입니다.</p>
<form action={pretendCert} className="mt-5 grid gap-3">
<label className="block text-[12px] font-bold text-neutral-700"></label>
<select name="provider" defaultValue="mock" className="rounded-lg border border-neutral-200 px-3 py-2 text-[13px]">
<option value="mock"> </option>
<option value="kcp">KCP</option>
<option value="inicis"></option>
<option value="okname">Okname</option>
</select>
<button type="submit" className="rounded-full bg-gradient-to-r from-brand-600 to-fuchsia-600 px-6 py-2.5 text-[14px] font-extrabold text-white"> (mock)</button>
</form>
{sp.status === 'ok' && <p className="mt-3 rounded-lg bg-emerald-50 px-3 py-2 text-[12px] text-emerald-700"> </p>}
</article>
);
}
+12 -1
View File
@@ -18,6 +18,14 @@ export default async function RootLayout({ children }: { children: React.ReactNo
const pathname = await getCurrentPathname();
const c = await cookies();
const isDark = c.get('slot_dark')?.value === '1';
const themeId = c.get('slot_theme')?.value ?? 'eyoom';
const themeAccent: Record<string, { primary: string; primaryDark: string; bg: string }> = {
basic: { primary: '#2563eb', primaryDark: '#1d4ed8', bg: '#f8fafc' },
eyoom: { primary: '#7c3aed', primaryDark: '#6d28d9', bg: '#f7f5fb' },
amina: { primary: '#0ea5e9', primaryDark: '#0369a1', bg: '#f8fafc' },
youngcart: { primary: '#ea580c', primaryDark: '#c2410c', bg: '#fff7ed' },
};
const t = themeAccent[themeId] ?? themeAccent.eyoom!;
const [user, popularTags, rankings, menus, visitors] = await Promise.all([
getCurrentSiteUser(),
getPopularTags(),
@@ -37,7 +45,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo
const hideSidebar = pathname.startsWith('/admin') || pathname.startsWith('/mypage') || pathname === '/login' || pathname === '/register';
return (
<html lang="ko" className={isDark ? 'dark' : ''}>
<html lang="ko" className={isDark ? 'dark' : ''} data-theme={themeId}>
<head>
<style>{`:root{--theme-primary:${t.primary};--theme-primary-dark:${t.primaryDark};--theme-bg:${t.bg};}body{background:var(--theme-bg);}`}</style>
</head>
<body>
{!hideEverything && <Header user={user} menus={menus} isDark={isDark} />}
<div className="mx-auto max-w-[1280px] px-6 py-6">
@@ -0,0 +1,63 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
export const dynamic = 'force-dynamic';
interface PostRow {
bo_table: string; wr_id: number; wr_subject: string; wr_datetime: Date; wr_hit: number; wr_good: number; wr_comment: number;
}
export default async function MyPostsPage() {
const user = await getCurrentSiteUser();
if (!user) redirect('/login?next=/mypage/posts');
const boards = await legacySql<{ bo_table: string }[]>`SELECT bo_table FROM inspection2.g5_board WHERE bo_use_search > 0 OR bo_use_search IS NULL LIMIT 30`.catch(() => []);
const all: PostRow[] = [];
for (const b of boards) {
if (!/^[a-z0-9_]+$/i.test(b.bo_table)) continue;
const tbl = `inspection2.g5_write_${b.bo_table}`;
const rows = await legacySql<PostRow[]>`
SELECT ${b.bo_table}::text AS bo_table, wr_id, wr_subject, wr_datetime, wr_hit, wr_good, wr_comment
FROM ${legacySql.unsafe(tbl)}
WHERE mb_id = ${user.loginId} AND wr_is_comment = 0
ORDER BY wr_id DESC LIMIT 10
`.catch(() => []);
all.push(...rows);
}
all.sort((a, b) => new Date(b.wr_datetime).getTime() - new Date(a.wr_datetime).getTime());
const posts = all.slice(0, 50);
return (
<article className="flex flex-col gap-4">
<header className="rounded-2xl bg-gradient-to-br from-brand-700 to-fuchsia-700 p-5 text-white">
<div className="text-[11px] font-bold uppercase tracking-widest text-white/80">MY POSTS</div>
<h1 className="mt-1 text-[24px] font-extrabold">📝 </h1>
<p className="mt-0.5 text-[12.5px] text-white/85"> 50 ( {all.length} )</p>
</header>
{posts.length === 0 ? (
<p className="rounded-xl border border-dashed border-neutral-200 bg-white py-10 text-center text-[13px] text-neutral-text-soft"> .</p>
) : (
<ul className="m-0 grid divide-y divide-neutral-100 rounded-xl border border-neutral-100 bg-white p-0 list-none">
{posts.map((p) => (
<li key={`${p.bo_table}-${p.wr_id}`} className="px-4 py-3 hover:bg-brand-50/40">
<Link href={`/${p.bo_table}/${p.wr_id}`} className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-bold text-brand-700">/{p.bo_table}</span>
<span className="ml-2 truncate text-[13.5px] font-medium text-neutral-800">{p.wr_subject}</span>
</div>
<div className="flex shrink-0 gap-3 text-[11px] text-neutral-text-soft">
<span>👁 {p.wr_hit ?? 0}</span>
<span>👍 {p.wr_good ?? 0}</span>
<span>💬 {p.wr_comment ?? 0}</span>
<span>{p.wr_datetime ? new Date(p.wr_datetime).toISOString().slice(0,10) : '-'}</span>
</div>
</Link>
</li>
))}
</ul>
)}
<Link href="/mypage" className="text-center text-[12px] text-neutral-text-soft hover:text-brand-700"> </Link>
</article>
);
}
@@ -0,0 +1,93 @@
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 MemberRow {
mb_id: string; mb_nick: string; mb_email: string; mb_hp: string;
mb_homepage: string; mb_signature: string; mb_profile: string;
mb_open: number; mb_mailling: number; mb_sms: number; mb_recv_email: number;
}
export default async function ProfilePage() {
const user = await getCurrentSiteUser();
if (!user) redirect('/login?next=/mypage/profile');
const rows = await legacySql<MemberRow[]>`
SELECT mb_id, mb_nick, mb_email, mb_hp, mb_homepage, mb_signature, mb_profile, mb_open, mb_mailling, mb_sms, mb_recv_email
FROM inspection2.g5_member WHERE mb_id = ${user.loginId} LIMIT 1
`.catch(() => []);
const m = rows[0];
if (!m) redirect('/');
async function saveProfile(formData: FormData) {
'use server';
const u = await getCurrentSiteUser();
if (!u) return;
const email = String(formData.get('email') ?? '').slice(0, 100);
const hp = String(formData.get('hp') ?? '').slice(0, 20);
const home = String(formData.get('homepage') ?? '').slice(0, 200);
const sig = String(formData.get('signature') ?? '').slice(0, 250);
const profile = String(formData.get('profile') ?? '').slice(0, 5000);
const open = formData.get('open') ? 1 : 0;
const mailling = formData.get('mailling') ? 1 : 0;
const sms = formData.get('sms') ? 1 : 0;
await legacySql`
UPDATE inspection2.g5_member
SET mb_email = ${email}, mb_hp = ${hp}, mb_homepage = ${home},
mb_signature = ${sig}, mb_profile = ${profile},
mb_open = ${open}, mb_mailling = ${mailling}, mb_sms = ${sms}
WHERE mb_id = ${u.loginId}
`.catch(() => {});
revalidatePath('/mypage/profile');
}
return (
<article className="flex flex-col gap-4">
<header className="rounded-2xl bg-gradient-to-br from-violet-700 to-fuchsia-700 p-5 text-white">
<div className="text-[11px] font-bold uppercase tracking-widest text-white/80">MY PROFILE</div>
<h1 className="mt-1 text-[24px] font-extrabold">👤 </h1>
<p className="mt-0.5 text-[12.5px] text-white/85">{m.mb_id} · {m.mb_nick}</p>
</header>
<form action={saveProfile} className="grid gap-4 rounded-2xl border border-neutral-100 bg-white p-5">
<Field name="email" label="이메일" defaultValue={m.mb_email} type="email" />
<Field name="hp" label="휴대폰" defaultValue={m.mb_hp} />
<Field name="homepage" label="홈페이지" defaultValue={m.mb_homepage} />
<Field name="signature" label="서명" defaultValue={m.mb_signature} />
<div>
<label className="block text-[12px] font-bold text-neutral-700"></label>
<textarea name="profile" defaultValue={m.mb_profile ?? ''} rows={5} className="mt-1 w-full rounded-lg border border-neutral-200 px-3 py-2 text-[13px]" />
</div>
<fieldset className="grid grid-cols-3 gap-2 text-[12.5px]">
<Check name="open" label="프로필 공개" defaultChecked={!!m.mb_open} />
<Check name="mailling" label="메일 수신" defaultChecked={!!m.mb_mailling} />
<Check name="sms" label="SMS 수신" defaultChecked={!!m.mb_sms} />
</fieldset>
<button type="submit" className="self-end rounded-full bg-brand-600 px-6 py-2 text-[13px] font-bold text-white hover:bg-brand-700"></button>
</form>
</article>
);
}
function Field({ name, label, defaultValue, type = 'text' }: { name: string; label: string; defaultValue: string | null | undefined; type?: string }) {
return (
<div>
<label className="block text-[12px] font-bold text-neutral-700">{label}</label>
<input
name={name}
type={type}
defaultValue={defaultValue ?? ''}
className="mt-1 w-full rounded-lg border border-neutral-200 px-3 py-2 text-[13px]"
/>
</div>
);
}
function Check({ name, label, defaultChecked }: { name: string; label: string; defaultChecked: boolean }) {
return (
<label className="flex items-center gap-2 rounded-lg border border-neutral-200 px-3 py-2">
<input type="checkbox" name={name} defaultChecked={defaultChecked} />
<span>{label}</span>
</label>
);
}
@@ -0,0 +1,57 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
export const dynamic = 'force-dynamic';
interface RespondRow { bo_table: string; wr_id: number; wr_subject: string; reactor: string; bg_flag: string; bg_datetime: Date }
export default async function MyRespondPage() {
const user = await getCurrentSiteUser();
if (!user) redirect('/login?next=/mypage/respond');
const recentBoardRows = await legacySql<{ bo_table: string }[]>`SELECT bo_table FROM inspection2.g5_board LIMIT 30`.catch(() => []);
const respondsAll: RespondRow[] = [];
for (const b of recentBoardRows) {
if (!/^[a-z0-9_]+$/i.test(b.bo_table)) continue;
const tbl = `inspection2.g5_write_${b.bo_table}`;
const r = await legacySql<RespondRow[]>`
SELECT ${b.bo_table}::text AS bo_table, w.wr_id, w.wr_subject, g.mb_id AS reactor, g.bg_flag, g.bg_datetime
FROM inspection2.g5_board_good g
INNER JOIN ${legacySql.unsafe(tbl)} w ON w.wr_id = g.wr_id
WHERE g.bo_table = ${b.bo_table} AND w.mb_id = ${user.loginId} AND w.wr_is_comment = 0
ORDER BY g.bg_no DESC LIMIT 50
`.catch(() => []);
respondsAll.push(...r);
}
respondsAll.sort((a, b) => new Date(b.bg_datetime).getTime() - new Date(a.bg_datetime).getTime());
const items = respondsAll.slice(0, 100);
return (
<article className="flex flex-col gap-4">
<header className="rounded-2xl bg-gradient-to-br from-emerald-600 to-teal-700 p-5 text-white">
<div className="text-[11px] font-bold uppercase tracking-widest text-white/80">MY RESPONDS</div>
<h1 className="mt-1 text-[24px] font-extrabold">👍 </h1>
<p className="mt-0.5 text-[12.5px] text-white/85"> 100</p>
</header>
{items.length === 0 ? (
<p className="rounded-xl border border-dashed border-neutral-200 bg-white py-10 text-center text-[13px] text-neutral-text-soft"> .</p>
) : (
<ul className="m-0 grid divide-y divide-neutral-100 rounded-xl border border-neutral-100 bg-white p-0 list-none">
{items.map((r, i) => (
<li key={`${r.bo_table}-${r.wr_id}-${i}`} className="flex items-center gap-3 px-4 py-2.5">
<span className={`grid h-7 w-7 place-items-center rounded-full text-[12px] ${r.bg_flag === 'G' ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-600'}`}>
{r.bg_flag === 'G' ? '👍' : '👎'}
</span>
<strong className="text-[12px] text-neutral-700">{r.reactor}</strong>
<span className="text-[12px] text-neutral-text-soft"></span>
<Link href={`/${r.bo_table}/${r.wr_id}`} className="flex-1 truncate text-[13px] hover:text-brand-700">"{r.wr_subject}"</Link>
<span className="shrink-0 text-[11px] text-neutral-text-soft">{r.bg_datetime ? new Date(r.bg_datetime).toLocaleDateString('ko-KR') : ''}</span>
</li>
))}
</ul>
)}
<Link href="/mypage" className="text-center text-[12px] text-neutral-text-soft hover:text-brand-700"> </Link>
</article>
);
}
@@ -0,0 +1,67 @@
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';
interface ScrapRow { ms_id: number; bo_table: string; wr_id: number; ms_datetime: Date; wr_subject?: string }
export default async function MyScrapPage() {
const user = await getCurrentSiteUser();
if (!user) redirect('/login?next=/mypage/scrap');
const rows = await legacySql<ScrapRow[]>`
SELECT ms_id, bo_table, wr_id, ms_datetime FROM inspection2.g5_scrap
WHERE mb_id = ${user.loginId} ORDER BY ms_id DESC LIMIT 100
`.catch(() => []);
for (const r of rows) {
if (!/^[a-z0-9_]+$/i.test(r.bo_table)) continue;
const tbl = `inspection2.g5_write_${r.bo_table}`;
const sub = await legacySql<{ wr_subject: string }[]>`SELECT wr_subject FROM ${legacySql.unsafe(tbl)} WHERE wr_id = ${r.wr_id}`.catch(() => []);
r.wr_subject = sub[0]?.wr_subject ?? '(삭제됨)';
}
async function removeScrap(formData: FormData) {
'use server';
const u = await getCurrentSiteUser();
if (!u) return;
const id = Number(formData.get('ms_id') ?? 0);
if (!id) return;
await legacySql`DELETE FROM inspection2.g5_scrap WHERE ms_id = ${id} AND mb_id = ${u.loginId}`.catch(() => {});
revalidatePath('/mypage/scrap');
}
return (
<article className="flex flex-col gap-4">
<header className="rounded-2xl bg-gradient-to-br from-amber-500 to-rose-600 p-5 text-white">
<div className="text-[11px] font-bold uppercase tracking-widest text-white/80">MY SCRAPS</div>
<h1 className="mt-1 text-[24px] font-extrabold"> </h1>
<p className="mt-0.5 text-[12.5px] text-white/85">{rows.length}</p>
</header>
{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"> .</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((r) => (
<li key={r.ms_id} className="flex items-center justify-between px-4 py-3 hover:bg-amber-50/40">
<Link href={`/${r.bo_table}/${r.wr_id}`} className="flex-1 truncate">
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-bold text-amber-700">/{r.bo_table}</span>
<span className="ml-2 text-[13.5px] font-medium text-neutral-800">{r.wr_subject}</span>
</Link>
<div className="flex items-center gap-2 text-[11px] text-neutral-text-soft">
<span>{r.ms_datetime ? new Date(r.ms_datetime).toISOString().slice(0,10) : '-'}</span>
<form action={removeScrap}>
<input type="hidden" name="ms_id" value={r.ms_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>
</div>
</li>
))}
</ul>
)}
<Link href="/mypage" className="text-center text-[12px] text-neutral-text-soft hover:text-brand-700"> </Link>
</article>
);
}
@@ -0,0 +1,73 @@
// Mock payment gateway entry — adds points to mb_point and writes g5_point.
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
export const dynamic = 'force-dynamic';
const PRESETS = [10_000, 30_000, 50_000, 100_000, 300_000, 500_000];
export default async function ChargePage({ searchParams }: { searchParams: Promise<{ status?: string; amount?: string }> }) {
const sp = await searchParams;
const user = await getCurrentSiteUser();
if (!user) redirect('/login?next=/wallet/charge');
async function charge(formData: FormData) {
'use server';
const u = await getCurrentSiteUser();
if (!u) return;
const amt = Math.max(1_000, Math.min(1_000_000, Math.trunc(Number(formData.get('amount') ?? 0)) || 0));
const provider = String(formData.get('provider') ?? 'kcp').slice(0, 20);
const cur = await legacySql<{ mb_point: number }[]>`SELECT mb_point FROM inspection2.g5_member WHERE mb_id = ${u.loginId}`.catch(() => []);
const balance = Number(cur[0]?.mb_point ?? 0);
const nowStr = new Date().toISOString().slice(0, 19).replace('T', ' ');
await legacySql.begin(async (tx) => {
await tx`UPDATE inspection2.g5_member SET mb_point = mb_point + ${amt} WHERE mb_id = ${u.loginId}`;
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 (${u.loginId}, ${nowStr}, ${'[충전] ' + provider + ' 결제'}, ${amt}, 0, ${amt}, 0, '9999-12-31', ${balance + amt}, '@charge', ${provider}, ${'charge-' + Date.now()})
`;
}).catch((e) => { console.error('charge fail', e); throw e; });
redirect(`/wallet/charge?status=ok&amount=${amt}`);
}
return (
<article className="flex flex-col gap-4">
<header className="rounded-3xl bg-gradient-to-br from-emerald-500 to-emerald-700 p-6 text-white shadow-[0_18px_38px_rgba(16,185,129,0.30)]">
<div className="text-[11px] font-bold uppercase tracking-widest text-white/80">CHARGE</div>
<h1 className="mt-1 text-[26px] font-extrabold tracking-tight">💳 (mock)</h1>
<p className="mt-1 text-[12.5px] text-white/85">M8에서 PG (KCP//LG//) mock</p>
</header>
{sp.status === 'ok' && (
<div className="rounded-2xl bg-emerald-50 px-4 py-3 text-[14px] font-bold text-emerald-700"> {Number(sp.amount).toLocaleString()}p </div>
)}
<form action={charge} className="grid gap-4 rounded-2xl border border-neutral-100 bg-white p-5">
<div>
<label className="block text-[12px] font-bold text-neutral-700"></label>
<select name="provider" className="mt-1 w-full rounded-lg border border-neutral-200 px-3 py-2 text-[13px]">
<option value="kcp">KCP ()</option>
<option value="inicis"> ()</option>
<option value="lg">LG ()</option>
<option value="kakaopay"></option>
<option value="naverpay"></option>
</select>
</div>
<div>
<label className="block text-[12px] font-bold text-neutral-700"></label>
<input name="amount" type="number" min={1000} step={1000} defaultValue={10000} className="mt-1 w-full rounded-lg border border-neutral-200 px-3 py-2 text-right text-[16px] tabular" />
<div className="mt-2 flex flex-wrap gap-1.5">
{PRESETS.map((v) => (
<button key={v} type="submit" name="amount" value={v} className="rounded-full bg-neutral-100 px-3 py-1 text-[11.5px] font-bold text-neutral-700 hover:bg-emerald-100">
{v.toLocaleString()}p
</button>
))}
</div>
</div>
<button type="submit" className="rounded-full bg-gradient-to-r from-emerald-500 to-teal-700 py-3 text-[14px] font-extrabold text-white"> (mock)</button>
</form>
<div className="rounded-2xl border border-neutral-100 bg-white p-4 text-[12.5px] text-neutral-text-soft">
: <strong className="text-neutral-900">{user.point.toLocaleString()}p</strong> · g5_point .
</div>
</article>
);
}