feat: Add logo comparison modal before overwriting existing logo
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

When a company already has a logo, the fetch shows both old and new
logos side-by-side so the user can choose to keep or replace. Uses
preview file mechanism (slug_preview.webp) with confirm/cancel actions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-18 17:15:08 +01:00
parent fdb4d214ac
commit 6b07fc98de
3 changed files with 261 additions and 56 deletions

View File

@ -602,6 +602,11 @@ def api_fetch_company_logo(company_id):
"""
API: Fetch company logo from their website automatically.
Actions:
- fetch (default): Download logo, save as preview if logo exists
- confirm: Rename preview to final (after user comparison)
- cancel: Delete preview file
Accessible by users who can edit the company profile (MANAGER+) or admins.
"""
db = SessionLocal()
@ -610,27 +615,44 @@ def api_fetch_company_logo(company_id):
if not company:
return jsonify({'success': False, 'error': 'Firma nie znaleziona'}), 404
# Permission: can_edit_company (MANAGER+) or is_admin
if not current_user.can_edit_company(company.id) and not current_user.is_admin:
return jsonify({
'success': False,
'error': 'Brak uprawnień do edycji profilu firmy'
}), 403
from logo_fetch_service import LogoFetchService
service = LogoFetchService()
data = request.get_json(silent=True) or {}
action = data.get('action', 'fetch')
if action == 'confirm':
file_ext = data.get('file_ext', 'webp')
ok = service.confirm_logo(company.slug, file_ext)
logger.info(f"Logo confirmed for {company.name} by {current_user.email}")
return jsonify({'success': ok, 'message': 'Logo zapisane' if ok else 'Brak pliku preview'})
if action == 'cancel':
service.cancel_logo(company.slug)
return jsonify({'success': True, 'message': 'Anulowano'})
# action == 'fetch'
if not company.website:
return jsonify({
'success': False,
'error': 'Firma nie ma ustawionej strony WWW'
}), 400
from logo_fetch_service import LogoFetchService
service = LogoFetchService()
result = service.fetch_logo(company.website, company.slug)
has_logo = service.has_existing_logo(company.slug)
result = service.fetch_logo(company.website, company.slug, preview=bool(has_logo))
result['has_existing_logo'] = has_logo is not None
result['existing_logo_ext'] = has_logo
logger.info(
f"Logo fetch for company {company.id} ({company.name}): "
f"success={result['success']}, source={result.get('source')}, "
f"by={current_user.email}"
f"has_existing={has_logo is not None}, by={current_user.email}"
)
return jsonify(result)

View File

@ -39,11 +39,14 @@ LOGO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'i
class LogoFetchService:
def fetch_logo(self, website_url: str, slug: str) -> dict:
def fetch_logo(self, website_url: str, slug: str, preview: bool = False) -> dict:
"""
Fetch logo from company website and save as WebP.
Returns: {'success': bool, 'message': str, 'source': str, 'steps': [...]}
Args:
preview: If True, save as {slug}_preview.{ext} instead of {slug}.{ext}
Returns: {'success': bool, 'message': str, 'source': str, 'file_ext': str, 'steps': [...]}
"""
steps = []
candidates = []
@ -98,17 +101,51 @@ class LogoFetchService:
return {'success': False, 'message': 'Błąd konwersji obrazu', 'source': None, 'steps': steps}
# Step 6: Save
saved_path = self._step_save(output_data, slug, file_ext, steps)
save_slug = f'{slug}_preview' if preview else slug
saved_path = self._step_save(output_data, save_slug, file_ext, steps)
if saved_path is None:
return {'success': False, 'message': 'Błąd zapisu pliku', 'source': None, 'steps': steps}
return {
'success': True,
'message': f'Logo pobrane z {image_source} i zapisane jako {slug}.{file_ext}',
'message': f'Logo pobrane z {image_source} i zapisane jako {save_slug}.{file_ext}',
'source': image_source,
'file_ext': file_ext,
'steps': steps
}
@staticmethod
def confirm_logo(slug: str, file_ext: str) -> bool:
"""Rename preview file to final."""
preview = os.path.join(LOGO_DIR, f'{slug}_preview.{file_ext}')
final = os.path.join(LOGO_DIR, f'{slug}.{file_ext}')
if os.path.exists(preview):
# Remove old logo in other format if exists
for ext in ('webp', 'svg'):
old = os.path.join(LOGO_DIR, f'{slug}.{ext}')
if old != final and os.path.exists(old):
os.remove(old)
os.rename(preview, final)
return True
return False
@staticmethod
def cancel_logo(slug: str) -> bool:
"""Delete preview file."""
for ext in ('webp', 'svg'):
preview = os.path.join(LOGO_DIR, f'{slug}_preview.{ext}')
if os.path.exists(preview):
os.remove(preview)
return True
@staticmethod
def has_existing_logo(slug: str) -> str | None:
"""Check if company already has a logo. Returns extension or None."""
for ext in ('webp', 'svg'):
if os.path.exists(os.path.join(LOGO_DIR, f'{slug}.{ext}')):
return ext
return None
def _step_fetch_website(self, url, steps):
"""Step 1: Fetch the website HTML."""
try:

