macbook 에서 리눅스로 이동

This commit is contained in:
k3341095
2026-03-05 10:35:28 +09:00
commit ffd13c0fbb
83 changed files with 12262 additions and 0 deletions

3
frontend/app/app.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<NuxtPage />
</template>

View File

@@ -0,0 +1,17 @@
@import "tailwindcss";
@source "../..";
@theme static {
--font-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
@layer base {
body {
margin: 0;
min-height: 100vh;
font-family: var(--font-sans);
background: #020617;
color: #e2e8f0;
}
}

View File

@@ -0,0 +1,151 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { type CampaignLabel } from '~/stores/pages'
interface Props {
campaigns: CampaignLabel[]
}
interface Emits {
(e: 'close'): void
(e: 'submit', payload: { name: string; campaignId: string; domain: string; routePath: string }): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const form = ref({
name: '메인 랜딩 페이지',
campaignId: '',
domain: 'ad-camp-temp.test',
routePath: '/'
})
const campaignChoices = computed(() =>
props.campaigns.map((campaign) => ({
value: campaign.id,
label: campaign.name
}))
)
if (campaignChoices.value.length > 0) {
form.value.campaignId = campaignChoices.value[0]?.value || ''
}
const onClose = () => {
emit('close')
}
const onSubmit = () => {
const payload = {
name: form.value.name.trim(),
campaignId: form.value.campaignId,
domain: form.value.domain.trim(),
routePath: form.value.routePath.trim()
}
if (!payload.name || !payload.campaignId || !payload.domain || !payload.routePath) {
return
}
emit('submit', payload)
form.value = {
name: '메인 랜딩 페이지',
campaignId: props.campaigns[0]?.id || '',
domain: 'ad-camp-temp.test',
routePath: '/'
}
}
const onBackdrop = (evt: MouseEvent) => {
if (evt.target === evt.currentTarget) {
onClose()
}
}
</script>
<template>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70 p-4"
role="dialog"
aria-modal="true"
@click="onBackdrop"
>
<div class="w-full max-w-lg rounded-2xl border border-indigo-400/20 bg-slate-950 p-6 shadow-2xl shadow-black/60">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold text-white"> 페이지 만들기</h2>
<button
type="button"
@click="onClose"
class="rounded-md border border-slate-700/70 px-2 py-1 text-xs font-semibold text-slate-300"
>
닫기
</button>
</div>
<p class="mt-2 text-sm text-slate-400">페이지명/도메인/경로를 입력하고 생성하면 즉시 페이지가 등록됩니다.</p>
<div class="mt-5 space-y-4">
<label class="block space-y-2">
<span class="text-sm text-slate-300">페이지명</span>
<input
v-model="form.name"
type="text"
placeholder="예: 메인 랜딩 페이지"
class="w-full rounded-xl border border-indigo-500/25 bg-slate-900 px-4 py-3 text-slate-100 outline-none focus:border-cyan-300"
/>
</label>
<label class="block space-y-2">
<span class="text-sm text-slate-300">프로젝트</span>
<select
v-model="form.campaignId"
class="w-full rounded-xl border border-indigo-500/25 bg-slate-900 px-4 py-3 text-slate-100 outline-none focus:border-cyan-300"
>
<option value="">프로젝트 선택</option>
<option v-for="campaign in campaignChoices" :key="campaign.value" :value="campaign.value">
{{ campaign.label }}
</option>
</select>
</label>
<label class="block space-y-2">
<span class="text-sm text-slate-300">도메인</span>
<input
v-model="form.domain"
type="text"
placeholder="예: summer.example.com"
class="w-full rounded-xl border border-indigo-500/25 bg-slate-900 px-4 py-3 text-slate-100 outline-none focus:border-cyan-300"
/>
</label>
<label class="block space-y-2">
<span class="text-sm text-slate-300">페이지 경로</span>
<input
v-model="form.routePath"
type="text"
placeholder="예: /, /offer, /google"
class="w-full rounded-xl border border-indigo-500/25 bg-slate-900 px-4 py-3 text-slate-100 outline-none focus:border-cyan-300"
/>
</label>
</div>
<div class="mt-6 flex justify-end gap-2">
<button
type="button"
@click="onClose"
class="rounded-md border border-slate-700 px-4 py-2 text-sm text-slate-300"
>
취소
</button>
<button
type="button"
@click="onSubmit"
class="rounded-md bg-gradient-to-r from-indigo-500 to-cyan-500 px-4 py-2 text-sm font-semibold text-white"
>
생성
</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { ref } from 'vue'
interface Emits {
(e: 'close'): void
(e: 'submit', payload: { name: string; campaignName: string }): void
}
const emit = defineEmits<Emits>()
const form = ref({
name: '',
campaignName: ''
})
const onClose = () => {
emit('close')
}
const onSubmit = () => {
const name = form.value.name.trim()
const campaignName = form.value.campaignName.trim()
if (!name || !campaignName) {
return
}
emit('submit', { name, campaignName })
form.value = { name: '', campaignName: '' }
}
</script>
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" role="dialog" aria-modal="true">
<div class="w-full max-w-lg rounded-2xl border border-indigo-400/20 bg-slate-950 p-6 shadow-2xl shadow-black/60">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold text-white"> 프로젝트 만들기</h2>
<button
type="button"
@click="onClose"
class="rounded-md border border-slate-700/70 px-2 py-1 text-xs font-semibold text-slate-300"
>
닫기
</button>
</div>
<p class="mt-2 text-sm text-slate-400">프로젝트 기본 정보만 입력해도 바로 저장됩니다.</p>
<div class="mt-5 space-y-4">
<label class="block space-y-2">
<span class="text-sm text-slate-300">프로젝트명</span>
<input
v-model="form.name"
type="text"
placeholder="예: 여름 프로모션 캠페인"
class="w-full rounded-xl border border-indigo-500/25 bg-slate-900 px-4 py-3 text-slate-100 outline-none focus:border-cyan-300"
/>
</label>
<label class="block space-y-2">
<span class="text-sm text-slate-300">캠페인 표시명</span>
<input
v-model="form.campaignName"
type="text"
placeholder="예: 캠페인 1"
class="w-full rounded-xl border border-indigo-500/25 bg-slate-900 px-4 py-3 text-slate-100 outline-none focus:border-cyan-300"
/>
</label>
</div>
<div class="mt-6 flex justify-end gap-2">
<button
type="button"
@click="onClose"
class="rounded-md border border-slate-700 px-4 py-2 text-sm text-slate-300"
>
취소
</button>
<button
type="button"
@click="onSubmit"
class="rounded-md bg-gradient-to-r from-indigo-500 to-cyan-500 px-4 py-2 text-sm font-semibold text-white"
>
생성
</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,41 @@
import { defineComponent, h } from 'vue';
export default defineComponent({
name: 'Button',
props: {
type: {
type: String,
default: 'button',
},
variant: {
type: String,
default: 'default',
validator: (value: string) => ['default', 'outline', 'ghost'].includes(value),
},
className: {
type: String,
default: '',
},
},
setup(props, { slots }) {
const base = 'inline-flex items-center justify-center rounded-md px-4 py-2 font-medium transition';
const styles: Record<string, string> = {
default: 'bg-cyan-500 text-black hover:bg-cyan-400',
outline: 'border border-slate-600 text-slate-100 bg-transparent hover:bg-slate-900',
ghost: 'text-slate-100 hover:bg-slate-800',
};
return () =>
h(
'button',
{
type: props.type as 'button' | 'submit' | 'reset',
class: `${base} ${styles[props.variant as keyof typeof styles]} ${props.className}`.trim(),
},
slots.default ? slots.default() : undefined,
);
},
});
export { Button };

View File

@@ -0,0 +1,50 @@
<template>
<div class="min-h-screen bg-slate-950 text-slate-100">
<div class="flex min-h-screen">
<aside class="w-64 border-r border-indigo-400/20 bg-gradient-to-b from-slate-900/95 via-slate-950 to-slate-950">
<div class="border-b border-slate-800/70 px-5 py-4">
<h1 class="text-sm font-bold tracking-[0.18em] text-transparent bg-gradient-to-r from-cyan-300 to-indigo-300 bg-clip-text">LANDING ADMIN</h1>
<p class="mt-1 text-xs text-slate-400">캠페인·페이지 콘솔</p>
</div>
<nav class="p-3">
<NuxtLink
to="/admin"
class="mb-1 flex rounded-md px-3 py-2 text-sm text-slate-200 transition hover:bg-slate-800/70 hover:text-cyan-200"
active-class="bg-indigo-500/15 text-cyan-200 ring-1 ring-cyan-300/30"
exact-active-class="bg-indigo-500/15 text-cyan-200 ring-1 ring-cyan-300/30"
>
대시보드
</NuxtLink>
<NuxtLink
to="/admin/projects"
class="mb-1 flex rounded-md px-3 py-2 text-sm text-slate-200 transition hover:bg-slate-800/70 hover:text-cyan-200"
active-class="bg-indigo-500/15 text-cyan-200 ring-1 ring-cyan-300/30"
>
프로젝트
</NuxtLink>
<NuxtLink
to="/admin/pages"
class="mb-1 flex rounded-md px-3 py-2 text-sm text-slate-200 transition hover:bg-slate-800/70 hover:text-cyan-200"
active-class="bg-indigo-500/15 text-cyan-200 ring-1 ring-cyan-300/30"
>
페이지 관리
</NuxtLink>
<NuxtLink
to="/admin/leads"
class="mb-1 flex rounded-md px-3 py-2 text-sm text-slate-200 transition hover:bg-slate-800/70 hover:text-cyan-200"
active-class="bg-indigo-500/15 text-cyan-200 ring-1 ring-cyan-300/30"
>
리드 조회
</NuxtLink>
</nav>
</aside>
<main class="min-w-0 flex-1 bg-slate-950 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.12),_transparent_42%)] bg-[radial-gradient(circle_at_bottom_right,_rgba(168,85,247,0.10),_transparent_40%)] p-6">
<div class="mx-auto max-w-6xl">
<NuxtPage />
</div>
</main>
</div>
</div>
</template>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useDashboardStore } from '~/stores/dashboard'
const dashboardStore = useDashboardStore()
const { kpiCards, trendBars, runningProjects } = storeToRefs(dashboardStore)
</script>
<template>
<div class="space-y-4">
<header class="rounded-xl border border-slate-700/80 bg-gradient-to-r from-slate-900 via-indigo-950/70 to-slate-900 px-5 py-4">
<p class="text-xs uppercase tracking-[0.18em] text-cyan-300">대시보드</p>
<div class="mt-2 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-slate-100">운영 대시보드</h1>
<p class="mt-1 text-sm text-slate-400">진행중 프로젝트, 전체 프로젝트, 신규 리드를 가로형 위젯으로 확인하세요.</p>
</div>
<NuxtLink
to="/admin/projects"
class="rounded-md border border-cyan-300/30 bg-cyan-500/10 px-3 py-1.5 text-sm font-semibold text-cyan-100 hover:bg-cyan-500/20"
>
프로젝트 보기
</NuxtLink>
</div>
</header>
<section class="grid gap-3 xl:grid-cols-[2fr_1fr]">
<div class="grid grid-cols-3 gap-3">
<article
v-for="card in kpiCards"
:key="card.label"
class="rounded-xl border bg-gradient-to-br from-slate-900 to-slate-950 p-5"
:class="[card.border]"
>
<p class="text-xs uppercase tracking-[0.16em] text-slate-200">{{ card.label }}</p>
<div class="mt-3 flex items-center gap-3">
<p class="text-3xl font-black" :class="card.color">{{ card.value }}</p>
<span class="inline-flex h-2.5 w-2.5 rounded-full bg-gradient-to-r" :class="card.band"></span>
</div>
<p class="mt-2 text-sm text-slate-400">{{ card.note }}</p>
</article>
</div>
<section class="rounded-xl border border-slate-800 bg-gradient-to-b from-slate-900 to-indigo-950/50 p-5">
<h2 class="text-sm font-semibold">리드 추이(샘플)</h2>
<div class="mt-3 h-36 rounded-lg border border-cyan-300/20 bg-slate-950/80">
<div class="flex h-full items-end gap-2 px-4 pb-4">
<span v-for="bar in trendBars" :key="bar" class="w-full bg-gradient-to-t from-cyan-300 to-indigo-300/80" :style="{ height: `${bar}%` }"></span>
</div>
</div>
</section>
</section>
<section class="grid gap-3 xl:grid-cols-[1fr_1fr]">
<article class="rounded-xl border border-slate-800 bg-gradient-to-b from-slate-900 to-slate-950 p-5">
<h2 class="text-lg font-semibold">진행중 프로젝트 목록</h2>
<p class="mt-1 text-sm text-slate-400">운영 중인 핵심 프로젝트</p>
<div class="mt-4 overflow-hidden rounded-lg border border-slate-800">
<table class="w-full text-left text-sm">
<thead class="bg-slate-900">
<tr class="text-slate-300">
<th class="px-4 py-3">프로젝트</th>
<th class="px-4 py-3">도메인</th>
<th class="px-4 py-3">상태</th>
<th class="px-4 py-3 text-right">리드</th>
</tr>
</thead>
<tbody>
<tr v-for="project in runningProjects" :key="project.domain" class="border-t border-slate-800 text-slate-200">
<td class="px-4 py-3">{{ project.name }}</td>
<td class="px-4 py-3">{{ project.domain }}</td>
<td class="px-4 py-3">
<span class="inline-flex items-center gap-1 rounded-full bg-indigo-900/40 px-2.5 py-0.5 text-xs font-medium text-indigo-200">
{{ project.status }}
</span>
</td>
<td class="px-4 py-3 text-right">{{ project.leads }}</td>
</tr>
</tbody>
</table>
</div>
</article>
<article class="rounded-xl border border-slate-800 bg-gradient-to-b from-slate-900 to-indigo-950/40 p-5">
<h2 class="text-lg font-semibold">빠른 액션</h2>
<p class="mt-1 text-sm text-slate-400">바로 이동해 바로 시작</p>
<div class="mt-4 flex flex-col gap-2">
<NuxtLink to="/admin/projects" class="rounded-md bg-gradient-to-r from-indigo-600 to-cyan-500 px-4 py-2 text-sm font-semibold text-white"> 프로젝트 만들기</NuxtLink>
<NuxtLink to="/admin/pages" class="rounded-md border border-indigo-300/30 bg-indigo-950/20 px-4 py-2 text-sm text-indigo-100 hover:bg-indigo-900/30">페이지 바로가기</NuxtLink>
<NuxtLink to="/admin/leads" class="rounded-md border border-cyan-300/30 bg-cyan-950/20 px-4 py-2 text-sm text-cyan-100 hover:bg-cyan-900/30">리드 조회</NuxtLink>
</div>
</article>
</section>
</div>
</template>

View File

@@ -0,0 +1,213 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
interface LeadRecord {
id: string
name: string
phone: string
email: string
campaign: string
page: string
channel: string
status: '새리드' | '검토중' | '완료'
createdAt: string
}
const leads = ref<LeadRecord[]>([
{
id: 'ld-1001',
name: '김하늘',
phone: '010-1111-2222',
email: 'haneul@example.com',
campaign: 'Q3 마케팅 캠페인',
page: '메인 랜딩',
channel: 'Google',
status: '새리드',
createdAt: '2026-03-04T09:12:00.000Z'
},
{
id: 'ld-1002',
name: '이준수',
phone: '010-2222-3333',
email: 'junsoo@example.com',
campaign: '블랙프라이데이 2023',
page: '구글 전용 랜딩',
channel: 'Meta',
status: '검토중',
createdAt: '2026-03-03T17:44:00.000Z'
},
{
id: 'ld-1003',
name: '박소영',
phone: '010-3333-4444',
email: 'soyoung@example.com',
campaign: 'Product Hunt 런칭',
page: '리타겟 랜딩',
channel: 'Instagram',
status: '완료',
createdAt: '2026-03-02T21:13:00.000Z'
},
{
id: 'ld-1004',
name: '최민재',
phone: '010-4444-5555',
email: 'minjae@example.com',
campaign: 'SaaS 웨비나 시리즈',
page: '오퍼 페이지 B',
channel: 'Naver',
status: '새리드',
createdAt: '2026-03-01T11:08:00.000Z'
},
{
id: 'ld-1005',
name: '정연우',
phone: '010-5555-6666',
email: 'yeonwoo@example.com',
campaign: 'Q3 마케팅 캠페인',
page: '메인 랜딩',
channel: 'Google',
status: '검토중',
createdAt: '2026-03-01T03:03:00.000Z'
}
])
const searchQuery = ref('')
const statusFilter = ref<'all' | LeadRecord['status']>('all')
const channelFilter = ref<'all' | string>('all')
const campaignFilter = ref<'all' | string>('all')
const channelOptions = ['Google', 'Meta', 'Instagram', 'Naver']
const campaigns = computed(() => {
const names = [...new Set(leads.value.map((lead) => lead.campaign))]
return names
})
const filteredLeads = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
return leads.value
.filter((lead) => {
const target = `${lead.name} ${lead.phone} ${lead.email} ${lead.campaign} ${lead.page} ${lead.channel} ${lead.status}`.toLowerCase()
const matchesQuery = query === '' || target.includes(query)
const matchesStatus = statusFilter.value === 'all' || lead.status === statusFilter.value
const matchesChannel = channelFilter.value === 'all' || lead.channel === channelFilter.value
const matchesCampaign = campaignFilter.value === 'all' || lead.campaign === campaignFilter.value
return matchesQuery && matchesStatus && matchesChannel && matchesCampaign
})
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
})
const statusLabel = (status: LeadRecord['status']) => {
if (status === '완료') return '완료'
if (status === '검토중') return '검토중'
return '새 리드'
}
const statusClass = (status: LeadRecord['status']) => {
if (status === '완료') {
return 'text-emerald-200 bg-emerald-400/10'
}
if (status === '검토중') {
return 'text-amber-200 bg-amber-400/10'
}
return 'text-indigo-200 bg-indigo-400/10'
}
</script>
<template>
<div class="space-y-5">
<header class="rounded-xl border border-slate-700/80 bg-gradient-to-r from-slate-900 via-slate-900 to-indigo-950/70 px-5 py-4 shadow-[0_16px_40px_rgba(0,0,0,0.35)]">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-sm text-slate-400"> <span class="px-2">></span> 리드 조회</p>
<h1 class="mt-1 text-3xl font-black tracking-tight text-slate-100">리드 조회</h1>
<p class="mt-1 text-sm text-slate-400">캠페인/페이지 기반으로 리드를 확인하고 상태를 관리하세요.</p>
</div>
<button class="rounded-xl bg-gradient-to-r from-indigo-500 to-cyan-500 px-5 py-2.5 text-base font-semibold text-white shadow-lg shadow-cyan-950/40 transition hover:from-indigo-400 hover:to-cyan-400">
리드 엑셀 내보내기
</button>
</div>
</header>
<section class="rounded-2xl border border-indigo-400/20 bg-gradient-to-br from-slate-900/80 via-slate-950 to-slate-900/90 p-5 shadow-[0_12px_30px_rgba(0,0,0,0.3)]">
<div class="flex flex-nowrap items-end justify-between gap-4">
<label class="min-w-0 space-y-2">
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">검색</span>
<div class="relative">
<span class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-cyan-300"></span>
<input
v-model="searchQuery"
type="text"
placeholder="이름, 전화번호, 이메일, 캠페인, 페이지"
class="w-full min-w-[420px] rounded-xl border border-indigo-500/20 bg-slate-950/80 px-9 py-3 text-sm text-slate-100 outline-none ring-0 transition focus:border-cyan-400 focus:shadow-[0_0_0_1px_rgba(103,232,249,0.35)]"
/>
</div>
</label>
<div class="ml-auto flex shrink-0 items-end gap-3">
<label class="flex flex-col space-y-2">
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">상태</span>
<select v-model="statusFilter" class="rounded-xl border border-indigo-500/25 bg-slate-950/80 px-5 py-3 text-sm font-medium text-slate-200 outline-none transition hover:border-cyan-400">
<option value="all">전체</option>
<option value="새리드"> 리드</option>
<option value="검토중">검토중</option>
<option value="완료">완료</option>
</select>
</label>
<label class="flex flex-col space-y-2">
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">채널</span>
<select v-model="channelFilter" class="rounded-xl border border-indigo-500/25 bg-slate-950/80 px-5 py-3 text-sm font-medium text-slate-200 outline-none transition hover:border-cyan-400">
<option value="all">전체</option>
<option v-for="ch in channelOptions" :key="ch" :value="ch">
{{ ch }}
</option>
</select>
</label>
<label class="flex flex-col space-y-2">
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">캠페인</span>
<select v-model="campaignFilter" class="rounded-xl border border-indigo-500/25 bg-slate-950/80 px-5 py-3 text-sm font-medium text-slate-200 outline-none transition hover:border-cyan-400">
<option value="all">전체 캠페인</option>
<option v-for="name in campaigns" :key="name" :value="name">{{ name }}</option>
</select>
</label>
</div>
</div>
</section>
<section class="overflow-hidden rounded-2xl border border-slate-800 bg-slate-950/80">
<table class="w-full text-left text-sm">
<thead class="bg-slate-900 text-slate-300">
<tr>
<th class="px-4 py-3">이름</th>
<th class="px-4 py-3">연락처</th>
<th class="px-4 py-3">이메일</th>
<th class="px-4 py-3">캠페인</th>
<th class="px-4 py-3">페이지</th>
<th class="px-4 py-3">채널</th>
<th class="px-4 py-3">상태</th>
<th class="px-4 py-3">수집일</th>
</tr>
</thead>
<tbody>
<tr v-for="lead in filteredLeads" :key="lead.id" class="border-t border-slate-800 text-slate-200">
<td class="px-4 py-3">{{ lead.name }}</td>
<td class="px-4 py-3">{{ lead.phone }}</td>
<td class="px-4 py-3 text-cyan-200">{{ lead.email }}</td>
<td class="px-4 py-3">{{ lead.campaign }}</td>
<td class="px-4 py-3">{{ lead.page }}</td>
<td class="px-4 py-3">{{ lead.channel }}</td>
<td class="px-4 py-3">
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold tracking-[0.07em]" :class="statusClass(lead.status)">
{{ statusLabel(lead.status) }}
</span>
</td>
<td class="px-4 py-3">{{ new Date(lead.createdAt).toLocaleString() }}</td>
</tr>
</tbody>
</table>
</section>
</div>
</template>

