Implement WYSIWYG newsletter editor with live preview, import, Supabase auto-save and Brevo export
This commit is contained in:
1 parent
8763c5bace
commit
538f2ccae3
10 files changed
+2246
-56
No files matched your search
@@ -0,0 +1,75 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { campaignName, subject, htmlContent, scheduledAt } = await req.json();
|
||||||
|
|
||||||
|
if (!campaignName || !subject || !htmlContent) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Chýbajú povinné polia (campaignName, subject, htmlContent).' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.BREVO_API_KEY;
|
||||||
|
const senderName = process.env.BREVO_SENDER_NAME || 'Jandl';
|
||||||
|
const senderEmail = process.env.BREVO_SENDER_EMAIL || 'tomas.rafay@jandl.sk';
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'BREVO_API_KEY nie je nakonfigurovaný na serveri.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volanie Brevo API v3
|
||||||
|
const payload: any = {
|
||||||
|
name: campaignName,
|
||||||
|
subject: subject,
|
||||||
|
sender: {
|
||||||
|
name: senderName,
|
||||||
|
email: senderEmail
|
||||||
|
},
|
||||||
|
type: 'classic',
|
||||||
|
htmlContent: htmlContent
|
||||||
|
};
|
||||||
|
|
||||||
|
if (scheduledAt) {
|
||||||
|
payload.scheduledAt = scheduledAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const brevoRes = await fetch('https://api.brevo.com/v3/emailCampaigns', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'accept': 'application/json',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'api-key': apiKey
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await brevoRes.json();
|
||||||
|
|
||||||
|
if (!brevoRes.ok) {
|
||||||
|
console.error('Brevo API error:', data);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: data.message || 'Chyba pri vytváraní kampane v Brevo', details: data },
|
||||||
|
{ status: brevoRes.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Úspech - vrátime campaignId a URL na správu v Brevo
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
campaignId: data.id,
|
||||||
|
brevoUrl: `https://app.brevo.com/camp/detail/email/${data.id}`,
|
||||||
|
message: 'Kampaň bola úspešne vytvorená v Brevo!'
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Export to Brevo error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || 'Interná chyba servera' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import juice from 'juice';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { html } = await req.json();
|
||||||
|
|
||||||
|
if (!html) {
|
||||||
|
return NextResponse.json({ error: 'HTML obsah je povinný.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inlined = juice(html, {
|
||||||
|
preserveMediaQueries: true,
|
||||||
|
removeStyleTags: false,
|
||||||
|
applyWidthAttributes: true,
|
||||||
|
applyHeightAttributes: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ html: inlined });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Inlining error:', err);
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+432
-56
@@ -1,69 +1,445 @@
|
|||||||
import Image from "next/image";
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { NewsletterCampaign, NewsletterModule, ProductItem, BadgeTemplate } from '@/types/newsletter';
|
||||||
|
import { DEFAULT_CAMPAIGN, SUPABASE_ASSETS_BASE } from '@/lib/defaultCampaign';
|
||||||
|
import { renderEmailHtml } from '@/lib/emailRenderer';
|
||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { TopBar } from '@/components/editor/TopBar';
|
||||||
|
import { ConfigPanel } from '@/components/editor/ConfigPanel';
|
||||||
|
import { ProductImportModal } from '@/components/editor/ProductImportModal';
|
||||||
|
import { BadgeManagerModal } from '@/components/editor/BadgeManagerModal';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { saveAs } from 'file-saver';
|
||||||
|
import { ChevronUp, ChevronDown, Copy, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const [campaign, setCampaign] = useState<NewsletterCampaign>(DEFAULT_CAMPAIGN);
|
||||||
|
const [selectedModuleId, setSelectedModuleId] = useState<string | null>('mod-products-fantasticky');
|
||||||
|
const [previewMode, setPreviewMode] = useState<'desktop' | 'mobile'>('desktop');
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isExportingBrevo, setIsExportingBrevo] = useState(false);
|
||||||
|
const [brevoUrl, setBrevoUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Modály
|
||||||
|
const [isImportOpen, setIsImportOpen] = useState(false);
|
||||||
|
const [isBadgeModalOpen, setIsBadgeModalOpen] = useState(false);
|
||||||
|
const [activeProductIdForBadge, setActiveProductIdForBadge] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Načítanie existujúceho draftu z databázy alebo inicializácia
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadInitial() {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('templates')
|
||||||
|
.select('*')
|
||||||
|
.order('updated_at', { ascending: false })
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
const dbItem = data[0];
|
||||||
|
setCampaign({
|
||||||
|
id: dbItem.id,
|
||||||
|
title: dbItem.title,
|
||||||
|
preheader: dbItem.data.preheader || DEFAULT_CAMPAIGN.preheader,
|
||||||
|
status: dbItem.status,
|
||||||
|
brevoCampaignId: dbItem.brevo_campaign_id,
|
||||||
|
modules: dbItem.data.modules || DEFAULT_CAMPAIGN.modules
|
||||||
|
});
|
||||||
|
if (dbItem.brevo_campaign_id) {
|
||||||
|
setBrevoUrl(`https://app.brevo.com/camp/detail/email/${dbItem.brevo_campaign_id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading template from Supabase:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadInitial();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-save s debounce (1 sekunda)
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
if (!campaign.id) {
|
||||||
|
// Vytvorenie nového záznamu
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('templates')
|
||||||
|
.insert([
|
||||||
|
{
|
||||||
|
title: campaign.title,
|
||||||
|
status: campaign.status,
|
||||||
|
data: {
|
||||||
|
preheader: campaign.preheader,
|
||||||
|
modules: campaign.modules
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
.select();
|
||||||
|
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
setCampaign((prev) => ({ ...prev, id: data[0].id }));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Update existujúceho
|
||||||
|
await supabase
|
||||||
|
.from('templates')
|
||||||
|
.update({
|
||||||
|
title: campaign.title,
|
||||||
|
data: {
|
||||||
|
preheader: campaign.preheader,
|
||||||
|
modules: campaign.modules
|
||||||
|
},
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.eq('id', campaign.id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Auto-save error:', err);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [campaign.title, campaign.preheader, campaign.modules]);
|
||||||
|
|
||||||
|
// Vybraný modul
|
||||||
|
const selectedModule = campaign.modules.find((m) => m.id === selectedModuleId) || null;
|
||||||
|
|
||||||
|
// Aktualizácia modulu
|
||||||
|
const handleUpdateModule = (updated: NewsletterModule) => {
|
||||||
|
setCampaign((prev) => ({
|
||||||
|
...prev,
|
||||||
|
modules: prev.modules.map((m) => (m.id === updated.id ? updated : m))
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Posun modulu hore/dole
|
||||||
|
const moveModule = (index: number, direction: 'up' | 'down') => {
|
||||||
|
const newModules = [...campaign.modules];
|
||||||
|
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
||||||
|
if (targetIndex < 0 || targetIndex >= newModules.length) return;
|
||||||
|
|
||||||
|
const temp = newModules[index];
|
||||||
|
newModules[index] = newModules[targetIndex];
|
||||||
|
newModules[targetIndex] = temp;
|
||||||
|
|
||||||
|
setCampaign((prev) => ({ ...prev, modules: newModules }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Duplikácia modulu
|
||||||
|
const duplicateModule = (index: number) => {
|
||||||
|
const original = campaign.modules[index];
|
||||||
|
const clone: NewsletterModule = {
|
||||||
|
...original,
|
||||||
|
id: `mod-${Date.now()}`,
|
||||||
|
title: `${original.title} (kópia)`,
|
||||||
|
data: JSON.parse(JSON.stringify(original.data))
|
||||||
|
};
|
||||||
|
const newModules = [...campaign.modules];
|
||||||
|
newModules.splice(index + 1, 0, clone);
|
||||||
|
setCampaign((prev) => ({ ...prev, modules: newModules }));
|
||||||
|
setSelectedModuleId(clone.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Zmazanie modulu
|
||||||
|
const deleteModule = (index: number) => {
|
||||||
|
if (campaign.modules.length <= 1) return;
|
||||||
|
const newModules = campaign.modules.filter((_, i) => i !== index);
|
||||||
|
setCampaign((prev) => ({ ...prev, modules: newModules }));
|
||||||
|
setSelectedModuleId(newModules[0]?.id || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pridanie nového modulu
|
||||||
|
const handleAddModule = (type: any) => {
|
||||||
|
let newMod: NewsletterModule;
|
||||||
|
if (type === 'banner') {
|
||||||
|
newMod = {
|
||||||
|
id: `mod-banner-${Date.now()}`,
|
||||||
|
type: 'banner',
|
||||||
|
title: 'Nový Banner',
|
||||||
|
data: {
|
||||||
|
imageUrl: `${SUPABASE_ASSETS_BASE}aktualne_letaky.jpg`,
|
||||||
|
linkUrl: 'https://www.coop.sk',
|
||||||
|
altText: 'Aktuálne letáky',
|
||||||
|
title: 'Prelistujte si naše najnovšie letáky online',
|
||||||
|
description: 'Všetky akcie a zľavy na jednom mieste priamo vo vašom mobile.',
|
||||||
|
buttonText: 'Pozrieť letáky',
|
||||||
|
buttonUrl: 'https://www.coop.sk',
|
||||||
|
buttonBgColor: '#e2001a'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
newMod = {
|
||||||
|
id: `mod-products-${Date.now()}`,
|
||||||
|
type: 'products_grid',
|
||||||
|
title: 'Nová sekcia produktov',
|
||||||
|
data: {
|
||||||
|
sectionTitle: 'AKČNÁ PONUKA',
|
||||||
|
globalValidity: '2. 5. – 15. 5. 2026',
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
id: `p-${Date.now()}-1`,
|
||||||
|
title: 'Nový produkt 1',
|
||||||
|
imageUrl: `${SUPABASE_ASSETS_BASE}produkt_5.png`,
|
||||||
|
priceMain: '2',
|
||||||
|
priceSub: '49',
|
||||||
|
unitPrice: '1kg = 9,90 €',
|
||||||
|
discountText: '15%',
|
||||||
|
badgeText: 'zľava',
|
||||||
|
cardBgColor: '#feeaea',
|
||||||
|
stores: { potraviny: true, supermarket: true, tempo: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setCampaign((prev) => ({
|
||||||
|
...prev,
|
||||||
|
modules: [...prev.modules, newMod]
|
||||||
|
}));
|
||||||
|
setSelectedModuleId(newMod.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hromadný import produktov do aktuálne vybraného modulu
|
||||||
|
const handleImportProducts = (imported: ProductItem[]) => {
|
||||||
|
if (!selectedModuleId) return;
|
||||||
|
const target = campaign.modules.find((m) => m.id === selectedModuleId);
|
||||||
|
if (!target || target.type !== 'products_grid') {
|
||||||
|
alert('Pre import prosím najskôr označte produktovú sekciu.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = {
|
||||||
|
...target,
|
||||||
|
data: {
|
||||||
|
...target.data,
|
||||||
|
products: [...(target.data.products || []), ...imported]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handleUpdateModule(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export do ZIP (JSZip)
|
||||||
|
const handleExportZip = async () => {
|
||||||
|
const rawHtml = renderEmailHtml(campaign);
|
||||||
|
|
||||||
|
// Inlinovanie CSS pred zabalením
|
||||||
|
let finalHtml = rawHtml;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/inline-css', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ html: rawHtml })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.html) finalHtml = data.html;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Inliner fallback to raw html:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('index.html', finalHtml);
|
||||||
|
|
||||||
|
// Pridáme README návod
|
||||||
|
zip.file(
|
||||||
|
'README.txt',
|
||||||
|
'COOP Jednota Newsletter Export\n\nSúbor index.html obsahuje finálny skompletizovaný a inlinovaný HTML kód pripravený na odoslanie cez ľubovoľný e-mailový systém (Brevo, Mailchimp, Outlook).'
|
||||||
|
);
|
||||||
|
|
||||||
|
const blob = await zip.generateAsync({ type: 'blob' });
|
||||||
|
saveAs(blob, `coop-newsletter-${new Date().toISOString().split('T')[0]}.zip`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export do Brevo (API v3)
|
||||||
|
const handleExportBrevo = async () => {
|
||||||
|
setIsExportingBrevo(true);
|
||||||
|
try {
|
||||||
|
const rawHtml = renderEmailHtml(campaign);
|
||||||
|
|
||||||
|
// Inlinovanie
|
||||||
|
let finalHtml = rawHtml;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/inline-css', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ html: rawHtml })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.html) finalHtml = data.html;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
const response = await fetch('/api/brevo/export', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
campaignName: `${campaign.title} - ${new Date().toLocaleDateString('sk-SK')}`,
|
||||||
|
subject: campaign.title,
|
||||||
|
htmlContent: finalHtml
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const resData = await response.json();
|
||||||
|
|
||||||
|
if (resData.success && resData.campaignId) {
|
||||||
|
setBrevoUrl(resData.brevoUrl);
|
||||||
|
// Uloženie stavu do Supabase
|
||||||
|
if (campaign.id) {
|
||||||
|
await supabase
|
||||||
|
.from('templates')
|
||||||
|
.update({
|
||||||
|
status: 'exported',
|
||||||
|
brevo_campaign_id: resData.campaignId
|
||||||
|
})
|
||||||
|
.eq('id', campaign.id);
|
||||||
|
}
|
||||||
|
alert('Kampaň bola úspešne odoslaná do Brevo!');
|
||||||
|
} else {
|
||||||
|
alert(`Chyba pri exporte do Brevo: ${resData.error || 'Neznáma chyba'}`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(`Chyba: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setIsExportingBrevo(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div className="flex flex-col h-screen overflow-hidden bg-slate-900 font-sans">
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
{/* 1. Horná lišta */}
|
||||||
<Image
|
<TopBar
|
||||||
className="dark:invert h-5 w-[100px]"
|
campaign={campaign}
|
||||||
src="/next.svg"
|
previewMode={previewMode}
|
||||||
alt="Next.js logo"
|
setPreviewMode={setPreviewMode}
|
||||||
width={100}
|
isSaving={isSaving}
|
||||||
height={20}
|
onSave={() => {}}
|
||||||
priority
|
onExportZip={handleExportZip}
|
||||||
|
onExportBrevo={handleExportBrevo}
|
||||||
|
isExportingBrevo={isExportingBrevo}
|
||||||
|
brevoCampaignUrl={brevoUrl}
|
||||||
|
onAddModule={handleAddModule}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
{/* 2. Hlavný pracovný priestor (2 zóny: Stredné plátno a Pravý panel) */}
|
||||||
To get started, edit the{" "}
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
|
{/* Stredné plátno - Živý WYSIWYG náhľad */}
|
||||||
page.tsx
|
<main className="flex-1 overflow-y-auto p-8 flex items-start justify-center bg-slate-900">
|
||||||
</code>{" "}
|
<div
|
||||||
file.
|
className={`transition-all duration-300 relative shadow-2xl rounded-2xl overflow-hidden ${
|
||||||
</h1>
|
previewMode === 'mobile' ? 'w-[390px]' : 'w-[640px]'
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
}`}
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
>
|
||||||
Templates
|
{/* Zoznam modulov na plátne */}
|
||||||
</a>{" "}
|
<div className="bg-[#001a33] min-h-screen p-4">
|
||||||
or the{" "}
|
{campaign.modules.map((mod, idx) => {
|
||||||
<a
|
const isSelected = mod.id === selectedModuleId;
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
return (
|
||||||
|
<div
|
||||||
|
key={mod.id}
|
||||||
|
onClick={() => setSelectedModuleId(mod.id)}
|
||||||
|
className={`relative group cursor-pointer transition-all duration-200 mb-3 rounded-xl border-2 ${
|
||||||
|
isSelected
|
||||||
|
? 'border-red-500 ring-4 ring-red-500/20'
|
||||||
|
: 'border-transparent hover:border-slate-600'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Learning
|
{/* Lišta s nástrojmi pre označený modul */}
|
||||||
</a>{" "}
|
{isSelected && (
|
||||||
center.
|
<div className="absolute -top-3 right-4 z-20 flex items-center gap-1 bg-gray-900 text-white px-2 py-1 rounded-lg text-xs shadow-lg border border-slate-700">
|
||||||
</p>
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
moveModule(idx, 'up');
|
||||||
|
}}
|
||||||
|
disabled={idx === 0}
|
||||||
|
className="hover:text-red-400 disabled:opacity-30 p-0.5"
|
||||||
|
title="Posunúť hore"
|
||||||
|
>
|
||||||
|
<ChevronUp className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
moveModule(idx, 'down');
|
||||||
|
}}
|
||||||
|
disabled={idx === campaign.modules.length - 1}
|
||||||
|
className="hover:text-red-400 disabled:opacity-30 p-0.5"
|
||||||
|
title="Posunúť dole"
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
duplicateModule(idx);
|
||||||
|
}}
|
||||||
|
className="hover:text-blue-400 p-0.5 ml-1"
|
||||||
|
title="Duplikovať"
|
||||||
|
>
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteModule(idx);
|
||||||
|
}}
|
||||||
|
className="hover:text-red-400 p-0.5 ml-1"
|
||||||
|
title="Zmazať"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
)}
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
{/* Vykreslenie modulu cez iframe alebo dangerouslySetInnerHTML */}
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<div
|
||||||
target="_blank"
|
dangerouslySetInnerHTML={{
|
||||||
rel="noopener noreferrer"
|
__html: renderEmailHtml({
|
||||||
>
|
...campaign,
|
||||||
<Image
|
modules: [mod]
|
||||||
className="dark:invert h-[14px] w-4"
|
})
|
||||||
src="/vercel.svg"
|
}}
|
||||||
alt="Vercel logomark"
|
className="pointer-events-none"
|
||||||
width={16}
|
|
||||||
height={14}
|
|
||||||
/>
|
/>
|
||||||
Deploy Now
|
</div>
|
||||||
</a>
|
);
|
||||||
<a
|
})}
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
</div>
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Pravý konfiguračný panel */}
|
||||||
|
<aside className="w-96 flex-shrink-0 h-full bg-white shadow-xl">
|
||||||
|
<ConfigPanel
|
||||||
|
module={selectedModule}
|
||||||
|
onUpdateModule={handleUpdateModule}
|
||||||
|
onOpenBadgeManager={(pId) => {
|
||||||
|
setActiveProductIdForBadge(pId);
|
||||||
|
setIsBadgeModalOpen(true);
|
||||||
|
}}
|
||||||
|
onOpenImport={() => setIsImportOpen(true)}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modály */}
|
||||||
|
<ProductImportModal
|
||||||
|
isOpen={isImportOpen}
|
||||||
|
onClose={() => setIsImportOpen(false)}
|
||||||
|
onImportProducts={handleImportProducts}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BadgeManagerModal
|
||||||
|
isOpen={isBadgeModalOpen}
|
||||||
|
onClose={() => setIsBadgeModalOpen(false)}
|
||||||
|
onSelectBadge={(badge) => {
|
||||||
|
// Priradiť cenovku k produktu
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { BadgeTemplate } from '@/types/newsletter';
|
||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { Tag, Plus, Check, Trash2, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface BadgeManagerModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelectBadge: (badge: BadgeTemplate) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BadgeManagerModal: React.FC<BadgeManagerModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSelectBadge
|
||||||
|
}) => {
|
||||||
|
const [badges, setBadges] = useState<BadgeTemplate[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newBadgeName, setNewBadgeName] = useState('');
|
||||||
|
const [newBadgeColor, setNewBadgeColor] = useState('#e2001a');
|
||||||
|
const [newBadgeText, setNewBadgeText] = useState('SUPER CENA');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadBadges();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const loadBadges = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.from('badges').select('*').order('created_at', { ascending: false });
|
||||||
|
if (data && !error) {
|
||||||
|
setBadges(
|
||||||
|
data.map((d: any) => ({
|
||||||
|
id: d.id,
|
||||||
|
name: d.name,
|
||||||
|
svgRaw: d.svg_raw,
|
||||||
|
layersConfig: d.layers_config || []
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading badges:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBadge = async () => {
|
||||||
|
if (!newBadgeName) return;
|
||||||
|
|
||||||
|
const svg = `<svg viewBox="0 0 160 80" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="80" rx="8" fill="${newBadgeColor}"/></svg>`;
|
||||||
|
const layers = [
|
||||||
|
{ id: 'l1', type: 'text', text: newBadgeText, x: 80, y: 35, fontSize: 18, fontWeight: '900', fill: '#ffffff', align: 'center' },
|
||||||
|
{ id: 'l2', type: 'text', text: '{zlava}', x: 80, y: 65, fontSize: 24, fontWeight: '900', fill: '#ffd100', align: 'center' }
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.from('badges').insert([
|
||||||
|
{
|
||||||
|
name: newBadgeName,
|
||||||
|
svg_raw: svg,
|
||||||
|
layers_config: layers
|
||||||
|
}
|
||||||
|
]).select();
|
||||||
|
|
||||||
|
if (data && !error) {
|
||||||
|
loadBadges();
|
||||||
|
setCreating(false);
|
||||||
|
setNewBadgeName('');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating badge:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-xs flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[85vh] overflow-hidden flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tag className="w-5 h-5 text-red-600" />
|
||||||
|
<h2 className="text-lg font-bold text-gray-900">Správca grafických cenoviek</h2>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded-lg">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 overflow-y-auto space-y-4">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Vyberte cenovku pre produkt alebo navrhnite vlastnú.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setCreating(!creating)}
|
||||||
|
className="text-xs font-bold text-red-600 hover:text-red-700 flex items-center gap-1 border border-red-200 px-3 py-1.5 rounded-lg hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
Nová cenovka
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Form */}
|
||||||
|
{creating && (
|
||||||
|
<div className="p-4 bg-gray-50 rounded-xl border border-gray-200 space-y-3">
|
||||||
|
<h3 className="text-xs font-bold text-gray-800 uppercase tracking-wider">Vytvoriť cenovku</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 mb-1">Názov cenovky</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newBadgeName}
|
||||||
|
onChange={(e) => setNewBadgeName(e.target.value)}
|
||||||
|
placeholder="Napr. Modrá novinka"
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 mb-1">Text cenovky</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newBadgeText}
|
||||||
|
onChange={(e) => setNewBadgeText(e.target.value)}
|
||||||
|
placeholder="Napr. EXTRA CENA"
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 mb-1">Farba pozadia</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={newBadgeColor}
|
||||||
|
onChange={(e) => setNewBadgeColor(e.target.value)}
|
||||||
|
className="w-8 h-8 rounded-md cursor-pointer border-0"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-mono">{newBadgeColor}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setCreating(false)}
|
||||||
|
className="px-3 py-1.5 text-xs font-semibold text-gray-600"
|
||||||
|
>
|
||||||
|
Zrušiť
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={createBadge}
|
||||||
|
className="px-4 py-1.5 text-xs font-bold bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Uložiť do databázy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Badges List */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{badges.map((b) => (
|
||||||
|
<div
|
||||||
|
key={b.id}
|
||||||
|
className="border border-gray-200 rounded-xl p-4 flex flex-col items-center justify-between hover:border-red-400 hover:shadow-md transition-all group bg-white"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-bold text-gray-700 mb-2">{b.name}</span>
|
||||||
|
<div
|
||||||
|
className="w-36 h-20 flex items-center justify-center relative my-2 overflow-hidden rounded-lg shadow-xs"
|
||||||
|
dangerouslySetInnerHTML={{ __html: b.svgRaw || '' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onSelectBadge(b);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="mt-3 w-full py-1.5 text-xs font-bold text-white bg-gray-900 group-hover:bg-red-600 rounded-lg transition-colors flex items-center justify-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
Použiť na produkt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { NewsletterModule, ProductItem } from '@/types/newsletter';
|
||||||
|
import { Plus, Trash2, Image as ImageIcon, Tag, Store } from 'lucide-react';
|
||||||
|
import { SUPABASE_ASSETS_BASE } from '@/lib/defaultCampaign';
|
||||||
|
|
||||||
|
interface ConfigPanelProps {
|
||||||
|
module: NewsletterModule | null;
|
||||||
|
onUpdateModule: (updated: NewsletterModule) => void;
|
||||||
|
onOpenBadgeManager?: (productId: string) => void;
|
||||||
|
onOpenImport?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
||||||
|
module,
|
||||||
|
onUpdateModule,
|
||||||
|
onOpenBadgeManager,
|
||||||
|
onOpenImport
|
||||||
|
}) => {
|
||||||
|
if (!module) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center p-6 text-center text-gray-400">
|
||||||
|
<p className="text-sm">Vyberte modul v náhľade e-mailu pre úpravu jeho vlastností.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = (newData: any) => {
|
||||||
|
onUpdateModule({
|
||||||
|
...module,
|
||||||
|
data: { ...module.data, ...newData }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto p-5 space-y-6 bg-white border-l border-gray-200">
|
||||||
|
<div className="border-b border-gray-100 pb-3">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-red-600 bg-red-50 px-2 py-0.5 rounded">
|
||||||
|
{module.type}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-base font-bold text-gray-900 mt-1">{module.title}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TOP ANNOUNCEMENT */}
|
||||||
|
{module.type === 'announcement' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-700 mb-1">Text badge (napr. TIP)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={module.data.tipBadge || ''}
|
||||||
|
onChange={(e) => updateData({ tipBadge: e.target.value })}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-700 mb-1">Hlavný text oznamu</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={module.data.text || ''}
|
||||||
|
onChange={(e) => updateData({ text: e.target.value })}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-700 mb-1">Text odkazu</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={module.data.linkText || ''}
|
||||||
|
onChange={(e) => updateData({ linkText: e.target.value })}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-700 mb-1">Cieľová URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={module.data.linkUrl || ''}
|
||||||
|
onChange={(e) => updateData({ linkUrl: e.target.value })}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* HEADER */}
|
||||||
|
{module.type === 'header' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-700 mb-1">URL Loga</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={module.data.logoUrl || ''}
|
||||||
|
onChange={(e) => updateData({ logoUrl: e.target.value })}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-700 mb-1">Odkaz loga</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={module.data.logoLink || ''}
|
||||||
|
onChange={(e) => updateData({ logoLink: e.target.value })}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* BANNER */}
|
||||||
|
{module.type === 'banner' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-700 mb-1">URL Obrázka bannera</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={module.data.imageUrl || ''}
|
||||||
|
onChange={(e) => updateData({ imageUrl: e.target.value })}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-700 mb-1">Odkaz bannera</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={module.data.linkUrl || ''}
|
||||||
|
onChange={(e) => updateData({ linkUrl: e.target.value })}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-700 mb-1">Nadpis pod bannerom</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={module.data.title || ''}
|
||||||
|
onChange={(e) => updateData({ title: e.target.value })}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-700 mb-1">Popis</label>
|
||||||
|
<textarea
|
||||||
|
value={module.data.description || ''}
|
||||||
|
onChange={(e) => updateData({ description: e.target.value })}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2 h-20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-700 mb-1">Text tlačidla</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={module.data.buttonText || ''}
|
||||||
|
onChange={(e) => updateData({ buttonText: e.target.value })}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-700 mb-1">Farba tlačidla</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={module.data.buttonBgColor || '#e2001a'}
|
||||||
|
onChange={(e) => updateData({ buttonBgColor: e.target.value })}
|
||||||
|
className="w-full h-9 rounded-lg cursor-pointer border border-gray-300 p-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PRODUCTS GRID */}
|
||||||
|
{module.type === 'products_grid' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-xs font-bold text-gray-800 uppercase tracking-wider">
|
||||||
|
Nastavenia sekcie
|
||||||
|
</h3>
|
||||||
|
{onOpenImport && (
|
||||||
|
<button
|
||||||
|
onClick={onOpenImport}
|
||||||
|
className="text-xs font-bold text-red-600 hover:text-red-700 bg-red-50 hover:bg-red-100 px-2.5 py-1 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Hromadný import
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-700 mb-1">Názov sekcie</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={module.data.sectionTitle || ''}
|
||||||
|
onChange={(e) => updateData({ sectionTitle: e.target.value })}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2 font-bold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-700 mb-1">Globálna platnosť akcie</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={module.data.globalValidity || ''}
|
||||||
|
onChange={(e) => updateData({ globalValidity: e.target.value })}
|
||||||
|
placeholder="napr. 2. 5. – 15. 5. 2026"
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zoznam produktov */}
|
||||||
|
<div className="space-y-4 pt-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold text-gray-700">
|
||||||
|
Produkty ({module.data.products?.length || 0})
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newP: ProductItem = {
|
||||||
|
id: `p-${Date.now()}`,
|
||||||
|
title: 'Nový produkt',
|
||||||
|
imageUrl: `${SUPABASE_ASSETS_BASE}produkt_1.png`,
|
||||||
|
priceMain: '1',
|
||||||
|
priceSub: '99',
|
||||||
|
unitPrice: '1kg = 4,99 €',
|
||||||
|
discountText: '20%',
|
||||||
|
badgeText: 'zľava',
|
||||||
|
cardBgColor: '#feeaea',
|
||||||
|
stores: { potraviny: true, supermarket: true, tempo: true }
|
||||||
|
};
|
||||||
|
updateData({ products: [...(module.data.products || []), newP] });
|
||||||
|
}}
|
||||||
|
className="text-xs font-bold text-white bg-gray-900 hover:bg-black px-2.5 py-1 rounded-lg flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" /> Pridať produkt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(module.data.products || []).map((p: ProductItem, pIdx: number) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="p-3 bg-gray-50 rounded-xl border border-gray-200 space-y-3 relative group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold text-gray-900 truncate max-w-[180px]">
|
||||||
|
{p.title || `Produkt ${pIdx + 1}`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const updated = (module.data.products || []).filter((_: any, i: number) => i !== pIdx);
|
||||||
|
updateData({ products: updated });
|
||||||
|
}}
|
||||||
|
className="text-gray-400 hover:text-red-600 p-1"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={p.title}
|
||||||
|
onChange={(e) => {
|
||||||
|
const products = [...module.data.products];
|
||||||
|
products[pIdx].title = e.target.value;
|
||||||
|
updateData({ products });
|
||||||
|
}}
|
||||||
|
placeholder="Názov produktu"
|
||||||
|
className="w-full text-xs border border-gray-300 rounded p-1.5 bg-white font-medium"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-gray-500">Cena (€)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={p.priceMain}
|
||||||
|
onChange={(e) => {
|
||||||
|
const products = [...module.data.products];
|
||||||
|
products[pIdx].priceMain = e.target.value;
|
||||||
|
updateData({ products });
|
||||||
|
}}
|
||||||
|
className="w-full text-xs border border-gray-300 rounded p-1 bg-white font-bold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-gray-500">Centy</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={p.priceSub}
|
||||||
|
onChange={(e) => {
|
||||||
|
const products = [...module.data.products];
|
||||||
|
products[pIdx].priceSub = e.target.value;
|
||||||
|
updateData({ products });
|
||||||
|
}}
|
||||||
|
className="w-full text-xs border border-gray-300 rounded p-1 bg-white font-bold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-gray-500">Zľava %</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={p.discountText}
|
||||||
|
onChange={(e) => {
|
||||||
|
const products = [...module.data.products];
|
||||||
|
products[pIdx].discountText = e.target.value;
|
||||||
|
updateData({ products });
|
||||||
|
}}
|
||||||
|
className="w-full text-xs border border-gray-300 rounded p-1 bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-gray-500">Merná cena</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={p.unitPrice}
|
||||||
|
onChange={(e) => {
|
||||||
|
const products = [...module.data.products];
|
||||||
|
products[pIdx].unitPrice = e.target.value;
|
||||||
|
updateData({ products });
|
||||||
|
}}
|
||||||
|
className="w-full text-xs border border-gray-300 rounded p-1 bg-white text-[10px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Predajne */}
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-gray-500 block mb-1">Dostupnosť (Piktogramy)</label>
|
||||||
|
<div className="flex gap-2 text-[10px]">
|
||||||
|
<label className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={p.stores?.potraviny}
|
||||||
|
onChange={(e) => {
|
||||||
|
const products = [...module.data.products];
|
||||||
|
products[pIdx].stores.potraviny = e.target.checked;
|
||||||
|
updateData({ products });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>Potraviny</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={p.stores?.supermarket}
|
||||||
|
onChange={(e) => {
|
||||||
|
const products = [...module.data.products];
|
||||||
|
products[pIdx].stores.supermarket = e.target.checked;
|
||||||
|
updateData({ products });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>Supermarket</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={p.stores?.tempo}
|
||||||
|
onChange={(e) => {
|
||||||
|
const products = [...module.data.products];
|
||||||
|
products[pIdx].stores.tempo = e.target.checked;
|
||||||
|
updateData({ products });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>Tempo</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vlajočka SR */}
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`flag-${p.id}`}
|
||||||
|
checked={p.showSlovakFlag || false}
|
||||||
|
onChange={(e) => {
|
||||||
|
const products = [...module.data.products];
|
||||||
|
products[pIdx].showSlovakFlag = e.target.checked;
|
||||||
|
updateData({ products });
|
||||||
|
}}
|
||||||
|
className="rounded text-red-600"
|
||||||
|
/>
|
||||||
|
<label htmlFor={`flag-${p.id}`} className="text-xs text-gray-700 cursor-pointer">
|
||||||
|
Zobraziť slovenskú vlajku / srdce
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FOOTER */}
|
||||||
|
{module.type === 'footer' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-700 mb-1">Právny text (Disclaimer)</label>
|
||||||
|
<textarea
|
||||||
|
value={module.data.disclaimer || ''}
|
||||||
|
onChange={(e) => updateData({ disclaimer: e.target.value })}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2 h-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-700 mb-1">Text odhlásenia</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={module.data.unsubscribeText || ''}
|
||||||
|
onChange={(e) => updateData({ unsubscribeText: e.target.value })}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import Papa from 'papaparse';
|
||||||
|
import { XMLParser } from 'fast-xml-parser';
|
||||||
|
import { ProductItem } from '@/types/newsletter';
|
||||||
|
import { Upload, Check, AlertCircle, FileText, X } from 'lucide-react';
|
||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
|
||||||
|
interface ImportModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onImportProducts: (products: ProductItem[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProductImportModal: React.FC<ImportModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onImportProducts
|
||||||
|
}) => {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [fileType, setFileType] = useState<'csv' | 'json' | 'xml'>('csv');
|
||||||
|
const [parsedRows, setParsedRows] = useState<any[]>([]);
|
||||||
|
const [columns, setColumns] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Mapovanie stĺpcov
|
||||||
|
const [mapping, setMapping] = useState<{
|
||||||
|
title: string;
|
||||||
|
priceMain: string;
|
||||||
|
priceSub: string;
|
||||||
|
unitPrice: string;
|
||||||
|
discountText: string;
|
||||||
|
imageUrl: string;
|
||||||
|
validity: string;
|
||||||
|
}>({
|
||||||
|
title: '',
|
||||||
|
priceMain: '',
|
||||||
|
priceSub: '',
|
||||||
|
unitPrice: '',
|
||||||
|
discountText: '',
|
||||||
|
imageUrl: '',
|
||||||
|
validity: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const [presetName, setPresetName] = useState('');
|
||||||
|
const [savedPresets, setSavedPresets] = useState<any[]>([]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const uploaded = e.target.files?.[0];
|
||||||
|
if (!uploaded) return;
|
||||||
|
setFile(uploaded);
|
||||||
|
|
||||||
|
const ext = uploaded.name.split('.').pop()?.toLowerCase();
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
if (ext === 'csv') {
|
||||||
|
setFileType('csv');
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const text = event.target?.result as string;
|
||||||
|
Papa.parse(text, {
|
||||||
|
header: true,
|
||||||
|
skipEmptyLines: true,
|
||||||
|
complete: (results) => {
|
||||||
|
if (results.data.length > 0) {
|
||||||
|
setParsedRows(results.data);
|
||||||
|
const headers = Object.keys(results.data[0] as object);
|
||||||
|
setColumns(headers);
|
||||||
|
autoGuessMapping(headers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsText(uploaded);
|
||||||
|
} else if (ext === 'json') {
|
||||||
|
setFileType('json');
|
||||||
|
reader.onload = (event) => {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(event.target?.result as string);
|
||||||
|
const arrayData = Array.isArray(json) ? json : json.products || [json];
|
||||||
|
setParsedRows(arrayData);
|
||||||
|
if (arrayData.length > 0) {
|
||||||
|
const headers = Object.keys(arrayData[0]);
|
||||||
|
setColumns(headers);
|
||||||
|
autoGuessMapping(headers);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Chyba pri parsovaní JSON súboru.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(uploaded);
|
||||||
|
} else if (ext === 'xml') {
|
||||||
|
setFileType('xml');
|
||||||
|
reader.onload = (event) => {
|
||||||
|
try {
|
||||||
|
const parser = new XMLParser({ ignoreAttributes: false });
|
||||||
|
const jsonObj = parser.parse(event.target?.result as string);
|
||||||
|
// Hľadáme pole produktov
|
||||||
|
const possibleArray =
|
||||||
|
jsonObj.products?.product || jsonObj.items?.item || Object.values(jsonObj)[0];
|
||||||
|
const arrayData = Array.isArray(possibleArray) ? possibleArray : [possibleArray];
|
||||||
|
setParsedRows(arrayData);
|
||||||
|
if (arrayData.length > 0) {
|
||||||
|
const headers = Object.keys(arrayData[0]);
|
||||||
|
setColumns(headers);
|
||||||
|
autoGuessMapping(headers);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Chyba pri parsovaní XML súboru.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(uploaded);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const autoGuessMapping = (headers: string[]) => {
|
||||||
|
const newMapping = { ...mapping };
|
||||||
|
headers.forEach((h) => {
|
||||||
|
const lower = h.toLowerCase();
|
||||||
|
if (lower.includes('nazov') || lower.includes('title') || lower.includes('name')) newMapping.title = h;
|
||||||
|
if (lower.includes('hlavna') || lower.includes('euro') || lower.includes('price_main')) newMapping.priceMain = h;
|
||||||
|
if (lower.includes('cent') || lower.includes('sub') || lower.includes('price_sub')) newMapping.priceSub = h;
|
||||||
|
if (lower.includes('merna') || lower.includes('unit') || lower.includes('kg')) newMapping.unitPrice = h;
|
||||||
|
if (lower.includes('zlava') || lower.includes('discount')) newMapping.discountText = h;
|
||||||
|
if (lower.includes('obrazok') || lower.includes('image') || lower.includes('foto')) newMapping.imageUrl = h;
|
||||||
|
if (lower.includes('platnost') || lower.includes('valid')) newMapping.validity = h;
|
||||||
|
});
|
||||||
|
setMapping(newMapping);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyImport = () => {
|
||||||
|
if (!mapping.title) {
|
||||||
|
alert('Prosím vyberte stĺpec pre Názov produktu.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const importedProducts: ProductItem[] = parsedRows.map((row, idx) => {
|
||||||
|
let pMain = String(row[mapping.priceMain] || '0').trim();
|
||||||
|
let pSub = String(row[mapping.priceSub] || '00').trim();
|
||||||
|
|
||||||
|
// Ak je cena v jednom stĺpci napr. "1.99" alebo "1,99"
|
||||||
|
if (!mapping.priceSub && mapping.priceMain) {
|
||||||
|
const fullPrice = String(row[mapping.priceMain] || '0').replace(',', '.');
|
||||||
|
const parts = fullPrice.split('.');
|
||||||
|
pMain = parts[0] || '0';
|
||||||
|
pSub = parts[1] || '00';
|
||||||
|
if (pSub.length === 1) pSub += '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `imported-${Date.now()}-${idx}`,
|
||||||
|
title: String(row[mapping.title] || 'Nový produkt'),
|
||||||
|
imageUrl: row[mapping.imageUrl] || 'https://dqexxfspkylqttrtxsww.supabase.co/storage/v1/object/public/newsletter-assets/produkt_1.png',
|
||||||
|
priceMain: pMain,
|
||||||
|
priceSub: pSub,
|
||||||
|
unitPrice: row[mapping.unitPrice] || '',
|
||||||
|
discountText: row[mapping.discountText] ? `${row[mapping.discountText]}%` : '',
|
||||||
|
badgeText: 'zľava',
|
||||||
|
cardBgColor: '#feeaea',
|
||||||
|
stores: { potraviny: true, supermarket: true, tempo: true },
|
||||||
|
validityOverride: row[mapping.validity] || undefined
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onImportProducts(importedProducts);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-xs flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Upload className="w-5 h-5 text-red-600" />
|
||||||
|
<h2 className="text-lg font-bold text-gray-900">Hromadný import produktov</h2>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded-lg">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 overflow-y-auto space-y-6">
|
||||||
|
{/* File Upload Box */}
|
||||||
|
<div className="border-2 border-dashed border-gray-300 hover:border-red-500 rounded-xl p-6 text-center cursor-pointer transition-colors bg-gray-50/50">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.json,.xml"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="hidden"
|
||||||
|
id="file-upload-input"
|
||||||
|
/>
|
||||||
|
<label htmlFor="file-upload-input" className="cursor-pointer">
|
||||||
|
<FileText className="w-10 h-10 text-gray-400 mx-auto mb-2" />
|
||||||
|
<p className="font-semibold text-gray-700">
|
||||||
|
{file ? file.name : 'Kliknite sem alebo pretiahnite CSV, JSON alebo XML'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Podporované formáty: .csv, .json, .xml</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mapping Table */}
|
||||||
|
{columns.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-bold text-gray-800 uppercase tracking-wider">
|
||||||
|
Vizuálny Data Mapper (Párovanie stĺpcov)
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 bg-gray-50 p-4 rounded-xl border border-gray-200">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 mb-1">Názov produktu *</label>
|
||||||
|
<select
|
||||||
|
value={mapping.title}
|
||||||
|
onChange={(e) => setMapping({ ...mapping, title: e.target.value })}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2 bg-white"
|
||||||
|
>
|
||||||
|
<option value="">-- Vyberte stĺpec --</option>
|
||||||
|
{columns.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 mb-1">Hlavná cena (Eurá)</label>
|
||||||
|
<select
|
||||||
|
value={mapping.priceMain}
|
||||||
|
onChange={(e) => setMapping({ ...mapping, priceMain: e.target.value })}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2 bg-white"
|
||||||
|
>
|
||||||
|
<option value="">-- Vyberte stĺpec --</option>
|
||||||
|
{columns.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 mb-1">Centy (podružná cena)</label>
|
||||||
|
<select
|
||||||
|
value={mapping.priceSub}
|
||||||
|
onChange={(e) => setMapping({ ...mapping, priceSub: e.target.value })}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2 bg-white"
|
||||||
|
>
|
||||||
|
<option value="">-- Vyberte stĺpec (voliteľné) --</option>
|
||||||
|
{columns.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 mb-1">Merná cena (napr. 1kg = 4,91 €)</label>
|
||||||
|
<select
|
||||||
|
value={mapping.unitPrice}
|
||||||
|
onChange={(e) => setMapping({ ...mapping, unitPrice: e.target.value })}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2 bg-white"
|
||||||
|
>
|
||||||
|
<option value="">-- Vyberte stĺpec --</option>
|
||||||
|
{columns.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 mb-1">Zľava %</label>
|
||||||
|
<select
|
||||||
|
value={mapping.discountText}
|
||||||
|
onChange={(e) => setMapping({ ...mapping, discountText: e.target.value })}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2 bg-white"
|
||||||
|
>
|
||||||
|
<option value="">-- Vyberte stĺpec --</option>
|
||||||
|
{columns.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 mb-1">URL obrázka</label>
|
||||||
|
<select
|
||||||
|
value={mapping.imageUrl}
|
||||||
|
onChange={(e) => setMapping({ ...mapping, imageUrl: e.target.value })}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded-lg p-2 bg-white"
|
||||||
|
>
|
||||||
|
<option value="">-- Vyberte stĺpec --</option>
|
||||||
|
{columns.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Živý náhľad prvých 2 riadkov */}
|
||||||
|
{parsedRows.length > 0 && (
|
||||||
|
<div className="border border-gray-200 rounded-xl overflow-hidden">
|
||||||
|
<div className="bg-gray-100 px-4 py-2 text-xs font-bold text-gray-700">
|
||||||
|
Živý náhľad prvých položiek ({parsedRows.length} nájdených záznamov)
|
||||||
|
</div>
|
||||||
|
<div className="p-3 text-xs space-y-2 bg-white">
|
||||||
|
{parsedRows.slice(0, 2).map((row, idx) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between border-b border-gray-100 pb-2">
|
||||||
|
<span className="font-semibold text-gray-800">{row[mapping.title] || 'Bez názvu'}</span>
|
||||||
|
<span className="text-red-600 font-bold">
|
||||||
|
{row[mapping.priceMain] || '0'},{row[mapping.priceSub] || '00'} €
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-semibold text-gray-600 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
Zrušiť
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={applyImport}
|
||||||
|
disabled={parsedRows.length === 0}
|
||||||
|
className="px-5 py-2 text-sm font-bold bg-red-600 text-white rounded-xl hover:bg-red-700 disabled:opacity-50 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
Importovať do kampane
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
Send,
|
||||||
|
ExternalLink,
|
||||||
|
Smartphone,
|
||||||
|
Monitor,
|
||||||
|
Save,
|
||||||
|
CheckCircle2,
|
||||||
|
Plus
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { NewsletterCampaign } from '@/types/newsletter';
|
||||||
|
|
||||||
|
interface TopBarProps {
|
||||||
|
campaign: NewsletterCampaign;
|
||||||
|
previewMode: 'desktop' | 'mobile';
|
||||||
|
setPreviewMode: (m: 'desktop' | 'mobile') => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
onSave: () => void;
|
||||||
|
onExportZip: () => void;
|
||||||
|
onExportBrevo: () => void;
|
||||||
|
isExportingBrevo: boolean;
|
||||||
|
brevoCampaignUrl?: string | null;
|
||||||
|
onAddModule: (type: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TopBar: React.FC<TopBarProps> = ({
|
||||||
|
campaign,
|
||||||
|
previewMode,
|
||||||
|
setPreviewMode,
|
||||||
|
isSaving,
|
||||||
|
onSave,
|
||||||
|
onExportZip,
|
||||||
|
onExportBrevo,
|
||||||
|
isExportingBrevo,
|
||||||
|
brevoCampaignUrl,
|
||||||
|
onAddModule
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<header className="h-16 bg-white border-b border-gray-200 px-6 flex items-center justify-between sticky top-0 z-30 shadow-xs">
|
||||||
|
{/* Ľavá časť: Logo a názov */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-red-600 flex items-center justify-center text-white font-black text-sm">
|
||||||
|
CJ
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-sm font-bold text-gray-900 leading-tight">
|
||||||
|
{campaign.title || 'COOP Newsletter'}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] text-gray-500">
|
||||||
|
{isSaving ? (
|
||||||
|
<span className="text-amber-600 animate-pulse">Ukladám do Supabase...</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-emerald-600 flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="w-3 h-3" /> Uložené v Supabase
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pridať modul dropdown */}
|
||||||
|
<div className="hidden md:flex items-center gap-1.5 ml-4 pl-4 border-l border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={() => onAddModule('banner')}
|
||||||
|
className="text-xs font-semibold text-gray-700 bg-gray-100 hover:bg-gray-200 px-2.5 py-1.5 rounded-lg flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5 text-gray-500" /> Banner
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onAddModule('products_grid')}
|
||||||
|
className="text-xs font-semibold text-gray-700 bg-gray-100 hover:bg-gray-200 px-2.5 py-1.5 rounded-lg flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5 text-gray-500" /> Produkty
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stredná časť: Prepínač Desktop / Mobil */}
|
||||||
|
<div className="flex items-center bg-gray-100 p-1 rounded-xl border border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={() => setPreviewMode('desktop')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
previewMode === 'desktop'
|
||||||
|
? 'bg-white text-gray-900 shadow-xs'
|
||||||
|
: 'text-gray-500 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Monitor className="w-3.5 h-3.5" /> Desktop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPreviewMode('mobile')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
previewMode === 'mobile'
|
||||||
|
? 'bg-white text-gray-900 shadow-xs'
|
||||||
|
: 'text-gray-500 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Smartphone className="w-3.5 h-3.5" /> Mobil
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pravá časť: Export do ZIP a Brevo */}
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<button
|
||||||
|
onClick={onExportZip}
|
||||||
|
className="flex items-center gap-1.5 text-xs font-bold text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 px-3.5 py-2 rounded-xl transition-all shadow-xs"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5 text-gray-600" /> Export ZIP
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{brevoCampaignUrl ? (
|
||||||
|
<a
|
||||||
|
href={brevoCampaignUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-1.5 text-xs font-bold text-emerald-700 bg-emerald-50 border border-emerald-300 hover:bg-emerald-100 px-3.5 py-2 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" /> Otvoriť v Brevo
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onExportBrevo}
|
||||||
|
disabled={isExportingBrevo}
|
||||||
|
className="flex items-center gap-1.5 text-xs font-bold text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 px-3.5 py-2 rounded-xl transition-all shadow-xs"
|
||||||
|
>
|
||||||
|
<Send className="w-3.5 h-3.5" />
|
||||||
|
{isExportingBrevo ? 'Odosielam...' : 'Odoslať do Brevo'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { NewsletterCampaign } from '@/types/newsletter';
|
||||||
|
|
||||||
|
export const SUPABASE_ASSETS_BASE =
|
||||||
|
'https://dqexxfspkylqttrtxsww.supabase.co/storage/v1/object/public/newsletter-assets/';
|
||||||
|
|
||||||
|
export const DEFAULT_CAMPAIGN: NewsletterCampaign = {
|
||||||
|
id: '',
|
||||||
|
title: 'COOP Jednota Newsletter',
|
||||||
|
preheader: 'Vyhrajte 3 autá KIA Sportage. Mliečne novinky, fantasticky nízke ceny, grilovanie aj vernostné zľavy s COOP Klubom.',
|
||||||
|
status: 'draft',
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
id: 'mod-top-announcement',
|
||||||
|
type: 'announcement',
|
||||||
|
title: 'Top oznamovacia lišta',
|
||||||
|
data: {
|
||||||
|
tipBadge: 'TIP',
|
||||||
|
text: 'Vyhrajte auto s COOP Jednotou',
|
||||||
|
linkText: 'Chcem vyhrať ›',
|
||||||
|
linkUrl: 'https://www.coop.sk',
|
||||||
|
bgColor: '#e2001a'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mod-header',
|
||||||
|
type: 'header',
|
||||||
|
title: 'Hlavička s logom',
|
||||||
|
data: {
|
||||||
|
logoUrl: `${SUPABASE_ASSETS_BASE}logo_coop.png`,
|
||||||
|
logoLink: 'https://www.coop.sk',
|
||||||
|
navLinks: [
|
||||||
|
{ label: 'Letáky', url: 'https://www.coop.sk/sk/letaky', variant: 'outline' },
|
||||||
|
{ label: 'COOP Klub', url: 'https://www.coopklub.sk', variant: 'filled' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mod-banner-1',
|
||||||
|
type: 'banner',
|
||||||
|
title: 'Hlavný Banner - Súťaž KIA',
|
||||||
|
data: {
|
||||||
|
imageUrl: `${SUPABASE_ASSETS_BASE}nws_podklady-velka_noc.jpg`,
|
||||||
|
linkUrl: 'https://www.coop.sk',
|
||||||
|
altText: 'Súťaž o autá',
|
||||||
|
title: 'Hrajte o 3 autá a ďalšie skvelé ceny!',
|
||||||
|
description: 'Nakúpte aspoň za 25 eur a hrajte o 3 autá KIA Sportage a tisíce okamžitých výhier.',
|
||||||
|
buttonText: 'Chcem vyhrať',
|
||||||
|
buttonUrl: 'https://www.coop.sk',
|
||||||
|
buttonBgColor: '#e2001a'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mod-banner-2',
|
||||||
|
type: 'banner',
|
||||||
|
title: 'Banner - Mliečne výrobky',
|
||||||
|
data: {
|
||||||
|
imageUrl: `${SUPABASE_ASSETS_BASE}nws_podklady-nove_obaly.jpg`,
|
||||||
|
linkUrl: 'https://www.coop.sk',
|
||||||
|
altText: 'Mliečne výrobky',
|
||||||
|
title: 'Tradične kvalitné mliečne výrobky v novom balení',
|
||||||
|
description: '',
|
||||||
|
buttonText: 'Nechajte sa ohúriť',
|
||||||
|
buttonUrl: 'https://www.coop.sk',
|
||||||
|
buttonBgColor: '#003399'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mod-products-fantasticky',
|
||||||
|
type: 'products_grid',
|
||||||
|
title: 'Fantasticky nízke ceny',
|
||||||
|
data: {
|
||||||
|
sectionTitle: 'Fantasticky nízke ceny!',
|
||||||
|
globalValidity: '2. 5. – 15. 5. 2026',
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
title: "Hellmann's Originál majonéza 405 ml",
|
||||||
|
imageUrl: `${SUPABASE_ASSETS_BASE}produkt_1.png`,
|
||||||
|
priceMain: '1',
|
||||||
|
priceSub: '99',
|
||||||
|
unitPrice: '1kg = 4,91 €',
|
||||||
|
discountText: '23%',
|
||||||
|
badgeText: 'zľava',
|
||||||
|
cardBgColor: '#feeaea',
|
||||||
|
showSlovakFlag: false,
|
||||||
|
stores: { potraviny: true, supermarket: true, tempo: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p2',
|
||||||
|
title: 'Amálka šunka najvyššej kvality 100 g',
|
||||||
|
imageUrl: `${SUPABASE_ASSETS_BASE}produkt_2.png`,
|
||||||
|
priceMain: '0',
|
||||||
|
priceSub: '89',
|
||||||
|
unitPrice: '1kg = 8,90 €',
|
||||||
|
discountText: '36%',
|
||||||
|
badgeText: 'zľava',
|
||||||
|
cardBgColor: '#feeaea',
|
||||||
|
showSlovakFlag: true,
|
||||||
|
stores: { potraviny: true, supermarket: true, tempo: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mod-products-domace',
|
||||||
|
type: 'products_grid',
|
||||||
|
title: 'Domáce a vždy čerstvé',
|
||||||
|
data: {
|
||||||
|
sectionTitle: 'DOMÁCE A VŽDY ČERSTVÉ',
|
||||||
|
globalValidity: '2. 5. – 15. 5. 2026',
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
id: 'p3',
|
||||||
|
title: 'Zemiaky žlté prané neskoré 2 kg 1 bal.',
|
||||||
|
imageUrl: `${SUPABASE_ASSETS_BASE}produkt_3.png`,
|
||||||
|
priceMain: '1',
|
||||||
|
priceSub: '05',
|
||||||
|
unitPrice: '1kg = 0,53 €',
|
||||||
|
discountText: '34%',
|
||||||
|
badgeText: 'zľava',
|
||||||
|
cardBgColor: '#fef0c7',
|
||||||
|
showSlovakFlag: true,
|
||||||
|
stores: { potraviny: false, supermarket: true, tempo: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p4',
|
||||||
|
title: 'Cibuľa žltá neskorá praná 1 kg',
|
||||||
|
imageUrl: `${SUPABASE_ASSETS_BASE}produkt_4.png`,
|
||||||
|
priceMain: '0',
|
||||||
|
priceSub: '79',
|
||||||
|
unitPrice: '1kg = 0,79 €',
|
||||||
|
discountText: '27%',
|
||||||
|
badgeText: 'zľava',
|
||||||
|
cardBgColor: '#fef0c7',
|
||||||
|
showSlovakFlag: true,
|
||||||
|
stores: { potraviny: false, supermarket: true, tempo: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mod-footer',
|
||||||
|
type: 'footer',
|
||||||
|
title: 'Pätička',
|
||||||
|
data: {
|
||||||
|
disclaimer: 'Tento newsletter má len informatívny charakter. Ceny a ponuka tovaru sa môžu na jednotlivých predajniach líšiť. Ponuka platí do vypredania zásob.',
|
||||||
|
unsubscribeText: 'Ak si už neželáte dostávať náš newsletter, môžete sa odhlásiť tu.',
|
||||||
|
unsubscribeUrl: '{{ unsubscribe }}',
|
||||||
|
socials: {
|
||||||
|
facebook: 'https://www.facebook.com/coopjednotaslovensko',
|
||||||
|
instagram: 'https://www.instagram.com/coopjednotaslovensko',
|
||||||
|
tiktok: 'https://www.tiktok.com/@coopjednota',
|
||||||
|
youtube: 'https://www.youtube.com/c/COOPJednotaSlovensko'
|
||||||
|
},
|
||||||
|
appStoreUrl: 'https://apps.apple.com/sk/app/coop-jednota/id1527814838',
|
||||||
|
googlePlayUrl: 'https://play.google.com/store/apps/details?id=sk.coop.app',
|
||||||
|
foundationUrl: 'https://www.nadaciacoop.sk'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { NewsletterCampaign, ProductItem } from '@/types/newsletter';
|
||||||
|
import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
|
||||||
|
|
||||||
|
export function renderEmailHtml(campaign: NewsletterCampaign): string {
|
||||||
|
const renderedModules = campaign.modules.map((m) => renderModule(m, campaign)).join('\n');
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="sk" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="x-apple-disable-message-reformatting">
|
||||||
|
<meta name="color-scheme" content="light only">
|
||||||
|
<meta name="supported-color-schemes" content="light">
|
||||||
|
<title>${escapeHtml(campaign.title)}</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700;800;900&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light only; supported-color-schemes: light; }
|
||||||
|
html, body {
|
||||||
|
margin: 0 auto !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
height: 100% !important;
|
||||||
|
width: 100% !important;
|
||||||
|
font-family: 'Poppins', Helvetica, Arial, sans-serif !important;
|
||||||
|
background-color: #001a33;
|
||||||
|
color-scheme: light only;
|
||||||
|
}
|
||||||
|
* { -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; }
|
||||||
|
table, td { mso-table-lspace: 0pt !important; mso-table-rspace: 0pt !important; }
|
||||||
|
img { -ms-interpolation-mode: bicubic; border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; max-width: 100%; }
|
||||||
|
a { text-decoration: none; }
|
||||||
|
@media screen and (max-width: 600px) {
|
||||||
|
.email-container { width: 100% !important; margin: auto !important; padding: 0 10px !important; box-sizing: border-box; }
|
||||||
|
.stack-column, .stack-column-center { display: block !important; width: 100% !important; max-width: 100% !important; direction: ltr !important; padding-left: 0 !important; padding-right: 0 !important; }
|
||||||
|
.stack-column-center { text-align: center !important; }
|
||||||
|
.mobile-padding { padding-left: 15px !important; padding-right: 15px !important; }
|
||||||
|
.logo-img { width: 90px !important; }
|
||||||
|
.header-link { display: block !important; margin: 3px 0 3px auto !important; padding: 4px 10px !important; width: 75px !important; font-size: 9px !important; text-align: center !important; margin-right: 0 !important; }
|
||||||
|
.glass-product { margin-bottom: 15px !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body width="100%" bgcolor="#001a33" style="margin: 0; padding: 0 !important; mso-line-height-rule: exactly; background-color: #001a33;">
|
||||||
|
<!-- Preheader text -->
|
||||||
|
<div style="display: none; max-height: 0; max-width: 0; overflow: hidden; opacity: 0; font-size: 1px; line-height: 1px; color: #fefefe; mso-hide: all;">
|
||||||
|
${escapeHtml(campaign.preheader || '')}
|
||||||
|
</div>
|
||||||
|
<center style="width: 100%; background-color: #001a33;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#001a33" style="background-color: #001a33; background-image: linear-gradient(135deg, #005bb5 0%, #000d1a 100%);">
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" align="center" style="width:600px;"><tr><td align="center" valign="top" style="padding-top:15px; padding-bottom:30px;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding-top: 15px; padding-bottom: 30px;" class="email-container">
|
||||||
|
${renderedModules}
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</center>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||||
|
switch (m.type) {
|
||||||
|
case 'announcement':
|
||||||
|
return `
|
||||||
|
<!-- TOP OZNAMOVACIA LIŠTA -->
|
||||||
|
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 15px 0 20px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="${m.data.bgColor || '#e2001a'}" style="background-color: ${m.data.bgColor || '#e2001a'}; background: linear-gradient(to right, #ff3d4f, #e2001a); border-radius: 12px; box-shadow: 0 10px 25px rgba(226, 0, 26, 0.3); border: 1px solid rgba(255,255,255,0.2);">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 20px; text-align: center;">
|
||||||
|
<div style="display: inline-block; vertical-align: middle;">
|
||||||
|
<span style="background-color: #fefefe; color: ${m.data.bgColor || '#e2001a'}; font-size: 10px; font-weight: 800; padding: 2px 8px; border-radius: 4px; margin: 4px 5px; vertical-align: middle; display: inline-block; letter-spacing: 1px;">${escapeHtml(m.data.tipBadge || 'TIP')}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: inline-block; vertical-align: middle;">
|
||||||
|
<span style="color: #fefefe; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; vertical-align: middle; display: inline-block; padding: 5px 10px;">${escapeHtml(m.data.text || '')}</span>
|
||||||
|
</div>
|
||||||
|
${m.data.linkText ? `
|
||||||
|
<div style="display: inline-block; vertical-align: middle;">
|
||||||
|
<a href="${escapeHtml(m.data.linkUrl || '#')}" style="color: #ffde00; font-size: 11px; font-weight: 800; text-decoration: none; text-transform: uppercase; display: inline-block; padding: 5px 10px;">${escapeHtml(m.data.linkText)}</a>
|
||||||
|
</div>` : ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>`;
|
||||||
|
|
||||||
|
case 'header':
|
||||||
|
const navLinksHtml = (m.data.navLinks || [])
|
||||||
|
.map((link: any) => {
|
||||||
|
if (link.variant === 'filled') {
|
||||||
|
return `<a href="${escapeHtml(link.url)}" style="color: #fefefe; font-size: 12px; font-weight: 700; text-decoration: none; text-transform: uppercase; padding: 10px 16px; background-color: #003399; border-radius: 20px; display: inline-block; letter-spacing: 0.5px; box-shadow: 0 4px 10px rgba(0, 51, 153, 0.2);">${escapeHtml(link.label)}</a>`;
|
||||||
|
}
|
||||||
|
return `<a href="${escapeHtml(link.url)}" style="color: #e2001a; font-size: 12px; font-weight: 700; text-decoration: none; text-transform: uppercase; padding: 8px 16px; border: 2px solid #e2001a; border-radius: 20px; margin-right: 6px; display: inline-block; letter-spacing: 0.5px;">${escapeHtml(link.label)}</a>`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!-- HLAVIČKA -->
|
||||||
|
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#fefefe" style="background-color: #fefefe; border-radius: 16px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); overflow: hidden; margin-bottom: 25px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 15px 20px;" class="mobile-padding">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td width="35%" valign="middle" style="text-align: left;">
|
||||||
|
<a href="${escapeHtml(m.data.logoLink || 'https://www.coop.sk')}" target="_blank">
|
||||||
|
<img src="${escapeHtml(m.data.logoUrl)}" width="130" alt="COOP Jednota" style="display: inline-block; border: 0; border-radius: 8px;" class="logo-img">
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td width="65%" valign="middle" style="text-align: right;">
|
||||||
|
${navLinksHtml}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>`;
|
||||||
|
|
||||||
|
case 'banner':
|
||||||
|
return `
|
||||||
|
<!-- BANNER -->
|
||||||
|
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#fefefe" style="background-color: #fefefe; border-radius: 16px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); overflow: hidden; margin-bottom: 25px;">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="${escapeHtml(m.data.linkUrl || '#')}">
|
||||||
|
<img src="${escapeHtml(m.data.imageUrl)}" width="600" alt="${escapeHtml(m.data.altText || '')}" style="display: block; border: 0; width: 100%; max-width: 600px; border-top-left-radius: 16px; border-top-right-radius: 16px;">
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
${m.data.title || m.data.description || m.data.buttonText ? `
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 25px 20px; text-align: center;" class="mobile-padding">
|
||||||
|
${m.data.title ? `<h2 style="margin: 0 0 15px 0; font-size: 22px; color: #1a1a1a; font-weight: 700;">${escapeHtml(m.data.title)}</h2>` : ''}
|
||||||
|
${m.data.description ? `<p style="margin: 0 0 20px 0; font-size: 15px; line-height: 1.5; color: #555555;">${escapeHtml(m.data.description)}</p>` : ''}
|
||||||
|
${m.data.buttonText ? `
|
||||||
|
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;">
|
||||||
|
<tr>
|
||||||
|
<td style="text-align: center;">
|
||||||
|
<a href="${escapeHtml(m.data.buttonUrl || '#')}" style="background-color: ${m.data.buttonBgColor || '#e2001a'}; color: #fefefe; font-family: 'Poppins', sans-serif; font-size: 13px; font-weight: 800; text-decoration: none; padding: 12px 30px; border-radius: 25px; display: inline-block; text-transform: uppercase; letter-spacing: 0.5px; box-shadow: 0 4px 12px rgba(226, 0, 26, 0.35);">${escapeHtml(m.data.buttonText)}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>` : ''}
|
||||||
|
</td>
|
||||||
|
</tr>` : ''}
|
||||||
|
</table>`;
|
||||||
|
|
||||||
|
case 'products_grid':
|
||||||
|
const globalValidity = m.data.globalValidity || '';
|
||||||
|
const products: ProductItem[] = m.data.products || [];
|
||||||
|
const rows: ProductItem[][] = [];
|
||||||
|
for (let i = 0; i < products.length; i += 2) {
|
||||||
|
rows.push(products.slice(i, i + 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowsHtml = rows
|
||||||
|
.map(
|
||||||
|
(row) => `
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
|
||||||
|
<tr>
|
||||||
|
${row
|
||||||
|
.map(
|
||||||
|
(p, idx) => `
|
||||||
|
<td width="50%" valign="top" style="padding-${idx === 0 ? 'right: 6px' : 'left: 6px'};">
|
||||||
|
${renderProductCard(p, globalValidity)}
|
||||||
|
</td>`
|
||||||
|
)
|
||||||
|
.join('\n')}
|
||||||
|
${row.length === 1 ? '<td width="50%"></td>' : ''}
|
||||||
|
</tr>
|
||||||
|
</table>`
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!-- PRODUKTOVÁ SEKCIA -->
|
||||||
|
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 25px;">
|
||||||
|
${m.data.sectionTitle ? `
|
||||||
|
<tr>
|
||||||
|
<td style="text-align: center; padding-top: 15px; padding-bottom: 20px;">
|
||||||
|
<h2 style="margin: 0; font-size: 24px; color: #fefefe; font-weight: 900; text-transform: uppercase; text-shadow: 0px 2px 5px rgba(0,0,0,0.5);">${escapeHtml(m.data.sectionTitle)}</h2>
|
||||||
|
</td>
|
||||||
|
</tr>` : ''}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
${rowsHtml}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>`;
|
||||||
|
|
||||||
|
case 'footer':
|
||||||
|
return `
|
||||||
|
<!-- PÄTIČKA -->
|
||||||
|
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#001a33" style="background-color: #001a33; padding: 25px 15px; text-align: center; color: #a0aec0; font-size: 11px; line-height: 1.6;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<!-- Sociálne siete -->
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<a href="${escapeHtml(m.data.socials?.facebook || '#')}" style="margin: 0 6px; display: inline-block;">
|
||||||
|
<img src="${SUPABASE_ASSETS_BASE}ikona_fb.png" width="28" height="28" alt="Facebook" style="display: block;">
|
||||||
|
</a>
|
||||||
|
<a href="${escapeHtml(m.data.socials?.instagram || '#')}" style="margin: 0 6px; display: inline-block;">
|
||||||
|
<img src="${SUPABASE_ASSETS_BASE}ikona_ig.png" width="28" height="28" alt="Instagram" style="display: block;">
|
||||||
|
</a>
|
||||||
|
<a href="${escapeHtml(m.data.socials?.tiktok || '#')}" style="margin: 0 6px; display: inline-block;">
|
||||||
|
<img src="${SUPABASE_ASSETS_BASE}ikona_tt.png" width="28" height="28" alt="TikTok" style="display: block;">
|
||||||
|
</a>
|
||||||
|
<a href="${escapeHtml(m.data.socials?.youtube || '#')}" style="margin: 0 6px; display: inline-block;">
|
||||||
|
<img src="${SUPABASE_ASSETS_BASE}ikona_yt.png" width="28" height="28" alt="YouTube" style="display: block;">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Disclaimer -->
|
||||||
|
<p style="margin: 0 0 15px 0; color: #718096; font-size: 10px;">
|
||||||
|
${escapeHtml(m.data.disclaimer || '')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Unsubscribe -->
|
||||||
|
<p style="margin: 0; color: #a0aec0;">
|
||||||
|
<a href="${escapeHtml(m.data.unsubscribeUrl || '{{ unsubscribe }}')}" style="color: #cbd5e0; text-decoration: underline;">${escapeHtml(m.data.unsubscribeText || 'Odhlásiť sa')}</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>`;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProductCard(p: ProductItem, globalValidity: string): string {
|
||||||
|
const validity = p.validityOverride || globalValidity;
|
||||||
|
const bgColor = p.cardBgColor || '#feeaea';
|
||||||
|
|
||||||
|
const storesHtml = `
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right">
|
||||||
|
${p.stores?.potraviny ? `<tr><td align="right" style="line-height: 0;"><table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right"><tr><td style="border: 1px solid #fefefe; background-color: #e2001a; padding: 0; line-height: 0;"><img src="${SUPABASE_ASSETS_BASE}stit_potraviny.png" height="10" alt="P" style="display: block; height: 10px; width: auto;"></td></tr></table></td></tr>` : ''}
|
||||||
|
${p.stores?.supermarket ? `<tr><td align="right" style="line-height: 0;"><table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right"><tr><td style="border: 1px solid #fefefe; background-color: #f9890e; padding: 0; line-height: 0; border-top: 0;"><img src="${SUPABASE_ASSETS_BASE}stit_supermarket.png" height="10" alt="S" style="display: block; height: 10px; width: auto;"></td></tr></table></td></tr>` : ''}
|
||||||
|
${p.stores?.tempo ? `<tr><td align="right" style="line-height: 0;"><table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right"><tr><td style="border: 1px solid #fefefe; background-color: #005239; padding: 0; line-height: 0; border-top: 0;"><img src="${SUPABASE_ASSETS_BASE}stit_tempo.png" height="10" alt="T" style="display: block; height: 10px; width: auto;"></td></tr></table></td></tr>` : ''}
|
||||||
|
</table>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%" bgcolor="${bgColor}" style="background-color: ${bgColor}; border: 1px solid rgba(0,0,0,0.08); border-radius: 16px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 15px 15px 0 15px;" valign="top">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td style="font-size: 9px; color: #666666; font-weight: 500;" valign="top">${escapeHtml(validity)}</td>
|
||||||
|
<td align="right">${storesHtml}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 0 8px 15px 8px; text-align: center; height: 160px;" valign="middle">
|
||||||
|
<img src="${escapeHtml(p.imageUrl)}" width="220" alt="${escapeHtml(p.title)}" style="display: block; width: 100%; max-width: 220px; margin: 0 auto; border-radius: 8px;">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 0 8px 10px 8px; text-align: center; height: 50px;" valign="top">
|
||||||
|
<h3 style="margin: 0; font-size: 15px; color: #1a1a1a; font-weight: 600;">${escapeHtml(p.title)}</h3>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr height="1">
|
||||||
|
<td style="padding: 0 8px 12px 8px; text-align: center;" valign="bottom">
|
||||||
|
<!-- Cenovka -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="width: 95px; margin: 0 auto;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="background-color: #fefefe; border: 2px solid #fefefe; border-radius: 12px 0 0 12px; box-shadow: 0 4px 10px rgba(0,0,0,0.18); width: 85px; overflow: hidden;">
|
||||||
|
${p.discountText ? `
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 2px 4px; text-align: right; background-color: #fefefe;">
|
||||||
|
<span style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 9px; color: #000000; font-weight: 700; text-transform: lowercase;">${escapeHtml(p.badgeText || 'zľava')}</span>
|
||||||
|
<span style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 20px; color: #000000; font-weight: 900; line-height: 1; margin-left: 2px;">${escapeHtml(p.discountText)}</span>
|
||||||
|
</td>
|
||||||
|
</tr>` : ''}
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#e2001a" style="background-color: #e2001a; padding: 4px 2px 2px 2px; font-family: 'Arial Narrow', Arial, sans-serif; color: #fefefe; font-weight: 900; line-height: 1; text-align: center; border-radius: 0 0 0 8px;">
|
||||||
|
<span style="font-size: 52px;">${escapeHtml(p.priceMain)}</span><sup style="font-size: 26px; vertical-align: top; line-height: 1.2;">${escapeHtml(p.priceSub)}</sup>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
${p.showSlovakFlag ? `
|
||||||
|
<tr>
|
||||||
|
<td align="right" style="line-height: 0; padding: 0;">
|
||||||
|
<img src="${SUPABASE_ASSETS_BASE}vlajka_srdce.png" width="40" alt="SK" style="display: block; margin-top: -26px; margin-right: -10px; padding: 4px;">
|
||||||
|
</td>
|
||||||
|
</tr>` : ''}
|
||||||
|
</table>
|
||||||
|
${p.unitPrice ? `<div style="font-size: 9px; color: #666666; margin-top: 4px; font-weight: 500; font-family: Arial, sans-serif;">${escapeHtml(p.unitPrice)}</div>` : ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
export type ModuleType =
|
||||||
|
| 'announcement'
|
||||||
|
| 'header'
|
||||||
|
| 'banner'
|
||||||
|
| 'article'
|
||||||
|
| 'button'
|
||||||
|
| 'products_grid'
|
||||||
|
| 'loyalty_club'
|
||||||
|
| 'articles_row'
|
||||||
|
| 'footer';
|
||||||
|
|
||||||
|
export interface StoreTag {
|
||||||
|
id: 'potraviny' | 'supermarket' | 'tempo';
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
imageUrl: string;
|
||||||
|
priceMain: string; // napr. "1" alebo "0"
|
||||||
|
priceSub: string; // napr. "99" alebo "89"
|
||||||
|
unitPrice: string; // napr. "1kg = 4,91 €"
|
||||||
|
discountText: string; // napr. "-23%" alebo "23%"
|
||||||
|
badgeText: string; // napr. "AKCIA" alebo "SUPER CENA"
|
||||||
|
badgeId?: string; // priradená cenovka z databázy
|
||||||
|
validityOverride?: string; // lokálny override napr. "2. 5. – 15. 5."
|
||||||
|
stores: {
|
||||||
|
potraviny: boolean;
|
||||||
|
supermarket: boolean;
|
||||||
|
tempo: boolean;
|
||||||
|
};
|
||||||
|
cardBgColor?: string; // HEX napr. "#feeaea" (fantasticky), "#fef0c7" (domace), "#dcb085" (grill)
|
||||||
|
showSlovakFlag?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnouncementModuleData {
|
||||||
|
tipBadge: string;
|
||||||
|
text: string;
|
||||||
|
linkText: string;
|
||||||
|
linkUrl: string;
|
||||||
|
bgColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeaderModuleData {
|
||||||
|
logoUrl: string;
|
||||||
|
logoLink: string;
|
||||||
|
navLinks: Array<{ label: string; url: string; variant?: 'outline' | 'filled' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BannerModuleData {
|
||||||
|
imageUrl: string;
|
||||||
|
linkUrl: string;
|
||||||
|
altText: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
buttonText?: string;
|
||||||
|
buttonUrl?: string;
|
||||||
|
buttonBgColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArticleModuleData {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
linkUrl?: string;
|
||||||
|
align?: 'left' | 'center' | 'right';
|
||||||
|
textColor?: string;
|
||||||
|
bgColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ButtonModuleData {
|
||||||
|
text: string;
|
||||||
|
url: string;
|
||||||
|
bgColor: string;
|
||||||
|
textColor: string;
|
||||||
|
borderRadius: number;
|
||||||
|
align: 'center' | 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductsGridModuleData {
|
||||||
|
sectionTitle: string;
|
||||||
|
globalValidity: string;
|
||||||
|
products: ProductItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoyaltyClubModuleData {
|
||||||
|
headerImageUrl: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
buttonText: string;
|
||||||
|
buttonUrl: string;
|
||||||
|
products: ProductItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FooterModuleData {
|
||||||
|
disclaimer: string;
|
||||||
|
unsubscribeText: string;
|
||||||
|
unsubscribeUrl: string;
|
||||||
|
socials: {
|
||||||
|
facebook: string;
|
||||||
|
instagram: string;
|
||||||
|
tiktok: string;
|
||||||
|
youtube: string;
|
||||||
|
};
|
||||||
|
appStoreUrl: string;
|
||||||
|
googlePlayUrl: string;
|
||||||
|
foundationUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewsletterModule {
|
||||||
|
id: string;
|
||||||
|
type: ModuleType;
|
||||||
|
title: string;
|
||||||
|
data:
|
||||||
|
| AnnouncementModuleData
|
||||||
|
| HeaderModuleData
|
||||||
|
| BannerModuleData
|
||||||
|
| ArticleModuleData
|
||||||
|
| ButtonModuleData
|
||||||
|
| ProductsGridModuleData
|
||||||
|
| LoyaltyClubModuleData
|
||||||
|
| FooterModuleData
|
||||||
|
| any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewsletterCampaign {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
preheader: string;
|
||||||
|
status: 'draft' | 'ready' | 'exported';
|
||||||
|
brevoCampaignId?: number | null;
|
||||||
|
modules: NewsletterModule[];
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BadgeLayer {
|
||||||
|
id: string;
|
||||||
|
type: 'text' | 'rect';
|
||||||
|
text?: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
fontSize?: number;
|
||||||
|
fontWeight?: string;
|
||||||
|
fill: string;
|
||||||
|
align?: 'left' | 'center' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BadgeTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
svgBackgroundUrl?: string;
|
||||||
|
svgRaw?: string;
|
||||||
|
layersConfig: BadgeLayer[];
|
||||||
|
previewImageUrl?: string;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user