1265 lines
46 KiB
Vue
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 => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
}[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>
|