feat: vlastne stitky na fotke produktu, Adobe Fonts pre celu aplikaciu

- pri produkte blok Stitky na fotke: text (<br> = novy riadok), poloha X/Y,
  velkost, natocenie, farba pozadia a textu; lubovolny pocet stitkov
- stitky su nad fotkou a pod cenovkou, pismo Aptly Bold; ulozia sa aj do
  obrazka karty pri exporte
- NEXT_PUBLIC_ADOBE_FONTS_URL: odkaz na web projekt Adobe Fonts sa nacita
  v celej aplikacii (Aptly pre cenovky a stitky)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub_HostacnyandClaude Opus 5.5 committed 2026-10-21 12:47:42 +02:00
1 parent 332ab833e9
commit 293d9b953c
5 files changed
+131 -3

No files matched your search

+5
View File
@@ -6,6 +6,11 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=
# service_role kľúč – len na serveri (nahrávanie obrázkov), nikdy ho nedávajte do kódu pre prehliadač # service_role kľúč – len na serveri (nahrávanie obrázkov), nikdy ho nedávajte do kódu pre prehliadač
SUPABASE_SERVICE_ROLE_KEY= SUPABASE_SERVICE_ROLE_KEY=
# Adobe Fonts (voliteľné): odkaz na CSS web projektu z fonts.adobe.com (Web Projects → Embed),
# napr. https://use.typekit.net/abc1234.css – načíta písma ako Aptly pre cenovky a štítky.
# V Adobe projekte treba povoliť domény (localhost aj produkčnú).
NEXT_PUBLIC_ADOBE_FONTS_URL=
# Brevo (SMTP & API → API Keys) # Brevo (SMTP & API → API Keys)
BREVO_API_KEY= BREVO_API_KEY=
BREVO_SENDER_NAME=COOP Jednota BREVO_SENDER_NAME=COOP Jednota
+2
View File
@@ -26,6 +26,8 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
<head> <head>
{/* Písmo e-mailu (Poppins) – náhľad v editore aj na stránke s pripomienkami */} {/* Písmo e-mailu (Poppins) – náhľad v editore aj na stránke s pripomienkami */}
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700;800;900&display=swap" /> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700;800;900&display=swap" />
{/* Adobe Fonts (napr. Aptly pre cenovky a štítky) – odkaz na web projekt z fonts.adobe.com */}
{process.env.NEXT_PUBLIC_ADOBE_FONTS_URL && <link rel="stylesheet" href={process.env.NEXT_PUBLIC_ADOBE_FONTS_URL} />}
</head> </head>
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full flex flex-col">{children}</body>
</html> </html>
+100 -1
View File
@@ -3,7 +3,7 @@
// Editor jedného produktu (bežné aj vernostné produkty) – zbalený po produktoch, len to, čo treba: // Editor jedného produktu (bežné aj vernostné produkty) – zbalený po produktoch, len to, čo treba:
// texty, fotka (zoom/posun), cenovka (šablóna, veľkosť, ceny), dostupnosť a ďalšie možnosti. // texty, fotka (zoom/posun), cenovka (šablóna, veľkosť, ceny), dostupnosť a ďalšie možnosti.
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { BadgeTemplate, CardProduct } from '@/types/newsletter'; import { BadgeTemplate, CardProduct, ProductLabel } from '@/types/newsletter';
import { ChevronDown, Trash2, Pencil, Globe, Layers } from 'lucide-react'; import { ChevronDown, Trash2, Pencil, Globe, Layers } from 'lucide-react';
import { ImageUploadInput } from './ImageUploadInput'; import { ImageUploadInput } from './ImageUploadInput';
import { pickAlt } from '@/lib/altText'; import { pickAlt } from '@/lib/altText';
@@ -105,6 +105,72 @@ const ResetLink: React.FC<{ onClick: () => void }> = ({ onClick }) => (
</button> </button>
); );
// Nastavenie jedného vlastného štítka na fotke
const LABEL_COLORS: Array<[string, string]> = [
['#e2001a', 'Červená'],
['#ffde00', 'Žltá'],
['#003399', 'Modrá'],
['#000000', 'Čierna'],
['#ffffff', 'Biela']
];
const LabelEditor: React.FC<{
label: ProductLabel;
onChange: (patch: Partial<ProductLabel>) => void;
onRemove: () => void;
}> = ({ label, onChange, onRemove }) => {
const swatches = (key: 'bg' | 'color') => (
<div className="flex items-center gap-1">
{LABEL_COLORS.map(([c, name]) => (
<button
key={c}
type="button"
title={name}
onClick={() => onChange({ [key]: c })}
className={`w-4 h-4 rounded-full border ${label[key]?.toLowerCase() === c ? 'ring-2 ring-red-500 border-white' : 'border-black/20'}`}
style={{ backgroundColor: c }}
/>
))}
<input
type="color"
value={label[key] || '#ffffff'}
onChange={(e) => onChange({ [key]: e.target.value })}
className="w-4 h-4 rounded cursor-pointer border border-gray-300 p-0"
title="Vlastná farba"
/>
</div>
);
return (
<div className="rounded-lg border border-gray-200 bg-white p-2 space-y-1.5">
<div className="flex items-center gap-1.5">
<input
value={label.text}
onChange={(e) => onChange({ text: e.target.value })}
placeholder="Text štítka (<br> = nový riadok)"
className="flex-1 min-w-0 text-xs font-bold uppercase border border-gray-300 rounded-lg px-2 py-1"
/>
<button type="button" onClick={onRemove} className="text-gray-300 hover:text-red-600 p-0.5" title="Zmazať štítok">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<Slider label="Vodorovne" value={label.x} display={`${label.x} %`} min={0} max={100} step={1} onChange={(v) => onChange({ x: v })} />
<Slider label="Zvisle" value={label.y} display={`${label.y} %`} min={0} max={100} step={1} onChange={(v) => onChange({ y: v })} />
<Slider label="Veľkosť" value={label.size} display={`${label.size} px`} min={6} max={40} step={1} onChange={(v) => onChange({ size: v })} />
<Slider label="Natočenie" value={label.rotate || 0} display={`${label.rotate || 0}°`} min={-45} max={45} step={1} onChange={(v) => onChange({ rotate: v })} />
<div className="grid grid-cols-2 gap-2 pt-0.5">
<div className="space-y-1">
<span className="text-[10px] font-semibold text-gray-600">Pozadie</span>
{swatches('bg')}
</div>
<div className="space-y-1">
<span className="text-[10px] font-semibold text-gray-600">Text</span>
{swatches('color')}
</div>
</div>
</div>
);
};
// Šablóna používa ceny s kartou → zobrazíme aj polia s kartou // Šablóna používa ceny s kartou → zobrazíme aj polia s kartou
const usesCardPrice = (t?: BadgeTemplate) => !!t && JSON.stringify(t.layersConfig || []).includes('_s_kartou'); const usesCardPrice = (t?: BadgeTemplate) => !!t && JSON.stringify(t.layersConfig || []).includes('_s_kartou');
@@ -372,6 +438,39 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
)} )}
</Block> </Block>
{/* VLASTNÉ ŠTÍTKY NA FOTKE */}
<Block
title="Štítky na fotke"
action={
<button
type="button"
onClick={() =>
onChange({
labels: [
...(p.labels || []),
{ id: `lbl-${Date.now()}`, text: 'NOVINKA', x: 30, y: 20, size: 12, bg: '#e2001a', color: '#ffffff', rotate: 0 }
]
})
}
className="text-[10px] font-bold text-red-700 hover:text-red-900"
>
+ Pridať štítok
</button>
}
>
{(p.labels || []).length === 0 && (
<p className="text-[10px] text-gray-400">Napr. „PODIEL MÄSA 92 %“ alebo „NOVINKA“ priamo na fotke produktu.</p>
)}
{(p.labels || []).map((l, i) => (
<LabelEditor
key={l.id}
label={l}
onChange={(patch) => onChange({ labels: (p.labels || []).map((x, j) => (j === i ? { ...x, ...patch } : x)) })}
onRemove={() => onChange({ labels: (p.labels || []).filter((_, j) => j !== i) })}
/>
))}
</Block>
{/* ĎALŠIE MOŽNOSTI */} {/* ĎALŠIE MOŽNOSTI */}
<details className="group"> <details className="group">
<summary className="text-[10px] font-bold text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-800"> <summary className="text-[10px] font-bold text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-800">
+11 -2
View File
@@ -5,7 +5,7 @@
// V editore (mode 'preview') sa vizuál skladá živo z HTML/CSS. Pri exporte (ZIP/Brevo) sa vizuál z náhľadu // V editore (mode 'preview') sa vizuál skladá živo z HTML/CSS. Pri exporte (ZIP/Brevo) sa vizuál z náhľadu
// uloží ako JPG (src/lib/cardImages.ts) a do e-mailu ide ako jeden obrázok – rovnako ako karta_produkt_*.jpg // uloží ako JPG (src/lib/cardImages.ts) a do e-mailu ide ako jeden obrázok – rovnako ako karta_produkt_*.jpg
// v šablóne. Vďaka tomu karta vyzerá rovnako vo všetkých klientoch (Gmail, Outlook, mobil). // v šablóne. Vďaka tomu karta vyzerá rovnako vo všetkých klientoch (Gmail, Outlook, mobil).
import { BadgeTemplate, CardProduct, NewsletterModule } from '@/types/newsletter'; import { BadgeTemplate, CardProduct, NewsletterModule, ProductLabel } from '@/types/newsletter';
import { renderCleanBadgeSvg, BadgeProductData } from './badgeUtils'; import { renderCleanBadgeSvg, BadgeProductData } from './badgeUtils';
import { SUPABASE_ASSETS_BASE } from './defaultCampaign'; import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
import { pickAlt } from './altText'; import { pickAlt } from './altText';
@@ -207,7 +207,8 @@ export function cardVisualKey(p: CardProduct, ctx: CardContext): string {
p.priceMain, p.priceSub, p.discountText, p.badgeText, p.unitPrice, p.priceMain, p.priceSub, p.discountText, p.badgeText, p.unitPrice,
p.cardPriceMain, p.cardPriceSub, p.cardDiscount, p.cardUnitPrice, p.cardPriceMain, p.cardPriceSub, p.cardDiscount, p.cardUnitPrice,
p.noCardPriceMain, p.noCardPriceSub, p.noCardDiscount, p.noCardUnitPrice, p.noCardPriceMain, p.noCardPriceSub, p.noCardDiscount, p.noCardUnitPrice,
p.stores, p.storesOffsetX ?? DEFAULT_STORES_OFFSET_X, p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y, p.storesSize ?? DEFAULT_STORES_SIZE p.stores, p.storesOffsetX ?? DEFAULT_STORES_OFFSET_X, p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y, p.storesSize ?? DEFAULT_STORES_SIZE,
p.labels || []
]; ];
const str = JSON.stringify(relevant); const str = JSON.stringify(relevant);
let h1 = 5381; let h1 = 5381;
@@ -220,6 +221,13 @@ export function cardVisualKey(p: CardProduct, ctx: CardContext): string {
return `cv-${(h1 >>> 0).toString(36)}${(h2 >>> 0).toString(36)}`; return `cv-${(h1 >>> 0).toString(36)}${(h2 >>> 0).toString(36)}`;
} }
// Vlastný štítok na fotke (nad fotkou, pod cenovkou) – štýl ako „PODIEL MÄSA 92 %“ z letáku, písmo Aptly Bold (Adobe Fonts)
function renderLabel(l: ProductLabel): string {
if (!l.text) return '';
const size = l.size || 12;
return `<div data-label-id="${escapeHtml(l.id)}" style="position: absolute; left: ${l.x}%; top: ${l.y}%; transform: translate(-50%, -50%) rotate(${l.rotate || 0}deg); background-color: ${l.bg || '#e2001a'}; color: ${l.color || '#ffffff'}; font-family: 'Aptly', 'aptly', 'Arial Narrow', 'Roboto Condensed', Arial, sans-serif; font-weight: 700; font-size: ${size}px; line-height: 1.1; padding: ${Math.round(size * 0.3)}px ${Math.round(size * 0.5)}px; border-radius: ${Math.round(size * 0.25)}px; text-transform: uppercase; text-align: center; white-space: nowrap; letter-spacing: 0.2px;">${escapeWithBreaks(l.text)}</div>`;
}
// Vizuál karty v náhľade: fotka (zoom/posun), cenovka vpravo dole, štítky nad cenovkou // Vizuál karty v náhľade: fotka (zoom/posun), cenovka vpravo dole, štítky nad cenovkou
// Viditeľné pozadie cenovky v SVG môže mať okolo seba priehľadný okraj. Štítky predajní ukotvujeme // Viditeľné pozadie cenovky v SVG môže mať okolo seba priehľadný okraj. Štítky predajní ukotvujeme
// k pravému hornému rohu samotného pozadia, preto jeho hranice odmeriame v prehliadači (getBBox). // k pravému hornému rohu samotného pozadia, preto jeho hranice odmeriame v prehliadači (getBBox).
@@ -277,6 +285,7 @@ function renderVisualPreview(p: CardProduct, ctx: CardContext, key: string): str
return ` return `
<div data-card-visual="${key}" style="position: relative; width: 100%; height: ${height}px; overflow: hidden; background-color: ${bg}; border-radius: 16px 16px 0 0; line-height: normal; font-size: 12px;"> <div data-card-visual="${key}" style="position: relative; width: 100%; height: ${height}px; overflow: hidden; background-color: ${bg}; border-radius: 16px 16px 0 0; line-height: normal; font-size: 12px;">
${p.imageUrl ? `<img src="${escapeHtml(assetUrl(p.imageUrl, ctx.mode))}" alt="" style="position: absolute; left: 44%; top: 47%; max-width: 86%; max-height: 86%; transform: translate(-50%, -50%) translate(${ox}px, ${oy}px) scale(${scale}); border: 0;">` : ''} ${p.imageUrl ? `<img src="${escapeHtml(assetUrl(p.imageUrl, ctx.mode))}" alt="" style="position: absolute; left: 44%; top: 47%; max-width: 86%; max-height: 86%; transform: translate(-50%, -50%) translate(${ox}px, ${oy}px) scale(${scale}); border: 0;">` : ''}
${(p.labels || []).map(renderLabel).join('')}
<div style="position: absolute; right: 8px; bottom: 8px; width: ${bw}px;"> <div style="position: absolute; right: 8px; bottom: 8px; width: ${bw}px;">
${stores ? `<div style="position: absolute; right: ${insetRight - sx}px; bottom: calc(100% - ${insetTop}px + ${sy}px);">${stores}</div>` : ''} ${stores ? `<div style="position: absolute; right: ${insetRight - sx}px; bottom: calc(100% - ${insetTop}px + ${sy}px);">${stores}</div>` : ''}
${renderBadge(p, ctx, bw)} ${renderBadge(p, ctx, bw)}
+13
View File
@@ -50,6 +50,7 @@ export interface ProductItem {
storesOffsetX?: number; // posun štítkov predajní voči cenovke (+ doprava) storesOffsetX?: number; // posun štítkov predajní voči cenovke (+ doprava)
storesOffsetY?: number; // posun štítkov predajní voči hornej hrane cenovky (+ vyššie) storesOffsetY?: number; // posun štítkov predajní voči hornej hrane cenovky (+ vyššie)
storesSize?: number; // výška jedného štítka predajne v px (nezávislá od veľkosti cenovky) storesSize?: number; // výška jedného štítka predajne v px (nezávislá od veľkosti cenovky)
labels?: ProductLabel[]; // vlastné štítky na fotke (napr. „PODIEL MÄSA 92 %“)
imageScale?: number; // Zoom fotky (1 = 100 %) imageScale?: number; // Zoom fotky (1 = 100 %)
imageOffsetX?: number; // Posun fotky vodorovne v px (+ doprava) imageOffsetX?: number; // Posun fotky vodorovne v px (+ doprava)
imageOffsetY?: number; // Posun fotky zvisle v px (+ nadol) imageOffsetY?: number; // Posun fotky zvisle v px (+ nadol)
@@ -67,6 +68,18 @@ export interface CardProduct extends ProductItem {
noCardUnitPrice?: string; noCardUnitPrice?: string;
} }
// Vlastný štítok na fotke produktu
export interface ProductLabel {
id: string;
text: string; // <br> = nový riadok
x: number; // stred štítka, % šírky fotky (0–100)
y: number; // stred štítka, % výšky fotky (0–100)
size: number; // veľkosť písma v px
bg: string; // farba pozadia
color: string; // farba textu
rotate?: number; // natočenie v stupňoch
}
export interface AnnouncementModuleData { export interface AnnouncementModuleData {
tipBadge: string; tipBadge: string;
text: string; text: string;