View File

@@ -0,0 +1,375 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { usePagesStore } from '~/stores/pages'
type BuilderBlockType = 'html' | 'image' | 'form' | 'kakao'
type BuilderBlock = {
id: string
type: BuilderBlockType
label: string
html?: string
src?: string
alt?: string
}
const route = useRoute()
const router = useRouter()
const pagesStore = usePagesStore()
const { campaignOptions } = storeToRefs(pagesStore)
const pageId = computed(() => String(route.params.id || ''))
const selectedPage = computed(() => pagesStore.pages.find((page) => page.id === pageId.value))
const pageTitle = ref('Landing Page')
const metaDescription = ref('landing page description')
const metaKeywords = ref('landing, ads')
const iconUrl = ref('https://avatars.githubusercontent.com/u/9919?s=200&v=4')
const headCode = ref('<meta property="og:type" content="website" />')
const bodyScript = ref("console.log('landing admin builder loaded')")
const footerHtml = ref('')
const isDragOver = ref(false)
const imageInputRef = ref<HTMLInputElement | null>(null)
const htmlDraft = ref('<p>새 HTML 블록입니다.</p>')
const imageAlt = ref('이미지')
const generatedHtml = ref('')
const isSaved = ref(false)
const objectUrlMap = ref<Record<string, string>>({})
const blockList = ref<BuilderBlock[]>([
{ id: `b-${Date.now()}`, type: 'html', label: '커스텀 HTML', html: '<p>커스텀 HTML 블록입니다.</p>' },
])
const campaignName = computed(() => {
if (!selectedPage.value) return '-'
return campaignOptions.value.find((campaign) => campaign.id === selectedPage.value?.campaignId)?.name || '-'
})
const pageUrl = computed(() => {
if (!selectedPage.value) return ''
return `https://${selectedPage.value.domain}${selectedPage.value.routePath}`
})
const previewHeader = computed(() => selectedPage.value?.name || '새 페이지')
const finalHeadHtml = computed(() => {
const pieces = [
'<meta charset="UTF-8" />',
`<title>${escapeHtml(pageTitle.value)}</title>`,
`<meta name="description" content="${escapeHtml(metaDescription.value)}" />`,
`<meta name="keywords" content="${escapeHtml(metaKeywords.value)}" />`,
`<link rel="icon" href="${escapeHtml(iconUrl.value)}" />`,
headCode.value.trim(),
]
return pieces.filter(Boolean).join('\n ')
})
const blockHtmls = computed(() => blockList.value.map((block) => {
if (block.type === 'image' && block.src) {
return `<img src="${escapeHtml(block.src)}" alt="${escapeHtml(block.alt || '')}" style="max-width:100%;border-radius:12px;display:block;" />`
}
return block.html ? block.html : '<p>빈 HTML</p>'
}))
const previewBlockHtmls = computed(() => blockList.value.map((block) => {
if (block.type === 'image' && block.src) {
return `<img src="${escapeHtml(block.src)}" alt="${escapeHtml(block.alt || '')}" style="max-width:320px; width:100%; max-height:220px; object-fit:contain;display:block;" />`
}
return block.html ? block.html : '<p>빈 HTML</p>'
}))
const finalBodyHtml = computed(() => {
const bodyOpen = '<' + 'script>'
const bodyClose = '<' + '/script>'
const safeBodyScript = bodyScript.value.replace(/<\/script/gi, '<\\/script')
const body = [
...blockHtmls.value,
footerHtml.value,
`${bodyOpen}${safeBodyScript}${bodyClose}`,
].join('\n')
return `<!doctype html>
<html>
<head>
${finalHeadHtml.value}
</head>
<body>
<div style="padding:16px;">${body}</div>
</body>
</html>`
})
const previewBodyHtml = computed(() => {
const body = [
...previewBlockHtmls.value,
footerHtml.value,
].join('\n')
return `<div style=\"padding:16px;\">${body}</div>`
})
const addHtmlBlock = () => {
const draft = htmlDraft.value.trim() || '<p>빈 HTML</p>'
blockList.value = [
{
id: `b-html-${Date.now()}`,
type: 'html',
label: `HTML 블록 ${blockList.value.length + 1}`,
html: draft,
},
...blockList.value,
]
htmlDraft.value = '<p>새 HTML 블록입니다.</p>'
}
const addImageFromFiles = (files: FileList | null) => {
if (!files || files.length === 0) return
const toAdd = Array.from(files)
const createdBlocks: BuilderBlock[] = toAdd.map((file, idx) => {
const localUrl = URL.createObjectURL(file)
const blockId = `b-image-${Date.now()}-${idx}`
objectUrlMap.value[blockId] = localUrl
return {
id: blockId,
type: 'image',
label: `이미지 블록 ${blockList.value.length + idx + 1}`,
src: localUrl,
alt: imageAlt.value.trim() || '이미지',
}
})
blockList.value = [...createdBlocks, ...blockList.value]
imageAlt.value = '이미지'
}
const addImageFromUpload = (evt: Event) => {
const target = evt.target as HTMLInputElement
const targetFiles = target.files
addImageFromFiles(targetFiles)
target.value = ''
}
const openImagePicker = () => {
imageInputRef.value?.click()
}
const onImageDragOver = (evt: DragEvent) => {
evt.preventDefault()
isDragOver.value = true
}
const onImageDragLeave = (evt: DragEvent) => {
evt.preventDefault()
isDragOver.value = false
}
const onImageDrop = (evt: DragEvent) => {
evt.preventDefault()
isDragOver.value = false
addImageFromFiles(evt.dataTransfer?.files || null)
}
const moveUp = (index: number) => {
if (index === 0) return
const next = [...blockList.value]
;[next[index - 1], next[index]] = [next[index], next[index - 1]]
blockList.value = next
}
const moveDown = (index: number) => {
if (index === blockList.value.length - 1) return
const next = [...blockList.value]
;[next[index], next[index + 1]] = [next[index + 1], next[index]]
blockList.value = next
}
const removeBlock = (id: string) => {
if (objectUrlMap.value[id]) {
URL.revokeObjectURL(objectUrlMap.value[id])
delete objectUrlMap.value[id]
}
blockList.value = blockList.value.filter((block) => block.id !== id)
}
const saveBuilder = () => {
generatedHtml.value = finalBodyHtml.value
isSaved.value = true
setTimeout(() => {
isSaved.value = false
}, 1200)
}
const copyFinalHtml = async () => {
if (!generatedHtml.value) {
generatedHtml.value = finalBodyHtml.value
}
await navigator.clipboard?.writeText(generatedHtml.value)
}
const backToList = () => {
router.push('/admin/pages')
}
function escapeHtml(value: string) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;')
}
</script>
<template>
<section class="space-y-5">
<header class="rounded-xl border border-slate-700/80 bg-slate-900 px-5 py-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<p class="text-xs text-slate-400"> &gt; 페이지 &gt; 빌더</p>
<h1 class="mt-1 text-2xl font-black tracking-tight text-white">페이지 빌더</h1>
<p class="mt-1 text-sm text-slate-400">선택한 페이지의 블록을 관리하고 HTML을 생성합니다.</p>
</div>
<div class="flex items-center gap-2">
<NuxtLink to="/admin/pages" class="rounded-md border border-slate-700 px-3 py-2 text-sm">목록으로</NuxtLink>
<button type="button" @click="backToList" class="rounded-md border border-slate-700 px-3 py-2 text-sm">닫기</button>
<button type="button" @click="saveBuilder" class="rounded-md bg-gradient-to-r from-indigo-600 to-cyan-500 px-4 py-2 text-sm font-medium text-white">저장</button>
</div>
</div>
</header>
<section v-if="selectedPage" class="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
<article class="rounded-2xl border border-slate-800 bg-slate-950 p-5">
<div class="mb-4">
<h2 class="text-xl font-bold text-white">{{ previewHeader }}</h2>
<p class="mt-1 text-sm text-slate-400">{{ campaignName }}</p>
<p class="mt-1 text-sm text-slate-300">{{ pageUrl }}</p>
</div>
<div class="mx-auto w-[360px] rounded-xl border border-slate-700 bg-black/20 p-4">
<div class="rounded-lg bg-slate-900/80 p-2 min-h-[120px]" v-html="previewBodyHtml"></div>
<div v-if="blockHtmls.length === 0" class="rounded-lg border border-slate-700 bg-slate-900/80 p-2 mt-2 text-xs text-slate-300">블록이 없습니다.</div>
</div>
</article>
<article class="rounded-2xl border border-slate-800 bg-slate-950 p-5 text-sm">
<h2 class="text-lg font-semibold text-white">빌더 설정</h2>
<p class="mt-1 text-slate-400">HTML/이미지 블록과 헤더/아이콘/Body Script/푸터 HTML을 설정하세요.</p>
<div class="mt-4 space-y-2">
<label class="block space-y-1">
<span class="text-slate-400">페이지 제목</span>
<input v-model="pageTitle" class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2" />
</label>
<label class="block space-y-1">
<span class="text-slate-400">메타 설명</span>
<textarea v-model="metaDescription" rows="2" class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2"></textarea>
</label>
<label class="block space-y-1">
<span class="text-slate-400">메타 키워드</span>
<input v-model="metaKeywords" class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2" />
</label>
<label class="block space-y-1">
<span class="text-slate-400">파비콘 URL</span>
<input v-model="iconUrl" class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2" />
</label>
<label class="block space-y-1">
<span class="text-slate-400">HEAD 추가 코드</span>
<textarea v-model="headCode" rows="3" class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2"></textarea>
</label>
<label class="block space-y-1">
<span class="text-slate-400">BODY Script</span>
<textarea v-model="bodyScript" rows="3" class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2"></textarea>
</label>
<label class="block space-y-1">
<span class="text-slate-400">Footer HTML</span>
<textarea v-model="footerHtml" rows="3" class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2"></textarea>
</label>
</div>
<div class="mt-4 space-y-2">
<h3 class="font-semibold text-white">블록 추가</h3>
<label class="block space-y-1">
<span class="text-slate-400">HTML 블록</span>
<textarea v-model="htmlDraft" rows="3" class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2"></textarea>
</label>
<button type="button" @click="addHtmlBlock" class="mt-1 rounded-md bg-cyan-900/60 px-3 py-2 text-sm text-white">HTML 블록 추가</button>
<div
class="mt-3 rounded-lg border-2 border-dashed border-slate-700 bg-slate-900/40 p-5 text-center text-slate-300"
:class="isDragOver ? 'border-cyan-400 bg-slate-900' : 'border-slate-700'"
@dragover.prevent="onImageDragOver"
@dragleave.prevent="onImageDragLeave"
@drop.prevent="onImageDrop"
@click="openImagePicker"
>
<p class="text-sm text-slate-200 font-semibold">이미지 파일을 드래그해서 올려주세요</p>
<p class="mt-1 text-xs text-slate-500">갯수 제한 없이 번에 여러 추가됩니다.</p>
<span class="text-xs text-slate-500 mt-2 block">참고: 업로드한 파일은 현재 페이지 미리보기/블록으로 즉시 반영됩니다.</span>
<input
ref="imageInputRef"
type="file"
accept="image/*"
multiple
class="hidden"
@change="addImageFromUpload"
/>
</div>
</div>
<div class="mt-4 space-y-2">
<div v-for="(block, idx) in blockList" :key="block.id" class="rounded-md border border-slate-700 bg-slate-900 px-3 py-2">
<div class="mb-1 flex items-start justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<img
v-if="block.type === 'image' && block.src"
:src="block.src"
:alt="block.alt || '이미지'"
class="h-12 w-16 rounded-none object-cover"
/>
<div class="min-w-0">
<p class="text-slate-100">{{ block.label }}</p>
<p class="text-xs text-slate-400">{{ block.type }}</p>
<p class="text-xs text-slate-300" v-if="block.type === 'html'">{{ block.html }}</p>
<p class="text-xs text-slate-300" v-else-if="block.type === 'image'">{{ block.alt || '이미지 블록' }}</p>
<p class="text-xs text-slate-300" v-else>{{ block.html || '사용자 블록' }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<button type="button" class="rounded-md border border-slate-700 px-2 py-1 text-xs" @click="moveUp(idx)"></button>
<button type="button" class="rounded-md border border-slate-700 px-2 py-1 text-xs" @click="moveDown(idx)"></button>
<button type="button" class="rounded-md border border-red-500/50 px-2 py-1 text-xs text-red-200" @click="removeBlock(block.id)">삭제</button>
</div>
</div>
</div>
</div>
<p class="mt-4 text-xs text-slate-400">{{ blockList.length }} 블록</p>
</article>
</section>
<section v-else class="rounded-2xl border border-dashed border-slate-700 bg-slate-900 p-6 text-slate-300">
<p>선택한 페이지를 찾을 없습니다.</p>
<NuxtLink to="/admin/pages" class="mt-4 inline-flex rounded-md border border-slate-700 px-3 py-1.5 text-sm text-slate-100">페이지 목록으로 이동</NuxtLink>
</section>
<section v-if="generatedHtml" class="rounded-2xl border border-slate-700 bg-slate-900 p-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-slate-100">생성 HTML</h3>
<button type="button" @click="copyFinalHtml" class="rounded-md border border-cyan-300/40 px-3 py-1.5 text-xs text-cyan-100">클립보드 복사</button>
</div>
<pre class="mt-3 max-h-64 overflow-auto rounded-lg bg-slate-950 p-3 text-xs text-slate-300">{{ generatedHtml }}</pre>
</section>
<p v-if="isSaved" class="rounded-md border border-emerald-300/40 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200">저장(HTML 생성)되었습니다.</p>
</section>
</template>

View File

@@ -0,0 +1,224 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { usePagesStore } from '~/stores/pages'
import { useVariantsStore } from '~/stores/variants'
const route = useRoute()
const router = useRouter()
const pagesStore = usePagesStore()
const variantsStore = useVariantsStore()
const pageId = computed(() => String(route.params.id || ''))
const page = computed(() => pagesStore.pages.find((item) => item.id === pageId.value) ?? null)
const variants = computed(() => variantsStore.listByPage(pageId.value))
const hasPage = computed(() => page.value !== null)
const totalLeads = computed(() => variants.value.reduce((sum, item) => sum + item.leadCount, 0))
const totalTraffic = computed(() => variants.value.reduce((sum, item) => sum + item.trafficWeight, 0))
const newVariantName = ref('')
const newVariantDescription = ref('')
const newVariantWeight = ref(10)
const pageTitle = computed(() => {
if (!page.value) {
return ''
}
return `${page.value.name} 변형 관리`
})
const backToPages = () => {
router.push('/admin/pages')
}
const toBuilder = () => {
if (hasPage.value) {
router.push(`/admin/pages/${pageId.value}/builder`)
}
}
const createVariant = () => {
const name = newVariantName.value.trim()
if (!hasPage.value || name.length === 0) {
return
}
variantsStore.createVariant({
pageId: pageId.value,
name,
description: newVariantDescription.value,
trafficWeight: Number(newVariantWeight.value) || 10
})
newVariantName.value = ''
newVariantDescription.value = ''
newVariantWeight.value = 10
}
const removeVariant = (variantId: string) => {
variantsStore.removeVariant(pageId.value, variantId)
}
const toggleVariant = (variantId: string) => {
variantsStore.toggleActive(pageId.value, variantId)
}
</script>
<template>
<div class="space-y-5">
<header class="rounded-xl border border-slate-700/80 bg-gradient-to-r from-slate-900 via-indigo-950/70 to-slate-900 px-5 py-4">
<p class="text-xs uppercase tracking-[0.18em] text-cyan-300">페이지</p>
<div class="mt-2 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-slate-100">{{ hasPage ? pageTitle : '페이지를 찾을 수 없습니다' }}</h1>
<p v-if="page" class="mt-1 text-sm text-slate-400">
{{ page.domain }}{{ page.routePath }} · {{ page.leadCount }} 리드
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="rounded-md border border-cyan-300/30 bg-cyan-500/10 px-3 py-1.5 text-sm font-semibold text-cyan-100 hover:bg-cyan-500/20"
@click="toBuilder"
>
빌더 열기
</button>
<button
type="button"
class="rounded-md border border-slate-700 px-3 py-1.5 text-sm text-slate-300 hover:bg-slate-900"
@click="backToPages"
>
페이지 목록
</button>
</div>
</div>
</header>
<div v-if="!hasPage" class="rounded-xl border border-slate-800 bg-slate-900/70 px-6 py-8">
<h2 class="text-xl font-bold text-slate-100">해당 페이지를 찾을 없습니다.</h2>
<p class="mt-1 text-sm text-slate-300">ID가 변경되었거나 삭제된 페이지일 있습니다.</p>
<button
type="button"
class="mt-4 rounded-md bg-gradient-to-r from-indigo-600 to-cyan-500 px-4 py-2 text-sm font-semibold text-white"
@click="backToPages"
>
페이지 목록으로 이동
</button>
</div>
<template v-else>
<section class="grid gap-4 md:grid-cols-2">
<article class="rounded-xl border border-slate-800 bg-slate-900/70 p-5">
<h2 class="text-sm font-semibold text-slate-200">변형 메트릭</h2>
<div class="mt-4 grid grid-cols-2 gap-3">
<div class="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
<p class="text-xs text-slate-400"> 변형 </p>
<p class="mt-2 text-2xl font-black text-slate-100">{{ variants.length }}</p>
</div>
<div class="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
<p class="text-xs text-slate-400">누적 리드</p>
<p class="mt-2 text-2xl font-black text-slate-100">{{ totalLeads }}</p>
</div>
<div class="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
<p class="text-xs text-slate-400"> 트래픽 비율</p>
<p class="mt-2 text-2xl font-black text-slate-100">{{ totalTraffic }}%</p>
</div>
</div>
</article>
<article class="rounded-xl border border-slate-800 bg-slate-900/70 p-5">
<h2 class="text-sm font-semibold text-slate-200"> 변형 추가</h2>
<div class="mt-4 space-y-3">
<label class="block space-y-2">
<span class="text-xs font-medium text-slate-300">변형 이름</span>
<input
v-model="newVariantName"
type="text"
placeholder="예: 주말 자극형"
class="w-full rounded-lg border border-indigo-400/30 bg-slate-950/80 px-3 py-2 text-sm text-slate-100 outline-none"
/>
</label>
<label class="block space-y-2">
<span class="text-xs font-medium text-slate-300">메모</span>
<input
v-model="newVariantDescription"
type="text"
placeholder="변형 설명"
class="w-full rounded-lg border border-indigo-400/30 bg-slate-950/80 px-3 py-2 text-sm text-slate-100 outline-none"
/>
</label>
<label class="block space-y-2">
<span class="text-xs font-medium text-slate-300">트래픽 비율 (%)</span>
<input
v-model.number="newVariantWeight"
type="number"
min="1"
max="100"
class="w-full rounded-lg border border-indigo-400/30 bg-slate-950/80 px-3 py-2 text-sm text-slate-100 outline-none"
/>
</label>
<button
type="button"
class="w-full rounded-lg bg-gradient-to-r from-indigo-600 to-cyan-500 px-4 py-2 text-sm font-semibold text-white"
@click="createVariant"
>
변형 생성
</button>
</div>
</article>
</section>
<section class="space-y-3">
<div class="rounded-xl border border-slate-800 bg-slate-900/70 px-4 py-3 text-sm font-semibold text-slate-100">
등록 변형 목록
</div>
<article
v-for="variant in variants"
:key="variant.id"
class="rounded-xl border border-slate-800 bg-slate-900/70 p-5"
>
<div class="flex flex-wrap items-start justify-between gap-2">
<div>
<h3 class="text-lg font-bold text-slate-100">{{ variant.name }}</h3>
<p class="mt-1 text-sm text-slate-400">{{ variant.description || '메모 없음' }}</p>
</div>
<button
type="button"
@click="toggleVariant(variant.id)"
:class="variant.isActive ? 'bg-emerald-500/20 text-emerald-100' : 'bg-slate-700 text-slate-300'"
class="rounded-md border border-slate-700 px-3 py-1.5 text-xs font-semibold"
>
{{ variant.isActive ? '활성' : '비활성' }}
</button>
</div>
<div class="mt-3 grid gap-3 md:grid-cols-3">
<div class="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
<p class="text-xs text-slate-400">상태</p>
<p class="mt-1 text-base font-semibold text-slate-100">
{{ variant.isActive ? '노출' : '중단' }}
</p>
</div>
<div class="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
<p class="text-xs text-slate-400">트래픽 비율</p>
<p class="mt-1 text-base font-semibold text-slate-100">{{ variant.trafficWeight }}%</p>
</div>
<div class="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
<p class="text-xs text-slate-400">리드 </p>
<p class="mt-1 text-base font-semibold text-slate-100">{{ variant.leadCount }}</p>
</div>
</div>
<div class="mt-4 flex justify-end">
<button
type="button"
class="rounded-md border border-red-300/40 bg-red-900/20 px-3 py-1.5 text-sm text-red-100"
@click="removeVariant(variant.id)"
>
삭제
</button>
</div>
</article>
</section>
</template>
</div>
</template>

View File

@@ -0,0 +1,273 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { usePagesStore, type PageStatus, type PageSortBy } from '~/stores/pages'
import { useVariantsStore } from '~/stores/variants'
import CreatePageModal from '~/components/admin/CreatePageModal.vue'
import { useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const pagesStore = usePagesStore()
const variantsStore = useVariantsStore()
const { filteredPages, searchQuery, campaignFilter, statusFilter, sortBy, campaignOptions } = storeToRefs(pagesStore)
const pages = computed(() =>
filteredPages.value.map((page) => ({
...page,
variantCount: variantsStore.countByPage(page.id)
}))
)
const showCreatePage = ref(false)
const initialCampaign = computed(() => {
const value = route.query.campaignId
if (typeof value === 'string' && value.length > 0) {
return value
}
return 'all'
})
watch(
() => initialCampaign.value,
(next) => {
pagesStore.setCampaignFilter(next)
},
{ immediate: true }
)
const statusOptions = [
{ value: 'all', label: '전체' },
{ value: 'live', label: '라이브' },
{ value: 'draft', label: '초안' },
{ value: 'paused', label: '일시정지' }
] as const
const sortOptions: Array<{ value: PageSortBy; label: string }> = [
{ value: 'updated', label: '최근 수정순' },
{ value: 'name', label: '이름 오름차순' },
{ value: 'leads', label: '리드' },
{ value: 'visitors', label: '방문자' },
{ value: 'variants', label: '변형 수' }
]
const campaignItems = computed(() => [
{ value: 'all', label: '전체 캠페인' },
...campaignOptions.value.map((campaign) => ({
value: campaign.id,
label: campaign.name
}))
])
const statusLabel = (status: PageStatus) => {
const { label } = pagesStore.statusClass(status)
return label
}
const statusClass = (status: PageStatus) => {
const { chipClass } = pagesStore.statusClass(status)
return chipClass
}
const statusDotClass = (status: PageStatus) => {
const { dotClass } = pagesStore.statusClass(status)
return dotClass
}
const openCreatePage = () => {
showCreatePage.value = true
}
const closeCreatePage = () => {
showCreatePage.value = false
}
const submitCreatePage = (payload: { name: string; campaignId: string; domain: string; routePath: string }) => {
pagesStore.createPage(payload)
showCreatePage.value = false
}
const openBuilder = (pageId: string) => {
router.push(`/admin/pages/${pageId}/builder`)
}
const onDelete = (pageId: string) => {
pagesStore.removePage(pageId)
}
const campaignLabel = (campaignId: string) => pagesStore.campaignName(campaignId)
const pageUrl = (domain: string, routePath: string) => `https://${domain}${routePath}`
</script>
<template>
<div class="space-y-5">
<header class="rounded-xl border border-slate-700/80 bg-gradient-to-r from-slate-900 via-slate-900 to-indigo-950/70 px-5 py-4 shadow-[0_16px_40px_rgba(0,0,0,0.35)]">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-sm text-slate-400"> <span class="px-2">></span> 페이지</p>
<h1 class="mt-1 text-3xl font-black tracking-tight text-slate-100">페이지 관리</h1>
<p class="mt-1 text-sm text-slate-400">캠페인 기준으로 페이지를 관리하고 운영하세요.</p>
</div>
<button
type="button"
@click="openCreatePage"
class="rounded-xl bg-gradient-to-r from-indigo-500 to-cyan-500 px-5 py-2.5 text-base font-semibold text-white shadow-lg shadow-cyan-950/40 transition hover:from-indigo-400 hover:to-cyan-400"
>
+ 페이지 만들기
</button>
</div>
</header>
<section class="rounded-2xl border border-indigo-400/20 bg-gradient-to-br from-slate-900/80 via-slate-950 to-slate-900/90 p-5 shadow-[0_12px_30px_rgba(0,0,0,0.3)]">
<div class="flex flex-nowrap items-end justify-between gap-4">
<label class="min-w-0 space-y-2">
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">검색</span>
<div class="relative">
<span class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-cyan-300"></span>
<input
v-model="searchQuery"
type="text"
placeholder="페이지명, 도메인, 경로로 검색"
class="w-full min-w-[520px] rounded-xl border border-indigo-500/20 bg-slate-950/80 px-9 py-3 text-sm text-slate-100 outline-none ring-0 transition focus:border-cyan-400 focus:shadow-[0_0_0_1px_rgba(103,232,249,0.35)]"
/>
</div>
</label>
<div class="ml-auto flex shrink-0 items-end gap-3">
<label class="flex flex-col space-y-2">
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">캠페인</span>
<select
v-model="campaignFilter"
class="shrink-0 rounded-xl border border-indigo-500/25 bg-slate-950/80 px-5 py-3 text-sm font-medium text-slate-200 outline-none transition hover:border-cyan-400"
>
<option
v-for="campaign in campaignItems"
:key="campaign.value"
:value="campaign.value"
>
{{ campaign.label }}
</option>
</select>
</label>
<label class="flex flex-col space-y-2">
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">상태</span>
<select
v-model="statusFilter"
class="shrink-0 rounded-xl border border-indigo-500/25 bg-slate-950/80 px-5 py-3 text-sm font-medium text-slate-200 outline-none transition hover:border-cyan-400"
>
<option v-for="status in statusOptions" :key="status.value" :value="status.value">
{{ status.label }}
</option>
</select>
</label>
<label class="flex flex-col space-y-2">
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">정렬</span>
<select
v-model="sortBy"
class="shrink-0 rounded-xl border border-indigo-500/25 bg-slate-950/80 px-5 py-3 text-sm font-medium text-slate-200 outline-none transition hover:border-cyan-400"
>
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
</div>
</div>
</section>
<section class="space-y-4">
<article
v-for="page in pages"
:key="page.id"
class="relative overflow-hidden rounded-2xl border border-slate-800 bg-gradient-to-b from-slate-900/95 to-slate-950/95 p-5 shadow-lg shadow-black/20 transition hover:-translate-y-0.5 hover:border-cyan-300/40"
>
<div class="flex items-start justify-between gap-2">
<div>
<h2 class="text-xl font-bold text-slate-100">{{ page.name }}</h2>
<p class="mt-1 text-sm text-slate-400">{{ campaignLabel(page.campaignId) }}</p>
<p class="mt-1 text-sm text-slate-300">
{{ pageUrl(page.domain, page.routePath) }}
</p>
</div>
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-semibold tracking-[0.08em]" :class="statusClass(page.status)" style="font-size: 9px; line-height: 1.1;">
<span class="h-1.5 w-1.5 rounded-full" :class="statusDotClass(page.status)" />
{{ statusLabel(page.status) }}
</span>
</div>
<div class="mt-4 grid grid-cols-4 gap-3">
<div class="rounded-xl border border-slate-800 bg-slate-950/80 p-3">
<p class="text-xs tracking-wider text-slate-400">도메인</p>
<p class="mt-1 text-lg font-bold text-slate-100">{{ page.domain }}</p>
</div>
<div class="rounded-xl border border-slate-800 bg-slate-950/80 p-3">
<p class="text-xs tracking-wider text-slate-400">리드</p>
<p class="mt-1 text-lg font-bold text-slate-100">{{ page.leadCount }}</p>
</div>
<div class="rounded-xl border border-slate-800 bg-slate-950/80 p-3">
<p class="text-xs tracking-wider text-slate-400">방문자</p>
<p class="mt-1 text-lg font-bold text-slate-100">{{ page.visitorCount }}</p>
</div>
<div class="rounded-xl border border-slate-800 bg-slate-950/80 p-3">
<p class="text-xs tracking-wider text-slate-400">변형</p>
<p class="mt-1 text-lg font-bold text-slate-100">{{ page.variantCount }}</p>
</div>
</div>
<div class="mt-4 flex items-center justify-between border-t border-slate-700/60 pt-4">
<p class="text-sm text-slate-400">마지막 수정: {{ new Date(page.updatedAt).toLocaleString() }}</p>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-md border border-slate-700/80 bg-slate-900 px-3 py-1.5 text-sm text-slate-200 transition hover:bg-slate-800/70"
>
미리보기
</button>
<NuxtLink
:to="`/admin/pages/${page.id}/variants`"
class="rounded-md border border-indigo-400/40 bg-indigo-950/50 px-3 py-1.5 text-sm text-indigo-100 transition hover:bg-indigo-900/50"
>
변형 관리
</NuxtLink>
<button
type="button"
@click="openBuilder(page.id)"
class="rounded-md bg-gradient-to-r from-indigo-600 to-cyan-500 px-3 py-1.5 text-sm font-medium text-white hover:from-indigo-500 hover:to-cyan-400"
>
빌더 열기
</button>
<button
type="button"
@click="onDelete(page.id)"
class="rounded-md border border-red-300/30 bg-red-900/30 px-3 py-1.5 text-sm text-red-100 transition hover:bg-red-900/50"
>
삭제
</button>
</div>
</div>
</article>
<article class="flex min-h-[220px] items-center justify-center rounded-2xl border border-dashed border-cyan-300/30 bg-gradient-to-b from-slate-900 to-indigo-950/60 p-5">
<div class="text-center">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-r from-indigo-400/20 to-cyan-400/20 text-2xl text-cyan-200">+</div>
<p class="text-2xl font-bold text-slate-100">페이지가 없습니다</p>
<p class="mt-2 text-slate-400">필터를 조정하거나 페이지를 먼저 만들어보세요.</p>
<button
type="button"
class="mx-auto mt-4 rounded-md bg-gradient-to-r from-indigo-600 to-cyan-500 px-4 py-2 text-sm font-semibold text-white"
@click="openCreatePage"
>
바로 만들기
</button>
</div>
</article>
</section>
<CreatePageModal
v-if="showCreatePage"
:campaigns="campaignOptions"
@close="closeCreatePage"
@submit="submitCreatePage"
/>
</div>
</template>

View File

@@ -0,0 +1,259 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useProjectsStore, type ProjectStatus } from '~/stores/projects'
import { usePagesStore } from '~/stores/pages'
import { ref } from 'vue'
import CreateProjectModal from '~/components/admin/CreateProjectModal.vue'
const projectsStore = useProjectsStore()
const pagesStore = usePagesStore()
const { filteredProjects, searchQuery, statusFilter, sortBy } = storeToRefs(projectsStore)
const projects = filteredProjects
const showCreateProject = ref(false)
const statusOptions = [
{ value: 'all', label: '전체' },
{ value: 'active', label: '진행중' },
{ value: 'archived', label: '종료' },
{ value: 'draft', label: '임시저장' }
] as const
const sortOptions = [
{ value: 'newest', label: '최신순' },
{ value: 'oldest', label: '오래된순' },
{ value: 'most-leads', label: '리드 많은순' },
{ value: 'most-variants', label: '변형 많은순' }
] as const
const statusLabel = (status: ProjectStatus) => {
if (status === 'active') {
return '진행중'
}
if (status === 'archived') {
return '종료'
}
return '임시저장'
}
const statusClass = (status: ProjectStatus) => {
if (status === 'active') {
return 'text-emerald-200 bg-emerald-400/10'
}
if (status === 'archived') {
return 'text-slate-300 bg-slate-500/10'
}
return 'text-amber-200 bg-amber-400/10'
}
const statusDotClass = (status: ProjectStatus) => {
if (status === 'active') {
return 'bg-emerald-300'
}
if (status === 'archived') {
return 'bg-slate-400'
}
return 'bg-amber-300'
}
const cardAccent = (status: ProjectStatus) => {
if (status === 'active') {
return 'before:bg-emerald-400'
}
if (status === 'archived') {
return 'before:bg-slate-400'
}
return 'before:bg-amber-300'
}
const getVariantPageId = (projectId: string) => {
return pagesStore.pages.find((page) => page.campaignId === projectId)?.id ?? ''
}
const openCreateModal = () => {
showCreateProject.value = true
}
const closeCreateModal = () => {
showCreateProject.value = false
}
const submitCreateProject = (payload: { name: string; campaignName: string }) => {
projectsStore.createProject(payload)
showCreateProject.value = false
}
</script>
<template>
<div class="space-y-5">
<header class="rounded-xl border border-slate-700/80 bg-gradient-to-r from-slate-900 via-slate-900 to-indigo-950/70 px-5 py-4 shadow-[0_16px_40px_rgba(0,0,0,0.35)]">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-sm text-slate-400"> <span class="px-2">></span> 프로젝트</p>
<h1 class="mt-1 text-3xl font-black tracking-tight text-slate-100">프로젝트 그룹</h1>
</div>
<button
type="button"
@click="openCreateModal"
class="rounded-xl bg-gradient-to-r from-indigo-500 to-cyan-500 px-5 py-2.5 text-base font-semibold text-white shadow-lg shadow-cyan-950/40 transition hover:from-indigo-400 hover:to-cyan-400"
>
+ 프로젝트 만들기
</button>
</div>
</header>
<section class="rounded-2xl border border-indigo-400/20 bg-gradient-to-br from-slate-900/80 via-slate-950 to-slate-900/90 p-5 shadow-[0_12px_30px_rgba(0,0,0,0.3)]">
<div class="flex flex-nowrap items-end justify-between gap-4">
<label class="min-w-0 space-y-2">
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">검색</span>
<div class="relative">
<span class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-cyan-300"></span>
<input
type="text"
placeholder="이름 또는 태그로 검색"
v-model="searchQuery"
class="w-full min-w-[520px] rounded-xl border border-indigo-500/20 bg-slate-950/80 px-9 py-3 text-sm text-slate-100 outline-none ring-0 transition focus:border-cyan-400 focus:shadow-[0_0_0_1px_rgba(103,232,249,0.35)]"
/>
</div>
</label>
<div class="ml-auto flex shrink-0 items-end gap-3">
<label class="flex flex-col space-y-2">
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">상태</span>
<select
v-model="statusFilter"
class="shrink-0 rounded-xl border border-indigo-500/25 bg-slate-950/80 px-5 py-3 text-sm font-medium text-slate-200 outline-none transition hover:border-cyan-400"
>
<option
v-for="status in statusOptions"
:key="status.value"
:value="status.value"
>
{{ status.label }}
</option>
</select>
</label>
<label class="flex flex-col space-y-2">
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">정렬</span>
<select
v-model="sortBy"
class="shrink-0 rounded-xl border border-indigo-500/25 bg-slate-950/80 px-5 py-3 text-sm font-medium text-slate-200 outline-none transition hover:border-cyan-400"
>
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<button
type="button"
class="shrink-0 rounded-xl border border-cyan-300/30 bg-gradient-to-r from-cyan-500/20 via-indigo-500/20 to-purple-500/20 px-5 py-3 text-sm font-semibold whitespace-nowrap text-cyan-50 transition hover:bg-slate-800 hover:border-cyan-200/50"
>
필터
</button>
</div>
</div>
</section>
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<article
v-for="project in projects"
:key="project.id"
class="relative overflow-hidden rounded-2xl border border-slate-800 bg-gradient-to-b from-slate-900/95 to-slate-950/95 p-5 shadow-lg shadow-black/20 transition hover:-translate-y-0.5 hover:border-cyan-300/40 before:absolute before:bottom-0 before:left-0 before:top-0 before:w-[3px] before:rounded-l-2xl"
:class="cardAccent(project.status)"
>
<div class="flex items-start justify-between gap-2">
<div>
<h2 class="text-xl font-bold text-slate-100">{{ project.title }}</h2>
<p class="mt-1 text-sm text-slate-400">{{ project.subtitle }}</p>
</div>
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-semibold tracking-[0.08em]" :class="statusClass(project.status)" style="font-size: 10px; line-height: 1.1;">
<span class="h-1.5 w-1.5 rounded-full" :class="statusDotClass(project.status)" />
{{ statusLabel(project.status) }}
</span>
</div>
<div class="mt-4 gap-3" style="display:grid;grid-template-columns:1fr 1fr;">
<div class="min-w-0 rounded-xl border border-slate-800 bg-slate-950/80 p-3">
<p class="text-xs tracking-wider text-slate-400"> 리드</p>
<p class="mt-1 text-3xl font-black text-slate-100">{{ project.leads }}</p>
</div>
<div class="min-w-0 rounded-xl border border-slate-800 bg-slate-950/80 p-3">
<p class="text-xs tracking-wider text-slate-400">{{ project.metricLabel }}</p>
<p class="mt-1 text-3xl font-black text-slate-100">{{ project.metricValue }}</p>
<p class="mt-1 text-sm" :class="project.trendUp ? 'text-emerald-300' : 'text-slate-400'">{{ project.trend }}</p>
</div>
</div>
<div
class="flex items-center justify-between border-t border-slate-700/60 pt-4"
style="margin-top: 28px;"
>
<p class="text-sm text-slate-400">{{ project.variants }} 변형 테스트</p>
<div class="flex items-center gap-2">
<NuxtLink
:to="`/admin/pages?campaignId=${project.id}`"
class="rounded-md border border-slate-700/80 bg-slate-900 px-3 py-1.5 text-sm text-slate-200 transition hover:bg-slate-800/70"
>
페이지 관리
</NuxtLink>
<NuxtLink
v-if="getVariantPageId(project.id)"
:to="`/admin/pages/${getVariantPageId(project.id)}/variants`"
class="rounded-md bg-gradient-to-r from-indigo-600 to-cyan-500 px-3 py-1.5 text-sm font-medium text-white hover:from-indigo-500 hover:to-cyan-400"
>
변형 관리
</NuxtLink>
<button
v-else
type="button"
class="rounded-md bg-slate-700 px-3 py-1.5 text-sm text-slate-300"
disabled
>
변형 없음
</button>
</div>
</div>
</article>
<article
class="flex min-h-[320px] items-center justify-center rounded-2xl border border-dashed border-cyan-300/30 bg-gradient-to-b from-slate-900 to-indigo-950/60 p-5"
>
<div class="text-center">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-r from-indigo-400/20 to-cyan-400/20 text-2xl text-cyan-200">+</div>
<p class="text-2xl font-bold text-slate-100"> 그룹 만들기</p>
<p class="mt-2 text-slate-400"> A/B 테스트나 캠페인을 시작하세요</p>
<button
type="button"
class="mx-auto mt-4 rounded-md bg-gradient-to-r from-indigo-600 to-cyan-500 px-4 py-2 text-sm font-semibold text-white"
@click="openCreateModal"
>
바로 생성하기
</button>
</div>
</article>
</section>
<footer class="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-slate-800 pt-4">
<p class="text-slate-400"> 12 1~4 표시</p>
<div class="flex items-center gap-2">
<button class="rounded-lg border border-slate-700 px-3 py-1.5 text-slate-300 hover:bg-slate-900">이전</button>
<button class="rounded-lg border border-indigo-300/30 bg-indigo-900/50 px-3 py-1.5 text-cyan-100">1</button>
<button class="rounded-lg border border-slate-700 px-3 py-1.5 text-slate-300 hover:bg-slate-900">2</button>
<button class="rounded-lg border border-slate-700 px-3 py-1.5 text-slate-300 hover:bg-slate-900">3</button>
<button class="rounded-lg border border-slate-700 px-3 py-1.5 text-slate-300 hover:bg-slate-900">다음</button>
</div>
</footer>
<CreateProjectModal
v-if="showCreateProject"
@close="closeCreateModal"
@submit="submitCreateProject"
/>
</div>
</template>

View File

@@ -0,0 +1,90 @@
<template>
<div class="min-h-screen bg-slate-950 text-slate-100">
<section class="relative overflow-hidden bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950">
<div class="pointer-events-none absolute -left-28 top-[-130px] h-72 w-72 rounded-full bg-cyan-400/20 blur-3xl"></div>
<div class="pointer-events-none absolute right-[-100px] bottom-[-120px] h-80 w-80 rounded-full bg-indigo-400/15 blur-3xl"></div>
<div class="mx-auto flex min-h-screen max-w-6xl flex-col justify-between px-6 py-10 md:px-10">
<header class="flex items-center justify-between">
<p class="text-xs font-bold tracking-[0.2em] text-slate-300">LANDING MANAGER</p>
<NuxtLink
to="/admin"
class="rounded-md border border-slate-700 px-4 py-2 text-sm text-slate-200 transition hover:bg-slate-800"
>
관리자 바로가기
</NuxtLink>
</header>
<main class="grid gap-10 py-16 md:py-24 lg:grid-cols-2 lg:items-center">
<div class="space-y-6">
<p class="inline-flex rounded-full border border-cyan-400/30 bg-cyan-400/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-cyan-200">
광고 랜딩 운영 시작점
</p>
<h1 class="text-4xl font-black leading-tight md:text-5xl">
캠페인 기반 랜딩 페이지를
<span class="text-cyan-300">빠르게</span> 운영하세요
</h1>
<p class="max-w-xl text-lg text-slate-300">
캠페인, 페이지, 도메인 매핑, 조건 분기까지 곳에서 관리하는 어드민 구조를
페이지 단위로 점진적으로 구성합니다.
</p>
<div class="flex flex-wrap gap-3">
<NuxtLink
to="/admin"
class="rounded-md bg-cyan-400 px-4 py-2 font-semibold text-slate-950 transition hover:bg-cyan-300"
>
관리자 대시보드 시작
</NuxtLink>
<NuxtLink
to="/"
class="rounded-md border border-slate-700 px-4 py-2 text-slate-200 transition hover:bg-slate-800"
>
가이드 보기
</NuxtLink>
</div>
</div>
<section class="rounded-2xl border border-slate-700 bg-slate-900/60 p-6 backdrop-blur">
<h2 class="text-sm font-bold uppercase tracking-[0.15em] text-cyan-300">현재 설계 범위</h2>
<p class="mt-2 text-slate-200">1단계: 기본 화면 + 관리 기본 페이지를 하나씩 생성</p>
<ul class="mt-4 space-y-3 text-sm text-slate-300">
<li class="rounded-md border border-slate-800 p-3">1) (현재 페이지)</li>
<li class="rounded-md border border-slate-800 p-3">2) 관리자 시작 페이지(다음)</li>
<li class="rounded-md border border-slate-800 p-3">3) 캠페인 목록</li>
<li class="rounded-md border border-slate-800 p-3">4) 페이지 상세/빌더</li>
</ul>
</section>
</main>
</div>
</section>
<section class="mx-auto max-w-6xl px-6 pb-16 md:px-10">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<article class="rounded-xl border border-slate-700 bg-slate-900 p-5">
<p class="text-xs uppercase text-slate-400">목표</p>
<h3 class="mt-1 text-xl font-bold">광고 집행용 랜딩 운영</h3>
<p class="mt-2 text-sm text-slate-300">
유입 페이지가 많아져도 관리가 쉬운 구조로 캠페인 단위 관리 흐름을 만든다.
</p>
</article>
<article class="rounded-xl border border-slate-700 bg-slate-900 p-5">
<p class="text-xs uppercase text-slate-400">기능</p>
<h3 class="mt-1 text-xl font-bold">도메인 분기 조건 분기</h3>
<p class="mt-2 text-sm text-slate-300">
호스트/경로 분기 + 요일/시간 조건 분기를 기반으로 보여줄 페이지를 제어한다.
</p>
</article>
<article class="rounded-xl border border-slate-700 bg-slate-900 p-5">
<p class="text-xs uppercase text-slate-400">철학</p>
<h3 class="mt-1 text-xl font-bold">페이지는 작게, 운영은 빠르게</h3>
<p class="mt-2 text-sm text-slate-300">
번에 완성보다, 페이지 하나씩 완성하면서 안정적으로 확장한다.
</p>
</article>
</div>
</section>
</div>
</template>

