Compare commits
10
Commits
48d071d48e
...
eb92d30dc3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb92d30dc3 | ||
|
|
9c2fb60fd6 | ||
|
|
d72293a2e2 | ||
|
|
3bd93fde20 | ||
|
|
22a9a51d00 | ||
|
|
92dc2081d0 | ||
|
|
d43a1dae40 | ||
|
|
23ac4dc830 | ||
|
|
21320e792b | ||
|
|
2c3f09f18f |
No files matched your search
@@ -1,4 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getBrevoCampaignUrl } from '@/lib/brevo';
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -63,7 +64,7 @@ export async function POST(req: NextRequest) {
|
|||||||
success: true,
|
success: true,
|
||||||
updated: true,
|
updated: true,
|
||||||
campaignId: Number(existingCampaignId),
|
campaignId: Number(existingCampaignId),
|
||||||
brevoUrl: 'https://app.brevo.com/campaigns/listing',
|
brevoUrl: getBrevoCampaignUrl(existingCampaignId),
|
||||||
brevoListingUrl: 'https://app.brevo.com/campaigns/listing',
|
brevoListingUrl: 'https://app.brevo.com/campaigns/listing',
|
||||||
message: 'Kampaň bola úspešne aktualizovaná v Brevo!'
|
message: 'Kampaň bola úspešne aktualizovaná v Brevo!'
|
||||||
});
|
});
|
||||||
@@ -113,7 +114,7 @@ export async function POST(req: NextRequest) {
|
|||||||
success: true,
|
success: true,
|
||||||
updated: false,
|
updated: false,
|
||||||
campaignId: data.id,
|
campaignId: data.id,
|
||||||
brevoUrl: 'https://app.brevo.com/campaigns/listing',
|
brevoUrl: getBrevoCampaignUrl(data.id),
|
||||||
brevoListingUrl: 'https://app.brevo.com/campaigns/listing',
|
brevoListingUrl: 'https://app.brevo.com/campaigns/listing',
|
||||||
message: 'Kampaň bola úspešne vytvorená v Brevo!'
|
message: 'Kampaň bola úspešne vytvorená v Brevo!'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getBrevoCampaignUrl } from '@/lib/brevo';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -51,7 +52,7 @@ export async function GET(req: NextRequest) {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
status: data.status,
|
status: data.status,
|
||||||
subject: data.subject,
|
subject: data.subject,
|
||||||
brevoUrl: 'https://app.brevo.com/campaigns/listing',
|
brevoUrl: getBrevoCampaignUrl(data.id),
|
||||||
brevoListingUrl: 'https://app.brevo.com/campaigns/listing'
|
brevoListingUrl: 'https://app.brevo.com/campaigns/listing'
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
+23
-5
@@ -6,6 +6,7 @@ import { DEFAULT_CAMPAIGN, SUPABASE_ASSETS_BASE } from '@/lib/defaultCampaign';
|
|||||||
import { renderEmailHtml, renderSingleModuleHtml } from '@/lib/emailRenderer';
|
import { renderEmailHtml, renderSingleModuleHtml } from '@/lib/emailRenderer';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
import { DEFAULT_BADGE_TEMPLATES } from '@/lib/badgeUtils';
|
import { DEFAULT_BADGE_TEMPLATES } from '@/lib/badgeUtils';
|
||||||
|
import { getBrevoCampaignUrl } from '@/lib/brevo';
|
||||||
import { TopBar } from '@/components/editor/TopBar';
|
import { TopBar } from '@/components/editor/TopBar';
|
||||||
import { ConfigPanel } from '@/components/editor/ConfigPanel';
|
import { ConfigPanel } from '@/components/editor/ConfigPanel';
|
||||||
import { ProductImportModal } from '@/components/editor/ProductImportModal';
|
import { ProductImportModal } from '@/components/editor/ProductImportModal';
|
||||||
@@ -144,7 +145,7 @@ export default function Home() {
|
|||||||
modules: enforceAnchors(dbItem.data.modules)
|
modules: enforceAnchors(dbItem.data.modules)
|
||||||
});
|
});
|
||||||
if (dbItem.brevo_campaign_id) {
|
if (dbItem.brevo_campaign_id) {
|
||||||
setBrevoUrl('https://app.brevo.com/campaigns/listing');
|
setBrevoUrl(getBrevoCampaignUrl(dbItem.brevo_campaign_id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -571,7 +572,8 @@ export default function Home() {
|
|||||||
...m,
|
...m,
|
||||||
data: {
|
data: {
|
||||||
...m.data,
|
...m.data,
|
||||||
products: (m.data.products || []).map((prod: any) => ({
|
// Produkty s vlastnou PNG cenovkou (výnimky) sa neprepisujú
|
||||||
|
products: (m.data.products || []).map((prod: any) => prod.customBadgePngUrl ? prod : ({
|
||||||
...prod,
|
...prod,
|
||||||
badgeId: badge.id,
|
badgeId: badge.id,
|
||||||
badgeCustomTemplate: badge,
|
badgeCustomTemplate: badge,
|
||||||
@@ -579,8 +581,7 @@ export default function Home() {
|
|||||||
badgeAlign: badge.align ?? prod.badgeAlign ?? 'center',
|
badgeAlign: badge.align ?? prod.badgeAlign ?? 'center',
|
||||||
badgePaddingTop: badge.paddingTop ?? prod.badgePaddingTop ?? 4,
|
badgePaddingTop: badge.paddingTop ?? prod.badgePaddingTop ?? 4,
|
||||||
badgePaddingBottom: badge.paddingBottom ?? prod.badgePaddingBottom ?? 4,
|
badgePaddingBottom: badge.paddingBottom ?? prod.badgePaddingBottom ?? 4,
|
||||||
badgeLayout: badgeLayout ?? prod.badgeLayout,
|
badgeLayout: badgeLayout ?? prod.badgeLayout
|
||||||
customBadgePngUrl: undefined
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -590,6 +591,19 @@ export default function Home() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Globálna platnosť akcie: prepíše sa do všetkých produktových sekcií aj vernostných zliav
|
||||||
|
// (produkty s vlastnou platnosťou validityOverride si ju ponechajú)
|
||||||
|
const handleSetGlobalValidity = (validity: string) => {
|
||||||
|
setCampaign((prev) => ({
|
||||||
|
...prev,
|
||||||
|
modules: prev.modules.map((m) =>
|
||||||
|
m.type === 'products_grid' || m.type === 'loyalty_club'
|
||||||
|
? { ...m, data: { ...m.data, globalValidity: validity } }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const topCount = campaign.modules.filter((m) => isTopModule(m)).length;
|
const topCount = campaign.modules.filter((m) => isTopModule(m)).length;
|
||||||
const firstBottomIdx = campaign.modules.findIndex((m) => isBottomModule(m));
|
const firstBottomIdx = campaign.modules.findIndex((m) => isBottomModule(m));
|
||||||
|
|
||||||
@@ -794,6 +808,7 @@ export default function Home() {
|
|||||||
module={selectedModule}
|
module={selectedModule}
|
||||||
onUpdateModule={handleUpdateModule}
|
onUpdateModule={handleUpdateModule}
|
||||||
onApplyBadgeToAll={handleApplyBadgeToAll}
|
onApplyBadgeToAll={handleApplyBadgeToAll}
|
||||||
|
onSetGlobalValidity={handleSetGlobalValidity}
|
||||||
availableBadges={availableBadges}
|
availableBadges={availableBadges}
|
||||||
onOpenBadgeManager={(pId) => {
|
onOpenBadgeManager={(pId) => {
|
||||||
setTargetProductIdForBadge(pId);
|
setTargetProductIdForBadge(pId);
|
||||||
@@ -820,6 +835,9 @@ export default function Home() {
|
|||||||
currentPriceMain={targetProduct?.priceMain || targetProduct?.cardPriceMain || '1'}
|
currentPriceMain={targetProduct?.priceMain || targetProduct?.cardPriceMain || '1'}
|
||||||
currentPriceSub={targetProduct?.priceSub || targetProduct?.cardPriceSub || '99'}
|
currentPriceSub={targetProduct?.priceSub || targetProduct?.cardPriceSub || '99'}
|
||||||
currentUnitPrice={targetProduct?.unitPrice || targetProduct?.cardUnitPrice || '1kg = 4,91 €'}
|
currentUnitPrice={targetProduct?.unitPrice || targetProduct?.cardUnitPrice || '1kg = 4,91 €'}
|
||||||
|
currentCardPriceMain={targetProduct?.cardPriceMain || undefined}
|
||||||
|
currentCardPriceSub={targetProduct?.cardPriceSub || undefined}
|
||||||
|
currentCardDiscount={targetProduct?.cardDiscount || undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modál Moje newslettre (Ukladanie, duplikácia, vytváranie a prepínanie) */}
|
{/* Modál Moje newslettre (Ukladanie, duplikácia, vytváranie a prepínanie) */}
|
||||||
@@ -831,7 +849,7 @@ export default function Home() {
|
|||||||
setCampaign({ ...selected, modules: enforceAnchors(selected.modules) });
|
setCampaign({ ...selected, modules: enforceAnchors(selected.modules) });
|
||||||
setSelectedModuleId(selected.modules[1]?.id || selected.modules[0]?.id || null);
|
setSelectedModuleId(selected.modules[1]?.id || selected.modules[0]?.id || null);
|
||||||
if (selected.brevoCampaignId) {
|
if (selected.brevoCampaignId) {
|
||||||
setBrevoUrl('https://app.brevo.com/campaigns');
|
setBrevoUrl(getBrevoCampaignUrl(selected.brevoCampaignId));
|
||||||
} else {
|
} else {
|
||||||
setBrevoUrl(null);
|
setBrevoUrl(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,28 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { BadgeTemplate, BadgeLayer } from '@/types/newsletter';
|
import { BadgeTemplate, BadgeLayer } from '@/types/newsletter';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
import { DEFAULT_BADGE_TEMPLATES, renderCleanBadgeSvg, svgToCleanPng } from '@/lib/badgeUtils';
|
import { DEFAULT_BADGE_TEMPLATES, renderCleanBadgeSvg, svgToCleanPng, BADGE_PRICE_TOKEN_RE, badgePriceParts, replaceBadgeTextVars } from '@/lib/badgeUtils';
|
||||||
import { Tag, Plus, Check, Trash2, X, Upload, Eye, Move, AlignLeft, AlignCenter, AlignRight, ZoomIn, ZoomOut } from 'lucide-react';
|
import { Tag, Plus, Check, Trash2, X, Upload, Eye, Move, AlignLeft, AlignCenter, AlignRight, ZoomIn, ZoomOut, Copy } from 'lucide-react';
|
||||||
|
|
||||||
|
// Dočasné ID šablóny pred uložením do databázy (prefix badge- = ešte nie je v Supabase)
|
||||||
|
function newLocalBadgeId(): string {
|
||||||
|
return `badge-${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Riadok tabuľky badges v Supabase pre danú šablónu
|
||||||
|
function toBadgeDbPayload(badge: BadgeTemplate) {
|
||||||
|
return {
|
||||||
|
name: badge.name,
|
||||||
|
svg_raw: badge.svgRaw,
|
||||||
|
layers_config: {
|
||||||
|
layers: badge.layersConfig,
|
||||||
|
width: badge.width || 110,
|
||||||
|
align: badge.align || 'center',
|
||||||
|
paddingTop: badge.paddingTop ?? 4,
|
||||||
|
paddingBottom: badge.paddingBottom ?? 4
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Priblíženie plátna v editore: rozsah posuvníka a kroky tlačidiel +/−
|
// Priblíženie plátna v editore: rozsah posuvníka a kroky tlačidiel +/−
|
||||||
const CANVAS_ZOOM_MIN = 0.5;
|
const CANVAS_ZOOM_MIN = 0.5;
|
||||||
@@ -19,6 +39,9 @@ interface BadgeEditorModalProps {
|
|||||||
currentPriceMain?: string;
|
currentPriceMain?: string;
|
||||||
currentPriceSub?: string;
|
currentPriceSub?: string;
|
||||||
currentUnitPrice?: string;
|
currentUnitPrice?: string;
|
||||||
|
currentCardPriceMain?: string;
|
||||||
|
currentCardPriceSub?: string;
|
||||||
|
currentCardDiscount?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
||||||
@@ -28,7 +51,10 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
|||||||
currentDiscount = '25%',
|
currentDiscount = '25%',
|
||||||
currentPriceMain = '1',
|
currentPriceMain = '1',
|
||||||
currentPriceSub = '99',
|
currentPriceSub = '99',
|
||||||
currentUnitPrice = '1kg = 4,91 €'
|
currentUnitPrice = '1kg = 4,91 €',
|
||||||
|
currentCardPriceMain = '1',
|
||||||
|
currentCardPriceSub = '49',
|
||||||
|
currentCardDiscount = '30%'
|
||||||
}) => {
|
}) => {
|
||||||
const POPULAR_FONTS = [
|
const POPULAR_FONTS = [
|
||||||
{ name: 'Poppins', source: 'google' as const },
|
{ name: 'Poppins', source: 'google' as const },
|
||||||
@@ -376,7 +402,7 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
|||||||
const addTextLayer = (presetText: string = 'Text') => {
|
const addTextLayer = (presetText: string = 'Text') => {
|
||||||
if (!activeBadge) return;
|
if (!activeBadge) return;
|
||||||
const newL: BadgeLayer = {
|
const newL: BadgeLayer = {
|
||||||
id: `l-${Date.now()}`,
|
id: `l-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: presetText,
|
text: presetText,
|
||||||
x: 70,
|
x: 70,
|
||||||
@@ -401,11 +427,47 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Duplikovanie šablóny: kópia sa hneď uloží (Supabase, inak localStorage) a otvorí na úpravu
|
||||||
|
const duplicateBadgeTemplate = async (badge: BadgeTemplate) => {
|
||||||
|
const copy: BadgeTemplate = {
|
||||||
|
...JSON.parse(JSON.stringify(badge)),
|
||||||
|
id: newLocalBadgeId(),
|
||||||
|
name: `${badge.name} (kópia)`
|
||||||
|
};
|
||||||
|
let saved = copy;
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('badges')
|
||||||
|
.insert([toBadgeDbPayload(copy)])
|
||||||
|
.select();
|
||||||
|
if (!error && data && data[0]) {
|
||||||
|
saved = { ...copy, id: data[0].id };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// bez databázy ostane kópia v prehliadači
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = [...badges];
|
||||||
|
const idx = list.findIndex((b) => b.id === badge.id);
|
||||||
|
list.splice(idx + 1, 0, saved);
|
||||||
|
setBadges(list);
|
||||||
|
setActiveBadge(saved);
|
||||||
|
setSelectedLayerId(saved.layersConfig?.[0]?.id ?? null);
|
||||||
|
try {
|
||||||
|
localStorage.setItem('coop_custom_badges', JSON.stringify(list));
|
||||||
|
} catch {
|
||||||
|
// localStorage nemusí byť dostupný
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const deleteBadgeTemplate = async (badgeId: string) => {
|
const deleteBadgeTemplate = async (badgeId: string) => {
|
||||||
if (confirm('Naozaj chcete zmazať túto šablónu cenovky?')) {
|
if (confirm('Naozaj chcete zmazať túto šablónu cenovky?')) {
|
||||||
|
// Predvolené šablóny existujú len v prehliadači (localStorage), v databáze nie sú
|
||||||
|
if (!badgeId.startsWith('badge-preset-')) {
|
||||||
try {
|
try {
|
||||||
await supabase.from('badges').delete().eq('id', badgeId);
|
await supabase.from('badges').delete().eq('id', badgeId);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
}
|
||||||
const remaining = badges.filter((b) => b.id !== badgeId);
|
const remaining = badges.filter((b) => b.id !== badgeId);
|
||||||
setBadges(remaining);
|
setBadges(remaining);
|
||||||
setActiveBadge(remaining[0] || null);
|
setActiveBadge(remaining[0] || null);
|
||||||
@@ -420,17 +482,7 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
|||||||
if (!activeBadge) return;
|
if (!activeBadge) return;
|
||||||
setIsSavingDb(true);
|
setIsSavingDb(true);
|
||||||
try {
|
try {
|
||||||
const payload: any = {
|
const payload = toBadgeDbPayload(activeBadge);
|
||||||
name: activeBadge.name,
|
|
||||||
svg_raw: activeBadge.svgRaw,
|
|
||||||
layers_config: {
|
|
||||||
layers: activeBadge.layersConfig,
|
|
||||||
width: activeBadge.width || 110,
|
|
||||||
align: activeBadge.align || 'center',
|
|
||||||
paddingTop: activeBadge.paddingTop ?? 4,
|
|
||||||
paddingBottom: activeBadge.paddingBottom ?? 4
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let savedBadge: BadgeTemplate | null = null;
|
let savedBadge: BadgeTemplate | null = null;
|
||||||
const isExistingUuid = activeBadge.id && !activeBadge.id.startsWith('badge-');
|
const isExistingUuid = activeBadge.id && !activeBadge.id.startsWith('badge-');
|
||||||
@@ -501,6 +553,17 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Vykreslenie vrstvy v React náhľade s centami ako horný index (sup) a BEZ znaku €
|
// Vykreslenie vrstvy v React náhľade s centami ako horný index (sup) a BEZ znaku €
|
||||||
|
// Ceny produktu, pre ktorý sa cenovka upravuje (náhľad premenných)
|
||||||
|
const previewProduct = {
|
||||||
|
priceMain: currentPriceMain,
|
||||||
|
priceSub: currentPriceSub,
|
||||||
|
discountText: currentDiscount,
|
||||||
|
unitPrice: currentUnitPrice,
|
||||||
|
cardPriceMain: currentCardPriceMain,
|
||||||
|
cardPriceSub: currentCardPriceSub,
|
||||||
|
cardDiscount: currentCardDiscount
|
||||||
|
};
|
||||||
|
|
||||||
const renderLayerVisualContent = (layer: BadgeLayer) => {
|
const renderLayerVisualContent = (layer: BadgeLayer) => {
|
||||||
const raw = layer.text || '';
|
const raw = layer.text || '';
|
||||||
const baseFontSize = layer.fontSize || 16;
|
const baseFontSize = layer.fontSize || 16;
|
||||||
@@ -508,12 +571,14 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
|||||||
const subY = layer.indexOffsetY !== undefined ? `${layer.indexOffsetY}px` : '-10px';
|
const subY = layer.indexOffsetY !== undefined ? `${layer.indexOffsetY}px` : '-10px';
|
||||||
const subX = layer.indexOffsetX !== undefined ? `${layer.indexOffsetX}px` : '2px';
|
const subX = layer.indexOffsetX !== undefined ? `${layer.indexOffsetX}px` : '2px';
|
||||||
|
|
||||||
if (raw.includes('{cena}')) {
|
const priceMatch = raw.match(BADGE_PRICE_TOKEN_RE);
|
||||||
const parts = raw.split('{cena}');
|
if (priceMatch) {
|
||||||
|
const [tokMain, tokSub] = badgePriceParts(priceMatch[1], previewProduct);
|
||||||
|
const idx = priceMatch.index || 0;
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
{parts[0]}
|
{replaceBadgeTextVars(raw.slice(0, idx), previewProduct)}
|
||||||
<span>{currentPriceMain}</span>
|
<span>{tokMain}</span>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
@@ -523,9 +588,9 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
|||||||
lineHeight: 1
|
lineHeight: 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{currentPriceSub}
|
{tokSub}
|
||||||
</span>
|
</span>
|
||||||
{parts[1]}
|
{replaceBadgeTextVars(raw.slice(idx + priceMatch[0].length), previewProduct)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -551,13 +616,7 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const replaced = raw
|
return replaceBadgeTextVars(raw, previewProduct);
|
||||||
.replace(/{cela_suma}/g, currentPriceMain)
|
|
||||||
.replace(/{zlava}/g, currentDiscount.replace('-', ''))
|
|
||||||
.replace(/{jednotkova_cena}/g, currentUnitPrice)
|
|
||||||
.replace(/{mena}/g, '€');
|
|
||||||
|
|
||||||
return replaced;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Aplikovanie cenovky na produkt: čisté vektorové SVG a čistý PNG (BEZ akéhokoľvek screenshotu nástrojov/rámčekov)
|
// Aplikovanie cenovky na produkt: čisté vektorové SVG a čistý PNG (BEZ akéhokoľvek screenshotu nástrojov/rámčekov)
|
||||||
@@ -567,12 +626,7 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
|||||||
try {
|
try {
|
||||||
const cleanSvg = renderCleanBadgeSvg(
|
const cleanSvg = renderCleanBadgeSvg(
|
||||||
activeBadge,
|
activeBadge,
|
||||||
{
|
previewProduct,
|
||||||
priceMain: currentPriceMain,
|
|
||||||
priceSub: currentPriceSub,
|
|
||||||
discountText: currentDiscount,
|
|
||||||
unitPrice: currentUnitPrice
|
|
||||||
},
|
|
||||||
activeBadge.width || 110
|
activeBadge.width || 110
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -643,30 +697,36 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<p className="text-xs font-bold text-gray-800 truncate">{b.name}</p>
|
<p className="text-xs font-bold text-gray-800 truncate">{b.name}</p>
|
||||||
{!b.id.startsWith('badge-preset-') && (
|
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
duplicateBadgeTemplate(b);
|
||||||
|
}}
|
||||||
|
className="text-gray-300 hover:text-blue-600 p-1 rounded transition-colors"
|
||||||
|
title="Duplikovať šablónu"
|
||||||
|
>
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
{/* Zmazať sa dá každá šablóna, aj staršie predvolené (badge-preset-*) uložené v prehliadači */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
deleteBadgeTemplate(b.id);
|
deleteBadgeTemplate(b.id);
|
||||||
}}
|
}}
|
||||||
className="text-gray-300 hover:text-red-600 p-1 rounded transition-colors opacity-0 group-hover:opacity-100"
|
className="text-gray-300 hover:text-red-600 p-1 rounded transition-colors"
|
||||||
title="Zmazať šablónu"
|
title="Zmazať šablónu"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="w-full h-12 flex items-center justify-center transform scale-75 origin-center overflow-hidden pointer-events-none"
|
className="w-full h-12 flex items-center justify-center transform scale-75 origin-center overflow-hidden pointer-events-none"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: renderCleanBadgeSvg(
|
__html: renderCleanBadgeSvg(
|
||||||
b,
|
b,
|
||||||
{
|
previewProduct,
|
||||||
priceMain: currentPriceMain,
|
|
||||||
priceSub: currentPriceSub,
|
|
||||||
discountText: currentDiscount,
|
|
||||||
unitPrice: currentUnitPrice
|
|
||||||
},
|
|
||||||
90
|
90
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -828,6 +888,13 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
|||||||
<span className="bg-slate-800 px-2 py-0.5 rounded font-mono text-emerald-300">
|
<span className="bg-slate-800 px-2 py-0.5 rounded font-mono text-emerald-300">
|
||||||
{'{zlava}'} → {currentDiscount.replace('-', '')}
|
{'{zlava}'} → {currentDiscount.replace('-', '')}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="bg-slate-800 px-2 py-0.5 rounded font-mono text-amber-300">
|
||||||
|
{'{cena_s_kartou}'} → {currentCardPriceMain}
|
||||||
|
<sup>{currentCardPriceSub}</sup>
|
||||||
|
</span>
|
||||||
|
<span className="bg-slate-800 px-2 py-0.5 rounded font-mono text-emerald-300">
|
||||||
|
{'{zlava_s_kartou}'} → {currentCardDiscount.replace('-', '')}
|
||||||
|
</span>
|
||||||
<span className="bg-slate-800 px-2 py-0.5 rounded font-mono text-blue-300">
|
<span className="bg-slate-800 px-2 py-0.5 rounded font-mono text-blue-300">
|
||||||
{'{mena}'} → €
|
{'{mena}'} → €
|
||||||
</span>
|
</span>
|
||||||
@@ -984,6 +1051,18 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
|||||||
>
|
>
|
||||||
+ Zľava %
|
+ Zľava %
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => addTextLayer('{cena_s_kartou}')}
|
||||||
|
className="text-[11px] font-bold bg-amber-50 text-amber-800 border border-amber-200 hover:bg-amber-100 px-2 py-1 rounded-lg"
|
||||||
|
>
|
||||||
|
+ Cena s kartou
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => addTextLayer('-{zlava_s_kartou}')}
|
||||||
|
className="text-[11px] font-bold bg-red-50 text-red-800 border border-red-200 hover:bg-red-100 px-2 py-1 rounded-lg"
|
||||||
|
>
|
||||||
|
+ Zľava s kartou
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => addTextLayer('€')}
|
onClick={() => addTextLayer('€')}
|
||||||
className="text-[11px] font-bold bg-blue-50 text-blue-800 border border-blue-200 hover:bg-blue-100 px-2 py-1 rounded-lg"
|
className="text-[11px] font-bold bg-blue-50 text-blue-800 border border-blue-200 hover:bg-blue-100 px-2 py-1 rounded-lg"
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { NewsletterModule, ProductItem, BadgeTemplate } from '@/types/newsletter';
|
import { NewsletterModule, ProductItem, BadgeTemplate } from '@/types/newsletter';
|
||||||
import { Plus, Trash2, Image as ImageIcon, Tag, Store, Globe, Palette, Check } from 'lucide-react';
|
import { Plus, Trash2, Image as ImageIcon, Tag, Store, Globe, Palette, Check, Code } from 'lucide-react';
|
||||||
import { SUPABASE_ASSETS_BASE } from '@/lib/defaultCampaign';
|
import { SUPABASE_ASSETS_BASE } from '@/lib/defaultCampaign';
|
||||||
import { ImageUploadInput } from './ImageUploadInput';
|
import { ImageUploadInput } from './ImageUploadInput';
|
||||||
|
import { pickAlt } from '@/lib/altText';
|
||||||
|
import { renderGeneratedModuleHtml, CUSTOM_HTML_MODULE_TYPES } from '@/lib/emailRenderer';
|
||||||
|
|
||||||
const PRODUCT_BG_PRESETS = [
|
const PRODUCT_BG_PRESETS = [
|
||||||
{ label: 'Červená', sub: 'Fantasticky', color: '#feeaea', border: '#f5d4d4' },
|
{ label: 'Červená', sub: 'Fantasticky', color: '#feeaea', border: '#f5d4d4' },
|
||||||
@@ -19,15 +21,169 @@ interface ConfigPanelProps {
|
|||||||
module: NewsletterModule | null;
|
module: NewsletterModule | null;
|
||||||
onUpdateModule: (updated: NewsletterModule) => void;
|
onUpdateModule: (updated: NewsletterModule) => void;
|
||||||
onApplyBadgeToAll?: (badge: BadgeTemplate, badgeLayout?: 'below' | 'side') => void;
|
onApplyBadgeToAll?: (badge: BadgeTemplate, badgeLayout?: 'below' | 'side') => void;
|
||||||
|
onSetGlobalValidity?: (validity: string) => void; // nastaví platnosť vo všetkých produktových sekciách
|
||||||
onOpenBadgeManager?: (productId: string) => void;
|
onOpenBadgeManager?: (productId: string) => void;
|
||||||
onOpenImport?: () => void;
|
onOpenImport?: () => void;
|
||||||
availableBadges?: BadgeTemplate[];
|
availableBadges?: BadgeTemplate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pomer strán obrázka (výška / šírka); pri chybe načítania 1
|
||||||
|
const loadImageAspect = (url: string): Promise<number> =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
if (!url) return resolve(1);
|
||||||
|
const img = new window.Image();
|
||||||
|
img.onload = () => resolve(img.naturalWidth ? img.naturalHeight / img.naturalWidth : 1);
|
||||||
|
img.onerror = () => resolve(1);
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Úprava produktovej fotky: zoom a posun (čo presahuje rámik, sa oreže)
|
||||||
|
const ProductImageAdjust: React.FC<{
|
||||||
|
product: ProductItem;
|
||||||
|
onChange: (patch: Partial<ProductItem>) => void;
|
||||||
|
}> = ({ product, onChange }) => {
|
||||||
|
const scale = product.imageScale ?? 1;
|
||||||
|
const offsetX = product.imageOffsetX || 0;
|
||||||
|
const offsetY = product.imageOffsetY || 0;
|
||||||
|
const isAdjusted = scale !== 1 || offsetX !== 0 || offsetY !== 0;
|
||||||
|
|
||||||
|
const update = async (patch: Partial<ProductItem>) => {
|
||||||
|
// Pomer strán fotky potrebujeme pre presný výpočet pozadia; zistíme ho pri prvej úprave
|
||||||
|
if (!product.imageAspect && product.imageUrl) {
|
||||||
|
patch = { ...patch, imageAspect: await loadImageAspect(product.imageUrl) };
|
||||||
|
}
|
||||||
|
onChange(patch);
|
||||||
|
};
|
||||||
|
|
||||||
|
const slider = (label: string, value: number, display: string, min: number, max: number, step: number, key: keyof ProductItem) => (
|
||||||
|
<div className="grid grid-cols-[64px_1fr_44px] items-center gap-2">
|
||||||
|
<span className="text-[10px] text-gray-600 font-semibold">{label}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => update({ [key]: Number(e.target.value) } as Partial<ProductItem>)}
|
||||||
|
className="w-full h-1 accent-red-600 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] font-mono text-gray-700 text-right">{display}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-2.5 rounded-xl border border-gray-200 bg-gray-50 space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] font-bold text-gray-700 uppercase tracking-wider">Úprava fotky</span>
|
||||||
|
{isAdjusted && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ imageScale: undefined, imageOffsetX: undefined, imageOffsetY: undefined })}
|
||||||
|
className="text-[10px] font-bold text-gray-500 hover:text-red-600"
|
||||||
|
>
|
||||||
|
Obnoviť
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{slider('Veľkosť', scale, `${Math.round(scale * 100)} %`, 0.5, 2.5, 0.05, 'imageScale')}
|
||||||
|
{slider('Vodorovne', offsetX, `${offsetX} px`, -150, 150, 1, 'imageOffsetX')}
|
||||||
|
{slider('Zvisle', offsetY, `${offsetY} px`, -120, 120, 1, 'imageOffsetY')}
|
||||||
|
<p className="text-[10px] text-gray-500">Čo presahuje rámik, sa oreže. Pri polohe cenovky „vedľa obrázka“ sa dá produkt zasunúť pod cenovku.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Jednoduchý HTML editor: vlastné HTML nahradí modul vygenerovaný z formulára
|
||||||
|
const CustomHtmlEditor: React.FC<{
|
||||||
|
module: NewsletterModule;
|
||||||
|
updateData: (patch: Record<string, unknown>) => void;
|
||||||
|
}> = ({ module, updateData }) => {
|
||||||
|
const enabled = !!module.data.useCustomHtml;
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
if (enabled) {
|
||||||
|
// Neupravené HTML nedržíme, aby sa pri ďalšom zapnutí predvyplnilo aktuálne z formulára
|
||||||
|
const unchanged = (module.data.customHtml || '').trim() === renderGeneratedModuleHtml(module);
|
||||||
|
updateData({ useCustomHtml: false, ...(unchanged ? { customHtml: undefined } : {}) });
|
||||||
|
} else {
|
||||||
|
// Pri prvom zapnutí predvyplníme aktuálne vygenerované HTML
|
||||||
|
updateData({
|
||||||
|
useCustomHtml: true,
|
||||||
|
customHtml: module.data.customHtml || renderGeneratedModuleHtml(module)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadFromForm = () => {
|
||||||
|
if (confirm('Prepísať vlastné HTML aktuálnym HTML vygenerovaným z formulára?')) {
|
||||||
|
updateData({ customHtml: renderGeneratedModuleHtml(module) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-5 p-3 rounded-xl border border-gray-200 bg-gray-50 space-y-2">
|
||||||
|
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
||||||
|
<span className="text-xs font-bold text-gray-800 flex items-center gap-1.5">
|
||||||
|
<Code className="w-3.5 h-3.5 text-gray-600" /> Vlastné HTML
|
||||||
|
</span>
|
||||||
|
<input type="checkbox" checked={enabled} onChange={toggle} className="w-4 h-4 accent-red-600" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{enabled ? (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
value={module.data.customHtml || ''}
|
||||||
|
onChange={(e) => updateData({ customHtml: e.target.value })}
|
||||||
|
spellCheck={false}
|
||||||
|
className="w-full h-96 text-[11px] leading-snug font-mono border border-gray-300 rounded-lg p-2 bg-white whitespace-pre"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-[10px] text-gray-500">Náhľad sa aktualizuje priebežne. Používajte tabuľky a inline štýly.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={reloadFromForm}
|
||||||
|
className="shrink-0 text-[10px] font-bold text-gray-700 bg-white hover:bg-gray-100 border border-gray-300 px-2 py-1 rounded-lg"
|
||||||
|
title="Nahradiť obsah HTML vygenerovaným z formulára"
|
||||||
|
>
|
||||||
|
Načítať z formulára
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-[10px] text-gray-500">Zapnite pre úpravu HTML kódu. Formulár ostane uložený a po vypnutí sa vráti.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nahratie celej cenovky ako PNG pre výnimočný produkt (prekryje šablónu)
|
||||||
|
const CustomBadgePngInput: React.FC<{ value?: string; onChange: (url: string) => void }> = ({ value, onChange }) => (
|
||||||
|
<div className="p-2.5 rounded-xl border border-dashed border-amber-300 bg-amber-50/60 space-y-1.5">
|
||||||
|
<ImageUploadInput
|
||||||
|
label="Vlastná PNG cenovka (výnimka)"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder="Nahrajte celú cenovku ako PNG"
|
||||||
|
/>
|
||||||
|
{value ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange('')}
|
||||||
|
className="w-full py-1.5 text-[11px] font-bold text-amber-800 bg-white hover:bg-amber-100 border border-amber-300 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Odstrániť PNG a vrátiť generovanú cenovku
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<p className="text-[10px] text-amber-800/80">Nahraná PNG nahradí generovanú cenovku len pri tomto produkte.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
||||||
module,
|
module,
|
||||||
onUpdateModule,
|
onUpdateModule,
|
||||||
onApplyBadgeToAll,
|
onApplyBadgeToAll,
|
||||||
|
onSetGlobalValidity,
|
||||||
onOpenBadgeManager,
|
onOpenBadgeManager,
|
||||||
onOpenImport,
|
onOpenImport,
|
||||||
availableBadges = []
|
availableBadges = []
|
||||||
@@ -124,8 +280,13 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Vlastné HTML pre hlavičku a pätičku */}
|
||||||
|
{CUSTOM_HTML_MODULE_TYPES.includes(module.type) && (
|
||||||
|
<CustomHtmlEditor module={module} updateData={updateData} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* HEADER */}
|
{/* HEADER */}
|
||||||
{module.type === 'header' && (() => {
|
{module.type === 'header' && !module.data.useCustomHtml && (() => {
|
||||||
const navLinks = module.data.navLinks || [
|
const navLinks = module.data.navLinks || [
|
||||||
{ label: 'Letáky', url: 'https://www.coop.sk/sk/letaky', variant: 'outline' },
|
{ label: 'Letáky', url: 'https://www.coop.sk/sk/letaky', variant: 'outline' },
|
||||||
{ label: 'COOP Klub', url: 'https://www.coopklub.sk', variant: 'filled' }
|
{ label: 'COOP Klub', url: 'https://www.coopklub.sk', variant: 'filled' }
|
||||||
@@ -143,6 +304,9 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
label="Logo COOP Jednota"
|
label="Logo COOP Jednota"
|
||||||
value={module.data.logoUrl || ''}
|
value={module.data.logoUrl || ''}
|
||||||
onChange={(url) => updateData({ logoUrl: url })}
|
onChange={(url) => updateData({ logoUrl: url })}
|
||||||
|
altValue={module.data.logoAlt}
|
||||||
|
onAltChange={(alt) => updateData({ logoAlt: alt })}
|
||||||
|
altPlaceholder="COOP Jednota"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-gray-700 mb-1">Odkaz loga</label>
|
<label className="block text-xs font-semibold text-gray-700 mb-1">Odkaz loga</label>
|
||||||
@@ -254,6 +418,9 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
label="Obrázok bannera"
|
label="Obrázok bannera"
|
||||||
value={module.data.imageUrl || ''}
|
value={module.data.imageUrl || ''}
|
||||||
onChange={(url) => updateData({ imageUrl: url })}
|
onChange={(url) => updateData({ imageUrl: url })}
|
||||||
|
altValue={module.data.altText}
|
||||||
|
onAltChange={(alt) => updateData({ altText: alt })}
|
||||||
|
altPlaceholder={pickAlt(module.data.title, module.title, 'COOP Jednota')}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-gray-700 mb-1">Odkaz bannera</label>
|
<label className="block text-xs font-semibold text-gray-700 mb-1">Odkaz bannera</label>
|
||||||
@@ -336,10 +503,15 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={module.data.globalValidity || ''}
|
value={module.data.globalValidity || ''}
|
||||||
onChange={(e) => updateData({ globalValidity: e.target.value })}
|
onChange={(e) =>
|
||||||
|
onSetGlobalValidity
|
||||||
|
? onSetGlobalValidity(e.target.value)
|
||||||
|
: updateData({ globalValidity: e.target.value })
|
||||||
|
}
|
||||||
placeholder="napr. 2. 5. – 15. 5. 2026"
|
placeholder="napr. 2. 5. – 15. 5. 2026"
|
||||||
className="w-full text-sm border border-gray-300 rounded-lg p-2"
|
className="w-full text-sm border border-gray-300 rounded-lg p-2"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-[10px] text-gray-500 mt-1">Platí pre všetky produktové sekcie aj vernostné zľavy. Produkty s vlastnou platnosťou si ju ponechajú.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rozloženie produktov (Počet produktov na riadok) */}
|
{/* Rozloženie produktov (Počet produktov na riadok) */}
|
||||||
@@ -534,11 +706,28 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
onChange={(url) => {
|
onChange={(url) => {
|
||||||
const products = [...module.data.products];
|
const products = [...module.data.products];
|
||||||
products[pIdx].imageUrl = url;
|
products[pIdx].imageUrl = url;
|
||||||
|
products[pIdx].imageAspect = undefined; // nová fotka = nový pomer strán
|
||||||
updateData({ products });
|
updateData({ products });
|
||||||
}}
|
}}
|
||||||
|
altValue={p.imageAlt}
|
||||||
|
onAltChange={(alt) => {
|
||||||
|
const products = [...module.data.products];
|
||||||
|
products[pIdx].imageAlt = alt;
|
||||||
|
updateData({ products });
|
||||||
|
}}
|
||||||
|
altPlaceholder={pickAlt(p.title, module.data.sectionTitle, 'Produkt')}
|
||||||
placeholder="https://...produkt.png alebo nahrajte súbor"
|
placeholder="https://...produkt.png alebo nahrajte súbor"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ProductImageAdjust
|
||||||
|
product={p}
|
||||||
|
onChange={(patch) => {
|
||||||
|
const products = [...module.data.products];
|
||||||
|
products[pIdx] = { ...products[pIdx], ...patch };
|
||||||
|
updateData({ products });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-gray-500 font-semibold block mb-0.5">Platnosť akcie pre tento produkt (override)</label>
|
<label className="text-[10px] text-gray-500 font-semibold block mb-0.5">Platnosť akcie pre tento produkt (override)</label>
|
||||||
<input
|
<input
|
||||||
@@ -612,6 +801,52 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Cena s kartou (voliteľné) – premenné {cena_s_kartou} a {zlava_s_kartou} v cenovke */}
|
||||||
|
<div className="grid grid-cols-3 gap-2 p-2 rounded-lg bg-amber-50/60 border border-amber-100">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-gray-500">S kartou (€)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={p.cardPriceMain || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const products = [...module.data.products];
|
||||||
|
products[pIdx].cardPriceMain = e.target.value;
|
||||||
|
updateData({ products });
|
||||||
|
}}
|
||||||
|
placeholder="—"
|
||||||
|
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.cardPriceSub || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const products = [...module.data.products];
|
||||||
|
products[pIdx].cardPriceSub = e.target.value;
|
||||||
|
updateData({ products });
|
||||||
|
}}
|
||||||
|
placeholder="—"
|
||||||
|
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">Zľava s kartou</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={p.cardDiscount || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const products = [...module.data.products];
|
||||||
|
products[pIdx].cardDiscount = e.target.value;
|
||||||
|
updateData({ products });
|
||||||
|
}}
|
||||||
|
placeholder="—"
|
||||||
|
className="w-full text-xs border border-gray-300 rounded p-1 bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Predajne */}
|
{/* Predajne */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-gray-500 block mb-1">Dostupnosť (Piktogramy)</label>
|
<label className="text-[10px] text-gray-500 block mb-1">Dostupnosť (Piktogramy)</label>
|
||||||
@@ -710,6 +945,16 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Vlastná PNG cenovka (výnimka): prekryje šablónu, po odstránení sa vráti generovaná */}
|
||||||
|
<CustomBadgePngInput
|
||||||
|
value={p.customBadgePngUrl}
|
||||||
|
onChange={(url) => {
|
||||||
|
const products = [...module.data.products];
|
||||||
|
products[pIdx].customBadgePngUrl = url || undefined;
|
||||||
|
updateData({ products });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Tlačidlá aplikácie cenovky */}
|
{/* Tlačidlá aplikácie cenovky */}
|
||||||
{p.badgeCustomTemplate && (
|
{p.badgeCustomTemplate && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -717,7 +962,8 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm(`Naozaj chcete aplikovať šablónu "${p.badgeCustomTemplate?.name}" na všetky produkty v tejto sekcii?`)) {
|
if (confirm(`Naozaj chcete aplikovať šablónu "${p.badgeCustomTemplate?.name}" na všetky produkty v tejto sekcii?`)) {
|
||||||
const products = (module.data.products || []).map((prod: ProductItem) => ({
|
// Produkty s vlastnou PNG cenovkou (výnimky) sa neprepisujú
|
||||||
|
const products = (module.data.products || []).map((prod: ProductItem) => prod.customBadgePngUrl ? prod : ({
|
||||||
...prod,
|
...prod,
|
||||||
badgeCustomTemplate: p.badgeCustomTemplate,
|
badgeCustomTemplate: p.badgeCustomTemplate,
|
||||||
badgeId: p.badgeCustomTemplate?.id,
|
badgeId: p.badgeCustomTemplate?.id,
|
||||||
@@ -725,8 +971,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
badgeAlign: p.badgeAlign || p.badgeCustomTemplate?.align || 'center',
|
badgeAlign: p.badgeAlign || p.badgeCustomTemplate?.align || 'center',
|
||||||
badgePaddingTop: p.badgePaddingTop ?? p.badgeCustomTemplate?.paddingTop ?? 4,
|
badgePaddingTop: p.badgePaddingTop ?? p.badgeCustomTemplate?.paddingTop ?? 4,
|
||||||
badgePaddingBottom: p.badgePaddingBottom ?? p.badgeCustomTemplate?.paddingBottom ?? 4,
|
badgePaddingBottom: p.badgePaddingBottom ?? p.badgeCustomTemplate?.paddingBottom ?? 4,
|
||||||
badgeLayout: p.badgeLayout || 'below',
|
badgeLayout: p.badgeLayout || 'below'
|
||||||
customBadgePngUrl: undefined
|
|
||||||
}));
|
}));
|
||||||
updateData({ products });
|
updateData({ products });
|
||||||
}
|
}
|
||||||
@@ -947,6 +1192,9 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
label="Obrázok banneru Vernostných zliav"
|
label="Obrázok banneru Vernostných zliav"
|
||||||
value={module.data.headerImageUrl || ''}
|
value={module.data.headerImageUrl || ''}
|
||||||
onChange={(url) => updateData({ headerImageUrl: url })}
|
onChange={(url) => updateData({ headerImageUrl: url })}
|
||||||
|
altValue={module.data.headerImageAlt}
|
||||||
|
onAltChange={(alt) => updateData({ headerImageAlt: alt })}
|
||||||
|
altPlaceholder={pickAlt(module.data.title, module.data.sectionTitle, 'Vernostné zľavy')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -964,10 +1212,15 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={module.data.globalValidity || ''}
|
value={module.data.globalValidity || ''}
|
||||||
onChange={(e) => updateData({ globalValidity: e.target.value })}
|
onChange={(e) =>
|
||||||
|
onSetGlobalValidity
|
||||||
|
? onSetGlobalValidity(e.target.value)
|
||||||
|
: updateData({ globalValidity: e.target.value })
|
||||||
|
}
|
||||||
placeholder="napr. 2. 5. – 15. 5. 2026"
|
placeholder="napr. 2. 5. – 15. 5. 2026"
|
||||||
className="w-full text-sm border border-gray-300 rounded-lg p-2"
|
className="w-full text-sm border border-gray-300 rounded-lg p-2"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-[10px] text-gray-500 mt-1">Platí pre všetky produktové sekcie aj vernostné zľavy. Produkty s vlastnou platnosťou si ju ponechajú.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Farba pozadia vernostných kariet */}
|
{/* Farba pozadia vernostných kariet */}
|
||||||
@@ -1093,11 +1346,28 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
onChange={(url) => {
|
onChange={(url) => {
|
||||||
const products = [...module.data.products];
|
const products = [...module.data.products];
|
||||||
products[pIdx].imageUrl = url;
|
products[pIdx].imageUrl = url;
|
||||||
|
products[pIdx].imageAspect = undefined; // nová fotka = nový pomer strán
|
||||||
updateData({ products });
|
updateData({ products });
|
||||||
}}
|
}}
|
||||||
|
altValue={p.imageAlt}
|
||||||
|
onAltChange={(alt) => {
|
||||||
|
const products = [...module.data.products];
|
||||||
|
products[pIdx].imageAlt = alt;
|
||||||
|
updateData({ products });
|
||||||
|
}}
|
||||||
|
altPlaceholder={pickAlt(p.title, module.data.sectionTitle, 'Produkt')}
|
||||||
placeholder="https://...produkt.png alebo nahrajte súbor"
|
placeholder="https://...produkt.png alebo nahrajte súbor"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ProductImageAdjust
|
||||||
|
product={p}
|
||||||
|
onChange={(patch) => {
|
||||||
|
const products = [...module.data.products];
|
||||||
|
products[pIdx] = { ...products[pIdx], ...patch };
|
||||||
|
updateData({ products });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-gray-500 font-semibold block mb-0.5">Platnosť akcie pre tento produkt (override)</label>
|
<label className="text-[10px] text-gray-500 font-semibold block mb-0.5">Platnosť akcie pre tento produkt (override)</label>
|
||||||
<input
|
<input
|
||||||
@@ -1311,6 +1581,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
if (val === 'default_loyalty') {
|
if (val === 'default_loyalty') {
|
||||||
products[pIdx].badgeCustomTemplate = undefined;
|
products[pIdx].badgeCustomTemplate = undefined;
|
||||||
products[pIdx].badgeId = undefined;
|
products[pIdx].badgeId = undefined;
|
||||||
|
products[pIdx].customBadgePngUrl = undefined;
|
||||||
} else {
|
} else {
|
||||||
const found = (availableBadges || []).find((b) => b.id === val);
|
const found = (availableBadges || []).find((b) => b.id === val);
|
||||||
if (found) {
|
if (found) {
|
||||||
@@ -1320,6 +1591,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
products[pIdx].badgeAlign = found.align ?? products[pIdx].badgeAlign ?? 'center';
|
products[pIdx].badgeAlign = found.align ?? products[pIdx].badgeAlign ?? 'center';
|
||||||
products[pIdx].badgePaddingTop = found.paddingTop ?? products[pIdx].badgePaddingTop ?? 4;
|
products[pIdx].badgePaddingTop = found.paddingTop ?? products[pIdx].badgePaddingTop ?? 4;
|
||||||
products[pIdx].badgePaddingBottom = found.paddingBottom ?? products[pIdx].badgePaddingBottom ?? 4;
|
products[pIdx].badgePaddingBottom = found.paddingBottom ?? products[pIdx].badgePaddingBottom ?? 4;
|
||||||
|
products[pIdx].customBadgePngUrl = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateData({ products });
|
updateData({ products });
|
||||||
@@ -1335,6 +1607,16 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Vlastná PNG cenovka (výnimka): prekryje šablónu, po odstránení sa vráti generovaná */}
|
||||||
|
<CustomBadgePngInput
|
||||||
|
value={p.customBadgePngUrl}
|
||||||
|
onChange={(url) => {
|
||||||
|
const products = [...module.data.products];
|
||||||
|
products[pIdx].customBadgePngUrl = url || undefined;
|
||||||
|
updateData({ products });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Tlačidlá aplikácie cenovky */}
|
{/* Tlačidlá aplikácie cenovky */}
|
||||||
{p.badgeCustomTemplate && (
|
{p.badgeCustomTemplate && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -1342,7 +1624,8 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm(`Naozaj chcete aplikovať šablónu "${p.badgeCustomTemplate?.name}" na všetky vernostné produkty?`)) {
|
if (confirm(`Naozaj chcete aplikovať šablónu "${p.badgeCustomTemplate?.name}" na všetky vernostné produkty?`)) {
|
||||||
const products = (module.data.products || []).map((prod: any) => ({
|
// Produkty s vlastnou PNG cenovkou (výnimky) sa neprepisujú
|
||||||
|
const products = (module.data.products || []).map((prod: any) => prod.customBadgePngUrl ? prod : ({
|
||||||
...prod,
|
...prod,
|
||||||
badgeCustomTemplate: p.badgeCustomTemplate,
|
badgeCustomTemplate: p.badgeCustomTemplate,
|
||||||
badgeId: p.badgeCustomTemplate?.id,
|
badgeId: p.badgeCustomTemplate?.id,
|
||||||
@@ -1422,7 +1705,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rýchle ovládanie veľkosti (Width), zarovnania a paddingu pre vernostnú cenovku */}
|
{/* Rýchle ovládanie veľkosti (Width), zarovnania a paddingu pre vernostnú cenovku */}
|
||||||
{p.badgeCustomTemplate && (
|
{(p.badgeCustomTemplate || p.customBadgePngUrl) && (
|
||||||
<div className="bg-gray-50 p-2.5 rounded-xl border border-gray-200 space-y-2">
|
<div className="bg-gray-50 p-2.5 rounded-xl border border-gray-200 space-y-2">
|
||||||
{/* Šírka */}
|
{/* Šírka */}
|
||||||
<div>
|
<div>
|
||||||
@@ -1585,6 +1868,11 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
onChange={(url) => updateData({
|
onChange={(url) => updateData({
|
||||||
item1: { ...module.data.item1, imageUrl: url }
|
item1: { ...module.data.item1, imageUrl: url }
|
||||||
})}
|
})}
|
||||||
|
altValue={module.data.item1?.altText}
|
||||||
|
onAltChange={(alt) => updateData({
|
||||||
|
item1: { ...module.data.item1, altText: alt }
|
||||||
|
})}
|
||||||
|
altPlaceholder={pickAlt(module.data.item1?.title, module.data.title, module.title)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1625,6 +1913,11 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
onChange={(url) => updateData({
|
onChange={(url) => updateData({
|
||||||
item2: { ...module.data.item2, imageUrl: url }
|
item2: { ...module.data.item2, imageUrl: url }
|
||||||
})}
|
})}
|
||||||
|
altValue={module.data.item2?.altText}
|
||||||
|
onAltChange={(alt) => updateData({
|
||||||
|
item2: { ...module.data.item2, altText: alt }
|
||||||
|
})}
|
||||||
|
altPlaceholder={pickAlt(module.data.item2?.title, module.data.title, module.title)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1666,6 +1959,11 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
onChange={(url) => updateData({
|
onChange={(url) => updateData({
|
||||||
item1: { ...module.data.item1, imageUrl: url }
|
item1: { ...module.data.item1, imageUrl: url }
|
||||||
})}
|
})}
|
||||||
|
altValue={module.data.item1?.altText}
|
||||||
|
onAltChange={(alt) => updateData({
|
||||||
|
item1: { ...module.data.item1, altText: alt }
|
||||||
|
})}
|
||||||
|
altPlaceholder={pickAlt(module.data.item1?.title, module.data.title, module.title)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1704,6 +2002,11 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
onChange={(url) => updateData({
|
onChange={(url) => updateData({
|
||||||
item2: { ...module.data.item2, imageUrl: url }
|
item2: { ...module.data.item2, imageUrl: url }
|
||||||
})}
|
})}
|
||||||
|
altValue={module.data.item2?.altText}
|
||||||
|
onAltChange={(alt) => updateData({
|
||||||
|
item2: { ...module.data.item2, altText: alt }
|
||||||
|
})}
|
||||||
|
altPlaceholder={pickAlt(module.data.item2?.title, module.data.title, module.title)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1713,7 +2016,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* FOOTER */}
|
{/* FOOTER */}
|
||||||
{module.type === 'footer' && (
|
{module.type === 'footer' && !module.data.useCustomHtml && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-gray-700 mb-1">Právny text (Disclaimer)</label>
|
<label className="block text-xs font-semibold text-gray-700 mb-1">Právny text (Disclaimer)</label>
|
||||||
|
|||||||
@@ -8,13 +8,20 @@ interface ImageUploadInputProps {
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (url: string) => void;
|
onChange: (url: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
// Voliteľný ručný alt text; prázdny = použije sa automatický (altPlaceholder)
|
||||||
|
altValue?: string;
|
||||||
|
onAltChange?: (alt: string) => void;
|
||||||
|
altPlaceholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ImageUploadInput: React.FC<ImageUploadInputProps> = ({
|
export const ImageUploadInput: React.FC<ImageUploadInputProps> = ({
|
||||||
label = 'Obrázok',
|
label = 'Obrázok',
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = 'https://... alebo nahrajte súbor'
|
placeholder = 'https://... alebo nahrajte súbor',
|
||||||
|
altValue,
|
||||||
|
onAltChange,
|
||||||
|
altPlaceholder
|
||||||
}) => {
|
}) => {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
@@ -107,6 +114,17 @@ export const ImageUploadInput: React.FC<ImageUploadInputProps> = ({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{onAltChange && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={altValue || ''}
|
||||||
|
onChange={(e) => onAltChange(e.target.value)}
|
||||||
|
placeholder={altPlaceholder ? `Alt text (automaticky: ${altPlaceholder})` : 'Alt text'}
|
||||||
|
title="Alt text obrázka. Ak ho nevyplníte, doplní sa automaticky."
|
||||||
|
className="w-full text-[11px] border border-gray-200 rounded-lg px-2 py-1.5 bg-white placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Check
|
Check
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { NewsletterCampaign } from '@/types/newsletter';
|
import { NewsletterCampaign } from '@/types/newsletter';
|
||||||
|
import { getBrevoCampaignUrl } from '@/lib/brevo';
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
campaign: NewsletterCampaign;
|
campaign: NewsletterCampaign;
|
||||||
@@ -43,8 +44,6 @@ export const TopBar: React.FC<TopBarProps> = ({
|
|||||||
onExportZip,
|
onExportZip,
|
||||||
onExportBrevo,
|
onExportBrevo,
|
||||||
isExportingBrevo,
|
isExportingBrevo,
|
||||||
brevoCampaignUrl,
|
|
||||||
brevoListingUrl = 'https://app.brevo.com/campaigns/listing',
|
|
||||||
onClearBrevoId,
|
onClearBrevoId,
|
||||||
onAddModule
|
onAddModule
|
||||||
}) => {
|
}) => {
|
||||||
@@ -214,11 +213,11 @@ export const TopBar: React.FC<TopBarProps> = ({
|
|||||||
<div className="flex items-center gap-1.5 bg-emerald-50 border border-emerald-200 p-1 rounded-xl">
|
<div className="flex items-center gap-1.5 bg-emerald-50 border border-emerald-200 p-1 rounded-xl">
|
||||||
{/* Odkaz na zoznam / úpravu v Brevo */}
|
{/* Odkaz na zoznam / úpravu v Brevo */}
|
||||||
<a
|
<a
|
||||||
href={brevoCampaignUrl || brevoListingUrl || 'https://app.brevo.com/campaigns/listing'}
|
href={getBrevoCampaignUrl(campaign.brevoCampaignId)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="flex items-center gap-1 text-xs font-bold text-white bg-emerald-600 hover:bg-emerald-700 px-3 py-1.2 rounded-lg transition-all shadow-xs"
|
className="flex items-center gap-1 text-xs font-bold text-white bg-emerald-600 hover:bg-emerald-700 px-3 py-1.2 rounded-lg transition-all shadow-xs"
|
||||||
title={`Kampaň #${campaign.brevoCampaignId} je v Brevo (${brevoCampaignStatus || 'draft'}). Kliknutím otvoríte zoznam kampaní v Brevo.`}
|
title={`Kampaň #${campaign.brevoCampaignId} je v Brevo (${brevoCampaignStatus || 'draft'}). Kliknutím ju otvoríte v Brevo.`}
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-3.5 h-3.5" />
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
<span>Otvoriť v Brevo</span>
|
<span>Otvoriť v Brevo</span>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Alt text obrázka: prvý neprázdny kandidát (ručne zadaný alt, názov produktu, nadpis okna...),
|
||||||
|
// bez HTML značiek (napr. <br> v nadpisoch) a nadbytočných medzier
|
||||||
|
export function pickAlt(...candidates: Array<string | null | undefined>): string {
|
||||||
|
for (const c of candidates) {
|
||||||
|
const clean = stripTags(c);
|
||||||
|
if (clean) return clean;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTags(str: string | null | undefined): string {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/<br\s*\/?>/gi, ' ')
|
||||||
|
.replace(/<[^>]*>/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
+32
-14
@@ -8,6 +8,30 @@ export interface BadgeProductData {
|
|||||||
priceSub: string;
|
priceSub: string;
|
||||||
discountText?: string;
|
discountText?: string;
|
||||||
unitPrice?: string;
|
unitPrice?: string;
|
||||||
|
cardPriceMain?: string; // cena s kartou → {cena_s_kartou}
|
||||||
|
cardPriceSub?: string;
|
||||||
|
cardDiscount?: string; // zľava s kartou → {zlava_s_kartou}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Premenné ceny s centami v hornom indexe: {cena} a {cena_s_kartou}
|
||||||
|
export const BADGE_PRICE_TOKEN_RE = /\{(cena|cena_s_kartou)\}/;
|
||||||
|
|
||||||
|
// Nahradenie jednoduchých textových premenných (bez horného indexu)
|
||||||
|
export function replaceBadgeTextVars(text: string, product: BadgeProductData): string {
|
||||||
|
return text
|
||||||
|
.replace(/{cela_suma}/g, product.priceMain || '0')
|
||||||
|
.replace(/{zlava_s_kartou}/g, (product.cardDiscount || '').replace('-', ''))
|
||||||
|
.replace(/{zlava}/g, (product.discountText || '').replace('-', ''))
|
||||||
|
.replace(/{jednotkova_cena}/g, product.unitPrice || '')
|
||||||
|
.replace(/{mena}/g, '€');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Celé eurá a centy pre danú cenovú premennú
|
||||||
|
export function badgePriceParts(token: string, product: BadgeProductData): [string, string] {
|
||||||
|
if (token === 'cena_s_kartou') {
|
||||||
|
return [product.cardPriceMain || '', product.cardPriceSub || ''];
|
||||||
|
}
|
||||||
|
return [product.priceMain || '0', product.priceSub || '00'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,10 +45,7 @@ export function renderCleanBadgeSvg(
|
|||||||
product: BadgeProductData,
|
product: BadgeProductData,
|
||||||
overrideWidth?: number
|
overrideWidth?: number
|
||||||
): string {
|
): string {
|
||||||
const pMain = product.priceMain || '0';
|
|
||||||
const pSub = product.priceSub || '00';
|
const pSub = product.priceSub || '00';
|
||||||
const discount = (product.discountText || '').replace('-', '');
|
|
||||||
const unitPrice = product.unitPrice || '';
|
|
||||||
|
|
||||||
// Získanie viewBox alebo predvolených rozmerov
|
// Získanie viewBox alebo predvolených rozmerov
|
||||||
let vb = '0 0 140 95';
|
let vb = '0 0 140 95';
|
||||||
@@ -88,23 +109,20 @@ export function renderCleanBadgeSvg(
|
|||||||
|
|
||||||
// Formátovanie textu: ak obsahuje {cena} alebo {centy}, centy vložíme do <tspan> ako horný index
|
// Formátovanie textu: ak obsahuje {cena} alebo {centy}, centy vložíme do <tspan> ako horný index
|
||||||
let contentSvg = '';
|
let contentSvg = '';
|
||||||
if (rawText.includes('{cena}')) {
|
const priceMatch = rawText.match(BADGE_PRICE_TOKEN_RE);
|
||||||
const parts = rawText.split('{cena}');
|
if (priceMatch) {
|
||||||
const before = escapeXml(parts[0] || '');
|
const [tokMain, tokSub] = badgePriceParts(priceMatch[1], product);
|
||||||
const after = escapeXml(parts[1] || '');
|
const idx = priceMatch.index || 0;
|
||||||
contentSvg = `${before}${escapeXml(pMain)}<tspan font-size="${subFontSize}px" dx="${subDx}" dy="${subDy}" font-weight="${fontWeight}">${escapeXml(pSub)}</tspan>${after ? `<tspan dy="${revertDy}">${after}</tspan>` : ''}`;
|
const before = escapeXml(replaceBadgeTextVars(rawText.slice(0, idx), product));
|
||||||
|
const after = escapeXml(replaceBadgeTextVars(rawText.slice(idx + priceMatch[0].length), product));
|
||||||
|
contentSvg = `${before}${escapeXml(tokMain)}<tspan font-size="${subFontSize}px" dx="${subDx}" dy="${subDy}" font-weight="${fontWeight}">${escapeXml(tokSub)}</tspan>${after ? `<tspan dy="${revertDy}">${after}</tspan>` : ''}`;
|
||||||
} else if (rawText.includes('{centy}')) {
|
} else if (rawText.includes('{centy}')) {
|
||||||
const parts = rawText.split('{centy}');
|
const parts = rawText.split('{centy}');
|
||||||
const before = escapeXml(parts[0] || '');
|
const before = escapeXml(parts[0] || '');
|
||||||
const after = escapeXml(parts[1] || '');
|
const after = escapeXml(parts[1] || '');
|
||||||
contentSvg = `${before}<tspan font-size="${subFontSize}px" dx="${subDx}" dy="${subDy}" font-weight="${fontWeight}">${escapeXml(pSub)}</tspan>${after ? `<tspan dy="${revertDy}">${after}</tspan>` : ''}`;
|
contentSvg = `${before}<tspan font-size="${subFontSize}px" dx="${subDx}" dy="${subDy}" font-weight="${fontWeight}">${escapeXml(pSub)}</tspan>${after ? `<tspan dy="${revertDy}">${after}</tspan>` : ''}`;
|
||||||
} else {
|
} else {
|
||||||
let replaced = rawText
|
contentSvg = escapeXml(replaceBadgeTextVars(rawText, product));
|
||||||
.replace(/{cela_suma}/g, pMain)
|
|
||||||
.replace(/{zlava}/g, discount)
|
|
||||||
.replace(/{jednotkova_cena}/g, unitPrice)
|
|
||||||
.replace(/{mena}/g, '€');
|
|
||||||
contentSvg = escapeXml(replaced);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return `<text x="${layer.x}" y="${layer.y}" text-anchor="${anchor}" dominant-baseline="central" font-family="${fontFamily}" font-size="${fontSize}px" font-weight="${fontWeight}" fill="${fill}">${contentSvg}</text>`;
|
return `<text x="${layer.x}" y="${layer.y}" text-anchor="${anchor}" dominant-baseline="central" font-family="${fontFamily}" font-size="${fontSize}px" font-weight="${fontWeight}" fill="${fill}">${contentSvg}</text>`;
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Priamy odkaz na úpravu konkrétnej kampane v Brevo
|
||||||
|
export function getBrevoCampaignUrl(campaignId: number | string): string {
|
||||||
|
return `https://app.brevo.com/marketing-campaign/edit/${encodeURIComponent(String(campaignId))}`;
|
||||||
|
}
|
||||||
+147
-41
@@ -1,6 +1,7 @@
|
|||||||
import { NewsletterCampaign, ProductItem } from '@/types/newsletter';
|
import { NewsletterCampaign, ProductItem } from '@/types/newsletter';
|
||||||
import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
|
import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
|
||||||
import { renderCleanBadgeSvg } from './badgeUtils';
|
import { renderCleanBadgeSvg } from './badgeUtils';
|
||||||
|
import { pickAlt } from './altText';
|
||||||
|
|
||||||
export function renderEmailHtml(campaign: NewsletterCampaign): string {
|
export function renderEmailHtml(campaign: NewsletterCampaign): string {
|
||||||
const renderedModules = campaign.modules.map((m) => renderModule(m, campaign)).join('\n');
|
const renderedModules = campaign.modules.map((m) => renderModule(m, campaign)).join('\n');
|
||||||
@@ -202,7 +203,21 @@ export function renderSingleModuleHtml(m: any, campaign: NewsletterCampaign): st
|
|||||||
return renderModule(m, campaign);
|
return renderModule(m, campaign);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Moduly, pri ktorých sa dá vygenerované HTML nahradiť vlastným (HTML editor)
|
||||||
|
export const CUSTOM_HTML_MODULE_TYPES = ['header', 'footer'];
|
||||||
|
|
||||||
|
// HTML vygenerované z formulára (ignoruje vlastné HTML) – predvyplnenie HTML editora
|
||||||
|
export function renderGeneratedModuleHtml(m: any): string {
|
||||||
|
// Hlavička ani pätička nepoužívajú údaje kampane, stačí prázdny objekt
|
||||||
|
return renderModule({ ...m, data: { ...m.data, useCustomHtml: false } }, {} as NewsletterCampaign).trim();
|
||||||
|
}
|
||||||
|
|
||||||
function renderModule(m: any, campaign: NewsletterCampaign): string {
|
function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||||
|
// Vlastné HTML hlavičky / pätičky má prednosť pred vygenerovaným
|
||||||
|
if (CUSTOM_HTML_MODULE_TYPES.includes(m.type) && m.data?.useCustomHtml && m.data?.customHtml) {
|
||||||
|
return m.data.customHtml;
|
||||||
|
}
|
||||||
|
|
||||||
switch (m.type) {
|
switch (m.type) {
|
||||||
case 'announcement':
|
case 'announcement':
|
||||||
if (m.data?.enabled === false) {
|
if (m.data?.enabled === false) {
|
||||||
@@ -256,7 +271,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
|||||||
<tr>
|
<tr>
|
||||||
<td width="35%" valign="middle" style="text-align: left;" class="mobile-header-cell">
|
<td width="35%" valign="middle" style="text-align: left;" class="mobile-header-cell">
|
||||||
<a href="${escapeHtml(m.data.logoLink || 'https://www.coop.sk')}" target="_blank">
|
<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">
|
<img src="${escapeHtml(m.data.logoUrl)}" width="130" alt="${escapeHtml(pickAlt(m.data.logoAlt, 'COOP Jednota'))}" style="display: inline-block; border: 0; border-radius: 8px;" class="logo-img">
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td width="65%" valign="middle" style="text-align: right;" class="mobile-header-cell">
|
<td width="65%" valign="middle" style="text-align: right;" class="mobile-header-cell">
|
||||||
@@ -276,7 +291,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a href="${escapeHtml(m.data.linkUrl || '#')}">
|
<a href="${escapeHtml(m.data.linkUrl || '#')}">
|
||||||
<img src="${escapeHtml(m.data.imageUrl)}" width="600" alt="${escapeHtml(m.data.altText || m.data.title || m.title || 'COOP Jednota')}" style="display: block; border: 0; width: 100%; max-width: 100%; border-radius: ${m.data.title || m.data.description || m.data.buttonText ? '16px 16px 0 0' : '16px'};">
|
<img src="${escapeHtml(m.data.imageUrl)}" width="600" alt="${escapeHtml(pickAlt(m.data.altText, m.data.title, m.title, 'COOP Jednota'))}" style="display: block; border: 0; width: 100%; max-width: 100%; border-radius: ${m.data.title || m.data.description || m.data.buttonText ? '16px 16px 0 0' : '16px'};">
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -322,7 +337,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
|||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td width="100%" valign="top">
|
<td width="100%" valign="top">
|
||||||
${renderExactProductCard(p, globalValidity, cardBg, cardBorder, badgeHdrBg, badgeHdrColor, badgePrcBg, badgePrcColor, badgeCustomTxt, m.data.sectionTitle)}
|
${renderExactProductCard(p, globalValidity, cardBg, cardBorder, badgeHdrBg, badgeHdrColor, badgePrcBg, badgePrcColor, badgeCustomTxt, m.data.sectionTitle, 600)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>`;
|
</table>`;
|
||||||
@@ -377,7 +392,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a href="${escapeHtml(m.data.headerLink || '#')}">
|
<a href="${escapeHtml(m.data.headerLink || '#')}">
|
||||||
<img src="${escapeHtml(m.data.headerImageUrl)}" width="600" alt="${escapeHtml(m.data.title || m.data.sectionTitle || 'Vernostné zľavy')}" style="display: block; border: 0; width: 100%; max-width: 100%; border-radius: 16px;">
|
<img src="${escapeHtml(m.data.headerImageUrl)}" width="600" alt="${escapeHtml(pickAlt(m.data.headerImageAlt, m.data.title, m.data.sectionTitle, 'Vernostné zľavy'))}" style="display: block; border: 0; width: 100%; max-width: 100%; border-radius: 16px;">
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -416,7 +431,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
|||||||
<tr height="1">
|
<tr height="1">
|
||||||
<td valign="top" style="line-height: 0; height: 1px;">
|
<td valign="top" style="line-height: 0; height: 1px;">
|
||||||
<a href="${escapeHtml(it1.linkUrl || '#')}">
|
<a href="${escapeHtml(it1.linkUrl || '#')}">
|
||||||
<img src="${escapeHtml(it1.imageUrl)}" width="300" alt="${escapeHtml(it1.title || m.data.title || m.title || 'Aktuálny leták')}" style="display: block; border: 0; width: 100%; max-width: 100%;">
|
<img src="${escapeHtml(it1.imageUrl)}" width="300" alt="${escapeHtml(pickAlt(it1.altText, it1.title, m.data.title, m.title, 'Aktuálny leták'))}" style="display: block; border: 0; width: 100%; max-width: 100%;">
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -450,7 +465,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
|||||||
<tr height="1">
|
<tr height="1">
|
||||||
<td valign="top" style="line-height: 0; height: 1px;">
|
<td valign="top" style="line-height: 0; height: 1px;">
|
||||||
<a href="${escapeHtml(it2.linkUrl || '#')}">
|
<a href="${escapeHtml(it2.linkUrl || '#')}">
|
||||||
<img src="${escapeHtml(it2.imageUrl)}" width="300" alt="${escapeHtml(it2.title || m.data.title || m.title || 'Aktuálny leták')}" style="display: block; border: 0; width: 100%; max-width: 100%;">
|
<img src="${escapeHtml(it2.imageUrl)}" width="300" alt="${escapeHtml(pickAlt(it2.altText, it2.title, m.data.title, m.title, 'Aktuálny leták'))}" style="display: block; border: 0; width: 100%; max-width: 100%;">
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -502,7 +517,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
|||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%">
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="bottom" style="padding-bottom: 10px;">
|
<td align="center" valign="bottom" style="padding-bottom: 10px;">
|
||||||
<img src="${escapeHtml(it1.badgeImgUrl)}" width="70" alt="${escapeHtml(it1.title || 'Recepty')}" style="display: block; border: 0; margin: 0 auto;">
|
<img src="${escapeHtml(it1.badgeImgUrl)}" width="70" alt="${escapeHtml(pickAlt(it1.altText, it1.title, 'Recepty'))}" style="display: block; border: 0; margin: 0 auto;">
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -531,7 +546,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
|||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%">
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="bottom" style="padding-bottom: 10px;">
|
<td align="center" valign="bottom" style="padding-bottom: 10px;">
|
||||||
<img src="${escapeHtml(it2.badgeImgUrl)}" width="70" alt="${escapeHtml(it2.title || 'Časopis Jednota')}" style="display: block; border: 0; margin: 0 auto;">
|
<img src="${escapeHtml(it2.badgeImgUrl)}" width="70" alt="${escapeHtml(pickAlt(it2.altText, it2.title, 'Časopis Jednota'))}" style="display: block; border: 0; margin: 0 auto;">
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -615,6 +630,56 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Úprava produktovej fotky (zoom / posun / orezanie)
|
||||||
|
// Upravená fotka sa vykreslí ako pozadie bunky (background-size / background-position
|
||||||
|
// fungujú aj v Gmaile, ktorý transform/position odstraňuje); čo presahuje rámik, sa oreže.
|
||||||
|
// Outlook pre Windows (mso) pozadia nevie, dostane pôvodný <img>.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function hasImageAdjust(p: ProductItem): boolean {
|
||||||
|
return (p.imageScale ?? 1) !== 1 || (p.imageOffsetX || 0) !== 0 || (p.imageOffsetY || 0) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obsah vkladaný do podmieneného komentára pre Outlook nesmie obsahovať vlastné komentáre (napr. zo SVG cenovky)
|
||||||
|
function stripHtmlComments(html: string): string {
|
||||||
|
return html.replace(/<!--[\s\S]*?-->/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Štýl pozadia: fotka so základnou šírkou baseW, vycentrovaná na (centerX, centerY) rámika, plus zoom a posun
|
||||||
|
function adjustedImageBgStyle(p: ProductItem, baseW: number, centerX: number, centerY: number): string {
|
||||||
|
const scale = p.imageScale ?? 1;
|
||||||
|
const aspect = p.imageAspect || 1;
|
||||||
|
const w = Math.round(baseW * scale);
|
||||||
|
const h = Math.round(w * aspect);
|
||||||
|
const x = Math.round(centerX - w / 2 + (p.imageOffsetX || 0));
|
||||||
|
const y = Math.round(centerY - h / 2 + (p.imageOffsetY || 0));
|
||||||
|
return `background-image: url('${escapeHtml(p.imageUrl)}'); background-repeat: no-repeat; background-size: ${w}px ${h}px; background-position: ${x}px ${y}px;`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Riadok s fotkou pod sebou (rozloženie "pod obrázkom" a vernostné karty)
|
||||||
|
function renderStackedImageRow(p: ProductItem, imgHtml: string, cellStyle: string, cardWidth: number, maxImgW: number, alt: string): string {
|
||||||
|
if (!hasImageAdjust(p)) {
|
||||||
|
return `
|
||||||
|
<tr height="160">
|
||||||
|
<td height="160" style="${cellStyle}" valign="middle">
|
||||||
|
${imgHtml}
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
const baseW = Math.min(maxImgW, cardWidth - 16);
|
||||||
|
const baseH = Math.round(baseW * (p.imageAspect || 1));
|
||||||
|
const frameH = Math.max(160, baseH + 15);
|
||||||
|
return `
|
||||||
|
<!--[if mso]>
|
||||||
|
<tr><td height="160" style="${cellStyle}" valign="middle">${imgHtml}</td></tr>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<tr height="${frameH}">
|
||||||
|
<td height="${frameH}" role="img" aria-label="${escapeHtml(alt)}" style="height: ${frameH}px; line-height: ${frameH}px; font-size: 0; ${adjustedImageBgStyle(p, baseW, cardWidth / 2, baseH / 2)}"> </td>
|
||||||
|
</tr>
|
||||||
|
<!--<![endif]-->`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderExactProductCard(
|
function renderExactProductCard(
|
||||||
p: ProductItem,
|
p: ProductItem,
|
||||||
globalValidity: string,
|
globalValidity: string,
|
||||||
@@ -625,7 +690,8 @@ function renderExactProductCard(
|
|||||||
badgePrcBg: string,
|
badgePrcBg: string,
|
||||||
badgePrcColor: string,
|
badgePrcColor: string,
|
||||||
badgeCustomTxt: string | null,
|
badgeCustomTxt: string | null,
|
||||||
sectionTitle?: string
|
sectionTitle?: string,
|
||||||
|
cardWidth: number = 294 // nominálna šírka karty v px (2 stĺpce = 294, 1 stĺpec = 600)
|
||||||
): string {
|
): string {
|
||||||
const validity = p.validityOverride || globalValidity;
|
const validity = p.validityOverride || globalValidity;
|
||||||
const effectiveCardBg = p.cardBgColor || cardBg || '#feeaea';
|
const effectiveCardBg = p.cardBgColor || cardBg || '#feeaea';
|
||||||
@@ -641,6 +707,17 @@ function renderExactProductCard(
|
|||||||
|
|
||||||
// Pomocná funkcia na render samotnej cenovky (bez obalového <td>)
|
// Pomocná funkcia na render samotnej cenovky (bez obalového <td>)
|
||||||
const renderBadgeContent = (bWidth: number, bAlign: string, padTop: number, padBottom: number, marginStyle: string) => {
|
const renderBadgeContent = (bWidth: number, bAlign: string, padTop: number, padBottom: number, marginStyle: string) => {
|
||||||
|
// Vlastná PNG cenovka (výnimka) má prednosť pred šablónou; po jej odstránení sa vráti šablóna
|
||||||
|
if (p.customBadgePngUrl) {
|
||||||
|
return `
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="${bAlign}" style="${marginStyle} width: ${bWidth}px; padding-top: ${padTop}px; padding-bottom: ${padBottom}px;">
|
||||||
|
<tr>
|
||||||
|
<td align="${bAlign}">
|
||||||
|
<img src="${escapeHtml(p.customBadgePngUrl)}" width="${bWidth}" alt="${escapeHtml(pickAlt(p.title, 'Cenovka'))}" style="display: block; width: 100%; max-width: ${bWidth}px; margin: 0 auto;">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
if (p.badgeCustomTemplate) {
|
if (p.badgeCustomTemplate) {
|
||||||
return `
|
return `
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="${bAlign}" style="${marginStyle} width: ${bWidth}px; padding-top: ${padTop}px; padding-bottom: ${padBottom}px;">
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="${bAlign}" style="${marginStyle} width: ${bWidth}px; padding-top: ${padTop}px; padding-bottom: ${padBottom}px;">
|
||||||
@@ -651,16 +728,6 @@ function renderExactProductCard(
|
|||||||
</tr>
|
</tr>
|
||||||
</table>`;
|
</table>`;
|
||||||
}
|
}
|
||||||
if (p.customBadgePngUrl) {
|
|
||||||
return `
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="${bAlign}" style="${marginStyle} width: ${bWidth}px; padding-top: ${padTop}px; padding-bottom: ${padBottom}px;">
|
|
||||||
<tr>
|
|
||||||
<td align="${bAlign}">
|
|
||||||
<img src="${escapeHtml(p.customBadgePngUrl)}" width="${bWidth}" alt="${escapeHtml(p.title || 'Cenovka')}" style="display: block; width: 100%; max-width: ${bWidth}px; margin: 0 auto;">
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>`;
|
|
||||||
}
|
|
||||||
const mainFontSize = Math.max(28, Math.round(bWidth * 0.54));
|
const mainFontSize = Math.max(28, Math.round(bWidth * 0.54));
|
||||||
const subFontSize = Math.max(14, Math.round(bWidth * 0.27));
|
const subFontSize = Math.max(14, Math.round(bWidth * 0.27));
|
||||||
return `
|
return `
|
||||||
@@ -710,15 +777,36 @@ function renderExactProductCard(
|
|||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
${(() => {
|
||||||
<td width="58%" height="160" style="padding: 8px 4px 8px 8px; text-align: center; height: 160px;" valign="middle">
|
const alt = pickAlt(p.imageAlt, p.title, sectionTitle, 'Produkt');
|
||||||
<img src="${escapeHtml(p.imageUrl)}" width="180" alt="${escapeHtml(p.title || sectionTitle || 'Produkt')}" style="display: block; width: 100%; max-width: 180px; margin: 0 auto; border-radius: 8px;">
|
const imgCell = `<td width="58%" height="160" style="padding: 8px 4px 8px 8px; text-align: center; height: 160px;" valign="middle">
|
||||||
</td>
|
<img src="${escapeHtml(p.imageUrl)}" width="180" alt="${escapeHtml(alt)}" style="display: block; width: 100%; max-width: 180px; margin: 0 auto; border-radius: 8px;">
|
||||||
<td width="42%" style="padding: 8px 8px 8px 4px; text-align: center;" valign="middle">
|
</td>`;
|
||||||
|
const badgeCell = `<td width="42%" style="padding: 8px 8px 8px 4px; text-align: center;" valign="middle">
|
||||||
${renderBadgeContent(bWidth, bAlign, padTop, padBottom, marginStyle)}
|
${renderBadgeContent(bWidth, bAlign, padTop, padBottom, marginStyle)}
|
||||||
${p.unitPrice ? `<div style="font-size: 8px; color: #666666; font-weight: 500; font-family: Arial, sans-serif; text-align: center; margin-top: 4px;">${escapeHtml(p.unitPrice)}</div>` : ''}
|
${p.unitPrice ? `<div style="font-size: 8px; color: #666666; font-weight: 500; font-family: Arial, sans-serif; text-align: center; margin-top: 4px;">${escapeHtml(p.unitPrice)}</div>` : ''}
|
||||||
|
</td>`;
|
||||||
|
if (!hasImageAdjust(p)) {
|
||||||
|
return `<tr>${imgCell}${badgeCell}</tr>`;
|
||||||
|
}
|
||||||
|
// Upravená fotka: pozadie celého riadku, cenovka je nad ňou (produkt sa dá zasunúť pod cenovku)
|
||||||
|
const baseW = Math.min(180, Math.round(cardWidth * 0.58) - 12);
|
||||||
|
const frameH = Math.max(160, Math.round(baseW * (p.imageAspect || 1)) + 16);
|
||||||
|
return `
|
||||||
|
<!--[if mso]><tr>${imgCell}${stripHtmlComments(badgeCell)}</tr><![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" height="${frameH}" role="img" aria-label="${escapeHtml(alt)}" style="height: ${frameH}px; ${adjustedImageBgStyle(p, baseW, Math.round(cardWidth * 0.29), frameH / 2)}">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="${frameH}">
|
||||||
|
<tr>
|
||||||
|
<td width="58%" height="${frameH}" style="font-size: 0; line-height: 0;"> </td>
|
||||||
|
${badgeCell}
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<!--<![endif]-->`;
|
||||||
|
})()}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2" height="54" style="padding: 0 8px 10px 8px; text-align: center; height: 54px; min-height: 54px;" valign="top">
|
<td colspan="2" height="54" style="padding: 0 8px 10px 8px; text-align: center; height: 54px; min-height: 54px;" valign="top">
|
||||||
<h3 class="force-text-dark" style="margin: 0; font-size: 15px; color: #1a1a1a; font-weight: 600; line-height: 1.25;">${escapeHtml(p.title)}</h3>
|
<h3 class="force-text-dark" style="margin: 0; font-size: 15px; color: #1a1a1a; font-weight: 600; line-height: 1.25;">${escapeHtml(p.title)}</h3>
|
||||||
@@ -740,11 +828,14 @@ function renderExactProductCard(
|
|||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr height="160">
|
${renderStackedImageRow(
|
||||||
<td height="160" style="padding: 0 8px 15px 8px; text-align: center; height: 160px;" valign="middle">
|
p,
|
||||||
<img src="${escapeHtml(p.imageUrl)}" width="220" alt="${escapeHtml(p.title || sectionTitle || 'Produkt')}" style="display: block; width: 100%; max-width: 220px; margin: 0 auto; border-radius: 8px;">
|
`<img src="${escapeHtml(p.imageUrl)}" width="220" alt="${escapeHtml(pickAlt(p.imageAlt, p.title, sectionTitle, 'Produkt'))}" style="display: block; width: 100%; max-width: 220px; margin: 0 auto; border-radius: 8px;">`,
|
||||||
</td>
|
'padding: 0 8px 15px 8px; text-align: center; height: 160px;',
|
||||||
</tr>
|
cardWidth,
|
||||||
|
220,
|
||||||
|
pickAlt(p.imageAlt, p.title, sectionTitle, 'Produkt')
|
||||||
|
)}
|
||||||
<tr>
|
<tr>
|
||||||
<td height="54" style="padding: 0 8px 10px 8px; text-align: center; height: 54px; min-height: 54px;" valign="top">
|
<td height="54" style="padding: 0 8px 10px 8px; text-align: center; height: 54px; min-height: 54px;" valign="top">
|
||||||
<h3 class="force-text-dark" style="margin: 0; font-size: 15px; color: #1a1a1a; font-weight: 600; line-height: 1.25;">${escapeHtml(p.title)}</h3>
|
<h3 class="force-text-dark" style="margin: 0; font-size: 15px; color: #1a1a1a; font-weight: 600; line-height: 1.25;">${escapeHtml(p.title)}</h3>
|
||||||
@@ -797,25 +888,35 @@ function renderExactLoyaltyCard(p: any, validity: string): string {
|
|||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
${hasImageAdjust(p)
|
||||||
|
? renderStackedImageRow(
|
||||||
|
p,
|
||||||
|
`<img src="${escapeHtml(p.imageUrl)}" width="220" alt="${escapeHtml(pickAlt(p.imageAlt, p.title, 'Produkt'))}" style="display: block; width: 100%; max-width: 220px; margin: 0 auto; border-radius: 8px;">`,
|
||||||
|
'padding: 0 8px 15px 8px; text-align: center; height: 160px;',
|
||||||
|
294,
|
||||||
|
220,
|
||||||
|
pickAlt(p.imageAlt, p.title, 'Produkt')
|
||||||
|
)
|
||||||
|
: `
|
||||||
<tr height="1">
|
<tr height="1">
|
||||||
<td style="padding: 0 8px 15px 8px; text-align: center; height: 160px;" valign="middle">
|
<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 || 'Produkt')}" style="display: block; width: 100%; max-width: 220px; margin: 0 auto; border-radius: 8px;">
|
<img src="${escapeHtml(p.imageUrl)}" width="220" alt="${escapeHtml(pickAlt(p.imageAlt, p.title, 'Produkt'))}" style="display: block; width: 100%; max-width: 220px; margin: 0 auto; border-radius: 8px;">
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>`}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 0 8px 10px 8px; text-align: center; height: 100%; min-height: 44px;" valign="top">
|
<td style="padding: 0 8px 10px 8px; text-align: center; height: 100%; min-height: 44px;" valign="top">
|
||||||
<h3 class="force-text-dark" style="margin: 0; font-size: 15px; color: #1a1a1a; font-weight: 600; line-height: 1.25;">${escapeHtml(p.title)}</h3>
|
<h3 class="force-text-dark" style="margin: 0; font-size: 15px; color: #1a1a1a; font-weight: 600; line-height: 1.25;">${escapeHtml(p.title)}</h3>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr height="1">
|
<tr height="1">
|
||||||
<td style="padding: 0 8px ${p.badgeCustomTemplate ? '4px' : '12px'} 8px; text-align: center; height: 1px;" valign="bottom">
|
<td style="padding: 0 8px ${p.badgeCustomTemplate || p.customBadgePngUrl ? '4px' : '12px'} 8px; text-align: center; height: 1px;" valign="bottom">
|
||||||
${(() => {
|
${(() => {
|
||||||
// 1. Ak má vernostný produkt vlastnú dynamickú šablónu cenovky
|
// 1. Ak má vernostný produkt vlastnú PNG cenovku (prednosť) alebo dynamickú šablónu cenovky
|
||||||
if (p.badgeCustomTemplate) {
|
if (p.customBadgePngUrl || p.badgeCustomTemplate) {
|
||||||
const bWidth = p.badgeWidth || p.badgeCustomTemplate.width || 120;
|
const bWidth = p.badgeWidth || p.badgeCustomTemplate?.width || 120;
|
||||||
const bAlign = p.badgeAlign || p.badgeCustomTemplate.align || 'center';
|
const bAlign = p.badgeAlign || p.badgeCustomTemplate?.align || 'center';
|
||||||
const padTop = p.badgePaddingTop ?? (p.badgeCustomTemplate.paddingTop ?? 0);
|
const padTop = p.badgePaddingTop ?? (p.badgeCustomTemplate?.paddingTop ?? 0);
|
||||||
const padBottom = p.badgePaddingBottom ?? (p.badgeCustomTemplate.paddingBottom ?? 0);
|
const padBottom = p.badgePaddingBottom ?? (p.badgeCustomTemplate?.paddingBottom ?? 0);
|
||||||
|
|
||||||
let marginStyle = 'margin: 0 auto;';
|
let marginStyle = 'margin: 0 auto;';
|
||||||
if (bAlign === 'left') {
|
if (bAlign === 'left') {
|
||||||
@@ -828,14 +929,19 @@ function renderExactLoyaltyCard(p: any, validity: string): string {
|
|||||||
priceMain: p.cardPriceMain || p.priceMain || '0',
|
priceMain: p.cardPriceMain || p.priceMain || '0',
|
||||||
priceSub: p.cardPriceSub || p.priceSub || '00',
|
priceSub: p.cardPriceSub || p.priceSub || '00',
|
||||||
discountText: p.cardDiscount || p.discountText || '',
|
discountText: p.cardDiscount || p.discountText || '',
|
||||||
unitPrice: p.cardUnitPrice || p.unitPrice || ''
|
unitPrice: p.cardUnitPrice || p.unitPrice || '',
|
||||||
|
cardPriceMain: p.cardPriceMain,
|
||||||
|
cardPriceSub: p.cardPriceSub,
|
||||||
|
cardDiscount: p.cardDiscount
|
||||||
};
|
};
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="${bAlign}" style="${marginStyle} width: ${bWidth}px; padding-top: ${padTop}px; padding-bottom: ${padBottom}px;">
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="${bAlign}" style="${marginStyle} width: ${bWidth}px; padding-top: ${padTop}px; padding-bottom: ${padBottom}px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="${bAlign}">
|
<td align="${bAlign}">
|
||||||
${renderCleanBadgeSvg(p.badgeCustomTemplate, loyaltyProductData, bWidth)}
|
${p.customBadgePngUrl
|
||||||
|
? `<img src="${escapeHtml(p.customBadgePngUrl)}" width="${bWidth}" alt="${escapeHtml(pickAlt(p.title, 'Cenovka'))}" style="display: block; width: 100%; max-width: ${bWidth}px; margin: 0 auto;">`
|
||||||
|
: renderCleanBadgeSvg(p.badgeCustomTemplate, loyaltyProductData, bWidth)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>`;
|
</table>`;
|
||||||
@@ -899,7 +1005,7 @@ function renderExactLoyaltyCard(p: any, validity: string): string {
|
|||||||
})()}
|
})()}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
${p.badgeCustomTemplate ? `
|
${p.badgeCustomTemplate || p.customBadgePngUrl ? `
|
||||||
<tr height="1">
|
<tr height="1">
|
||||||
<td align="center" valign="bottom" style="padding: 2px 8px 12px 8px; text-align: center; height: 1px;">
|
<td align="center" valign="bottom" style="padding: 2px 8px 12px 8px; text-align: center; height: 1px;">
|
||||||
${p.cardUnitPrice || p.unitPrice ? `<div style="font-size: 9px; color: #666666; font-weight: 500; font-family: Arial, sans-serif; text-align: center;">${escapeHtml(p.cardUnitPrice || p.unitPrice)}</div>` : '<div style="font-size: 9px; line-height: 9px; height: 9px;"> </div>'}
|
${p.cardUnitPrice || p.unitPrice ? `<div style="font-size: 9px; color: #666666; font-weight: 500; font-family: Arial, sans-serif; text-align: center;">${escapeHtml(p.cardUnitPrice || p.unitPrice)}</div>` : '<div style="font-size: 9px; line-height: 9px; height: 9px;"> </div>'}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export interface ProductItem {
|
|||||||
priceSub: string; // napr. "99" alebo "89"
|
priceSub: string; // napr. "99" alebo "89"
|
||||||
unitPrice: string; // napr. "1kg = 4,91 €"
|
unitPrice: string; // napr. "1kg = 4,91 €"
|
||||||
discountText: string; // napr. "-23%" alebo "23%"
|
discountText: string; // napr. "-23%" alebo "23%"
|
||||||
|
cardPriceMain?: string; // cena s kartou (vernostná), celé eurá
|
||||||
|
cardPriceSub?: string; // cena s kartou, centy
|
||||||
|
cardDiscount?: string; // zľava s kartou, napr. "30%"
|
||||||
badgeText: string; // napr. "AKCIA" alebo "SUPER CENA"
|
badgeText: string; // napr. "AKCIA" alebo "SUPER CENA"
|
||||||
badgeId?: string; // priradená cenovka z databázy
|
badgeId?: string; // priradená cenovka z databázy
|
||||||
badgeCustomTemplate?: BadgeTemplate; // Uložená dynamická šablóna cenovky (bez fixnej ceny)
|
badgeCustomTemplate?: BadgeTemplate; // Uložená dynamická šablóna cenovky (bez fixnej ceny)
|
||||||
@@ -40,6 +43,11 @@ export interface ProductItem {
|
|||||||
cardBgColor?: string; // HEX napr. "#feeaea" (fantasticky), "#fef0c7" (domace), "#dcb085" (grill)
|
cardBgColor?: string; // HEX napr. "#feeaea" (fantasticky), "#fef0c7" (domace), "#dcb085" (grill)
|
||||||
showSlovakFlag?: boolean;
|
showSlovakFlag?: boolean;
|
||||||
customBadgePngUrl?: string; // Voliteľná statická PNG cenovka
|
customBadgePngUrl?: string; // Voliteľná statická PNG cenovka
|
||||||
|
imageAlt?: string; // Ručný alt text fotky (inak názov produktu)
|
||||||
|
imageScale?: number; // Zoom fotky (1 = 100 %)
|
||||||
|
imageOffsetX?: number; // Posun fotky vodorovne v px (+ doprava)
|
||||||
|
imageOffsetY?: number; // Posun fotky zvisle v px (+ nadol)
|
||||||
|
imageAspect?: number; // Pomer strán fotky výška/šírka (pre výpočet pozadia)
|
||||||
badgeLayout?: 'below' | 'side'; // Poloha cenovky: 'below' = pod obrázkom (default), 'side' = vedľa obrázka
|
badgeLayout?: 'below' | 'side'; // Poloha cenovky: 'below' = pod obrázkom (default), 'side' = vedľa obrázka
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +63,7 @@ export interface AnnouncementModuleData {
|
|||||||
export interface HeaderModuleData {
|
export interface HeaderModuleData {
|
||||||
logoUrl: string;
|
logoUrl: string;
|
||||||
logoLink: string;
|
logoLink: string;
|
||||||
|
logoAlt?: string;
|
||||||
navLinks: Array<{ label: string; url: string; variant?: 'outline' | 'filled' }>;
|
navLinks: Array<{ label: string; url: string; variant?: 'outline' | 'filled' }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +108,7 @@ export interface ProductsGridModuleData {
|
|||||||
|
|
||||||
export interface LoyaltyClubModuleData {
|
export interface LoyaltyClubModuleData {
|
||||||
headerImageUrl: string;
|
headerImageUrl: string;
|
||||||
|
headerImageAlt?: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
buttonText: string;
|
buttonText: string;
|
||||||
|
|||||||
Reference in new issue
Block a user