From df72d3888a7350f3bb25c65acc66bb14bdfb0acb Mon Sep 17 00:00:00 2001 From: chpark Date: Tue, 28 Apr 2026 12:26:03 +0900 Subject: [PATCH] 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) --- .../web/src/app/admin/eyoom/menu/page.tsx | 82 ++++++++++++++ .../web/src/app/admin/shop/items/page.tsx | 103 ++++++++++++++++++ .../web/src/app/admin/shop/sendcost/page.tsx | 85 +++++++++++++++ .../apps/web/src/app/api/ui/theme/route.ts | 23 ++++ next-app/apps/web/src/app/auth/cert/page.tsx | 50 +++++++++ next-app/apps/web/src/app/layout.tsx | 13 ++- .../apps/web/src/app/mypage/posts/page.tsx | 63 +++++++++++ .../apps/web/src/app/mypage/profile/page.tsx | 93 ++++++++++++++++ .../apps/web/src/app/mypage/respond/page.tsx | 57 ++++++++++ .../apps/web/src/app/mypage/scrap/page.tsx | 67 ++++++++++++ .../apps/web/src/app/wallet/charge/page.tsx | 73 +++++++++++++ 11 files changed, 708 insertions(+), 1 deletion(-) create mode 100644 next-app/apps/web/src/app/admin/eyoom/menu/page.tsx create mode 100644 next-app/apps/web/src/app/admin/shop/items/page.tsx create mode 100644 next-app/apps/web/src/app/admin/shop/sendcost/page.tsx create mode 100644 next-app/apps/web/src/app/api/ui/theme/route.ts create mode 100644 next-app/apps/web/src/app/auth/cert/page.tsx create mode 100644 next-app/apps/web/src/app/mypage/posts/page.tsx create mode 100644 next-app/apps/web/src/app/mypage/profile/page.tsx create mode 100644 next-app/apps/web/src/app/mypage/respond/page.tsx create mode 100644 next-app/apps/web/src/app/mypage/scrap/page.tsx create mode 100644 next-app/apps/web/src/app/wallet/charge/page.tsx diff --git a/next-app/apps/web/src/app/admin/eyoom/menu/page.tsx b/next-app/apps/web/src/app/admin/eyoom/menu/page.tsx new file mode 100644 index 0000000..8239542 --- /dev/null +++ b/next-app/apps/web/src/app/admin/eyoom/menu/page.tsx @@ -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` + 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 ( +
+
+
이윰빌더
+

홈페이지 메뉴 설정 ({rows.length})

+

현재 테마: {theme}

+ +
+
+ + + + + + {rows.map((r) => ( + + + + ))} + +
코드메뉴명링크순서사용저장
+
+ + {r.me_code} + + + + + +
+
+
+
+ ); +} diff --git a/next-app/apps/web/src/app/admin/shop/items/page.tsx b/next-app/apps/web/src/app/admin/shop/items/page.tsx new file mode 100644 index 0000000..9e7e4e7 --- /dev/null +++ b/next-app/apps/web/src/app/admin/shop/items/page.tsx @@ -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` + 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 ( +
+
+
포인트몰
+

상품 관리 ({rows.length})

+
+
+ + + + + +
+
+ + + + + + + + + + + + + {rows.map((r) => ( + + + + + ))} + +
ID상품명가격재고판매저장
{r.it_id} +
+ + + + + + +
+
+
+
+ ); +} diff --git a/next-app/apps/web/src/app/admin/shop/sendcost/page.tsx b/next-app/apps/web/src/app/admin/shop/sendcost/page.tsx new file mode 100644 index 0000000..2fbcb48 --- /dev/null +++ b/next-app/apps/web/src/app/admin/shop/sendcost/page.tsx @@ -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` + 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 ( +
+
+
포인트몰
+

추가 배송비 관리

+

우편번호 구간별 추가배송비 (g5_shop_sendcost)

+
+
+ + + + +
+
+ + + + + + {rows.map((r) => ( + + + + + + + + ))} + {rows.length === 0 && } + +
IDFromTo금액관리
{r.sc_id}{r.sc_zip_from}{r.sc_zip_to}{Number(r.sc_price ?? 0).toLocaleString()}원 +
+ + +
+
규칙 없음
+
+
+ ); +} diff --git a/next-app/apps/web/src/app/api/ui/theme/route.ts b/next-app/apps/web/src/app/api/ui/theme/route.ts new file mode 100644 index 0000000..436ec8c --- /dev/null +++ b/next-app/apps/web/src/app/api/ui/theme/route.ts @@ -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; +} diff --git a/next-app/apps/web/src/app/auth/cert/page.tsx b/next-app/apps/web/src/app/auth/cert/page.tsx new file mode 100644 index 0000000..0ffbe4b --- /dev/null +++ b/next-app/apps/web/src/app/auth/cert/page.tsx @@ -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 ( +
+

🔐 본인인증

+

실제 본인인증 게이트(KCP/이니시스/Okname)는 M9에서 연동됩니다. 이 페이지는 통과 mock입니다.

+
+ + + +
+ {sp.status === 'ok' &&

✅ 인증 완료

} +
+ ); +} diff --git a/next-app/apps/web/src/app/layout.tsx b/next-app/apps/web/src/app/layout.tsx index 9313441..540861d 100644 --- a/next-app/apps/web/src/app/layout.tsx +++ b/next-app/apps/web/src/app/layout.tsx @@ -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 = { + 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 ( - + + + + {!hideEverything &&
}
diff --git a/next-app/apps/web/src/app/mypage/posts/page.tsx b/next-app/apps/web/src/app/mypage/posts/page.tsx new file mode 100644 index 0000000..0c0993e --- /dev/null +++ b/next-app/apps/web/src/app/mypage/posts/page.tsx @@ -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` + 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 ( +
+
+
MY POSTS
+

📝 내가 쓴 글

+

최근 50건 (전체 {all.length}건 중)

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

작성한 글이 없습니다.

+ ) : ( +
    + {posts.map((p) => ( +
  • + +
    + /{p.bo_table} + {p.wr_subject} +
    +
    + 👁 {p.wr_hit ?? 0} + 👍 {p.wr_good ?? 0} + 💬 {p.wr_comment ?? 0} + {p.wr_datetime ? new Date(p.wr_datetime).toISOString().slice(0,10) : '-'} +
    + +
  • + ))} +
+ )} + ← 마이페이지 +
+ ); +} diff --git a/next-app/apps/web/src/app/mypage/profile/page.tsx b/next-app/apps/web/src/app/mypage/profile/page.tsx new file mode 100644 index 0000000..42e4c5a --- /dev/null +++ b/next-app/apps/web/src/app/mypage/profile/page.tsx @@ -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` + 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 ( +
+
+
MY PROFILE
+

👤 회원정보 수정

+

{m.mb_id} · {m.mb_nick}

+
+
+ + + + +
+ +