Files
BarangaySystem/resources/js/Pages/LandingPageEditor.vue
2026-06-06 18:43:00 +08:00

1265 lines
46 KiB
Vue

<template>
<div class="landing-editor-page min-vh-100 pb-5">
<!-- Header -->
<header class="header-landing text-white py-4 shadow-lg border-bottom border-warning border-4 position-relative overflow-hidden">
<div class="header-mesh position-absolute top-0 start-0 w-100 h-100"></div>
<div class="container py-2 position-relative" style="z-index: 2;">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-4">
<div class="d-flex align-items-center gap-4 animate-fade-in">
<div class="display-container position-relative">
<i class="fas fa-palette fa-3x text-gradient-warm position-relative" style="z-index: 3;"></i>
<div class="pulse-ring-warm position-absolute top-50 start-50 translate-middle"></div>
</div>
<div>
<h1 class="display-6 fw-black text-white mb-0 ls-tight">Landing Page <span class="text-gradient-warm">Editor</span></h1>
<p class="text-white-50 fw-medium small text-uppercase ls-wide mt-1">
Design the guest homepage experience
</p>
</div>
</div>
<div class="d-flex gap-3 flex-wrap">
<button
@click="openCreateModal"
class="btn btn-gradient-warm rounded-pill px-4 fw-bold shadow-lg d-flex align-items-center gap-2"
>
<i class="fas fa-plus-circle"></i>
New Landing Page
</button>
<button
@click="goBack"
class="btn btn-outline-light rounded-pill px-4 fw-bold border-2"
>
<i class="fas fa-arrow-left me-2"></i> Back
</button>
</div>
</div>
</div>
</header>
<div class="container mt-4">
<!-- Active Landing Page Indicator -->
<div v-if="activePage" class="active-indicator mb-4 animate-slide-up">
<div class="card border-0 shadow-lg rounded-5 overflow-hidden bg-gradient-subtle-warm">
<div class="card-body p-4 d-flex align-items-center justify-content-between flex-wrap gap-3">
<div class="d-flex align-items-center gap-3">
<div class="pulse-dot-success"></div>
<div>
<div class="fw-black text-dark">Currently Active: <span class="text-primary">{{ activePage.title }}</span></div>
<div class="small text-muted">This landing page is shown to all unauthenticated visitors.</div>
</div>
</div>
<button
@click="deactivateAll"
class="btn btn-outline-danger rounded-pill px-4 fw-bold btn-sm border-2"
:disabled="isLoading"
>
<i class="fas fa-times-circle me-1"></i>
Deactivate (Use Default)
</button>
</div>
</div>
</div>
<!-- No Active Page Notice -->
<div v-else-if="!isLoading && pages.length > 0" class="mb-4 animate-slide-up">
<div class="alert alert-info border-0 rounded-4 shadow-sm d-flex align-items-center gap-3">
<i class="fas fa-info-circle fa-lg"></i>
<div class="fw-bold small">No active landing page. Guests will see the default homepage. Set one as active below.</div>
</div>
</div>
<!-- Loading State -->
<div v-if="isLoading && pages.length === 0" class="text-center py-5">
<div class="spinner-border text-primary mb-3" style="width: 3rem; height: 3rem;"></div>
<div class="fw-bold text-muted">Loading landing pages...</div>
</div>
<!-- Empty State -->
<div v-else-if="pages.length === 0 && !isLoading" class="text-center py-5 animate-fade-in">
<div class="empty-state mx-auto mb-4">
<i class="fas fa-file-code fa-5x text-muted opacity-15"></i>
</div>
<h3 class="fw-black text-muted mb-2">No Landing Pages Yet</h3>
<p class="text-muted mb-4">Create your first landing page to welcome guest visitors with a custom experience.</p>
<button @click="openCreateModal" class="btn btn-gradient-warm rounded-pill px-5 py-3 fw-bold shadow-lg">
<i class="fas fa-plus-circle me-2"></i>
Create Your First Landing Page
</button>
</div>
<!-- Pages Grid -->
<div v-else class="row g-4">
<div v-for="page in pages" :key="page.hashkey" class="col-md-6 col-xl-4 animate-slide-up">
<div
class="card border-0 shadow-sm rounded-5 overflow-hidden h-100 page-card transition-all"
:class="{ 'ring-active': page.is_active }"
>
<!-- Preview Thumbnail -->
<div class="card-preview position-relative">
<div class="preview-frame bg-light" v-html="sanitizePreview(page.html_content)"></div>
<div class="preview-overlay d-flex align-items-center justify-content-center gap-2">
<button @click="openPreviewModal(page)" class="btn btn-white rounded-pill shadow-sm fw-bold px-3 btn-sm">
<i class="fas fa-eye me-1"></i> Preview
</button>
<button @click="editPage(page)" class="btn btn-primary rounded-pill shadow-sm fw-bold px-3 btn-sm">
<i class="fas fa-edit me-1"></i> Edit
</button>
</div>
<!-- Active Badge -->
<div v-if="page.is_active" class="position-absolute top-0 end-0 m-3">
<span class="badge bg-success rounded-pill px-3 py-2 fw-bold shadow-sm">
<i class="fas fa-check-circle me-1"></i> ACTIVE
</span>
</div>
</div>
<!-- Card Info -->
<div class="card-body p-4">
<h5 class="fw-black mb-1 text-dark">{{ page.title }}</h5>
<p v-if="page.description" class="text-muted small mb-2 text-truncate">{{ page.description }}</p>
<div class="d-flex align-items-center gap-2 small text-muted">
<i class="fas fa-clock"></i>
<span>{{ formatDate(page.updated_at || page.created_at) }}</span>
</div>
</div>
<!-- Card Actions -->
<div class="card-footer bg-white border-0 p-4 pt-0">
<div class="d-flex gap-2">
<button
v-if="!page.is_active"
@click="setActive(page)"
class="btn btn-soft-success rounded-pill px-3 fw-bold btn-sm flex-grow-1"
:disabled="isLoading"
>
<i class="fas fa-check me-1"></i> Set Active
</button>
<button
v-else
@click="deactivateAll"
class="btn btn-soft-warning rounded-pill px-3 fw-bold btn-sm flex-grow-1"
:disabled="isLoading"
>
<i class="fas fa-pause me-1"></i> Deactivate
</button>
<button
@click="confirmDelete(page)"
class="btn btn-soft-danger rounded-pill px-3 fw-bold btn-sm"
:disabled="isLoading"
>
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div v-if="showEditorModal" class="modal-overlay" @click.self="closeEditor">
<div class="editor-modal animate-slide-up" @click.stop>
<div class="editor-header d-flex align-items-center justify-content-between p-4 border-bottom">
<h4 class="fw-black mb-0 d-flex align-items-center gap-3">
<i :class="editingPage ? 'fas fa-edit text-primary' : 'fas fa-plus-circle text-success'"></i>
{{ editingPage ? 'Edit Landing Page' : 'Create Landing Page' }}
</h4>
<button @click="closeEditor" class="btn btn-outline-secondary rounded-circle p-0" style="width: 36px; height: 36px;">
<i class="fas fa-times"></i>
</button>
</div>
<div class="editor-body p-4">
<!-- Title -->
<div class="mb-4">
<label class="form-label fw-bold">Page Title <span class="text-danger">*</span></label>
<input
v-model="editorForm.title"
type="text"
class="form-control rounded-pill border-2 px-4 h-48px"
placeholder="e.g., Holiday Special Landing Page"
>
</div>
<!-- Description -->
<div class="mb-4">
<label class="form-label fw-bold">Description <span class="text-muted small">(optional)</span></label>
<input
v-model="editorForm.description"
type="text"
class="form-control rounded-pill border-2 px-4"
placeholder="Brief description for internal reference..."
>
</div>
<!-- Tabs for Visual, Code and Preview -->
<div class="mb-3 d-flex gap-2 flex-wrap">
<button
@click="switchTab('visual')"
class="btn rounded-pill px-4 fw-bold btn-sm"
:class="editorTab === 'visual' ? 'btn-dark' : 'btn-outline-dark'"
>
<i class="fas fa-magic me-1"></i> Visual Builder
</button>
<button
@click="switchTab('code')"
class="btn rounded-pill px-4 fw-bold btn-sm"
:class="editorTab === 'code' ? 'btn-dark' : 'btn-outline-dark'"
>
<i class="fas fa-code me-1"></i> HTML Code
</button>
<button
@click="switchTab('preview')"
class="btn rounded-pill px-4 fw-bold btn-sm"
:class="editorTab === 'preview' ? 'btn-dark' : 'btn-outline-dark'"
>
<i class="fas fa-eye me-1"></i> Preview
</button>
</div>
<!-- Visual Builder -->
<div v-show="editorTab === 'visual'" class="visual-builder">
<div class="row g-3">
<!-- Palette -->
<div class="col-md-3">
<div class="palette p-3 rounded-4 border bg-light h-100">
<div class="fw-black mb-2 small text-uppercase text-muted">
<i class="fas fa-shapes me-1"></i> Drag to add
</div>
<div
v-for="t in blockTypeList"
:key="t.id"
class="palette-item d-flex align-items-center gap-2 p-2 mb-2 rounded-3 bg-white border fw-bold small"
draggable="true"
@dragstart="onPaletteDragStart($event, t.id)"
>
<i :class="t.icon" class="text-primary"></i>
<span>{{ t.label }}</span>
<i class="fas fa-grip-vertical ms-auto text-muted small"></i>
</div>
</div>
</div>
<!-- Canvas -->
<div class="col-md-5">
<div
class="canvas rounded-4 border p-2 bg-white"
@dragover.prevent
@drop="onCanvasDrop($event, blocks.length)"
>
<div v-if="blocks.length === 0" class="text-center text-muted py-5">
<i class="fas fa-hand-pointer fa-2x mb-2 opacity-50"></i>
<div class="fw-bold">Drop blocks here</div>
<div class="small">Drag from the palette on the left</div>
</div>
<div
v-for="(b, i) in blocks"
:key="b._id"
class="block-card position-relative rounded-3 border mb-2"
:class="{ 'block-active': selectedBlockId === b._id }"
draggable="true"
@dragstart="onBlockDragStart($event, i)"
@dragover.prevent="onBlockDragOver($event, i)"
@drop.stop="onCanvasDrop($event, i)"
@click="selectedBlockId = b._id"
>
<div class="block-header d-flex align-items-center gap-2 px-2 py-1 bg-light border-bottom">
<i class="fas fa-grip-vertical text-muted small" style="cursor: grab;"></i>
<i :class="blockTypes[b.type].icon" class="text-primary small"></i>
<span class="fw-bold small">{{ blockTypes[b.type].label }}</span>
<button class="btn btn-sm btn-link text-muted ms-auto p-0" @click.stop="duplicateBlock(i)" title="Duplicate">
<i class="fas fa-copy"></i>
</button>
<button class="btn btn-sm btn-link text-danger p-0" @click.stop="removeBlock(i)" title="Delete">
<i class="fas fa-trash-alt"></i>
</button>
</div>
<div class="block-thumb p-1" v-html="renderBlock(b)"></div>
</div>
</div>
</div>
<!-- Inspector -->
<div class="col-md-4">
<div class="inspector rounded-4 border bg-light p-3 h-100">
<div class="fw-black mb-3 small text-uppercase text-muted">
<i class="fas fa-sliders-h me-1"></i> Properties
</div>
<div v-if="!selectedBlock" class="text-muted small text-center py-4">
Select a block to edit its properties.
</div>
<div v-else>
<div v-for="f in blockTypes[selectedBlock.type].fields" :key="f.key" class="mb-3">
<label class="form-label small fw-bold mb-1">{{ f.label }}</label>
<textarea
v-if="f.type === 'textarea'"
v-model="selectedBlock[f.key]"
class="form-control form-control-sm rounded-3"
rows="3"
></textarea>
<input
v-else-if="f.type === 'color'"
type="color"
v-model="selectedBlock[f.key]"
class="form-control form-control-sm form-control-color"
>
<input
v-else-if="f.type === 'number'"
type="number"
v-model.number="selectedBlock[f.key]"
class="form-control form-control-sm rounded-3"
>
<input
v-else
type="text"
v-model="selectedBlock[f.key]"
class="form-control form-control-sm rounded-3"
>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Code Editor -->
<div v-show="editorTab === 'code'" class="code-editor-wrapper">
<div class="code-toolbar d-flex align-items-center justify-content-between px-3 py-2 bg-dark rounded-top-4">
<span class="text-white-50 small fw-bold">
<i class="fas fa-code me-1"></i> HTML Editor
</span>
<div class="d-flex gap-2">
<button @click="insertTemplate('hero')" class="btn btn-sm btn-outline-light rounded-pill px-3 fw-bold" title="Insert Hero Section">
<i class="fas fa-star me-1"></i> Hero
</button>
<button @click="insertTemplate('features')" class="btn btn-sm btn-outline-light rounded-pill px-3 fw-bold" title="Insert Features Section">
<i class="fas fa-th me-1"></i> Features
</button>
<button @click="insertTemplate('cta')" class="btn btn-sm btn-outline-light rounded-pill px-3 fw-bold" title="Insert CTA Section">
<i class="fas fa-bullhorn me-1"></i> CTA
</button>
</div>
</div>
<textarea
ref="codeEditor"
v-model="editorForm.html_content"
class="code-textarea font-monospace"
rows="20"
placeholder="Paste or write your HTML landing page content here..."
spellcheck="false"
></textarea>
</div>
<!-- Preview -->
<div v-show="editorTab === 'preview'" class="preview-container rounded-4 border overflow-hidden bg-white shadow-sm">
<div class="preview-bar bg-light d-flex align-items-center gap-2 px-3 py-2 border-bottom">
<div class="d-flex gap-1">
<span class="dot-red"></span>
<span class="dot-yellow"></span>
<span class="dot-green"></span>
</div>
<span class="text-muted small fw-bold flex-grow-1 text-center">Landing Page Preview</span>
</div>
<div class="preview-frame-lg" v-html="editorForm.html_content"></div>
</div>
</div>
<div class="editor-footer d-flex align-items-center justify-content-end gap-3 p-4 border-top bg-light">
<button @click="closeEditor" class="btn btn-outline-secondary rounded-pill px-4 fw-bold">
Cancel
</button>
<button
@click="savePage"
class="btn btn-gradient-warm rounded-pill px-5 fw-bold shadow-lg d-flex align-items-center gap-2"
:disabled="isLoading || !editorForm.title || (!editorForm.html_content && blocks.length === 0)"
>
<i v-if="isLoading" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas fa-save"></i>
{{ editingPage ? 'Update Page' : 'Save Page' }}
</button>
</div>
</div>
</div>
<!-- Preview Modal -->
<div v-if="showPreviewModal" class="modal-overlay" @click.self="showPreviewModal = false">
<div class="preview-modal animate-slide-up" @click.stop>
<div class="d-flex align-items-center justify-content-between p-3 border-bottom bg-white">
<h5 class="fw-black mb-0">
<i class="fas fa-eye text-primary me-2"></i>
{{ previewPage?.title || 'Preview' }}
</h5>
<button @click="showPreviewModal = false" class="btn btn-outline-secondary rounded-circle p-0" style="width: 36px; height: 36px;">
<i class="fas fa-times"></i>
</button>
</div>
<div class="preview-frame-full" v-html="previewPage?.html_content"></div>
</div>
</div>
<!-- Delete Confirmation -->
<ConfirmModal
v-model="showDeleteConfirm"
title="Delete Landing Page"
:message="'Are you sure you want to permanently delete \'' + (deletingPage?.title || '') + '\'? This cannot be undone.'"
confirmText="Delete Permanently"
variant="danger"
@confirm="performDelete"
/>
<!-- Toast -->
<div class="position-fixed bottom-0 end-0 p-4 mb-5" style="z-index: 99999">
<div v-for="t in toasts" :key="t.id" class="toast show animate-slide-in mb-3 border-0 shadow-lg rounded-4 overflow-hidden" :class="'bg-' + t.type">
<div class="d-flex p-3 align-items-center gap-3 text-white">
<i :class="t.icon"></i>
<span class="fw-bold">{{ t.msg }}</span>
<button @click="removeToast(t.id)" class="ms-auto btn-close btn-close-white small"></button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import axios from 'axios';
import ConfirmModal from '@/Components/Core/ConfirmModal.vue';
const pages = ref([]);
const isLoading = ref(false);
const showEditorModal = ref(false);
const showPreviewModal = ref(false);
const showDeleteConfirm = ref(false);
const editorTab = ref('code');
const editingPage = ref(null);
const previewPage = ref(null);
const deletingPage = ref(null);
const toasts = ref([]);
const codeEditor = ref(null);
const editorForm = ref({
title: '',
html_content: '',
description: '',
hashkey: null,
});
// ── Visual builder state ───────────────────────────────────────────────
const blocks = ref([]);
const selectedBlockId = ref(null);
const selectedBlock = computed(() => blocks.value.find(b => b._id === selectedBlockId.value));
const escHtml = (v) => String(v ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
const blockTypes = {
hero: {
label: 'Hero',
icon: 'fas fa-star',
defaults: () => ({
title: 'Welcome to Our Platform',
subtitle: 'Discover fresh produce, connect with local farmers, and support your community.',
ctaText: 'Get Started',
ctaUrl: '/login',
bgFrom: '#667eea',
bgTo: '#764ba2',
textColor: '#ffffff',
}),
fields: [
{ key: 'title', label: 'Headline', type: 'text' },
{ key: 'subtitle', label: 'Subtitle', type: 'textarea' },
{ key: 'ctaText', label: 'Button text', type: 'text' },
{ key: 'ctaUrl', label: 'Button link', type: 'text' },
{ key: 'bgFrom', label: 'Background gradient (from)', type: 'color' },
{ key: 'bgTo', label: 'Background gradient (to)', type: 'color' },
{ key: 'textColor', label: 'Text color', type: 'color' },
],
render: (b) => `<section style="background: linear-gradient(135deg, ${b.bgFrom} 0%, ${b.bgTo} 100%); color: ${b.textColor}; padding: 80px 20px; text-align: center;">
<h1 style="font-size: 3rem; font-weight: 900; margin-bottom: 16px;">${escHtml(b.title)}</h1>
<p style="font-size: 1.25rem; opacity: 0.9; max-width: 600px; margin: 0 auto 32px;">${escHtml(b.subtitle)}</p>
<a href="${escHtml(b.ctaUrl)}" style="display: inline-block; background: white; color: #333; padding: 14px 40px; border-radius: 50px; font-weight: 700; text-decoration: none; font-size: 1.1rem; box-shadow: 0 4px 15px rgba(0,0,0,0.2);">${escHtml(b.ctaText)}</a>
</section>`,
},
features: {
label: 'Features (3-up)',
icon: 'fas fa-th',
defaults: () => ({
heading: 'Why Choose Us',
icon1: '🌾', title1: 'Fresh Produce', body1: 'Direct from local farmers to your doorstep.',
icon2: '🤝', title2: 'Community First', body2: 'Supporting local cooperatives and farmers.',
icon3: '🚚', title3: 'Fast Delivery', body3: 'Reliable logistics with real-time tracking.',
bg: '#f8f9fa',
}),
fields: [
{ key: 'heading', label: 'Section heading', type: 'text' },
{ key: 'bg', label: 'Background', type: 'color' },
{ key: 'icon1', label: 'Icon 1 (emoji)', type: 'text' },
{ key: 'title1', label: 'Title 1', type: 'text' },
{ key: 'body1', label: 'Body 1', type: 'textarea' },
{ key: 'icon2', label: 'Icon 2 (emoji)', type: 'text' },
{ key: 'title2', label: 'Title 2', type: 'text' },
{ key: 'body2', label: 'Body 2', type: 'textarea' },
{ key: 'icon3', label: 'Icon 3 (emoji)', type: 'text' },
{ key: 'title3', label: 'Title 3', type: 'text' },
{ key: 'body3', label: 'Body 3', type: 'textarea' },
],
render: (b) => `<section style="padding: 60px 20px; background: ${b.bg};">
<div style="max-width: 900px; margin: 0 auto; text-align: center;">
<h2 style="font-size: 2rem; font-weight: 800; margin-bottom: 40px; color: #1a1a2e;">${escHtml(b.heading)}</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 24px;">
${[1,2,3].map(i => `<div style="background: white; padding: 32px; border-radius: 16px; box-shadow: 0 4px 20px rgba(0,0,0,0.08);">
<div style="font-size: 2.5rem; margin-bottom: 16px;">${escHtml(b['icon'+i])}</div>
<h3 style="font-weight: 700; margin-bottom: 8px;">${escHtml(b['title'+i])}</h3>
<p style="color: #6c757d;">${escHtml(b['body'+i])}</p>
</div>`).join('')}
</div>
</div>
</section>`,
},
cta: {
label: 'Call to Action',
icon: 'fas fa-bullhorn',
defaults: () => ({
heading: 'Ready to Get Started?',
subheading: 'Join thousands of users already trading on our platform.',
ctaText: 'Login / Sign Up',
ctaUrl: '/login',
bgFrom: '#0d6efd',
bgTo: '#00d2ff',
textColor: '#ffffff',
}),
fields: [
{ key: 'heading', label: 'Heading', type: 'text' },
{ key: 'subheading', label: 'Subheading', type: 'textarea' },
{ key: 'ctaText', label: 'Button text', type: 'text' },
{ key: 'ctaUrl', label: 'Button link', type: 'text' },
{ key: 'bgFrom', label: 'BG gradient (from)', type: 'color' },
{ key: 'bgTo', label: 'BG gradient (to)', type: 'color' },
{ key: 'textColor', label: 'Text color', type: 'color' },
],
render: (b) => `<section style="background: linear-gradient(135deg, ${b.bgFrom} 0%, ${b.bgTo} 100%); color: ${b.textColor}; padding: 60px 20px; text-align: center;">
<h2 style="font-size: 2rem; font-weight: 800; margin-bottom: 12px;">${escHtml(b.heading)}</h2>
<p style="font-size: 1.1rem; opacity: 0.9; margin-bottom: 24px;">${escHtml(b.subheading)}</p>
<a href="${escHtml(b.ctaUrl)}" style="display: inline-block; background: white; color: #0d6efd; padding: 14px 40px; border-radius: 50px; font-weight: 700; text-decoration: none; box-shadow: 0 4px 15px rgba(0,0,0,0.15);">${escHtml(b.ctaText)}</a>
</section>`,
},
text: {
label: 'Text Block',
icon: 'fas fa-paragraph',
defaults: () => ({
heading: 'Section Heading',
body: 'Add your paragraph text here. This block is great for descriptions, mission statements, or any prose content.',
align: 'left',
bg: '#ffffff',
color: '#1a1a2e',
}),
fields: [
{ key: 'heading', label: 'Heading (optional)', type: 'text' },
{ key: 'body', label: 'Body', type: 'textarea' },
{ key: 'align', label: 'Alignment (left/center/right)', type: 'text' },
{ key: 'bg', label: 'Background', type: 'color' },
{ key: 'color', label: 'Text color', type: 'color' },
],
render: (b) => `<section style="padding: 48px 20px; background: ${b.bg}; color: ${b.color}; text-align: ${escHtml(b.align)};">
<div style="max-width: 800px; margin: 0 auto;">
${b.heading ? `<h2 style="font-size: 1.75rem; font-weight: 800; margin-bottom: 16px;">${escHtml(b.heading)}</h2>` : ''}
<p style="font-size: 1.05rem; line-height: 1.7; white-space: pre-wrap;">${escHtml(b.body)}</p>
</div>
</section>`,
},
image: {
label: 'Image',
icon: 'fas fa-image',
defaults: () => ({
url: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin',
alt: 'Image',
maxWidth: 800,
align: 'center',
bg: '#ffffff',
}),
fields: [
{ key: 'url', label: 'Image URL', type: 'text' },
{ key: 'alt', label: 'Alt text', type: 'text' },
{ key: 'maxWidth', label: 'Max width (px)', type: 'number' },
{ key: 'align', label: 'Alignment (left/center/right)', type: 'text' },
{ key: 'bg', label: 'Background', type: 'color' },
],
render: (b) => `<section style="padding: 32px 20px; background: ${b.bg}; text-align: ${escHtml(b.align)};">
<img src="${escHtml(b.url)}" alt="${escHtml(b.alt)}" style="max-width: 100%; width: ${Number(b.maxWidth) || 800}px; height: auto; border-radius: 12px;">
</section>`,
},
button: {
label: 'Button',
icon: 'fas fa-hand-pointer',
defaults: () => ({
text: 'Click Me',
url: '/login',
bg: '#0d6efd',
color: '#ffffff',
align: 'center',
sectionBg: '#ffffff',
}),
fields: [
{ key: 'text', label: 'Button text', type: 'text' },
{ key: 'url', label: 'Link URL', type: 'text' },
{ key: 'bg', label: 'Button background', type: 'color' },
{ key: 'color', label: 'Button text color', type: 'color' },
{ key: 'align', label: 'Alignment (left/center/right)', type: 'text' },
{ key: 'sectionBg', label: 'Section background', type: 'color' },
],
render: (b) => `<section style="padding: 24px 20px; background: ${b.sectionBg}; text-align: ${escHtml(b.align)};">
<a href="${escHtml(b.url)}" style="display: inline-block; background: ${b.bg}; color: ${b.color}; padding: 14px 36px; border-radius: 50px; font-weight: 700; text-decoration: none; box-shadow: 0 4px 12px rgba(0,0,0,0.12);">${escHtml(b.text)}</a>
</section>`,
},
spacer: {
label: 'Spacer',
icon: 'fas fa-arrows-alt-v',
defaults: () => ({ height: 40, bg: '#ffffff' }),
fields: [
{ key: 'height', label: 'Height (px)', type: 'number' },
{ key: 'bg', label: 'Background', type: 'color' },
],
render: (b) => `<div style="height: ${Number(b.height) || 40}px; background: ${b.bg};"></div>`,
},
html: {
label: 'Raw HTML',
icon: 'fas fa-code',
defaults: () => ({ html: '<div style="padding: 32px; text-align: center;">Custom HTML</div>' }),
fields: [
{ key: 'html', label: 'HTML', type: 'textarea' },
],
render: (b) => b.html || '',
},
};
const blockTypeList = computed(() => Object.entries(blockTypes).map(([id, t]) => ({ id, label: t.label, icon: t.icon })));
const newBlock = (type) => ({
_id: `b_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
type,
...blockTypes[type].defaults(),
});
const renderBlock = (b) => {
try { return blockTypes[b.type].render(b); } catch (e) { return ''; }
};
// Drag handlers
let dragSourceIndex = null;
let dragNewType = null;
const onPaletteDragStart = (e, typeId) => {
dragNewType = typeId;
dragSourceIndex = null;
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', `palette:${typeId}`);
};
const onBlockDragStart = (e, index) => {
dragSourceIndex = index;
dragNewType = null;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', `block:${index}`);
};
const onBlockDragOver = (e, index) => {
e.dataTransfer.dropEffect = dragNewType ? 'copy' : 'move';
};
const onCanvasDrop = (e, targetIndex) => {
e.preventDefault();
if (dragNewType) {
const b = newBlock(dragNewType);
blocks.value.splice(targetIndex, 0, b);
selectedBlockId.value = b._id;
} else if (dragSourceIndex !== null && dragSourceIndex !== targetIndex) {
const [moved] = blocks.value.splice(dragSourceIndex, 1);
const insertAt = dragSourceIndex < targetIndex ? targetIndex - 1 : targetIndex;
blocks.value.splice(insertAt, 0, moved);
}
dragNewType = null;
dragSourceIndex = null;
};
const removeBlock = (i) => {
const removed = blocks.value.splice(i, 1)[0];
if (selectedBlockId.value === removed?._id) selectedBlockId.value = null;
};
const duplicateBlock = (i) => {
const orig = blocks.value[i];
const copy = { ...orig, _id: `b_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` };
blocks.value.splice(i + 1, 0, copy);
selectedBlockId.value = copy._id;
};
// ── Round-trip between blocks and html_content ────────────────────────
const BLOCKS_MARKER_RE = /^<!--BLOCKS:([A-Za-z0-9+/=]+)-->\n?/;
const compileBlocksToHtml = () => {
const html = blocks.value.map(renderBlock).join('\n');
const payload = blocks.value.map(({ _id, ...rest }) => rest);
const marker = `<!--BLOCKS:${btoa(unescape(encodeURIComponent(JSON.stringify(payload))))}-->\n`;
return marker + html;
};
const parseBlocksFromHtml = (html) => {
if (!html) return [];
const m = html.match(BLOCKS_MARKER_RE);
if (!m) return [];
try {
const json = decodeURIComponent(escape(atob(m[1])));
const arr = JSON.parse(json);
return arr.map(b => ({
...b,
_id: `b_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
}));
} catch (e) { return []; }
};
const switchTab = (tab) => {
// Going INTO visual: try to parse blocks from current HTML.
if (tab === 'visual' && blocks.value.length === 0) {
blocks.value = parseBlocksFromHtml(editorForm.value.html_content);
}
// Leaving visual: compile blocks back to HTML so other tabs see latest.
if (editorTab.value === 'visual' && tab !== 'visual' && blocks.value.length > 0) {
editorForm.value.html_content = compileBlocksToHtml();
}
editorTab.value = tab;
};
const activePage = computed(() => pages.value.find(p => p.is_active));
const fetchPages = async () => {
isLoading.value = true;
try {
const res = await axios.post('/admin/landing-pages/list');
if (res.data.success) {
pages.value = res.data.data;
}
} catch (err) {
toast('Failed to load landing pages', 'danger', 'fas fa-exclamation-triangle');
} finally {
isLoading.value = false;
}
};
const openCreateModal = () => {
editingPage.value = null;
editorForm.value = { title: '', html_content: '', description: '', hashkey: null };
blocks.value = [newBlock('hero'), newBlock('features'), newBlock('cta')];
selectedBlockId.value = blocks.value[0]?._id ?? null;
editorTab.value = 'visual';
showEditorModal.value = true;
};
const editPage = (page) => {
editingPage.value = page;
editorForm.value = {
title: page.title,
html_content: page.html_content,
description: page.description || '',
hashkey: page.hashkey,
};
const parsed = parseBlocksFromHtml(page.html_content);
blocks.value = parsed;
selectedBlockId.value = parsed[0]?._id ?? null;
// If we successfully parsed blocks, default to Visual; otherwise legacy/raw → HTML tab.
editorTab.value = parsed.length > 0 ? 'visual' : 'code';
showEditorModal.value = true;
};
const closeEditor = () => {
showEditorModal.value = false;
editingPage.value = null;
blocks.value = [];
selectedBlockId.value = null;
};
const savePage = async () => {
// Ensure html_content reflects the visual builder if user is currently on that tab.
if (editorTab.value === 'visual' && blocks.value.length > 0) {
editorForm.value.html_content = compileBlocksToHtml();
}
if (!editorForm.value.title || !editorForm.value.html_content) return;
isLoading.value = true;
try {
const res = await axios.post('/admin/landing-pages/store', editorForm.value);
if (res.data.success) {
toast(editingPage.value ? 'Landing page updated!' : 'Landing page created!', 'success', 'fas fa-check-circle');
closeEditor();
await fetchPages();
}
} catch (err) {
toast('Failed to save landing page', 'danger', 'fas fa-exclamation-triangle');
} finally {
isLoading.value = false;
}
};
const setActive = async (page) => {
isLoading.value = true;
try {
const res = await axios.post('/admin/landing-pages/set-active', { hashkey: page.hashkey });
if (res.data.success) {
toast(`"${page.title}" is now the active landing page`, 'success', 'fas fa-check-circle');
await fetchPages();
}
} catch (err) {
toast('Failed to activate landing page', 'danger', 'fas fa-exclamation-triangle');
} finally {
isLoading.value = false;
}
};
const deactivateAll = async () => {
isLoading.value = true;
try {
const res = await axios.post('/admin/landing-pages/deactivate-all');
if (res.data.success) {
toast('All landing pages deactivated. Default homepage will show.', 'warning', 'fas fa-pause-circle');
await fetchPages();
}
} catch (err) {
toast('Failed to deactivate', 'danger', 'fas fa-exclamation-triangle');
} finally {
isLoading.value = false;
}
};
const confirmDelete = (page) => {
deletingPage.value = page;
showDeleteConfirm.value = true;
};
const performDelete = async () => {
if (!deletingPage.value) return;
isLoading.value = true;
try {
const res = await axios.post('/admin/landing-pages/delete', { hashkey: deletingPage.value.hashkey });
if (res.data.success) {
toast('Landing page deleted', 'success', 'fas fa-trash-alt');
await fetchPages();
}
} catch (err) {
toast('Failed to delete landing page', 'danger', 'fas fa-exclamation-triangle');
} finally {
isLoading.value = false;
deletingPage.value = null;
}
};
const openPreviewModal = (page) => {
previewPage.value = page;
showPreviewModal.value = true;
};
const sanitizePreview = (html) => {
// Return a truncated version for card thumbnails
if (!html) return '<div class="text-center text-muted p-4"><i class="fas fa-file-code fa-2x"></i></div>';
return html;
};
const formatDate = (dateStr) => {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const insertTemplate = (type) => {
const templates = {
hero: `
<section style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 80px 20px; text-align: center;">
<h1 style="font-size: 3rem; font-weight: 900; margin-bottom: 16px;">Welcome to Our Platform</h1>
<p style="font-size: 1.25rem; opacity: 0.9; max-width: 600px; margin: 0 auto 32px;">
Discover fresh produce, connect with local farmers, and support your community.
</p>
<a href="/login" style="display: inline-block; background: white; color: #667eea; padding: 14px 40px; border-radius: 50px; font-weight: 700; text-decoration: none; font-size: 1.1rem; box-shadow: 0 4px 15px rgba(0,0,0,0.2);">
Get Started
</a>
</section>`,
features: `
<section style="padding: 60px 20px; background: #f8f9fa;">
<div style="max-width: 900px; margin: 0 auto; text-align: center;">
<h2 style="font-size: 2rem; font-weight: 800; margin-bottom: 40px; color: #1a1a2e;">Why Choose Us</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 24px;">
<div style="background: white; padding: 32px; border-radius: 16px; box-shadow: 0 4px 20px rgba(0,0,0,0.08);">
<div style="font-size: 2.5rem; margin-bottom: 16px;">🌾</div>
<h3 style="font-weight: 700; margin-bottom: 8px;">Fresh Produce</h3>
<p style="color: #6c757d;">Direct from local farmers to your doorstep.</p>
</div>
<div style="background: white; padding: 32px; border-radius: 16px; box-shadow: 0 4px 20px rgba(0,0,0,0.08);">
<div style="font-size: 2.5rem; margin-bottom: 16px;">🤝</div>
<h3 style="font-weight: 700; margin-bottom: 8px;">Community First</h3>
<p style="color: #6c757d;">Supporting local cooperatives and farmers.</p>
</div>
<div style="background: white; padding: 32px; border-radius: 16px; box-shadow: 0 4px 20px rgba(0,0,0,0.08);">
<div style="font-size: 2.5rem; margin-bottom: 16px;">🚚</div>
<h3 style="font-weight: 700; margin-bottom: 8px;">Fast Delivery</h3>
<p style="color: #6c757d;">Reliable logistics with real-time tracking.</p>
</div>
</div>
</div>
</section>`,
cta: `
<section style="background: linear-gradient(135deg, #0d6efd 0%, #00d2ff 100%); color: white; padding: 60px 20px; text-align: center;">
<h2 style="font-size: 2rem; font-weight: 800; margin-bottom: 12px;">Ready to Get Started?</h2>
<p style="font-size: 1.1rem; opacity: 0.9; margin-bottom: 24px;">Join thousands of users already trading on our platform.</p>
<a href="/login" style="display: inline-block; background: white; color: #0d6efd; padding: 14px 40px; border-radius: 50px; font-weight: 700; text-decoration: none; box-shadow: 0 4px 15px rgba(0,0,0,0.15);">
Login / Sign Up
</a>
</section>`,
};
editorForm.value.html_content += templates[type] || '';
};
// Toast helpers
let toastId = 0;
const toast = (msg, type = 'success', icon = 'fas fa-check-circle') => {
const id = ++toastId;
toasts.value.push({ id, msg, type, icon });
setTimeout(() => removeToast(id), 4000);
};
const removeToast = (id) => {
toasts.value = toasts.value.filter(t => t.id !== id);
};
const goBack = () => {
window.history.back();
};
onMounted(() => {
fetchPages();
});
</script>
<style scoped>
/* ── Header ── */
.header-landing {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 40%, #0f3460 100%);
}
.header-mesh {
background-image:
radial-gradient(circle at 20% 80%, rgba(255, 165, 0, 0.08) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(255, 87, 34, 0.08) 0%, transparent 50%);
}
/* ── Gradient utilities ── */
.text-gradient-warm {
background: linear-gradient(135deg, #ff9800 0%, #ff5722 50%, #e91e63 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.btn-gradient-warm {
background: linear-gradient(135deg, #ff9800 0%, #ff5722 100%);
border: none;
color: white;
transition: all 0.3s ease;
}
.btn-gradient-warm:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(255, 87, 34, 0.35);
color: white;
}
.bg-gradient-subtle-warm {
background: linear-gradient(135deg, #fff3e0 0%, #fce4ec 100%);
}
/* ── Pulse ring ── */
.pulse-ring-warm {
width: 70px;
height: 70px;
background: rgba(255, 152, 0, 0.15);
border-radius: 50%;
animation: pulseWarm 2s infinite;
}
@keyframes pulseWarm {
0% { transform: translate(-50%, -50%) scale(1); opacity: 0.5; }
100% { transform: translate(-50%, -50%) scale(1.5); opacity: 0; }
}
.pulse-dot-success {
width: 12px;
height: 12px;
background: #22c55e;
border-radius: 50%;
animation: pulseDot 2s infinite;
}
@keyframes pulseDot {
0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.6); }
50% { box-shadow: 0 0 0 8px rgba(34, 197, 94, 0); }
}
/* ── Card styles ── */
.page-card {
border: 2px solid transparent;
}
.page-card:hover {
transform: translateY(-4px);
box-shadow: 0 16px 40px rgba(0,0,0,0.12) !important;
}
.ring-active {
border-color: #22c55e;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15);
}
.card-preview {
height: 180px;
overflow: hidden;
position: relative;
}
.preview-frame {
transform: scale(0.35);
transform-origin: top left;
width: 286%;
height: 520px;
pointer-events: none;
overflow: hidden;
}
.preview-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.5);
opacity: 0;
transition: opacity 0.3s ease;
}
.card-preview:hover .preview-overlay {
opacity: 1;
}
.btn-white {
background: white;
color: #333;
border: none;
}
.btn-white:hover {
background: #f1f1f1;
}
/* ── Soft buttons ── */
.btn-soft-success {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
border: none;
}
.btn-soft-success:hover {
background: rgba(34, 197, 94, 0.2);
color: #16a34a;
}
.btn-soft-warning {
background: rgba(245, 158, 11, 0.1);
color: #d97706;
border: none;
}
.btn-soft-warning:hover {
background: rgba(245, 158, 11, 0.2);
color: #d97706;
}
.btn-soft-danger {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
border: none;
}
.btn-soft-danger:hover {
background: rgba(239, 68, 68, 0.2);
color: #dc2626;
}
/* ── Editor Modal ── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
z-index: 99990;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.editor-modal {
background: white;
border-radius: 24px;
width: 100%;
max-width: 1000px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 25px 60px rgba(0,0,0,0.3);
}
.editor-body {
overflow-y: auto;
flex: 1;
}
.code-textarea {
width: 100%;
border: none;
border-radius: 0 0 16px 16px;
resize: vertical;
background: #1e1e2e;
color: #a6e3a1;
padding: 16px;
font-size: 13px;
line-height: 1.6;
min-height: 400px;
outline: none;
tab-size: 2;
}
.code-textarea::placeholder {
color: #585b70;
}
.code-textarea:focus {
outline: none;
box-shadow: inset 0 0 0 2px rgba(166, 227, 161, 0.3);
}
.preview-frame-lg {
min-height: 400px;
overflow-y: auto;
max-height: 500px;
}
/* ── Preview Modal ── */
.preview-modal {
background: white;
border-radius: 24px;
width: 100%;
max-width: 1200px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 25px 60px rgba(0,0,0,0.3);
}
.preview-frame-full {
overflow-y: auto;
flex: 1;
min-height: 500px;
max-height: calc(90vh - 60px);
}
/* ── Browser bar dots ── */
.dot-red, .dot-yellow, .dot-green {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.dot-red { background: #ff5f57; }
.dot-yellow { background: #ffbd2e; }
.dot-green { background: #28c940; }
/* ── Generic ── */
.h-48px { height: 48px; }
.ls-tight { letter-spacing: -0.025em; }
.ls-wide { letter-spacing: 0.1em; }
.fw-black { font-weight: 900; }
.transition-all {
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.animate-fade-in {
animation: fadeIn 0.8s ease-out;
}
.animate-slide-up {
animation: slideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.animate-slide-in {
animation: slideIn 0.4s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
@media (max-width: 768px) {
.editor-modal, .preview-modal {
max-width: 100%;
border-radius: 16px;
}
}
/* ── Visual Builder ── */
.visual-builder .palette,
.visual-builder .inspector {
min-height: 420px;
max-height: 60vh;
overflow-y: auto;
}
.palette-item {
cursor: grab;
user-select: none;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.palette-item:hover {
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(0,0,0,0.06);
}
.palette-item:active { cursor: grabbing; }
.canvas {
min-height: 420px;
max-height: 60vh;
overflow-y: auto;
background-image: linear-gradient(45deg, #f8f9fa 25%, transparent 25%, transparent 75%, #f8f9fa 75%, #f8f9fa),
linear-gradient(45deg, #f8f9fa 25%, transparent 25%, transparent 75%, #f8f9fa 75%, #f8f9fa);
background-size: 16px 16px;
background-position: 0 0, 8px 8px;
}
.block-card {
background: white;
cursor: grab;
transition: box-shadow 0.15s ease, border-color 0.15s ease;
}
.block-card:hover { box-shadow: 0 4px 14px rgba(0,0,0,0.08); }
.block-card.block-active {
border-color: #0d6efd !important;
box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.15);
}
.block-thumb {
pointer-events: none;
max-height: 140px;
overflow: hidden;
position: relative;
}
.block-thumb::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent 60%, rgba(255,255,255,0.85) 100%);
}
.inspector .form-control-color {
width: 100%;
height: 38px;
padding: 4px;
}
</style>