View File

@@ -0,0 +1,69 @@
import { defineStore } from 'pinia'
export interface KpiCard {
label: string
value: string
note: string
color: string
band: string
border: string
}
export interface RunningProject {
name: string
domain: string
status: string
leads: number
}
export const useDashboardStore = defineStore('admin-dashboard', {
state: () => ({
kpiCards: [
{
label: '진행중 프로젝트',
value: '5',
note: '최근 7일 내 변경 3건',
color: 'text-cyan-300',
band: 'from-cyan-300/80 to-cyan-200/20',
border: 'border-cyan-300/25'
},
{
label: '전체 프로젝트',
value: '18',
note: '캠페인 기준 총 18개',
color: 'text-fuchsia-200',
band: 'from-fuchsia-300/80 to-fuchsia-200/20',
border: 'border-fuchsia-300/25'
},
{
label: '새로운 리드',
value: '24',
note: '오늘 신규 유입',
color: 'text-emerald-300',
band: 'from-emerald-300/80 to-emerald-200/20',
border: 'border-emerald-300/25'
}
],
trendBars: [12, 20, 16, 26, 18, 24],
runningProjects: [
{
name: '여름 프로모션',
domain: 'summer.ad-camp.kr',
status: '운영중',
leads: 42
},
{
name: '주말 특가 랜딩',
domain: 'weekend.offer.kr',
status: '테스트',
leads: 13
},
{
name: '리타겟팅 A',
domain: 'retarget.example.com',
status: '준비중',
leads: 0
}
] as RunningProject[]
})
})

