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:
1 parent
332ab833e9
commit
293d9b953c
5 files changed
+131
-3
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user