feat: klik na produkt v nahlade otvori jeho editor (harmonika)

- v paneli je otvoreny vzdy len jeden produkt, ostatne su zbalene
- klik na kartu produktu v nahlade vyberie sekciu aj produkt, panel sa
  posunie na jeho editor a karta sa v nahlade zvyrazni
- odkazy v nahlade sa klikom neotvaraju

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub_HostacnyandClaude Opus 5.5 committed 2026-10-21 10:52:09 +02:00
1 parent 109d65a690
commit c426145c48
4 files changed
+49 -12

No files matched your search

+23 -2
View File
@@ -20,6 +20,8 @@ import { ChevronUp, ChevronDown, Copy, Trash2, Anchor, Lock } from 'lucide-react
export default function Home() { export default function Home() {
const [campaign, setCampaign] = useState<NewsletterCampaign>(DEFAULT_CAMPAIGN); const [campaign, setCampaign] = useState<NewsletterCampaign>(DEFAULT_CAMPAIGN);
const [selectedModuleId, setSelectedModuleId] = useState<string | null>('mod-products-fantasticky'); const [selectedModuleId, setSelectedModuleId] = useState<string | null>('mod-products-fantasticky');
// Produkt otvorený v paneli (harmonika); nastaví sa aj klikom na produkt v náhľade
const [selectedProductId, setSelectedProductId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isExportingBrevo, setIsExportingBrevo] = useState(false); const [isExportingBrevo, setIsExportingBrevo] = useState(false);
const [brevoUrl, setBrevoUrl] = useState<string | null>(null); const [brevoUrl, setBrevoUrl] = useState<string | null>(null);
@@ -667,6 +669,10 @@ export default function Home() {
backgroundImage: 'linear-gradient(135deg, #005bb5 0%, #000d1a 100%)' backgroundImage: 'linear-gradient(135deg, #005bb5 0%, #000d1a 100%)'
}} }}
> >
{/* Zvýraznenie produktu otvoreného v paneli */}
{selectedProductId && (
<style>{`main [data-product-id="${selectedProductId.replace(/["\\]/g, '')}"] { outline: 3px solid #ef4444; outline-offset: 3px; }`}</style>
)}
{/* E-mail Container 1:1 k originálu */} {/* E-mail Container 1:1 k originálu */}
<div className="w-full max-w-[600px] my-4"> <div className="w-full max-w-[600px] my-4">
{campaign.modules.map((mod, idx) => { {campaign.modules.map((mod, idx) => {
@@ -683,7 +689,16 @@ export default function Home() {
return ( return (
<div <div
key={mod.id} key={mod.id}
onClick={() => setSelectedModuleId(mod.id)} onClick={(e) => {
// Klik na kartu produktu otvorí v paneli práve tento produkt
const card = (e.target as HTMLElement).closest('[data-product-id]');
if (card) {
setSelectedProductId(card.getAttribute('data-product-id'));
} else if (mod.id !== selectedModuleId) {
setSelectedProductId(null);
}
setSelectedModuleId(mod.id);
}}
className={`relative cursor-pointer transition-all duration-150 rounded-2xl ${ className={`relative cursor-pointer transition-all duration-150 rounded-2xl ${
isSelected isSelected
? 'ring-2 ring-offset-4 ring-offset-[#001a33] ring-red-500 shadow-2xl' ? 'ring-2 ring-offset-4 ring-offset-[#001a33] ring-red-500 shadow-2xl'
@@ -866,7 +881,11 @@ export default function Home() {
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: renderSingleModuleHtml(mod, campaign) __html: renderSingleModuleHtml(mod, campaign)
}} }}
className="pointer-events-none" onClick={(e) => {
// odkazy v náhľade neotvárame, klik slúži na výber modulu / produktu
if ((e.target as HTMLElement).closest('a')) e.preventDefault();
}}
className="[&_[data-product-id]]:cursor-pointer"
/> />
)} )}
</div> </div>
@@ -879,6 +898,8 @@ export default function Home() {
<aside className="w-96 flex-shrink-0 h-full bg-white shadow-2xl z-20"> <aside className="w-96 flex-shrink-0 h-full bg-white shadow-2xl z-20">
<ConfigPanel <ConfigPanel
module={selectedModule} module={selectedModule}
selectedProductId={selectedProductId}
onSelectProduct={setSelectedProductId}
onUpdateModule={handleUpdateModule} onUpdateModule={handleUpdateModule}
onApplyBadgeToAll={handleApplyBadgeToAll} onApplyBadgeToAll={handleApplyBadgeToAll}
onUpdateAllProductSections={handleUpdateAllProductSections} onUpdateAllProductSections={handleUpdateAllProductSections}
+10 -3
View File
@@ -21,6 +21,9 @@ interface ConfigPanelProps {
onOpenBadgeManager?: (productId: string) => void; onOpenBadgeManager?: (productId: string) => void;
onOpenImport?: () => void; onOpenImport?: () => void;
availableBadges?: BadgeTemplate[]; availableBadges?: BadgeTemplate[];
// Otvorený produkt v harmonike (klik na produkt v náhľade ho otvorí)
selectedProductId?: string | null;
onSelectProduct?: (productId: string | null) => void;
} }
// Jednoduchý HTML editor: vlastné HTML nahradí modul vygenerovaný z formulára // Jednoduchý HTML editor: vlastné HTML nahradí modul vygenerovaný z formulára
@@ -94,7 +97,9 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
campaignValidity = '', campaignValidity = '',
onOpenBadgeManager, onOpenBadgeManager,
onOpenImport, onOpenImport,
availableBadges = [] availableBadges = [],
selectedProductId = null,
onSelectProduct
}) => { }) => {
if (!module) { if (!module) {
return ( return (
@@ -660,7 +665,8 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
product={p} product={p}
index={pIdx} index={pIdx}
variant="standard" variant="standard"
defaultOpen={pIdx === 0} open={selectedProductId === p.id}
onToggle={() => onSelectProduct?.(selectedProductId === p.id ? null : p.id)}
availableBadges={availableBadges || []} availableBadges={availableBadges || []}
onChange={(patch) => updateProduct(pIdx, patch)} onChange={(patch) => updateProduct(pIdx, patch)}
onDelete={() => updateData({ products: (module.data.products || []).filter((_: ProductItem, i: number) => i !== pIdx) })} onDelete={() => updateData({ products: (module.data.products || []).filter((_: ProductItem, i: number) => i !== pIdx) })}
@@ -845,7 +851,8 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
product={p} product={p}
index={pIdx} index={pIdx}
variant="loyalty" variant="loyalty"
defaultOpen={pIdx === 0} open={selectedProductId === p.id}
onToggle={() => onSelectProduct?.(selectedProductId === p.id ? null : p.id)}
availableBadges={availableBadges || []} availableBadges={availableBadges || []}
onChange={(patch) => updateProduct(pIdx, patch)} onChange={(patch) => updateProduct(pIdx, patch)}
onOpenBadgeEditor={onOpenBadgeManager ? () => onOpenBadgeManager(p.id) : undefined} onOpenBadgeEditor={onOpenBadgeManager ? () => onOpenBadgeManager(p.id) : undefined}
+15 -6
View File
@@ -2,7 +2,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, { useState } from 'react'; import React, { useEffect, useRef } from 'react';
import { BadgeTemplate, CardProduct } from '@/types/newsletter'; import { BadgeTemplate, CardProduct } 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';
@@ -31,7 +31,8 @@ interface ProductEditorProps {
index: number; index: number;
variant: Variant; variant: Variant;
availableBadges: BadgeTemplate[]; availableBadges: BadgeTemplate[];
defaultOpen?: boolean; open: boolean; // harmonika: otvorený je vždy len jeden produkt
onToggle: () => void;
onChange: (patch: Patch) => void; onChange: (patch: Patch) => void;
onDelete?: () => void; onDelete?: () => void;
onOpenBadgeEditor?: () => void; onOpenBadgeEditor?: () => void;
@@ -111,14 +112,19 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
index, index,
variant, variant,
availableBadges, availableBadges,
defaultOpen = false, open,
onToggle,
onChange, onChange,
onDelete, onDelete,
onOpenBadgeEditor, onOpenBadgeEditor,
onApplyBadgeToSection, onApplyBadgeToSection,
onApplyBadgeToAll onApplyBadgeToAll
}) => { }) => {
const [open, setOpen] = useState(defaultOpen); // Po otvorení (aj klikom na produkt v náhľade) posunieme panel na tento produkt
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) rootRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
}, [open]);
const isLoyalty = variant === 'loyalty'; const isLoyalty = variant === 'loyalty';
const set = (key: string) => (v: unknown) => onChange({ [key]: v }); const set = (key: string) => (v: unknown) => onChange({ [key]: v });
@@ -166,10 +172,13 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
); );
return ( return (
<div className="rounded-xl border border-gray-200 bg-gray-50 overflow-hidden"> <div
ref={rootRef}
className={`rounded-xl border bg-gray-50 overflow-hidden scroll-mt-2 ${open ? 'border-red-400 ring-1 ring-red-200' : 'border-gray-200'}`}
>
{/* Hlavička produktu – klik rozbalí / zbalí */} {/* Hlavička produktu – klik rozbalí / zbalí */}
<div className="flex items-center gap-2 px-2.5 py-2 bg-white"> <div className="flex items-center gap-2 px-2.5 py-2 bg-white">
<button type="button" onClick={() => setOpen(!open)} className="flex-1 flex items-center gap-2 min-w-0 text-left"> <button type="button" onClick={onToggle} className="flex-1 flex items-center gap-2 min-w-0 text-left">
<span className="w-8 h-8 rounded-md bg-gray-100 border border-gray-200 shrink-0 overflow-hidden flex items-center justify-center"> <span className="w-8 h-8 rounded-md bg-gray-100 border border-gray-200 shrink-0 overflow-hidden flex items-center justify-center">
{p.imageUrl && ( {p.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
+1 -1
View File
@@ -302,7 +302,7 @@ export function renderProductCard(p: CardProduct, ctx: CardContext): string {
const key = cardVisualKey(p, ctx); const key = cardVisualKey(p, ctx);
const visual = ctx.mode === 'preview' ? renderVisualPreview(p, ctx, key) : renderVisualExport(p, ctx, key); const visual = ctx.mode === 'preview' ? renderVisualPreview(p, ctx, key) : renderVisualExport(p, ctx, key);
return ` return `
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%" class="glass-product ${colorClass}" bgcolor="${bg}" style="background-color: ${bg}; border-radius: 16px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15); overflow: hidden; height: 100%;"> <table ${ctx.mode === 'preview' ? `data-product-id="${escapeHtml(p.id)}" ` : ''}role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%" class="glass-product ${colorClass}" bgcolor="${bg}" style="background-color: ${bg}; border-radius: 16px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15); overflow: hidden; height: 100%;">
<tr> <tr>
<td style="padding: 0; line-height: 0; font-size: 0; border-top-left-radius: 16px; border-top-right-radius: 16px;"> <td style="padding: 0; line-height: 0; font-size: 0; border-top-left-radius: 16px; border-top-right-radius: 16px;">
${visual} ${visual}