← back to Dw Theme Boost Fix
templates/page.spec-sheet.liquid
774 lines
{% comment %}
Simple Spec Sheet - Refined version
- Larger image (500x500px)
- Single column if space available
- Title case, not bold
{% endcomment %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spec Sheet - Designer Wallcoverings</title>
<!-- jsPDF Library -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; background: #f5f5f5; padding: 20px; }
.spec-page { width: 8.5in; min-height: 11in; margin: 0 auto; background: white; box-shadow: 0 4px 20px rgba(0,0,0,0.1); display: flex; flex-direction: column; position: relative; }
.header { padding: 30px 40px; text-align: center; border-bottom: 2px solid #2c5f7f; }
.logo { max-width: 200px; margin: 0 auto; display: block; }
.download-btn { position: absolute; top: 30px; right: 30px; background: #c8a551; color: white; padding: 10px 20px; border: none; border-radius: 20px; cursor: pointer; font-size: 12px; font-weight: bold; box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
.download-btn:hover { background: #b8954a; }
.printable-content { flex: 1; padding: 30px 40px; }
/* Title - single line, title case, not bold, centered */
.product-title {
font-size: 18px;
color: #333;
margin-bottom: 15px;
line-height: 1.1;
font-weight: normal;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-transform: capitalize;
}
/* Larger centered image */
.product-img {
max-width: 500px;
max-height: 500px;
object-fit: contain;
display: block;
margin: 0 0 20px 0;
width: 100%;
}
/* Single column by default, 2 columns only if lots of data */
.spec-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
/* Switch to 2 columns if more than 15 items */
.spec-grid.many-items {
grid-template-columns: repeat(2, 1fr);
gap: 12px 20px;
}
/* Content wrapper to align with image */
.content-wrapper {
max-width: 500px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.spec-item {
font-size: 14px;
line-height: 1.6;
display: flex;
align-items: baseline;
text-align: left;
}
.spec-item strong {
color: #333;
font-weight: bold;
margin-right: 6px;
white-space: nowrap;
}
.spec-item span {
color: #555;
flex: 1;
text-align: left;
}
.footer { padding: 20px 40px; text-align: center; border-top: 2px solid #2c5f7f; background: #f9f9f9; margin-top: auto; }
.footer-line { font-size: 10px; color: #666; margin: 3px 0; }
.footer-line.company { font-size: 12px; font-weight: bold; color: #333; margin-bottom: 6px; }
#pdf-content { background: white; min-height: 11in; display: flex; flex-direction: column; }
.loading { text-align: center; padding: 60px 40px; }
.loading h1 { color: #c8a551; margin-bottom: 20px; }
@media print {
.download-btn { display: none !important; }
.spec-page { box-shadow: none; }
}
</style>
</head>
<body>
<div class="spec-page">
<div class="header">
<img src="https://www.designerwallcoverings.com/cdn/shop/files/Screen_Shot_2019-02-06_at_12.08.24_PM_300x172.png?v=1613539653" alt="Designer Wallcoverings" class="logo">
<button class="download-btn" onclick="downloadPDF()" id="pdfBtn" style="display:none;">📄 Download PDF</button>
</div>
<div id="pdf-content">
<div class="printable-content" id="content">
<div class="loading">
<h1>Loading Product...</h1>
<p>Please wait...</p>
</div>
</div>
<div class="footer">
<div class="footer-line company">Designer Wallcoverings Purchasing Agency</div>
<div class="footer-line">15442 Ventura Bl #102, Sherman Oaks, CA 91403</div>
<div class="footer-line">Phone: (888) 373-4564 | Email: info@designerwallcoverings.com | www.designerwallcoverings.com</div>
<div class="footer-line" style="margin-top: 10px; font-style: italic;">Purchasing Agency for the Wallcovering and Surface Material Industry</div>
<div class="footer-line" style="margin-top: 8px; font-size: 9px;">Spec sheet information valid only when purchased through Designer Wallcoverings Purchasing</div>
</div>
</div>
</div>
<script>
let currentProduct = null;
let productHandle = null;
function getProductHandle() {
const urlParams = new URLSearchParams(window.location.search);
const paramHandle = urlParams.get('product');
if (paramHandle) return decodeURIComponent(paramHandle);
const pageHandle = '{{ page.handle }}';
if (pageHandle && pageHandle !== 'spec-sheet') {
return pageHandle.replace(/-spec$/, '');
}
return null;
}
// Convert to title case
function toTitleCase(str) {
return str.toLowerCase().replace(/\b\w/g, l => l.toUpperCase());
}
productHandle = getProductHandle();
if (!productHandle) {
document.getElementById('content').innerHTML = '<div class="loading"><h1>No Product Specified</h1></div>';
} else {
loadProduct();
}
async function loadProduct() {
try {
const response = await fetch(`/products/${productHandle}.js`);
if (!response.ok) throw new Error('Product not found');
currentProduct = await response.json();
const pageResponse = await fetch(`/products/${productHandle}`);
const html = await pageResponse.text();
// Extract metafields from product JSON (if available)
const metafields = extractMetafieldsFromProduct(currentProduct);
// Merge with data extracted from HTML and description
const extractedData = universalExtractor(html, currentProduct.description);
Object.assign(metafields, extractedData);
renderProduct(metafields);
document.getElementById('pdfBtn').style.display = 'block';
} catch (error) {
console.error('Error:', error);
document.getElementById('content').innerHTML = `
<div class="loading">
<h1>Product Not Found</h1>
<p>Could not find product: <strong>${productHandle}</strong></p>
</div>
`;
}
}
function extractMetafieldsFromProduct(product) {
const data = {};
// Shopify metafields are not directly available in product.js
// But we can extract them from the HTML page
// This function will be populated by universalExtractor
// Priority: Metafields > Tags > Description
return data;
}
function universalExtractor(html, description) {
const data = {};
// Extract from page HTML metafields first (highest priority)
const pageDiv = document.createElement('div');
pageDiv.innerHTML = html;
// Look for metafield data in the HTML
// Map existing metafield names to display names
const metafieldMapping = {
'Width': 'Width',
'repeat': 'Repeat',
'Content': 'Composition',
'Contents': 'Composition',
'Brand': 'Brand',
'Color': 'Color',
'Name of Pattern': 'Extra Info',
'SubBrand': 'Collection',
'title': 'Title',
'title_tag': 'Title Tag'
};
// Extract metafields from page HTML
// Look for metafield values in the page
const metafieldMatches = html.matchAll(/metafield[s]?['":\s]*{([^}]+)}/gi);
for (const match of metafieldMatches) {
try {
const jsonStr = '{' + match[1] + '}';
const metafields = JSON.parse(jsonStr);
for (const [key, value] of Object.entries(metafields)) {
const displayKey = metafieldMapping[key] || key;
if (value && typeof value === 'string' && value.length > 0) {
data[displayKey] = value;
}
}
} catch (e) {
// Not valid JSON, skip
}
}
// Also check for data-metafield attributes
const metafieldElements = pageDiv.querySelectorAll('[data-metafield], [data-metafield-key]');
metafieldElements.forEach(elem => {
const key = elem.getAttribute('data-metafield-key') || elem.getAttribute('data-metafield');
const value = elem.textContent.trim();
if (key && value) {
const displayKey = metafieldMapping[key] || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
data[displayKey] = value;
}
});
if (description) {
const div = document.createElement('div');
div.innerHTML = description;
// Extract from tables (but skip if it's a specifications table)
const tables = div.querySelectorAll('table');
tables.forEach(table => {
// Check if this table is preceded by "Specifications" heading
let isSpecTable = false;
let prevElement = table.previousElementSibling;
// Check previous 3 siblings for "Specifications" heading
for (let i = 0; i < 3 && prevElement; i++) {
const text = prevElement.textContent.toLowerCase();
if (text.includes('specification') || text.trim() === 'specifications') {
isSpecTable = true;
break;
}
prevElement = prevElement.previousElementSibling;
}
// Skip specification tables - we'll display them separately
if (isSpecTable) return;
const rows = table.querySelectorAll('tr');
rows.forEach(row => {
const cells = row.querySelectorAll('td, th');
if (cells.length >= 2) {
let key = cells[0].textContent.trim().replace(/[:\-]+$/, '').trim();
let value = cells[1].textContent.trim();
if (key && value && value.length < 300 && !key.toLowerCase().includes('specification')) {
data[key] = value;
}
}
});
});
// Extract from lists (skip if under "Specifications" heading)
const lists = div.querySelectorAll('ul, ol');
lists.forEach(list => {
// Check if this list is preceded by "Specifications" heading
let isSpecList = false;
let prevElement = list.previousElementSibling;
for (let i = 0; i < 3 && prevElement; i++) {
const text = prevElement.textContent.toLowerCase();
if (text.includes('specification') || text.trim() === 'specifications') {
isSpecList = true;
break;
}
prevElement = prevElement.previousElementSibling;
}
// Skip specification lists
if (isSpecList) return;
const items = list.querySelectorAll('li');
items.forEach(item => {
const text = item.textContent;
const colonMatch = text.match(/^([^:]+?):\s*(.+)$/);
if (colonMatch) {
let key = colonMatch[1].trim();
let value = colonMatch[2].trim();
if (key && value && value.length < 300) {
data[key] = value;
}
}
});
});
// Extract from plain text patterns (skip if from Specifications section)
const plainText = description.replace(/<[^>]*>/g, '\n');
const lines = plainText.split('\n');
let inSpecSection = false;
lines.forEach(line => {
const lineLower = line.trim().toLowerCase();
// Check if we're entering a specifications section
if (lineLower === 'specifications' || lineLower === 'specification') {
inSpecSection = true;
return;
}
// Check if we're leaving the specifications section (new heading or significant content)
if (inSpecSection && line.trim() && !line.includes(':') && line.length > 30) {
inSpecSection = false;
}
// Skip lines that are in a specifications section
if (inSpecSection) return;
const match = line.match(/^([A-Za-z][A-Za-z0-9\s\-\/\(\)]+?):\s*(.+)$/);
if (match) {
let key = match[1].trim();
let value = match[2].trim();
if (key.length > 2 && key.length < 60 &&
value.length > 0 && value.length < 300 &&
!value.includes('{') && !value.includes('function') &&
!key.toLowerCase().includes('http') &&
!value.toLowerCase().includes('click here')) {
data[key] = value;
}
}
});
}
// pageDiv already created above for metafield extraction
// Extract from <p><strong>Field : </strong>Value</p> pattern
const paragraphs = pageDiv.querySelectorAll('p');
paragraphs.forEach(p => {
const strong = p.querySelector('strong');
if (strong) {
const text = strong.textContent.trim();
const colonMatch = text.match(/^(.+?)\s*:\s*$/);
if (colonMatch) {
const key = colonMatch[1].trim();
let value = p.textContent.replace(text, '').trim();
// Clean up extra whitespace
value = value.replace(/\s+/g, ' ').trim();
// Filter out CSS values and invalid data
if (key && value && value.length > 0 && value.length < 300 &&
!key.toLowerCase().includes('specification') &&
!value.includes('%}') && !value.includes('%;') &&
!value.includes('px') && !value.includes('em') &&
value !== 'auto' && value !== 'none' && value !== '100%') {
data[key] = value;
}
}
}
});
const pageText = pageDiv.textContent || pageDiv.innerText;
const commonFields = {
'Width': /Width\s*[:\-]\s*([^\n]+)/i,
'Repeat': /Repeat\s*[:\-]\s*([^\n]+)/i,
'Lead Time': /Lead\s+Time\s*[:\-]\s*([^\n]+)/i,
'Composition': /Composition\s*[:\-]\s*([^\n]+)/i,
'Use': /Use\s*[:\-]\s*([^\n]+)/i,
'Weight': /Weight\s*[:\-]\s*([^\n]+)/i,
'Mfr SKU': /Mfr\s+SKU\s*[:\-]\s*([^\n]+)/i,
'mfr_sku': /mfr[_\s]sku\s*[:\-]\s*([^\n]+)/i
};
for (const [key, pattern] of Object.entries(commonFields)) {
if (!data[key]) {
const match = pageText.match(pattern);
if (match && match[1]) {
const value = match[1].trim().split(/\n/)[0].trim();
if (value && value.length > 0 && value.length < 150) {
data[key] = value;
}
}
}
}
return data;
}
// Manufacturer pattern extraction from tags
const manufacturerPatterns = {
'Schumacher': { regex: /^\d{5,7}$/, flags: '' },
'Cole & Son': { regex: /^[\d]+\/[\d]+\.?[cC][sS]\.?\d*$/, flags: '' },
'Cole and Son': { regex: /^[\d]+\/[\d]+\.?[cC][sS]\.?\d*$/, flags: '' },
'Patty Madden': { regex: /^pm\d{2}-\d{2}$/i, flags: 'i' },
'York Wallcoverings': { regex: /^[a-z]{2,4}\d{4,6}$/i, flags: 'i' },
'Brewster': { regex: /^[a-z]{2,3}-?\d{4,6}$/i, flags: 'i' },
'Koroseal': { regex: /^[A-Z]{2,4}-[A-Z]{2}\d{2}-\d{2}$/, flags: '' },
'Hollywood Wallcoverings': { regex: /^[a-z]{3}-\d{5}$/i, flags: 'i' },
'Graham & Brown': { regex: /^\d{5,6}$/i, flags: '' },
'Kravet Couture': { regex: /^[Aa][Mm][Ww]\d+\.\d+\.\d+$/, flags: '' },
'Andrew Martin': { regex: /^[Aa][Mm][Ww]\d+\.\d+\.\d+$/, flags: '' },
'Brunschwig & Fils': { regex: /^[Pp]\d{7}\.\d{3}\.\d+$/, flags: '' },
'Brunschwig': { regex: /^[Pp]\d{7}\.\d{3}\.\d+$/, flags: '' }
};
function extractMfrPattern(vendor, tags) {
if (!vendor || !tags || tags.length === 0) return null;
const config = manufacturerPatterns[vendor];
if (!config) return null;
const regex = new RegExp(config.regex.source || config.regex, config.flags);
for (const tag of tags) {
const cleanTag = tag.trim();
if (regex.test(cleanTag)) {
return cleanTag;
}
}
return null;
}
function renderProduct(metafields) {
let sku = currentProduct.variants[0]?.sku || 'N/A';
// Extract manufacturer pattern number from tags
let mfrPattern = extractMfrPattern(currentProduct.vendor, currentProduct.tags);
// Fallback: Check metafield
if (!mfrPattern) {
mfrPattern = metafields['Mfr SKU'] || metafields['mfr_sku'];
}
// Last resort: Extract from SKU (remove DW prefix)
if (!mfrPattern) {
mfrPattern = sku.replace(/^DW[A-Z]*-?/i, '');
}
// Remove "-sample" or "-Sample" from DW SKU for display
sku = sku.replace(/-+sample$/i, '').replace(/-+$/g, '');
// Add vendor and type if available
if (currentProduct.vendor && !metafields['Brand']) {
metafields['Brand'] = currentProduct.vendor;
}
if (currentProduct.type && !metafields['Type']) {
metafields['Type'] = currentProduct.type;
}
// Extract color, width, repeat, dimensions, and certifications from tags
let colorName = '';
let widthFromTag = '';
let repeatFromTag = '';
let lengthFromTag = '';
let useFromTag = '';
const certifications = [];
const colors = ['Almond', 'Charcoal', 'Cocoa', 'Flint', 'Khaki', 'Linen', 'Mocha', 'Olive', 'Stone', 'Taupe', 'Umber',
'Amber', 'Azure', 'Snow', 'Ivory', 'Cream', 'Beige', 'Gray', 'Grey', 'Black', 'White', 'Brown', 'Blue',
'Green', 'Red', 'Yellow', 'Orange', 'Purple', 'Pink', 'Gold', 'Silver', 'Bronze', 'Copper'];
if (currentProduct.tags && currentProduct.tags.length > 0) {
currentProduct.tags.forEach(tag => {
const tagLower = tag.toLowerCase();
// Width extraction - various formats
// Format 1: "20.5in" or "54in"
let widthMatch = tag.match(/^([\d.]+)\s*in$/i);
if (widthMatch && !widthFromTag) {
widthFromTag = widthMatch[1] + '"';
}
// Format 2: "27" Width" or '27" Width x 5 yds'
widthMatch = tag.match(/([\d.]+)"\s*[Ww]idth/);
if (widthMatch && !widthFromTag) {
widthFromTag = widthMatch[1] + '"';
}
// Repeat extraction - "6"V Repeat" or "11" repeat"
const repeatMatch = tag.match(/([\d.]+)"\s*[VvHh]?\s*[Rr]epeat/);
if (repeatMatch && !repeatFromTag) {
repeatFromTag = repeatMatch[1] + '"';
}
// Length extraction - "5 yds" or "x 5 yds"
const lengthMatch = tag.match(/x?\s*([\d.]+)\s*yds?/i);
if (lengthMatch && !lengthFromTag) {
lengthFromTag = lengthMatch[1] + ' yds';
}
// Use extraction - tags that start with "Use" or contain usage info
if (tag.match(/^[Uu]se\s*:/)) {
useFromTag = tag.replace(/^[Uu]se\s*:\s*/i, '');
}
// Material tags like "100% vinyl"
if (tag.includes('%') && !metafields['Composition']) {
metafields['Composition'] = tag;
}
// Color detection
colors.forEach(color => {
if (tagLower.includes(color.toLowerCase()) && !colorName) {
colorName = tag;
}
});
// Certifications
if (tagLower.includes('phthalate') || tagLower.includes('phthalate free')) {
certifications.push('Phthalate Free');
}
if (tagLower.includes('type ii') || tagLower.includes('type 2')) {
certifications.push('Type II');
}
if (tagLower.includes('eco') || tagLower.includes('eco-friendly') || tagLower.includes('eco friendly')) {
certifications.push('Eco Friendly');
}
if (tagLower.includes('high traffic') || tagLower.includes('commercial')) {
certifications.push('High Traffic');
}
if (tagLower.includes('seamless')) {
certifications.push('Seamless');
}
if (tagLower.includes('vinyl')) {
certifications.push('Vinyl');
}
});
}
// Check variant options for color
if (!colorName && currentProduct.options) {
currentProduct.options.forEach(option => {
if (option.name.toLowerCase() === 'color' && option.values && option.values.length > 0) {
colorName = option.values[0];
}
});
}
// Use width from tag if found
if (widthFromTag && !metafields['Width']) {
metafields['Width'] = widthFromTag;
}
// Use repeat from tag if found
if (repeatFromTag && !metafields['Repeat']) {
metafields['Repeat'] = repeatFromTag;
}
// Use length from tag if found
if (lengthFromTag && !metafields['Length']) {
metafields['Length'] = lengthFromTag;
}
// Use usage info from tag if found
if (useFromTag && !metafields['Use']) {
metafields['Use'] = useFromTag;
}
// Priority order for fields
const priorityFields = [
'Brand', 'Collection', 'Designer', 'Manufacturer',
'Composition', 'Content', 'Material',
'Width', 'Roll Width',
'Horizontal Repeat', 'Vertical Repeat', 'Repeat',
'Weight',
'Match', 'Pattern Match',
'Abrasion Resistance', 'Durability',
'Backing', 'Substrate',
'Type', 'Product Type',
'Finish', 'Surface',
'Style', 'Color', 'Pattern'
];
const certificationFields = [];
const otherFields = [];
Object.keys(metafields).forEach(key => {
if (!priorityFields.includes(key)) {
if (key.includes('TB') || key.includes('UFAC') || key.includes('NFPA') ||
key.includes('Certified') || key.includes('Compliant') ||
key.includes('Fire') || key.includes('Safety') ||
key.includes('Class') || key.includes('Standard')) {
certificationFields.push(key);
} else {
otherFields.push(key);
}
}
});
const allFields = [...priorityFields, ...certificationFields, ...otherFields,
'Lead Time', 'Country of Origin', 'Ships From'];
let specs = '';
// Add product title first
specs += `
<div class="spec-item">
<strong>Title:</strong>
<span>${currentProduct.title}</span>
</div>
`;
// Add DW SKU
specs += `
<div class="spec-item">
<strong>DW SKU:</strong>
<span>${sku}</span>
</div>
`;
let itemCount = 2; // Start with 2 (Title + DW SKU)
// Add color if found
if (colorName) {
specs += `
<div class="spec-item">
<strong>Color:</strong>
<span>${colorName}</span>
</div>
`;
itemCount++;
}
const displayedFields = new Set(); // Track displayed fields to avoid duplicates
allFields.forEach(field => {
if (metafields[field] && !displayedFields.has(field)) {
specs += `
<div class="spec-item">
<strong>${field}:</strong>
<span>${metafields[field]}</span>
</div>
`;
displayedFields.add(field);
itemCount++;
}
});
// Add certifications
if (certifications.length > 0) {
const uniqueCerts = [...new Set(certifications)];
uniqueCerts.forEach(cert => {
specs += `
<div class="spec-item">
<strong>${cert}:</strong>
<span>Yes</span>
</div>
`;
itemCount++;
});
}
// Add Extra Info with manufacturer pattern number at the end
specs += `
<div class="spec-item">
<strong>Extra Info:</strong>
<span>${mfrPattern}</span>
</div>
`;
itemCount++;
// Use 2 columns only if more than 15 items
const columnClass = itemCount > 15 ? 'many-items' : '';
// Build title with color if available
let displayTitle = toTitleCase(currentProduct.title);
if (colorName && !displayTitle.toLowerCase().includes(colorName.toLowerCase())) {
// Extract the first word (pattern name) and insert color after it
const words = displayTitle.split(' ');
if (words.length > 0) {
// Insert color in title case after the first word (pattern name)
words.splice(1, 0, toTitleCase(colorName));
displayTitle = words.join(' ');
}
}
const html = `
<h1 class="product-title">${displayTitle}</h1>
<div class="content-wrapper">
${currentProduct.featured_image ?
`<img src="${currentProduct.featured_image}" alt="${currentProduct.title}" class="product-img" crossorigin="anonymous">`
: ''}
<div class="spec-grid ${columnClass}">
${specs}
</div>
</div>
`;
document.getElementById('content').innerHTML = html;
document.title = `${currentProduct.title} - Spec Sheet`;
}
async function downloadPDF() {
const button = document.getElementById('pdfBtn');
button.textContent = 'Generating PDF...';
button.disabled = true;
const element = document.getElementById('pdf-content');
const sku = currentProduct?.variants[0]?.sku || 'spec';
const filename = `Designer-Wallcoverings-Los-Angeles-${sku}.pdf`;
try {
const canvas = await html2canvas(element, {
scale: 2,
useCORS: true,
allowTaint: false,
backgroundColor: '#ffffff',
logging: false
});
const { jsPDF } = window.jspdf;
const imgData = canvas.toDataURL('image/png');
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'letter'
});
const imgWidth = 216;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
pdf.addImage(imgData, 'PNG', 0, 0, imgWidth, imgHeight);
pdf.save(filename);
button.textContent = '✓ Downloaded!';
setTimeout(() => {
button.textContent = '📄 Download PDF';
button.disabled = false;
}, 2000);
} catch (error) {
console.error('PDF generation failed:', error);
button.textContent = '✗ Error - Try Again';
setTimeout(() => {
button.textContent = '📄 Download PDF';
button.disabled = false;
}, 2000);
}
}
loadProduct();
</script>
</body>
</html>