View File

@ -362,6 +362,94 @@
.logo-step-icon.error svg { color: #ef4444; }
.logo-step-icon.missing svg { color: #6b7280; }
.logo-step-icon.skipped svg { color: #9ca3af; }
/* Logo Comparison Modal */
.logo-compare-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 2100;
align-items: center;
justify-content: center;
}
.logo-compare-overlay.active { display: flex; }
.logo-compare-content {
background: white;
border-radius: var(--radius-xl);
padding: var(--spacing-2xl);
max-width: 600px;
width: 90%;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.logo-compare-content h3 {
text-align: center;
margin: 0 0 var(--spacing-lg) 0;
font-size: var(--font-size-xl);
color: var(--text-primary);
}
.logo-compare-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-xl);
margin-bottom: var(--spacing-xl);
}
.logo-compare-item {
text-align: center;
}
.logo-compare-item .logo-compare-label {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--spacing-sm);
}
.logo-compare-item .logo-compare-img {
width: 200px;
height: 200px;
margin: 0 auto;
background: var(--background);
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 2px solid var(--border);
}
.logo-compare-item .logo-compare-img img {
max-width: 90%;
max-height: 90%;
object-fit: contain;
}
.logo-compare-actions {
display: flex;
gap: var(--spacing-md);
justify-content: center;
}
.logo-compare-actions button {
padding: var(--spacing-sm) var(--spacing-xl);
border: none;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.logo-compare-actions .btn-keep {
background: var(--background);
color: var(--text-primary);
border: 1px solid var(--border);
}
.logo-compare-actions .btn-keep:hover { background: var(--border); }
.logo-compare-actions .btn-replace {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: white;
}
.logo-compare-actions .btn-replace:hover {
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.4);
}
.logo-step-text {
font-size: var(--font-size-sm);
color: var(--text-secondary);
@ -4024,6 +4112,31 @@
</div>
</div>
<!-- Logo Comparison Modal -->
<div class="logo-compare-overlay" id="logoCompareOverlay">
<div class="logo-compare-content">
<h3>Porównanie logo</h3>
<div class="logo-compare-grid">
<div class="logo-compare-item">
<div class="logo-compare-label">Obecne logo</div>
<div class="logo-compare-img">
<img id="logoCompareOld" src="" alt="Obecne logo">
</div>
</div>
<div class="logo-compare-item">
<div class="logo-compare-label">Nowe logo (ze strony WWW)</div>
<div class="logo-compare-img">
<img id="logoCompareNew" src="" alt="Nowe logo">
</div>
</div>
</div>
<div class="logo-compare-actions">
<button class="btn-keep" id="logoKeepOld">Zachowaj obecne</button>
<button class="btn-replace" id="logoUseNew">Użyj nowego</button>
</div>
</div>
</div>
<!-- Logo Fetch Progress Overlay -->
<div class="logo-loading-overlay" id="logoLoadingOverlay">
<div class="logo-loading-content">
@ -4543,80 +4656,113 @@ function updateLogoStep(stepId, status, message) {
const logoBtn = document.getElementById('logoFetchBtn');
if (!logoBtn) return;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const companyId = logoBtn.dataset.companyId;
const slug = '{{ company.slug }}';
async function animateSteps(data) {
const stepOrder = ['fetch_website', 'meta_tags', 'scan_images', 'download', 'convert', 'save'];
const stepsMap = {};
if (data.steps) data.steps.forEach(s => { stepsMap[s.step] = s; });
for (let i = 0; i < stepOrder.length; i++) {
const sid = stepOrder[i];
const stepData = stepsMap[sid];
if (stepData) {
updateLogoStep(sid, 'in_progress', stepData.message);
await sleep(300);
updateLogoStep(sid, stepData.status, stepData.message);
} else {
updateLogoStep(sid, 'skipped', null);
}
await sleep(300);
}
}
async function sendLogoAction(action, extra) {
const body = { action, ...extra };
const resp = await fetch(`/api/company/${companyId}/fetch-logo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify(body)
});
return resp.json();
}
logoBtn.addEventListener('click', async function() {
const companyId = this.dataset.companyId;
const overlay = document.getElementById('logoLoadingOverlay');
// Reset all steps to pending
// Reset steps
['fetch_website', 'meta_tags', 'scan_images', 'download', 'convert', 'save'].forEach(s => {
updateLogoStep(s, 'pending', null);
const el = document.getElementById('logoStep_' + s);
if (el) { el.classList.remove('active', 'done'); }
if (el) el.classList.remove('active', 'done');
});
// Show overlay, disable button
overlay.classList.add('active');
this.classList.add('loading');
this.disabled = true;
// Start spinner on first step
updateLogoStep('fetch_website', 'in_progress', 'Sprawdzam stronę WWW firmy...');
try {
const response = await fetch(`/api/company/${companyId}/fetch-logo`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]')?.content || ''
}
});
const data = await response.json();
// Animate steps with delay
const stepOrder = ['fetch_website', 'meta_tags', 'scan_images', 'download', 'convert', 'save'];
const stepsMap = {};
if (data.steps) {
data.steps.forEach(s => { stepsMap[s.step] = s; });
}
for (let i = 0; i < stepOrder.length; i++) {
const sid = stepOrder[i];
const stepData = stepsMap[sid];
if (stepData) {
updateLogoStep(sid, 'in_progress', stepData.message);
await sleep(300);
updateLogoStep(sid, stepData.status, stepData.message);
} else {
// Step not in response — mark as skipped
updateLogoStep(sid, 'skipped', null);
}
await sleep(300);
}
// Pause so user can read results
await sleep(3000);
// Hide overlay
const data = await sendLogoAction('fetch');
await animateSteps(data);
await sleep(2000);
overlay.classList.remove('active');
if (data.success) {
showToast('Logo pobrane i zapisane!', 'success', 3000);
await sleep(2000);
window.location.reload();
} else {
if (!data.success) {
showToast(data.message || data.error || 'Nie udało się pobrać logo', 'error', 5000);
this.classList.remove('loading');
this.disabled = false;
return;
}
if (data.has_existing_logo) {
// Show comparison modal
const compareOverlay = document.getElementById('logoCompareOverlay');
const oldImg = document.getElementById('logoCompareOld');
const newImg = document.getElementById('logoCompareNew');
const cacheBust = '?t=' + Date.now();
oldImg.src = `/static/img/companies/${slug}.${data.existing_logo_ext}${cacheBust}`;
newImg.src = `/static/img/companies/${slug}_preview.${data.file_ext}${cacheBust}`;
compareOverlay.classList.add('active');
// Store file_ext for confirm action
compareOverlay.dataset.fileExt = data.file_ext;
} else {
// No existing logo — already saved directly
showToast('Logo pobrane i zapisane!', 'success', 3000);
await sleep(2000);
window.location.reload();
}
} catch (error) {
console.error('Logo fetch error:', error);
document.getElementById('logoLoadingOverlay').classList.remove('active');
overlay.classList.remove('active');
showToast('Błąd połączenia z serwerem', 'error', 5000);
this.classList.remove('loading');
this.disabled = false;
}
});
// Comparison modal: Keep old
document.getElementById('logoKeepOld').addEventListener('click', async function() {
await sendLogoAction('cancel');
document.getElementById('logoCompareOverlay').classList.remove('active');
showToast('Zachowano obecne logo', 'info', 3000);
logoBtn.classList.remove('loading');
logoBtn.disabled = false;
});
// Comparison modal: Use new
document.getElementById('logoUseNew').addEventListener('click', async function() {
const compareOverlay = document.getElementById('logoCompareOverlay');
const fileExt = compareOverlay.dataset.fileExt || 'webp';
await sendLogoAction('confirm', { file_ext: fileExt });
compareOverlay.classList.remove('active');
showToast('Nowe logo zapisane!', 'success', 3000);
await sleep(2000);
window.location.reload();
});
})();
</script>