feat: automaticke alt texty s moznostou rucneho prepisu (uloha 14)
- produkt: nazov produktu, ostatne obrazky: nadpis okna / nazov modulu - z alt textu sa odstrania HTML znacky (napr. <br> v nadpisoch) - pri kazdom obrazku pole Alt text; ak je vyplnene, ma prednost Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
23ac4dc830
commit
d43a1dae40
5 files changed
+96
-12
No files matched your search
@@ -5,6 +5,7 @@ import { NewsletterModule, ProductItem, BadgeTemplate } from '@/types/newsletter
|
||||
import { Plus, Trash2, Image as ImageIcon, Tag, Store, Globe, Palette, Check } from 'lucide-react';
|
||||
import { SUPABASE_ASSETS_BASE } from '@/lib/defaultCampaign';
|
||||
import { ImageUploadInput } from './ImageUploadInput';
|
||||
import { pickAlt } from '@/lib/altText';
|
||||
|
||||
const PRODUCT_BG_PRESETS = [
|
||||
{ label: 'Červená', sub: 'Fantasticky', color: '#feeaea', border: '#f5d4d4' },
|
||||
@@ -143,6 +144,9 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
||||
label="Logo COOP Jednota"
|
||||
value={module.data.logoUrl || ''}
|
||||
onChange={(url) => updateData({ logoUrl: url })}
|
||||
altValue={module.data.logoAlt}
|
||||
onAltChange={(alt) => updateData({ logoAlt: alt })}
|
||||
altPlaceholder="COOP Jednota"
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-700 mb-1">Odkaz loga</label>
|
||||
@@ -254,6 +258,9 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
||||
label="Obrázok bannera"
|
||||
value={module.data.imageUrl || ''}
|
||||
onChange={(url) => updateData({ imageUrl: url })}
|
||||
altValue={module.data.altText}
|
||||
onAltChange={(alt) => updateData({ altText: alt })}
|
||||
altPlaceholder={pickAlt(module.data.title, module.title, 'COOP Jednota')}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-700 mb-1">Odkaz bannera</label>
|
||||
@@ -536,6 +543,13 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
||||
products[pIdx].imageUrl = url;
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -947,6 +961,9 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
||||
label="Obrázok banneru Vernostných zliav"
|
||||
value={module.data.headerImageUrl || ''}
|
||||
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>
|
||||
@@ -1095,6 +1112,13 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
||||
products[pIdx].imageUrl = url;
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -1585,6 +1609,11 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
||||
onChange={(url) => updateData({
|
||||
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>
|
||||
@@ -1625,6 +1654,11 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
||||
onChange={(url) => updateData({
|
||||
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>
|
||||
@@ -1666,6 +1700,11 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
||||
onChange={(url) => updateData({
|
||||
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>
|
||||
@@ -1704,6 +1743,11 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
||||
onChange={(url) => updateData({
|
||||
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>
|
||||
|
||||
@@ -8,13 +8,20 @@ interface ImageUploadInputProps {
|
||||
value: string;
|
||||
onChange: (url: string) => void;
|
||||
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> = ({
|
||||
label = 'Obrázok',
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'https://... alebo nahrajte súbor'
|
||||
placeholder = 'https://... alebo nahrajte súbor',
|
||||
altValue,
|
||||
onAltChange,
|
||||
altPlaceholder
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
@@ -107,6 +114,17 @@ export const ImageUploadInput: React.FC<ImageUploadInputProps> = ({
|
||||
)}
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
+12
-11
@@ -1,6 +1,7 @@
|
||||
import { NewsletterCampaign, ProductItem } from '@/types/newsletter';
|
||||
import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
|
||||
import { renderCleanBadgeSvg } from './badgeUtils';
|
||||
import { pickAlt } from './altText';
|
||||
|
||||
export function renderEmailHtml(campaign: NewsletterCampaign): string {
|
||||
const renderedModules = campaign.modules.map((m) => renderModule(m, campaign)).join('\n');
|
||||
@@ -256,7 +257,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
<tr>
|
||||
<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">
|
||||
<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>
|
||||
</td>
|
||||
<td width="65%" valign="middle" style="text-align: right;" class="mobile-header-cell">
|
||||
@@ -276,7 +277,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
<tr>
|
||||
<td>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -377,7 +378,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
<tr>
|
||||
<td>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -416,7 +417,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
<tr height="1">
|
||||
<td valign="top" style="line-height: 0; height: 1px;">
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -450,7 +451,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
<tr height="1">
|
||||
<td valign="top" style="line-height: 0; height: 1px;">
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -502,7 +503,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%">
|
||||
<tr>
|
||||
<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>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -531,7 +532,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%">
|
||||
<tr>
|
||||
<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>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -656,7 +657,7 @@ function renderExactProductCard(
|
||||
<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;">
|
||||
<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>`;
|
||||
@@ -712,7 +713,7 @@ function renderExactProductCard(
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="58%" height="160" style="padding: 8px 4px 8px 8px; text-align: center; height: 160px;" valign="middle">
|
||||
<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;">
|
||||
<img src="${escapeHtml(p.imageUrl)}" width="180" alt="${escapeHtml(pickAlt(p.imageAlt, p.title, sectionTitle, 'Produkt'))}" style="display: block; width: 100%; max-width: 180px; margin: 0 auto; border-radius: 8px;">
|
||||
</td>
|
||||
<td width="42%" style="padding: 8px 8px 8px 4px; text-align: center;" valign="middle">
|
||||
${renderBadgeContent(bWidth, bAlign, padTop, padBottom, marginStyle)}
|
||||
@@ -742,7 +743,7 @@ function renderExactProductCard(
|
||||
</tr>
|
||||
<tr height="160">
|
||||
<td height="160" style="padding: 0 8px 15px 8px; text-align: center; height: 160px;" valign="middle">
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -799,7 +800,7 @@ function renderExactLoyaltyCard(p: any, validity: string): string {
|
||||
</tr>
|
||||
<tr height="1">
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface ProductItem {
|
||||
cardBgColor?: string; // HEX napr. "#feeaea" (fantasticky), "#fef0c7" (domace), "#dcb085" (grill)
|
||||
showSlovakFlag?: boolean;
|
||||
customBadgePngUrl?: string; // Voliteľná statická PNG cenovka
|
||||
imageAlt?: string; // Ručný alt text fotky (inak názov produktu)
|
||||
badgeLayout?: 'below' | 'side'; // Poloha cenovky: 'below' = pod obrázkom (default), 'side' = vedľa obrázka
|
||||
}
|
||||
|
||||
@@ -55,6 +56,7 @@ export interface AnnouncementModuleData {
|
||||
export interface HeaderModuleData {
|
||||
logoUrl: string;
|
||||
logoLink: string;
|
||||
logoAlt?: string;
|
||||
navLinks: Array<{ label: string; url: string; variant?: 'outline' | 'filled' }>;
|
||||
}
|
||||
|
||||
@@ -99,6 +101,7 @@ export interface ProductsGridModuleData {
|
||||
|
||||
export interface LoyaltyClubModuleData {
|
||||
headerImageUrl: string;
|
||||
headerImageAlt?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
buttonText: string;
|
||||
|
||||
Reference in new issue
Block a user