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

13
frontend/.editorconfig Executable file
View File

@@ -0,0 +1,13 @@
# editorconfig.org
root = true
[*]
indent_size = 2
indent_style = space
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

34
frontend/.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: ci
on: push
jobs:
ci:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
node: [22]
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install node
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Lint
run: pnpm run lint
- name: Typecheck
run: pnpm run typecheck

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

21
frontend/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Nuxt UI Templates
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

60
frontend/README.md Normal file
View File

@@ -0,0 +1,60 @@
# Nuxt Starter Template
[![Nuxt UI](https://img.shields.io/badge/Made%20with-Nuxt%20UI-00DC82?logo=nuxt&labelColor=020420)](https://ui.nuxt.com)
Use this template to get started with [Nuxt UI](https://ui.nuxt.com) quickly.
- [Live demo](https://starter-template.nuxt.dev/)
- [Documentation](https://ui.nuxt.com/docs/getting-started/installation/nuxt)
<a href="https://starter-template.nuxt.dev/" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://ui.nuxt.com/assets/templates/nuxt/starter-dark.png">
<source media="(prefers-color-scheme: light)" srcset="https://ui.nuxt.com/assets/templates/nuxt/starter-light.png">
<img alt="Nuxt Starter Template" src="https://ui.nuxt.com/assets/templates/nuxt/starter-light.png" width="830" height="466">
</picture>
</a>
> The starter template for Vue is on https://github.com/nuxt-ui-templates/starter-vue.
## Quick Start
```bash [Terminal]
npm create nuxt@latest -- -t github:nuxt-ui-templates/starter
```
## Deploy your own
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-name=starter&repository-url=https%3A%2F%2Fgithub.com%2Fnuxt-ui-templates%2Fstarter&demo-image=https%3A%2F%2Fui.nuxt.com%2Fassets%2Ftemplates%2Fnuxt%2Fstarter-dark.png&demo-url=https%3A%2F%2Fstarter-template.nuxt.dev%2F&demo-title=Nuxt%20Starter%20Template&demo-description=A%20minimal%20template%20to%20get%20started%20with%20Nuxt%20UI.)
## Setup
Make sure to install the dependencies:
```bash
pnpm install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
pnpm dev
```
## Production
Build the application for production:
```bash
pnpm build
```
Locally preview production build:
```bash
pnpm preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

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)
}
}
})

1934
frontend/bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
// Your custom configs here
)

View File

@@ -0,0 +1,505 @@
# Landing Admin Page Log
관리자 페이지를 새로 만들거나 기능을 추가할 때마다 페이지별로 기록합니다.
## 기록 규칙
- 작성 시각
- 페이지 경로
- 목적
- 라우트
- 구성 항목
- 비고/다음 작업
## 2026-03-03
- 페이지: `/` 홈 랜딩
- 타입: 기본 페이지(비관리자)
- 노트: 초기 기본 레이아웃을 마크업으로 구성했고, 이후 `/admin` 계열부터 페이지 단위 기록은 여기서 계속 누적.
## 2026-03-03
- 페이지: `/admin` 대시보드
- 타입: 관리자 진입점 + 공통 레이아웃
- 라우트: `/admin`, `/admin/index`
- 구성:
- 사이드바 고정형 레이아웃 (`pages/admin.vue`) 추가
- 대시보드 메인 카드 3종(진행중 프로젝트, 전체 프로젝트, 새로운 리드)
- 진행중 프로젝트 테이블 요약 섹션 추가
- 비고:
- 1단계 MVP에서 API 연동 없이 정적 상태로 구성
- 다음 작업: `/admin/projects`, `/admin/pages`, `/admin/leads` 페이지 순차 추가
## 2026-03-03
- 페이지: `/admin` 대시보드(레이아웃 개선)
- 타입: 관리자 대시보드 UI 개선
- 라우트: `/admin`
- 구성:
- 상단 헤더 + 우측 액션 버튼을 1줄 배치
- KPI 3개 카드 + 추이 카드/미니 차트 구간을 2열로 구성하여 세로 몰림 완화
- 하단 목록/빠른 액션 영역을 2열로 나눠 가로형 화면 비중 확대
- 비고: 사용자 요청 반영(“너무 세로로 구성되어 있음” 해결)
## 2026-03-03
- 페이지: `/admin` 대시보드 KPI 라인 정렬
- 타입: 카드 배치 조정
- 라우트: `/admin`
- 구성:
- KPI 카드 3개(진행중 프로젝트/전체 프로젝트/새로운 리드)를 같은 라인(grid-cols-3)으로 고정 배치
- 비고: 동일 라인 요청 반영
## 2026-03-04
- 페이지: `/admin/projects`
- 타입: 프로젝트 그룹 보드
- 라우트: `/admin/projects`
- 구성:
- 상단 브레드크럼 + 타이틀 + `Create New Group` 버튼
- 검색/상태/정렬/필터 바
- 카드형 프로젝트 목록(상태 배지, 메트릭 2열, 하단 액션 버튼)
- `Create New Group` 대시드 카드
- 하단 페이지네이션 바
- 비고:
- 사용자 제공 레퍼런스 이미지 기반으로 다크 보드 스타일 구성
- 현재는 정적 더미 데이터 기반, 이후 API 연동 예정
## 2026-03-04
- 페이지: `/admin/projects` 상태 표시/디자인 톤 보정
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- 상태 표시를 큰 배지에서 `작은 dot + label` 형태로 변경
- 카드 두께/폰트 크기/메트릭 박스 크기 축소로 덜 투박하게 정리
- `Create New Group` 원형 아이콘 크기 축소
- 비고: 사용자 피드백(원형 과대, 전체 투박함) 반영
## 2026-03-04
- 페이지: `/admin/projects` 메트릭 정렬/상태 텍스트 크기 조정
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- 카드 내 `TOTAL LEADS`와 우측 메트릭 박스를 `flex` 기반 좌우 정렬로 고정
- 상태 라벨 폰트 크기 축소(`text-[10px]`) 및 패딩 축소
- 비고: 사용자 피드백(좌우 배치/상태 글자 크기) 반영
## 2026-03-04
- 페이지: `/admin/projects` 메트릭 50:50 폭 고정
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- 카드 메트릭 박스 2개를 `w-1/2 basis-1/2`로 고정하여 정확히 반반 분할
- 비고: 사용자 피드백(한쪽 쏠림) 반영
## 2026-03-04
- 페이지: `/admin/projects` 메트릭 반반 분할 방식 보정
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- `flex` 분할을 `grid grid-cols-2`로 변경해 gap 포함 상태에서도 정확히 50:50 유지
- 비고: 사용자 피드백(여전히 반반 아님) 재반영
## 2026-03-04
- 페이지: `/admin/projects` 메트릭 2열 강제 고정
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- 메트릭 래퍼에 인라인 `display:grid; grid-template-columns: 1fr 1fr;` 적용
- Tailwind 클래스 해석/브레이크포인트 영향 없이 무조건 좌우 반반 유지
- 비고: 사용자 피드백(여전히 위아래 배치됨) 재반영
## 2026-03-04
- 페이지: `/admin/projects` 하단 액션/상태 라벨 조정
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- 카드 하단 `Variants Tested` 라인과 버튼 간격을 늘려 버튼 위쪽 마진 추가
- 상태 배지 텍스트를 더 작은 크기로 축소
- 비고: 사용자 요청 반영(“tested copy url variants 버튼 윗쪽 마진, 상태 표시 더 작게")
## 2026-03-04
- 페이지: `/admin/projects` 하단 액션 여백 추가 강화
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- 하단 액션 래퍼(`Variants/Copy URL` 버튼 행) 상단 마진을 기존 대비 더 크게(`mt-6`) 상향
- 비고: “그대로 딱 붙어있다”는 피드백 반영
## 2026-03-04
- 페이지: `/admin/projects` 하단 액션 강제 간격 적용
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- 하단 버튼 행에 유틸 클래스 기반 `mt-`을 제거하고 `style="margin-top: 48px;"` 인라인 마진 강제 적용
- "tested copy url variants" 구간 분리를 즉시 체감 가능한 수치로 조정
- 비고: 변경사항이 반영 안됐다는 피드백에 대한 재반영
## 2026-03-04
- 페이지: `/admin/projects` 하단 액션 간격 과다 완화
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- 하단 액션 행 인라인 마진을 `48px`에서 `24px`로 축소
- 비고: “너무 많이 떨어짐” 피드백 반영
## 2026-03-04
- 페이지: `/admin/projects` 하단 액션 간격 재상향 조정
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- 하단 액션 행 인라인 마진을 `24px`에서 `28px`로 증가
- 비고: “좀더 올려줘” 피드백 반영
## 2026-03-04
- 페이지: `/admin/projects` 하단 간격 보장 방식 변경
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- `Variants Tested` 블록과 하단 액션 행 사이에 고정 높이 공백 블록(`h-6`) 추가
- 유틸 클래스/인라인 마진 조합과 함께 간격이 누락되지 않도록 구조적으로 보강
- 비고: 간격 변경이 화면에 반영되지 않는 피드백의 재반영
## 2026-03-04
- 페이지: `/admin/projects` 페이지네이션 위치 조정
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- 페이지네이션(`footer`) 위쪽에 `mt-4` 여백 추가
- 비고: 카드 본문보다 약간 아래쪽으로 페이지네이션을 띄우는 요청 반영
## 2026-03-04
- 페이지: `/admin/projects` 상태 라벨 크기 추가 축소
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- 상태 배지 텍스트 사이즈를 `text-[9px]`에서 `text-[8px]`로 축소
- 비고: `ACTIVE/ARCHIVED/DRAFT` 라벨이 더 작게 보이도록 조정
## 2026-03-04
- 페이지: `/admin/projects` 상태 라벨 직접 크기 강제 적용
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- 상태 배지에 `style="font-size: 8px; line-height: 1;"` 직접 지정
- 비고: Tailwind 유틸이 반영 안될 때도 체감되도록 강제 적용
## 2026-03-04
- 페이지: `/admin/projects` 상태 라벨 크기 상향 조정
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- 상태 배지 인라인 폰트 크기를 `8px`에서 `10px`로, `line-height``1.1`로 조정
- 비고: “너무 작다” 피드백 반영
## 2026-03-04
- 페이지: `/admin/projects` 검색/필터 패널 디자인 개선
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- 검색 영역을 입력 박스 + 라벨 + 통일된 필터 칩 스타일의 카드형 패널로 교체
- 검색창에 아이콘 유사 요소 및 focus 보더/그림자 적용
- 상태/정렬/필터 버튼을 동일한 반원각/톤으로 정돈
- 비고: “위쪽 검색하는 부분 이쁘게” 요청 반영
## 2026-03-04
- 페이지: `/admin/projects` 검색/필터 가로 고정 정렬
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- 검색/필터 블록을 `flex-nowrap` 1줄 레이아웃으로 변경
- 버튼에 `shrink-0`/`whitespace-nowrap` 적용해 라인 분리 없이 가로 유지
- 검색 입력 최소 너비 지정으로 한 줄 안정성 강화
- 비고: “가로로 생겨야지” 요청 반영
## 2026-03-04
- 페이지: `/admin/projects` 검색/필터 패널 패딩 보정
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- 검색 패널 `p-4``p-5`, 내부 간격을 `gap-4`로 강화
- 검색 라벨 간격 및 입력창 패딩(`px-9 py-3`) 확장
- 버튼 패딩(`px-5 py-3`) 상향으로 시각적 여유 확보
- 비고: “패딩 주고 보기 이쁘게” 요청 반영
## 2026-03-04
- 페이지: `/admin/projects` 검색/필터 정렬 조정
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- 검색 입력 최소 너비를 `360px`로 증가
- 상단 블록을 `justify-between` 처리 후 버튼 그룹에 `ml-auto` 적용해 Status~Filter를 오른쪽 정렬
- `Status / Sort / Filter`를 한 묶음으로 묶어 오른쪽 정렬 유지
- 비고: “검색란 넓히고 status부터는 오른쪽 정렬” 요청 반영
## 2026-03-04
- 페이지: `/admin/projects` Sort/Filter 컨트롤 타입 정리
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- `Status`를 select 박스로 유지
- `Sort by`를 select 박스로 전환
- `Filter`는 버튼으로 유지
## 2026-03-04
- 페이지: `/admin/projects` Filter 버튼 모양 정리
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- `Filter` 버튼의 그라디언트 스타일 제거 후, 다른 입력 컨트롤과 톤을 맞춘 플랫 보더 스타일로 통일
## 2026-03-04
- 페이지: `/admin/projects` 검색 입력 폭 추가 확대
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- 검색 input 최소 너비를 `520px`로 상향
- 비고: “검색박스 좀 길게” 요청 반영
## 2026-03-04
- 페이지: `/admin/projects` Status 컨트롤 select 전환
- 타입: UI polish
- 라우트: `/admin/projects`
- 구성:
- `Status: All` 버튼을 라벨 + `select` 박스로 변경
- 옵션: All / Active / Archived / Draft
## 2026-03-04
- 페이지: `/admin/pages/[id]/builder`
- 타입: 관리자 페이지 편집
- 라우트: `/admin/pages/:id/builder`
- 구성:
- `빌더 열기` 버튼을 페이지 목록에서 해당 route로 이동하도록 연결
- 동적 라우트 페이지 추가
- 페이지 메타(이름/캠페인/도메인/경로/상태) 표시
- 모바일 폭 기준 미리보기 카드형 캔버스 + 블록 리스트 영역 구성
- 블록 추가/위/아래 이동/삭제 인터랙션 추가
- 빈 상태 시 “페이지를 찾을 수 없음” 대응 처리
## 2026-03-04
- 페이지: `/admin/pages` 새 페이지 생성 기능
- 타입: UI/기능 개선
- 라우트: `/admin/pages`
- 구성:
- `CreatePageModal` 컴포넌트 추가
- 입력 필드: 페이지명, 프로젝트, 도메인, 경로
- 배경 클릭/닫기 버튼/취소 버튼으로 모달 종료
- `/admin/pages` 생성 버튼과 빈 상태 카드의 “바로 만들기”에 모달 연동
- `pages` store에 `createPage` 액션 추가
- 수동 입력 기반 페이지 생성(상태: `draft`, 리드/방문자/버전=0/0/1)
- 기존 `createQuickPage`는 기본값 기반 호출에서 `createPage`로 내부 위임
## 2026-03-04
- 페이지: `/admin/pages` 페이지 관리
- 타입: 관리자 페이지 기능 확장
- 라우트: `/admin/pages`
- 구성:
- Pinia 기반 `usePagesStore` 추가 (`frontend/app/stores/pages.ts`)
- 페이지 목록 UI/검색/필터/Campaign/정렬/생성/삭제 기능 구현
- 캠페인 카드에서 `페이지 관리` 버튼 추가 (`/admin/pages?campaignId=<projectId>`)
- `/admin` 좌측 사이드바 링크(`/admin/pages`)와 동작 연동
- 비고:
- 현재는 더미 데이터 기반 MVP 운영 화면
- 빌더 연동은 다음 단계에서 라우트 연계
## 2026-03-04
- 페이지: `/admin/pages`, `/admin/projects`
- 타입: 한글 로컬라이즈
- 라우트: `/admin/pages`, `/admin/projects`
- 구성:
- `/admin/pages` 제목/버튼/라벨/컬럼/상태/안내문구 한글 전환
- `/admin/projects` 영문 노출 라벨 일괄 한국어 전환
- 페이지 관리 스토어 캠페인명, 샘플 페이지명, 상태 라벨을 한글로 조정
- 비고:
- 영문 코드는 값 자체 유지(`live`, `draft` 등), 화면 텍스트만 한글화
## 2026-03-04
- 페이지: `/admin/leads`
- 타입: 리드 조회 페이지 추가
- 라우트: `/admin/leads`
- 구성:
- 검색, 상태, 채널, 캠페인 필터 지원
- 더미 리드 테이블(이름/연락처/이메일/캠페인/페이지/채널/상태/수집일) 표시
- 엑셀 내보내기 버튼(미구현 액션, UI 우선) 추가
- 비고:
- 사이드바 링크는 기존 `/admin` 레이아웃에 이미 존재해 바로 접근 가능
## 2026-03-04
- 페이지: `/admin/projects`
- 타입: 새 프로젝트 생성 모달 컴포넌트
- 라우트: `/admin/projects`
- 구성:
- 공용 컴포넌트 `CreateProjectModal` 신규 생성 (`frontend/app/components/admin/CreateProjectModal.vue`)
- 프로젝트명, 캠페인 표시명 입력폼 + 닫기/생성 액션
- 프로젝트 목록 페이지에서 모달 오픈/닫기 상태 관리 및 제출 이벤트 처리 추가
- `useProjectsStore``createProject` 액션 추가 (`frontend/app/stores/projects.ts`)
- 새 프로젝트 생성 버튼 및 새 그룹 카드에서 모달 오픈 연동
- 비고:
- 현재는 더미 데이터 스토어에 `unshift`로 즉시 반영되는 MVP 동작
## 2026-03-04
- 페이지: `/admin`, `/admin/projects`, `/admin/pages`
- 타입: 프로젝트/대시보드 라벨 완전 한글화
- 라우트: `/admin`, `/admin/index`, `/admin/projects`, `/admin/pages`
- 구성:
- 레이아웃 타이틀을 `Campaign & Page Console`에서 `캠페인·페이지 콘솔`로 변경
- 대시보드 라벨 `Dashboard``대시보드`로 변경
- 프로젝트 스토어 샘플 데이터(`title/subtitle/metricLabel`) 한글화
- `CONV. RATE``전환율`
- `CTR``클릭률`
- `Final``종료`
## 2026-03-04
- 페이지: `/admin` Pinia 런타임
- 타입: 상태 관리 안정화
- 라우트: `/admin`, `/admin/projects`
- 구성:
- `frontend/app/plugins/pinia.ts`(수동 `createPinia()` 주입 플러그인) 제거
- `@pinia/nuxt` 공식 모듈만 사용하도록 정리
- `useDashboardStore`, `useProjectsStore`를 setup 내에서 호출하되 Pinia 인스턴스가 항상 Nuxt 플러그인으로 주입되도록 정리
- 비고:
- `[🍍] getActivePinia()` 경고는 커스텀 플러그인과 공식 모듈 중복 등록 가능성이 원인이라 판단
- 수정 후 dev 서버 재시작 필요
## 2026-03-04
- 설정: Pinia 전역 상태 관리 연동
- 타입: 플랫폼 인프라
- 라우트: 전체 관리자 페이지
- 구성:
- `@pinia/nuxt``pinia` 의존성 추가
- `nuxt.config.ts``@pinia/nuxt` 모듈 등록
- 비고: 요청 반영으로 Pinia 기반 관리 준비
## 2026-03-04
- 페이지: `/admin/projects` 프로젝트 데이터 스토어 마이그레이션
- 타입: 상태 관리
- 라우트: `/admin/projects`
- 구성:
- `app/stores/projects.ts` 신규 추가
- 검색/상태필터/정렬 state 및 `filteredProjects` getter 적용
- 상단 검색/Status/Sort 컨트롤을 store 바인딩(`v-model`) 처리
## 2026-03-04
- 페이지: `/admin` 대시보드 데이터 스토어 마이그레이션
- 타입: 상태 관리
- 라우트: `/admin`
- 구성:
- `app/stores/dashboard.ts` 신규 추가
- KPI 카드, 리드 추이, 진행중 프로젝트 목록을 스토어로 이동
- `admin/index.vue``storeToRefs(useDashboardStore())` 연동
## 2026-03-04
- 스타일: 관리자 전체 컬러 톤 강화
- 타입: UI polish
- 라우트: `/admin/*`
- 구성:
- 사이드바/네비게이션에 그라데이션 및 포인트 색상(시안/인디고) 적용
- 메인 영역 배경에 라이트 누출형 radial 컬러 레이어 적용
- 대시보드/프로젝트 페이지의 헤더, 검색, 필터, 카드, 버튼에 컬러 그라데이션 추가
- 비고: “너무 칙칙한데 컬러 좀 넣어줘” 요청 반영
## 2026-03-04
- 페이지: `/admin/pages`
- 타입: 라우팅 구조 보정
- 라우트: `/admin/pages`
- 구성:
- 페이지 목록 파일을 `frontend/app/pages/admin/pages.vue`에서 `frontend/app/pages/admin/pages/index.vue`로 이동
- 하위 경로인 `/admin/pages/:id/builder` 라우트 충돌 가능성 해소
## 2026-03-04
- 페이지: `/admin/pages/[id]/builder`
- 타입: 빌더 기능 확장
- 라우트: `/admin/pages/:id/builder`
- 구성:
- 블록 타입을 `html`, `image`로 단일화
- 이미지 파일 업로드 시 업로드 즉시 `이미지` 블록 생성
- 이미지 URL로 블록 생성 기능 추가
- 블록 순서 이동(위/아래) 및 삭제 유지
- 헤더 설정 구간 추가: 페이지 제목, 메타 설명/키워드, 파비콘, head 추가코드, body script
- 저장 버튼에서 메타 + 블록을 조합한 최종 HTML 생성 및 클립보드 복사 영역 제공
## 2026-03-04
- 페이지: `/admin/pages/[id]/builder`
- 타입: 컴파일 오류 대응 리팩터링
- 라우트: `/admin/pages/:id/builder`
- 구성:
- `Invalid end tag` 원인 분리를 위해 빌더 컴포넌트를 전체 재작성
- `script` 태그 문자열 조합을 안전 분할(`'<' + 'script>'` / `'</' + 'script>'`) 방식으로 정리
- 핵심 편집/미리보기/블록 추가/정렬/삭제/삭제/클립보드 복사 흐름 유지
- 기존 템플릿/스크립트 충돌 의심 소지를 줄이는 형태로 재구성
## 2026-03-04
- 페이지: `/admin/pages/[id]/builder`
- 타입: UI/UX 조정
- 라우트: `/admin/pages/:id/builder`
- 구성:
- 헤더 제목/본문 문구/버튼 문구 입력 영역 제거(요청 반영)
- 최종 생성 HTML에서 기본 문구 블록 제거, 블록 기반 렌더링 중심으로 정리
- 이미지 업로드에 `multiple` 적용 및 드래그앤드롭 지원 추가
- 드롭/선택된 이미지들을 한 번에 최대 10개까지 등록하도록 처리
## 2026-03-04
- 페이지: `/admin/pages/[id]/builder`
- 타입: 업로드 UX 축소
- 라우트: `/admin/pages/:id/builder`
- 구성:
- 로컬 업로드 라벨/이미지 URL 입력 UI 제거
- 이미지 업로드를 드래그앤드롭 박스 1개와 내부 파일 입력으로만 처리
- 별도 로컬/URL 선택 패널 UI 제거 후 업로드 영역 단일화
## 2026-03-04
- 페이지: `/admin/pages/[id]/builder`
- 타입: 미리보기 정합성
- 라우트: `/admin/pages/:id/builder`
- 구성:
- 미리보기 패널이 `space-y` 래핑 때문에 생기던 블록 간 간격을 제거
- 실제 최종 랜딩 렌더(body) 문자열과 동일한 DOM 기준으로 미리보기 렌더링 적용
## 2026-03-04
- 페이지: `/admin/pages/[id]/builder`
- 타입: 블록 구성 변경
- 라우트: `/admin/pages/:id/builder`
- 구성:
- 폼 블록/카톡 싱크 버튼 블록 생성 버튼을 제거
- footer HTML 입력 필드 추가 (헤더/메타/바디스크립트 설정과 함께 저장/미리보기에 반영)
## 2026-03-04
- 페이지: `/admin/pages/[id]/builder`
- 타입: 미리보기 UX
- 라우트: `/admin/pages/:id/builder`
- 구성:
- 실제 랜딩 생성 데이터는 유지한 상태로, 미리보기 전용 이미지 렌더 방식을 분리
- 미리보기에서만 이미지 최대 너비/높이 제한을 적용해 썸네일처럼 작게 표시
## 2026-03-04
- 페이지: `/admin/pages/[id]/builder`
- 타입: 이미지 블록 목록 UX
- 라우트: `/admin/pages/:id/builder`
- 구성:
- 이미지 블록 미리보기에서 둥근 모서리 제거(`border-radius:0`)
- 이미지 블록 목록에서 blob URL 텍스트 제거
- 이미지 블록 목록 항목 좌측에 썸네일 이미지 미리보기 표시
## 2026-03-04
- 페이지: `/admin/pages/[id]/builder`
- 타입: 블록 종류 확장
- 라우트: `/admin/pages/:id/builder`
- 구성:
- `폼 블록` 추가 기능(기본 폼 HTML 생성)
- `카카오 싱크 버튼 블록` 추가 기능(카카오 버튼 샘플 HTML 생성)
- `html 블록` 옆에 두 액션 버튼 배치(폼/카톡 싱크)
## 2026-03-04
- 페이지: `/admin/pages/[id]/builder`
- 타입: 이미지 업로드 정책 변경
- 라우트: `/admin/pages/:id/builder`
- 구성:
- 이미지 업로드 최대 장수 제한 제거
- 드롭/선택한 모든 이미지 파일을 일괄 등록
- UI 문구에서 “최대 10장” 표현 제거
## 2026-03-04
- 페이지: `/admin/pages/[id]/variants`
- 타입: 변형(variant) 관리 페이지
- 라우트: `/admin/pages/:id/variants`
- 구성:
- Pinia 스토어 `frontend/app/stores/variants.ts` 신규 추가
- 페이지별 변형 목록/생성/삭제/활성-비활성 토글 기능
- 페이지 목록(`/admin/pages`) 카드에 “변형 관리” 바로가기 추가
- 프로젝트 카드의 “변형 관리” 버튼을 페이지별 변형 페이지로 연결
- 변형 메트릭(총 변형 수, 누적 리드, 총 트래픽 비율), 변형명/설명/비율/리드 수 UI 구성
- 비고:
- 페이지별 변형 개수는 `variants` 스토어 기준으로 실시간 계산되어 표시

21
frontend/nuxt.config.ts Normal file
View File

@@ -0,0 +1,21 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
import tailwindcss from '@tailwindcss/vite'
export default defineNuxtConfig({
modules: ['@pinia/nuxt', 'shadcn-nuxt'],
shadcn: {
prefix: 'Ui',
},
devtools: {
enabled: true,
},
css: ['~/assets/css/main.css'],
vite: {
plugins: [tailwindcss()],
},
compatibilityDate: '2025-01-15'
});

28
frontend/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "frontend",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev --port 3000",
"preview": "nuxt preview --port 3000",
"postinstall": "nuxt prepare",
"lint": "eslint .",
"typecheck": "nuxt typecheck"
},
"packageManager": "bun@1.3.9",
"dependencies": {
"@pinia/nuxt": "^0.11.3",
"@tailwindcss/vite": "^4.1.18",
"nuxt": "^4.3.1",
"pinia": "^3.0.4",
"shadcn-nuxt": "^2.2.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@nuxt/eslint": "^1.15.1",
"eslint": "^10.0.0",
"typescript": "^5.9.3",
"vue-tsc": "^3.2.4"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

13
frontend/renovate.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": [
"github>nuxt/renovate-config-nuxt"
],
"lockFileMaintenance": {
"enabled": true
},
"packageRules": [{
"matchDepTypes": ["resolutions"],
"enabled": false
}],
"postUpdateOptions": ["pnpmDedupe"]
}

10
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{ "path": "./.nuxt/tsconfig.app.json" },
{ "path": "./.nuxt/tsconfig.server.json" },
{ "path": "./.nuxt/tsconfig.shared.json" },
{ "path": "./.nuxt/tsconfig.node.json" }
]
}