View File

@@ -0,0 +1,242 @@
import { defineStore } from 'pinia'
export type PageStatus = 'live' | 'draft' | 'paused'
export type PageSortBy =
| 'updated'
| 'name'
| 'leads'
| 'visitors'
| 'variants'
export interface CampaignLabel {
id: string
name: string
}
export interface AdminPage {
id: string
campaignId: string
name: string
domain: string
routePath: string
status: PageStatus
leadCount: number
visitorCount: number
variantCount: number
updatedAt: string
}
const campaigns: CampaignLabel[] = [
{ id: 'p1', name: 'Q3 마케팅 캠페인' },
{ id: 'p2', name: '블랙프라이데이 2023' },
{ id: 'p3', name: 'SaaS 웨비나 시리즈' },
{ id: 'p4', name: 'Product Hunt 론칭' }
]
const pageSource: AdminPage[] = [
{
id: 'lp01',
campaignId: 'p1',
name: '메인 랜딩',
domain: 'summer.ad-camp.kr',
routePath: '/',
status: 'live',
leadCount: 124,
visitorCount: 2400,
variantCount: 2,
updatedAt: '2026-03-04T09:12:00.000Z'
},
{
id: 'lp02',
campaignId: 'p1',
name: '오퍼 페이지 B',
domain: 'summer.ad-camp.kr',
routePath: '/offer',
status: 'draft',
leadCount: 16,
visitorCount: 420,
variantCount: 1,
updatedAt: '2026-03-03T18:05:00.000Z'
},
{
id: 'lp03',
campaignId: 'p2',
name: '구글 전용 랜딩',
domain: 'weekend.offer.kr',
routePath: '/google',
status: 'live',
leadCount: 88,
visitorCount: 1210,
variantCount: 3,
updatedAt: '2026-03-01T13:40:00.000Z'
},
{
id: 'lp04',
campaignId: 'p2',
name: '일반 버전',
domain: 'weekend.offer.kr',
routePath: '/',
status: 'paused',
leadCount: 22,
visitorCount: 540,
variantCount: 1,
updatedAt: '2026-02-28T06:25:00.000Z'
},
{
id: 'lp05',
campaignId: 'p4',
name: '리타겟 랜딩',
domain: 'retarget.example.com',
routePath: '/',
status: 'draft',
leadCount: 0,
visitorCount: 40,
variantCount: 1,
updatedAt: '2026-03-02T22:17:00.000Z'
}
]
const statusRank: Record<PageStatus, number> = {
live: 0,
draft: 1,
paused: 2
}
const statusLabel = (status: PageStatus) => {
if (status === 'live') {
return '라이브'
}
if (status === 'paused') {
return '일시정지'
}
return '초안'
}
export const usePagesStore = defineStore('admin-pages', {
state: () => ({
pages: pageSource,
campaigns,
searchQuery: '',
campaignFilter: 'all' as 'all' | string,
statusFilter: 'all' as 'all' | PageStatus,
sortBy: 'updated' as PageSortBy
}),
getters: {
filteredPages: (state): AdminPage[] => {
const query = state.searchQuery.trim().toLowerCase()
const result = state.pages
.filter((page) => {
const text = `${page.name} ${page.domain} ${page.routePath}`.toLowerCase()
const searchOk = query === '' || text.includes(query)
const campaignOk = state.campaignFilter === 'all' || page.campaignId === state.campaignFilter
const statusOk = state.statusFilter === 'all' || page.status === state.statusFilter
return searchOk && campaignOk && statusOk
})
.sort((a, b) => {
if (state.sortBy === 'name') {
return a.name.localeCompare(b.name)
}
if (state.sortBy === 'leads') {
return b.leadCount - a.leadCount
}
if (state.sortBy === 'visitors') {
return b.visitorCount - a.visitorCount
}
if (state.sortBy === 'variants') {
return b.variantCount - a.variantCount
}
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
})
return result
},
campaignOptions: (state): CampaignLabel[] => state.campaigns
},
actions: {
setSearchQuery(value: string) {
this.searchQuery = value
},
setCampaignFilter(value: 'all' | string) {
this.campaignFilter = value
},
setStatusFilter(value: 'all' | PageStatus) {
this.statusFilter = value
},
setSortBy(value: PageSortBy) {
this.sortBy = value
},
removePage(pageId: string) {
this.pages = this.pages.filter((page) => page.id !== pageId)
},
createQuickPage() {
const campaign = this.campaigns[this.pages.length % this.campaigns.length]
this.createPage({
name: `새 페이지 ${this.pages.length + 1}`,
campaignId: campaign.id,
domain: 'ad-camp-temp.test',
routePath: `/lp-${this.pages.length + 1}`
})
},
createPage(payload: {
name: string
campaignId: string
domain: string
routePath: string
}) {
const normalizedRoute = payload.routePath.trim().startsWith('/') ? payload.routePath.trim() : `/${payload.routePath.trim()}`
const normalizedDomain = payload.domain.trim()
const newPage: AdminPage = {
id: `lp-${Date.now()}`,
campaignId: payload.campaignId,
name: payload.name.trim(),
domain: normalizedDomain,
routePath: normalizedRoute,
status: 'draft',
leadCount: 0,
visitorCount: 0,
variantCount: 1,
updatedAt: new Date().toISOString()
}
this.pages.unshift(newPage)
},
statusClass(status: PageStatus) {
if (status === 'live') {
return {
label: statusLabel(status),
chipClass: 'text-emerald-200 bg-emerald-400/10',
dotClass: 'bg-emerald-300'
}
}
if (status === 'paused') {
return {
label: statusLabel(status),
chipClass: 'text-slate-300 bg-slate-500/10',
dotClass: 'bg-slate-400'
}
}
return {
label: statusLabel(status),
chipClass: 'text-amber-200 bg-amber-400/10',
dotClass: 'bg-amber-300'
}
},
campaignName(campaignId: string) {
return (
this.campaigns.find((campaign) => campaign.id === campaignId)?.name || campaignId || '미지정'
)
},
previewUrl(page: AdminPage) {
return `https://${page.domain}${page.routePath}`
}
}
})

