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
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:
parent
fdb4d214ac
commit
6b07fc98de
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user