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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user