View File

@@ -0,0 +1,156 @@
import { defineStore } from 'pinia'
export type ProjectStatus = 'active' | 'archived' | 'draft'
export type ProjectSortBy = 'newest' | 'oldest' | 'most-leads' | 'most-variants'
export type ProjectStatusFilter = 'all' | ProjectStatus
export interface ProjectCard {
id: string
title: string
subtitle: string
status: ProjectStatus
leads: string
metricLabel: string
metricValue: string
trend: string
trendUp: boolean
variants: number
createdAt: string
}
const projectSource: ProjectCard[] = [
{
id: 'p1',
title: 'Q3 마케팅 캠페인',
subtitle: '2일 전 생성',
status: 'active',
leads: '1,240',
metricLabel: '전환율',
metricValue: '4.8%',
trend: '+12%',
trendUp: true,
variants: 3,
createdAt: '2026-03-01T09:00:00.000Z'
},
{
id: 'p2',
title: '블랙프라이데이 2023',
subtitle: '2023년 12월 1일 종료',
status: 'archived',
leads: '5,100',
metricLabel: '방문자',
metricValue: '42.5k',
trend: '종료',
trendUp: false,
variants: 3,
createdAt: '2023-12-01T03:00:00.000Z'
},
{
id: 'p3',
title: 'SaaS 웨비나 시리즈',
subtitle: '4시간 전 수정됨',
status: 'draft',
leads: '0',
metricLabel: '노출 수',
metricValue: '12k',
trend: '+18%',
trendUp: true,
variants: 2,
createdAt: '2026-03-04T01:20:00.000Z'
},
{
id: 'p4',
title: 'Product Hunt 런칭',
subtitle: '2주간 운영중',
status: 'active',
leads: '892',
metricLabel: '클릭률',
metricValue: '2.1%',
trend: '-2%',
trendUp: false,
variants: 1,
createdAt: '2026-02-12T16:30:00.000Z'
}
]
const toLeadNumber = (value: string) => {
const trimmed = value.trim().toLowerCase().replace(/,/g, '')
const numeric = Number(trimmed.replace('k', ''))
if (Number.isNaN(numeric)) {
return 0
}
return trimmed.endsWith('k') ? numeric * 1000 : numeric
}
export const useProjectsStore = defineStore('admin-projects', {
state: () => ({
projects: projectSource,
searchQuery: '',
statusFilter: 'all' as ProjectStatusFilter,
sortBy: 'newest' as ProjectSortBy
}),
getters: {
filteredProjects: (state): ProjectCard[] => {
const query = state.searchQuery.trim().toLowerCase()
const list = state.projects
.filter((project) => {
const target = `${project.title} ${project.subtitle}`.toLowerCase()
const matchQuery = query === '' || target.includes(query)
const matchStatus =
state.statusFilter === 'all' || project.status === state.statusFilter
return matchQuery && matchStatus
})
.sort((a, b) => {
if (state.sortBy === 'oldest') {
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
}
if (state.sortBy === 'most-leads') {
return toLeadNumber(b.leads) - toLeadNumber(a.leads)
}
if (state.sortBy === 'most-variants') {
return b.variants - a.variants
}
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
})
return list
}
},
actions: {
createProject(input: { name: string; campaignName: string }) {
const now = new Date().toISOString()
const id = `p-${Date.now().toString(36)}`
this.projects.unshift({
id,
title: input.name.trim(),
subtitle: `${input.campaignName.trim()}에서 생성`,
status: 'draft',
leads: '0',
metricLabel: '전환율',
metricValue: '-',
trend: '신규',
trendUp: true,
variants: 0,
createdAt: now
})
},
setSearchQuery(value: string) {
this.searchQuery = value
},
setStatusFilter(value: ProjectStatusFilter) {
this.statusFilter = value
},
setSortBy(value: ProjectSortBy) {
this.sortBy = value
}
}
})

