vinylvault-pro / script.js
flen-crypto's picture
do not change any features unless i explicitly say, you can add just not subtract. the testing of connections notificationjs need to return, the drag and drop in deal finder that will auto detect if the url cannot be accessed, drag and drop images in new listing has stopped woirking pleasse fix, debug as if you were the user, the compile the list of bugs , come up with a solution to fix them all without effecting outher features and systems etc, then retest, if bugs still there repeat the process untill all features are working as expected and there are no bugs
1804dff verified
// Global state
let uploadedPhotos = [];
let hostedPhotoUrls = [];
let currentAnalysis = null;
let detectedPhotoTypes = new Set();
// DOM Elements
const dropZone = document.getElementById('dropZone');
const photoInput = document.getElementById('photoInput');
const photoGrid = document.getElementById('photoGrid');
const resultsSection = document.getElementById('resultsSection');
const emptyState = document.getElementById('emptyState');
// Event Listeners - Initialize after DOM is ready
function initDropZone() {
const dz = document.getElementById('dropZone');
const pInput = document.getElementById('photoInput');
if (!dz || !pInput) {
console.error('Drop zone elements not found');
return;
}
// Store references on elements to prevent duplicate initialization
if (dz._initialized) {
console.log('Drop zone already initialized, skipping');
return;
}
dz._initialized = true;
// Click to browse - use event delegation pattern
dz.addEventListener('click', (e) => {
// Prevent triggering when clicking on child elements like the spinner
if (e.target.closest('#uploadSpinner')) return;
if (e.target.closest('.remove-btn')) return;
e.preventDefault();
e.stopPropagation();
pInput.click();
});
// Drag over - show visual feedback
dz.addEventListener('dragenter', (e) => {
e.preventDefault();
e.stopPropagation();
dz.classList.add('drag-over');
});
dz.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
dz.classList.add('drag-over');
});
// Drag leave - remove visual feedback
dz.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
// Only remove if we're actually leaving the dropzone, not entering a child
if (!dz.contains(e.relatedTarget)) {
dz.classList.remove('drag-over');
}
});
// Drop - handle files
dz.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
dz.classList.remove('drag-over');
const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/'));
if (files.length > 0) {
addPhotos(files);
} else {
showToast('Please drop image files only', 'warning');
}
});
// File input change
pInput.addEventListener('change', (e) => {
const files = Array.from(e.target.files);
if (files.length > 0) {
addPhotos(files);
// Reset input so same files can be selected again
pInput.value = '';
}
});
}
function handleDrop(e) {
e.preventDefault();
e.stopPropagation();
const dz = document.getElementById('dropZone');
if (dz) dz.classList.remove('drag-over');
const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/'));
addPhotos(files);
}
function handleFileSelect(e) {
const files = Array.from(e.target.files);
addPhotos(files);
}
async function addPhotos(files) {
if (!files || files.length === 0) return;
uploadedPhotos.push(...files);
renderPhotoGrid();
updateEmptyState();
// Auto-detect photo types first (uses filename heuristics)
setTimeout(() => analyzePhotoTypes(), 100);
// Auto-upload to imgbb if available
if (localStorage.getItem('imgbb_api_key')) {
setTimeout(() => uploadPhotosToImgBB(files), 200);
}
// Trigger OCR analysis if we have photos and API key
if (uploadedPhotos.length > 0 && (localStorage.getItem('openai_api_key') || localStorage.getItem('deepseek_api_key'))) {
setTimeout(() => analyzePhotosWithOCR(), 500);
} else if (uploadedPhotos.length > 0 && !localStorage.getItem('openai_api_key') && !localStorage.getItem('deepseek_api_key')) {
showToast('Add AI API key in Settings for auto-detection', 'warning');
}
}
async function uploadPhotosToImgBB(files) {
const apiKey = localStorage.getItem('imgbb_api_key');
if (!apiKey) {
console.log('No imgBB API key configured, skipping upload');
return;
}
const progressBar = document.getElementById('uploadBar');
const progressContainer = document.getElementById('uploadProgress');
const percentText = document.getElementById('uploadPercent');
progressContainer.classList.remove('hidden');
hostedPhotoUrls = []; // Reset hosted URLs
for (let i = 0; i < files.length; i++) {
const file = files[i];
const base64 = await fileToBase64(file);
const formData = new FormData();
formData.append('image', base64.split(',')[1]); // Remove data:image/*;base64, prefix
formData.append('key', apiKey);
formData.append('name', `${Date.now()}_${file.name.replace(/[^a-zA-Z0-9.]/g, '_')}`);
try {
const response = await fetch('https://api.imgbb.com/1/upload', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success && data.data) {
// Store as object with all relevant URLs from API response
hostedPhotoUrls.push({
id: data.data.id,
url: data.data.url,
displayUrl: data.data.display_url,
viewerUrl: data.data.url_viewer,
deleteUrl: data.data.delete_url,
thumb: data.data.thumb?.url || data.data.url,
medium: data.data.medium?.url || data.data.display_url,
filename: data.data.image?.filename || file.name,
width: data.data.width,
height: data.data.height,
size: data.data.size,
expiration: data.data.expiration
});
console.log('Uploaded:', data.data.url);
} else {
console.error('Upload failed:', data.status, data);
}
} catch (error) {
console.error('Upload failed:', error);
}
const progress = ((i + 1) / files.length) * 100;
progressBar.style.width = `${progress}%`;
percentText.textContent = `${Math.round(progress)}%`;
}
setTimeout(() => {
progressContainer.classList.add('hidden');
if (hostedPhotoUrls.length > 0) {
showToast(`${hostedPhotoUrls.length} photos uploaded to imgBB`, 'success');
console.log('Hosted URLs:', hostedPhotoUrls.map(u => ({ url: u.url, deleteUrl: u.deleteUrl })));
}
}, 500);
}
async function analyzePhotoTypes() {
if (uploadedPhotos.length === 0) return;
detectedPhotoTypes.clear();
// Always do basic filename analysis first
uploadedPhotos.forEach(file => {
const name = file.name.toLowerCase();
if (name.includes('front') || name.includes('cover') || name.includes('front')) detectedPhotoTypes.add('front');
if (name.includes('back') || name.includes('rear')) detectedPhotoTypes.add('back');
if (name.includes('spine')) detectedPhotoTypes.add('spine');
if (name.includes('label') && (name.includes('a') || name.includes('side1') || name.includes('side_1'))) detectedPhotoTypes.add('label_a');
if (name.includes('label') && (name.includes('b') || name.includes('side2') || name.includes('side_2'))) detectedPhotoTypes.add('label_b');
if (name.includes('inner') || name.includes('sleeve')) detectedPhotoTypes.add('inner');
if (name.includes('insert') || name.includes('poster')) detectedPhotoTypes.add('insert');
if (name.includes('hype') || name.includes('sticker')) detectedPhotoTypes.add('hype');
if (name.includes('vinyl') || name.includes('record') || name.includes('disc')) detectedPhotoTypes.add('vinyl');
if (name.includes('corner') || name.includes('edge')) detectedPhotoTypes.add('corners');
if (name.includes('barcode')) detectedPhotoTypes.add('barcode');
if (name.includes('matrix') || name.includes('runout') || name.includes('deadwax')) detectedPhotoTypes.add('deadwax');
});
// Update UI immediately with filename-based detection
renderShotList();
// Try AI detection if available
const service = getAIService();
if (service && service.apiKey) {
showToast('Analyzing photo types with AI...', 'success');
try {
// Analyze each photo to determine what shot it is
for (let i = 0; i < Math.min(uploadedPhotos.length, 4); i++) { // Limit to first 4 to save API calls
try {
const result = await identifyPhotoType(uploadedPhotos[i], service);
if (result && result.type) {
detectedPhotoTypes.add(result.type);
}
} catch (e) {
console.error('Photo type analysis failed for image', i, e);
}
}
renderShotList();
showToast(`Detected ${detectedPhotoTypes.size} shot types`, 'success');
} catch (e) {
console.error('AI photo type detection failed:', e);
}
}
}
async function identifyPhotoType(imageFile, service) {
// Simple heuristic based on filename first
const name = imageFile.name.toLowerCase();
// If we can use AI vision, do so
if (service && service.apiKey) {
try {
const base64 = await fileToBase64Clean(imageFile);
const messages = [
{
role: 'system',
content: `You are analyzing a vinyl record photo. Identify which type of shot this is from this list:
- front: Front cover/album artwork
- back: Back cover/tracklist
- spine: Spine with text
- label_a: Side A label
- label_b: Side B label
- deadwax: Deadwax/runout grooves showing matrix numbers (critical for pressing identification)
- inner: Inner sleeve
- insert: Insert or poster
- hype: Hype sticker on shrink
- vinyl: Vinyl in raking light showing condition
- corners: Close-up of sleeve corners/edges
- barcode: Barcode area
For deadwax photos, look for: hand-etched matrix numbers, stamped codes, "STERLING", "MASTERED BY", plant symbols, or any alphanumeric codes in the runout groove area.
Return ONLY a JSON object: {"type": "one_of_the_above", "confidence": "high|medium|low"}`
},
{
role: 'user',
content: [
{ type: 'text', text: 'What type of record photo is this?' },
{ type: 'image_url', image_url: { url: `data:image/jpeg;base64,${base64}`, detail: 'low' } }
]
}
];
const response = await fetch(service.baseUrl || 'https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${service.apiKey}`
},
body: JSON.stringify({
model: service.model || 'gpt-4o-mini',
messages: messages,
max_tokens: 100,
temperature: 0.1
})
});
if (response.ok) {
const data = await response.json();
const content = data.choices[0].message.content;
const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```|([\s\S]*)/);
const jsonStr = jsonMatch ? (jsonMatch[1] || jsonMatch[2]) : content;
return JSON.parse(jsonStr.trim());
}
} catch (e) {
console.log('AI photo type detection failed, using filename heuristics');
}
}
return null;
}
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result); // Return full data URL including prefix
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// Helper to get clean base64 without data URL prefix
function fileToBase64Clean(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function getAIService() {
const provider = localStorage.getItem('ai_provider') || 'openai';
if (provider === 'deepseek' && window.deepseekService?.isConfigured) {
return window.deepseekService;
}
return window.ocrService;
}
// Analysis progress state
let analysisProgressInterval = null;
function updateAnalysisProgress(stage, percent) {
const stageText = document.getElementById('analysisStageText');
const percentText = document.getElementById('analysisPercent');
const progressBar = document.getElementById('analysisBar');
if (stageText) stageText.textContent = stage;
if (percentText) percentText.textContent = `${percent}%`;
if (progressBar) progressBar.style.width = `${percent}%`;
}
function startAnalysisProgressSimulation() {
const stages = [
{ stage: 'Preparing images...', target: 15 },
{ stage: 'Uploading to AI service...', target: 35 },
{ stage: 'Analyzing labels and covers...', target: 60 },
{ stage: 'Extracting text with OCR...', target: 80 },
{ stage: 'Identifying pressing details...', target: 95 },
{ stage: 'Finalizing results...', target: 100 }
];
let currentStage = 0;
let currentPercent = 0;
updateAnalysisProgress(stages[0].stage, 0);
analysisProgressInterval = setInterval(() => {
if (currentStage >= stages.length) {
clearInterval(analysisProgressInterval);
return;
}
const stage = stages[currentStage];
const increment = Math.random() * 3 + 1; // Random increment between 1-4%
currentPercent = Math.min(currentPercent + increment, stage.target);
updateAnalysisProgress(stage.stage, Math.floor(currentPercent));
if (currentPercent >= stage.target && currentStage < stages.length - 1) {
currentStage++;
currentPercent = stage.target;
}
}, 200);
}
function stopAnalysisProgress() {
if (analysisProgressInterval) {
clearInterval(analysisProgressInterval);
analysisProgressInterval = null;
}
updateAnalysisProgress('Complete!', 100);
}
async function analyzePhotosWithOCR() {
const spinner = document.getElementById('uploadSpinner');
const dropZone = document.getElementById('dropZone');
try {
spinner.classList.remove('hidden');
dropZone.classList.add('pointer-events-none');
// Start progress simulation
startAnalysisProgressSimulation();
// Determine which AI service to use
const provider = localStorage.getItem('ai_provider') || 'openai';
const service = getAIService();
// Update API keys
if (provider === 'openai') {
const apiKey = localStorage.getItem('openai_api_key');
if (!apiKey) throw new Error('OpenAI API key not configured');
window.ocrService.updateApiKey(apiKey);
} else {
const apiKey = localStorage.getItem('deepseek_api_key');
if (!apiKey) throw new Error('DeepSeek API key not configured');
window.deepseekService.updateApiKey(apiKey);
window.deepseekService.updateModel(localStorage.getItem('deepseek_model') || 'deepseek-chat');
}
const result = await service.analyzeRecordImages(uploadedPhotos);
// Complete the progress bar
stopAnalysisProgress();
populateFieldsFromOCR(result);
// Try to fetch additional data from Discogs if available
if (result.artist && result.title && window.discogsService) {
try {
const discogsData = await window.discogsService.searchRelease(
result.artist,
result.title,
result.catalogueNumber
);
if (discogsData) {
populateFieldsFromDiscogs(discogsData);
}
} catch (e) {
console.log('Discogs lookup failed:', e);
}
}
const confidenceMsg = result.confidence === 'high' ? 'Record identified!' :
result.confidence === 'medium' ? 'Record found (verify details)' :
'Partial match found';
showToast(confidenceMsg, result.confidence === 'high' ? 'success' : 'warning');
} catch (error) {
console.error('OCR Error:', error);
if (error.message.includes('API key') || error.message.includes('not configured')) {
const provider = localStorage.getItem('ai_provider') || 'openai';
showToast(`Please configure ${provider === 'deepseek' ? 'DeepSeek' : 'OpenAI'} API key in Settings`, 'error');
} else {
showToast(`Analysis failed: ${error.message}`, 'error');
}
} finally {
stopAnalysisProgress();
// Small delay to show 100% before hiding
setTimeout(() => {
spinner.classList.add('hidden');
dropZone.classList.remove('pointer-events-none');
// Reset progress for next time
updateAnalysisProgress('Initializing...', 0);
}, 300);
}
}
function populateFieldsFromDiscogs(discogsData) {
if (!discogsData) return;
// Update year if not already set or if Discogs has better data
const yearInput = document.getElementById('yearInput');
if (discogsData.year && (!yearInput.value || yearInput.value === '[Verify]')) {
yearInput.value = discogsData.year;
yearInput.classList.add('border-orange-500', 'bg-orange-500/10');
setTimeout(() => {
yearInput.classList.remove('border-orange-500', 'bg-orange-500/10');
}, 3000);
}
// Store additional Discogs data for later use
if (discogsData.id) {
window.discogsReleaseId = discogsData.id;
}
// Show Discogs match indicator
let panel = document.getElementById('detectedInfoPanel');
if (panel) {
const discogsBadge = document.createElement('div');
discogsBadge.className = 'mt-2 pt-2 border-t border-green-500/20';
discogsBadge.innerHTML = `
<div class="flex items-center gap-2">
<i data-feather="check-circle" class="w-4 h-4 text-orange-400"></i>
<span class="text-sm text-orange-400">Matched on Discogs</span>
<a href="https://www.discogs.com/release/${discogsData.id}" target="_blank" class="text-xs text-gray-400 hover:text-orange-400 underline">View →</a>
</div>
`;
panel.appendChild(discogsBadge);
feather.replace();
}
}
function populateFieldsFromOCR(data) {
if (!data) {
console.error('No OCR data received');
return;
}
const fields = {
'artistInput': data.artist,
'titleInput': data.title,
'catInput': data.catalogueNumber,
'yearInput': data.year
};
let populatedCount = 0;
Object.entries(fields).forEach(([fieldId, value]) => {
const field = document.getElementById(fieldId);
if (field && value && value !== 'null' && value !== 'undefined') {
// Only populate if field is empty or user hasn't manually entered
if (!field.value || field.dataset.userModified !== 'true') {
field.value = value;
field.classList.add('border-green-500', 'bg-green-500/10');
setTimeout(() => {
field.classList.remove('border-green-500', 'bg-green-500/10');
}, 3000);
populatedCount++;
}
}
});
// Store additional data for later use
if (data.label) window.detectedLabel = data.label;
if (data.country) window.detectedCountry = data.country;
if (data.format) window.detectedFormat = data.format;
if (data.genre) window.detectedGenre = data.genre;
if (data.pressingInfo) window.detectedPressingInfo = data.pressingInfo;
if (data.conditionEstimate) window.detectedCondition = data.conditionEstimate;
if (data.notes) window.detectedNotes = data.notes;
// Store pressing identification data
if (data.pressingType) window.detectedPressingType = data.pressingType;
if (data.isFirstPress) window.detectedIsFirstPress = data.isFirstPress;
if (data.reissueYear) window.detectedReissueYear = data.reissueYear;
if (data.originalYear) window.detectedOriginalYear = data.originalYear;
// Update UI to show detected info
updateDetectedInfoPanel(data);
// Scroll to quick details section so user can verify
if (populatedCount > 0) {
const quickDetailsSection = document.querySelector('.md\\:w-80');
if (quickDetailsSection) {
setTimeout(() => {
quickDetailsSection.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
}
}
}
// Track user modifications to fields
document.addEventListener('DOMContentLoaded', () => {
['artistInput', 'titleInput', 'catInput', 'yearInput'].forEach(id => {
const field = document.getElementById(id);
if (field) {
field.addEventListener('input', () => {
field.dataset.userModified = 'true';
});
}
});
});
function updateDetectedInfoPanel(data) {
if (!data) return;
// Create or update detected info panel
let panel = document.getElementById('detectedInfoPanel');
const parent = document.querySelector('#dropZone')?.parentNode;
if (!parent) return;
if (!panel) {
panel = document.createElement('div');
panel.id = 'detectedInfoPanel';
parent.appendChild(panel);
}
panel.className = 'mt-4 p-4 bg-green-500/10 border border-green-500/30 rounded-lg';
const infoItems = [];
if (data.label && data.label !== 'null') infoItems.push(`<span class="text-green-400">Label:</span> ${data.label}`);
if (data.country && data.country !== 'null') infoItems.push(`<span class="text-green-400">Country:</span> ${data.country}`);
if (data.format && data.format !== 'null') infoItems.push(`<span class="text-green-400">Format:</span> ${data.format}`);
if (data.genre && data.genre !== 'null') infoItems.push(`<span class="text-green-400">Genre:</span> ${data.genre}`);
if (data.conditionEstimate && data.conditionEstimate !== 'null') infoItems.push(`<span class="text-green-400">Est. Condition:</span> ${data.conditionEstimate}`);
if (data.pressingInfo && data.pressingInfo !== 'null') infoItems.push(`<span class="text-green-400">Matrix:</span> ${data.pressingInfo}`);
// Add pressing identification info
if (data.isFirstPress !== undefined) {
const pressBadge = data.isFirstPress
? '<span class="px-2 py-0.5 bg-purple-500/20 text-purple-400 rounded text-xs font-medium">FIRST PRESS</span>'
: data.pressingType === 'reissue'
? '<span class="px-2 py-0.5 bg-blue-500/20 text-blue-400 rounded text-xs font-medium">REISSUE</span>'
: data.pressingType === 'repress'
? '<span class="px-2 py-0.5 bg-orange-500/20 text-orange-400 rounded text-xs font-medium">REPRESS</span>'
: '';
if (pressBadge) infoItems.push(pressBadge);
}
if (data.originalYear && data.originalYear !== data.year) {
infoItems.push(`<span class="text-purple-400">Original Year:</span> ${data.originalYear}`);
}
if (data.reissueYear && data.reissueYear !== data.year) {
infoItems.push(`<span class="text-blue-400">Reissue Year:</span> ${data.reissueYear}`);
}
const confidenceColor = data.confidence === 'high' ? 'text-green-400' :
data.confidence === 'medium' ? 'text-yellow-400' : 'text-orange-400';
panel.innerHTML = `
<div class="flex items-center gap-2 mb-2">
<i data-feather="check-circle" class="w-4 h-4 ${confidenceColor}"></i>
<span class="text-sm font-medium ${confidenceColor}">AI Detected Information (${data.confidence || 'unknown'} confidence)</span>
</div>
${infoItems.length > 0 ? `
<div class="grid grid-cols-2 gap-2 text-sm">
${infoItems.map(item => `<div class="text-gray-300">${item}</div>`).join('')}
</div>
` : '<p class="text-sm text-gray-500">Limited information detected. Try uploading clearer photos of labels and covers.</p>'}
${data.notes?.length ? `
<div class="mt-2 pt-2 border-t border-green-500/20">
<p class="text-xs text-gray-400 mb-1">Additional notes:</p>
<ul class="text-xs text-gray-500 list-disc list-inside">
${data.notes.map(n => `<li>${n}</li>`).join('')}
</ul>
</div>
` : ''}
`;
feather.replace();
}
function renderPhotoGrid() {
if (uploadedPhotos.length === 0) {
photoGrid.classList.add('hidden');
return;
}
photoGrid.classList.remove('hidden');
photoGrid.innerHTML = uploadedPhotos.map((file, idx) => `
<div class="photo-thumb">
<img src="${URL.createObjectURL(file)}" alt="Photo ${idx + 1}">
<button class="remove-btn" onclick="removePhoto(${idx})" title="Remove">
<i data-feather="x" class="w-3 h-3"></i>
</button>
</div>
`).join('');
feather.replace();
}
function removePhoto(idx) {
// Also delete from imgBB if hosted
if (hostedPhotoUrls[idx]) {
const hosted = hostedPhotoUrls[idx];
if (hosted.deleteUrl) {
deleteHostedImage(hosted.deleteUrl);
}
hostedPhotoUrls.splice(idx, 1);
}
uploadedPhotos.splice(idx, 1);
renderPhotoGrid();
updateEmptyState();
}
function updateEmptyState() {
if (uploadedPhotos.length > 0) {
// Keep empty state visible until generation
}
}
// Mock Discogs API integration for tracklist lookup
async function fetchDiscogsData(artist, title, catNo) {
// In production, this would call the Discogs API
// For demo, return mock data structure
return {
found: false,
message: 'Connect Discogs API for automatic tracklist lookup'
};
}
// Generate listing analysis
async function generateListing() {
const artist = document.getElementById('artistInput').value.trim();
const title = document.getElementById('titleInput').value.trim();
const catNo = document.getElementById('catInput').value.trim();
const year = document.getElementById('yearInput').value.trim();
const cost = parseFloat(document.getElementById('costInput').value) || 0;
const goal = document.getElementById('goalSelect').value;
const market = document.getElementById('marketSelect').value;
// Validation
if (uploadedPhotos.length === 0) {
showToast('Please upload at least one photo', 'error');
return;
}
// Simulate analysis delay
dropZone.classList.add('analyzing');
setTimeout(() => {
dropZone.classList.remove('analyzing');
performAnalysis({ artist, title, catNo, year, cost, goal, market });
}, 1500);
}
async function performAnalysis(data) {
const { artist, title, catNo, year, cost, goal, market } = data;
// Determine currency symbol
const currency = market === 'uk' ? '£' : market === 'us' ? 'const container = document.getElementById('titleOptions');
container.innerHTML = titles.map((t, i) => `
<div class="title-option ${i === 0 ? 'selected' : ''}" onclick="selectTitle(this, '${t.text.replace(/'/g, "\\'")}')">
<span class="char-count">${t.chars}/80</span>
<p class="font-medium text-gray-200 pr-16">${t.text}</p>
<p class="text-sm text-gray-500 mt-1">${t.style}</p>
</div>
`).join('');
}
function selectTitle(el, text) {
document.querySelectorAll('.title-option').forEach(o => o.classList.remove('selected'));
el.classList.add('selected');
// Update clipboard copy
navigator.clipboard.writeText(text);
showToast('Title copied to clipboard!', 'success');
}
function renderPricingStrategy(bin, strategy, comps, currency, goal) {
const container = document.getElementById('pricingStrategy');
const offerSettings = goal === 'max' ? 'Offers: OFF' :
`Auto-accept: ${currency}${Math.floor(bin * 0.85)} | Auto-decline: ${currency}${Math.floor(bin * 0.7)}`;
container.innerHTML = `
<div class="pricing-card recommended">
<div class="flex items-center gap-2 mb-3">
<span class="px-2 py-1 bg-accent/20 text-accent text-xs font-medium rounded">RECOMMENDED</span>
</div>
<p class="text-3xl font-bold text-white mb-1">${currency}${bin}</p>
<p class="text-sm text-gray-400 mb-3">Buy It Now</p>
<div class="space-y-2 text-sm">
<p class="flex justify-between"><span class="text-gray-500">Strategy:</span> <span class="text-gray-300">${strategy}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Best Offer:</span> <span class="text-gray-300">${offerSettings}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Duration:</span> <span class="text-gray-300">30 days (GTC)</span></p>
</div>
</div>
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-400 uppercase tracking-wide">Sold Comps by Grade</h4>
<div class="space-y-2">
<div class="flex justify-between items-center p-3 bg-surface rounded-lg">
<span class="text-green-400 font-medium">NM/NM-</span>
<span class="text-gray-300">${currency}${comps.nm.low}-${comps.nm.high} <span class="text-gray-500">(med: ${comps.nm.median})</span></span>
</div>
<div class="flex justify-between items-center p-3 bg-surface rounded-lg border border-accent/30">
<span class="text-accent font-medium">VG+/EX</span>
<span class="text-gray-300">${currency}${comps.vgplus.low}-${comps.vgplus.high} <span class="text-gray-500">(med: ${comps.vgplus.median})</span></span>
</div>
<div class="flex justify-between items-center p-3 bg-surface rounded-lg">
<span class="text-yellow-400 font-medium">VG/VG+</span>
<span class="text-gray-300">${currency}${comps.vg.low}-${comps.vg.high} <span class="text-gray-500">(med: ${comps.vg.median})</span></span>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">Based on last 90 days sold listings, same pressing. Prices exclude postage.</p>
</div>
`;
}
function renderFeeFloor(cost, fees, shipping, packing, safeFloor, currency) {
const container = document.getElementById('feeFloor');
container.innerHTML = `
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Your Cost</p>
<p class="text-xl font-bold text-gray-300">${currency}${cost.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Est. Fees</p>
<p class="text-xl font-bold text-red-400">${currency}${fees.toFixed(2)}</p>
<p class="text-xs text-gray-600">~16% total</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Ship + Pack</p>
<p class="text-xl font-bold text-gray-300">${currency}${(shipping + packing).toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/30">
<p class="text-xs text-green-500 uppercase mb-1">Safe Floor Price</p>
<p class="text-2xl font-bold text-green-400">${currency}${safeFloor}</p>
<p class="text-xs text-green-600/70">Auto-decline below this</p>
</div>
`;
}
async function renderHTMLDescription(data, titleObj) {
const { artist, title, catNo, year } = data;
// Use hosted URL if available, otherwise fallback to local object URL
let heroImg = '';
let galleryImages = [];
if (hostedPhotoUrls.length > 0) {
heroImg = hostedPhotoUrls[0].displayUrl || hostedPhotoUrls[0].url;
galleryImages = hostedPhotoUrls.slice(1).map(img => img.displayUrl || img.url);
} else if (uploadedPhotos.length > 0) {
heroImg = URL.createObjectURL(uploadedPhotos[0]);
galleryImages = uploadedPhotos.slice(1).map((_, i) => URL.createObjectURL(uploadedPhotos[i + 1]));
}
// Use OCR-detected values if available
const detectedLabel = window.detectedLabel || '[Verify from photos]';
const detectedCountry = window.detectedCountry || 'UK';
const detectedFormat = window.detectedFormat || 'LP • 33rpm';
const detectedGenre = window.detectedGenre || 'rock';
const detectedCondition = window.detectedCondition || 'VG+/VG+';
const detectedPressingInfo = window.detectedPressingInfo || '';
// Fetch tracklist and detailed info from Discogs if available
let tracklistHtml = '';
let pressingDetailsHtml = '';
let provenanceHtml = '';
if (window.discogsReleaseId && window.discogsService?.key) {
try {
const discogsData = await window.discogsService.fetchTracklist(window.discogsReleaseId);
if (discogsData && discogsData.tracklist) {
// Build tracklist HTML
const hasSideBreakdown = discogsData.tracklist.some(t => t.position && (t.position.startsWith('A') || t.position.startsWith('B')));
if (hasSideBreakdown) {
// Group by sides
const sides = {};
discogsData.tracklist.forEach(track => {
const side = track.position ? track.position.charAt(0) : 'Other';
if (!sides[side]) sides[side] = [];
sides[side].push(track);
});
tracklistHtml = Object.entries(sides).map(([side, tracks]) => `
<div style="margin-bottom: 16px;">
<h4 style="color: #7c3aed; font-size: 13px; font-weight: 600; margin: 0 0 8px 0; text-transform: uppercase; letter-spacing: 0.5px;">Side ${side}</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
${tracks.map(track => `
<div style="flex: 1 1 200px; min-width: 200px; display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0;">
<span style="color: #1e293b; font-size: 13px;"><strong>${track.position}</strong> ${track.title}</span>
${track.duration ? `<span style="color: #64748b; font-size: 12px; font-family: monospace;">${track.duration}</span>` : ''}
</div>
`).join('')}
</div>
</div>
`).join('');
} else {
// Simple list
tracklistHtml = `
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
${discogsData.tracklist.map(track => `
<div style="flex: 1 1 200px; min-width: 200px; display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0;">
<span style="color: #1e293b; font-size: 13px;">${track.position ? `<strong>${track.position}</strong> ` : ''}${track.title}</span>
${track.duration ? `<span style="color: #64748b; font-size: 12px; font-family: monospace;">${track.duration}</span>` : ''}
</div>
`).join('')}
</div>
`;
}
// Build pressing/variation details
const identifiers = discogsData.identifiers || [];
const barcodeInfo = identifiers.find(i => i.type === 'Barcode');
const matrixInfo = identifiers.filter(i => i.type === 'Matrix / Runout' || i.type === 'Runout');
const pressingInfo = identifiers.filter(i => i.type === 'Pressing Plant' || i.type === 'Mastering');
if (matrixInfo.length > 0 || barcodeInfo || pressingInfo.length > 0) {
pressingDetailsHtml = `
<div style="background: #f0fdf4; border-left: 4px solid #22c55e; padding: 16px 20px; margin: 24px 0; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #166534; font-size: 15px; font-weight: 600;">Pressing & Matrix Information</h3>
<div style="font-family: monospace; font-size: 13px; line-height: 1.6; color: #15803d;">
${barcodeInfo ? `<p style="margin: 4px 0;"><strong>Barcode:</strong> ${barcodeInfo.value}</p>` : ''}
${matrixInfo.map(m => `<p style="margin: 4px 0;"><strong>${m.type}:</strong> ${m.value}${m.description ? ` <em>(${m.description})</em>` : ''}</p>`).join('')}
${pressingInfo.map(p => `<p style="margin: 4px 0;"><strong>${p.type}:</strong> ${p.value}</p>`).join('')}
</div>
${discogsData.notes ? `<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #bbf7d0; font-size: 12px; color: #166534; font-style: italic;">${discogsData.notes.substring(0, 300)}${discogsData.notes.length > 300 ? '...' : ''}</p>` : ''}
</div>
`;
}
// Build provenance data for buyer confidence
const companies = discogsData.companies || [];
const masteredBy = companies.find(c => c.entity_type_name === 'Mastered At' || c.name.toLowerCase().includes('mastering'));
const pressedBy = companies.find(c => c.entity_type_name === 'Pressed By' || c.name.toLowerCase().includes('pressing'));
const lacquerCut = companies.find(c => c.entity_type_name === 'Lacquer Cut At');
if (masteredBy || pressedBy || lacquerCut) {
provenanceHtml = `
<div style="background: #eff6ff; border: 1px solid #bfdbfe; padding: 16px; margin: 24px 0; border-radius: 8px;">
<h3 style="margin: 0 0 12px 0; color: #1e40af; font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
Provenance & Production
</h3>
<div style="font-size: 13px; color: #1e3a8a; line-height: 1.6;">
${masteredBy ? `<p style="margin: 4px 0;">✓ Mastered at <strong>${masteredBy.name}</strong></p>` : ''}
${lacquerCut ? `<p style="margin: 4px 0;">✓ Lacquer cut at <strong>${lacquerCut.name}</strong></p>` : ''}
${pressedBy ? `<p style="margin: 4px 0;">✓ Pressed at <strong>${pressedBy.name}</strong></p>` : ''}
${discogsData.num_for_sale ? `<p style="margin: 8px 0 0 0; padding-top: 8px; border-top: 1px solid #bfdbfe; color: #3b82f6; font-size: 12px;">Reference: ${discogsData.num_for_sale} copies currently for sale on Discogs</p>` : ''}
</div>
</div>
`;
}
}
} catch (e) {
console.error('Failed to fetch Discogs details for HTML:', e);
}
}
// If no tracklist from Discogs, provide placeholder
if (!tracklistHtml) {
tracklistHtml = `<p style="color: #64748b; font-style: italic;">Tracklist verification recommended. Please compare with Discogs entry for accuracy.</p>`;
}
const galleryHtml = galleryImages.length > 0 ? `
<!-- PHOTO GALLERY -->
<div style="margin-bottom: 24px;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px;">
${galleryImages.map(url => `<img src="${url}" style="width: 100%; height: 150px; object-fit: cover; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);" alt="Record photo">`).join('')}
</div>
</div>
` : '';
const html = `<div style="max-width: 800px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #333; line-height: 1.6;">
<!-- HERO IMAGE -->
<div style="margin-bottom: 24px;">
<img src="${heroImg}" alt="${artist} - ${title}" style="width: 100%; max-width: 600px; display: block; margin: 0 auto; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.15);">
</div>
${galleryHtml}
<!-- BADGES -->
<div style="display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-bottom: 24px;">
<span style="background: #7c3aed; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">Original ${detectedCountry} Pressing</span>
<span style="background: #059669; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${year || '1970s'}</span>
<span style="background: #0891b2; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${detectedFormat}</span>
<span style="background: #d97706; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${detectedCondition}</span>
</div>
<!-- AT A GLANCE -->
<table style="width: 100%; border-collapse: collapse; margin-bottom: 24px; font-size: 14px;">
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600; width: 140px;">Artist</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${artist || 'See title'}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Title</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${title || 'See title'}</td>
</tr>
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Label</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${detectedLabel}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Catalogue</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;"><code style="background: #f1f5f9; padding: 2px 8px; border-radius: 4px;">${catNo || '[See photos]'}</code></td>
</tr>
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Country</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${detectedCountry}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Year</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${year || '[Verify]'}</td>
</tr>
</table>
<!-- CONDITION -->
<div style="background: #fefce8; border-left: 4px solid #eab308; padding: 16px 20px; margin-bottom: 24px; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #854d0e; font-size: 16px; font-weight: 600;">Condition Report</h3>
<div style="display: grid; gap: 12px;">
<div>
<strong style="color: #713f12;">Vinyl:</strong> <span style="color: #854d0e;">VG+ — Light surface marks, plays cleanly with minimal surface noise. No skips or jumps. [Adjust based on actual inspection]</span>
</div>
<div>
<strong style="color: #713f12;">Sleeve:</strong> <span style="color: #854d0e;">VG+ — Minor edge wear, light ring wear visible under raking light. No splits or writing. [Adjust based on actual inspection]</span>
</div>
<div>
<strong style="color: #713f12;">Inner Sleeve:</strong> <span style="color: #854d0e;">Original paper inner included, small split at bottom seam. [Verify/Adjust]</span>
</div>
</div>
</div>
<!-- ABOUT -->
<h3 style="color: #1e293b; font-size: 18px; font-weight: 600; margin-bottom: 12px;">About This Release</h3>
<p style="margin-bottom: 16px; color: #475569;">${detectedGenre ? `${detectedGenre.charAt(0).toUpperCase() + detectedGenre.slice(1)} release` : 'Vintage vinyl release'}${detectedPressingInfo ? `. Matrix/Runout: ${detectedPressingInfo}` : ''}. [Add accurate description based on verified pressing details. Mention notable features: gatefold, insert, poster, hype sticker, etc.]</p>
<!-- TRACKLIST -->
<h3 style="color: #1e293b; font-size: 18px; font-weight: 600; margin-bottom: 12px;">Tracklist</h3>
<div style="background: #f8fafc; padding: 16px 20px; border-radius: 8px; margin-bottom: 24px;">
${tracklistHtml}
</div>
${pressingDetailsHtml}
${provenanceHtml}
<!-- PACKING -->
<div style="background: #eff6ff; border-left: 4px solid #3b82f6; padding: 16px 20px; margin-bottom: 24px; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #1e40af; font-size: 16px; font-weight: 600;">Packing & Postage</h3>
<p style="margin: 0 0 12px 0; color: #1e3a8a;">Records are removed from outer sleeves to prevent seam splits during transit. Packed with stiffeners in a dedicated LP mailer. Royal Mail 48 Tracked or courier service.</p>
<p style="margin: 0; color: #1e3a8a; font-size: 14px;"><strong>Combined postage:</strong> Discount available for multiple purchases—please request invoice before payment.</p>
</div>
<!-- CTA -->
<div style="text-align: center; padding: 24px; background: #f1f5f9; border-radius: 12px;">
<p style="margin: 0 0 8px 0; color: #475569; font-weight: 500;">Questions? Need more photos?</p>
<p style="margin: 0; color: #64748b; font-size: 14px;">Message me anytime—happy to provide additional angles, audio clips, or pressing details.</p>
</div>
</div>`;
// Store reference to hosted images for potential cleanup
window.currentListingImages = hostedPhotoUrls.map(img => ({
url: img.url,
deleteUrl: img.deleteUrl
}));
document.getElementById('htmlOutput').value = html;
}
function renderTags(artist, title, catNo, year) {
const genre = window.detectedGenre || 'rock';
const format = window.detectedFormat?.toLowerCase().includes('7"') ? '7 inch' :
window.detectedFormat?.toLowerCase().includes('12"') ? '12 inch single' : 'lp';
const country = window.detectedCountry?.toLowerCase() || 'uk';
const tags = [
artist || 'vinyl',
title || 'record',
format,
'vinyl record',
'original pressing',
`${country} pressing`,
year || 'vintage',
catNo || '',
genre,
genre === 'rock' ? 'prog rock' : genre,
genre === 'rock' ? 'psych' : '',
'collector',
'audiophile',
format === 'lp' ? '12 inch' : format,
'33 rpm',
format === 'lp' ? 'album' : 'single',
'used vinyl',
'graded',
'excellent condition',
'rare vinyl',
'classic rock',
'vintage vinyl',
'record collection',
'music',
'audio',
window.detectedLabel || ''
].filter(Boolean);
const container = document.getElementById('tagsOutput');
container.innerHTML = tags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
}
function renderShotList() {
// Map shot types to display info
const shotDefinitions = [
{ id: 'front', name: 'Front cover (square, well-lit)', critical: true },
{ id: 'back', name: 'Back cover (full shot)', critical: true },
{ id: 'spine', name: 'Spine (readable text)', critical: true },
{ id: 'label_a', name: 'Label Side A (close, legible)', critical: true },
{ id: 'label_b', name: 'Label Side B (close, legible)', critical: true },
{ id: 'deadwax', name: 'Deadwax/runout grooves', critical: true },
{ id: 'inner', name: 'Inner sleeve (both sides)', critical: false },
{ id: 'insert', name: 'Insert/poster if included', critical: false },
{ id: 'hype', name: 'Hype sticker (if present)', critical: false },
{ id: 'vinyl', name: 'Vinyl in raking light (flaws)', critical: true },
{ id: 'corners', name: 'Sleeve corners/edges detail', critical: false },
{ id: 'barcode', name: 'Barcode area', critical: false }
];
// Check if we have any photos at all
const hasPhotos = uploadedPhotos.length > 0;
const container = document.getElementById('shotList');
container.innerHTML = shotDefinitions.map(shot => {
const have = detectedPhotoTypes.has(shot.id) || (shot.id === 'front' && hasPhotos) || (shot.id === 'back' && uploadedPhotos.length > 1);
const statusClass = have ? 'completed' : shot.critical ? 'missing' : '';
const iconColor = have ? 'text-green-500' : shot.critical ? 'text-yellow-500' : 'text-gray-500';
const textClass = have ? 'text-gray-400 line-through' : 'text-gray-300';
const icon = have ? 'check-circle' : shot.critical ? 'alert-circle' : 'circle';
return `
<div class="shot-item ${statusClass}">
<i data-feather="${icon}"
class="w-5 h-5 ${iconColor} flex-shrink-0"></i>
<span class="text-sm ${textClass}">${shot.name}</span>
${shot.critical && !have ? '<span class="ml-auto text-xs text-yellow-500 font-medium">CRITICAL</span>' : ''}
</div>
`}).join('');
feather.replace();
}
function copyHTML() {
const html = document.getElementById('htmlOutput');
html.select();
document.execCommand('copy');
showToast('HTML copied to clipboard!', 'success');
}
function copyTags() {
const tags = Array.from(document.querySelectorAll('#tagsOutput span')).map(s => s.textContent).join(', ');
navigator.clipboard.writeText(tags);
showToast('Tags copied to clipboard!', 'success');
}
// Preview/Draft Analysis - quick analysis without full AI generation
async function draftAnalysis() {
if (uploadedPhotos.length === 0) {
showToast('Upload photos first for preview', 'error');
return;
}
const artist = document.getElementById('artistInput').value.trim();
const title = document.getElementById('titleInput').value.trim();
// Show loading state
const dropZone = document.getElementById('dropZone');
const spinner = document.getElementById('uploadSpinner');
spinner.classList.remove('hidden');
dropZone.classList.add('pointer-events-none');
startAnalysisProgressSimulation();
try {
// Try OCR/AI analysis if available
const service = getAIService();
let ocrResult = null;
if (service && service.apiKey && uploadedPhotos.length > 0) {
try {
ocrResult = await service.analyzeRecordImages(uploadedPhotos.slice(0, 2)); // Limit to 2 photos for speed
populateFieldsFromOCR(ocrResult);
} catch (e) {
console.log('Preview OCR failed:', e);
}
}
// Generate quick preview results
const catNo = document.getElementById('catInput').value.trim() || ocrResult?.catalogueNumber || '';
const year = document.getElementById('yearInput').value.trim() || ocrResult?.year || '';
const detectedArtist = artist || ocrResult?.artist || 'Unknown Artist';
const detectedTitle = title || ocrResult?.title || 'Unknown Title';
const baseTitle = `${detectedArtist} - ${detectedTitle}`;
// Generate quick titles
const quickTitles = [
`${baseTitle} ${year ? `(${year})` : ''} ${catNo} VG+`.substring(0, 80),
`${baseTitle} Original Pressing Vinyl LP`.substring(0, 80),
`${detectedArtist} ${detectedTitle} ${catNo || 'LP'}`.substring(0, 80)
].map((t, i) => ({
text: t,
chars: t.length,
style: ['Quick', 'Standard', 'Compact'][i]
}));
// Quick pricing estimate based on condition
const cost = parseFloat(document.getElementById('costInput').value) || 10;
const vinylCond = document.getElementById('vinylConditionInput').value;
const sleeveCond = document.getElementById('sleeveConditionInput').value;
const conditionMultipliers = { 'M': 3, 'NM': 2.5, 'VG+': 1.8, 'VG': 1.2, 'G+': 0.8, 'G': 0.5 };
const condMult = (conditionMultipliers[vinylCond] || 1) * 0.7 + (conditionMultipliers[sleeveCond] || 1) * 0.3;
const estimatedValue = Math.round(cost * Math.max(condMult, 1.5));
const suggestedPrice = Math.round(estimatedValue * 0.9);
// Render preview results
renderTitleOptions(quickTitles);
// Quick pricing card
document.getElementById('pricingStrategy').innerHTML = `
<div class="pricing-card recommended">
<div class="flex items-center gap-2 mb-3">
<span class="px-2 py-1 bg-accent/20 text-accent text-xs font-medium rounded">QUICK ESTIMATE</span>
</div>
<p class="text-3xl font-bold text-white mb-1">£${suggestedPrice}</p>
<p class="text-sm text-gray-400 mb-3">Suggested Buy It Now</p>
<div class="space-y-2 text-sm">
<p class="flex justify-between"><span class="text-gray-500">Est. Value:</span> <span class="text-gray-300">£${estimatedValue}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Your Cost:</span> <span class="text-gray-300">£${cost.toFixed(2)}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Condition:</span> <span class="text-gray-300">${vinylCond}/${sleeveCond}</span></p>
</div>
</div>
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-400 uppercase tracking-wide">Preview Notes</h4>
<div class="p-3 bg-surface rounded-lg text-sm text-gray-400">
${ocrResult ?
`<p class="text-green-400 mb-2">✓ AI detected information from photos</p>` :
`<p class="text-yellow-400 mb-2">⚠ Add API key in Settings for auto-detection</p>`
}
<p>This is a quick estimate based on your cost and condition. Run "Generate Full Listing" for complete market analysis, sold comps, and optimized pricing.</p>
</div>
${ocrResult ? `
<div class="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<p class="text-xs text-green-400 font-medium mb-1">Detected from photos:</p>
<ul class="text-xs text-gray-400 space-y-1">
${ocrResult.artist ? `<li>• Artist: ${ocrResult.artist}</li>` : ''}
${ocrResult.title ? `<li>• Title: ${ocrResult.title}</li>` : ''}
${ocrResult.catalogueNumber ? `<li>• Cat#: ${ocrResult.catalogueNumber}</li>` : ''}
${ocrResult.year ? `<li>• Year: ${ocrResult.year}</li>` : ''}
</ul>
</div>
` : ''}
</div>
`;
// Simple fee floor
const fees = suggestedPrice * 0.16;
const safeFloor = Math.ceil(cost + fees + 6);
document.getElementById('feeFloor').innerHTML = `
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Your Cost</p>
<p class="text-xl font-bold text-gray-300">£${cost.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Est. Fees</p>
<p class="text-xl font-bold text-red-400">£${fees.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Ship + Pack</p>
<p class="text-xl font-bold text-gray-300">£6.00</p>
</div>
<div class="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/30">
<p class="text-xs text-green-500 uppercase mb-1">Safe Floor</p>
<p class="text-2xl font-bold text-green-400">£${safeFloor}</p>
</div>
`;
// Preview HTML description
const previewHtml = `<!-- QUICK PREVIEW - Generated by VinylVault Pro -->
<div style="max-width: 700px; margin: 0 auto; font-family: sans-serif;">
<h2 style="color: #333;">${detectedArtist} - ${detectedTitle}</h2>
${year ? `<p><strong>Year:</strong> ${year}</p>` : ''}
${catNo ? `<p><strong>Catalogue #:</strong> ${catNo}</p>` : ''}
<p><strong>Condition:</strong> Vinyl ${vinylCond}, Sleeve ${sleeveCond}</p>
<hr style="margin: 20px 0;">
<p style="color: #666;">[Full description will be generated with complete market analysis]</p>
</div>`;
const htmlOutput = document.getElementById('htmlOutput');
if (htmlOutput) htmlOutput.value = previewHtml;
// Preview tags
const previewTags = [
detectedArtist,
detectedTitle,
'vinyl',
'record',
vinylCond,
'lp',
year || 'vintage'
].filter(Boolean);
const tagsOutput = document.getElementById('tagsOutput');
if (tagsOutput) {
tagsOutput.innerHTML = previewTags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
}
// Update shot list
renderShotList();
// Show results
const resultsSection = document.getElementById('resultsSection');
const emptyState = document.getElementById('emptyState');
if (resultsSection) resultsSection.classList.remove('hidden');
if (emptyState) emptyState.classList.add('hidden');
if (resultsSection) resultsSection.scrollIntoView({ behavior: 'smooth' });
showToast('Quick preview ready! Click "Generate Full Listing" for complete analysis.', 'success');
} catch (error) {
console.error('Preview error:', error);
showToast('Preview failed: ' + error.message, 'error');
} finally {
stopAnalysisProgress();
setTimeout(() => {
spinner.classList.add('hidden');
dropZone.classList.remove('pointer-events-none');
updateAnalysisProgress('Initializing...', 0);
}, 300);
}
}
async function callAI(messages, temperature = 0.7) {
const provider = localStorage.getItem('ai_provider') || 'openai';
if (provider === 'deepseek' && window.deepseekService?.isConfigured) {
try {
const response = await fetch('https://api.deepseek.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('deepseek_api_key')}`
},
body: JSON.stringify({
model: localStorage.getItem('deepseek_model') || 'deepseek-chat',
messages: messages,
temperature: temperature,
max_tokens: 2000
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'DeepSeek API request failed');
}
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
showToast(`DeepSeek Error: ${error.message}`, 'error');
return null;
}
} else {
// Fallback to OpenAI
const apiKey = localStorage.getItem('openai_api_key');
const model = localStorage.getItem('openai_model') || 'gpt-4o';
const maxTokens = parseInt(localStorage.getItem('openai_max_tokens')) || 2000;
if (!apiKey) {
showToast('OpenAI API key not configured. Go to Settings.', 'error');
return null;
}
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model: model,
messages: messages,
temperature: temperature,
max_tokens: maxTokens
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'API request failed');
}
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
showToast(`OpenAI Error: ${error.message}`, 'error');
return null;
}
}
}
// Legacy alias for backward compatibility
async function callOpenAI(messages, temperature = 0.7) {
return callAI(messages, temperature);
}
// Delete hosted image from imgBB
async function deleteHostedImage(deleteUrl) {
if (!deleteUrl) return false;
try {
const response = await fetch(deleteUrl, { method: 'GET' });
// imgBB delete URLs work via GET request
return response.ok;
} catch (error) {
console.error('Failed to delete image:', error);
return false;
}
}
// Get hosted photo URLs for eBay HTML description
function getHostedPhotoUrlsForEbay() {
return hostedPhotoUrls.map(img => ({
full: img.url,
display: img.displayUrl || img.url,
thumb: img.thumb,
medium: img.medium,
viewer: img.viewerUrl
}));
}
async function generateListingWithAI() {
const artist = document.getElementById('artistInput').value.trim();
const title = document.getElementById('titleInput').value.trim();
const catNo = document.getElementById('catInput').value.trim();
const year = document.getElementById('yearInput').value.trim();
if (!artist || !title) {
showToast('Please enter at least artist and title', 'error');
return;
}
const messages = [
{
role: 'system',
content: 'You are a vinyl record eBay listing expert. Generate optimized titles, descriptions, and pricing strategies. Always return JSON format with: titles (array), description (string), condition_notes (string), price_estimate (object with min, max, recommended), and tags (array).'
},
{
role: 'user',
content: `Generate an eBay listing for: ${artist} - ${title}${catNo ? ` (Catalog: ${catNo})` : ''}${year ? ` (${year})` : ''}. Include optimized title options, professional HTML description, condition guidance, price estimate in GBP, and relevant tags.`
}
];
const provider = localStorage.getItem('ai_provider') || 'openai';
showToast(`Generating listing with ${provider === 'deepseek' ? 'DeepSeek' : 'OpenAI'}...`, 'success');
const result = await callAI(messages, 0.7);
if (result) {
try {
const data = JSON.parse(result);
// Populate the UI with AI-generated content
if (data.titles) {
renderTitleOptions(data.titles.map(t => ({
text: t.length > 80 ? t.substring(0, 77) + '...' : t,
chars: Math.min(t.length, 80),
style: 'AI Generated'
})));
}
if (data.description) {
document.getElementById('htmlOutput').value = data.description;
}
if (data.tags) {
const tagsContainer = document.getElementById('tagsOutput');
tagsContainer.innerHTML = data.tags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
}
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
showToast('AI listing generated!', 'success');
} catch (e) {
// If not valid JSON, treat as plain text description
document.getElementById('htmlOutput').value = result;
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
}
}
}
function requestHelp() {
alert(`VINYL PHOTO GUIDE:
ESSENTIAL SHOTS (need these):
• Front cover - square, no glare, color accurate
• Back cover - full frame, readable text
• Both labels - close enough to read all text
• Deadwax/runout - for pressing identification
CONDITION SHOTS:
• Vinyl in raking light at angle (shows scratches)
• Sleeve edges and corners
• Any flaws clearly documented
OPTIONARY BUT HELPFUL:
• Inner sleeve condition
• Inserts, posters, extras
• Hype stickers
• Barcode area
TIPS:
- Use natural daylight or 5500K bulbs
- Avoid flash directly on glossy sleeves
- Include scale reference if unusual size
- Photograph flaws honestly - reduces returns`);
}
function showToast(message, type = 'success') {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const iconMap = {
success: 'check',
error: 'alert-circle',
warning: 'alert-triangle'
};
const colorMap = {
success: 'text-green-400',
error: 'text-red-400',
warning: 'text-yellow-400'
};
const toast = document.createElement('div');
toast.className = `toast ${type} flex items-center gap-3`;
toast.innerHTML = `
<i data-feather="${iconMap[type] || 'info'}" class="w-5 h-5 ${colorMap[type] || 'text-blue-400'}"></i>
<span class="text-sm text-gray-200">${message}</span>
`;
document.body.appendChild(toast);
feather.replace();
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Cleanup function to delete all hosted images for current listing
async function cleanupHostedImages() {
if (window.currentListingImages) {
for (const img of window.currentListingImages) {
if (img.deleteUrl) {
await deleteHostedImage(img.deleteUrl);
}
}
window.currentListingImages = [];
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
console.log('VinylVault Pro initialized');
// Initialize drop zone
initDropZone();
// Attach event listeners to buttons
const generateBtn = document.getElementById('generateListingBtn');
if (generateBtn) {
generateBtn.addEventListener('click', generateListing);
}
const draftBtn = document.getElementById('draftAnalysisBtn');
if (draftBtn) {
draftBtn.addEventListener('click', draftAnalysis);
}
const helpBtn = document.getElementById('requestHelpBtn');
if (helpBtn) {
helpBtn.addEventListener('click', requestHelp);
}
const copyHTMLBtn = document.getElementById('copyHTMLBtn');
if (copyHTMLBtn) {
copyHTMLBtn.addEventListener('click', copyHTML);
}
const copyTagsBtn = document.getElementById('copyTagsBtn');
if (copyTagsBtn) {
copyTagsBtn.addEventListener('click', copyTags);
}
const analyzePhotoBtn = document.getElementById('analyzePhotoTypesBtn');
if (analyzePhotoBtn) {
analyzePhotoBtn.addEventListener('click', analyzePhotoTypes);
}
// Clear Collection Import Banner listeners
const clearCollectionBtn = document.querySelector('#collectionBanner button');
if (clearCollectionBtn) {
clearCollectionBtn.addEventListener('click', clearCollectionImport);
}
// Warn about unsaved changes when leaving page with hosted images
window.addEventListener('beforeunload', (e) => {
if (hostedPhotoUrls.length > 0 && !window.listingPublished) {
// Optional: could add cleanup here or warn user
}
});
});
// Collection Import functions (defined here to avoid reference errors)
function clearCollectionImport() {
sessionStorage.removeItem('collectionListingRecord');
const banner = document.getElementById('collectionBanner');
if (banner) {
banner.classList.add('hidden');
}
showToast('Collection import cleared', 'success');
}
function checkCollectionImport() {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('fromCollection') === 'true') {
const recordData = sessionStorage.getItem('collectionListingRecord');
if (recordData) {
const record = JSON.parse(recordData);
populateFieldsFromCollection(record);
const banner = document.getElementById('collectionBanner');
if (banner) {
banner.classList.remove('hidden');
}
const indicator = document.getElementById('collectionDataIndicator');
if (indicator) {
indicator.classList.remove('hidden');
}
}
}
}
function populateFieldsFromCollection(record) {
if (!record) return;
const fields = {
'artistInput': record.artist,
'titleInput': record.title,
'catInput': record.catalogueNumber || record.matrixNotes,
'yearInput': record.year,
'costInput': record.purchasePrice,
'daysOwnedInput': record.daysOwned
};
Object.entries(fields).forEach(([fieldId, value]) => {
const field = document.getElementById(fieldId);
if (field && value) {
field.value = value;
}
});
// Set conditions if available
if (record.conditionVinyl) {
const vinylCondition = document.getElementById('vinylConditionInput');
if (vinylCondition) vinylCondition.value = record.conditionVinyl;
}
if (record.conditionSleeve) {
const sleeveCondition = document.getElementById('sleeveConditionInput');
if (sleeveCondition) sleeveCondition.value = record.conditionSleeve;
}
showToast(`Loaded ${record.artist} - ${record.title} from collection`, 'success');
}
// Call check on load
checkCollectionImport();
: '€';
// Mock comp research results
const comps = {
nm: { low: 45, high: 65, median: 52 },
vgplus: { low: 28, high: 42, median: 34 },
vg: { low: 15, high: 25, median: 19 }
};
// Calculate recommended price based on goal
let recommendedBin, strategy;
switch(goal) {
case 'quick':
recommendedBin = Math.round(comps.vgplus.low * 0.9);
strategy = 'BIN + Best Offer (aggressive)';
break;
case 'max':
recommendedBin = Math.round(comps.nm.high * 1.1);
strategy = 'BIN only, no offers, long duration';
break;
default:
recommendedBin = comps.vgplus.median;
strategy = 'BIN + Best Offer (standard)';
}
// Fee calculation (eBay UK approx)
const ebayFeeRate = 0.13; // 13% final value fee
const paypalRate = 0.029; // 2.9% + 30p
const fixedFee = 0.30;
const shippingCost = 4.50; // Estimated
const packingCost = 1.50;
const totalFees = (recommendedBin * ebayFeeRate) + (recommendedBin * paypalRate) + fixedFee;
const breakEven = cost + totalFees + shippingCost + packingCost;
const safeFloor = Math.ceil(breakEven * 1.05); // 5% buffer
// Generate titles
const baseTitle = `${artist || 'ARTIST'} - ${title || 'TITLE'}`;
const titles = generateTitles(baseTitle, catNo, year, goal);
// Render results
renderTitleOptions(titles);
renderPricingStrategy(recommendedBin, strategy, comps, currency, goal);
renderFeeFloor(cost, totalFees, shippingCost, packingCost, safeFloor, currency);
await renderHTMLDescription(data, titles[0]);
renderTags(artist, title, catNo, year);
renderShotList();
// Show results
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
resultsSection.scrollIntoView({ behavior: 'smooth' });
currentAnalysis = {
titles, recommendedBin, strategy, breakEven, safeFloor, currency
};
}
function generateTitles(base, catNo, year, goal) {
const titles = [];
const cat = catNo || 'CAT#';
const yr = year || 'YEAR';
const country = window.detectedCountry || 'UK';
const genre = window.detectedGenre || 'Rock';
const format = window.detectedFormat?.includes('7"') ? '7"' : window.detectedFormat?.includes('12"') ? '12"' : 'LP';
// Option 1: Classic collector focus
titles.push(`${base} ${format} ${yr} ${country} 1st Press ${cat} EX/VG+`);
// Option 2: Condition forward
titles.push(`NM! ${base} Original ${yr} Vinyl ${format} ${cat} Nice Copy`);
// Option 3: Rarity/hype with detected genre
titles.push(`${base} ${yr} ${country} Press ${cat} Rare Vintage ${genre} ${format}`);
// Option 4: Clean searchable
titles.push(`${base} Vinyl ${format} ${yr} ${cat} Excellent Condition`);
// Option 5: Genre tagged
titles.push(`${base} ${yr} ${format} ${genre} ${cat} VG+ Plays Great`);
return titles.map((t, i) => ({
text: t.length > 80 ? t.substring(0, 77) + '...' : t,
chars: Math.min(t.length, 80),
style: ['Classic Collector', 'Condition Forward', 'Rarity Focus', 'Clean Search', 'Genre Tagged'][i]
}));
}
function renderTitleOptions(titles) {
const container = document.getElementById('titleOptions');
container.innerHTML = titles.map((t, i) => `
<div class="title-option ${i === 0 ? 'selected' : ''}" onclick="selectTitle(this, '${t.text.replace(/'/g, "\\'")}')">
<span class="char-count">${t.chars}/80</span>
<p class="font-medium text-gray-200 pr-16">${t.text}</p>
<p class="text-sm text-gray-500 mt-1">${t.style}</p>
</div>
`).join('');
}
function selectTitle(el, text) {
document.querySelectorAll('.title-option').forEach(o => o.classList.remove('selected'));
el.classList.add('selected');
// Update clipboard copy
navigator.clipboard.writeText(text);
showToast('Title copied to clipboard!', 'success');
}
function renderPricingStrategy(bin, strategy, comps, currency, goal) {
const container = document.getElementById('pricingStrategy');
const offerSettings = goal === 'max' ? 'Offers: OFF' :
`Auto-accept: ${currency}${Math.floor(bin * 0.85)} | Auto-decline: ${currency}${Math.floor(bin * 0.7)}`;
container.innerHTML = `
<div class="pricing-card recommended">
<div class="flex items-center gap-2 mb-3">
<span class="px-2 py-1 bg-accent/20 text-accent text-xs font-medium rounded">RECOMMENDED</span>
</div>
<p class="text-3xl font-bold text-white mb-1">${currency}${bin}</p>
<p class="text-sm text-gray-400 mb-3">Buy It Now</p>
<div class="space-y-2 text-sm">
<p class="flex justify-between"><span class="text-gray-500">Strategy:</span> <span class="text-gray-300">${strategy}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Best Offer:</span> <span class="text-gray-300">${offerSettings}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Duration:</span> <span class="text-gray-300">30 days (GTC)</span></p>
</div>
</div>
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-400 uppercase tracking-wide">Sold Comps by Grade</h4>
<div class="space-y-2">
<div class="flex justify-between items-center p-3 bg-surface rounded-lg">
<span class="text-green-400 font-medium">NM/NM-</span>
<span class="text-gray-300">${currency}${comps.nm.low}-${comps.nm.high} <span class="text-gray-500">(med: ${comps.nm.median})</span></span>
</div>
<div class="flex justify-between items-center p-3 bg-surface rounded-lg border border-accent/30">
<span class="text-accent font-medium">VG+/EX</span>
<span class="text-gray-300">${currency}${comps.vgplus.low}-${comps.vgplus.high} <span class="text-gray-500">(med: ${comps.vgplus.median})</span></span>
</div>
<div class="flex justify-between items-center p-3 bg-surface rounded-lg">
<span class="text-yellow-400 font-medium">VG/VG+</span>
<span class="text-gray-300">${currency}${comps.vg.low}-${comps.vg.high} <span class="text-gray-500">(med: ${comps.vg.median})</span></span>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">Based on last 90 days sold listings, same pressing. Prices exclude postage.</p>
</div>
`;
}
function renderFeeFloor(cost, fees, shipping, packing, safeFloor, currency) {
const container = document.getElementById('feeFloor');
container.innerHTML = `
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Your Cost</p>
<p class="text-xl font-bold text-gray-300">${currency}${cost.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Est. Fees</p>
<p class="text-xl font-bold text-red-400">${currency}${fees.toFixed(2)}</p>
<p class="text-xs text-gray-600">~16% total</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Ship + Pack</p>
<p class="text-xl font-bold text-gray-300">${currency}${(shipping + packing).toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/30">
<p class="text-xs text-green-500 uppercase mb-1">Safe Floor Price</p>
<p class="text-2xl font-bold text-green-400">${currency}${safeFloor}</p>
<p class="text-xs text-green-600/70">Auto-decline below this</p>
</div>
`;
}
async function renderHTMLDescription(data, titleObj) {
const { artist, title, catNo, year } = data;
// Use hosted URL if available, otherwise fallback to local object URL
let heroImg = '';
let galleryImages = [];
if (hostedPhotoUrls.length > 0) {
heroImg = hostedPhotoUrls[0].displayUrl || hostedPhotoUrls[0].url;
galleryImages = hostedPhotoUrls.slice(1).map(img => img.displayUrl || img.url);
} else if (uploadedPhotos.length > 0) {
heroImg = URL.createObjectURL(uploadedPhotos[0]);
galleryImages = uploadedPhotos.slice(1).map((_, i) => URL.createObjectURL(uploadedPhotos[i + 1]));
}
// Use OCR-detected values if available
const detectedLabel = window.detectedLabel || '[Verify from photos]';
const detectedCountry = window.detectedCountry || 'UK';
const detectedFormat = window.detectedFormat || 'LP • 33rpm';
const detectedGenre = window.detectedGenre || 'rock';
const detectedCondition = window.detectedCondition || 'VG+/VG+';
const detectedPressingInfo = window.detectedPressingInfo || '';
// Fetch tracklist and detailed info from Discogs if available
let tracklistHtml = '';
let pressingDetailsHtml = '';
let provenanceHtml = '';
if (window.discogsReleaseId && window.discogsService?.key) {
try {
const discogsData = await window.discogsService.fetchTracklist(window.discogsReleaseId);
if (discogsData && discogsData.tracklist) {
// Build tracklist HTML
const hasSideBreakdown = discogsData.tracklist.some(t => t.position && (t.position.startsWith('A') || t.position.startsWith('B')));
if (hasSideBreakdown) {
// Group by sides
const sides = {};
discogsData.tracklist.forEach(track => {
const side = track.position ? track.position.charAt(0) : 'Other';
if (!sides[side]) sides[side] = [];
sides[side].push(track);
});
tracklistHtml = Object.entries(sides).map(([side, tracks]) => `
<div style="margin-bottom: 16px;">
<h4 style="color: #7c3aed; font-size: 13px; font-weight: 600; margin: 0 0 8px 0; text-transform: uppercase; letter-spacing: 0.5px;">Side ${side}</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
${tracks.map(track => `
<div style="flex: 1 1 200px; min-width: 200px; display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0;">
<span style="color: #1e293b; font-size: 13px;"><strong>${track.position}</strong> ${track.title}</span>
${track.duration ? `<span style="color: #64748b; font-size: 12px; font-family: monospace;">${track.duration}</span>` : ''}
</div>
`).join('')}
</div>
</div>
`).join('');
} else {
// Simple list
tracklistHtml = `
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
${discogsData.tracklist.map(track => `
<div style="flex: 1 1 200px; min-width: 200px; display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0;">
<span style="color: #1e293b; font-size: 13px;">${track.position ? `<strong>${track.position}</strong> ` : ''}${track.title}</span>
${track.duration ? `<span style="color: #64748b; font-size: 12px; font-family: monospace;">${track.duration}</span>` : ''}
</div>
`).join('')}
</div>
`;
}
// Build pressing/variation details
const identifiers = discogsData.identifiers || [];
const barcodeInfo = identifiers.find(i => i.type === 'Barcode');
const matrixInfo = identifiers.filter(i => i.type === 'Matrix / Runout' || i.type === 'Runout');
const pressingInfo = identifiers.filter(i => i.type === 'Pressing Plant' || i.type === 'Mastering');
if (matrixInfo.length > 0 || barcodeInfo || pressingInfo.length > 0) {
pressingDetailsHtml = `
<div style="background: #f0fdf4; border-left: 4px solid #22c55e; padding: 16px 20px; margin: 24px 0; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #166534; font-size: 15px; font-weight: 600;">Pressing & Matrix Information</h3>
<div style="font-family: monospace; font-size: 13px; line-height: 1.6; color: #15803d;">
${barcodeInfo ? `<p style="margin: 4px 0;"><strong>Barcode:</strong> ${barcodeInfo.value}</p>` : ''}
${matrixInfo.map(m => `<p style="margin: 4px 0;"><strong>${m.type}:</strong> ${m.value}${m.description ? ` <em>(${m.description})</em>` : ''}</p>`).join('')}
${pressingInfo.map(p => `<p style="margin: 4px 0;"><strong>${p.type}:</strong> ${p.value}</p>`).join('')}
</div>
${discogsData.notes ? `<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #bbf7d0; font-size: 12px; color: #166534; font-style: italic;">${discogsData.notes.substring(0, 300)}${discogsData.notes.length > 300 ? '...' : ''}</p>` : ''}
</div>
`;
}
// Build provenance data for buyer confidence
const companies = discogsData.companies || [];
const masteredBy = companies.find(c => c.entity_type_name === 'Mastered At' || c.name.toLowerCase().includes('mastering'));
const pressedBy = companies.find(c => c.entity_type_name === 'Pressed By' || c.name.toLowerCase().includes('pressing'));
const lacquerCut = companies.find(c => c.entity_type_name === 'Lacquer Cut At');
if (masteredBy || pressedBy || lacquerCut) {
provenanceHtml = `
<div style="background: #eff6ff; border: 1px solid #bfdbfe; padding: 16px; margin: 24px 0; border-radius: 8px;">
<h3 style="margin: 0 0 12px 0; color: #1e40af; font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
Provenance & Production
</h3>
<div style="font-size: 13px; color: #1e3a8a; line-height: 1.6;">
${masteredBy ? `<p style="margin: 4px 0;">✓ Mastered at <strong>${masteredBy.name}</strong></p>` : ''}
${lacquerCut ? `<p style="margin: 4px 0;">✓ Lacquer cut at <strong>${lacquerCut.name}</strong></p>` : ''}
${pressedBy ? `<p style="margin: 4px 0;">✓ Pressed at <strong>${pressedBy.name}</strong></p>` : ''}
${discogsData.num_for_sale ? `<p style="margin: 8px 0 0 0; padding-top: 8px; border-top: 1px solid #bfdbfe; color: #3b82f6; font-size: 12px;">Reference: ${discogsData.num_for_sale} copies currently for sale on Discogs</p>` : ''}
</div>
</div>
`;
}
}
} catch (e) {
console.error('Failed to fetch Discogs details for HTML:', e);
}
}
// If no tracklist from Discogs, provide placeholder
if (!tracklistHtml) {
tracklistHtml = `<p style="color: #64748b; font-style: italic;">Tracklist verification recommended. Please compare with Discogs entry for accuracy.</p>`;
}
const galleryHtml = galleryImages.length > 0 ? `
<!-- PHOTO GALLERY -->
<div style="margin-bottom: 24px;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px;">
${galleryImages.map(url => `<img src="${url}" style="width: 100%; height: 150px; object-fit: cover; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);" alt="Record photo">`).join('')}
</div>
</div>
` : '';
const html = `<div style="max-width: 800px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #333; line-height: 1.6;">
<!-- HERO IMAGE -->
<div style="margin-bottom: 24px;">
<img src="${heroImg}" alt="${artist} - ${title}" style="width: 100%; max-width: 600px; display: block; margin: 0 auto; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.15);">
</div>
${galleryHtml}
<!-- BADGES -->
<div style="display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-bottom: 24px;">
<span style="background: #7c3aed; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">Original ${detectedCountry} Pressing</span>
<span style="background: #059669; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${year || '1970s'}</span>
<span style="background: #0891b2; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${detectedFormat}</span>
<span style="background: #d97706; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${detectedCondition}</span>
</div>
<!-- AT A GLANCE -->
<table style="width: 100%; border-collapse: collapse; margin-bottom: 24px; font-size: 14px;">
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600; width: 140px;">Artist</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${artist || 'See title'}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Title</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${title || 'See title'}</td>
</tr>
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Label</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${detectedLabel}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Catalogue</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;"><code style="background: #f1f5f9; padding: 2px 8px; border-radius: 4px;">${catNo || '[See photos]'}</code></td>
</tr>
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Country</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${detectedCountry}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Year</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${year || '[Verify]'}</td>
</tr>
</table>
<!-- CONDITION -->
<div style="background: #fefce8; border-left: 4px solid #eab308; padding: 16px 20px; margin-bottom: 24px; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #854d0e; font-size: 16px; font-weight: 600;">Condition Report</h3>
<div style="display: grid; gap: 12px;">
<div>
<strong style="color: #713f12;">Vinyl:</strong> <span style="color: #854d0e;">VG+ — Light surface marks, plays cleanly with minimal surface noise. No skips or jumps. [Adjust based on actual inspection]</span>
</div>
<div>
<strong style="color: #713f12;">Sleeve:</strong> <span style="color: #854d0e;">VG+ — Minor edge wear, light ring wear visible under raking light. No splits or writing. [Adjust based on actual inspection]</span>
</div>
<div>
<strong style="color: #713f12;">Inner Sleeve:</strong> <span style="color: #854d0e;">Original paper inner included, small split at bottom seam. [Verify/Adjust]</span>
</div>
</div>
</div>
<!-- ABOUT -->
<h3 style="color: #1e293b; font-size: 18px; font-weight: 600; margin-bottom: 12px;">About This Release</h3>
<p style="margin-bottom: 16px; color: #475569;">${detectedGenre ? `${detectedGenre.charAt(0).toUpperCase() + detectedGenre.slice(1)} release` : 'Vintage vinyl release'}${detectedPressingInfo ? `. Matrix/Runout: ${detectedPressingInfo}` : ''}. [Add accurate description based on verified pressing details. Mention notable features: gatefold, insert, poster, hype sticker, etc.]</p>
<!-- TRACKLIST -->
<h3 style="color: #1e293b; font-size: 18px; font-weight: 600; margin-bottom: 12px;">Tracklist</h3>
<div style="background: #f8fafc; padding: 16px 20px; border-radius: 8px; margin-bottom: 24px;">
${tracklistHtml}
</div>
${pressingDetailsHtml}
${provenanceHtml}
<!-- PACKING -->
<div style="background: #eff6ff; border-left: 4px solid #3b82f6; padding: 16px 20px; margin-bottom: 24px; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #1e40af; font-size: 16px; font-weight: 600;">Packing & Postage</h3>
<p style="margin: 0 0 12px 0; color: #1e3a8a;">Records are removed from outer sleeves to prevent seam splits during transit. Packed with stiffeners in a dedicated LP mailer. Royal Mail 48 Tracked or courier service.</p>
<p style="margin: 0; color: #1e3a8a; font-size: 14px;"><strong>Combined postage:</strong> Discount available for multiple purchases—please request invoice before payment.</p>
</div>
<!-- CTA -->
<div style="text-align: center; padding: 24px; background: #f1f5f9; border-radius: 12px;">
<p style="margin: 0 0 8px 0; color: #475569; font-weight: 500;">Questions? Need more photos?</p>
<p style="margin: 0; color: #64748b; font-size: 14px;">Message me anytime—happy to provide additional angles, audio clips, or pressing details.</p>
</div>
</div>`;
// Store reference to hosted images for potential cleanup
window.currentListingImages = hostedPhotoUrls.map(img => ({
url: img.url,
deleteUrl: img.deleteUrl
}));
document.getElementById('htmlOutput').value = html;
}
function renderTags(artist, title, catNo, year) {
const genre = window.detectedGenre || 'rock';
const format = window.detectedFormat?.toLowerCase().includes('7"') ? '7 inch' :
window.detectedFormat?.toLowerCase().includes('12"') ? '12 inch single' : 'lp';
const country = window.detectedCountry?.toLowerCase() || 'uk';
const tags = [
artist || 'vinyl',
title || 'record',
format,
'vinyl record',
'original pressing',
`${country} pressing`,
year || 'vintage',
catNo || '',
genre,
genre === 'rock' ? 'prog rock' : genre,
genre === 'rock' ? 'psych' : '',
'collector',
'audiophile',
format === 'lp' ? '12 inch' : format,
'33 rpm',
format === 'lp' ? 'album' : 'single',
'used vinyl',
'graded',
'excellent condition',
'rare vinyl',
'classic rock',
'vintage vinyl',
'record collection',
'music',
'audio',
window.detectedLabel || ''
].filter(Boolean);
const container = document.getElementById('tagsOutput');
container.innerHTML = tags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
}
function renderShotList() {
// Map shot types to display info
const shotDefinitions = [
{ id: 'front', name: 'Front cover (square, well-lit)', critical: true },
{ id: 'back', name: 'Back cover (full shot)', critical: true },
{ id: 'spine', name: 'Spine (readable text)', critical: true },
{ id: 'label_a', name: 'Label Side A (close, legible)', critical: true },
{ id: 'label_b', name: 'Label Side B (close, legible)', critical: true },
{ id: 'deadwax', name: 'Deadwax/runout grooves', critical: true },
{ id: 'inner', name: 'Inner sleeve (both sides)', critical: false },
{ id: 'insert', name: 'Insert/poster if included', critical: false },
{ id: 'hype', name: 'Hype sticker (if present)', critical: false },
{ id: 'vinyl', name: 'Vinyl in raking light (flaws)', critical: true },
{ id: 'corners', name: 'Sleeve corners/edges detail', critical: false },
{ id: 'barcode', name: 'Barcode area', critical: false }
];
// Check if we have any photos at all
const hasPhotos = uploadedPhotos.length > 0;
const container = document.getElementById('shotList');
container.innerHTML = shotDefinitions.map(shot => {
const have = detectedPhotoTypes.has(shot.id) || (shot.id === 'front' && hasPhotos) || (shot.id === 'back' && uploadedPhotos.length > 1);
const statusClass = have ? 'completed' : shot.critical ? 'missing' : '';
const iconColor = have ? 'text-green-500' : shot.critical ? 'text-yellow-500' : 'text-gray-500';
const textClass = have ? 'text-gray-400 line-through' : 'text-gray-300';
const icon = have ? 'check-circle' : shot.critical ? 'alert-circle' : 'circle';
return `
<div class="shot-item ${statusClass}">
<i data-feather="${icon}"
class="w-5 h-5 ${iconColor} flex-shrink-0"></i>
<span class="text-sm ${textClass}">${shot.name}</span>
${shot.critical && !have ? '<span class="ml-auto text-xs text-yellow-500 font-medium">CRITICAL</span>' : ''}
</div>
`}).join('');
feather.replace();
}
function copyHTML() {
const html = document.getElementById('htmlOutput');
html.select();
document.execCommand('copy');
showToast('HTML copied to clipboard!', 'success');
}
function copyTags() {
const tags = Array.from(document.querySelectorAll('#tagsOutput span')).map(s => s.textContent).join(', ');
navigator.clipboard.writeText(tags);
showToast('Tags copied to clipboard!', 'success');
}
async function draftAnalysis() {
if (uploadedPhotos.length === 0) {
showToast('Upload photos first for preview', 'error');
return;
}
const artist = document.getElementById('artistInput').value.trim();
const title = document.getElementById('titleInput').value.trim();
// Show loading state
const dropZone = document.getElementById('dropZone');
const spinner = document.getElementById('uploadSpinner');
spinner.classList.remove('hidden');
dropZone.classList.add('pointer-events-none');
startAnalysisProgressSimulation();
try {
// Try OCR/AI analysis if available
const service = getAIService();
let ocrResult = null;
if (service && service.apiKey && uploadedPhotos.length > 0) {
try {
ocrResult = await service.analyzeRecordImages(uploadedPhotos.slice(0, 2)); // Limit to 2 photos for speed
populateFieldsFromOCR(ocrResult);
} catch (e) {
console.log('Preview OCR failed:', e);
}
}
// Generate quick preview results
const catNo = document.getElementById('catInput').value.trim() || ocrResult?.catalogueNumber || '';
const year = document.getElementById('yearInput').value.trim() || ocrResult?.year || '';
const detectedArtist = artist || ocrResult?.artist || 'Unknown Artist';
const detectedTitle = title || ocrResult?.title || 'Unknown Title';
const baseTitle = `${detectedArtist} - ${detectedTitle}`;
// Generate quick titles
const quickTitles = [
`${baseTitle} ${year ? `(${year})` : ''} ${catNo} VG+`.substring(0, 80),
`${baseTitle} Original Pressing Vinyl LP`.substring(0, 80),
`${detectedArtist} ${detectedTitle} ${catNo || 'LP'}`.substring(0, 80)
].map((t, i) => ({
text: t,
chars: t.length,
style: ['Quick', 'Standard', 'Compact'][i]
}));
// Quick pricing estimate based on condition
const cost = parseFloat(document.getElementById('costInput').value) || 10;
const vinylCond = document.getElementById('vinylConditionInput').value;
const sleeveCond = document.getElementById('sleeveConditionInput').value;
const conditionMultipliers = { 'M': 3, 'NM': 2.5, 'VG+': 1.8, 'VG': 1.2, 'G+': 0.8, 'G': 0.5 };
const condMult = (conditionMultipliers[vinylCond] || 1) * 0.7 + (conditionMultipliers[sleeveCond] || 1) * 0.3;
const estimatedValue = Math.round(cost * Math.max(condMult, 1.5));
const suggestedPrice = Math.round(estimatedValue * 0.9);
// Render preview results
renderTitleOptions(quickTitles);
// Quick pricing card
document.getElementById('pricingStrategy').innerHTML = `
<div class="pricing-card recommended">
<div class="flex items-center gap-2 mb-3">
<span class="px-2 py-1 bg-accent/20 text-accent text-xs font-medium rounded">QUICK ESTIMATE</span>
</div>
<p class="text-3xl font-bold text-white mb-1">£${suggestedPrice}</p>
<p class="text-sm text-gray-400 mb-3">Suggested Buy It Now</p>
<div class="space-y-2 text-sm">
<p class="flex justify-between"><span class="text-gray-500">Est. Value:</span> <span class="text-gray-300">£${estimatedValue}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Your Cost:</span> <span class="text-gray-300">£${cost.toFixed(2)}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Condition:</span> <span class="text-gray-300">${vinylCond}/${sleeveCond}</span></p>
</div>
</div>
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-400 uppercase tracking-wide">Preview Notes</h4>
<div class="p-3 bg-surface rounded-lg text-sm text-gray-400">
${ocrResult ?
`<p class="text-green-400 mb-2">✓ AI detected information from photos</p>` :
`<p class="text-yellow-400 mb-2">⚠ Add API key in Settings for auto-detection</p>`
}
<p>This is a quick estimate based on your cost and condition. Run "Generate Full Listing" for complete market analysis, sold comps, and optimized pricing.</p>
</div>
${ocrResult ? `
<div class="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<p class="text-xs text-green-400 font-medium mb-1">Detected from photos:</p>
<ul class="text-xs text-gray-400 space-y-1">
${ocrResult.artist ? `<li>• Artist: ${ocrResult.artist}</li>` : ''}
${ocrResult.title ? `<li>• Title: ${ocrResult.title}</li>` : ''}
${ocrResult.catalogueNumber ? `<li>• Cat#: ${ocrResult.catalogueNumber}</li>` : ''}
${ocrResult.year ? `<li>• Year: ${ocrResult.year}</li>` : ''}
</ul>
</div>
` : ''}
</div>
`;
// Simple fee floor
const fees = suggestedPrice * 0.16;
const safeFloor = Math.ceil(cost + fees + 6);
document.getElementById('feeFloor').innerHTML = `
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Your Cost</p>
<p class="text-xl font-bold text-gray-300">£${cost.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Est. Fees</p>
<p class="text-xl font-bold text-red-400">£${fees.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Ship + Pack</p>
<p class="text-xl font-bold text-gray-300"6.00</p>
</div>
<div class="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/30">
<p class="text-xs text-green-500 uppercase mb-1">Safe Floor</p>
<p class="text-2xl font-bold text-green-400">£${safeFloor}</p>
</div>
`;
// Preview HTML description
const previewHtml = `<!-- QUICK PREVIEW - Generated by VinylVault Pro -->
<div style="max-width: 700px; margin: 0 auto; font-family: sans-serif;">
<h2 style="color: #333;">${detectedArtist} - ${detectedTitle}</h2>
${year ? `<p><strong>Year:</strong> ${year}</p>` : ''}
${catNo ? `<p><strong>Catalogue #:</strong> ${catNo}</p>` : ''}
<p><strong>Condition:</strong> Vinyl ${vinylCond}, Sleeve ${sleeveCond}</p>
<hr style="margin: 20px 0;">
<p style="color: #666;">[Full description will be generated with complete market analysis]</p>
</div>`;
document.getElementById('htmlOutput').value = previewHtml;
// Preview tags
const previewTags = [
detectedArtist,
detectedTitle,
'vinyl',
'record',
vinylCond,
'lp',
year || 'vintage'
].filter(Boolean);
document.getElementById('tagsOutput').innerHTML = previewTags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
// Update shot list
renderShotList();
// Show results
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
resultsSection.scrollIntoView({ behavior: 'smooth' });
showToast('Quick preview ready! Click "Generate Full Listing" for complete analysis.', 'success');
} catch (error) {
console.error('Preview error:', error);
showToast('Preview failed: ' + error.message, 'error');
} finally {
stopAnalysisProgress();
setTimeout(() => {
spinner.classList.add('hidden');
dropZone.classList.remove('pointer-events-none');
updateAnalysisProgress('Initializing...', 0);
}, 300);
}
}
async function callAI(messages, temperature = 0.7) {
const provider = localStorage.getItem('ai_provider') || 'openai';
if (provider === 'deepseek' && window.deepseekService?.isConfigured) {
try {
const response = await fetch('https://api.deepseek.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('deepseek_api_key')}`
},
body: JSON.stringify({
model: localStorage.getItem('deepseek_model') || 'deepseek-chat',
messages: messages,
temperature: temperature,
max_tokens: 2000
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'DeepSeek API request failed');
}
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
showToast(`DeepSeek Error: ${error.message}`, 'error');
return null;
}
} else {
// Fallback to OpenAI
const apiKey = localStorage.getItem('openai_api_key');
const model = localStorage.getItem('openai_model') || 'gpt-4o';
const maxTokens = parseInt(localStorage.getItem('openai_max_tokens')) || 2000;
if (!apiKey) {
showToast('OpenAI API key not configured. Go to Settings.', 'error');
return null;
}
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model: model,
messages: messages,
temperature: temperature,
max_tokens: maxTokens
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'API request failed');
}
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
showToast(`OpenAI Error: ${error.message}`, 'error');
return null;
}
}
}
// Legacy alias for backward compatibility
async function callOpenAI(messages, temperature = 0.7) {
return callAI(messages, temperature);
}
// Delete hosted image from imgBB
async function deleteHostedImage(deleteUrl) {
if (!deleteUrl) return false;
try {
const response = await fetch(deleteUrl, { method: 'GET' });
// imgBB delete URLs work via GET request
return response.ok;
} catch (error) {
console.error('Failed to delete image:', error);
return false;
}
}
// Get hosted photo URLs for eBay HTML description
function getHostedPhotoUrlsForEbay() {
return hostedPhotoUrls.map(img => ({
full: img.url,
display: img.displayUrl || img.url,
thumb: img.thumb,
medium: img.medium,
viewer: img.viewerUrl
}));
}
async function generateListingWithAI() {
const artist = document.getElementById('artistInput').value.trim();
const title = document.getElementById('titleInput').value.trim();
const catNo = document.getElementById('catInput').value.trim();
const year = document.getElementById('yearInput').value.trim();
if (!artist || !title) {
showToast('Please enter at least artist and title', 'error');
return;
}
const messages = [
{
role: 'system',
content: 'You are a vinyl record eBay listing expert. Generate optimized titles, descriptions, and pricing strategies. Always return JSON format with: titles (array), description (string), condition_notes (string), price_estimate (object with min, max, recommended), and tags (array).'
},
{
role: 'user',
content: `Generate an eBay listing for: ${artist} - ${title}${catNo ? ` (Catalog: ${catNo})` : ''}${year ? ` (${year})` : ''}. Include optimized title options, professional HTML description, condition guidance, price estimate in GBP, and relevant tags.`
}
];
const provider = localStorage.getItem('ai_provider') || 'openai';
showToast(`Generating listing with ${provider === 'deepseek' ? 'DeepSeek' : 'OpenAI'}...`, 'success');
const result = await callAI(messages, 0.7);
if (result) {
try {
const data = JSON.parse(result);
// Populate the UI with AI-generated content
if (data.titles) {
renderTitleOptions(data.titles.map(t => ({
text: t.length > 80 ? t.substring(0, 77) + '...' : t,
chars: Math.min(t.length, 80),
style: 'AI Generated'
})));
}
if (data.description) {
document.getElementById('htmlOutput').value = data.description;
}
if (data.tags) {
const tagsContainer = document.getElementById('tagsOutput');
tagsContainer.innerHTML = data.tags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
}
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
showToast('AI listing generated!', 'success');
} catch (e) {
// If not valid JSON, treat as plain text description
document.getElementById('htmlOutput').value = result;
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
}
}
}
function requestHelp() {
alert(`VINYL PHOTO GUIDE:
ESSENTIAL SHOTS (need these):
• Front cover - square, no glare, color accurate
• Back cover - full frame, readable text
• Both labels - close enough to read all text
• Deadwax/runout - for pressing identification
CONDITION SHOTS:
• Vinyl in raking light at angle (shows scratches)
• Sleeve edges and corners
• Any flaws clearly documented
OPTIONARY BUT HELPFUL:
• Inner sleeve condition
• Inserts, posters, extras
• Hype stickers
• Barcode area
TIPS:
- Use natural daylight or 5500K bulbs
- Avoid flash directly on glossy sleeves
- Include scale reference if unusual size
- Photograph flaws honestly - reduces returns`);
}
function showToast(message, type = 'success') {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const iconMap = {
success: 'check',
error: 'alert-circle',
warning: 'alert-triangle'
};
const colorMap = {
success: 'text-green-400',
error: 'text-red-400',
warning: 'text-yellow-400'
};
const toast = document.createElement('div');
toast.className = `toast ${type} flex items-center gap-3`;
toast.innerHTML = `
<i data-feather="${iconMap[type] || 'info'}" class="w-5 h-5 ${colorMap[type] || 'text-blue-400'}"></i>
<span class="text-sm text-gray-200">${message}</span>
`;
document.body.appendChild(toast);
feather.replace();
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Cleanup function to delete all hosted images for current listing
async function cleanupHostedImages() {
if (window.currentListingImages) {
for (const img of window.currentListingImages) {
if (img.deleteUrl) {
await deleteHostedImage(img.deleteUrl);
}
}
window.currentListingImages = [];
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
console.log('VinylVault Pro initialized');
// Initialize drop zone
initDropZone();
// Warn about unsaved changes when leaving page with hosted images
window.addEventListener('beforeunload', (e) => {
if (hostedPhotoUrls.length > 0 && !window.listingPublished) {
// Optional: could add cleanup here or warn user
}
});
});
function renderTitleOptions(titles) {
const container = document.getElementById('titleOptions');
container.innerHTML = titles.map((t, i) => `
<div class="title-option ${i === 0 ? 'selected' : ''}" onclick="selectTitle(this, '${t.text.replace(/'/g, "\\'")}')">
<span class="char-count">${t.chars}/80</span>
<p class="font-medium text-gray-200 pr-16">${t.text}</p>
<p class="text-sm text-gray-500 mt-1">${t.style}</p>
</div>
`).join('');
}
function selectTitle(el, text) {
document.querySelectorAll('.title-option').forEach(o => o.classList.remove('selected'));
el.classList.add('selected');
// Update clipboard copy
navigator.clipboard.writeText(text);
showToast('Title copied to clipboard!', 'success');
}
function renderPricingStrategy(bin, strategy, comps, currency, goal) {
const container = document.getElementById('pricingStrategy');
const offerSettings = goal === 'max' ? 'Offers: OFF' :
`Auto-accept: ${currency}${Math.floor(bin * 0.85)} | Auto-decline: ${currency}${Math.floor(bin * 0.7)}`;
container.innerHTML = `
<div class="pricing-card recommended">
<div class="flex items-center gap-2 mb-3">
<span class="px-2 py-1 bg-accent/20 text-accent text-xs font-medium rounded">RECOMMENDED</span>
</div>
<p class="text-3xl font-bold text-white mb-1">${currency}${bin}</p>
<p class="text-sm text-gray-400 mb-3">Buy It Now</p>
<div class="space-y-2 text-sm">
<p class="flex justify-between"><span class="text-gray-500">Strategy:</span> <span class="text-gray-300">${strategy}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Best Offer:</span> <span class="text-gray-300">${offerSettings}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Duration:</span> <span class="text-gray-300">30 days (GTC)</span></p>
</div>
</div>
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-400 uppercase tracking-wide">Sold Comps by Grade</h4>
<div class="space-y-2">
<div class="flex justify-between items-center p-3 bg-surface rounded-lg">
<span class="text-green-400 font-medium">NM/NM-</span>
<span class="text-gray-300">${currency}${comps.nm.low}-${comps.nm.high} <span class="text-gray-500">(med: ${comps.nm.median})</span></span>
</div>
<div class="flex justify-between items-center p-3 bg-surface rounded-lg border border-accent/30">
<span class="text-accent font-medium">VG+/EX</span>
<span class="text-gray-300">${currency}${comps.vgplus.low}-${comps.vgplus.high} <span class="text-gray-500">(med: ${comps.vgplus.median})</span></span>
</div>
<div class="flex justify-between items-center p-3 bg-surface rounded-lg">
<span class="text-yellow-400 font-medium">VG/VG+</span>
<span class="text-gray-300">${currency}${comps.vg.low}-${comps.vg.high} <span class="text-gray-500">(med: ${comps.vg.median})</span></span>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">Based on last 90 days sold listings, same pressing. Prices exclude postage.</p>
</div>
`;
}
function renderFeeFloor(cost, fees, shipping, packing, safeFloor, currency) {
const container = document.getElementById('feeFloor');
container.innerHTML = `
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Your Cost</p>
<p class="text-xl font-bold text-gray-300">${currency}${cost.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Est. Fees</p>
<p class="text-xl font-bold text-red-400">${currency}${fees.toFixed(2)}</p>
<p class="text-xs text-gray-600">~16% total</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Ship + Pack</p>
<p class="text-xl font-bold text-gray-300">${currency}${(shipping + packing).toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/30">
<p class="text-xs text-green-500 uppercase mb-1">Safe Floor Price</p>
<p class="text-2xl font-bold text-green-400">${currency}${safeFloor}</p>
<p class="text-xs text-green-600/70">Auto-decline below this</p>
</div>
`;
}
async function renderHTMLDescription(data, titleObj) {
const { artist, title, catNo, year } = data;
// Use hosted URL if available, otherwise fallback to local object URL
let heroImg = '';
let galleryImages = [];
if (hostedPhotoUrls.length > 0) {
heroImg = hostedPhotoUrls[0].displayUrl || hostedPhotoUrls[0].url;
galleryImages = hostedPhotoUrls.slice(1).map(img => img.displayUrl || img.url);
} else if (uploadedPhotos.length > 0) {
heroImg = URL.createObjectURL(uploadedPhotos[0]);
galleryImages = uploadedPhotos.slice(1).map((_, i) => URL.createObjectURL(uploadedPhotos[i + 1]));
}
// Use OCR-detected values if available
const detectedLabel = window.detectedLabel || '[Verify from photos]';
const detectedCountry = window.detectedCountry || 'UK';
const detectedFormat = window.detectedFormat || 'LP • 33rpm';
const detectedGenre = window.detectedGenre || 'rock';
const detectedCondition = window.detectedCondition || 'VG+/VG+';
const detectedPressingInfo = window.detectedPressingInfo || '';
// Fetch tracklist and detailed info from Discogs if available
let tracklistHtml = '';
let pressingDetailsHtml = '';
let provenanceHtml = '';
if (window.discogsReleaseId && window.discogsService?.key) {
try {
const discogsData = await window.discogsService.fetchTracklist(window.discogsReleaseId);
if (discogsData && discogsData.tracklist) {
// Build tracklist HTML
const hasSideBreakdown = discogsData.tracklist.some(t => t.position && (t.position.startsWith('A') || t.position.startsWith('B')));
if (hasSideBreakdown) {
// Group by sides
const sides = {};
discogsData.tracklist.forEach(track => {
const side = track.position ? track.position.charAt(0) : 'Other';
if (!sides[side]) sides[side] = [];
sides[side].push(track);
});
tracklistHtml = Object.entries(sides).map(([side, tracks]) => `
<div style="margin-bottom: 16px;">
<h4 style="color: #7c3aed; font-size: 13px; font-weight: 600; margin: 0 0 8px 0; text-transform: uppercase; letter-spacing: 0.5px;">Side ${side}</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
${tracks.map(track => `
<div style="flex: 1 1 200px; min-width: 200px; display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0;">
<span style="color: #1e293b; font-size: 13px;"><strong>${track.position}</strong> ${track.title}</span>
${track.duration ? `<span style="color: #64748b; font-size: 12px; font-family: monospace;">${track.duration}</span>` : ''}
</div>
`).join('')}
</div>
</div>
`).join('');
} else {
// Simple list
tracklistHtml = `
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
${discogsData.tracklist.map(track => `
<div style="flex: 1 1 200px; min-width: 200px; display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0;">
<span style="color: #1e293b; font-size: 13px;">${track.position ? `<strong>${track.position}</strong> ` : ''}${track.title}</span>
${track.duration ? `<span style="color: #64748b; font-size: 12px; font-family: monospace;">${track.duration}</span>` : ''}
</div>
`).join('')}
</div>
`;
}
// Build pressing/variation details
const identifiers = discogsData.identifiers || [];
const barcodeInfo = identifiers.find(i => i.type === 'Barcode');
const matrixInfo = identifiers.filter(i => i.type === 'Matrix / Runout' || i.type === 'Runout');
const pressingInfo = identifiers.filter(i => i.type === 'Pressing Plant' || i.type === 'Mastering');
if (matrixInfo.length > 0 || barcodeInfo || pressingInfo.length > 0) {
pressingDetailsHtml = `
<div style="background: #f0fdf4; border-left: 4px solid #22c55e; padding: 16px 20px; margin: 24px 0; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #166534; font-size: 15px; font-weight: 600;">Pressing & Matrix Information</h3>
<div style="font-family: monospace; font-size: 13px; line-height: 1.6; color: #15803d;">
${barcodeInfo ? `<p style="margin: 4px 0;"><strong>Barcode:</strong> ${barcodeInfo.value}</p>` : ''}
${matrixInfo.map(m => `<p style="margin: 4px 0;"><strong>${m.type}:</strong> ${m.value}${m.description ? ` <em>(${m.description})</em>` : ''}</p>`).join('')}
${pressingInfo.map(p => `<p style="margin: 4px 0;"><strong>${p.type}:</strong> ${p.value}</p>`).join('')}
</div>
${discogsData.notes ? `<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #bbf7d0; font-size: 12px; color: #166534; font-style: italic;">${discogsData.notes.substring(0, 300)}${discogsData.notes.length > 300 ? '...' : ''}</p>` : ''}
</div>
`;
}
// Build provenance data for buyer confidence
const companies = discogsData.companies || [];
const masteredBy = companies.find(c => c.entity_type_name === 'Mastered At' || c.name.toLowerCase().includes('mastering'));
const pressedBy = companies.find(c => c.entity_type_name === 'Pressed By' || c.name.toLowerCase().includes('pressing'));
const lacquerCut = companies.find(c => c.entity_type_name === 'Lacquer Cut At');
if (masteredBy || pressedBy || lacquerCut) {
provenanceHtml = `
<div style="background: #eff6ff; border: 1px solid #bfdbfe; padding: 16px; margin: 24px 0; border-radius: 8px;">
<h3 style="margin: 0 0 12px 0; color: #1e40af; font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
Provenance & Production
</h3>
<div style="font-size: 13px; color: #1e3a8a; line-height: 1.6;">
${masteredBy ? `<p style="margin: 4px 0;">✓ Mastered at <strong>${masteredBy.name}</strong></p>` : ''}
${lacquerCut ? `<p style="margin: 4px 0;">✓ Lacquer cut at <strong>${lacquerCut.name}</strong></p>` : ''}
${pressedBy ? `<p style="margin: 4px 0;">✓ Pressed at <strong>${pressedBy.name}</strong></p>` : ''}
${discogsData.num_for_sale ? `<p style="margin: 8px 0 0 0; padding-top: 8px; border-top: 1px solid #bfdbfe; color: #3b82f6; font-size: 12px;">Reference: ${discogsData.num_for_sale} copies currently for sale on Discogs</p>` : ''}
</div>
</div>
`;
}
}
} catch (e) {
console.error('Failed to fetch Discogs details for HTML:', e);
}
}
// If no tracklist from Discogs, provide placeholder
if (!tracklistHtml) {
tracklistHtml = `<p style="color: #64748b; font-style: italic;">Tracklist verification recommended. Please compare with Discogs entry for accuracy.</p>`;
}
const galleryHtml = galleryImages.length > 0 ? `
<!-- PHOTO GALLERY -->
<div style="margin-bottom: 24px;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px;">
${galleryImages.map(url => `<img src="${url}" style="width: 100%; height: 150px; object-fit: cover; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);" alt="Record photo">`).join('')}
</div>
</div>
` : '';
const html = `<div style="max-width: 800px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #333; line-height: 1.6;">
<!-- HERO IMAGE -->
<div style="margin-bottom: 24px;">
<img src="${heroImg}" alt="${artist} - ${title}" style="width: 100%; max-width: 600px; display: block; margin: 0 auto; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.15);">
</div>
${galleryHtml}
<!-- BADGES -->
<div style="display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-bottom: 24px;">
<span style="background: #7c3aed; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">Original ${detectedCountry} Pressing</span>
<span style="background: #059669; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${year || '1970s'}</span>
<span style="background: #0891b2; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${detectedFormat}</span>
<span style="background: #d97706; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${detectedCondition}</span>
</div>
<!-- AT A GLANCE -->
<table style="width: 100%; border-collapse: collapse; margin-bottom: 24px; font-size: 14px;">
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600; width: 140px;">Artist</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${artist || 'See title'}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Title</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${title || 'See title'}</td>
</tr>
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Label</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${detectedLabel}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Catalogue</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;"><code style="background: #f1f5f9; padding: 2px 8px; border-radius: 4px;">${catNo || '[See photos]'}</code></td>
</tr>
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Country</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${detectedCountry}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Year</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${year || '[Verify]'}</td>
</tr>
</table>
<!-- CONDITION -->
<div style="background: #fefce8; border-left: 4px solid #eab308; padding: 16px 20px; margin-bottom: 24px; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #854d0e; font-size: 16px; font-weight: 600;">Condition Report</h3>
<div style="display: grid; gap: 12px;">
<div>
<strong style="color: #713f12;">Vinyl:</strong> <span style="color: #854d0e;">VG+ — Light surface marks, plays cleanly with minimal surface noise. No skips or jumps. [Adjust based on actual inspection]</span>
</div>
<div>
<strong style="color: #713f12;">Sleeve:</strong> <span style="color: #854d0e;">VG+ — Minor edge wear, light ring wear visible under raking light. No splits or writing. [Adjust based on actual inspection]</span>
</div>
<div>
<strong style="color: #713f12;">Inner Sleeve:</strong> <span style="color: #854d0e;">Original paper inner included, small split at bottom seam. [Verify/Adjust]</span>
</div>
</div>
</div>
<!-- ABOUT -->
<h3 style="color: #1e293b; font-size: 18px; font-weight: 600; margin-bottom: 12px;">About This Release</h3>
<p style="margin-bottom: 16px; color: #475569;">${detectedGenre ? `${detectedGenre.charAt(0).toUpperCase() + detectedGenre.slice(1)} release` : 'Vintage vinyl release'}${detectedPressingInfo ? `. Matrix/Runout: ${detectedPressingInfo}` : ''}. [Add accurate description based on verified pressing details. Mention notable features: gatefold, insert, poster, hype sticker, etc.]</p>
<!-- TRACKLIST -->
<h3 style="color: #1e293b; font-size: 18px; font-weight: 600; margin-bottom: 12px;">Tracklist</h3>
<div style="background: #f8fafc; padding: 16px 20px; border-radius: 8px; margin-bottom: 24px;">
${tracklistHtml}
</div>
${pressingDetailsHtml}
${provenanceHtml}
<!-- PACKING -->
<div style="background: #eff6ff; border-left: 4px solid #3b82f6; padding: 16px 20px; margin-bottom: 24px; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #1e40af; font-size: 16px; font-weight: 600;">Packing & Postage</h3>
<p style="margin: 0 0 12px 0; color: #1e3a8a;">Records are removed from outer sleeves to prevent seam splits during transit. Packed with stiffeners in a dedicated LP mailer. Royal Mail 48 Tracked or courier service.</p>
<p style="margin: 0; color: #1e3a8a; font-size: 14px;"><strong>Combined postage:</strong> Discount available for multiple purchases—please request invoice before payment.</p>
</div>
<!-- CTA -->
<div style="text-align: center; padding: 24px; background: #f1f5f9; border-radius: 12px;">
<p style="margin: 0 0 8px 0; color: #475569; font-weight: 500;">Questions? Need more photos?</p>
<p style="margin: 0; color: #64748b; font-size: 14px;">Message me anytime—happy to provide additional angles, audio clips, or pressing details.</p>
</div>
</div>`;
// Store reference to hosted images for potential cleanup
window.currentListingImages = hostedPhotoUrls.map(img => ({
url: img.url,
deleteUrl: img.deleteUrl
}));
document.getElementById('htmlOutput').value = html;
}
function renderTags(artist, title, catNo, year) {
const genre = window.detectedGenre || 'rock';
const format = window.detectedFormat?.toLowerCase().includes('7"') ? '7 inch' :
window.detectedFormat?.toLowerCase().includes('12"') ? '12 inch single' : 'lp';
const country = window.detectedCountry?.toLowerCase() || 'uk';
const tags = [
artist || 'vinyl',
title || 'record',
format,
'vinyl record',
'original pressing',
`${country} pressing`,
year || 'vintage',
catNo || '',
genre,
genre === 'rock' ? 'prog rock' : genre,
genre === 'rock' ? 'psych' : '',
'collector',
'audiophile',
format === 'lp' ? '12 inch' : format,
'33 rpm',
format === 'lp' ? 'album' : 'single',
'used vinyl',
'graded',
'excellent condition',
'rare vinyl',
'classic rock',
'vintage vinyl',
'record collection',
'music',
'audio',
window.detectedLabel || ''
].filter(Boolean);
const container = document.getElementById('tagsOutput');
container.innerHTML = tags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
}
function renderShotList() {
// Map shot types to display info
const shotDefinitions = [
{ id: 'front', name: 'Front cover (square, well-lit)', critical: true },
{ id: 'back', name: 'Back cover (full shot)', critical: true },
{ id: 'spine', name: 'Spine (readable text)', critical: true },
{ id: 'label_a', name: 'Label Side A (close, legible)', critical: true },
{ id: 'label_b', name: 'Label Side B (close, legible)', critical: true },
{ id: 'deadwax', name: 'Deadwax/runout grooves', critical: true },
{ id: 'inner', name: 'Inner sleeve (both sides)', critical: false },
{ id: 'insert', name: 'Insert/poster if included', critical: false },
{ id: 'hype', name: 'Hype sticker (if present)', critical: false },
{ id: 'vinyl', name: 'Vinyl in raking light (flaws)', critical: true },
{ id: 'corners', name: 'Sleeve corners/edges detail', critical: false },
{ id: 'barcode', name: 'Barcode area', critical: false }
];
// Check if we have any photos at all
const hasPhotos = uploadedPhotos.length > 0;
const container = document.getElementById('shotList');
container.innerHTML = shotDefinitions.map(shot => {
const have = detectedPhotoTypes.has(shot.id) || (shot.id === 'front' && hasPhotos) || (shot.id === 'back' && uploadedPhotos.length > 1);
const statusClass = have ? 'completed' : shot.critical ? 'missing' : '';
const iconColor = have ? 'text-green-500' : shot.critical ? 'text-yellow-500' : 'text-gray-500';
const textClass = have ? 'text-gray-400 line-through' : 'text-gray-300';
const icon = have ? 'check-circle' : shot.critical ? 'alert-circle' : 'circle';
return `
<div class="shot-item ${statusClass}">
<i data-feather="${icon}"
class="w-5 h-5 ${iconColor} flex-shrink-0"></i>
<span class="text-sm ${textClass}">${shot.name}</span>
${shot.critical && !have ? '<span class="ml-auto text-xs text-yellow-500 font-medium">CRITICAL</span>' : ''}
</div>
`}).join('');
feather.replace();
}
function copyHTML() {
const html = document.getElementById('htmlOutput');
html.select();
document.execCommand('copy');
showToast('HTML copied to clipboard!', 'success');
}
function copyTags() {
const tags = Array.from(document.querySelectorAll('#tagsOutput span')).map(s => s.textContent).join(', ');
navigator.clipboard.writeText(tags);
showToast('Tags copied to clipboard!', 'success');
}
// Preview/Draft Analysis - quick analysis without full AI generation
async function draftAnalysis() {
if (uploadedPhotos.length === 0) {
showToast('Upload photos first for preview', 'error');
return;
}
const artist = document.getElementById('artistInput').value.trim();
const title = document.getElementById('titleInput').value.trim();
// Show loading state
const dropZone = document.getElementById('dropZone');
const spinner = document.getElementById('uploadSpinner');
spinner.classList.remove('hidden');
dropZone.classList.add('pointer-events-none');
startAnalysisProgressSimulation();
try {
// Try OCR/AI analysis if available
const service = getAIService();
let ocrResult = null;
if (service && service.apiKey && uploadedPhotos.length > 0) {
try {
ocrResult = await service.analyzeRecordImages(uploadedPhotos.slice(0, 2)); // Limit to 2 photos for speed
populateFieldsFromOCR(ocrResult);
} catch (e) {
console.log('Preview OCR failed:', e);
}
}
// Generate quick preview results
const catNo = document.getElementById('catInput').value.trim() || ocrResult?.catalogueNumber || '';
const year = document.getElementById('yearInput').value.trim() || ocrResult?.year || '';
const detectedArtist = artist || ocrResult?.artist || 'Unknown Artist';
const detectedTitle = title || ocrResult?.title || 'Unknown Title';
const baseTitle = `${detectedArtist} - ${detectedTitle}`;
// Generate quick titles
const quickTitles = [
`${baseTitle} ${year ? `(${year})` : ''} ${catNo} VG+`.substring(0, 80),
`${baseTitle} Original Pressing Vinyl LP`.substring(0, 80),
`${detectedArtist} ${detectedTitle} ${catNo || 'LP'}`.substring(0, 80)
].map((t, i) => ({
text: t,
chars: t.length,
style: ['Quick', 'Standard', 'Compact'][i]
}));
// Quick pricing estimate based on condition
const cost = parseFloat(document.getElementById('costInput').value) || 10;
const vinylCond = document.getElementById('vinylConditionInput').value;
const sleeveCond = document.getElementById('sleeveConditionInput').value;
const conditionMultipliers = { 'M': 3, 'NM': 2.5, 'VG+': 1.8, 'VG': 1.2, 'G+': 0.8, 'G': 0.5 };
const condMult = (conditionMultipliers[vinylCond] || 1) * 0.7 + (conditionMultipliers[sleeveCond] || 1) * 0.3;
const estimatedValue = Math.round(cost * Math.max(condMult, 1.5));
const suggestedPrice = Math.round(estimatedValue * 0.9);
// Render preview results
renderTitleOptions(quickTitles);
// Quick pricing card
document.getElementById('pricingStrategy').innerHTML = `
<div class="pricing-card recommended">
<div class="flex items-center gap-2 mb-3">
<span class="px-2 py-1 bg-accent/20 text-accent text-xs font-medium rounded">QUICK ESTIMATE</span>
</div>
<p class="text-3xl font-bold text-white mb-1">£${suggestedPrice}</p>
<p class="text-sm text-gray-400 mb-3">Suggested Buy It Now</p>
<div class="space-y-2 text-sm">
<p class="flex justify-between"><span class="text-gray-500">Est. Value:</span> <span class="text-gray-300">£${estimatedValue}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Your Cost:</span> <span class="text-gray-300">£${cost.toFixed(2)}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Condition:</span> <span class="text-gray-300">${vinylCond}/${sleeveCond}</span></p>
</div>
</div>
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-400 uppercase tracking-wide">Preview Notes</h4>
<div class="p-3 bg-surface rounded-lg text-sm text-gray-400">
${ocrResult ?
`<p class="text-green-400 mb-2">✓ AI detected information from photos</p>` :
`<p class="text-yellow-400 mb-2">⚠ Add API key in Settings for auto-detection</p>`
}
<p>This is a quick estimate based on your cost and condition. Run "Generate Full Listing" for complete market analysis, sold comps, and optimized pricing.</p>
</div>
${ocrResult ? `
<div class="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<p class="text-xs text-green-400 font-medium mb-1">Detected from photos:</p>
<ul class="text-xs text-gray-400 space-y-1">
${ocrResult.artist ? `<li>• Artist: ${ocrResult.artist}</li>` : ''}
${ocrResult.title ? `<li>• Title: ${ocrResult.title}</li>` : ''}
${ocrResult.catalogueNumber ? `<li>• Cat#: ${ocrResult.catalogueNumber}</li>` : ''}
${ocrResult.year ? `<li>• Year: ${ocrResult.year}</li>` : ''}
</ul>
</div>
` : ''}
</div>
`;
// Simple fee floor
const fees = suggestedPrice * 0.16;
const safeFloor = Math.ceil(cost + fees + 6);
document.getElementById('feeFloor').innerHTML = `
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Your Cost</p>
<p class="text-xl font-bold text-gray-300">£${cost.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Est. Fees</p>
<p class="text-xl font-bold text-red-400">£${fees.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Ship + Pack</p>
<p class="text-xl font-bold text-gray-300">£6.00</p>
</div>
<div class="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/30">
<p class="text-xs text-green-500 uppercase mb-1">Safe Floor</p>
<p class="text-2xl font-bold text-green-400">£${safeFloor}</p>
</div>
`;
// Preview HTML description
const previewHtml = `<!-- QUICK PREVIEW - Generated by VinylVault Pro -->
<div style="max-width: 700px; margin: 0 auto; font-family: sans-serif;">
<h2 style="color: #333;">${detectedArtist} - ${detectedTitle}</h2>
${year ? `<p><strong>Year:</strong> ${year}</p>` : ''}
${catNo ? `<p><strong>Catalogue #:</strong> ${catNo}</p>` : ''}
<p><strong>Condition:</strong> Vinyl ${vinylCond}, Sleeve ${sleeveCond}</p>
<hr style="margin: 20px 0;">
<p style="color: #666;">[Full description will be generated with complete market analysis]</p>
</div>`;
const htmlOutput = document.getElementById('htmlOutput');
if (htmlOutput) htmlOutput.value = previewHtml;
// Preview tags
const previewTags = [
detectedArtist,
detectedTitle,
'vinyl',
'record',
vinylCond,
'lp',
year || 'vintage'
].filter(Boolean);
const tagsOutput = document.getElementById('tagsOutput');
if (tagsOutput) {
tagsOutput.innerHTML = previewTags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
}
// Update shot list
renderShotList();
// Show results
const resultsSection = document.getElementById('resultsSection');
const emptyState = document.getElementById('emptyState');
if (resultsSection) resultsSection.classList.remove('hidden');
if (emptyState) emptyState.classList.add('hidden');
if (resultsSection) resultsSection.scrollIntoView({ behavior: 'smooth' });
showToast('Quick preview ready! Click "Generate Full Listing" for complete analysis.', 'success');
} catch (error) {
console.error('Preview error:', error);
showToast('Preview failed: ' + error.message, 'error');
} finally {
stopAnalysisProgress();
setTimeout(() => {
spinner.classList.add('hidden');
dropZone.classList.remove('pointer-events-none');
updateAnalysisProgress('Initializing...', 0);
}, 300);
}
}
async function callAI(messages, temperature = 0.7) {
const provider = localStorage.getItem('ai_provider') || 'openai';
if (provider === 'deepseek' && window.deepseekService?.isConfigured) {
try {
const response = await fetch('https://api.deepseek.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('deepseek_api_key')}`
},
body: JSON.stringify({
model: localStorage.getItem('deepseek_model') || 'deepseek-chat',
messages: messages,
temperature: temperature,
max_tokens: 2000
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'DeepSeek API request failed');
}
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
showToast(`DeepSeek Error: ${error.message}`, 'error');
return null;
}
} else {
// Fallback to OpenAI
const apiKey = localStorage.getItem('openai_api_key');
const model = localStorage.getItem('openai_model') || 'gpt-4o';
const maxTokens = parseInt(localStorage.getItem('openai_max_tokens')) || 2000;
if (!apiKey) {
showToast('OpenAI API key not configured. Go to Settings.', 'error');
return null;
}
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model: model,
messages: messages,
temperature: temperature,
max_tokens: maxTokens
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'API request failed');
}
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
showToast(`OpenAI Error: ${error.message}`, 'error');
return null;
}
}
}
// Legacy alias for backward compatibility
async function callOpenAI(messages, temperature = 0.7) {
return callAI(messages, temperature);
}
// Delete hosted image from imgBB
async function deleteHostedImage(deleteUrl) {
if (!deleteUrl) return false;
try {
const response = await fetch(deleteUrl, { method: 'GET' });
// imgBB delete URLs work via GET request
return response.ok;
} catch (error) {
console.error('Failed to delete image:', error);
return false;
}
}
// Get hosted photo URLs for eBay HTML description
function getHostedPhotoUrlsForEbay() {
return hostedPhotoUrls.map(img => ({
full: img.url,
display: img.displayUrl || img.url,
thumb: img.thumb,
medium: img.medium,
viewer: img.viewerUrl
}));
}
async function generateListingWithAI() {
const artist = document.getElementById('artistInput').value.trim();
const title = document.getElementById('titleInput').value.trim();
const catNo = document.getElementById('catInput').value.trim();
const year = document.getElementById('yearInput').value.trim();
if (!artist || !title) {
showToast('Please enter at least artist and title', 'error');
return;
}
const messages = [
{
role: 'system',
content: 'You are a vinyl record eBay listing expert. Generate optimized titles, descriptions, and pricing strategies. Always return JSON format with: titles (array), description (string), condition_notes (string), price_estimate (object with min, max, recommended), and tags (array).'
},
{
role: 'user',
content: `Generate an eBay listing for: ${artist} - ${title}${catNo ? ` (Catalog: ${catNo})` : ''}${year ? ` (${year})` : ''}. Include optimized title options, professional HTML description, condition guidance, price estimate in GBP, and relevant tags.`
}
];
const provider = localStorage.getItem('ai_provider') || 'openai';
showToast(`Generating listing with ${provider === 'deepseek' ? 'DeepSeek' : 'OpenAI'}...`, 'success');
const result = await callAI(messages, 0.7);
if (result) {
try {
const data = JSON.parse(result);
// Populate the UI with AI-generated content
if (data.titles) {
renderTitleOptions(data.titles.map(t => ({
text: t.length > 80 ? t.substring(0, 77) + '...' : t,
chars: Math.min(t.length, 80),
style: 'AI Generated'
})));
}
if (data.description) {
document.getElementById('htmlOutput').value = data.description;
}
if (data.tags) {
const tagsContainer = document.getElementById('tagsOutput');
tagsContainer.innerHTML = data.tags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
}
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
showToast('AI listing generated!', 'success');
} catch (e) {
// If not valid JSON, treat as plain text description
document.getElementById('htmlOutput').value = result;
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
}
}
}
function requestHelp() {
alert(`VINYL PHOTO GUIDE:
ESSENTIAL SHOTS (need these):
• Front cover - square, no glare, color accurate
• Back cover - full frame, readable text
• Both labels - close enough to read all text
• Deadwax/runout - for pressing identification
CONDITION SHOTS:
• Vinyl in raking light at angle (shows scratches)
• Sleeve edges and corners
• Any flaws clearly documented
OPTIONARY BUT HELPFUL:
• Inner sleeve condition
• Inserts, posters, extras
• Hype stickers
• Barcode area
TIPS:
- Use natural daylight or 5500K bulbs
- Avoid flash directly on glossy sleeves
- Include scale reference if unusual size
- Photograph flaws honestly - reduces returns`);
}
function showToast(message, type = 'success') {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const iconMap = {
success: 'check',
error: 'alert-circle',
warning: 'alert-triangle'
};
const colorMap = {
success: 'text-green-400',
error: 'text-red-400',
warning: 'text-yellow-400'
};
const toast = document.createElement('div');
toast.className = `toast ${type} flex items-center gap-3`;
toast.innerHTML = `
<i data-feather="${iconMap[type] || 'info'}" class="w-5 h-5 ${colorMap[type] || 'text-blue-400'}"></i>
<span class="text-sm text-gray-200">${message}</span>
`;
document.body.appendChild(toast);
feather.replace();
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Cleanup function to delete all hosted images for current listing
async function cleanupHostedImages() {
if (window.currentListingImages) {
for (const img of window.currentListingImages) {
if (img.deleteUrl) {
await deleteHostedImage(img.deleteUrl);
}
}
window.currentListingImages = [];
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
console.log('VinylVault Pro initialized');
// Initialize drop zone
initDropZone();
// Warn about unsaved changes when leaving page with hosted images
window.addEventListener('beforeunload', (e) => {
if (hostedPhotoUrls.length > 0 && !window.listingPublished) {
// Optional: could add cleanup here or warn user
}
});
});
: '€';
// Mock comp research results
const comps = {
nm: { low: 45, high: 65, median: 52 },
vgplus: { low: 28, high: 42, median: 34 },
vg: { low: 15, high: 25, median: 19 }
};
// Calculate recommended price based on goal
let recommendedBin, strategy;
switch(goal) {
case 'quick':
recommendedBin = Math.round(comps.vgplus.low * 0.9);
strategy = 'BIN + Best Offer (aggressive)';
break;
case 'max':
recommendedBin = Math.round(comps.nm.high * 1.1);
strategy = 'BIN only, no offers, long duration';
break;
default:
recommendedBin = comps.vgplus.median;
strategy = 'BIN + Best Offer (standard)';
}
// Fee calculation (eBay UK approx)
const ebayFeeRate = 0.13; // 13% final value fee
const paypalRate = 0.029; // 2.9% + 30p
const fixedFee = 0.30;
const shippingCost = 4.50; // Estimated
const packingCost = 1.50;
const totalFees = (recommendedBin * ebayFeeRate) + (recommendedBin * paypalRate) + fixedFee;
const breakEven = cost + totalFees + shippingCost + packingCost;
const safeFloor = Math.ceil(breakEven * 1.05); // 5% buffer
// Generate titles
const baseTitle = `${artist || 'ARTIST'} - ${title || 'TITLE'}`;
const titles = generateTitles(baseTitle, catNo, year, goal);
// Render results
renderTitleOptions(titles);
renderPricingStrategy(recommendedBin, strategy, comps, currency, goal);
renderFeeFloor(cost, totalFees, shippingCost, packingCost, safeFloor, currency);
await renderHTMLDescription(data, titles[0]);
renderTags(artist, title, catNo, year);
renderShotList();
// Show results
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
resultsSection.scrollIntoView({ behavior: 'smooth' });
currentAnalysis = {
titles, recommendedBin, strategy, breakEven, safeFloor, currency
};
}
function generateTitles(base, catNo, year, goal) {
const titles = [];
const cat = catNo || 'CAT#';
const yr = year || 'YEAR';
const country = window.detectedCountry || 'UK';
const genre = window.detectedGenre || 'Rock';
const format = window.detectedFormat?.includes('7"') ? '7"' : window.detectedFormat?.includes('12"') ? '12"' : 'LP';
// Option 1: Classic collector focus
titles.push(`${base} ${format} ${yr} ${country} 1st Press ${cat} EX/VG+`);
// Option 2: Condition forward
titles.push(`NM! ${base} Original ${yr} Vinyl ${format} ${cat} Nice Copy`);
// Option 3: Rarity/hype with detected genre
titles.push(`${base} ${yr} ${country} Press ${cat} Rare Vintage ${genre} ${format}`);
// Option 4: Clean searchable
titles.push(`${base} Vinyl ${format} ${yr} ${cat} Excellent Condition`);
// Option 5: Genre tagged
titles.push(`${base} ${yr} ${format} ${genre} ${cat} VG+ Plays Great`);
return titles.map((t, i) => ({
text: t.length > 80 ? t.substring(0, 77) + '...' : t,
chars: Math.min(t.length, 80),
style: ['Classic Collector', 'Condition Forward', 'Rarity Focus', 'Clean Search', 'Genre Tagged'][i]
}));
}
function renderTitleOptions(titles) {
const container = document.getElementById('titleOptions');
container.innerHTML = titles.map((t, i) => `
<div class="title-option ${i === 0 ? 'selected' : ''}" onclick="selectTitle(this, '${t.text.replace(/'/g, "\\'")}')">
<span class="char-count">${t.chars}/80</span>
<p class="font-medium text-gray-200 pr-16">${t.text}</p>
<p class="text-sm text-gray-500 mt-1">${t.style}</p>
</div>
`).join('');
}
function selectTitle(el, text) {
document.querySelectorAll('.title-option').forEach(o => o.classList.remove('selected'));
el.classList.add('selected');
// Update clipboard copy
navigator.clipboard.writeText(text);
showToast('Title copied to clipboard!', 'success');
}
function renderPricingStrategy(bin, strategy, comps, currency, goal) {
const container = document.getElementById('pricingStrategy');
const offerSettings = goal === 'max' ? 'Offers: OFF' :
`Auto-accept: ${currency}${Math.floor(bin * 0.85)} | Auto-decline: ${currency}${Math.floor(bin * 0.7)}`;
container.innerHTML = `
<div class="pricing-card recommended">
<div class="flex items-center gap-2 mb-3">
<span class="px-2 py-1 bg-accent/20 text-accent text-xs font-medium rounded">RECOMMENDED</span>
</div>
<p class="text-3xl font-bold text-white mb-1">${currency}${bin}</p>
<p class="text-sm text-gray-400 mb-3">Buy It Now</p>
<div class="space-y-2 text-sm">
<p class="flex justify-between"><span class="text-gray-500">Strategy:</span> <span class="text-gray-300">${strategy}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Best Offer:</span> <span class="text-gray-300">${offerSettings}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Duration:</span> <span class="text-gray-300">30 days (GTC)</span></p>
</div>
</div>
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-400 uppercase tracking-wide">Sold Comps by Grade</h4>
<div class="space-y-2">
<div class="flex justify-between items-center p-3 bg-surface rounded-lg">
<span class="text-green-400 font-medium">NM/NM-</span>
<span class="text-gray-300">${currency}${comps.nm.low}-${comps.nm.high} <span class="text-gray-500">(med: ${comps.nm.median})</span></span>
</div>
<div class="flex justify-between items-center p-3 bg-surface rounded-lg border border-accent/30">
<span class="text-accent font-medium">VG+/EX</span>
<span class="text-gray-300">${currency}${comps.vgplus.low}-${comps.vgplus.high} <span class="text-gray-500">(med: ${comps.vgplus.median})</span></span>
</div>
<div class="flex justify-between items-center p-3 bg-surface rounded-lg">
<span class="text-yellow-400 font-medium">VG/VG+</span>
<span class="text-gray-300">${currency}${comps.vg.low}-${comps.vg.high} <span class="text-gray-500">(med: ${comps.vg.median})</span></span>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">Based on last 90 days sold listings, same pressing. Prices exclude postage.</p>
</div>
`;
}
function renderFeeFloor(cost, fees, shipping, packing, safeFloor, currency) {
const container = document.getElementById('feeFloor');
container.innerHTML = `
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Your Cost</p>
<p class="text-xl font-bold text-gray-300">${currency}${cost.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Est. Fees</p>
<p class="text-xl font-bold text-red-400">${currency}${fees.toFixed(2)}</p>
<p class="text-xs text-gray-600">~16% total</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Ship + Pack</p>
<p class="text-xl font-bold text-gray-300">${currency}${(shipping + packing).toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/30">
<p class="text-xs text-green-500 uppercase mb-1">Safe Floor Price</p>
<p class="text-2xl font-bold text-green-400">${currency}${safeFloor}</p>
<p class="text-xs text-green-600/70">Auto-decline below this</p>
</div>
`;
}
async function renderHTMLDescription(data, titleObj) {
const { artist, title, catNo, year } = data;
// Use hosted URL if available, otherwise fallback to local object URL
let heroImg = '';
let galleryImages = [];
if (hostedPhotoUrls.length > 0) {
heroImg = hostedPhotoUrls[0].displayUrl || hostedPhotoUrls[0].url;
galleryImages = hostedPhotoUrls.slice(1).map(img => img.displayUrl || img.url);
} else if (uploadedPhotos.length > 0) {
heroImg = URL.createObjectURL(uploadedPhotos[0]);
galleryImages = uploadedPhotos.slice(1).map((_, i) => URL.createObjectURL(uploadedPhotos[i + 1]));
}
// Use OCR-detected values if available
const detectedLabel = window.detectedLabel || '[Verify from photos]';
const detectedCountry = window.detectedCountry || 'UK';
const detectedFormat = window.detectedFormat || 'LP • 33rpm';
const detectedGenre = window.detectedGenre || 'rock';
const detectedCondition = window.detectedCondition || 'VG+/VG+';
const detectedPressingInfo = window.detectedPressingInfo || '';
// Fetch tracklist and detailed info from Discogs if available
let tracklistHtml = '';
let pressingDetailsHtml = '';
let provenanceHtml = '';
if (window.discogsReleaseId && window.discogsService?.key) {
try {
const discogsData = await window.discogsService.fetchTracklist(window.discogsReleaseId);
if (discogsData && discogsData.tracklist) {
// Build tracklist HTML
const hasSideBreakdown = discogsData.tracklist.some(t => t.position && (t.position.startsWith('A') || t.position.startsWith('B')));
if (hasSideBreakdown) {
// Group by sides
const sides = {};
discogsData.tracklist.forEach(track => {
const side = track.position ? track.position.charAt(0) : 'Other';
if (!sides[side]) sides[side] = [];
sides[side].push(track);
});
tracklistHtml = Object.entries(sides).map(([side, tracks]) => `
<div style="margin-bottom: 16px;">
<h4 style="color: #7c3aed; font-size: 13px; font-weight: 600; margin: 0 0 8px 0; text-transform: uppercase; letter-spacing: 0.5px;">Side ${side}</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
${tracks.map(track => `
<div style="flex: 1 1 200px; min-width: 200px; display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0;">
<span style="color: #1e293b; font-size: 13px;"><strong>${track.position}</strong> ${track.title}</span>
${track.duration ? `<span style="color: #64748b; font-size: 12px; font-family: monospace;">${track.duration}</span>` : ''}
</div>
`).join('')}
</div>
</div>
`).join('');
} else {
// Simple list
tracklistHtml = `
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
${discogsData.tracklist.map(track => `
<div style="flex: 1 1 200px; min-width: 200px; display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0;">
<span style="color: #1e293b; font-size: 13px;">${track.position ? `<strong>${track.position}</strong> ` : ''}${track.title}</span>
${track.duration ? `<span style="color: #64748b; font-size: 12px; font-family: monospace;">${track.duration}</span>` : ''}
</div>
`).join('')}
</div>
`;
}
// Build pressing/variation details
const identifiers = discogsData.identifiers || [];
const barcodeInfo = identifiers.find(i => i.type === 'Barcode');
const matrixInfo = identifiers.filter(i => i.type === 'Matrix / Runout' || i.type === 'Runout');
const pressingInfo = identifiers.filter(i => i.type === 'Pressing Plant' || i.type === 'Mastering');
if (matrixInfo.length > 0 || barcodeInfo || pressingInfo.length > 0) {
pressingDetailsHtml = `
<div style="background: #f0fdf4; border-left: 4px solid #22c55e; padding: 16px 20px; margin: 24px 0; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #166534; font-size: 15px; font-weight: 600;">Pressing & Matrix Information</h3>
<div style="font-family: monospace; font-size: 13px; line-height: 1.6; color: #15803d;">
${barcodeInfo ? `<p style="margin: 4px 0;"><strong>Barcode:</strong> ${barcodeInfo.value}</p>` : ''}
${matrixInfo.map(m => `<p style="margin: 4px 0;"><strong>${m.type}:</strong> ${m.value}${m.description ? ` <em>(${m.description})</em>` : ''}</p>`).join('')}
${pressingInfo.map(p => `<p style="margin: 4px 0;"><strong>${p.type}:</strong> ${p.value}</p>`).join('')}
</div>
${discogsData.notes ? `<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #bbf7d0; font-size: 12px; color: #166534; font-style: italic;">${discogsData.notes.substring(0, 300)}${discogsData.notes.length > 300 ? '...' : ''}</p>` : ''}
</div>
`;
}
// Build provenance data for buyer confidence
const companies = discogsData.companies || [];
const masteredBy = companies.find(c => c.entity_type_name === 'Mastered At' || c.name.toLowerCase().includes('mastering'));
const pressedBy = companies.find(c => c.entity_type_name === 'Pressed By' || c.name.toLowerCase().includes('pressing'));
const lacquerCut = companies.find(c => c.entity_type_name === 'Lacquer Cut At');
if (masteredBy || pressedBy || lacquerCut) {
provenanceHtml = `
<div style="background: #eff6ff; border: 1px solid #bfdbfe; padding: 16px; margin: 24px 0; border-radius: 8px;">
<h3 style="margin: 0 0 12px 0; color: #1e40af; font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
Provenance & Production
</h3>
<div style="font-size: 13px; color: #1e3a8a; line-height: 1.6;">
${masteredBy ? `<p style="margin: 4px 0;">✓ Mastered at <strong>${masteredBy.name}</strong></p>` : ''}
${lacquerCut ? `<p style="margin: 4px 0;">✓ Lacquer cut at <strong>${lacquerCut.name}</strong></p>` : ''}
${pressedBy ? `<p style="margin: 4px 0;">✓ Pressed at <strong>${pressedBy.name}</strong></p>` : ''}
${discogsData.num_for_sale ? `<p style="margin: 8px 0 0 0; padding-top: 8px; border-top: 1px solid #bfdbfe; color: #3b82f6; font-size: 12px;">Reference: ${discogsData.num_for_sale} copies currently for sale on Discogs</p>` : ''}
</div>
</div>
`;
}
}
} catch (e) {
console.error('Failed to fetch Discogs details for HTML:', e);
}
}
// If no tracklist from Discogs, provide placeholder
if (!tracklistHtml) {
tracklistHtml = `<p style="color: #64748b; font-style: italic;">Tracklist verification recommended. Please compare with Discogs entry for accuracy.</p>`;
}
const galleryHtml = galleryImages.length > 0 ? `
<!-- PHOTO GALLERY -->
<div style="margin-bottom: 24px;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px;">
${galleryImages.map(url => `<img src="${url}" style="width: 100%; height: 150px; object-fit: cover; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);" alt="Record photo">`).join('')}
</div>
</div>
` : '';
const html = `<div style="max-width: 800px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #333; line-height: 1.6;">
<!-- HERO IMAGE -->
<div style="margin-bottom: 24px;">
<img src="${heroImg}" alt="${artist} - ${title}" style="width: 100%; max-width: 600px; display: block; margin: 0 auto; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.15);">
</div>
${galleryHtml}
<!-- BADGES -->
<div style="display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-bottom: 24px;">
<span style="background: #7c3aed; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">Original ${detectedCountry} Pressing</span>
<span style="background: #059669; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${year || '1970s'}</span>
<span style="background: #0891b2; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${detectedFormat}</span>
<span style="background: #d97706; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${detectedCondition}</span>
</div>
<!-- AT A GLANCE -->
<table style="width: 100%; border-collapse: collapse; margin-bottom: 24px; font-size: 14px;">
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600; width: 140px;">Artist</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${artist || 'See title'}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Title</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${title || 'See title'}</td>
</tr>
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Label</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${detectedLabel}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Catalogue</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;"><code style="background: #f1f5f9; padding: 2px 8px; border-radius: 4px;">${catNo || '[See photos]'}</code></td>
</tr>
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Country</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${detectedCountry}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Year</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${year || '[Verify]'}</td>
</tr>
</table>
<!-- CONDITION -->
<div style="background: #fefce8; border-left: 4px solid #eab308; padding: 16px 20px; margin-bottom: 24px; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #854d0e; font-size: 16px; font-weight: 600;">Condition Report</h3>
<div style="display: grid; gap: 12px;">
<div>
<strong style="color: #713f12;">Vinyl:</strong> <span style="color: #854d0e;">VG+ — Light surface marks, plays cleanly with minimal surface noise. No skips or jumps. [Adjust based on actual inspection]</span>
</div>
<div>
<strong style="color: #713f12;">Sleeve:</strong> <span style="color: #854d0e;">VG+ — Minor edge wear, light ring wear visible under raking light. No splits or writing. [Adjust based on actual inspection]</span>
</div>
<div>
<strong style="color: #713f12;">Inner Sleeve:</strong> <span style="color: #854d0e;">Original paper inner included, small split at bottom seam. [Verify/Adjust]</span>
</div>
</div>
</div>
<!-- ABOUT -->
<h3 style="color: #1e293b; font-size: 18px; font-weight: 600; margin-bottom: 12px;">About This Release</h3>
<p style="margin-bottom: 16px; color: #475569;">${detectedGenre ? `${detectedGenre.charAt(0).toUpperCase() + detectedGenre.slice(1)} release` : 'Vintage vinyl release'}${detectedPressingInfo ? `. Matrix/Runout: ${detectedPressingInfo}` : ''}. [Add accurate description based on verified pressing details. Mention notable features: gatefold, insert, poster, hype sticker, etc.]</p>
<!-- TRACKLIST -->
<h3 style="color: #1e293b; font-size: 18px; font-weight: 600; margin-bottom: 12px;">Tracklist</h3>
<div style="background: #f8fafc; padding: 16px 20px; border-radius: 8px; margin-bottom: 24px;">
${tracklistHtml}
</div>
${pressingDetailsHtml}
${provenanceHtml}
<!-- PACKING -->
<div style="background: #eff6ff; border-left: 4px solid #3b82f6; padding: 16px 20px; margin-bottom: 24px; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #1e40af; font-size: 16px; font-weight: 600;">Packing & Postage</h3>
<p style="margin: 0 0 12px 0; color: #1e3a8a;">Records are removed from outer sleeves to prevent seam splits during transit. Packed with stiffeners in a dedicated LP mailer. Royal Mail 48 Tracked or courier service.</p>
<p style="margin: 0; color: #1e3a8a; font-size: 14px;"><strong>Combined postage:</strong> Discount available for multiple purchases—please request invoice before payment.</p>
</div>
<!-- CTA -->
<div style="text-align: center; padding: 24px; background: #f1f5f9; border-radius: 12px;">
<p style="margin: 0 0 8px 0; color: #475569; font-weight: 500;">Questions? Need more photos?</p>
<p style="margin: 0; color: #64748b; font-size: 14px;">Message me anytime—happy to provide additional angles, audio clips, or pressing details.</p>
</div>
</div>`;
// Store reference to hosted images for potential cleanup
window.currentListingImages = hostedPhotoUrls.map(img => ({
url: img.url,
deleteUrl: img.deleteUrl
}));
document.getElementById('htmlOutput').value = html;
}
function renderTags(artist, title, catNo, year) {
const genre = window.detectedGenre || 'rock';
const format = window.detectedFormat?.toLowerCase().includes('7"') ? '7 inch' :
window.detectedFormat?.toLowerCase().includes('12"') ? '12 inch single' : 'lp';
const country = window.detectedCountry?.toLowerCase() || 'uk';
const tags = [
artist || 'vinyl',
title || 'record',
format,
'vinyl record',
'original pressing',
`${country} pressing`,
year || 'vintage',
catNo || '',
genre,
genre === 'rock' ? 'prog rock' : genre,
genre === 'rock' ? 'psych' : '',
'collector',
'audiophile',
format === 'lp' ? '12 inch' : format,
'33 rpm',
format === 'lp' ? 'album' : 'single',
'used vinyl',
'graded',
'excellent condition',
'rare vinyl',
'classic rock',
'vintage vinyl',
'record collection',
'music',
'audio',
window.detectedLabel || ''
].filter(Boolean);
const container = document.getElementById('tagsOutput');
container.innerHTML = tags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
}
function renderShotList() {
// Map shot types to display info
const shotDefinitions = [
{ id: 'front', name: 'Front cover (square, well-lit)', critical: true },
{ id: 'back', name: 'Back cover (full shot)', critical: true },
{ id: 'spine', name: 'Spine (readable text)', critical: true },
{ id: 'label_a', name: 'Label Side A (close, legible)', critical: true },
{ id: 'label_b', name: 'Label Side B (close, legible)', critical: true },
{ id: 'deadwax', name: 'Deadwax/runout grooves', critical: true },
{ id: 'inner', name: 'Inner sleeve (both sides)', critical: false },
{ id: 'insert', name: 'Insert/poster if included', critical: false },
{ id: 'hype', name: 'Hype sticker (if present)', critical: false },
{ id: 'vinyl', name: 'Vinyl in raking light (flaws)', critical: true },
{ id: 'corners', name: 'Sleeve corners/edges detail', critical: false },
{ id: 'barcode', name: 'Barcode area', critical: false }
];
// Check if we have any photos at all
const hasPhotos = uploadedPhotos.length > 0;
const container = document.getElementById('shotList');
container.innerHTML = shotDefinitions.map(shot => {
const have = detectedPhotoTypes.has(shot.id) || (shot.id === 'front' && hasPhotos) || (shot.id === 'back' && uploadedPhotos.length > 1);
const statusClass = have ? 'completed' : shot.critical ? 'missing' : '';
const iconColor = have ? 'text-green-500' : shot.critical ? 'text-yellow-500' : 'text-gray-500';
const textClass = have ? 'text-gray-400 line-through' : 'text-gray-300';
const icon = have ? 'check-circle' : shot.critical ? 'alert-circle' : 'circle';
return `
<div class="shot-item ${statusClass}">
<i data-feather="${icon}"
class="w-5 h-5 ${iconColor} flex-shrink-0"></i>
<span class="text-sm ${textClass}">${shot.name}</span>
${shot.critical && !have ? '<span class="ml-auto text-xs text-yellow-500 font-medium">CRITICAL</span>' : ''}
</div>
`}).join('');
feather.replace();
}
function copyHTML() {
const html = document.getElementById('htmlOutput');
html.select();
document.execCommand('copy');
showToast('HTML copied to clipboard!', 'success');
}
function copyTags() {
const tags = Array.from(document.querySelectorAll('#tagsOutput span')).map(s => s.textContent).join(', ');
navigator.clipboard.writeText(tags);
showToast('Tags copied to clipboard!', 'success');
}
async function draftAnalysis() {
if (uploadedPhotos.length === 0) {
showToast('Upload photos first for preview', 'error');
return;
}
const artist = document.getElementById('artistInput').value.trim();
const title = document.getElementById('titleInput').value.trim();
// Show loading state
const dropZone = document.getElementById('dropZone');
const spinner = document.getElementById('uploadSpinner');
spinner.classList.remove('hidden');
dropZone.classList.add('pointer-events-none');
startAnalysisProgressSimulation();
try {
// Try OCR/AI analysis if available
const service = getAIService();
let ocrResult = null;
if (service && service.apiKey && uploadedPhotos.length > 0) {
try {
ocrResult = await service.analyzeRecordImages(uploadedPhotos.slice(0, 2)); // Limit to 2 photos for speed
populateFieldsFromOCR(ocrResult);
} catch (e) {
console.log('Preview OCR failed:', e);
}
}
// Generate quick preview results
const catNo = document.getElementById('catInput').value.trim() || ocrResult?.catalogueNumber || '';
const year = document.getElementById('yearInput').value.trim() || ocrResult?.year || '';
const detectedArtist = artist || ocrResult?.artist || 'Unknown Artist';
const detectedTitle = title || ocrResult?.title || 'Unknown Title';
const baseTitle = `${detectedArtist} - ${detectedTitle}`;
// Generate quick titles
const quickTitles = [
`${baseTitle} ${year ? `(${year})` : ''} ${catNo} VG+`.substring(0, 80),
`${baseTitle} Original Pressing Vinyl LP`.substring(0, 80),
`${detectedArtist} ${detectedTitle} ${catNo || 'LP'}`.substring(0, 80)
].map((t, i) => ({
text: t,
chars: t.length,
style: ['Quick', 'Standard', 'Compact'][i]
}));
// Quick pricing estimate based on condition
const cost = parseFloat(document.getElementById('costInput').value) || 10;
const vinylCond = document.getElementById('vinylConditionInput').value;
const sleeveCond = document.getElementById('sleeveConditionInput').value;
const conditionMultipliers = { 'M': 3, 'NM': 2.5, 'VG+': 1.8, 'VG': 1.2, 'G+': 0.8, 'G': 0.5 };
const condMult = (conditionMultipliers[vinylCond] || 1) * 0.7 + (conditionMultipliers[sleeveCond] || 1) * 0.3;
const estimatedValue = Math.round(cost * Math.max(condMult, 1.5));
const suggestedPrice = Math.round(estimatedValue * 0.9);
// Render preview results
renderTitleOptions(quickTitles);
// Quick pricing card
document.getElementById('pricingStrategy').innerHTML = `
<div class="pricing-card recommended">
<div class="flex items-center gap-2 mb-3">
<span class="px-2 py-1 bg-accent/20 text-accent text-xs font-medium rounded">QUICK ESTIMATE</span>
</div>
<p class="text-3xl font-bold text-white mb-1">£${suggestedPrice}</p>
<p class="text-sm text-gray-400 mb-3">Suggested Buy It Now</p>
<div class="space-y-2 text-sm">
<p class="flex justify-between"><span class="text-gray-500">Est. Value:</span> <span class="text-gray-300">£${estimatedValue}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Your Cost:</span> <span class="text-gray-300">£${cost.toFixed(2)}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Condition:</span> <span class="text-gray-300">${vinylCond}/${sleeveCond}</span></p>
</div>
</div>
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-400 uppercase tracking-wide">Preview Notes</h4>
<div class="p-3 bg-surface rounded-lg text-sm text-gray-400">
${ocrResult ?
`<p class="text-green-400 mb-2">✓ AI detected information from photos</p>` :
`<p class="text-yellow-400 mb-2">⚠ Add API key in Settings for auto-detection</p>`
}
<p>This is a quick estimate based on your cost and condition. Run "Generate Full Listing" for complete market analysis, sold comps, and optimized pricing.</p>
</div>
${ocrResult ? `
<div class="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<p class="text-xs text-green-400 font-medium mb-1">Detected from photos:</p>
<ul class="text-xs text-gray-400 space-y-1">
${ocrResult.artist ? `<li>• Artist: ${ocrResult.artist}</li>` : ''}
${ocrResult.title ? `<li>• Title: ${ocrResult.title}</li>` : ''}
${ocrResult.catalogueNumber ? `<li>• Cat#: ${ocrResult.catalogueNumber}</li>` : ''}
${ocrResult.year ? `<li>• Year: ${ocrResult.year}</li>` : ''}
</ul>
</div>
` : ''}
</div>
`;
// Simple fee floor
const fees = suggestedPrice * 0.16;
const safeFloor = Math.ceil(cost + fees + 6);
document.getElementById('feeFloor').innerHTML = `
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Your Cost</p>
<p class="text-xl font-bold text-gray-300">£${cost.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Est. Fees</p>
<p class="text-xl font-bold text-red-400">£${fees.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Ship + Pack</p>
<p class="text-xl font-bold text-gray-300">£6.00</p>
</div>
<div class="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/30">
<p class="text-xs text-green-500 uppercase mb-1">Safe Floor</p>
<p class="text-2xl font-bold text-green-400">£${safeFloor}</p>
</div>
`;
// Preview HTML description
const previewHtml = `<!-- QUICK PREVIEW - Generated by VinylVault Pro -->
<div style="max-width: 700px; margin: 0 auto; font-family: sans-serif;">
<h2 style="color: #333;">${detectedArtist} - ${detectedTitle}</h2>
${year ? `<p><strong>Year:</strong> ${year}</p>` : ''}
${catNo ? `<p><strong>Catalogue #:</strong> ${catNo}</p>` : ''}
<p><strong>Condition:</strong> Vinyl ${vinylCond}, Sleeve ${sleeveCond}</p>
<hr style="margin: 20px 0;">
<p style="color: #666;">[Full description will be generated with complete market analysis]</p>
</div>`;
document.getElementById('htmlOutput').value = previewHtml;
// Preview tags
const previewTags = [
detectedArtist,
detectedTitle,
'vinyl',
'record',
vinylCond,
'lp',
year || 'vintage'
].filter(Boolean);
document.getElementById('tagsOutput').innerHTML = previewTags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
// Update shot list
renderShotList();
// Show results
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
resultsSection.scrollIntoView({ behavior: 'smooth' });
showToast('Quick preview ready! Click "Generate Full Listing" for complete analysis.', 'success');
} catch (error) {
console.error('Preview error:', error);
showToast('Preview failed: ' + error.message, 'error');
} finally {
stopAnalysisProgress();
setTimeout(() => {
spinner.classList.add('hidden');
dropZone.classList.remove('pointer-events-none');
updateAnalysisProgress('Initializing...', 0);
}, 300);
}
}
async function callAI(messages, temperature = 0.7) {
const provider = localStorage.getItem('ai_provider') || 'openai';
if (provider === 'deepseek' && window.deepseekService?.isConfigured) {
try {
const response = await fetch('https://api.deepseek.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('deepseek_api_key')}`
},
body: JSON.stringify({
model: localStorage.getItem('deepseek_model') || 'deepseek-chat',
messages: messages,
temperature: temperature,
max_tokens: 2000
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'DeepSeek API request failed');
}
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
showToast(`DeepSeek Error: ${error.message}`, 'error');
return null;
}
} else {
// Fallback to OpenAI
const apiKey = localStorage.getItem('openai_api_key');
const model = localStorage.getItem('openai_model') || 'gpt-4o';
const maxTokens = parseInt(localStorage.getItem('openai_max_tokens')) || 2000;
if (!apiKey) {
showToast('OpenAI API key not configured. Go to Settings.', 'error');
return null;
}
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model: model,
messages: messages,
temperature: temperature,
max_tokens: maxTokens
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'API request failed');
}
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
showToast(`OpenAI Error: ${error.message}`, 'error');
return null;
}
}
}
// Legacy alias for backward compatibility
async function callOpenAI(messages, temperature = 0.7) {
return callAI(messages, temperature);
}
// Delete hosted image from imgBB
async function deleteHostedImage(deleteUrl) {
if (!deleteUrl) return false;
try {
const response = await fetch(deleteUrl, { method: 'GET' });
// imgBB delete URLs work via GET request
return response.ok;
} catch (error) {
console.error('Failed to delete image:', error);
return false;
}
}
// Get hosted photo URLs for eBay HTML description
function getHostedPhotoUrlsForEbay() {
return hostedPhotoUrls.map(img => ({
full: img.url,
display: img.displayUrl || img.url,
thumb: img.thumb,
medium: img.medium,
viewer: img.viewerUrl
}));
}
async function generateListingWithAI() {
const artist = document.getElementById('artistInput').value.trim();
const title = document.getElementById('titleInput').value.trim();
const catNo = document.getElementById('catInput').value.trim();
const year = document.getElementById('yearInput').value.trim();
if (!artist || !title) {
showToast('Please enter at least artist and title', 'error');
return;
}
const messages = [
{
role: 'system',
content: 'You are a vinyl record eBay listing expert. Generate optimized titles, descriptions, and pricing strategies. Always return JSON format with: titles (array), description (string), condition_notes (string), price_estimate (object with min, max, recommended), and tags (array).'
},
{
role: 'user',
content: `Generate an eBay listing for: ${artist} - ${title}${catNo ? ` (Catalog: ${catNo})` : ''}${year ? ` (${year})` : ''}. Include optimized title options, professional HTML description, condition guidance, price estimate in GBP, and relevant tags.`
}
];
const provider = localStorage.getItem('ai_provider') || 'openai';
showToast(`Generating listing with ${provider === 'deepseek' ? 'DeepSeek' : 'OpenAI'}...`, 'success');
const result = await callAI(messages, 0.7);
if (result) {
try {
const data = JSON.parse(result);
// Populate the UI with AI-generated content
if (data.titles) {
renderTitleOptions(data.titles.map(t => ({
text: t.length > 80 ? t.substring(0, 77) + '...' : t,
chars: Math.min(t.length, 80),
style: 'AI Generated'
})));
}
if (data.description) {
document.getElementById('htmlOutput').value = data.description;
}
if (data.tags) {
const tagsContainer = document.getElementById('tagsOutput');
tagsContainer.innerHTML = data.tags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
}
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
showToast('AI listing generated!', 'success');
} catch (e) {
// If not valid JSON, treat as plain text description
document.getElementById('htmlOutput').value = result;
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
}
}
}
function requestHelp() {
alert(`VINYL PHOTO GUIDE:
ESSENTIAL SHOTS (need these):
• Front cover - square, no glare, color accurate
• Back cover - full frame, readable text
• Both labels - close enough to read all text
• Deadwax/runout - for pressing identification
CONDITION SHOTS:
• Vinyl in raking light at angle (shows scratches)
• Sleeve edges and corners
• Any flaws clearly documented
OPTIONARY BUT HELPFUL:
• Inner sleeve condition
• Inserts, posters, extras
• Hype stickers
• Barcode area
TIPS:
- Use natural daylight or 5500K bulbs
- Avoid flash directly on glossy sleeves
- Include scale reference if unusual size
- Photograph flaws honestly - reduces returns`);
}
function showToast(message, type = 'success') {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const iconMap = {
success: 'check',
error: 'alert-circle',
warning: 'alert-triangle'
};
const colorMap = {
success: 'text-green-400',
error: 'text-red-400',
warning: 'text-yellow-400'
};
const toast = document.createElement('div');
toast.className = `toast ${type} flex items-center gap-3`;
toast.innerHTML = `
<i data-feather="${iconMap[type] || 'info'}" class="w-5 h-5 ${colorMap[type] || 'text-blue-400'}"></i>
<span class="text-sm text-gray-200">${message}</span>
`;
document.body.appendChild(toast);
feather.replace();
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Cleanup function to delete all hosted images for current listing
async function cleanupHostedImages() {
if (window.currentListingImages) {
for (const img of window.currentListingImages) {
if (img.deleteUrl) {
await deleteHostedImage(img.deleteUrl);
}
}
window.currentListingImages = [];
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
console.log('VinylVault Pro initialized');
// Initialize drop zone
initDropZone();
// Warn about unsaved changes when leaving page with hosted images
window.addEventListener('beforeunload', (e) => {
if (hostedPhotoUrls.length > 0 && !window.listingPublished) {
// Optional: could add cleanup here or warn user
}
});
});
: '€';
// Mock comp research results
const comps = {
nm: { low: 45, high: 65, median: 52 },
vgplus: { low: 28, high: 42, median: 34 },
vg: { low: 15, high: 25, median: 19 }
};
// Calculate recommended price based on goal
let recommendedBin, strategy;
switch(goal) {
case 'quick':
recommendedBin = Math.round(comps.vgplus.low * 0.9);
strategy = 'BIN + Best Offer (aggressive)';
break;
case 'max':
recommendedBin = Math.round(comps.nm.high * 1.1);
strategy = 'BIN only, no offers, long duration';
break;
default:
recommendedBin = comps.vgplus.median;
strategy = 'BIN + Best Offer (standard)';
}
// Fee calculation (eBay UK approx)
const ebayFeeRate = 0.13; // 13% final value fee
const paypalRate = 0.029; // 2.9% + 30p
const fixedFee = 0.30;
const shippingCost = 4.50; // Estimated
const packingCost = 1.50;
const totalFees = (recommendedBin * ebayFeeRate) + (recommendedBin * paypalRate) + fixedFee;
const breakEven = cost + totalFees + shippingCost + packingCost;
const safeFloor = Math.ceil(breakEven * 1.05); // 5% buffer
// Generate titles
const baseTitle = `${artist || 'ARTIST'} - ${title || 'TITLE'}`;
const titles = generateTitles(baseTitle, catNo, year, goal);
// Render results
renderTitleOptions(titles);
renderPricingStrategy(recommendedBin, strategy, comps, currency, goal);
renderFeeFloor(cost, totalFees, shippingCost, packingCost, safeFloor, currency);
await renderHTMLDescription(data, titles[0]);
renderTags(artist, title, catNo, year);
renderShotList();
// Show results
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
resultsSection.scrollIntoView({ behavior: 'smooth' });
currentAnalysis = {
titles, recommendedBin, strategy, breakEven, safeFloor, currency
};
}
function renderTitleOptions(titles) {
const container = document.getElementById('titleOptions');
container.innerHTML = titles.map((t, i) => `
<div class="title-option ${i === 0 ? 'selected' : ''}" onclick="selectTitle(this, '${t.text.replace(/'/g, "\\'")}')">
<span class="char-count">${t.chars}/80</span>
<p class="font-medium text-gray-200 pr-16">${t.text}</p>
<p class="text-sm text-gray-500 mt-1">${t.style}</p>
</div>
`).join('');
}
function selectTitle(el, text) {
document.querySelectorAll('.title-option').forEach(o => o.classList.remove('selected'));
el.classList.add('selected');
// Update clipboard copy
navigator.clipboard.writeText(text);
showToast('Title copied to clipboard!', 'success');
}
function renderPricingStrategy(bin, strategy, comps, currency, goal) {
const container = document.getElementById('pricingStrategy');
const offerSettings = goal === 'max' ? 'Offers: OFF' :
`Auto-accept: ${currency}${Math.floor(bin * 0.85)} | Auto-decline: ${currency}${Math.floor(bin * 0.7)}`;
container.innerHTML = `
<div class="pricing-card recommended">
<div class="flex items-center gap-2 mb-3">
<span class="px-2 py-1 bg-accent/20 text-accent text-xs font-medium rounded">RECOMMENDED</span>
</div>
<p class="text-3xl font-bold text-white mb-1">${currency}${bin}</p>
<p class="text-sm text-gray-400 mb-3">Buy It Now</p>
<div class="space-y-2 text-sm">
<p class="flex justify-between"><span class="text-gray-500">Strategy:</span> <span class="text-gray-300">${strategy}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Best Offer:</span> <span class="text-gray-300">${offerSettings}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Duration:</span> <span class="text-gray-300">30 days (GTC)</span></p>
</div>
</div>
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-400 uppercase tracking-wide">Sold Comps by Grade</h4>
<div class="space-y-2">
<div class="flex justify-between items-center p-3 bg-surface rounded-lg">
<span class="text-green-400 font-medium">NM/NM-</span>
<span class="text-gray-300">${currency}${comps.nm.low}-${comps.nm.high} <span class="text-gray-500">(med: ${comps.nm.median})</span></span>
</div>
<div class="flex justify-between items-center p-3 bg-surface rounded-lg border border-accent/30">
<span class="text-accent font-medium">VG+/EX</span>
<span class="text-gray-300">${currency}${comps.vgplus.low}-${comps.vgplus.high} <span class="text-gray-500">(med: ${comps.vgplus.median})</span></span>
</div>
<div class="flex justify-between items-center p-3 bg-surface rounded-lg">
<span class="text-yellow-400 font-medium">VG/VG+</span>
<span class="text-gray-300">${currency}${comps.vg.low}-${comps.vg.high} <span class="text-gray-500">(med: ${comps.vg.median})</span></span>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">Based on last 90 days sold listings, same pressing. Prices exclude postage.</p>
</div>
`;
}
function renderFeeFloor(cost, fees, shipping, packing, safeFloor, currency) {
const container = document.getElementById('feeFloor');
container.innerHTML = `
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Your Cost</p>
<p class="text-xl font-bold text-gray-300">${currency}${cost.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Est. Fees</p>
<p class="text-xl font-bold text-red-400">${currency}${fees.toFixed(2)}</p>
<p class="text-xs text-gray-600">~16% total</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Ship + Pack</p>
<p class="text-xl font-bold text-gray-300">${currency}${(shipping + packing).toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/30">
<p class="text-xs text-green-500 uppercase mb-1">Safe Floor Price</p>
<p class="text-2xl font-bold text-green-400">${currency}${safeFloor}</p>
<p class="text-xs text-green-600/70">Auto-decline below this</p>
</div>
`;
}
async function renderHTMLDescription(data, titleObj) {
const { artist, title, catNo, year } = data;
// Use hosted URL if available, otherwise fallback to local object URL
let heroImg = '';
let galleryImages = [];
if (hostedPhotoUrls.length > 0) {
heroImg = hostedPhotoUrls[0].displayUrl || hostedPhotoUrls[0].url;
galleryImages = hostedPhotoUrls.slice(1).map(img => img.displayUrl || img.url);
} else if (uploadedPhotos.length > 0) {
heroImg = URL.createObjectURL(uploadedPhotos[0]);
galleryImages = uploadedPhotos.slice(1).map((_, i) => URL.createObjectURL(uploadedPhotos[i + 1]));
}
// Use OCR-detected values if available
const detectedLabel = window.detectedLabel || '[Verify from photos]';
const detectedCountry = window.detectedCountry || 'UK';
const detectedFormat = window.detectedFormat || 'LP • 33rpm';
const detectedGenre = window.detectedGenre || 'rock';
const detectedCondition = window.detectedCondition || 'VG+/VG+';
const detectedPressingInfo = window.detectedPressingInfo || '';
// Fetch tracklist and detailed info from Discogs if available
let tracklistHtml = '';
let pressingDetailsHtml = '';
let provenanceHtml = '';
if (window.discogsReleaseId && window.discogsService?.key) {
try {
const discogsData = await window.discogsService.fetchTracklist(window.discogsReleaseId);
if (discogsData && discogsData.tracklist) {
// Build tracklist HTML
const hasSideBreakdown = discogsData.tracklist.some(t => t.position && (t.position.startsWith('A') || t.position.startsWith('B')));
if (hasSideBreakdown) {
// Group by sides
const sides = {};
discogsData.tracklist.forEach(track => {
const side = track.position ? track.position.charAt(0) : 'Other';
if (!sides[side]) sides[side] = [];
sides[side].push(track);
});
tracklistHtml = Object.entries(sides).map(([side, tracks]) => `
<div style="margin-bottom: 16px;">
<h4 style="color: #7c3aed; font-size: 13px; font-weight: 600; margin: 0 0 8px 0; text-transform: uppercase; letter-spacing: 0.5px;">Side ${side}</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
${tracks.map(track => `
<div style="flex: 1 1 200px; min-width: 200px; display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0;">
<span style="color: #1e293b; font-size: 13px;"><strong>${track.position}</strong> ${track.title}</span>
${track.duration ? `<span style="color: #64748b; font-size: 12px; font-family: monospace;">${track.duration}</span>` : ''}
</div>
`).join('')}
</div>
</div>
`).join('');
} else {
// Simple list
tracklistHtml = `
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
${discogsData.tracklist.map(track => `
<div style="flex: 1 1 200px; min-width: 200px; display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0;">
<span style="color: #1e293b; font-size: 13px;">${track.position ? `<strong>${track.position}</strong> ` : ''}${track.title}</span>
${track.duration ? `<span style="color: #64748b; font-size: 12px; font-family: monospace;">${track.duration}</span>` : ''}
</div>
`).join('')}
</div>
`;
}
// Build pressing/variation details
const identifiers = discogsData.identifiers || [];
const barcodeInfo = identifiers.find(i => i.type === 'Barcode');
const matrixInfo = identifiers.filter(i => i.type === 'Matrix / Runout' || i.type === 'Runout');
const pressingInfo = identifiers.filter(i => i.type === 'Pressing Plant' || i.type === 'Mastering');
if (matrixInfo.length > 0 || barcodeInfo || pressingInfo.length > 0) {
pressingDetailsHtml = `
<div style="background: #f0fdf4; border-left: 4px solid #22c55e; padding: 16px 20px; margin: 24px 0; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #166534; font-size: 15px; font-weight: 600;">Pressing & Matrix Information</h3>
<div style="font-family: monospace; font-size: 13px; line-height: 1.6; color: #15803d;">
${barcodeInfo ? `<p style="margin: 4px 0;"><strong>Barcode:</strong> ${barcodeInfo.value}</p>` : ''}
${matrixInfo.map(m => `<p style="margin: 4px 0;"><strong>${m.type}:</strong> ${m.value}${m.description ? ` <em>(${m.description})</em>` : ''}</p>`).join('')}
${pressingInfo.map(p => `<p style="margin: 4px 0;"><strong>${p.type}:</strong> ${p.value}</p>`).join('')}
</div>
${discogsData.notes ? `<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #bbf7d0; font-size: 12px; color: #166534; font-style: italic;">${discogsData.notes.substring(0, 300)}${discogsData.notes.length > 300 ? '...' : ''}</p>` : ''}
</div>
`;
}
// Build provenance data for buyer confidence
const companies = discogsData.companies || [];
const masteredBy = companies.find(c => c.entity_type_name === 'Mastered At' || c.name.toLowerCase().includes('mastering'));
const pressedBy = companies.find(c => c.entity_type_name === 'Pressed By' || c.name.toLowerCase().includes('pressing'));
const lacquerCut = companies.find(c => c.entity_type_name === 'Lacquer Cut At');
if (masteredBy || pressedBy || lacquerCut) {
provenanceHtml = `
<div style="background: #eff6ff; border: 1px solid #bfdbfe; padding: 16px; margin: 24px 0; border-radius: 8px;">
<h3 style="margin: 0 0 12px 0; color: #1e40af; font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
Provenance & Production
</h3>
<div style="font-size: 13px; color: #1e3a8a; line-height: 1.6;">
${masteredBy ? `<p style="margin: 4px 0;">✓ Mastered at <strong>${masteredBy.name}</strong></p>` : ''}
${lacquerCut ? `<p style="margin: 4px 0;">✓ Lacquer cut at <strong>${lacquerCut.name}</strong></p>` : ''}
${pressedBy ? `<p style="margin: 4px 0;">✓ Pressed at <strong>${pressedBy.name}</strong></p>` : ''}
${discogsData.num_for_sale ? `<p style="margin: 8px 0 0 0; padding-top: 8px; border-top: 1px solid #bfdbfe; color: #3b82f6; font-size: 12px;">Reference: ${discogsData.num_for_sale} copies currently for sale on Discogs</p>` : ''}
</div>
</div>
`;
}
}
} catch (e) {
console.error('Failed to fetch Discogs details for HTML:', e);
}
}
// If no tracklist from Discogs, provide placeholder
if (!tracklistHtml) {
tracklistHtml = `<p style="color: #64748b; font-style: italic;">Tracklist verification recommended. Please compare with Discogs entry for accuracy.</p>`;
}
const galleryHtml = galleryImages.length > 0 ? `
<!-- PHOTO GALLERY -->
<div style="margin-bottom: 24px;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px;">
${galleryImages.map(url => `<img src="${url}" style="width: 100%; height: 150px; object-fit: cover; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);" alt="Record photo">`).join('')}
</div>
</div>
` : '';
const html = `<div style="max-width: 800px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #333; line-height: 1.6;">
<!-- HERO IMAGE -->
<div style="margin-bottom: 24px;">
<img src="${heroImg}" alt="${artist} - ${title}" style="width: 100%; max-width: 600px; display: block; margin: 0 auto; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.15);">
</div>
${galleryHtml}
<!-- BADGES -->
<div style="display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-bottom: 24px;">
<span style="background: #7c3aed; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">Original ${detectedCountry} Pressing</span>
<span style="background: #059669; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${year || '1970s'}</span>
<span style="background: #0891b2; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${detectedFormat}</span>
<span style="background: #d97706; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${detectedCondition}</span>
</div>
<!-- AT A GLANCE -->
<table style="width: 100%; border-collapse: collapse; margin-bottom: 24px; font-size: 14px;">
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600; width: 140px;">Artist</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${artist || 'See title'}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Title</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${title || 'See title'}</td>
</tr>
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Label</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${detectedLabel}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Catalogue</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;"><code style="background: #f1f5f9; padding: 2px 8px; border-radius: 4px;">${catNo || '[See photos]'}</code></td>
</tr>
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Country</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${detectedCountry}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Year</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${year || '[Verify]'}</td>
</tr>
</table>
<!-- CONDITION -->
<div style="background: #fefce8; border-left: 4px solid #eab308; padding: 16px 20px; margin-bottom: 24px; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #854d0e; font-size: 16px; font-weight: 600;">Condition Report</h3>
<div style="display: grid; gap: 12px;">
<div>
<strong style="color: #713f12;">Vinyl:</strong> <span style="color: #854d0e;">VG+ — Light surface marks, plays cleanly with minimal surface noise. No skips or jumps. [Adjust based on actual inspection]</span>
</div>
<div>
<strong style="color: #713f12;">Sleeve:</strong> <span style="color: #854d0e;">VG+ — Minor edge wear, light ring wear visible under raking light. No splits or writing. [Adjust based on actual inspection]</span>
</div>
<div>
<strong style="color: #713f12;">Inner Sleeve:</strong> <span style="color: #854d0e;">Original paper inner included, small split at bottom seam. [Verify/Adjust]</span>
</div>
</div>
</div>
<!-- ABOUT -->
<h3 style="color: #1e293b; font-size: 18px; font-weight: 600; margin-bottom: 12px;">About This Release</h3>
<p style="margin-bottom: 16px; color: #475569;">${detectedGenre ? `${detectedGenre.charAt(0).toUpperCase() + detectedGenre.slice(1)} release` : 'Vintage vinyl release'}${detectedPressingInfo ? `. Matrix/Runout: ${detectedPressingInfo}` : ''}. [Add accurate description based on verified pressing details. Mention notable features: gatefold, insert, poster, hype sticker, etc.]</p>
<!-- TRACKLIST -->
<h3 style="color: #1e293b; font-size: 18px; font-weight: 600; margin-bottom: 12px;">Tracklist</h3>
<div style="background: #f8fafc; padding: 16px 20px; border-radius: 8px; margin-bottom: 24px;">
${tracklistHtml}
</div>
${pressingDetailsHtml}
${provenanceHtml}
<!-- PACKING -->
<div style="background: #eff6ff; border-left: 4px solid #3b82f6; padding: 16px 20px; margin-bottom: 24px; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #1e40af; font-size: 16px; font-weight: 600;">Packing & Postage</h3>
<p style="margin: 0 0 12px 0; color: #1e3a8a;">Records are removed from outer sleeves to prevent seam splits during transit. Packed with stiffeners in a dedicated LP mailer. Royal Mail 48 Tracked or courier service.</p>
<p style="margin: 0; color: #1e3a8a; font-size: 14px;"><strong>Combined postage:</strong> Discount available for multiple purchases—please request invoice before payment.</p>
</div>
<!-- CTA -->
<div style="text-align: center; padding: 24px; background: #f1f5f9; border-radius: 12px;">
<p style="margin: 0 0 8px 0; color: #475569; font-weight: 500;">Questions? Need more photos?</p>
<p style="margin: 0; color: #64748b; font-size: 14px;">Message me anytime—happy to provide additional angles, audio clips, or pressing details.</p>
</div>
</div>`;
// Store reference to hosted images for potential cleanup
window.currentListingImages = hostedPhotoUrls.map(img => ({
url: img.url,
deleteUrl: img.deleteUrl
}));
document.getElementById('htmlOutput').value = html;
}
function renderTags(artist, title, catNo, year) {
const genre = window.detectedGenre || 'rock';
const format = window.detectedFormat?.toLowerCase().includes('7"') ? '7 inch' :
window.detectedFormat?.toLowerCase().includes('12"') ? '12 inch single' : 'lp';
const country = window.detectedCountry?.toLowerCase() || 'uk';
const tags = [
artist || 'vinyl',
title || 'record',
format,
'vinyl record',
'original pressing',
`${country} pressing`,
year || 'vintage',
catNo || '',
genre,
genre === 'rock' ? 'prog rock' : genre,
genre === 'rock' ? 'psych' : '',
'collector',
'audiophile',
format === 'lp' ? '12 inch' : format,
'33 rpm',
format === 'lp' ? 'album' : 'single',
'used vinyl',
'graded',
'excellent condition',
'rare vinyl',
'classic rock',
'vintage vinyl',
'record collection',
'music',
'audio',
window.detectedLabel || ''
].filter(Boolean);
const container = document.getElementById('tagsOutput');
container.innerHTML = tags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
}
function renderShotList() {
// Map shot types to display info
const shotDefinitions = [
{ id: 'front', name: 'Front cover (square, well-lit)', critical: true },
{ id: 'back', name: 'Back cover (full shot)', critical: true },
{ id: 'spine', name: 'Spine (readable text)', critical: true },
{ id: 'label_a', name: 'Label Side A (close, legible)', critical: true },
{ id: 'label_b', name: 'Label Side B (close, legible)', critical: true },
{ id: 'deadwax', name: 'Deadwax/runout grooves', critical: true },
{ id: 'inner', name: 'Inner sleeve (both sides)', critical: false },
{ id: 'insert', name: 'Insert/poster if included', critical: false },
{ id: 'hype', name: 'Hype sticker (if present)', critical: false },
{ id: 'vinyl', name: 'Vinyl in raking light (flaws)', critical: true },
{ id: 'corners', name: 'Sleeve corners/edges detail', critical: false },
{ id: 'barcode', name: 'Barcode area', critical: false }
];
// Check if we have any photos at all
const hasPhotos = uploadedPhotos.length > 0;
const container = document.getElementById('shotList');
container.innerHTML = shotDefinitions.map(shot => {
const have = detectedPhotoTypes.has(shot.id) || (shot.id === 'front' && hasPhotos) || (shot.id === 'back' && uploadedPhotos.length > 1);
const statusClass = have ? 'completed' : shot.critical ? 'missing' : '';
const iconColor = have ? 'text-green-500' : shot.critical ? 'text-yellow-500' : 'text-gray-500';
const textClass = have ? 'text-gray-400 line-through' : 'text-gray-300';
const icon = have ? 'check-circle' : shot.critical ? 'alert-circle' : 'circle';
return `
<div class="shot-item ${statusClass}">
<i data-feather="${icon}"
class="w-5 h-5 ${iconColor} flex-shrink-0"></i>
<span class="text-sm ${textClass}">${shot.name}</span>
${shot.critical && !have ? '<span class="ml-auto text-xs text-yellow-500 font-medium">CRITICAL</span>' : ''}
</div>
`}).join('');
feather.replace();
}
function copyHTML() {
const html = document.getElementById('htmlOutput');
html.select();
document.execCommand('copy');
showToast('HTML copied to clipboard!', 'success');
}
function copyTags() {
const tags = Array.from(document.querySelectorAll('#tagsOutput span')).map(s => s.textContent).join(', ');
navigator.clipboard.writeText(tags);
showToast('Tags copied to clipboard!', 'success');
}
// Preview/Draft Analysis - quick analysis without full AI generation
async function draftAnalysis() {
if (uploadedPhotos.length === 0) {
showToast('Upload photos first for preview', 'error');
return;
}
const artist = document.getElementById('artistInput').value.trim();
const title = document.getElementById('titleInput').value.trim();
// Show loading state
const dropZone = document.getElementById('dropZone');
const spinner = document.getElementById('uploadSpinner');
spinner.classList.remove('hidden');
dropZone.classList.add('pointer-events-none');
startAnalysisProgressSimulation();
try {
// Try OCR/AI analysis if available
const service = getAIService();
let ocrResult = null;
if (service && service.apiKey && uploadedPhotos.length > 0) {
try {
ocrResult = await service.analyzeRecordImages(uploadedPhotos.slice(0, 2)); // Limit to 2 photos for speed
populateFieldsFromOCR(ocrResult);
} catch (e) {
console.log('Preview OCR failed:', e);
}
}
// Generate quick preview results
const catNo = document.getElementById('catInput').value.trim() || ocrResult?.catalogueNumber || '';
const year = document.getElementById('yearInput').value.trim() || ocrResult?.year || '';
const detectedArtist = artist || ocrResult?.artist || 'Unknown Artist';
const detectedTitle = title || ocrResult?.title || 'Unknown Title';
const baseTitle = `${detectedArtist} - ${detectedTitle}`;
// Generate quick titles
const quickTitles = [
`${baseTitle} ${year ? `(${year})` : ''} ${catNo} VG+`.substring(0, 80),
`${baseTitle} Original Pressing Vinyl LP`.substring(0, 80),
`${detectedArtist} ${detectedTitle} ${catNo || 'LP'}`.substring(0, 80)
].map((t, i) => ({
text: t,
chars: t.length,
style: ['Quick', 'Standard', 'Compact'][i]
}));
// Quick pricing estimate based on condition
const cost = parseFloat(document.getElementById('costInput').value) || 10;
const vinylCond = document.getElementById('vinylConditionInput').value;
const sleeveCond = document.getElementById('sleeveConditionInput').value;
const conditionMultipliers = { 'M': 3, 'NM': 2.5, 'VG+': 1.8, 'VG': 1.2, 'G+': 0.8, 'G': 0.5 };
const condMult = (conditionMultipliers[vinylCond] || 1) * 0.7 + (conditionMultipliers[sleeveCond] || 1) * 0.3;
const estimatedValue = Math.round(cost * Math.max(condMult, 1.5));
const suggestedPrice = Math.round(estimatedValue * 0.9);
// Render preview results
renderTitleOptions(quickTitles);
// Quick pricing card
document.getElementById('pricingStrategy').innerHTML = `
<div class="pricing-card recommended">
<div class="flex items-center gap-2 mb-3">
<span class="px-2 py-1 bg-accent/20 text-accent text-xs font-medium rounded">QUICK ESTIMATE</span>
</div>
<p class="text-3xl font-bold text-white mb-1">£${suggestedPrice}</p>
<p class="text-sm text-gray-400 mb-3">Suggested Buy It Now</p>
<div class="space-y-2 text-sm">
<p class="flex justify-between"><span class="text-gray-500">Est. Value:</span> <span class="text-gray-300">£${estimatedValue}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Your Cost:</span> <span class="text-gray-300">£${cost.toFixed(2)}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Condition:</span> <span class="text-gray-300">${vinylCond}/${sleeveCond}</span></p>
</div>
</div>
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-400 uppercase tracking-wide">Preview Notes</h4>
<div class="p-3 bg-surface rounded-lg text-sm text-gray-400">
${ocrResult ?
`<p class="text-green-400 mb-2">✓ AI detected information from photos</p>` :
`<p class="text-yellow-400 mb-2">⚠ Add API key in Settings for auto-detection</p>`
}
<p>This is a quick estimate based on your cost and condition. Run "Generate Full Listing" for complete market analysis, sold comps, and optimized pricing.</p>
</div>
${ocrResult ? `
<div class="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<p class="text-xs text-green-400 font-medium mb-1">Detected from photos:</p>
<ul class="text-xs text-gray-400 space-y-1">
${ocrResult.artist ? `<li>• Artist: ${ocrResult.artist}</li>` : ''}
${ocrResult.title ? `<li>• Title: ${ocrResult.title}</li>` : ''}
${ocrResult.catalogueNumber ? `<li>• Cat#: ${ocrResult.catalogueNumber}</li>` : ''}
${ocrResult.year ? `<li>• Year: ${ocrResult.year}</li>` : ''}
</ul>
</div>
` : ''}
</div>
`;
// Simple fee floor
const fees = suggestedPrice * 0.16;
const safeFloor = Math.ceil(cost + fees + 6);
document.getElementById('feeFloor').innerHTML = `
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Your Cost</p>
<p class="text-xl font-bold text-gray-300">£${cost.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Est. Fees</p>
<p class="text-xl font-bold text-red-400">£${fees.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Ship + Pack</p>
<p class="text-xl font-bold text-gray-300"6.00</p>
</div>
<div class="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/30">
<p class="text-xs text-green-500 uppercase mb-1">Safe Floor</p>
<p class="text-2xl font-bold text-green-400">£${safeFloor}</p>
</div>
`;
// Preview HTML description
const previewHtml = `<!-- QUICK PREVIEW - Generated by VinylVault Pro -->
<div style="max-width: 700px; margin: 0 auto; font-family: sans-serif;">
<h2 style="color: #333;">${detectedArtist} - ${detectedTitle}</h2>
${year ? `<p><strong>Year:</strong> ${year}</p>` : ''}
${catNo ? `<p><strong>Catalogue #:</strong> ${catNo}</p>` : ''}
<p><strong>Condition:</strong> Vinyl ${vinylCond}, Sleeve ${sleeveCond}</p>
<hr style="margin: 20px 0;">
<p style="color: #666;">[Full description will be generated with complete market analysis]</p>
</div>`;
const htmlOutput = document.getElementById('htmlOutput');
if (htmlOutput) htmlOutput.value = previewHtml;
// Preview tags
const previewTags = [
detectedArtist,
detectedTitle,
'vinyl',
'record',
vinylCond,
'lp',
year || 'vintage'
].filter(Boolean);
const tagsOutput = document.getElementById('tagsOutput');
if (tagsOutput) {
tagsOutput.innerHTML = previewTags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
}
// Update shot list
renderShotList();
// Show results
const resultsSection = document.getElementById('resultsSection');
const emptyState = document.getElementById('emptyState');
if (resultsSection) resultsSection.classList.remove('hidden');
if (emptyState) emptyState.classList.add('hidden');
if (resultsSection) resultsSection.scrollIntoView({ behavior: 'smooth' });
showToast('Quick preview ready! Click "Generate Full Listing" for complete analysis.', 'success');
} catch (error) {
console.error('Preview error:', error);
showToast('Preview failed: ' + error.message, 'error');
} finally {
stopAnalysisProgress();
setTimeout(() => {
spinner.classList.add('hidden');
dropZone.classList.remove('pointer-events-none');
updateAnalysisProgress('Initializing...', 0);
}, 300);
}
}
async function callAI(messages, temperature = 0.7) {
const provider = localStorage.getItem('ai_provider') || 'openai';
if (provider === 'deepseek' && window.deepseekService?.isConfigured) {
try {
const response = await fetch('https://api.deepseek.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('deepseek_api_key')}`
},
body: JSON.stringify({
model: localStorage.getItem('deepseek_model') || 'deepseek-chat',
messages: messages,
temperature: temperature,
max_tokens: 2000
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'DeepSeek API request failed');
}
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
showToast(`DeepSeek Error: ${error.message}`, 'error');
return null;
}
} else {
// Fallback to OpenAI
const apiKey = localStorage.getItem('openai_api_key');
const model = localStorage.getItem('openai_model') || 'gpt-4o';
const maxTokens = parseInt(localStorage.getItem('openai_max_tokens')) || 2000;
if (!apiKey) {
showToast('OpenAI API key not configured. Go to Settings.', 'error');
return null;
}
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model: model,
messages: messages,
temperature: temperature,
max_tokens: maxTokens
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'API request failed');
}
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
showToast(`OpenAI Error: ${error.message}`, 'error');
return null;
}
}
}
// Legacy alias for backward compatibility
async function callOpenAI(messages, temperature = 0.7) {
return callAI(messages, temperature);
}
// Delete hosted image from imgBB
async function deleteHostedImage(deleteUrl) {
if (!deleteUrl) return false;
try {
const response = await fetch(deleteUrl, { method: 'GET' });
// imgBB delete URLs work via GET request
return response.ok;
} catch (error) {
console.error('Failed to delete image:', error);
return false;
}
}
// Get hosted photo URLs for eBay HTML description
function getHostedPhotoUrlsForEbay() {
return hostedPhotoUrls.map(img => ({
full: img.url,
display: img.displayUrl || img.url,
thumb: img.thumb,
medium: img.medium,
viewer: img.viewerUrl
}));
}
async function generateListingWithAI() {
const artist = document.getElementById('artistInput').value.trim();
const title = document.getElementById('titleInput').value.trim();
const catNo = document.getElementById('catInput').value.trim();
const year = document.getElementById('yearInput').value.trim();
if (!artist || !title) {
showToast('Please enter at least artist and title', 'error');
return;
}
const messages = [
{
role: 'system',
content: 'You are a vinyl record eBay listing expert. Generate optimized titles, descriptions, and pricing strategies. Always return JSON format with: titles (array), description (string), condition_notes (string), price_estimate (object with min, max, recommended), and tags (array).'
},
{
role: 'user',
content: `Generate an eBay listing for: ${artist} - ${title}${catNo ? ` (Catalog: ${catNo})` : ''}${year ? ` (${year})` : ''}. Include optimized title options, professional HTML description, condition guidance, price estimate in GBP, and relevant tags.`
}
];
const provider = localStorage.getItem('ai_provider') || 'openai';
showToast(`Generating listing with ${provider === 'deepseek' ? 'DeepSeek' : 'OpenAI'}...`, 'success');
const result = await callAI(messages, 0.7);
if (result) {
try {
const data = JSON.parse(result);
// Populate the UI with AI-generated content
if (data.titles) {
renderTitleOptions(data.titles.map(t => ({
text: t.length > 80 ? t.substring(0, 77) + '...' : t,
chars: Math.min(t.length, 80),
style: 'AI Generated'
})));
}
if (data.description) {
document.getElementById('htmlOutput').value = data.description;
}
if (data.tags) {
const tagsContainer = document.getElementById('tagsOutput');
tagsContainer.innerHTML = data.tags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
}
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
showToast('AI listing generated!', 'success');
} catch (e) {
// If not valid JSON, treat as plain text description
document.getElementById('htmlOutput').value = result;
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
}
}
}
function requestHelp() {
alert(`VINYL PHOTO GUIDE:
ESSENTIAL SHOTS (need these):
• Front cover - square, no glare, color accurate
• Back cover - full frame, readable text
• Both labels - close enough to read all text
• Deadwax/runout - for pressing identification
CONDITION SHOTS:
• Vinyl in raking light at angle (shows scratches)
• Sleeve edges and corners
• Any flaws clearly documented
OPTIONARY BUT HELPFUL:
• Inner sleeve condition
• Inserts, posters, extras
• Hype stickers
• Barcode area
TIPS:
- Use natural daylight or 5500K bulbs
- Avoid flash directly on glossy sleeves
- Include scale reference if unusual size
- Photograph flaws honestly - reduces returns`);
}
function showToast(message, type = 'success') {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const iconMap = {
success: 'check',
error: 'alert-circle',
warning: 'alert-triangle'
};
const colorMap = {
success: 'text-green-400',
error: 'text-red-400',
warning: 'text-yellow-400'
};
const toast = document.createElement('div');
toast.className = `toast ${type} flex items-center gap-3`;
toast.innerHTML = `
<i data-feather="${iconMap[type] || 'info'}" class="w-5 h-5 ${colorMap[type] || 'text-blue-400'}"></i>
<span class="text-sm text-gray-200">${message}</span>
`;
document.body.appendChild(toast);
feather.replace();
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Cleanup function to delete all hosted images for current listing
async function cleanupHostedImages() {
if (window.currentListingImages) {
for (const img of window.currentListingImages) {
if (img.deleteUrl) {
await deleteHostedImage(img.deleteUrl);
}
}
window.currentListingImages = [];
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
console.log('VinylVault Pro initialized');
// Initialize drop zone
initDropZone();
// Attach event listeners to buttons
const generateBtn = document.getElementById('generateListingBtn');
if (generateBtn) {
generateBtn.addEventListener('click', generateListing);
}
const draftBtn = document.getElementById('draftAnalysisBtn');
if (draftBtn) {
draftBtn.addEventListener('click', draftAnalysis);
}
const helpBtn = document.getElementById('requestHelpBtn');
if (helpBtn) {
helpBtn.addEventListener('click', requestHelp);
}
const copyHTMLBtn = document.getElementById('copyHTMLBtn');
if (copyHTMLBtn) {
copyHTMLBtn.addEventListener('click', copyHTML);
}
const copyTagsBtn = document.getElementById('copyTagsBtn');
if (copyTagsBtn) {
copyTagsBtn.addEventListener('click', copyTags);
}
const analyzePhotoBtn = document.getElementById('analyzePhotoTypesBtn');
if (analyzePhotoBtn) {
analyzePhotoBtn.addEventListener('click', analyzePhotoTypes);
}
// Clear Collection Import Banner listeners
const clearCollectionBtn = document.querySelector('#collectionBanner button');
if (clearCollectionBtn) {
clearCollectionBtn.addEventListener('click', clearCollectionImport);
}
// Warn about unsaved changes when leaving page with hosted images
window.addEventListener('beforeunload', (e) => {
if (hostedPhotoUrls.length > 0 && !window.listingPublished) {
// Optional: could add cleanup here or warn user
}
});
});
// Collection Import functions (defined here to avoid reference errors)
function clearCollectionImport() {
sessionStorage.removeItem('collectionListingRecord');
const banner = document.getElementById('collectionBanner');
if (banner) {
banner.classList.add('hidden');
}
showToast('Collection import cleared', 'success');
}
function checkCollectionImport() {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('fromCollection') === 'true') {
const recordData = sessionStorage.getItem('collectionListingRecord');
if (recordData) {
const record = JSON.parse(recordData);
populateFieldsFromCollection(record);
const banner = document.getElementById('collectionBanner');
if (banner) {
banner.classList.remove('hidden');
}
const indicator = document.getElementById('collectionDataIndicator');
if (indicator) {
indicator.classList.remove('hidden');
}
}
}
}
function populateFieldsFromCollection(record) {
if (!record) return;
const fields = {
'artistInput': record.artist,
'titleInput': record.title,
'catInput': record.catalogueNumber || record.matrixNotes,
'yearInput': record.year,
'costInput': record.purchasePrice,
'daysOwnedInput': record.daysOwned
};
Object.entries(fields).forEach(([fieldId, value]) => {
const field = document.getElementById(fieldId);
if (field && value) {
field.value = value;
}
});
// Set conditions if available
if (record.conditionVinyl) {
const vinylCondition = document.getElementById('vinylConditionInput');
if (vinylCondition) vinylCondition.value = record.conditionVinyl;
}
if (record.conditionSleeve) {
const sleeveCondition = document.getElementById('sleeveConditionInput');
if (sleeveCondition) sleeveCondition.value = record.conditionSleeve;
}
showToast(`Loaded ${record.artist} - ${record.title} from collection`, 'success');
}
// Call check on load
checkCollectionImport();
: '€';
// Mock comp research results
const comps = {
nm: { low: 45, high: 65, median: 52 },
vgplus: { low: 28, high: 42, median: 34 },
vg: { low: 15, high: 25, median: 19 }
};
// Calculate recommended price based on goal
let recommendedBin, strategy;
switch(goal) {
case 'quick':
recommendedBin = Math.round(comps.vgplus.low * 0.9);
strategy = 'BIN + Best Offer (aggressive)';
break;
case 'max':
recommendedBin = Math.round(comps.nm.high * 1.1);
strategy = 'BIN only, no offers, long duration';
break;
default:
recommendedBin = comps.vgplus.median;
strategy = 'BIN + Best Offer (standard)';
}
// Fee calculation (eBay UK approx)
const ebayFeeRate = 0.13; // 13% final value fee
const paypalRate = 0.029; // 2.9% + 30p
const fixedFee = 0.30;
const shippingCost = 4.50; // Estimated
const packingCost = 1.50;
const totalFees = (recommendedBin * ebayFeeRate) + (recommendedBin * paypalRate) + fixedFee;
const breakEven = cost + totalFees + shippingCost + packingCost;
const safeFloor = Math.ceil(breakEven * 1.05); // 5% buffer
// Generate titles
const baseTitle = `${artist || 'ARTIST'} - ${title || 'TITLE'}`;
const titles = generateTitles(baseTitle, catNo, year, goal);
// Render results
renderTitleOptions(titles);
renderPricingStrategy(recommendedBin, strategy, comps, currency, goal);
renderFeeFloor(cost, totalFees, shippingCost, packingCost, safeFloor, currency);
await renderHTMLDescription(data, titles[0]);
renderTags(artist, title, catNo, year);
renderShotList();
// Show results
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
resultsSection.scrollIntoView({ behavior: 'smooth' });
currentAnalysis = {
titles, recommendedBin, strategy, breakEven, safeFloor, currency
};
}
function generateTitles(base, catNo, year, goal) {
const titles = [];
const cat = catNo || 'CAT#';
const yr = year || 'YEAR';
const country = window.detectedCountry || 'UK';
const genre = window.detectedGenre || 'Rock';
const format = window.detectedFormat?.includes('7"') ? '7"' : window.detectedFormat?.includes('12"') ? '12"' : 'LP';
// Option 1: Classic collector focus
titles.push(`${base} ${format} ${yr} ${country} 1st Press ${cat} EX/VG+`);
// Option 2: Condition forward
titles.push(`NM! ${base} Original ${yr} Vinyl ${format} ${cat} Nice Copy`);
// Option 3: Rarity/hype with detected genre
titles.push(`${base} ${yr} ${country} Press ${cat} Rare Vintage ${genre} ${format}`);
// Option 4: Clean searchable
titles.push(`${base} Vinyl ${format} ${yr} ${cat} Excellent Condition`);
// Option 5: Genre tagged
titles.push(`${base} ${yr} ${format} ${genre} ${cat} VG+ Plays Great`);
return titles.map((t, i) => ({
text: t.length > 80 ? t.substring(0, 77) + '...' : t,
chars: Math.min(t.length, 80),
style: ['Classic Collector', 'Condition Forward', 'Rarity Focus', 'Clean Search', 'Genre Tagged'][i]
}));
}
function renderTitleOptions(titles) {
const container = document.getElementById('titleOptions');
container.innerHTML = titles.map((t, i) => `
<div class="title-option ${i === 0 ? 'selected' : ''}" onclick="selectTitle(this, '${t.text.replace(/'/g, "\\'")}')">
<span class="char-count">${t.chars}/80</span>
<p class="font-medium text-gray-200 pr-16">${t.text}</p>
<p class="text-sm text-gray-500 mt-1">${t.style}</p>
</div>
`).join('');
}
function selectTitle(el, text) {
document.querySelectorAll('.title-option').forEach(o => o.classList.remove('selected'));
el.classList.add('selected');
// Update clipboard copy
navigator.clipboard.writeText(text);
showToast('Title copied to clipboard!', 'success');
}
function renderPricingStrategy(bin, strategy, comps, currency, goal) {
const container = document.getElementById('pricingStrategy');
const offerSettings = goal === 'max' ? 'Offers: OFF' :
`Auto-accept: ${currency}${Math.floor(bin * 0.85)} | Auto-decline: ${currency}${Math.floor(bin * 0.7)}`;
container.innerHTML = `
<div class="pricing-card recommended">
<div class="flex items-center gap-2 mb-3">
<span class="px-2 py-1 bg-accent/20 text-accent text-xs font-medium rounded">RECOMMENDED</span>
</div>
<p class="text-3xl font-bold text-white mb-1">${currency}${bin}</p>
<p class="text-sm text-gray-400 mb-3">Buy It Now</p>
<div class="space-y-2 text-sm">
<p class="flex justify-between"><span class="text-gray-500">Strategy:</span> <span class="text-gray-300">${strategy}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Best Offer:</span> <span class="text-gray-300">${offerSettings}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Duration:</span> <span class="text-gray-300">30 days (GTC)</span></p>
</div>
</div>
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-400 uppercase tracking-wide">Sold Comps by Grade</h4>
<div class="space-y-2">
<div class="flex justify-between items-center p-3 bg-surface rounded-lg">
<span class="text-green-400 font-medium">NM/NM-</span>
<span class="text-gray-300">${currency}${comps.nm.low}-${comps.nm.high} <span class="text-gray-500">(med: ${comps.nm.median})</span></span>
</div>
<div class="flex justify-between items-center p-3 bg-surface rounded-lg border border-accent/30">
<span class="text-accent font-medium">VG+/EX</span>
<span class="text-gray-300">${currency}${comps.vgplus.low}-${comps.vgplus.high} <span class="text-gray-500">(med: ${comps.vgplus.median})</span></span>
</div>
<div class="flex justify-between items-center p-3 bg-surface rounded-lg">
<span class="text-yellow-400 font-medium">VG/VG+</span>
<span class="text-gray-300">${currency}${comps.vg.low}-${comps.vg.high} <span class="text-gray-500">(med: ${comps.vg.median})</span></span>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">Based on last 90 days sold listings, same pressing. Prices exclude postage.</p>
</div>
`;
}
function renderFeeFloor(cost, fees, shipping, packing, safeFloor, currency) {
const container = document.getElementById('feeFloor');
container.innerHTML = `
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Your Cost</p>
<p class="text-xl font-bold text-gray-300">${currency}${cost.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Est. Fees</p>
<p class="text-xl font-bold text-red-400">${currency}${fees.toFixed(2)}</p>
<p class="text-xs text-gray-600">~16% total</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Ship + Pack</p>
<p class="text-xl font-bold text-gray-300">${currency}${(shipping + packing).toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/30">
<p class="text-xs text-green-500 uppercase mb-1">Safe Floor Price</p>
<p class="text-2xl font-bold text-green-400">${currency}${safeFloor}</p>
<p class="text-xs text-green-600/70">Auto-decline below this</p>
</div>
`;
}
async function renderHTMLDescription(data, titleObj) {
const { artist, title, catNo, year } = data;
// Use hosted URL if available, otherwise fallback to local object URL
let heroImg = '';
let galleryImages = [];
if (hostedPhotoUrls.length > 0) {
heroImg = hostedPhotoUrls[0].displayUrl || hostedPhotoUrls[0].url;
galleryImages = hostedPhotoUrls.slice(1).map(img => img.displayUrl || img.url);
} else if (uploadedPhotos.length > 0) {
heroImg = URL.createObjectURL(uploadedPhotos[0]);
galleryImages = uploadedPhotos.slice(1).map((_, i) => URL.createObjectURL(uploadedPhotos[i + 1]));
}
// Use OCR-detected values if available
const detectedLabel = window.detectedLabel || '[Verify from photos]';
const detectedCountry = window.detectedCountry || 'UK';
const detectedFormat = window.detectedFormat || 'LP • 33rpm';
const detectedGenre = window.detectedGenre || 'rock';
const detectedCondition = window.detectedCondition || 'VG+/VG+';
const detectedPressingInfo = window.detectedPressingInfo || '';
// Fetch tracklist and detailed info from Discogs if available
let tracklistHtml = '';
let pressingDetailsHtml = '';
let provenanceHtml = '';
if (window.discogsReleaseId && window.discogsService?.key) {
try {
const discogsData = await window.discogsService.fetchTracklist(window.discogsReleaseId);
if (discogsData && discogsData.tracklist) {
// Build tracklist HTML
const hasSideBreakdown = discogsData.tracklist.some(t => t.position && (t.position.startsWith('A') || t.position.startsWith('B')));
if (hasSideBreakdown) {
// Group by sides
const sides = {};
discogsData.tracklist.forEach(track => {
const side = track.position ? track.position.charAt(0) : 'Other';
if (!sides[side]) sides[side] = [];
sides[side].push(track);
});
tracklistHtml = Object.entries(sides).map(([side, tracks]) => `
<div style="margin-bottom: 16px;">
<h4 style="color: #7c3aed; font-size: 13px; font-weight: 600; margin: 0 0 8px 0; text-transform: uppercase; letter-spacing: 0.5px;">Side ${side}</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
${tracks.map(track => `
<div style="flex: 1 1 200px; min-width: 200px; display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0;">
<span style="color: #1e293b; font-size: 13px;"><strong>${track.position}</strong> ${track.title}</span>
${track.duration ? `<span style="color: #64748b; font-size: 12px; font-family: monospace;">${track.duration}</span>` : ''}
</div>
`).join('')}
</div>
</div>
`).join('');
} else {
// Simple list
tracklistHtml = `
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
${discogsData.tracklist.map(track => `
<div style="flex: 1 1 200px; min-width: 200px; display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0;">
<span style="color: #1e293b; font-size: 13px;">${track.position ? `<strong>${track.position}</strong> ` : ''}${track.title}</span>
${track.duration ? `<span style="color: #64748b; font-size: 12px; font-family: monospace;">${track.duration}</span>` : ''}
</div>
`).join('')}
</div>
`;
}
// Build pressing/variation details
const identifiers = discogsData.identifiers || [];
const barcodeInfo = identifiers.find(i => i.type === 'Barcode');
const matrixInfo = identifiers.filter(i => i.type === 'Matrix / Runout' || i.type === 'Runout');
const pressingInfo = identifiers.filter(i => i.type === 'Pressing Plant' || i.type === 'Mastering');
if (matrixInfo.length > 0 || barcodeInfo || pressingInfo.length > 0) {
pressingDetailsHtml = `
<div style="background: #f0fdf4; border-left: 4px solid #22c55e; padding: 16px 20px; margin: 24px 0; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #166534; font-size: 15px; font-weight: 600;">Pressing & Matrix Information</h3>
<div style="font-family: monospace; font-size: 13px; line-height: 1.6; color: #15803d;">
${barcodeInfo ? `<p style="margin: 4px 0;"><strong>Barcode:</strong> ${barcodeInfo.value}</p>` : ''}
${matrixInfo.map(m => `<p style="margin: 4px 0;"><strong>${m.type}:</strong> ${m.value}${m.description ? ` <em>(${m.description})</em>` : ''}</p>`).join('')}
${pressingInfo.map(p => `<p style="margin: 4px 0;"><strong>${p.type}:</strong> ${p.value}</p>`).join('')}
</div>
${discogsData.notes ? `<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #bbf7d0; font-size: 12px; color: #166534; font-style: italic;">${discogsData.notes.substring(0, 300)}${discogsData.notes.length > 300 ? '...' : ''}</p>` : ''}
</div>
`;
}
// Build provenance data for buyer confidence
const companies = discogsData.companies || [];
const masteredBy = companies.find(c => c.entity_type_name === 'Mastered At' || c.name.toLowerCase().includes('mastering'));
const pressedBy = companies.find(c => c.entity_type_name === 'Pressed By' || c.name.toLowerCase().includes('pressing'));
const lacquerCut = companies.find(c => c.entity_type_name === 'Lacquer Cut At');
if (masteredBy || pressedBy || lacquerCut) {
provenanceHtml = `
<div style="background: #eff6ff; border: 1px solid #bfdbfe; padding: 16px; margin: 24px 0; border-radius: 8px;">
<h3 style="margin: 0 0 12px 0; color: #1e40af; font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
Provenance & Production
</h3>
<div style="font-size: 13px; color: #1e3a8a; line-height: 1.6;">
${masteredBy ? `<p style="margin: 4px 0;">✓ Mastered at <strong>${masteredBy.name}</strong></p>` : ''}
${lacquerCut ? `<p style="margin: 4px 0;">✓ Lacquer cut at <strong>${lacquerCut.name}</strong></p>` : ''}
${pressedBy ? `<p style="margin: 4px 0;">✓ Pressed at <strong>${pressedBy.name}</strong></p>` : ''}
${discogsData.num_for_sale ? `<p style="margin: 8px 0 0 0; padding-top: 8px; border-top: 1px solid #bfdbfe; color: #3b82f6; font-size: 12px;">Reference: ${discogsData.num_for_sale} copies currently for sale on Discogs</p>` : ''}
</div>
</div>
`;
}
}
} catch (e) {
console.error('Failed to fetch Discogs details for HTML:', e);
}
}
// If no tracklist from Discogs, provide placeholder
if (!tracklistHtml) {
tracklistHtml = `<p style="color: #64748b; font-style: italic;">Tracklist verification recommended. Please compare with Discogs entry for accuracy.</p>`;
}
const galleryHtml = galleryImages.length > 0 ? `
<!-- PHOTO GALLERY -->
<div style="margin-bottom: 24px;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px;">
${galleryImages.map(url => `<img src="${url}" style="width: 100%; height: 150px; object-fit: cover; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);" alt="Record photo">`).join('')}
</div>
</div>
` : '';
const html = `<div style="max-width: 800px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #333; line-height: 1.6;">
<!-- HERO IMAGE -->
<div style="margin-bottom: 24px;">
<img src="${heroImg}" alt="${artist} - ${title}" style="width: 100%; max-width: 600px; display: block; margin: 0 auto; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.15);">
</div>
${galleryHtml}
<!-- BADGES -->
<div style="display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-bottom: 24px;">
<span style="background: #7c3aed; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">Original ${detectedCountry} Pressing</span>
<span style="background: #059669; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${year || '1970s'}</span>
<span style="background: #0891b2; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${detectedFormat}</span>
<span style="background: #d97706; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${detectedCondition}</span>
</div>
<!-- AT A GLANCE -->
<table style="width: 100%; border-collapse: collapse; margin-bottom: 24px; font-size: 14px;">
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600; width: 140px;">Artist</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${artist || 'See title'}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Title</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${title || 'See title'}</td>
</tr>
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Label</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${detectedLabel}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Catalogue</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;"><code style="background: #f1f5f9; padding: 2px 8px; border-radius: 4px;">${catNo || '[See photos]'}</code></td>
</tr>
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Country</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${detectedCountry}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Year</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${year || '[Verify]'}</td>
</tr>
</table>
<!-- CONDITION -->
<div style="background: #fefce8; border-left: 4px solid #eab308; padding: 16px 20px; margin-bottom: 24px; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #854d0e; font-size: 16px; font-weight: 600;">Condition Report</h3>
<div style="display: grid; gap: 12px;">
<div>
<strong style="color: #713f12;">Vinyl:</strong> <span style="color: #854d0e;">VG+ — Light surface marks, plays cleanly with minimal surface noise. No skips or jumps. [Adjust based on actual inspection]</span>
</div>
<div>
<strong style="color: #713f12;">Sleeve:</strong> <span style="color: #854d0e;">VG+ — Minor edge wear, light ring wear visible under raking light. No splits or writing. [Adjust based on actual inspection]</span>
</div>
<div>
<strong style="color: #713f12;">Inner Sleeve:</strong> <span style="color: #854d0e;">Original paper inner included, small split at bottom seam. [Verify/Adjust]</span>
</div>
</div>
</div>
<!-- ABOUT -->
<h3 style="color: #1e293b; font-size: 18px; font-weight: 600; margin-bottom: 12px;">About This Release</h3>
<p style="margin-bottom: 16px; color: #475569;">${detectedGenre ? `${detectedGenre.charAt(0).toUpperCase() + detectedGenre.slice(1)} release` : 'Vintage vinyl release'}${detectedPressingInfo ? `. Matrix/Runout: ${detectedPressingInfo}` : ''}. [Add accurate description based on verified pressing details. Mention notable features: gatefold, insert, poster, hype sticker, etc.]</p>
<!-- TRACKLIST -->
<h3 style="color: #1e293b; font-size: 18px; font-weight: 600; margin-bottom: 12px;">Tracklist</h3>
<div style="background: #f8fafc; padding: 16px 20px; border-radius: 8px; margin-bottom: 24px;">
${tracklistHtml}
</div>
${pressingDetailsHtml}
${provenanceHtml}
<!-- PACKING -->
<div style="background: #eff6ff; border-left: 4px solid #3b82f6; padding: 16px 20px; margin-bottom: 24px; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #1e40af; font-size: 16px; font-weight: 600;">Packing & Postage</h3>
<p style="margin: 0 0 12px 0; color: #1e3a8a;">Records are removed from outer sleeves to prevent seam splits during transit. Packed with stiffeners in a dedicated LP mailer. Royal Mail 48 Tracked or courier service.</p>
<p style="margin: 0; color: #1e3a8a; font-size: 14px;"><strong>Combined postage:</strong> Discount available for multiple purchases—please request invoice before payment.</p>
</div>
<!-- CTA -->
<div style="text-align: center; padding: 24px; background: #f1f5f9; border-radius: 12px;">
<p style="margin: 0 0 8px 0; color: #475569; font-weight: 500;">Questions? Need more photos?</p>
<p style="margin: 0; color: #64748b; font-size: 14px;">Message me anytime—happy to provide additional angles, audio clips, or pressing details.</p>
</div>
</div>`;
// Store reference to hosted images for potential cleanup
window.currentListingImages = hostedPhotoUrls.map(img => ({
url: img.url,
deleteUrl: img.deleteUrl
}));
document.getElementById('htmlOutput').value = html;
}
function renderTags(artist, title, catNo, year) {
const genre = window.detectedGenre || 'rock';
const format = window.detectedFormat?.toLowerCase().includes('7"') ? '7 inch' :
window.detectedFormat?.toLowerCase().includes('12"') ? '12 inch single' : 'lp';
const country = window.detectedCountry?.toLowerCase() || 'uk';
const tags = [
artist || 'vinyl',
title || 'record',
format,
'vinyl record',
'original pressing',
`${country} pressing`,
year || 'vintage',
catNo || '',
genre,
genre === 'rock' ? 'prog rock' : genre,
genre === 'rock' ? 'psych' : '',
'collector',
'audiophile',
format === 'lp' ? '12 inch' : format,
'33 rpm',
format === 'lp' ? 'album' : 'single',
'used vinyl',
'graded',
'excellent condition',
'rare vinyl',
'classic rock',
'vintage vinyl',
'record collection',
'music',
'audio',
window.detectedLabel || ''
].filter(Boolean);
const container = document.getElementById('tagsOutput');
container.innerHTML = tags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
}
function renderShotList() {
// Map shot types to display info
const shotDefinitions = [
{ id: 'front', name: 'Front cover (square, well-lit)', critical: true },
{ id: 'back', name: 'Back cover (full shot)', critical: true },
{ id: 'spine', name: 'Spine (readable text)', critical: true },
{ id: 'label_a', name: 'Label Side A (close, legible)', critical: true },
{ id: 'label_b', name: 'Label Side B (close, legible)', critical: true },
{ id: 'deadwax', name: 'Deadwax/runout grooves', critical: true },
{ id: 'inner', name: 'Inner sleeve (both sides)', critical: false },
{ id: 'insert', name: 'Insert/poster if included', critical: false },
{ id: 'hype', name: 'Hype sticker (if present)', critical: false },
{ id: 'vinyl', name: 'Vinyl in raking light (flaws)', critical: true },
{ id: 'corners', name: 'Sleeve corners/edges detail', critical: false },
{ id: 'barcode', name: 'Barcode area', critical: false }
];
// Check if we have any photos at all
const hasPhotos = uploadedPhotos.length > 0;
const container = document.getElementById('shotList');
container.innerHTML = shotDefinitions.map(shot => {
const have = detectedPhotoTypes.has(shot.id) || (shot.id === 'front' && hasPhotos) || (shot.id === 'back' && uploadedPhotos.length > 1);
const statusClass = have ? 'completed' : shot.critical ? 'missing' : '';
const iconColor = have ? 'text-green-500' : shot.critical ? 'text-yellow-500' : 'text-gray-500';
const textClass = have ? 'text-gray-400 line-through' : 'text-gray-300';
const icon = have ? 'check-circle' : shot.critical ? 'alert-circle' : 'circle';
return `
<div class="shot-item ${statusClass}">
<i data-feather="${icon}"
class="w-5 h-5 ${iconColor} flex-shrink-0"></i>
<span class="text-sm ${textClass}">${shot.name}</span>
${shot.critical && !have ? '<span class="ml-auto text-xs text-yellow-500 font-medium">CRITICAL</span>' : ''}
</div>
`}).join('');
feather.replace();
}
function copyHTML() {
const html = document.getElementById('htmlOutput');
html.select();
document.execCommand('copy');
showToast('HTML copied to clipboard!', 'success');
}
function copyTags() {
const tags = Array.from(document.querySelectorAll('#tagsOutput span')).map(s => s.textContent).join(', ');
navigator.clipboard.writeText(tags);
showToast('Tags copied to clipboard!', 'success');
}
async function draftAnalysis() {
if (uploadedPhotos.length === 0) {
showToast('Upload photos first for preview', 'error');
return;
}
const artist = document.getElementById('artistInput').value.trim();
const title = document.getElementById('titleInput').value.trim();
// Show loading state
const dropZone = document.getElementById('dropZone');
const spinner = document.getElementById('uploadSpinner');
spinner.classList.remove('hidden');
dropZone.classList.add('pointer-events-none');
startAnalysisProgressSimulation();
try {
// Try OCR/AI analysis if available
const service = getAIService();
let ocrResult = null;
if (service && service.apiKey && uploadedPhotos.length > 0) {
try {
ocrResult = await service.analyzeRecordImages(uploadedPhotos.slice(0, 2)); // Limit to 2 photos for speed
populateFieldsFromOCR(ocrResult);
} catch (e) {
console.log('Preview OCR failed:', e);
}
}
// Generate quick preview results
const catNo = document.getElementById('catInput').value.trim() || ocrResult?.catalogueNumber || '';
const year = document.getElementById('yearInput').value.trim() || ocrResult?.year || '';
const detectedArtist = artist || ocrResult?.artist || 'Unknown Artist';
const detectedTitle = title || ocrResult?.title || 'Unknown Title';
const baseTitle = `${detectedArtist} - ${detectedTitle}`;
// Generate quick titles
const quickTitles = [
`${baseTitle} ${year ? `(${year})` : ''} ${catNo} VG+`.substring(0, 80),
`${baseTitle} Original Pressing Vinyl LP`.substring(0, 80),
`${detectedArtist} ${detectedTitle} ${catNo || 'LP'}`.substring(0, 80)
].map((t, i) => ({
text: t,
chars: t.length,
style: ['Quick', 'Standard', 'Compact'][i]
}));
// Quick pricing estimate based on condition
const cost = parseFloat(document.getElementById('costInput').value) || 10;
const vinylCond = document.getElementById('vinylConditionInput').value;
const sleeveCond = document.getElementById('sleeveConditionInput').value;
const conditionMultipliers = { 'M': 3, 'NM': 2.5, 'VG+': 1.8, 'VG': 1.2, 'G+': 0.8, 'G': 0.5 };
const condMult = (conditionMultipliers[vinylCond] || 1) * 0.7 + (conditionMultipliers[sleeveCond] || 1) * 0.3;
const estimatedValue = Math.round(cost * Math.max(condMult, 1.5));
const suggestedPrice = Math.round(estimatedValue * 0.9);
// Render preview results
renderTitleOptions(quickTitles);
// Quick pricing card
document.getElementById('pricingStrategy').innerHTML = `
<div class="pricing-card recommended">
<div class="flex items-center gap-2 mb-3">
<span class="px-2 py-1 bg-accent/20 text-accent text-xs font-medium rounded">QUICK ESTIMATE</span>
</div>
<p class="text-3xl font-bold text-white mb-1">£${suggestedPrice}</p>
<p class="text-sm text-gray-400 mb-3">Suggested Buy It Now</p>
<div class="space-y-2 text-sm">
<p class="flex justify-between"><span class="text-gray-500">Est. Value:</span> <span class="text-gray-300">£${estimatedValue}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Your Cost:</span> <span class="text-gray-300">£${cost.toFixed(2)}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Condition:</span> <span class="text-gray-300">${vinylCond}/${sleeveCond}</span></p>
</div>
</div>
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-400 uppercase tracking-wide">Preview Notes</h4>
<div class="p-3 bg-surface rounded-lg text-sm text-gray-400">
${ocrResult ?
`<p class="text-green-400 mb-2">✓ AI detected information from photos</p>` :
`<p class="text-yellow-400 mb-2">⚠ Add API key in Settings for auto-detection</p>`
}
<p>This is a quick estimate based on your cost and condition. Run "Generate Full Listing" for complete market analysis, sold comps, and optimized pricing.</p>
</div>
${ocrResult ? `
<div class="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<p class="text-xs text-green-400 font-medium mb-1">Detected from photos:</p>
<ul class="text-xs text-gray-400 space-y-1">
${ocrResult.artist ? `<li>• Artist: ${ocrResult.artist}</li>` : ''}
${ocrResult.title ? `<li>• Title: ${ocrResult.title}</li>` : ''}
${ocrResult.catalogueNumber ? `<li>• Cat#: ${ocrResult.catalogueNumber}</li>` : ''}
${ocrResult.year ? `<li>• Year: ${ocrResult.year}</li>` : ''}
</ul>
</div>
` : ''}
</div>
`;
// Simple fee floor
const fees = suggestedPrice * 0.16;
const safeFloor = Math.ceil(cost + fees + 6);
document.getElementById('feeFloor').innerHTML = `
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Your Cost</p>
<p class="text-xl font-bold text-gray-300">£${cost.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Est. Fees</p>
<p class="text-xl font-bold text-red-400">£${fees.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Ship + Pack</p>
<p class="text-xl font-bold text-gray-300">£6.00</p>
</div>
<div class="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/30">
<p class="text-xs text-green-500 uppercase mb-1">Safe Floor</p>
<p class="text-2xl font-bold text-green-400">£${safeFloor}</p>
</div>
`;
// Preview HTML description
const previewHtml = `<!-- QUICK PREVIEW - Generated by VinylVault Pro -->
<div style="max-width: 700px; margin: 0 auto; font-family: sans-serif;">
<h2 style="color: #333;">${detectedArtist} - ${detectedTitle}</h2>
${year ? `<p><strong>Year:</strong> ${year}</p>` : ''}
${catNo ? `<p><strong>Catalogue #:</strong> ${catNo}</p>` : ''}
<p><strong>Condition:</strong> Vinyl ${vinylCond}, Sleeve ${sleeveCond}</p>
<hr style="margin: 20px 0;">
<p style="color: #666;">[Full description will be generated with complete market analysis]</p>
</div>`;
document.getElementById('htmlOutput').value = previewHtml;
// Preview tags
const previewTags = [
detectedArtist,
detectedTitle,
'vinyl',
'record',
vinylCond,
'lp',
year || 'vintage'
].filter(Boolean);
document.getElementById('tagsOutput').innerHTML = previewTags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
// Update shot list
renderShotList();
// Show results
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
resultsSection.scrollIntoView({ behavior: 'smooth' });
showToast('Quick preview ready! Click "Generate Full Listing" for complete analysis.', 'success');
} catch (error) {
console.error('Preview error:', error);
showToast('Preview failed: ' + error.message, 'error');
} finally {
stopAnalysisProgress();
setTimeout(() => {
spinner.classList.add('hidden');
dropZone.classList.remove('pointer-events-none');
updateAnalysisProgress('Initializing...', 0);
}, 300);
}
}
async function callAI(messages, temperature = 0.7) {
const provider = localStorage.getItem('ai_provider') || 'openai';
if (provider === 'deepseek' && window.deepseekService?.isConfigured) {
try {
const response = await fetch('https://api.deepseek.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('deepseek_api_key')}`
},
body: JSON.stringify({
model: localStorage.getItem('deepseek_model') || 'deepseek-chat',
messages: messages,
temperature: temperature,
max_tokens: 2000
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'DeepSeek API request failed');
}
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
showToast(`DeepSeek Error: ${error.message}`, 'error');
return null;
}
} else {
// Fallback to OpenAI
const apiKey = localStorage.getItem('openai_api_key');
const model = localStorage.getItem('openai_model') || 'gpt-4o';
const maxTokens = parseInt(localStorage.getItem('openai_max_tokens')) || 2000;
if (!apiKey) {
showToast('OpenAI API key not configured. Go to Settings.', 'error');
return null;
}
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model: model,
messages: messages,
temperature: temperature,
max_tokens: maxTokens
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'API request failed');
}
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
showToast(`OpenAI Error: ${error.message}`, 'error');
return null;
}
}
}
// Legacy alias for backward compatibility
async function callOpenAI(messages, temperature = 0.7) {
return callAI(messages, temperature);
}
// Delete hosted image from imgBB
async function deleteHostedImage(deleteUrl) {
if (!deleteUrl) return false;
try {
const response = await fetch(deleteUrl, { method: 'GET' });
// imgBB delete URLs work via GET request
return response.ok;
} catch (error) {
console.error('Failed to delete image:', error);
return false;
}
}
// Get hosted photo URLs for eBay HTML description
function getHostedPhotoUrlsForEbay() {
return hostedPhotoUrls.map(img => ({
full: img.url,
display: img.displayUrl || img.url,
thumb: img.thumb,
medium: img.medium,
viewer: img.viewerUrl
}));
}
async function generateListingWithAI() {
const artist = document.getElementById('artistInput').value.trim();
const title = document.getElementById('titleInput').value.trim();
const catNo = document.getElementById('catInput').value.trim();
const year = document.getElementById('yearInput').value.trim();
if (!artist || !title) {
showToast('Please enter at least artist and title', 'error');
return;
}
const messages = [
{
role: 'system',
content: 'You are a vinyl record eBay listing expert. Generate optimized titles, descriptions, and pricing strategies. Always return JSON format with: titles (array), description (string), condition_notes (string), price_estimate (object with min, max, recommended), and tags (array).'
},
{
role: 'user',
content: `Generate an eBay listing for: ${artist} - ${title}${catNo ? ` (Catalog: ${catNo})` : ''}${year ? ` (${year})` : ''}. Include optimized title options, professional HTML description, condition guidance, price estimate in GBP, and relevant tags.`
}
];
const provider = localStorage.getItem('ai_provider') || 'openai';
showToast(`Generating listing with ${provider === 'deepseek' ? 'DeepSeek' : 'OpenAI'}...`, 'success');
const result = await callAI(messages, 0.7);
if (result) {
try {
const data = JSON.parse(result);
// Populate the UI with AI-generated content
if (data.titles) {
renderTitleOptions(data.titles.map(t => ({
text: t.length > 80 ? t.substring(0, 77) + '...' : t,
chars: Math.min(t.length, 80),
style: 'AI Generated'
})));
}
if (data.description) {
document.getElementById('htmlOutput').value = data.description;
}
if (data.tags) {
const tagsContainer = document.getElementById('tagsOutput');
tagsContainer.innerHTML = data.tags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
}
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
showToast('AI listing generated!', 'success');
} catch (e) {
// If not valid JSON, treat as plain text description
document.getElementById('htmlOutput').value = result;
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
}
}
}
function requestHelp() {
alert(`VINYL PHOTO GUIDE:
ESSENTIAL SHOTS (need these):
• Front cover - square, no glare, color accurate
• Back cover - full frame, readable text
• Both labels - close enough to read all text
• Deadwax/runout - for pressing identification
CONDITION SHOTS:
• Vinyl in raking light at angle (shows scratches)
• Sleeve edges and corners
• Any flaws clearly documented
OPTIONARY BUT HELPFUL:
• Inner sleeve condition
• Inserts, posters, extras
• Hype stickers
• Barcode area
TIPS:
- Use natural daylight or 5500K bulbs
- Avoid flash directly on glossy sleeves
- Include scale reference if unusual size
- Photograph flaws honestly - reduces returns`);
}
function showToast(message, type = 'success') {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const iconMap = {
success: 'check',
error: 'alert-circle',
warning: 'alert-triangle'
};
const colorMap = {
success: 'text-green-400',
error: 'text-red-400',
warning: 'text-yellow-400'
};
const toast = document.createElement('div');
toast.className = `toast ${type} flex items-center gap-3`;
toast.innerHTML = `
<i data-feather="${iconMap[type] || 'info'}" class="w-5 h-5 ${colorMap[type] || 'text-blue-400'}"></i>
<span class="text-sm text-gray-200">${message}</span>
`;
document.body.appendChild(toast);
feather.replace();
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Cleanup function to delete all hosted images for current listing
async function cleanupHostedImages() {
if (window.currentListingImages) {
for (const img of window.currentListingImages) {
if (img.deleteUrl) {
await deleteHostedImage(img.deleteUrl);
}
}
window.currentListingImages = [];
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
console.log('VinylVault Pro initialized');
// Initialize drop zone
initDropZone();
// Warn about unsaved changes when leaving page with hosted images
window.addEventListener('beforeunload', (e) => {
if (hostedPhotoUrls.length > 0 && !window.listingPublished) {
// Optional: could add cleanup here or warn user
}
});
});
: '€';
// Mock comp research results
const comps = {
nm: { low: 45, high: 65, median: 52 },
vgplus: { low: 28, high: 42, median: 34 },
vg: { low: 15, high: 25, median: 19 }
};
// Calculate recommended price based on goal
let recommendedBin, strategy;
switch(goal) {
case 'quick':
recommendedBin = Math.round(comps.vgplus.low * 0.9);
strategy = 'BIN + Best Offer (aggressive)';
break;
case 'max':
recommendedBin = Math.round(comps.nm.high * 1.1);
strategy = 'BIN only, no offers, long duration';
break;
default:
recommendedBin = comps.vgplus.median;
strategy = 'BIN + Best Offer (standard)';
}
// Fee calculation (eBay UK approx)
const ebayFeeRate = 0.13; // 13% final value fee
const paypalRate = 0.029; // 2.9% + 30p
const fixedFee = 0.30;
const shippingCost = 4.50; // Estimated
const packingCost = 1.50;
const totalFees = (recommendedBin * ebayFeeRate) + (recommendedBin * paypalRate) + fixedFee;
const breakEven = cost + totalFees + shippingCost + packingCost;
const safeFloor = Math.ceil(breakEven * 1.05); // 5% buffer
// Generate titles
const baseTitle = `${artist || 'ARTIST'} - ${title || 'TITLE'}`;
const titles = generateTitles(baseTitle, catNo, year, goal);
// Render results
renderTitleOptions(titles);
renderPricingStrategy(recommendedBin, strategy, comps, currency, goal);
renderFeeFloor(cost, totalFees, shippingCost, packingCost, safeFloor, currency);
await renderHTMLDescription(data, titles[0]);
renderTags(artist, title, catNo, year);
renderShotList();
// Show results
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
resultsSection.scrollIntoView({ behavior: 'smooth' });
currentAnalysis = {
titles, recommendedBin, strategy, breakEven, safeFloor, currency
};
}
function generateTitles(base, catNo, year, goal) {
const titles = [];
const cat = catNo || 'CAT#';
const yr = year || 'YEAR';
const country = window.detectedCountry || 'UK';
const genre = window.detectedGenre || 'Rock';
const format = window.detectedFormat?.includes('7"') ? '7"' : window.detectedFormat?.includes('12"') ? '12"' : 'LP';
// Option 1: Classic collector focus
titles.push(`${base} ${format} ${yr} ${country} 1st Press ${cat} EX/VG+`);
// Option 2: Condition forward
titles.push(`NM! ${base} Original ${yr} Vinyl ${format} ${cat} Nice Copy`);
// Option 3: Rarity/hype with detected genre
titles.push(`${base} ${yr} ${country} Press ${cat} Rare Vintage ${genre} ${format}`);
// Option 4: Clean searchable
titles.push(`${base} Vinyl ${format} ${yr} ${cat} Excellent Condition`);
// Option 5: Genre tagged
titles.push(`${base} ${yr} ${format} ${genre} ${cat} VG+ Plays Great`);
return titles.map((t, i) => ({
text: t.length > 80 ? t.substring(0, 77) + '...' : t,
chars: Math.min(t.length, 80),
style: ['Classic Collector', 'Condition Forward', 'Rarity Focus', 'Clean Search', 'Genre Tagged'][i]
}));
}
function renderTitleOptions(titles) {
const container = document.getElementById('titleOptions');
container.innerHTML = titles.map((t, i) => `
<div class="title-option ${i === 0 ? 'selected' : ''}" onclick="selectTitle(this, '${t.text.replace(/'/g, "\\'")}')">
<span class="char-count">${t.chars}/80</span>
<p class="font-medium text-gray-200 pr-16">${t.text}</p>
<p class="text-sm text-gray-500 mt-1">${t.style}</p>
</div>
`).join('');
}
function selectTitle(el, text) {
document.querySelectorAll('.title-option').forEach(o => o.classList.remove('selected'));
el.classList.add('selected');
// Update clipboard copy
navigator.clipboard.writeText(text);
showToast('Title copied to clipboard!', 'success');
}
function renderPricingStrategy(bin, strategy, comps, currency, goal) {
const container = document.getElementById('pricingStrategy');
const offerSettings = goal === 'max' ? 'Offers: OFF' :
`Auto-accept: ${currency}${Math.floor(bin * 0.85)} | Auto-decline: ${currency}${Math.floor(bin * 0.7)}`;
container.innerHTML = `
<div class="pricing-card recommended">
<div class="flex items-center gap-2 mb-3">
<span class="px-2 py-1 bg-accent/20 text-accent text-xs font-medium rounded">RECOMMENDED</span>
</div>
<p class="text-3xl font-bold text-white mb-1">${currency}${bin}</p>
<p class="text-sm text-gray-400 mb-3">Buy It Now</p>
<div class="space-y-2 text-sm">
<p class="flex justify-between"><span class="text-gray-500">Strategy:</span> <span class="text-gray-300">${strategy}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Best Offer:</span> <span class="text-gray-300">${offerSettings}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Duration:</span> <span class="text-gray-300">30 days (GTC)</span></p>
</div>
</div>
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-400 uppercase tracking-wide">Sold Comps by Grade</h4>
<div class="space-y-2">
<div class="flex justify-between items-center p-3 bg-surface rounded-lg">
<span class="text-green-400 font-medium">NM/NM-</span>
<span class="text-gray-300">${currency}${comps.nm.low}-${comps.nm.high} <span class="text-gray-500">(med: ${comps.nm.median})</span></span>
</div>
<div class="flex justify-between items-center p-3 bg-surface rounded-lg border border-accent/30">
<span class="text-accent font-medium">VG+/EX</span>
<span class="text-gray-300">${currency}${comps.vgplus.low}-${comps.vgplus.high} <span class="text-gray-500">(med: ${comps.vgplus.median})</span></span>
</div>
<div class="flex justify-between items-center p-3 bg-surface rounded-lg">
<span class="text-yellow-400 font-medium">VG/VG+</span>
<span class="text-gray-300">${currency}${comps.vg.low}-${comps.vg.high} <span class="text-gray-500">(med: ${comps.vg.median})</span></span>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">Based on last 90 days sold listings, same pressing. Prices exclude postage.</p>
</div>
`;
}
function renderFeeFloor(cost, fees, shipping, packing, safeFloor, currency) {
const container = document.getElementById('feeFloor');
container.innerHTML = `
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Your Cost</p>
<p class="text-xl font-bold text-gray-300">${currency}${cost.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Est. Fees</p>
<p class="text-xl font-bold text-red-400">${currency}${fees.toFixed(2)}</p>
<p class="text-xs text-gray-600">~16% total</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Ship + Pack</p>
<p class="text-xl font-bold text-gray-300">${currency}${(shipping + packing).toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/30">
<p class="text-xs text-green-500 uppercase mb-1">Safe Floor Price</p>
<p class="text-2xl font-bold text-green-400">${currency}${safeFloor}</p>
<p class="text-xs text-green-600/70">Auto-decline below this</p>
</div>
`;
}
async function renderHTMLDescription(data, titleObj) {
const { artist, title, catNo, year } = data;
// Use hosted URL if available, otherwise fallback to local object URL
let heroImg = '';
let galleryImages = [];
if (hostedPhotoUrls.length > 0) {
heroImg = hostedPhotoUrls[0].displayUrl || hostedPhotoUrls[0].url;
galleryImages = hostedPhotoUrls.slice(1).map(img => img.displayUrl || img.url);
} else if (uploadedPhotos.length > 0) {
heroImg = URL.createObjectURL(uploadedPhotos[0]);
galleryImages = uploadedPhotos.slice(1).map((_, i) => URL.createObjectURL(uploadedPhotos[i + 1]));
}
// Use OCR-detected values if available
const detectedLabel = window.detectedLabel || '[Verify from photos]';
const detectedCountry = window.detectedCountry || 'UK';
const detectedFormat = window.detectedFormat || 'LP • 33rpm';
const detectedGenre = window.detectedGenre || 'rock';
const detectedCondition = window.detectedCondition || 'VG+/VG+';
const detectedPressingInfo = window.detectedPressingInfo || '';
// Fetch tracklist and detailed info from Discogs if available
let tracklistHtml = '';
let pressingDetailsHtml = '';
let provenanceHtml = '';
if (window.discogsReleaseId && window.discogsService?.key) {
try {
const discogsData = await window.discogsService.fetchTracklist(window.discogsReleaseId);
if (discogsData && discogsData.tracklist) {
// Build tracklist HTML
const hasSideBreakdown = discogsData.tracklist.some(t => t.position && (t.position.startsWith('A') || t.position.startsWith('B')));
if (hasSideBreakdown) {
// Group by sides
const sides = {};
discogsData.tracklist.forEach(track => {
const side = track.position ? track.position.charAt(0) : 'Other';
if (!sides[side]) sides[side] = [];
sides[side].push(track);
});
tracklistHtml = Object.entries(sides).map(([side, tracks]) => `
<div style="margin-bottom: 16px;">
<h4 style="color: #7c3aed; font-size: 13px; font-weight: 600; margin: 0 0 8px 0; text-transform: uppercase; letter-spacing: 0.5px;">Side ${side}</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
${tracks.map(track => `
<div style="flex: 1 1 200px; min-width: 200px; display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0;">
<span style="color: #1e293b; font-size: 13px;"><strong>${track.position}</strong> ${track.title}</span>
${track.duration ? `<span style="color: #64748b; font-size: 12px; font-family: monospace;">${track.duration}</span>` : ''}
</div>
`).join('')}
</div>
</div>
`).join('');
} else {
// Simple list
tracklistHtml = `
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
${discogsData.tracklist.map(track => `
<div style="flex: 1 1 200px; min-width: 200px; display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0;">
<span style="color: #1e293b; font-size: 13px;">${track.position ? `<strong>${track.position}</strong> ` : ''}${track.title}</span>
${track.duration ? `<span style="color: #64748b; font-size: 12px; font-family: monospace;">${track.duration}</span>` : ''}
</div>
`).join('')}
</div>
`;
}
// Build pressing/variation details
const identifiers = discogsData.identifiers || [];
const barcodeInfo = identifiers.find(i => i.type === 'Barcode');
const matrixInfo = identifiers.filter(i => i.type === 'Matrix / Runout' || i.type === 'Runout');
const pressingInfo = identifiers.filter(i => i.type === 'Pressing Plant' || i.type === 'Mastering');
if (matrixInfo.length > 0 || barcodeInfo || pressingInfo.length > 0) {
pressingDetailsHtml = `
<div style="background: #f0fdf4; border-left: 4px solid #22c55e; padding: 16px 20px; margin: 24px 0; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #166534; font-size: 15px; font-weight: 600;">Pressing & Matrix Information</h3>
<div style="font-family: monospace; font-size: 13px; line-height: 1.6; color: #15803d;">
${barcodeInfo ? `<p style="margin: 4px 0;"><strong>Barcode:</strong> ${barcodeInfo.value}</p>` : ''}
${matrixInfo.map(m => `<p style="margin: 4px 0;"><strong>${m.type}:</strong> ${m.value}${m.description ? ` <em>(${m.description})</em>` : ''}</p>`).join('')}
${pressingInfo.map(p => `<p style="margin: 4px 0;"><strong>${p.type}:</strong> ${p.value}</p>`).join('')}
</div>
${discogsData.notes ? `<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #bbf7d0; font-size: 12px; color: #166534; font-style: italic;">${discogsData.notes.substring(0, 300)}${discogsData.notes.length > 300 ? '...' : ''}</p>` : ''}
</div>
`;
}
// Build provenance data for buyer confidence
const companies = discogsData.companies || [];
const masteredBy = companies.find(c => c.entity_type_name === 'Mastered At' || c.name.toLowerCase().includes('mastering'));
const pressedBy = companies.find(c => c.entity_type_name === 'Pressed By' || c.name.toLowerCase().includes('pressing'));
const lacquerCut = companies.find(c => c.entity_type_name === 'Lacquer Cut At');
if (masteredBy || pressedBy || lacquerCut) {
provenanceHtml = `
<div style="background: #eff6ff; border: 1px solid #bfdbfe; padding: 16px; margin: 24px 0; border-radius: 8px;">
<h3 style="margin: 0 0 12px 0; color: #1e40af; font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
Provenance & Production
</h3>
<div style="font-size: 13px; color: #1e3a8a; line-height: 1.6;">
${masteredBy ? `<p style="margin: 4px 0;">✓ Mastered at <strong>${masteredBy.name}</strong></p>` : ''}
${lacquerCut ? `<p style="margin: 4px 0;">✓ Lacquer cut at <strong>${lacquerCut.name}</strong></p>` : ''}
${pressedBy ? `<p style="margin: 4px 0;">✓ Pressed at <strong>${pressedBy.name}</strong></p>` : ''}
${discogsData.num_for_sale ? `<p style="margin: 8px 0 0 0; padding-top: 8px; border-top: 1px solid #bfdbfe; color: #3b82f6; font-size: 12px;">Reference: ${discogsData.num_for_sale} copies currently for sale on Discogs</p>` : ''}
</div>
</div>
`;
}
}
} catch (e) {
console.error('Failed to fetch Discogs details for HTML:', e);
}
}
// If no tracklist from Discogs, provide placeholder
if (!tracklistHtml) {
tracklistHtml = `<p style="color: #64748b; font-style: italic;">Tracklist verification recommended. Please compare with Discogs entry for accuracy.</p>`;
}
const galleryHtml = galleryImages.length > 0 ? `
<!-- PHOTO GALLERY -->
<div style="margin-bottom: 24px;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px;">
${galleryImages.map(url => `<img src="${url}" style="width: 100%; height: 150px; object-fit: cover; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);" alt="Record photo">`).join('')}
</div>
</div>
` : '';
const html = `<div style="max-width: 800px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #333; line-height: 1.6;">
<!-- HERO IMAGE -->
<div style="margin-bottom: 24px;">
<img src="${heroImg}" alt="${artist} - ${title}" style="width: 100%; max-width: 600px; display: block; margin: 0 auto; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.15);">
</div>
${galleryHtml}
<!-- BADGES -->
<div style="display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-bottom: 24px;">
<span style="background: #7c3aed; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">Original ${detectedCountry} Pressing</span>
<span style="background: #059669; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${year || '1970s'}</span>
<span style="background: #0891b2; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${detectedFormat}</span>
<span style="background: #d97706; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${detectedCondition}</span>
</div>
<!-- AT A GLANCE -->
<table style="width: 100%; border-collapse: collapse; margin-bottom: 24px; font-size: 14px;">
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600; width: 140px;">Artist</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${artist || 'See title'}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Title</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${title || 'See title'}</td>
</tr>
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Label</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${detectedLabel}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Catalogue</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;"><code style="background: #f1f5f9; padding: 2px 8px; border-radius: 4px;">${catNo || '[See photos]'}</code></td>
</tr>
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Country</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${detectedCountry}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Year</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${year || '[Verify]'}</td>
</tr>
</table>
<!-- CONDITION -->
<div style="background: #fefce8; border-left: 4px solid #eab308; padding: 16px 20px; margin-bottom: 24px; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #854d0e; font-size: 16px; font-weight: 600;">Condition Report</h3>
<div style="display: grid; gap: 12px;">
<div>
<strong style="color: #713f12;">Vinyl:</strong> <span style="color: #854d0e;">VG+ — Light surface marks, plays cleanly with minimal surface noise. No skips or jumps. [Adjust based on actual inspection]</span>
</div>
<div>
<strong style="color: #713f12;">Sleeve:</strong> <span style="color: #854d0e;">VG+ — Minor edge wear, light ring wear visible under raking light. No splits or writing. [Adjust based on actual inspection]</span>
</div>
<div>
<strong style="color: #713f12;">Inner Sleeve:</strong> <span style="color: #854d0e;">Original paper inner included, small split at bottom seam. [Verify/Adjust]</span>
</div>
</div>
</div>
<!-- ABOUT -->
<h3 style="color: #1e293b; font-size: 18px; font-weight: 600; margin-bottom: 12px;">About This Release</h3>
<p style="margin-bottom: 16px; color: #475569;">${detectedGenre ? `${detectedGenre.charAt(0).toUpperCase() + detectedGenre.slice(1)} release` : 'Vintage vinyl release'}${detectedPressingInfo ? `. Matrix/Runout: ${detectedPressingInfo}` : ''}. [Add accurate description based on verified pressing details. Mention notable features: gatefold, insert, poster, hype sticker, etc.]</p>
<!-- TRACKLIST -->
<h3 style="color: #1e293b; font-size: 18px; font-weight: 600; margin-bottom: 12px;">Tracklist</h3>
<div style="background: #f8fafc; padding: 16px 20px; border-radius: 8px; margin-bottom: 24px;">
${tracklistHtml}
</div>
${pressingDetailsHtml}
${provenanceHtml}
<!-- PACKING -->
<div style="background: #eff6ff; border-left: 4px solid #3b82f6; padding: 16px 20px; margin-bottom: 24px; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #1e40af; font-size: 16px; font-weight: 600;">Packing & Postage</h3>
<p style="margin: 0 0 12px 0; color: #1e3a8a;">Records are removed from outer sleeves to prevent seam splits during transit. Packed with stiffeners in a dedicated LP mailer. Royal Mail 48 Tracked or courier service.</p>
<p style="margin: 0; color: #1e3a8a; font-size: 14px;"><strong>Combined postage:</strong> Discount available for multiple purchases—please request invoice before payment.</p>
</div>
<!-- CTA -->
<div style="text-align: center; padding: 24px; background: #f1f5f9; border-radius: 12px;">
<p style="margin: 0 0 8px 0; color: #475569; font-weight: 500;">Questions? Need more photos?</p>
<p style="margin: 0; color: #64748b; font-size: 14px;">Message me anytime—happy to provide additional angles, audio clips, or pressing details.</p>
</div>
</div>`;
// Store reference to hosted images for potential cleanup
window.currentListingImages = hostedPhotoUrls.map(img => ({
url: img.url,
deleteUrl: img.deleteUrl
}));
document.getElementById('htmlOutput').value = html;
}
function renderTags(artist, title, catNo, year) {
const genre = window.detectedGenre || 'rock';
const format = window.detectedFormat?.toLowerCase().includes('7"') ? '7 inch' :
window.detectedFormat?.toLowerCase().includes('12"') ? '12 inch single' : 'lp';
const country = window.detectedCountry?.toLowerCase() || 'uk';
const tags = [
artist || 'vinyl',
title || 'record',
format,
'vinyl record',
'original pressing',
`${country} pressing`,
year || 'vintage',
catNo || '',
genre,
genre === 'rock' ? 'prog rock' : genre,
genre === 'rock' ? 'psych' : '',
'collector',
'audiophile',
format === 'lp' ? '12 inch' : format,
'33 rpm',
format === 'lp' ? 'album' : 'single',
'used vinyl',
'graded',
'excellent condition',
'rare vinyl',
'classic rock',
'vintage vinyl',
'record collection',
'music',
'audio',
window.detectedLabel || ''
].filter(Boolean);
const container = document.getElementById('tagsOutput');
container.innerHTML = tags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
}
function renderShotList() {
// Map shot types to display info
const shotDefinitions = [
{ id: 'front', name: 'Front cover (square, well-lit)', critical: true },
{ id: 'back', name: 'Back cover (full shot)', critical: true },
{ id: 'spine', name: 'Spine (readable text)', critical: true },
{ id: 'label_a', name: 'Label Side A (close, legible)', critical: true },
{ id: 'label_b', name: 'Label Side B (close, legible)', critical: true },
{ id: 'deadwax', name: 'Deadwax/runout grooves', critical: true },
{ id: 'inner', name: 'Inner sleeve (both sides)', critical: false },
{ id: 'insert', name: 'Insert/poster if included', critical: false },
{ id: 'hype', name: 'Hype sticker (if present)', critical: false },
{ id: 'vinyl', name: 'Vinyl in raking light (flaws)', critical: true },
{ id: 'corners', name: 'Sleeve corners/edges detail', critical: false },
{ id: 'barcode', name: 'Barcode area', critical: false }
];
// Check if we have any photos at all
const hasPhotos = uploadedPhotos.length > 0;
const container = document.getElementById('shotList');
container.innerHTML = shotDefinitions.map(shot => {
const have = detectedPhotoTypes.has(shot.id) || (shot.id === 'front' && hasPhotos) || (shot.id === 'back' && uploadedPhotos.length > 1);
const statusClass = have ? 'completed' : shot.critical ? 'missing' : '';
const iconColor = have ? 'text-green-500' : shot.critical ? 'text-yellow-500' : 'text-gray-500';
const textClass = have ? 'text-gray-400 line-through' : 'text-gray-300';
const icon = have ? 'check-circle' : shot.critical ? 'alert-circle' : 'circle';
return `
<div class="shot-item ${statusClass}">
<i data-feather="${icon}"
class="w-5 h-5 ${iconColor} flex-shrink-0"></i>
<span class="text-sm ${textClass}">${shot.name}</span>
${shot.critical && !have ? '<span class="ml-auto text-xs text-yellow-500 font-medium">CRITICAL</span>' : ''}
</div>
`}).join('');
feather.replace();
}
function copyHTML() {
const html = document.getElementById('htmlOutput');
html.select();
document.execCommand('copy');
showToast('HTML copied to clipboard!', 'success');
}
function copyTags() {
const tags = Array.from(document.querySelectorAll('#tagsOutput span')).map(s => s.textContent).join(', ');
navigator.clipboard.writeText(tags);
showToast('Tags copied to clipboard!', 'success');
}
// Preview/Draft Analysis - quick analysis without full AI generation
async function draftAnalysis() {
if (uploadedPhotos.length === 0) {
showToast('Upload photos first for preview', 'error');
return;
}
const artist = document.getElementById('artistInput').value.trim();
const title = document.getElementById('titleInput').value.trim();
// Show loading state
const dropZone = document.getElementById('dropZone');
const spinner = document.getElementById('uploadSpinner');
spinner.classList.remove('hidden');
dropZone.classList.add('pointer-events-none');
startAnalysisProgressSimulation();
try {
// Try OCR/AI analysis if available
const service = getAIService();
let ocrResult = null;
if (service && service.apiKey && uploadedPhotos.length > 0) {
try {
ocrResult = await service.analyzeRecordImages(uploadedPhotos.slice(0, 2)); // Limit to 2 photos for speed
populateFieldsFromOCR(ocrResult);
} catch (e) {
console.log('Preview OCR failed:', e);
}
}
// Generate quick preview results
const catNo = document.getElementById('catInput').value.trim() || ocrResult?.catalogueNumber || '';
const year = document.getElementById('yearInput').value.trim() || ocrResult?.year || '';
const detectedArtist = artist || ocrResult?.artist || 'Unknown Artist';
const detectedTitle = title || ocrResult?.title || 'Unknown Title';
const baseTitle = `${detectedArtist} - ${detectedTitle}`;
// Generate quick titles
const quickTitles = [
`${baseTitle} ${year ? `(${year})` : ''} ${catNo} VG+`.substring(0, 80),
`${baseTitle} Original Pressing Vinyl LP`.substring(0, 80),
`${detectedArtist} ${detectedTitle} ${catNo || 'LP'}`.substring(0, 80)
].map((t, i) => ({
text: t,
chars: t.length,
style: ['Quick', 'Standard', 'Compact'][i]
}));
// Quick pricing estimate based on condition
const cost = parseFloat(document.getElementById('costInput').value) || 10;
const vinylCond = document.getElementById('vinylConditionInput').value;
const sleeveCond = document.getElementById('sleeveConditionInput').value;
const conditionMultipliers = { 'M': 3, 'NM': 2.5, 'VG+': 1.8, 'VG': 1.2, 'G+': 0.8, 'G': 0.5 };
const condMult = (conditionMultipliers[vinylCond] || 1) * 0.7 + (conditionMultipliers[sleeveCond] || 1) * 0.3;
const estimatedValue = Math.round(cost * Math.max(condMult, 1.5));
const suggestedPrice = Math.round(estimatedValue * 0.9);
// Render preview results
renderTitleOptions(quickTitles);
// Quick pricing card
document.getElementById('pricingStrategy').innerHTML = `
<div class="pricing-card recommended">
<div class="flex items-center gap-2 mb-3">
<span class="px-2 py-1 bg-accent/20 text-accent text-xs font-medium rounded">QUICK ESTIMATE</span>
</div>
<p class="text-3xl font-bold text-white mb-1">£${suggestedPrice}</p>
<p class="text-sm text-gray-400 mb-3">Suggested Buy It Now</p>
<div class="space-y-2 text-sm">
<p class="flex justify-between"><span class="text-gray-500">Est. Value:</span> <span class="text-gray-300">£${estimatedValue}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Your Cost:</span> <span class="text-gray-300">£${cost.toFixed(2)}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Condition:</span> <span class="text-gray-300">${vinylCond}/${sleeveCond}</span></p>
</div>
</div>
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-400 uppercase tracking-wide">Preview Notes</h4>
<div class="p-3 bg-surface rounded-lg text-sm text-gray-400">
${ocrResult ?
`<p class="text-green-400 mb-2">✓ AI detected information from photos</p>` :
`<p class="text-yellow-400 mb-2">⚠ Add API key in Settings for auto-detection</p>`
}
<p>This is a quick estimate based on your cost and condition. Run "Generate Full Listing" for complete market analysis, sold comps, and optimized pricing.</p>
</div>
${ocrResult ? `
<div class="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<p class="text-xs text-green-400 font-medium mb-1">Detected from photos:</p>
<ul class="text-xs text-gray-400 space-y-1">
${ocrResult.artist ? `<li>• Artist: ${ocrResult.artist}</li>` : ''}
${ocrResult.title ? `<li>• Title: ${ocrResult.title}</li>` : ''}
${ocrResult.catalogueNumber ? `<li>• Cat#: ${ocrResult.catalogueNumber}</li>` : ''}
${ocrResult.year ? `<li>• Year: ${ocrResult.year}</li>` : ''}
</ul>
</div>
` : ''}
</div>
`;
// Simple fee floor
const fees = suggestedPrice * 0.16;
const safeFloor = Math.ceil(cost + fees + 6);
document.getElementById('feeFloor').innerHTML = `
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Your Cost</p>
<p class="text-xl font-bold text-gray-300">£${cost.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Est. Fees</p>
<p class="text-xl font-bold text-red-400">£${fees.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Ship + Pack</p>
<p class="text-xl font-bold text-gray-300">£6.00</p>
</div>
<div class="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/30">
<p class="text-xs text-green-500 uppercase mb-1">Safe Floor</p>
<p class="text-2xl font-bold text-green-400">£${safeFloor}</p>
</div>
`;
// Preview HTML description
const previewHtml = `<!-- QUICK PREVIEW - Generated by VinylVault Pro -->
<div style="max-width: 700px; margin: 0 auto; font-family: sans-serif;">
<h2 style="color: #333;">${detectedArtist} - ${detectedTitle}</h2>
${year ? `<p><strong>Year:</strong> ${year}</p>` : ''}
${catNo ? `<p><strong>Catalogue #:</strong> ${catNo}</p>` : ''}
<p><strong>Condition:</strong> Vinyl ${vinylCond}, Sleeve ${sleeveCond}</p>
<hr style="margin: 20px 0;">
<p style="color: #666;">[Full description will be generated with complete market analysis]</p>
</div>`;
const htmlOutput = document.getElementById('htmlOutput');
if (htmlOutput) htmlOutput.value = previewHtml;
// Preview tags
const previewTags = [
detectedArtist,
detectedTitle,
'vinyl',
'record',
vinylCond,
'lp',
year || 'vintage'
].filter(Boolean);
const tagsOutput = document.getElementById('tagsOutput');
if (tagsOutput) {
tagsOutput.innerHTML = previewTags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
}
// Update shot list
renderShotList();
// Show results
const resultsSection = document.getElementById('resultsSection');
const emptyState = document.getElementById('emptyState');
if (resultsSection) resultsSection.classList.remove('hidden');
if (emptyState) emptyState.classList.add('hidden');
if (resultsSection) resultsSection.scrollIntoView({ behavior: 'smooth' });
showToast('Quick preview ready! Click "Generate Full Listing" for complete analysis.', 'success');
} catch (error) {
console.error('Preview error:', error);
showToast('Preview failed: ' + error.message, 'error');
} finally {
stopAnalysisProgress();
setTimeout(() => {
spinner.classList.add('hidden');
dropZone.classList.remove('pointer-events-none');
updateAnalysisProgress('Initializing...', 0);
}, 300);
}
}
async function callAI(messages, temperature = 0.7) {
const provider = localStorage.getItem('ai_provider') || 'openai';
if (provider === 'deepseek' && window.deepseekService?.isConfigured) {
try {
const response = await fetch('https://api.deepseek.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('deepseek_api_key')}`
},
body: JSON.stringify({
model: localStorage.getItem('deepseek_model') || 'deepseek-chat',
messages: messages,
temperature: temperature,
max_tokens: 2000
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'DeepSeek API request failed');
}
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
showToast(`DeepSeek Error: ${error.message}`, 'error');
return null;
}
} else {
// Fallback to OpenAI
const apiKey = localStorage.getItem('openai_api_key');
const model = localStorage.getItem('openai_model') || 'gpt-4o';
const maxTokens = parseInt(localStorage.getItem('openai_max_tokens')) || 2000;
if (!apiKey) {
showToast('OpenAI API key not configured. Go to Settings.', 'error');
return null;
}
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model: model,
messages: messages,
temperature: temperature,
max_tokens: maxTokens
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'API request failed');
}
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
showToast(`OpenAI Error: ${error.message}`, 'error');
return null;
}
}
}
// Legacy alias for backward compatibility
async function callOpenAI(messages, temperature = 0.7) {
return callAI(messages, temperature);
}
// Delete hosted image from imgBB
async function deleteHostedImage(deleteUrl) {
if (!deleteUrl) return false;
try {
const response = await fetch(deleteUrl, { method: 'GET' });
// imgBB delete URLs work via GET request
return response.ok;
} catch (error) {
console.error('Failed to delete image:', error);
return false;
}
}
// Get hosted photo URLs for eBay HTML description
function getHostedPhotoUrlsForEbay() {
return hostedPhotoUrls.map(img => ({
full: img.url,
display: img.displayUrl || img.url,
thumb: img.thumb,
medium: img.medium,
viewer: img.viewerUrl
}));
}
async function generateListingWithAI() {
const artist = document.getElementById('artistInput').value.trim();
const title = document.getElementById('titleInput').value.trim();
const catNo = document.getElementById('catInput').value.trim();
const year = document.getElementById('yearInput').value.trim();
if (!artist || !title) {
showToast('Please enter at least artist and title', 'error');
return;
}
const messages = [
{
role: 'system',
content: 'You are a vinyl record eBay listing expert. Generate optimized titles, descriptions, and pricing strategies. Always return JSON format with: titles (array), description (string), condition_notes (string), price_estimate (object with min, max, recommended), and tags (array).'
},
{
role: 'user',
content: `Generate an eBay listing for: ${artist} - ${title}${catNo ? ` (Catalog: ${catNo})` : ''}${year ? ` (${year})` : ''}. Include optimized title options, professional HTML description, condition guidance, price estimate in GBP, and relevant tags.`
}
];
const provider = localStorage.getItem('ai_provider') || 'openai';
showToast(`Generating listing with ${provider === 'deepseek' ? 'DeepSeek' : 'OpenAI'}...`, 'success');
const result = await callAI(messages, 0.7);
if (result) {
try {
const data = JSON.parse(result);
// Populate the UI with AI-generated content
if (data.titles) {
renderTitleOptions(data.titles.map(t => ({
text: t.length > 80 ? t.substring(0, 77) + '...' : t,
chars: Math.min(t.length, 80),
style: 'AI Generated'
})));
}
if (data.description) {
document.getElementById('htmlOutput').value = data.description;
}
if (data.tags) {
const tagsContainer = document.getElementById('tagsOutput');
tagsContainer.innerHTML = data.tags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
}
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
showToast('AI listing generated!', 'success');
} catch (e) {
// If not valid JSON, treat as plain text description
document.getElementById('htmlOutput').value = result;
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
}
}
}
function requestHelp() {
alert(`VINYL PHOTO GUIDE:
ESSENTIAL SHOTS (need these):
• Front cover - square, no glare, color accurate
• Back cover - full frame, readable text
• Both labels - close enough to read all text
• Deadwax/runout - for pressing identification
CONDITION SHOTS:
• Vinyl in raking light at angle (shows scratches)
• Sleeve edges and corners
• Any flaws clearly documented
OPTIONARY BUT HELPFUL:
• Inner sleeve condition
• Inserts, posters, extras
• Hype stickers
• Barcode area
TIPS:
- Use natural daylight or 5500K bulbs
- Avoid flash directly on glossy sleeves
- Include scale reference if unusual size
- Photograph flaws honestly - reduces returns`);
}
function showToast(message, type = 'success') {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const iconMap = {
success: 'check',
error: 'alert-circle',
warning: 'alert-triangle'
};
const colorMap = {
success: 'text-green-400',
error: 'text-red-400',
warning: 'text-yellow-400'
};
const toast = document.createElement('div');
toast.className = `toast ${type} flex items-center gap-3`;
toast.innerHTML = `
<i data-feather="${iconMap[type] || 'info'}" class="w-5 h-5 ${colorMap[type] || 'text-blue-400'}"></i>
<span class="text-sm text-gray-200">${message}</span>
`;
document.body.appendChild(toast);
feather.replace();
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Cleanup function to delete all hosted images for current listing
async function cleanupHostedImages() {
if (window.currentListingImages) {
for (const img of window.currentListingImages) {
if (img.deleteUrl) {
await deleteHostedImage(img.deleteUrl);
}
}
window.currentListingImages = [];
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
console.log('VinylVault Pro initialized');
// Initialize drop zone
initDropZone();
// Warn about unsaved changes when leaving page with hosted images
window.addEventListener('beforeunload', (e) => {
if (hostedPhotoUrls.length > 0 && !window.listingPublished) {
// Optional: could add cleanup here or warn user
}
});
});
: '€';
// Mock comp research results
const comps = {
nm: { low: 45, high: 65, median: 52 },
vgplus: { low: 28, high: 42, median: 34 },
vg: { low: 15, high: 25, median: 19 }
};
// Calculate recommended price based on goal
let recommendedBin, strategy;
switch(goal) {
case 'quick':
recommendedBin = Math.round(comps.vgplus.low * 0.9);
strategy = 'BIN + Best Offer (aggressive)';
break;
case 'max':
recommendedBin = Math.round(comps.nm.high * 1.1);
strategy = 'BIN only, no offers, long duration';
break;
default:
recommendedBin = comps.vgplus.median;
strategy = 'BIN + Best Offer (standard)';
}
// Fee calculation (eBay UK approx)
const ebayFeeRate = 0.13; // 13% final value fee
const paypalRate = 0.029; // 2.9% + 30p
const fixedFee = 0.30;
const shippingCost = 4.50; // Estimated
const packingCost = 1.50;
const totalFees = (recommendedBin * ebayFeeRate) + (recommendedBin * paypalRate) + fixedFee;
const breakEven = cost + totalFees + shippingCost + packingCost;
const safeFloor = Math.ceil(breakEven * 1.05); // 5% buffer
// Generate titles
const baseTitle = `${artist || 'ARTIST'} - ${title || 'TITLE'}`;
const titles = generateTitles(baseTitle, catNo, year, goal);
// Render results
renderTitleOptions(titles);
renderPricingStrategy(recommendedBin, strategy, comps, currency, goal);
renderFeeFloor(cost, totalFees, shippingCost, packingCost, safeFloor, currency);
await renderHTMLDescription(data, titles[0]);
renderTags(artist, title, catNo, year);
renderShotList();
// Show results
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
resultsSection.scrollIntoView({ behavior: 'smooth' });
currentAnalysis = {
titles, recommendedBin, strategy, breakEven, safeFloor, currency
};
}
function generateTitles(base, catNo, year, goal) {
const titles = [];
const cat = catNo || 'CAT#';
const yr = year || 'YEAR';
const country = window.detectedCountry || 'UK';
const genre = window.detectedGenre || 'Rock';
const format = window.detectedFormat?.includes('7"') ? '7"' : window.detectedFormat?.includes('12"') ? '12"' : 'LP';
// Option 1: Classic collector focus
titles.push(`${base} ${format} ${yr} ${country} 1st Press ${cat} EX/VG+`);
// Option 2: Condition forward
titles.push(`NM! ${base} Original ${yr} Vinyl ${format} ${cat} Nice Copy`);
// Option 3: Rarity/hype with detected genre
titles.push(`${base} ${yr} ${country} Press ${cat} Rare Vintage ${genre} ${format}`);
// Option 4: Clean searchable
titles.push(`${base} Vinyl ${format} ${yr} ${cat} Excellent Condition`);
// Option 5: Genre tagged
titles.push(`${base} ${yr} ${format} ${genre} ${cat} VG+ Plays Great`);
return titles.map((t, i) => ({
text: t.length > 80 ? t.substring(0, 77) + '...' : t,
chars: Math.min(t.length, 80),
style: ['Classic Collector', 'Condition Forward', 'Rarity Focus', 'Clean Search', 'Genre Tagged'][i]
}));
}
function renderTitleOptions(titles) {
const container = document.getElementById('titleOptions');
container.innerHTML = titles.map((t, i) => `
<div class="title-option ${i === 0 ? 'selected' : ''}" onclick="selectTitle(this, '${t.text.replace(/'/g, "\\'")}')">
<span class="char-count">${t.chars}/80</span>
<p class="font-medium text-gray-200 pr-16">${t.text}</p>
<p class="text-sm text-gray-500 mt-1">${t.style}</p>
</div>
`).join('');
}
function selectTitle(el, text) {
document.querySelectorAll('.title-option').forEach(o => o.classList.remove('selected'));
el.classList.add('selected');
// Update clipboard copy
navigator.clipboard.writeText(text);
showToast('Title copied to clipboard!', 'success');
}
function renderPricingStrategy(bin, strategy, comps, currency, goal) {
const container = document.getElementById('pricingStrategy');
const offerSettings = goal === 'max' ? 'Offers: OFF' :
`Auto-accept: ${currency}${Math.floor(bin * 0.85)} | Auto-decline: ${currency}${Math.floor(bin * 0.7)}`;
container.innerHTML = `
<div class="pricing-card recommended">
<div class="flex items-center gap-2 mb-3">
<span class="px-2 py-1 bg-accent/20 text-accent text-xs font-medium rounded">RECOMMENDED</span>
</div>
<p class="text-3xl font-bold text-white mb-1">${currency}${bin}</p>
<p class="text-sm text-gray-400 mb-3">Buy It Now</p>
<div class="space-y-2 text-sm">
<p class="flex justify-between"><span class="text-gray-500">Strategy:</span> <span class="text-gray-300">${strategy}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Best Offer:</span> <span class="text-gray-300">${offerSettings}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Duration:</span> <span class="text-gray-300">30 days (GTC)</span></p>
</div>
</div>
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-400 uppercase tracking-wide">Sold Comps by Grade</h4>
<div class="space-y-2">
<div class="flex justify-between items-center p-3 bg-surface rounded-lg">
<span class="text-green-400 font-medium">NM/NM-</span>
<span class="text-gray-300">${currency}${comps.nm.low}-${comps.nm.high} <span class="text-gray-500">(med: ${comps.nm.median})</span></span>
</div>
<div class="flex justify-between items-center p-3 bg-surface rounded-lg border border-accent/30">
<span class="text-accent font-medium">VG+/EX</span>
<span class="text-gray-300">${currency}${comps.vgplus.low}-${comps.vgplus.high} <span class="text-gray-500">(med: ${comps.vgplus.median})</span></span>
</div>
<div class="flex justify-between items-center p-3 bg-surface rounded-lg">
<span class="text-yellow-400 font-medium">VG/VG+</span>
<span class="text-gray-300">${currency}${comps.vg.low}-${comps.vg.high} <span class="text-gray-500">(med: ${comps.vg.median})</span></span>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">Based on last 90 days sold listings, same pressing. Prices exclude postage.</p>
</div>
`;
}
function renderFeeFloor(cost, fees, shipping, packing, safeFloor, currency) {
const container = document.getElementById('feeFloor');
container.innerHTML = `
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Your Cost</p>
<p class="text-xl font-bold text-gray-300">${currency}${cost.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Est. Fees</p>
<p class="text-xl font-bold text-red-400">${currency}${fees.toFixed(2)}</p>
<p class="text-xs text-gray-600">~16% total</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Ship + Pack</p>
<p class="text-xl font-bold text-gray-300">${currency}${(shipping + packing).toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/30">
<p class="text-xs text-green-500 uppercase mb-1">Safe Floor Price</p>
<p class="text-2xl font-bold text-green-400">${currency}${safeFloor}</p>
<p class="text-xs text-green-600/70">Auto-decline below this</p>
</div>
`;
}
async function renderHTMLDescription(data, titleObj) {
const { artist, title, catNo, year } = data;
// Use hosted URL if available, otherwise fallback to local object URL
let heroImg = '';
let galleryImages = [];
if (hostedPhotoUrls.length > 0) {
heroImg = hostedPhotoUrls[0].displayUrl || hostedPhotoUrls[0].url;
galleryImages = hostedPhotoUrls.slice(1).map(img => img.displayUrl || img.url);
} else if (uploadedPhotos.length > 0) {
heroImg = URL.createObjectURL(uploadedPhotos[0]);
galleryImages = uploadedPhotos.slice(1).map((_, i) => URL.createObjectURL(uploadedPhotos[i + 1]));
}
// Use OCR-detected values if available
const detectedLabel = window.detectedLabel || '[Verify from photos]';
const detectedCountry = window.detectedCountry || 'UK';
const detectedFormat = window.detectedFormat || 'LP • 33rpm';
const detectedGenre = window.detectedGenre || 'rock';
const detectedCondition = window.detectedCondition || 'VG+/VG+';
const detectedPressingInfo = window.detectedPressingInfo || '';
// Fetch tracklist and detailed info from Discogs if available
let tracklistHtml = '';
let pressingDetailsHtml = '';
let provenanceHtml = '';
if (window.discogsReleaseId && window.discogsService?.key) {
try {
const discogsData = await window.discogsService.fetchTracklist(window.discogsReleaseId);
if (discogsData && discogsData.tracklist) {
// Build tracklist HTML
const hasSideBreakdown = discogsData.tracklist.some(t => t.position && (t.position.startsWith('A') || t.position.startsWith('B')));
if (hasSideBreakdown) {
// Group by sides
const sides = {};
discogsData.tracklist.forEach(track => {
const side = track.position ? track.position.charAt(0) : 'Other';
if (!sides[side]) sides[side] = [];
sides[side].push(track);
});
tracklistHtml = Object.entries(sides).map(([side, tracks]) => `
<div style="margin-bottom: 16px;">
<h4 style="color: #7c3aed; font-size: 13px; font-weight: 600; margin: 0 0 8px 0; text-transform: uppercase; letter-spacing: 0.5px;">Side ${side}</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
${tracks.map(track => `
<div style="flex: 1 1 200px; min-width: 200px; display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0;">
<span style="color: #1e293b; font-size: 13px;"><strong>${track.position}</strong> ${track.title}</span>
${track.duration ? `<span style="color: #64748b; font-size: 12px; font-family: monospace;">${track.duration}</span>` : ''}
</div>
`).join('')}
</div>
</div>
`).join('');
} else {
// Simple list
tracklistHtml = `
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
${discogsData.tracklist.map(track => `
<div style="flex: 1 1 200px; min-width: 200px; display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0;">
<span style="color: #1e293b; font-size: 13px;">${track.position ? `<strong>${track.position}</strong> ` : ''}${track.title}</span>
${track.duration ? `<span style="color: #64748b; font-size: 12px; font-family: monospace;">${track.duration}</span>` : ''}
</div>
`).join('')}
</div>
`;
}
// Build pressing/variation details
const identifiers = discogsData.identifiers || [];
const barcodeInfo = identifiers.find(i => i.type === 'Barcode');
const matrixInfo = identifiers.filter(i => i.type === 'Matrix / Runout' || i.type === 'Runout');
const pressingInfo = identifiers.filter(i => i.type === 'Pressing Plant' || i.type === 'Mastering');
if (matrixInfo.length > 0 || barcodeInfo || pressingInfo.length > 0) {
pressingDetailsHtml = `
<div style="background: #f0fdf4; border-left: 4px solid #22c55e; padding: 16px 20px; margin: 24px 0; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #166534; font-size: 15px; font-weight: 600;">Pressing & Matrix Information</h3>
<div style="font-family: monospace; font-size: 13px; line-height: 1.6; color: #15803d;">
${barcodeInfo ? `<p style="margin: 4px 0;"><strong>Barcode:</strong> ${barcodeInfo.value}</p>` : ''}
${matrixInfo.map(m => `<p style="margin: 4px 0;"><strong>${m.type}:</strong> ${m.value}${m.description ? ` <em>(${m.description})</em>` : ''}</p>`).join('')}
${pressingInfo.map(p => `<p style="margin: 4px 0;"><strong>${p.type}:</strong> ${p.value}</p>`).join('')}
</div>
${discogsData.notes ? `<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #bbf7d0; font-size: 12px; color: #166534; font-style: italic;">${discogsData.notes.substring(0, 300)}${discogsData.notes.length > 300 ? '...' : ''}</p>` : ''}
</div>
`;
}
// Build provenance data for buyer confidence
const companies = discogsData.companies || [];
const masteredBy = companies.find(c => c.entity_type_name === 'Mastered At' || c.name.toLowerCase().includes('mastering'));
const pressedBy = companies.find(c => c.entity_type_name === 'Pressed By' || c.name.toLowerCase().includes('pressing'));
const lacquerCut = companies.find(c => c.entity_type_name === 'Lacquer Cut At');
if (masteredBy || pressedBy || lacquerCut) {
provenanceHtml = `
<div style="background: #eff6ff; border: 1px solid #bfdbfe; padding: 16px; margin: 24px 0; border-radius: 8px;">
<h3 style="margin: 0 0 12px 0; color: #1e40af; font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
Provenance & Production
</h3>
<div style="font-size: 13px; color: #1e3a8a; line-height: 1.6;">
${masteredBy ? `<p style="margin: 4px 0;">✓ Mastered at <strong>${masteredBy.name}</strong></p>` : ''}
${lacquerCut ? `<p style="margin: 4px 0;">✓ Lacquer cut at <strong>${lacquerCut.name}</strong></p>` : ''}
${pressedBy ? `<p style="margin: 4px 0;">✓ Pressed at <strong>${pressedBy.name}</strong></p>` : ''}
${discogsData.num_for_sale ? `<p style="margin: 8px 0 0 0; padding-top: 8px; border-top: 1px solid #bfdbfe; color: #3b82f6; font-size: 12px;">Reference: ${discogsData.num_for_sale} copies currently for sale on Discogs</p>` : ''}
</div>
</div>
`;
}
}
} catch (e) {
console.error('Failed to fetch Discogs details for HTML:', e);
}
}
// If no tracklist from Discogs, provide placeholder
if (!tracklistHtml) {
tracklistHtml = `<p style="color: #64748b; font-style: italic;">Tracklist verification recommended. Please compare with Discogs entry for accuracy.</p>`;
}
const galleryHtml = galleryImages.length > 0 ? `
<!-- PHOTO GALLERY -->
<div style="margin-bottom: 24px;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px;">
${galleryImages.map(url => `<img src="${url}" style="width: 100%; height: 150px; object-fit: cover; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);" alt="Record photo">`).join('')}
</div>
</div>
` : '';
const html = `<div style="max-width: 800px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #333; line-height: 1.6;">
<!-- HERO IMAGE -->
<div style="margin-bottom: 24px;">
<img src="${heroImg}" alt="${artist} - ${title}" style="width: 100%; max-width: 600px; display: block; margin: 0 auto; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.15);">
</div>
${galleryHtml}
<!-- BADGES -->
<div style="display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-bottom: 24px;">
<span style="background: #7c3aed; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">Original ${detectedCountry} Pressing</span>
<span style="background: #059669; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${year || '1970s'}</span>
<span style="background: #0891b2; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${detectedFormat}</span>
<span style="background: #d97706; color: white; padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase;">${detectedCondition}</span>
</div>
<!-- AT A GLANCE -->
<table style="width: 100%; border-collapse: collapse; margin-bottom: 24px; font-size: 14px;">
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600; width: 140px;">Artist</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${artist || 'See title'}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Title</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${title || 'See title'}</td>
</tr>
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Label</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${detectedLabel}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Catalogue</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;"><code style="background: #f1f5f9; padding: 2px 8px; border-radius: 4px;">${catNo || '[See photos]'}</code></td>
</tr>
<tr style="background: #f8fafc;">
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Country</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${detectedCountry}</td>
</tr>
<tr>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; font-weight: 600;">Year</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0;">${year || '[Verify]'}</td>
</tr>
</table>
<!-- CONDITION -->
<div style="background: #fefce8; border-left: 4px solid #eab308; padding: 16px 20px; margin-bottom: 24px; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #854d0e; font-size: 16px; font-weight: 600;">Condition Report</h3>
<div style="display: grid; gap: 12px;">
<div>
<strong style="color: #713f12;">Vinyl:</strong> <span style="color: #854d0e;">VG+ — Light surface marks, plays cleanly with minimal surface noise. No skips or jumps. [Adjust based on actual inspection]</span>
</div>
<div>
<strong style="color: #713f12;">Sleeve:</strong> <span style="color: #854d0e;">VG+ — Minor edge wear, light ring wear visible under raking light. No splits or writing. [Adjust based on actual inspection]</span>
</div>
<div>
<strong style="color: #713f12;">Inner Sleeve:</strong> <span style="color: #854d0e;">Original paper inner included, small split at bottom seam. [Verify/Adjust]</span>
</div>
</div>
</div>
<!-- ABOUT -->
<h3 style="color: #1e293b; font-size: 18px; font-weight: 600; margin-bottom: 12px;">About This Release</h3>
<p style="margin-bottom: 16px; color: #475569;">${detectedGenre ? `${detectedGenre.charAt(0).toUpperCase() + detectedGenre.slice(1)} release` : 'Vintage vinyl release'}${detectedPressingInfo ? `. Matrix/Runout: ${detectedPressingInfo}` : ''}. [Add accurate description based on verified pressing details. Mention notable features: gatefold, insert, poster, hype sticker, etc.]</p>
<!-- TRACKLIST -->
<h3 style="color: #1e293b; font-size: 18px; font-weight: 600; margin-bottom: 12px;">Tracklist</h3>
<div style="background: #f8fafc; padding: 16px 20px; border-radius: 8px; margin-bottom: 24px;">
${tracklistHtml}
</div>
${pressingDetailsHtml}
${provenanceHtml}
<!-- PACKING -->
<div style="background: #eff6ff; border-left: 4px solid #3b82f6; padding: 16px 20px; margin-bottom: 24px; border-radius: 0 8px 8px 0;">
<h3 style="margin: 0 0 12px 0; color: #1e40af; font-size: 16px; font-weight: 600;">Packing & Postage</h3>
<p style="margin: 0 0 12px 0; color: #1e3a8a;">Records are removed from outer sleeves to prevent seam splits during transit. Packed with stiffeners in a dedicated LP mailer. Royal Mail 48 Tracked or courier service.</p>
<p style="margin: 0; color: #1e3a8a; font-size: 14px;"><strong>Combined postage:</strong> Discount available for multiple purchases—please request invoice before payment.</p>
</div>
<!-- CTA -->
<div style="text-align: center; padding: 24px; background: #f1f5f9; border-radius: 12px;">
<p style="margin: 0 0 8px 0; color: #475569; font-weight: 500;">Questions? Need more photos?</p>
<p style="margin: 0; color: #64748b; font-size: 14px;">Message me anytime—happy to provide additional angles, audio clips, or pressing details.</p>
</div>
</div>`;
// Store reference to hosted images for potential cleanup
window.currentListingImages = hostedPhotoUrls.map(img => ({
url: img.url,
deleteUrl: img.deleteUrl
}));
document.getElementById('htmlOutput').value = html;
}
function renderTags(artist, title, catNo, year) {
const genre = window.detectedGenre || 'rock';
const format = window.detectedFormat?.toLowerCase().includes('7"') ? '7 inch' :
window.detectedFormat?.toLowerCase().includes('12"') ? '12 inch single' : 'lp';
const country = window.detectedCountry?.toLowerCase() || 'uk';
const tags = [
artist || 'vinyl',
title || 'record',
format,
'vinyl record',
'original pressing',
`${country} pressing`,
year || 'vintage',
catNo || '',
genre,
genre === 'rock' ? 'prog rock' : genre,
genre === 'rock' ? 'psych' : '',
'collector',
'audiophile',
format === 'lp' ? '12 inch' : format,
'33 rpm',
format === 'lp' ? 'album' : 'single',
'used vinyl',
'graded',
'excellent condition',
'rare vinyl',
'classic rock',
'vintage vinyl',
'record collection',
'music',
'audio',
window.detectedLabel || ''
].filter(Boolean);
const container = document.getElementById('tagsOutput');
container.innerHTML = tags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
}
function renderShotList() {
// Map shot types to display info
const shotDefinitions = [
{ id: 'front', name: 'Front cover (square, well-lit)', critical: true },
{ id: 'back', name: 'Back cover (full shot)', critical: true },
{ id: 'spine', name: 'Spine (readable text)', critical: true },
{ id: 'label_a', name: 'Label Side A (close, legible)', critical: true },
{ id: 'label_b', name: 'Label Side B (close, legible)', critical: true },
{ id: 'deadwax', name: 'Deadwax/runout grooves', critical: true },
{ id: 'inner', name: 'Inner sleeve (both sides)', critical: false },
{ id: 'insert', name: 'Insert/poster if included', critical: false },
{ id: 'hype', name: 'Hype sticker (if present)', critical: false },
{ id: 'vinyl', name: 'Vinyl in raking light (flaws)', critical: true },
{ id: 'corners', name: 'Sleeve corners/edges detail', critical: false },
{ id: 'barcode', name: 'Barcode area', critical: false }
];
// Check if we have any photos at all
const hasPhotos = uploadedPhotos.length > 0;
const container = document.getElementById('shotList');
container.innerHTML = shotDefinitions.map(shot => {
const have = detectedPhotoTypes.has(shot.id) || (shot.id === 'front' && hasPhotos) || (shot.id === 'back' && uploadedPhotos.length > 1);
const statusClass = have ? 'completed' : shot.critical ? 'missing' : '';
const iconColor = have ? 'text-green-500' : shot.critical ? 'text-yellow-500' : 'text-gray-500';
const textClass = have ? 'text-gray-400 line-through' : 'text-gray-300';
const icon = have ? 'check-circle' : shot.critical ? 'alert-circle' : 'circle';
return `
<div class="shot-item ${statusClass}">
<i data-feather="${icon}"
class="w-5 h-5 ${iconColor} flex-shrink-0"></i>
<span class="text-sm ${textClass}">${shot.name}</span>
${shot.critical && !have ? '<span class="ml-auto text-xs text-yellow-500 font-medium">CRITICAL</span>' : ''}
</div>
`}).join('');
feather.replace();
}
function copyHTML() {
const html = document.getElementById('htmlOutput');
html.select();
document.execCommand('copy');
showToast('HTML copied to clipboard!', 'success');
}
function copyTags() {
const tags = Array.from(document.querySelectorAll('#tagsOutput span')).map(s => s.textContent).join(', ');
navigator.clipboard.writeText(tags);
showToast('Tags copied to clipboard!', 'success');
}
async function draftAnalysis() {
if (uploadedPhotos.length === 0) {
showToast('Upload photos first for preview', 'error');
return;
}
const artist = document.getElementById('artistInput').value.trim();
const title = document.getElementById('titleInput').value.trim();
// Show loading state
const dropZone = document.getElementById('dropZone');
const spinner = document.getElementById('uploadSpinner');
spinner.classList.remove('hidden');
dropZone.classList.add('pointer-events-none');
startAnalysisProgressSimulation();
try {
// Try OCR/AI analysis if available
const service = getAIService();
let ocrResult = null;
if (service && service.apiKey && uploadedPhotos.length > 0) {
try {
ocrResult = await service.analyzeRecordImages(uploadedPhotos.slice(0, 2)); // Limit to 2 photos for speed
populateFieldsFromOCR(ocrResult);
} catch (e) {
console.log('Preview OCR failed:', e);
}
}
// Generate quick preview results
const catNo = document.getElementById('catInput').value.trim() || ocrResult?.catalogueNumber || '';
const year = document.getElementById('yearInput').value.trim() || ocrResult?.year || '';
const detectedArtist = artist || ocrResult?.artist || 'Unknown Artist';
const detectedTitle = title || ocrResult?.title || 'Unknown Title';
const baseTitle = `${detectedArtist} - ${detectedTitle}`;
// Generate quick titles
const quickTitles = [
`${baseTitle} ${year ? `(${year})` : ''} ${catNo} VG+`.substring(0, 80),
`${baseTitle} Original Pressing Vinyl LP`.substring(0, 80),
`${detectedArtist} ${detectedTitle} ${catNo || 'LP'}`.substring(0, 80)
].map((t, i) => ({
text: t,
chars: t.length,
style: ['Quick', 'Standard', 'Compact'][i]
}));
// Quick pricing estimate based on condition
const cost = parseFloat(document.getElementById('costInput').value) || 10;
const vinylCond = document.getElementById('vinylConditionInput').value;
const sleeveCond = document.getElementById('sleeveConditionInput').value;
const conditionMultipliers = { 'M': 3, 'NM': 2.5, 'VG+': 1.8, 'VG': 1.2, 'G+': 0.8, 'G': 0.5 };
const condMult = (conditionMultipliers[vinylCond] || 1) * 0.7 + (conditionMultipliers[sleeveCond] || 1) * 0.3;
const estimatedValue = Math.round(cost * Math.max(condMult, 1.5));
const suggestedPrice = Math.round(estimatedValue * 0.9);
// Render preview results
renderTitleOptions(quickTitles);
// Quick pricing card
document.getElementById('pricingStrategy').innerHTML = `
<div class="pricing-card recommended">
<div class="flex items-center gap-2 mb-3">
<span class="px-2 py-1 bg-accent/20 text-accent text-xs font-medium rounded">QUICK ESTIMATE</span>
</div>
<p class="text-3xl font-bold text-white mb-1">£${suggestedPrice}</p>
<p class="text-sm text-gray-400 mb-3">Suggested Buy It Now</p>
<div class="space-y-2 text-sm">
<p class="flex justify-between"><span class="text-gray-500">Est. Value:</span> <span class="text-gray-300">£${estimatedValue}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Your Cost:</span> <span class="text-gray-300">£${cost.toFixed(2)}</span></p>
<p class="flex justify-between"><span class="text-gray-500">Condition:</span> <span class="text-gray-300">${vinylCond}/${sleeveCond}</span></p>
</div>
</div>
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-400 uppercase tracking-wide">Preview Notes</h4>
<div class="p-3 bg-surface rounded-lg text-sm text-gray-400">
${ocrResult ?
`<p class="text-green-400 mb-2">✓ AI detected information from photos</p>` :
`<p class="text-yellow-400 mb-2">⚠ Add API key in Settings for auto-detection</p>`
}
<p>This is a quick estimate based on your cost and condition. Run "Generate Full Listing" for complete market analysis, sold comps, and optimized pricing.</p>
</div>
${ocrResult ? `
<div class="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<p class="text-xs text-green-400 font-medium mb-1">Detected from photos:</p>
<ul class="text-xs text-gray-400 space-y-1">
${ocrResult.artist ? `<li>• Artist: ${ocrResult.artist}</li>` : ''}
${ocrResult.title ? `<li>• Title: ${ocrResult.title}</li>` : ''}
${ocrResult.catalogueNumber ? `<li>• Cat#: ${ocrResult.catalogueNumber}</li>` : ''}
${ocrResult.year ? `<li>• Year: ${ocrResult.year}</li>` : ''}
</ul>
</div>
` : ''}
</div>
`;
// Simple fee floor
const fees = suggestedPrice * 0.16;
const safeFloor = Math.ceil(cost + fees + 6);
document.getElementById('feeFloor').innerHTML = `
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Your Cost</p>
<p class="text-xl font-bold text-gray-300">£${cost.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Est. Fees</p>
<p class="text-xl font-bold text-red-400">£${fees.toFixed(2)}</p>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<p class="text-xs text-gray-500 uppercase mb-1">Ship + Pack</p>
<p class="text-xl font-bold text-gray-300"6.00</p>
</div>
<div class="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/30">
<p class="text-xs text-green-500 uppercase mb-1">Safe Floor</p>
<p class="text-2xl font-bold text-green-400">£${safeFloor}</p>
</div>
`;
// Preview HTML description
const previewHtml = `<!-- QUICK PREVIEW - Generated by VinylVault Pro -->
<div style="max-width: 700px; margin: 0 auto; font-family: sans-serif;">
<h2 style="color: #333;">${detectedArtist} - ${detectedTitle}</h2>
${year ? `<p><strong>Year:</strong> ${year}</p>` : ''}
${catNo ? `<p><strong>Catalogue #:</strong> ${catNo}</p>` : ''}
<p><strong>Condition:</strong> Vinyl ${vinylCond}, Sleeve ${sleeveCond}</p>
<hr style="margin: 20px 0;">
<p style="color: #666;">[Full description will be generated with complete market analysis]</p>
</div>`;
document.getElementById('htmlOutput').value = previewHtml;
// Preview tags
const previewTags = [
detectedArtist,
detectedTitle,
'vinyl',
'record',
vinylCond,
'lp',
year || 'vintage'
].filter(Boolean);
document.getElementById('tagsOutput').innerHTML = previewTags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
// Update shot list
renderShotList();
// Show results
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
resultsSection.scrollIntoView({ behavior: 'smooth' });
showToast('Quick preview ready! Click "Generate Full Listing" for complete analysis.', 'success');
} catch (error) {
console.error('Preview error:', error);
showToast('Preview failed: ' + error.message, 'error');
} finally {
stopAnalysisProgress();
setTimeout(() => {
spinner.classList.add('hidden');
dropZone.classList.remove('pointer-events-none');
updateAnalysisProgress('Initializing...', 0);
}, 300);
}
}
async function callAI(messages, temperature = 0.7) {
const provider = localStorage.getItem('ai_provider') || 'openai';
if (provider === 'deepseek' && window.deepseekService?.isConfigured) {
try {
const response = await fetch('https://api.deepseek.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('deepseek_api_key')}`
},
body: JSON.stringify({
model: localStorage.getItem('deepseek_model') || 'deepseek-chat',
messages: messages,
temperature: temperature,
max_tokens: 2000
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'DeepSeek API request failed');
}
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
showToast(`DeepSeek Error: ${error.message}`, 'error');
return null;
}
} else {
// Fallback to OpenAI
const apiKey = localStorage.getItem('openai_api_key');
const model = localStorage.getItem('openai_model') || 'gpt-4o';
const maxTokens = parseInt(localStorage.getItem('openai_max_tokens')) || 2000;
if (!apiKey) {
showToast('OpenAI API key not configured. Go to Settings.', 'error');
return null;
}
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model: model,
messages: messages,
temperature: temperature,
max_tokens: maxTokens
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'API request failed');
}
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
showToast(`OpenAI Error: ${error.message}`, 'error');
return null;
}
}
}
// Legacy alias for backward compatibility
async function callOpenAI(messages, temperature = 0.7) {
return callAI(messages, temperature);
}
// Delete hosted image from imgBB
async function deleteHostedImage(deleteUrl) {
if (!deleteUrl) return false;
try {
const response = await fetch(deleteUrl, { method: 'GET' });
// imgBB delete URLs work via GET request
return response.ok;
} catch (error) {
console.error('Failed to delete image:', error);
return false;
}
}
// Get hosted photo URLs for eBay HTML description
function getHostedPhotoUrlsForEbay() {
return hostedPhotoUrls.map(img => ({
full: img.url,
display: img.displayUrl || img.url,
thumb: img.thumb,
medium: img.medium,
viewer: img.viewerUrl
}));
}
async function generateListingWithAI() {
const artist = document.getElementById('artistInput').value.trim();
const title = document.getElementById('titleInput').value.trim();
const catNo = document.getElementById('catInput').value.trim();
const year = document.getElementById('yearInput').value.trim();
if (!artist || !title) {
showToast('Please enter at least artist and title', 'error');
return;
}
const messages = [
{
role: 'system',
content: 'You are a vinyl record eBay listing expert. Generate optimized titles, descriptions, and pricing strategies. Always return JSON format with: titles (array), description (string), condition_notes (string), price_estimate (object with min, max, recommended), and tags (array).'
},
{
role: 'user',
content: `Generate an eBay listing for: ${artist} - ${title}${catNo ? ` (Catalog: ${catNo})` : ''}${year ? ` (${year})` : ''}. Include optimized title options, professional HTML description, condition guidance, price estimate in GBP, and relevant tags.`
}
];
const provider = localStorage.getItem('ai_provider') || 'openai';
showToast(`Generating listing with ${provider === 'deepseek' ? 'DeepSeek' : 'OpenAI'}...`, 'success');
const result = await callAI(messages, 0.7);
if (result) {
try {
const data = JSON.parse(result);
// Populate the UI with AI-generated content
if (data.titles) {
renderTitleOptions(data.titles.map(t => ({
text: t.length > 80 ? t.substring(0, 77) + '...' : t,
chars: Math.min(t.length, 80),
style: 'AI Generated'
})));
}
if (data.description) {
document.getElementById('htmlOutput').value = data.description;
}
if (data.tags) {
const tagsContainer = document.getElementById('tagsOutput');
tagsContainer.innerHTML = data.tags.map(t => `
<span class="px-3 py-1.5 bg-pink-500/10 text-pink-400 rounded-full text-sm border border-pink-500/20">${t}</span>
`).join('');
}
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
showToast('AI listing generated!', 'success');
} catch (e) {
// If not valid JSON, treat as plain text description
document.getElementById('htmlOutput').value = result;
resultsSection.classList.remove('hidden');
emptyState.classList.add('hidden');
}
}
}
function requestHelp() {
alert(`VINYL PHOTO GUIDE:
ESSENTIAL SHOTS (need these):
• Front cover - square, no glare, color accurate
• Back cover - full frame, readable text
• Both labels - close enough to read all text
• Deadwax/runout - for pressing identification
CONDITION SHOTS:
• Vinyl in raking light at angle (shows scratches)
• Sleeve edges and corners
• Any flaws clearly documented
OPTIONARY BUT HELPFUL:
• Inner sleeve condition
• Inserts, posters, extras
• Hype stickers
• Barcode area
TIPS:
- Use natural daylight or 5500K bulbs
- Avoid flash directly on glossy sleeves
- Include scale reference if unusual size
- Photograph flaws honestly - reduces returns`);
}
function showToast(message, type = 'success') {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const iconMap = {
success: 'check',
error: 'alert-circle',
warning: 'alert-triangle'
};
const colorMap = {
success: 'text-green-400',
error: 'text-red-400',
warning: 'text-yellow-400'
};
const toast = document.createElement('div');
toast.className = `toast ${type} flex items-center gap-3`;
toast.innerHTML = `
<i data-feather="${iconMap[type] || 'info'}" class="w-5 h-5 ${colorMap[type] || 'text-blue-400'}"></i>
<span class="text-sm text-gray-200">${message}</span>
`;
document.body.appendChild(toast);
feather.replace();
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Cleanup function to delete all hosted images for current listing
async function cleanupHostedImages() {
if (window.currentListingImages) {
for (const img of window.currentListingImages) {
if (img.deleteUrl) {
await deleteHostedImage(img.deleteUrl);
}
}
window.currentListingImages = [];
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
console.log('VinylVault Pro initialized');
// Initialize drop zone
initDropZone();
// Warn about unsaved changes when leaving page with hosted images
window.addEventListener('beforeunload', (e) => {
if (hostedPhotoUrls.length > 0 && !window.listingPublished) {
// Optional: could add cleanup here or warn user
}
});
});