macbook 에서 리눅스로 이동
This commit is contained in:
13
frontend/.editorconfig
Executable file
13
frontend/.editorconfig
Executable 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
34
frontend/.github/workflows/ci.yml
vendored
Normal 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
24
frontend/.gitignore
vendored
Normal 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
21
frontend/LICENSE
Normal 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
60
frontend/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Nuxt Starter Template
|
||||
|
||||
[](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
|
||||
|
||||
[](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
3
frontend/app/app.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
17
frontend/app/assets/css/main.css
Normal file
17
frontend/app/assets/css/main.css
Normal 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;
|
||||
}
|
||||
}
|
||||
151
frontend/app/components/admin/CreatePageModal.vue
Normal file
151
frontend/app/components/admin/CreatePageModal.vue
Normal 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>
|
||||
89
frontend/app/components/admin/CreateProjectModal.vue
Normal file
89
frontend/app/components/admin/CreateProjectModal.vue
Normal 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>
|
||||
41
frontend/app/components/ui/button/index.ts
Normal file
41
frontend/app/components/ui/button/index.ts
Normal 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 };
|
||||
|
||||
50
frontend/app/pages/admin.vue
Normal file
50
frontend/app/pages/admin.vue
Normal 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>
|
||||
95
frontend/app/pages/admin/index.vue
Normal file
95
frontend/app/pages/admin/index.vue
Normal 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>
|
||||
213
frontend/app/pages/admin/leads.vue
Normal file
213
frontend/app/pages/admin/leads.vue
Normal 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>
|
||||
375
frontend/app/pages/admin/pages/[id]/builder.vue
Normal file
375
frontend/app/pages/admin/pages/[id]/builder.vue
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
</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">홈 > 페이지 > 빌더</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>
|
||||
224
frontend/app/pages/admin/pages/[id]/variants.vue
Normal file
224
frontend/app/pages/admin/pages/[id]/variants.vue
Normal 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>
|
||||
273
frontend/app/pages/admin/pages/index.vue
Normal file
273
frontend/app/pages/admin/pages/index.vue
Normal 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>
|
||||
259
frontend/app/pages/admin/projects.vue
Normal file
259
frontend/app/pages/admin/projects.vue
Normal 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>
|
||||
90
frontend/app/pages/index.vue
Normal file
90
frontend/app/pages/index.vue
Normal 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>
|
||||
69
frontend/app/stores/dashboard.ts
Normal file
69
frontend/app/stores/dashboard.ts
Normal 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[]
|
||||
})
|
||||
})
|
||||
242
frontend/app/stores/pages.ts
Normal file
242
frontend/app/stores/pages.ts
Normal 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}`
|
||||
}
|
||||
}
|
||||
})
|
||||
156
frontend/app/stores/projects.ts
Normal file
156
frontend/app/stores/projects.ts
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
238
frontend/app/stores/variants.ts
Normal file
238
frontend/app/stores/variants.ts
Normal 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
1934
frontend/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
6
frontend/eslint.config.mjs
Normal file
6
frontend/eslint.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
// @ts-check
|
||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||
|
||||
export default withNuxt(
|
||||
// Your custom configs here
|
||||
)
|
||||
505
frontend/landing-admin-notes.md
Normal file
505
frontend/landing-admin-notes.md
Normal 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
21
frontend/nuxt.config.ts
Normal 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
28
frontend/package.json
Normal 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
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
13
frontend/renovate.json
Normal file
13
frontend/renovate.json
Normal 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
10
frontend/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user