feat: poradie fotiek v karte ako zoznam vrstiev (hore = vpredu)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
533a856303
commit
3ece5abf6a
3 files changed
+85
-20
No files matched your search
@@ -4,10 +4,10 @@
|
|||||||
// 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, ProductLabel, ProductPhoto } from '@/types/newsletter';
|
import { BadgeTemplate, CardProduct, ProductLabel, ProductPhoto } from '@/types/newsletter';
|
||||||
import { ChevronDown, Trash2, Pencil, Globe, Layers } from 'lucide-react';
|
import { ChevronDown, ChevronUp, 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';
|
||||||
import { DEFAULT_STORES_OFFSET_X, DEFAULT_STORES_OFFSET_Y, DEFAULT_STORES_SIZE, badgeWidthFor, productCardAlt } from '@/lib/productCard';
|
import { DEFAULT_STORES_OFFSET_X, DEFAULT_STORES_OFFSET_Y, DEFAULT_STORES_SIZE, MAIN_PHOTO, badgeWidthFor, photoStack, productCardAlt } from '@/lib/productCard';
|
||||||
|
|
||||||
export const PRODUCT_BG_PRESETS = [
|
export const PRODUCT_BG_PRESETS = [
|
||||||
{ label: 'Červená', sub: 'Fantasticky', color: '#feeaea', border: '#f5d4d4' },
|
{ label: 'Červená', sub: 'Fantasticky', color: '#feeaea', border: '#f5d4d4' },
|
||||||
@@ -105,7 +105,49 @@ const ResetLink: React.FC<{ onClick: () => void }> = ({ onClick }) => (
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ďalšia fotka v karte (bundle): obrázok, poloha, veľkosť, pred/za hlavnou fotkou
|
// Poradie fotiek ako vrstvy: navrchu zoznamu je fotka najviac vpredu
|
||||||
|
const PhotoOrderList: React.FC<{ product: CardProduct; onChange: (order: string[]) => void }> = ({ product, onChange }) => {
|
||||||
|
const stack = photoStack(product); // odzadu dopredu
|
||||||
|
const extras = product.extraPhotos || [];
|
||||||
|
const move = (id: string, dir: 1 | -1) => {
|
||||||
|
const i = stack.indexOf(id);
|
||||||
|
const j = i + dir;
|
||||||
|
if (j < 0 || j >= stack.length) return;
|
||||||
|
const next = [...stack];
|
||||||
|
[next[i], next[j]] = [next[j], next[i]];
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
const front = [...stack].reverse();
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-2 space-y-1">
|
||||||
|
<span className="text-[10px] font-bold text-gray-600">Poradie fotiek (hore = vpredu)</span>
|
||||||
|
{front.map((id, k) => {
|
||||||
|
const isMain = id === MAIN_PHOTO;
|
||||||
|
const extraIdx = extras.findIndex((f) => f.id === id);
|
||||||
|
const url = isMain ? product.imageUrl : extras[extraIdx]?.url;
|
||||||
|
return (
|
||||||
|
<div key={id} className={`flex items-center gap-2 rounded-md px-1.5 py-1 ${isMain ? 'bg-red-50' : 'bg-gray-50'}`}>
|
||||||
|
<span className="w-7 h-7 rounded bg-white border border-gray-200 overflow-hidden flex items-center justify-center shrink-0">
|
||||||
|
{url && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={url} alt="" className="w-full h-full object-contain" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 text-[11px] font-semibold text-gray-800">{isMain ? 'Hlavná fotka' : `Fotka ${extraIdx + 2}`}</span>
|
||||||
|
<button type="button" disabled={k === 0} onClick={() => move(id, 1)} className="p-0.5 text-gray-500 hover:text-gray-900 disabled:opacity-25" title="Dopredu">
|
||||||
|
<ChevronUp className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={k === front.length - 1} onClick={() => move(id, -1)} className="p-0.5 text-gray-500 hover:text-gray-900 disabled:opacity-25" title="Dozadu">
|
||||||
|
<ChevronDown className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ďalšia fotka v karte (bundle): obrázok, poloha, veľkosť
|
||||||
const ExtraPhotoEditor: React.FC<{
|
const ExtraPhotoEditor: React.FC<{
|
||||||
index: number;
|
index: number;
|
||||||
photo: ProductPhoto;
|
photo: ProductPhoto;
|
||||||
@@ -123,10 +165,6 @@ const ExtraPhotoEditor: React.FC<{
|
|||||||
<Slider label="Vodorovne" value={photo.x} display={`${photo.x} %`} min={0} max={100} step={1} onChange={(v) => onChange({ x: v })} />
|
<Slider label="Vodorovne" value={photo.x} display={`${photo.x} %`} min={0} max={100} step={1} onChange={(v) => onChange({ x: v })} />
|
||||||
<Slider label="Zvisle" value={photo.y} display={`${photo.y} %`} min={0} max={100} step={1} onChange={(v) => onChange({ y: v })} />
|
<Slider label="Zvisle" value={photo.y} display={`${photo.y} %`} min={0} max={100} step={1} onChange={(v) => onChange({ y: v })} />
|
||||||
<Slider label="Veľkosť" value={photo.scale} display={`${Math.round(photo.scale * 100)} %`} min={0.2} max={2} step={0.05} onChange={(v) => onChange({ scale: v })} />
|
<Slider label="Veľkosť" value={photo.scale} display={`${Math.round(photo.scale * 100)} %`} min={0.2} max={2} step={0.05} onChange={(v) => onChange({ scale: v })} />
|
||||||
<label className="flex items-center gap-1.5 text-[10px] text-gray-600 cursor-pointer">
|
|
||||||
<input type="checkbox" checked={!!photo.behind} onChange={(e) => onChange({ behind: e.target.checked })} className="w-3 h-3 accent-red-600" />
|
|
||||||
Za hlavnou fotkou
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -355,23 +393,29 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
|||||||
index={i}
|
index={i}
|
||||||
photo={f}
|
photo={f}
|
||||||
onChange={(patch) => onChange({ extraPhotos: (p.extraPhotos || []).map((x, j) => (j === i ? { ...x, ...patch } : x)) })}
|
onChange={(patch) => onChange({ extraPhotos: (p.extraPhotos || []).map((x, j) => (j === i ? { ...x, ...patch } : x)) })}
|
||||||
onRemove={() => onChange({ extraPhotos: (p.extraPhotos || []).filter((_, j) => j !== i) })}
|
onRemove={() =>
|
||||||
|
onChange({
|
||||||
|
extraPhotos: (p.extraPhotos || []).filter((_, j) => j !== i),
|
||||||
|
photoOrder: photoStack(p).filter((id) => id !== f.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
|
const id = `ph-${Date.now()}`;
|
||||||
onChange({
|
onChange({
|
||||||
extraPhotos: [
|
extraPhotos: [...(p.extraPhotos || []), { id, url: '', x: 62, y: 52, scale: 0.75 }],
|
||||||
...(p.extraPhotos || []),
|
photoOrder: [...photoStack(p), id] // nová fotka ide dopredu
|
||||||
{ id: `ph-${Date.now()}`, url: '', x: 62, y: 52, scale: 0.75, behind: false }
|
});
|
||||||
]
|
}}
|
||||||
})
|
|
||||||
}
|
|
||||||
className="w-full text-[10px] font-bold text-gray-600 border border-dashed border-gray-300 hover:border-gray-400 hover:bg-white rounded-lg py-1.5"
|
className="w-full text-[10px] font-bold text-gray-600 border border-dashed border-gray-300 hover:border-gray-400 hover:bg-white rounded-lg py-1.5"
|
||||||
>
|
>
|
||||||
+ Pridať ďalšiu fotku (bundle)
|
+ Pridať ďalšiu fotku (bundle)
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{(p.extraPhotos || []).length > 0 && <PhotoOrderList product={p} onChange={(photoOrder) => onChange({ photoOrder })} />}
|
||||||
</Block>
|
</Block>
|
||||||
|
|
||||||
{/* CENOVKA */}
|
{/* CENOVKA */}
|
||||||
|
|||||||
+24
-4
@@ -208,7 +208,7 @@ export function cardVisualKey(p: CardProduct, ctx: CardContext): string {
|
|||||||
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 || [], p.extraPhotos || []
|
p.labels || [], p.extraPhotos || [], photoStack(p)
|
||||||
];
|
];
|
||||||
const str = JSON.stringify(relevant);
|
const str = JSON.stringify(relevant);
|
||||||
let h1 = 5381;
|
let h1 = 5381;
|
||||||
@@ -221,6 +221,20 @@ 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)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Poradie fotiek v karte odzadu dopredu ('main' = hlavná fotka). Uložené poradie sa doplní o nové fotky
|
||||||
|
// (dopredu) a očistí o zmazané; bez uloženého poradia platí staršie nastavenie „za hlavnou fotkou“.
|
||||||
|
export const MAIN_PHOTO = 'main';
|
||||||
|
export function photoStack(p: Pick<CardProduct, 'extraPhotos' | 'photoOrder'>): string[] {
|
||||||
|
const extras = p.extraPhotos || [];
|
||||||
|
const ids = new Set([MAIN_PHOTO, ...extras.map((f) => f.id)]);
|
||||||
|
if (p.photoOrder?.length) {
|
||||||
|
const order = p.photoOrder.filter((id) => ids.has(id));
|
||||||
|
for (const id of ids) if (!order.includes(id)) order.push(id);
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
return [...extras.filter((f) => f.behind).map((f) => f.id), MAIN_PHOTO, ...extras.filter((f) => !f.behind).map((f) => f.id)];
|
||||||
|
}
|
||||||
|
|
||||||
// Ďalšia fotka v karte (bundle): poloha v % vizuálu, veľkosť voči hlavnej fotke
|
// Ďalšia fotka v karte (bundle): poloha v % vizuálu, veľkosť voči hlavnej fotke
|
||||||
function renderExtraPhoto(f: ProductPhoto, mode: RenderMode): string {
|
function renderExtraPhoto(f: ProductPhoto, mode: RenderMode): string {
|
||||||
if (!f.url) return '';
|
if (!f.url) return '';
|
||||||
@@ -290,9 +304,15 @@ function renderVisualPreview(p: CardProduct, ctx: CardContext, key: string): str
|
|||||||
const insetRight = bgBox ? Math.round(bgBox.right * bw) : 0;
|
const insetRight = bgBox ? Math.round(bgBox.right * bw) : 0;
|
||||||
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.extraPhotos || []).filter((f) => f.behind).map((f) => renderExtraPhoto(f, ctx.mode)).join('')}
|
${photoStack(p)
|
||||||
${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;">` : ''}
|
.map((id) => {
|
||||||
${(p.extraPhotos || []).filter((f) => !f.behind).map((f) => renderExtraPhoto(f, ctx.mode)).join('')}
|
if (id === MAIN_PHOTO) {
|
||||||
|
return 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;">` : '';
|
||||||
|
}
|
||||||
|
const f = (p.extraPhotos || []).find((x) => x.id === id);
|
||||||
|
return f ? renderExtraPhoto(f, ctx.mode) : '';
|
||||||
|
})
|
||||||
|
.join('')}
|
||||||
${(p.labels || []).map(renderLabel).join('')}
|
${(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>` : ''}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export interface ProductItem {
|
|||||||
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 %“)
|
labels?: ProductLabel[]; // vlastné štítky na fotke (napr. „PODIEL MÄSA 92 %“)
|
||||||
extraPhotos?: ProductPhoto[]; // ďalšie fotky v karte (bundle) popri hlavnej fotke
|
extraPhotos?: ProductPhoto[]; // ďalšie fotky v karte (bundle) popri hlavnej fotke
|
||||||
|
photoOrder?: string[]; // poradie fotiek odzadu dopredu: id ďalších fotiek a 'main' (hlavná fotka)
|
||||||
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)
|
||||||
@@ -76,7 +77,7 @@ export interface ProductPhoto {
|
|||||||
x: number; // stred fotky, % šírky vizuálu (0–100)
|
x: number; // stred fotky, % šírky vizuálu (0–100)
|
||||||
y: number; // stred fotky, % výšky vizuálu (0–100)
|
y: number; // stred fotky, % výšky vizuálu (0–100)
|
||||||
scale: number; // 1 = rovnaká veľkosť ako hlavná fotka bez zoomu
|
scale: number; // 1 = rovnaká veľkosť ako hlavná fotka bez zoomu
|
||||||
behind?: boolean; // za hlavnou fotkou (inak pred ňou)
|
behind?: boolean; // (staršie) za hlavnou fotkou – nahradené poradím photoOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vlastný štítok na fotke produktu
|
// Vlastný štítok na fotke produktu
|
||||||
|
|||||||
Reference in new issue
Block a user