View File

@@ -0,0 +1,238 @@
import { defineStore } from 'pinia'
export interface PageVariant {
id: string
pageId: string
name: string
description: string
trafficWeight: number
isActive: boolean
leadCount: number
updatedAt: string
}
export interface CreateVariantInput {
pageId: string
name: string
description: string
trafficWeight: number
}
export interface UpdateVariantInput {
id: string
pageId: string
name?: string
description?: string
trafficWeight?: number
isActive?: boolean
leadCount?: number
}
type VariantMap = Record<string, PageVariant[]>
const toVariantIso = (value = 0) => {
const date = new Date()
date.setSeconds(date.getSeconds() + value)
return date.toISOString()
}
const variantSeed: VariantMap = {
lp01: [
{
id: 'v-lp01-a',
pageId: 'lp01',
name: '일반형',
description: '현재 운영 중인 기본 랜딩 버전',
trafficWeight: 70,
isActive: true,
leadCount: 76,
updatedAt: toVariantIso(-1200)
},
{
id: 'v-lp01-b',
pageId: 'lp01',
name: '강한 메시지형',
description: 'CTA 문구를 자극적으로 강화한 변형',
trafficWeight: 30,
isActive: true,
leadCount: 48,
updatedAt: toVariantIso(-900)
}
],
lp02: [
{
id: 'v-lp02-a',
pageId: 'lp02',
name: '기본 오퍼',
description: '초안 상태 테스트용 템플릿',
trafficWeight: 100,
isActive: true,
leadCount: 16,
updatedAt: toVariantIso(-2000)
}
],
lp03: [
{
id: 'v-lp03-a',
pageId: 'lp03',
name: '구글 버전 A',
description: '광고 채널별로 분기되는 기본 버전',
trafficWeight: 50,
isActive: true,
leadCount: 58,
updatedAt: toVariantIso(-1300)
},
{
id: 'v-lp03-b',
pageId: 'lp03',
name: '구글 버전 B',
description: '강조 문구/이미지를 변경한 실험군',
trafficWeight: 30,
isActive: true,
leadCount: 22,
updatedAt: toVariantIso(-800)
},
{
id: 'v-lp03-c',
pageId: 'lp03',
name: '구글 버전 C',
description: '폼 위치를 상단으로 이동한 변형',
trafficWeight: 20,
isActive: true,
leadCount: 8,
updatedAt: toVariantIso(-500)
}
],
lp04: [
{
id: 'v-lp04-a',
pageId: 'lp04',
name: '저녁 타겟형',
description: '기본 버전',
trafficWeight: 100,
isActive: false,
leadCount: 22,
updatedAt: toVariantIso(-700)
}
],
lp05: [
{
id: 'v-lp05-a',
pageId: 'lp05',
name: '리타겟 기본형',
description: '리마케팅 유입용 기본 템플릿',
trafficWeight: 100,
isActive: true,
leadCount: 0,
updatedAt: toVariantIso(-1000)
}
]
}
const clampTrafficWeight = (value: number) => {
if (!Number.isFinite(value)) {
return 1
}
if (value < 1) {
return 1
}
if (value > 100) {
return 100
}
return Math.round(value)
}
export const useVariantsStore = defineStore('admin-variants', {
state: () => ({
variantsByPage: variantSeed as VariantMap
}),
getters: {
listByPage: (state) => (pageId: string) => {
const list = state.variantsByPage[pageId] ?? []
return [...list].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
},
countByPage: (state) => (pageId: string) => {
return state.variantsByPage[pageId]?.length ?? 0
},
totalLeadsByPage: (state) => (pageId: string) => {
return (state.variantsByPage[pageId] ?? []).reduce((sum, item) => sum + item.leadCount, 0)
}
},
actions: {
createVariant(payload: CreateVariantInput) {
const normalizedName = payload.name.trim()
const pageId = payload.pageId.trim()
const description = payload.description.trim()
if (pageId.length === 0 || normalizedName.length === 0) {
return
}
const pageVariants = this.variantsByPage[pageId] ?? []
const newVariant: PageVariant = {
id: `v-${Date.now().toString(36)}`,
pageId,
name: normalizedName,
description,
trafficWeight: clampTrafficWeight(payload.trafficWeight),
isActive: true,
leadCount: 0,
updatedAt: new Date().toISOString()
}
this.variantsByPage[pageId] = [newVariant, ...pageVariants]
},
updateVariant(payload: UpdateVariantInput) {
const pageId = payload.pageId.trim()
const variants = this.variantsByPage[pageId]
if (!variants) {
return
}
const idx = variants.findIndex((item) => item.id === payload.id)
if (idx === -1) {
return
}
const target = variants[idx]
variants[idx] = {
...target,
...payload,
updatedAt: new Date().toISOString(),
pageId
}
},
toggleActive(pageId: string, variantId: string) {
const variants = this.variantsByPage[pageId]
if (!variants) {
return
}
const idx = variants.findIndex((item) => item.id === variantId)
if (idx === -1) {
return
}
variants[idx] = {
...variants[idx],
isActive: !variants[idx].isActive,
updatedAt: new Date().toISOString()
}
},
removeVariant(pageId: string, variantId: string) {
const variants = this.variantsByPage[pageId]
if (!variants) {
return
}
this.variantsByPage[pageId] = variants.filter((variant) => variant.id !== variantId)
}
}
})