[object Object]

← back to Grassclothwallpaper

initial scaffold

a59a2f44b39dcd7e02f9adf4af54345a2938ed71 · 2026-05-13 05:27:35 -0700 · Steve

Files touched

Diff

commit a59a2f44b39dcd7e02f9adf4af54345a2938ed71
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed May 13 05:27:35 2026 -0700

    initial scaffold
---
 .gitignore                     |   8 ++
 crop-xgr-6201-bottom.js        | 189 +++++++++++++++++++++++++++
 execute-sql-setup.js           |  31 +++++
 extract-and-save-colors.js     | 131 +++++++++++++++++++
 force-crop-all-grs.js          | 192 ++++++++++++++++++++++++++++
 migrate-to-supabase-storage.js | 244 +++++++++++++++++++++++++++++++++++
 process-grs-20385-first.js     | 282 +++++++++++++++++++++++++++++++++++++++++
 supabase-setup.sql             |  95 ++++++++++++++
 test-new-grid.html             |  53 ++++++++
 9 files changed, 1225 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/crop-xgr-6201-bottom.js b/crop-xgr-6201-bottom.js
new file mode 100644
index 0000000..07cebbc
--- /dev/null
+++ b/crop-xgr-6201-bottom.js
@@ -0,0 +1,189 @@
+const fetch = require('node-fetch');
+const getColors = require('get-image-colors');
+const axios = require('axios');
+const sharp = require('sharp');
+
+const SUPABASE_URL = 'https://fypvpcxyfmmdhtklgbrt.supabase.co';
+const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZ5cHZwY3h5Zm1tZGh0a2xnYnJ0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTg4MTAxMjMsImV4cCI6MjA3NDM4NjEyM30.fzDf6VE7jyrqLIbebAwI1NHXi50pI0b_FgQVO-6QcAk';
+
+// Convert color to hex
+function toHex(color) {
+    const rgb = color.rgb();
+    const r = Math.round(rgb[0]).toString(16).padStart(2, '0');
+    const g = Math.round(rgb[1]).toString(16).padStart(2, '0');
+    const b = Math.round(rgb[2]).toString(16).padStart(2, '0');
+    return `#${r}${g}${b}`.toUpperCase();
+}
+
+// Extract colors EXCLUDING bottom 20% (opposite of usual top crop)
+async function extractColorsExcludingBottom(imageUrl) {
+    try {
+        console.log(`  Downloading image: ${imageUrl}`);
+        const response = await axios.get(imageUrl, {
+            responseType: 'arraybuffer',
+            timeout: 20000,
+            maxContentLength: 15 * 1024 * 1024
+        });
+
+        const buffer = Buffer.from(response.data);
+        const metadata = await sharp(buffer).metadata();
+
+        // EXCLUDE bottom 20% from color extraction
+        const cropBottom = Math.floor(metadata.height * 0.20);
+        const usableHeight = metadata.height - cropBottom;
+
+        console.log(`    Original: ${metadata.width}x${metadata.height}`);
+        console.log(`    Excluding bottom ${cropBottom}px (20%)`);
+        console.log(`    Using top ${usableHeight}px for colors`);
+
+        const croppedBuffer = await sharp(buffer)
+            .extract({
+                left: 0,
+                top: 0,  // Start from top
+                width: metadata.width,
+                height: usableHeight  // Use only top 80%
+            })
+            .jpeg()
+            .toBuffer();
+
+        const colors = await getColors(croppedBuffer, {
+            count: 40,
+            type: 'image/jpeg'
+        });
+
+        const hexColors = colors
+            .map(c => toHex(c))
+            .filter(hex => {
+                const r = parseInt(hex.substr(1, 2), 16);
+                const g = parseInt(hex.substr(3, 2), 16);
+                const b = parseInt(hex.substr(5, 2), 16);
+                const brightness = (r + g + b) / 3;
+                // Filter out very bright (white text) and very dark colors
+                return brightness > 15 && brightness < 240;
+            });
+
+        if (hexColors.length < 2) {
+            const allHex = colors.map(c => toHex(c));
+            return { primary: allHex[0] || '#808080', secondary: allHex[1] || '#A0A0A0' };
+        }
+
+        // Find most distinct colors
+        let color1 = hexColors[0];
+        let color2 = hexColors[1];
+        let maxDist = 0;
+
+        for (let i = 0; i < Math.min(hexColors.length, 20); i++) {
+            for (let j = i + 1; j < Math.min(hexColors.length, 20); j++) {
+                const r1 = parseInt(hexColors[i].substr(1, 2), 16);
+                const g1 = parseInt(hexColors[i].substr(3, 2), 16);
+                const b1 = parseInt(hexColors[i].substr(5, 2), 16);
+                const r2 = parseInt(hexColors[j].substr(1, 2), 16);
+                const g2 = parseInt(hexColors[j].substr(3, 2), 16);
+                const b2 = parseInt(hexColors[j].substr(5, 2), 16);
+
+                const dist = Math.sqrt(
+                    Math.pow(r2 - r1, 2) +
+                    Math.pow(g2 - g1, 2) +
+                    Math.pow(b2 - b1, 2)
+                );
+
+                if (dist > maxDist) {
+                    maxDist = dist;
+                    color1 = hexColors[i];
+                    color2 = hexColors[j];
+                }
+            }
+        }
+
+        return { primary: color1, secondary: color2 };
+
+    } catch (error) {
+        console.log(`    Error: ${error.message}`);
+        return null;
+    }
+}
+
+async function cropXGR6201Bottom() {
+    console.log('🔽 CROPPING BOTTOM 20% OF XGR-6201\n');
+    console.log('This product needs bottom cropping instead of top cropping');
+    console.log('=' .repeat(60) + '\n');
+
+    try {
+        // Get XGR-6201 product
+        const response = await fetch(
+            `${SUPABASE_URL}/rest/v1/grasscloth_products?sku=eq.XGR-6201&select=*`,
+            {
+                headers: {
+                    'apikey': SUPABASE_KEY,
+                    'Authorization': `Bearer ${SUPABASE_KEY}`
+                }
+            }
+        );
+
+        const products = await response.json();
+
+        if (!products || products.length === 0) {
+            console.log('❌ XGR-6201 not found in database');
+            return;
+        }
+
+        const product = products[0];
+        console.log(`\nProcessing XGR-6201:${product.name}`);
+        console.log(`  Current colors: ${product.hex_color} | ${product.hex2_color}`);
+
+        if (!product.image_url) {
+            console.log('  NO IMAGE URL');
+            return;
+        }
+
+        const newColors = await extractColorsExcludingBottom(product.image_url);
+
+        if (newColors) {
+            console.log(`  New colors (excluding bottom 20%): ${newColors.primary} | ${newColors.secondary}`);
+
+            // Check brightness
+            const r = parseInt(newColors.primary.substr(1, 2), 16);
+            const g = parseInt(newColors.primary.substr(3, 2), 16);
+            const b = parseInt(newColors.primary.substr(5, 2), 16);
+            const brightness = Math.round((r + g + b) / 3);
+            console.log(`  Primary brightness: ${brightness}`);
+
+            // Update database
+            const updateResponse = await fetch(
+                `${SUPABASE_URL}/rest/v1/grasscloth_products?id=eq.${product.id}`,
+                {
+                    method: 'PATCH',
+                    headers: {
+                        'apikey': SUPABASE_KEY,
+                        'Authorization': `Bearer ${SUPABASE_KEY}`,
+                        'Content-Type': 'application/json',
+                        'Prefer': 'return=minimal'
+                    },
+                    body: JSON.stringify({
+                        hex_color: newColors.primary,
+                        hex2_color: newColors.secondary
+                    })
+                }
+            );
+
+            if (updateResponse.ok) {
+                console.log('  ✓ UPDATED - colors now exclude bottom 20%!');
+            } else {
+                console.log('  ✗ Update failed');
+                console.log('  Response:', await updateResponse.text());
+            }
+        } else {
+            console.log('  ✗ Could not extract colors');
+        }
+
+        console.log('\n' + '=' .repeat(60));
+        console.log('XGR-6201 BOTTOM CROP COMPLETE');
+        console.log('Colors extracted from top 80% of image (excluding bottom text/elements)');
+
+    } catch (error) {
+        console.error('Fatal error:', error.message);
+    }
+}
+
+// Run it
+cropXGR6201Bottom();
\ No newline at end of file
diff --git a/execute-sql-setup.js b/execute-sql-setup.js
new file mode 100644
index 0000000..13b0a6c
--- /dev/null
+++ b/execute-sql-setup.js
@@ -0,0 +1,31 @@
+require('dotenv').config();
+const { createClient } = require('@supabase/supabase-js');
+
+const supabaseUrl = process.env.SUPABASE_URL || 'https://fypvpcxyfmmdhtklgbrt.supabase.co';
+const supabaseServiceKey = process.env.SUPABASE_SERVICE_KEY || process.env.SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZ5cHZwY3h5Zm1tZGh0a2xnYnJ0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTg4MTAxMjMsImV4cCI6MjA3NDM4NjEyM30.fzDf6VE7jyrqLIbebAwI1NHXi50pI0b_FgQVO-6QcAk';
+
+console.log('📝 Instructions to add has_valid_image column:\n');
+console.log('1. Go to your Supabase dashboard: https://supabase.com/dashboard');
+console.log('2. Select your project (fypvpcxyfmmdhtklgbrt)');
+console.log('3. Navigate to the SQL Editor');
+console.log('4. Copy and run this SQL:\n');
+
+const sql = `-- Add has_valid_image column to grasscloth_products table
+ALTER TABLE grasscloth_products
+ADD COLUMN IF NOT EXISTS has_valid_image BOOLEAN DEFAULT false;
+
+-- Create index for better performance
+CREATE INDEX IF NOT EXISTS idx_grasscloth_products_has_valid_image
+ON grasscloth_products(has_valid_image);
+
+-- Add comment to document the column
+COMMENT ON COLUMN grasscloth_products.has_valid_image IS 'Indicates whether the product has a valid, accessible image URL';`;
+
+console.log('```sql');
+console.log(sql);
+console.log('```\n');
+console.log('5. After running the SQL, run: node setup-image-validation.js');
+console.log('6. Then test the site to see only products with valid images\n');
+
+console.log('📌 Alternative: The site will still work without this column!');
+console.log('   It will fallback to filtering by image_url field automatically.');
\ No newline at end of file
diff --git a/extract-and-save-colors.js b/extract-and-save-colors.js
new file mode 100644
index 0000000..abd83b7
--- /dev/null
+++ b/extract-and-save-colors.js
@@ -0,0 +1,131 @@
+const fetch = require('node-fetch');
+const Vibrant = require('node-vibrant/node');
+
+const SUPABASE_URL = 'https://fypvpcxyfmmdhtklgbrt.supabase.co';
+const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZ5cHZwY3h5Zm1tZGh0a2xnYnJ0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTg4MTAxMjMsImV4cCI6MjA3NDM4NjEyM30.fzDf6VE7jyrqLIbebAwI1NHXi50pI0b_FgQVO-6QcAk';
+
+// First, let's create a JSON file with all the colors
+async function extractAllColors() {
+    console.log('=== COLOR EXTRACTION PROCESS ===\n');
+    console.log('Step 1: Fetching all products...');
+
+    try {
+        // Fetch all products
+        const response = await fetch(
+            `${SUPABASE_URL}/rest/v1/grasscloth_products?select=*&order=sku`,
+            {
+                headers: {
+                    'apikey': SUPABASE_KEY,
+                    'Authorization': `Bearer ${SUPABASE_KEY}`
+                }
+            }
+        );
+
+        if (!response.ok) {
+            throw new Error(`Failed to fetch products: ${response.statusText}`);
+        }
+
+        const products = await response.json();
+        const productsWithImages = products.filter(p =>
+            p.image_url && p.image_url.includes('supabase.co/storage')
+        );
+
+        console.log(`Found ${productsWithImages.length} products with Supabase images\n`);
+
+        const colorData = [];
+        let processed = 0;
+
+        console.log('Step 2: Extracting colors from images...\n');
+
+        // Process each product
+        for (const product of productsWithImages) {
+            try {
+                process.stdout.write(`Processing ${processed + 1}/${productsWithImages.length}: ${product.sku}... `);
+
+                // Use Vibrant properly
+                const v = new Vibrant(product.image_url);
+                const palette = await v.getPalette();
+
+                let primaryColor = '#D4B896';  // Default beige
+                let secondaryColor = '#8B7355'; // Default brown
+
+                if (palette) {
+                    // Get primary color
+                    if (palette.Vibrant) {
+                        primaryColor = palette.Vibrant.getHex();
+                    } else if (palette.Muted) {
+                        primaryColor = palette.Muted.getHex();
+                    } else if (palette.DarkVibrant) {
+                        primaryColor = palette.DarkVibrant.getHex();
+                    }
+
+                    // Get secondary color
+                    if (palette.DarkMuted) {
+                        secondaryColor = palette.DarkMuted.getHex();
+                    } else if (palette.DarkVibrant && primaryColor !== palette.DarkVibrant.getHex()) {
+                        secondaryColor = palette.DarkVibrant.getHex();
+                    } else if (palette.LightVibrant) {
+                        secondaryColor = palette.LightVibrant.getHex();
+                    }
+                }
+
+                colorData.push({
+                    id: product.id,
+                    sku: product.sku,
+                    primary_color: primaryColor,
+                    secondary_color: secondaryColor
+                });
+
+                console.log(`✓ (${primaryColor}, ${secondaryColor})`);
+                processed++;
+
+                // Small delay every 10 items to avoid overwhelming
+                if (processed % 10 === 0) {
+                    await new Promise(resolve => setTimeout(resolve, 500));
+                }
+
+            } catch (error) {
+                console.log(`✗ Error: ${error.message}`);
+                // Add with default colors on error
+                colorData.push({
+                    id: product.id,
+                    sku: product.sku,
+                    primary_color: '#D4B896',
+                    secondary_color: '#8B7355'
+                });
+                processed++;
+            }
+        }
+
+        console.log('\n\nStep 3: Saving color data to JSON file...');
+
+        // Save to JSON file
+        const fs = require('fs').promises;
+        await fs.writeFile(
+            'product-colors.json',
+            JSON.stringify(colorData, null, 2)
+        );
+
+        console.log('✓ Color data saved to product-colors.json');
+
+        // Show sample
+        console.log('\nSample of extracted colors:');
+        colorData.slice(0, 5).forEach(item => {
+            console.log(`  ${item.sku}: Primary=${item.primary_color}, Secondary=${item.secondary_color}`);
+        });
+
+        console.log('\n=== EXTRACTION COMPLETE ===');
+        console.log(`Total products processed: ${processed}`);
+        console.log('\nNext steps:');
+        console.log('1. Add color columns to Supabase using the SQL in add-color-columns.sql');
+        console.log('2. Run: node upload-colors-to-supabase.js');
+
+        return colorData;
+
+    } catch (error) {
+        console.error('Fatal error:', error);
+    }
+}
+
+// Run extraction
+extractAllColors();
\ No newline at end of file
diff --git a/force-crop-all-grs.js b/force-crop-all-grs.js
new file mode 100644
index 0000000..9cee837
--- /dev/null
+++ b/force-crop-all-grs.js
@@ -0,0 +1,192 @@
+const fetch = require('node-fetch');
+const getColors = require('get-image-colors');
+const axios = require('axios');
+const sharp = require('sharp');
+
+const SUPABASE_URL = 'https://fypvpcxyfmmdhtklgbrt.supabase.co';
+const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZ5cHZwY3h5Zm1tZGh0a2xnYnJ0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTg4MTAxMjMsImV4cCI6MjA3NDM4NjEyM30.fzDf6VE7jyrqLIbebAwI1NHXi50pI0b_FgQVO-6QcAk';
+
+// Convert color to hex
+function toHex(color) {
+    const rgb = color.rgb();
+    const r = Math.round(rgb[0]).toString(16).padStart(2, '0');
+    const g = Math.round(rgb[1]).toString(16).padStart(2, '0');
+    const b = Math.round(rgb[2]).toString(16).padStart(2, '0');
+    return `#${r}${g}${b}`.toUpperCase();
+}
+
+// Extract colors with FORCED 20% top crop - NO CONDITIONS
+async function extractColorsWithCrop(imageUrl) {
+    try {
+        const response = await axios.get(imageUrl, {
+            responseType: 'arraybuffer',
+            timeout: 15000,
+            maxContentLength: 10 * 1024 * 1024
+        });
+
+        const buffer = Buffer.from(response.data);
+        const metadata = await sharp(buffer).metadata();
+
+        // ALWAYS crop 20% from top, 15% from bottom
+        const cropTop = Math.floor(metadata.height * 0.2);
+        const cropBottom = Math.floor(metadata.height * 0.15);
+        const croppedHeight = metadata.height - cropTop - cropBottom;
+
+        const croppedBuffer = await sharp(buffer)
+            .extract({
+                left: 0,
+                top: cropTop,
+                width: metadata.width,
+                height: croppedHeight
+            })
+            .jpeg()
+            .toBuffer();
+
+        const colors = await getColors(croppedBuffer, {
+            count: 30,
+            type: 'image/jpeg'
+        });
+
+        // Filter out whites and blacks
+        const hexColors = colors
+            .map(c => toHex(c))
+            .filter(hex => {
+                const r = parseInt(hex.substr(1, 2), 16);
+                const g = parseInt(hex.substr(3, 2), 16);
+                const b = parseInt(hex.substr(5, 2), 16);
+                const brightness = (r + g + b) / 3;
+                return brightness > 20 && brightness < 235;
+            });
+
+        if (hexColors.length < 2) return null;
+
+        // Find most distinct colors
+        let color1 = hexColors[0];
+        let color2 = hexColors[1];
+        let maxDist = 0;
+
+        for (let i = 0; i < Math.min(hexColors.length, 15); i++) {
+            for (let j = i + 1; j < Math.min(hexColors.length, 15); j++) {
+                const r1 = parseInt(hexColors[i].substr(1, 2), 16);
+                const g1 = parseInt(hexColors[i].substr(3, 2), 16);
+                const b1 = parseInt(hexColors[i].substr(5, 2), 16);
+                const r2 = parseInt(hexColors[j].substr(1, 2), 16);
+                const g2 = parseInt(hexColors[j].substr(3, 2), 16);
+                const b2 = parseInt(hexColors[j].substr(5, 2), 16);
+
+                const dist = Math.sqrt(
+                    Math.pow(r2 - r1, 2) +
+                    Math.pow(g2 - g1, 2) +
+                    Math.pow(b2 - b1, 2)
+                );
+
+                if (dist > maxDist) {
+                    maxDist = dist;
+                    color1 = hexColors[i];
+                    color2 = hexColors[j];
+                }
+            }
+        }
+
+        return { primary: color1, secondary: color2 };
+
+    } catch (error) {
+        return null;
+    }
+}
+
+async function forceCropAllGRS() {
+    console.log('=== FORCE CROPPING ALL GRS PRODUCTS - NO CONDITIONS ===\n');
+    console.log('WILL UPDATE EVERY SINGLE GRS PRODUCT WITH 20% TOP CROP\n');
+    console.log('=' .repeat(60) + '\n');
+
+    try {
+        // Get ALL GRS products
+        const response = await fetch(
+            `${SUPABASE_URL}/rest/v1/grasscloth_products?select=*&sku=like.GRS*&order=sku`,
+            {
+                headers: {
+                    'apikey': SUPABASE_KEY,
+                    'Authorization': `Bearer ${SUPABASE_KEY}`
+                }
+            }
+        );
+
+        const products = await response.json();
+        console.log(`Found ${products.length} GRS products\n`);
+        console.log('STARTING FORCED UPDATE OF ALL PRODUCTS...\n');
+
+        let processedCount = 0;
+        let updatedCount = 0;
+        let errorCount = 0;
+
+        for (const product of products) {
+            processedCount++;
+
+            if (!product.image_url) {
+                console.log(`[${processedCount}/${products.length}] ${product.sku}: NO IMAGE`);
+                errorCount++;
+                continue;
+            }
+
+            process.stdout.write(`[${processedCount}/${products.length}] ${product.sku}: `);
+            process.stdout.write(`OLD: ${product.hex_color}|${product.hex2_color} → `);
+
+            const newColors = await extractColorsWithCrop(product.image_url);
+
+            if (newColors) {
+                process.stdout.write(`NEW: ${newColors.primary}|${newColors.secondary} `);
+
+                // ALWAYS UPDATE - NO COMPARISON
+                const updateResponse = await fetch(
+                    `${SUPABASE_URL}/rest/v1/grasscloth_products?id=eq.${product.id}`,
+                    {
+                        method: 'PATCH',
+                        headers: {
+                            'apikey': SUPABASE_KEY,
+                            'Authorization': `Bearer ${SUPABASE_KEY}`,
+                            'Content-Type': 'application/json',
+                            'Prefer': 'return=minimal'
+                        },
+                        body: JSON.stringify({
+                            hex_color: newColors.primary,
+                            hex2_color: newColors.secondary
+                        })
+                    }
+                );
+
+                if (updateResponse.ok) {
+                    console.log('✓ UPDATED');
+                    updatedCount++;
+                } else {
+                    console.log('✗ FAILED');
+                    errorCount++;
+                }
+            } else {
+                console.log('ERROR EXTRACTING');
+                errorCount++;
+            }
+
+            // Progress every 10 items
+            if (processedCount % 10 === 0) {
+                const percent = Math.round((processedCount / products.length) * 100);
+                console.log(`\n--- ${percent}% COMPLETE | ${updatedCount} UPDATED, ${errorCount} ERRORS ---\n`);
+            }
+        }
+
+        // Final summary
+        console.log('\n' + '=' .repeat(70));
+        console.log('FORCED CROP COMPLETE');
+        console.log('=' .repeat(70));
+        console.log(`Total GRS products: ${processedCount}`);
+        console.log(`Successfully updated: ${updatedCount}`);
+        console.log(`Errors: ${errorCount}`);
+        console.log(`\nALL PRODUCTS HAVE BEEN PROCESSED WITH 20% TOP CROP`);
+
+    } catch (error) {
+        console.error('Fatal error:', error.message);
+    }
+}
+
+// RUN IT
+forceCropAllGRS();
\ No newline at end of file
diff --git a/migrate-to-supabase-storage.js b/migrate-to-supabase-storage.js
new file mode 100644
index 0000000..a523589
--- /dev/null
+++ b/migrate-to-supabase-storage.js
@@ -0,0 +1,244 @@
+require('dotenv').config();
+const { createClient } = require('@supabase/supabase-js');
+const https = require('https');
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const supabaseUrl = process.env.SUPABASE_URL || 'https://fypvpcxyfmmdhtklgbrt.supabase.co';
+const supabaseServiceKey = process.env.SUPABASE_SERVICE_KEY || process.env.SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZ5cHZwY3h5Zm1tZGh0a2xnYnJ0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTg4MTAxMjMsImV4cCI6MjA3NDM4NjEyM30.fzDf6VE7jyrqLIbebAwI1NHXi50pI0b_FgQVO-6QcAk';
+
+const supabase = createClient(supabaseUrl, supabaseServiceKey);
+
+const BUCKET_NAME = 'product-images';
+const BATCH_SIZE = 10; // Process images in batches
+
+async function downloadImage(url) {
+    return new Promise((resolve, reject) => {
+        const protocol = url.startsWith('https') ? https : http;
+
+        protocol.get(url, (response) => {
+            if (response.statusCode !== 200) {
+                reject(new Error(`Failed to download: ${response.statusCode}`));
+                return;
+            }
+
+            const chunks = [];
+            response.on('data', chunk => chunks.push(chunk));
+            response.on('end', () => resolve(Buffer.concat(chunks)));
+            response.on('error', reject);
+        }).on('error', reject);
+    });
+}
+
+async function createBucketIfNotExists() {
+    console.log('📦 Checking/Creating storage bucket...');
+
+    const { data: buckets, error: listError } = await supabase.storage.listBuckets();
+
+    if (listError) {
+        console.error('Error listing buckets:', listError);
+        return false;
+    }
+
+    const bucketExists = buckets?.some(b => b.name === BUCKET_NAME);
+
+    if (!bucketExists) {
+        console.log(`Creating bucket: ${BUCKET_NAME}`);
+        const { data, error } = await supabase.storage.createBucket(BUCKET_NAME, {
+            public: true,
+            fileSizeLimit: 5242880 // 5MB
+        });
+
+        if (error) {
+            console.error('Error creating bucket:', error);
+            return false;
+        }
+        console.log('✅ Bucket created successfully');
+    } else {
+        console.log('✅ Bucket already exists');
+    }
+
+    return true;
+}
+
+async function uploadImageToSupabase(imageBuffer, fileName, contentType = 'image/jpeg') {
+    try {
+        const { data, error } = await supabase.storage
+            .from(BUCKET_NAME)
+            .upload(fileName, imageBuffer, {
+                contentType: contentType,
+                upsert: true
+            });
+
+        if (error) {
+            console.error(`Upload error for ${fileName}:`, error.message);
+            return null;
+        }
+
+        // Get public URL
+        const { data: urlData } = supabase.storage
+            .from(BUCKET_NAME)
+            .getPublicUrl(fileName);
+
+        return urlData.publicUrl;
+    } catch (error) {
+        console.error(`Error uploading ${fileName}:`, error);
+        return null;
+    }
+}
+
+async function migrateImagesToSupabase() {
+    console.log('🚀 Starting migration to Supabase Storage...\n');
+
+    // 1. Create bucket if needed
+    const bucketReady = await createBucketIfNotExists();
+    if (!bucketReady) {
+        console.error('❌ Failed to create/access bucket');
+        return;
+    }
+
+    // 2. Get all products with external images
+    console.log('\n📊 Fetching products with external images...');
+    const { data: products, error } = await supabase
+        .from('grasscloth_products')
+        .select('*')
+        .or('image_url.ilike.%shopify.com%,image_url.ilike.%http%')
+        .not('image_url', 'ilike', '%supabase.co%')
+        .order('sku');
+
+    if (error) {
+        console.error('Error fetching products:', error);
+        return;
+    }
+
+    console.log(`Found ${products.length} products with external images to migrate\n`);
+
+    // 3. Process products in batches
+    let successCount = 0;
+    let failCount = 0;
+    const failedProducts = [];
+
+    for (let i = 0; i < products.length; i += BATCH_SIZE) {
+        const batch = products.slice(i, Math.min(i + BATCH_SIZE, products.length));
+        console.log(`\nProcessing batch ${Math.floor(i/BATCH_SIZE) + 1} (${i + 1}-${Math.min(i + BATCH_SIZE, products.length)} of ${products.length})`);
+
+        await Promise.all(batch.map(async (product) => {
+            try {
+                if (!product.image_url) {
+                    console.log(`  ⚠️  ${product.sku}: No image URL`);
+                    failedProducts.push(product);
+                    failCount++;
+                    return;
+                }
+
+                // Skip if already Supabase URL
+                if (product.image_url.includes('supabase.co')) {
+                    console.log(`  ⏭️  ${product.sku}: Already in Supabase`);
+                    successCount++;
+                    return;
+                }
+
+                // Download image
+                console.log(`  ⬇️  ${product.sku}: Downloading...`);
+                const imageBuffer = await downloadImage(product.image_url);
+
+                // Determine file extension
+                const urlParts = product.image_url.split('.');
+                const extension = urlParts[urlParts.length - 1].split('?')[0].toLowerCase();
+                const validExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
+                const fileExt = validExtensions.includes(extension) ? extension : 'jpg';
+
+                // Create filename
+                const fileName = `${product.sku || `product-${Date.now()}`}.${fileExt}`;
+
+                // Upload to Supabase
+                const supabaseUrl = await uploadImageToSupabase(imageBuffer, fileName, `image/${fileExt}`);
+
+                if (supabaseUrl) {
+                    // Update product record
+                    const { error: updateError } = await supabase
+                        .from('grasscloth_products')
+                        .update({
+                            image_url: supabaseUrl,
+                            has_valid_image: true
+                        })
+                        .eq('sku', product.sku);
+
+                    if (updateError) {
+                        console.log(`  ❌ ${product.sku}: Failed to update database`);
+                        failedProducts.push(product);
+                        failCount++;
+                    } else {
+                        console.log(`  ✅ ${product.sku}: Migrated successfully`);
+                        successCount++;
+                    }
+                } else {
+                    console.log(`  ❌ ${product.sku}: Failed to upload`);
+                    failedProducts.push(product);
+                    failCount++;
+                }
+            } catch (error) {
+                console.log(`  ❌ ${product.sku}: Error - ${error.message}`);
+                failedProducts.push(product);
+                failCount++;
+            }
+        }));
+
+        // Small delay between batches
+        if (i + BATCH_SIZE < products.length) {
+            await new Promise(resolve => setTimeout(resolve, 1000));
+        }
+    }
+
+    // 4. Handle products without images
+    console.log('\n📊 Checking products without any images...');
+    const { data: noImageProducts } = await supabase
+        .from('grasscloth_products')
+        .select('sku, name')
+        .is('image_url', null);
+
+    // 5. Final report
+    console.log('\n' + '='.repeat(60));
+    console.log('📊 MIGRATION COMPLETE:');
+    console.log('='.repeat(60));
+    console.log(`✅ Successfully migrated: ${successCount} products`);
+    console.log(`❌ Failed to migrate: ${failCount} products`);
+    console.log(`⚠️  No images: ${noImageProducts?.length || 0} products`);
+    console.log('='.repeat(60));
+
+    if (failedProducts.length > 0) {
+        console.log('\n❌ Failed products (first 10):');
+        failedProducts.slice(0, 10).forEach(p => {
+            console.log(`  - ${p.sku}: ${p.name || 'No name'}`);
+        });
+    }
+
+    // Save failed products list for manual review
+    if (failedProducts.length > 0) {
+        fs.writeFileSync(
+            'failed-migrations.json',
+            JSON.stringify(failedProducts, null, 2)
+        );
+        console.log('\n📝 Failed products saved to failed-migrations.json');
+    }
+
+    return {
+        success: successCount,
+        failed: failCount,
+        noImages: noImageProducts?.length || 0
+    };
+}
+
+// Export for use in other scripts
+module.exports = { migrateImagesToSupabase };
+
+// Run if called directly
+if (require.main === module) {
+    migrateImagesToSupabase().then(result => {
+        console.log('\n🎯 Next steps:');
+        console.log('1. Review failed-migrations.json for any failed uploads');
+        console.log('2. Update frontend to filter by Supabase URLs only');
+        console.log('3. Test the website to ensure all images load correctly');
+    });
+}
\ No newline at end of file
diff --git a/process-grs-20385-first.js b/process-grs-20385-first.js
new file mode 100644
index 0000000..dccd529
--- /dev/null
+++ b/process-grs-20385-first.js
@@ -0,0 +1,282 @@
+const fetch = require('node-fetch');
+const getColors = require('get-image-colors');
+const axios = require('axios');
+const sharp = require('sharp');
+
+const SUPABASE_URL = 'https://fypvpcxyfmmdhtklgbrt.supabase.co';
+const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZ5cHZwY3h5Zm1tZGh0a2xnYnJ0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTg4MTAxMjMsImV4cCI6MjA3NDM4NjEyM30.fzDf6VE7jyrqLIbebAwI1NHXi50pI0b_FgQVO-6QcAk';
+
+// Convert color to hex
+function toHex(color) {
+    const rgb = color.rgb();
+    const r = Math.round(rgb[0]).toString(16).padStart(2, '0');
+    const g = Math.round(rgb[1]).toString(16).padStart(2, '0');
+    const b = Math.round(rgb[2]).toString(16).padStart(2, '0');
+    return `#${r}${g}${b}`.toUpperCase();
+}
+
+// Extract colors with 20% top crop
+async function extractColorsWithCrop(imageUrl) {
+    try {
+        const response = await axios.get(imageUrl, {
+            responseType: 'arraybuffer',
+            timeout: 15000,
+            maxContentLength: 10 * 1024 * 1024
+        });
+
+        const buffer = Buffer.from(response.data);
+        const metadata = await sharp(buffer).metadata();
+
+        // Crop 20% from top, 15% from bottom
+        const cropTop = Math.floor(metadata.height * 0.2);
+        const cropBottom = Math.floor(metadata.height * 0.15);
+        const croppedHeight = metadata.height - cropTop - cropBottom;
+
+        const croppedBuffer = await sharp(buffer)
+            .extract({
+                left: 0,
+                top: cropTop,
+                width: metadata.width,
+                height: croppedHeight
+            })
+            .jpeg()
+            .toBuffer();
+
+        const colors = await getColors(croppedBuffer, {
+            count: 30,
+            type: 'image/jpeg'
+        });
+
+        // Filter out whites and blacks
+        const hexColors = colors
+            .map(c => toHex(c))
+            .filter(hex => {
+                const r = parseInt(hex.substr(1, 2), 16);
+                const g = parseInt(hex.substr(3, 2), 16);
+                const b = parseInt(hex.substr(5, 2), 16);
+                const brightness = (r + g + b) / 3;
+                return brightness > 20 && brightness < 235;
+            });
+
+        if (hexColors.length < 2) return null;
+
+        // Find most distinct colors
+        let color1 = hexColors[0];
+        let color2 = hexColors[1];
+        let maxDist = 0;
+
+        for (let i = 0; i < Math.min(hexColors.length, 15); i++) {
+            for (let j = i + 1; j < Math.min(hexColors.length, 15); j++) {
+                const r1 = parseInt(hexColors[i].substr(1, 2), 16);
+                const g1 = parseInt(hexColors[i].substr(3, 2), 16);
+                const b1 = parseInt(hexColors[i].substr(5, 2), 16);
+                const r2 = parseInt(hexColors[j].substr(1, 2), 16);
+                const g2 = parseInt(hexColors[j].substr(3, 2), 16);
+                const b2 = parseInt(hexColors[j].substr(5, 2), 16);
+
+                const dist = Math.sqrt(
+                    Math.pow(r2 - r1, 2) +
+                    Math.pow(g2 - g1, 2) +
+                    Math.pow(b2 - b1, 2)
+                );
+
+                if (dist > maxDist) {
+                    maxDist = dist;
+                    color1 = hexColors[i];
+                    color2 = hexColors[j];
+                }
+            }
+        }
+
+        return { primary: color1, secondary: color2 };
+
+    } catch (error) {
+        return null;
+    }
+}
+
+async function processGRS20385() {
+    console.log('=== PROCESSING GRS-20385 FIRST ===\n');
+
+    try {
+        // Get GRS-20385 product
+        const response = await fetch(
+            `${SUPABASE_URL}/rest/v1/grasscloth_products?sku=eq.GRS-20385&select=*`,
+            {
+                headers: {
+                    'apikey': SUPABASE_KEY,
+                    'Authorization': `Bearer ${SUPABASE_KEY}`
+                }
+            }
+        );
+
+        const products = await response.json();
+
+        if (!products || products.length === 0) {
+            console.log('GRS-20385 not found in database');
+            return;
+        }
+
+        const product = products[0];
+        console.log('Found GRS-20385:');
+        console.log(`  Title: ${product.title || 'No title'}`);
+        console.log(`  Current colors: ${product.hex_color} | ${product.hex2_color}`);
+        console.log(`  Image URL: ${product.image_url}\n`);
+
+        if (!product.image_url) {
+            console.log('No image URL for GRS-20385');
+            return;
+        }
+
+        console.log('Applying 20% top crop, 15% bottom crop...\n');
+        const newColors = await extractColorsWithCrop(product.image_url);
+
+        if (newColors) {
+            console.log(`New colors: ${newColors.primary} | ${newColors.secondary}\n`);
+
+            // Check if colors actually changed
+            if (newColors.primary === product.hex_color && newColors.secondary === product.hex2_color) {
+                console.log('No change needed - colors are the same');
+            } else {
+                console.log('Updating database...');
+
+                // Update database
+                const updateResponse = await fetch(
+                    `${SUPABASE_URL}/rest/v1/grasscloth_products?id=eq.${product.id}`,
+                    {
+                        method: 'PATCH',
+                        headers: {
+                            'apikey': SUPABASE_KEY,
+                            'Authorization': `Bearer ${SUPABASE_KEY}`,
+                            'Content-Type': 'application/json',
+                            'Prefer': 'return=minimal'
+                        },
+                        body: JSON.stringify({
+                            hex_color: newColors.primary,
+                            hex2_color: newColors.secondary
+                        })
+                    }
+                );
+
+                if (updateResponse.ok) {
+                    console.log('✓ GRS-20385 successfully updated!');
+                    console.log(`  Old: ${product.hex_color} | ${product.hex2_color}`);
+                    console.log(`  New: ${newColors.primary} | ${newColors.secondary}`);
+                } else {
+                    console.log('✗ Failed to update GRS-20385');
+                }
+            }
+        } else {
+            console.log('Could not extract colors from GRS-20385');
+        }
+
+        console.log('\n=== GRS-20385 PROCESSING COMPLETE ===\n');
+
+        // Now continue with all GRS products
+        console.log('Continuing with all other GRS products...\n');
+        await processAllGRSProducts();
+
+    } catch (error) {
+        console.error('Error processing GRS-20385:', error.message);
+    }
+}
+
+async function processAllGRSProducts() {
+    console.log('=== PROCESSING ALL GRS PRODUCTS ===\n');
+
+    try {
+        // Get ALL GRS products
+        const response = await fetch(
+            `${SUPABASE_URL}/rest/v1/grasscloth_products?select=*&sku=like.GRS*&order=sku`,
+            {
+                headers: {
+                    'apikey': SUPABASE_KEY,
+                    'Authorization': `Bearer ${SUPABASE_KEY}`
+                }
+            }
+        );
+
+        const products = await response.json();
+        console.log(`Found ${products.length} GRS products total\n`);
+
+        let processedCount = 0;
+        let fixedCount = 0;
+        let skippedCount = 0;
+        let errorCount = 0;
+
+        for (const product of products) {
+            processedCount++;
+
+            // Skip if no image
+            if (!product.image_url) {
+                console.log(`[${processedCount}/${products.length}] ${product.sku}: NO IMAGE`);
+                errorCount++;
+                continue;
+            }
+
+            // Show progress
+            process.stdout.write(`[${processedCount}/${products.length}] ${product.sku}: `);
+
+            const newColors = await extractColorsWithCrop(product.image_url);
+
+            if (newColors) {
+                // Check if colors changed
+                if (newColors.primary === product.hex_color && newColors.secondary === product.hex2_color) {
+                    console.log('no change');
+                    skippedCount++;
+                } else {
+                    // Update database
+                    const updateResponse = await fetch(
+                        `${SUPABASE_URL}/rest/v1/grasscloth_products?id=eq.${product.id}`,
+                        {
+                            method: 'PATCH',
+                            headers: {
+                                'apikey': SUPABASE_KEY,
+                                'Authorization': `Bearer ${SUPABASE_KEY}`,
+                                'Content-Type': 'application/json',
+                                'Prefer': 'return=minimal'
+                            },
+                            body: JSON.stringify({
+                                hex_color: newColors.primary,
+                                hex2_color: newColors.secondary
+                            })
+                        }
+                    );
+
+                    if (updateResponse.ok) {
+                        console.log(`✓ ${product.hex_color} → ${newColors.primary}`);
+                        fixedCount++;
+                    } else {
+                        console.log('✗ update failed');
+                        errorCount++;
+                    }
+                }
+            } else {
+                console.log('FAILED');
+                errorCount++;
+            }
+
+            // Progress summary every 20 items
+            if (processedCount % 20 === 0) {
+                const percentComplete = Math.round((processedCount / products.length) * 100);
+                console.log(`\n--- ${percentComplete}% complete | ${fixedCount} updated, ${skippedCount} unchanged ---\n`);
+            }
+        }
+
+        // Final summary
+        console.log('\n' + '=' .repeat(70));
+        console.log('COMPLETE SUMMARY');
+        console.log('=' .repeat(70));
+        console.log(`Total GRS products: ${processedCount}`);
+        console.log(`Updated: ${fixedCount}`);
+        console.log(`Unchanged: ${skippedCount}`);
+        console.log(`Errors: ${errorCount}`);
+        console.log(`Success rate: ${Math.round(((fixedCount + skippedCount) / processedCount) * 100)}%`);
+
+    } catch (error) {
+        console.error('Error processing all GRS products:', error.message);
+    }
+}
+
+// Run - process GRS-20385 first, then all others
+processGRS20385();
\ No newline at end of file
diff --git a/supabase-setup.sql b/supabase-setup.sql
new file mode 100644
index 0000000..f1cef15
--- /dev/null
+++ b/supabase-setup.sql
@@ -0,0 +1,95 @@
+
+-- ============================================
+-- SQL COMMANDS FOR SUPABASE SQL EDITOR
+-- ============================================
+-- Go to: SQL Editor > New Query
+-- Copy and paste all of this, then click "Run"
+
+-- Drop existing tables if they exist (optional - for clean setup)
+DROP TABLE IF EXISTS sample_requests CASCADE;
+DROP TABLE IF EXISTS grasscloth_products CASCADE;
+
+-- Create Grasscloth Products Table
+CREATE TABLE grasscloth_products (
+    id SERIAL PRIMARY KEY,
+    name VARCHAR(255) NOT NULL,
+    sku VARCHAR(100) UNIQUE,
+    color VARCHAR(100),
+    brand VARCHAR(100) DEFAULT 'Phillipe Romano',
+    category VARCHAR(100) DEFAULT 'Grasscloth',
+    url TEXT,
+    image_url TEXT,
+    description TEXT,
+    width VARCHAR(50) DEFAULT '36"',
+    price_per_yard DECIMAL(10,2),
+    in_stock BOOLEAN DEFAULT true,
+    featured BOOLEAN DEFAULT false,
+    created_at TIMESTAMP DEFAULT NOW(),
+    updated_at TIMESTAMP DEFAULT NOW()
+);
+
+-- Create indexes for better performance
+CREATE INDEX idx_grasscloth_name ON grasscloth_products(name);
+CREATE INDEX idx_grasscloth_sku ON grasscloth_products(sku);
+CREATE INDEX idx_grasscloth_featured ON grasscloth_products(featured);
+
+-- Create Sample Requests Table
+CREATE TABLE sample_requests (
+    id SERIAL PRIMARY KEY,
+    product_name VARCHAR(255),
+    product_sku VARCHAR(100),
+    customer_name VARCHAR(255) NOT NULL,
+    customer_email VARCHAR(255) NOT NULL,
+    customer_phone VARCHAR(50),
+    company VARCHAR(255),
+    address TEXT,
+    city VARCHAR(100),
+    state VARCHAR(50),
+    zip VARCHAR(20),
+    country VARCHAR(100) DEFAULT 'USA',
+    message TEXT,
+    status VARCHAR(50) DEFAULT 'pending',
+    created_at TIMESTAMP DEFAULT NOW()
+);
+
+-- Create indexes for sample requests
+CREATE INDEX idx_requests_email ON sample_requests(customer_email);
+CREATE INDEX idx_requests_status ON sample_requests(status);
+
+-- Enable Row Level Security (RLS)
+ALTER TABLE grasscloth_products ENABLE ROW LEVEL SECURITY;
+ALTER TABLE sample_requests ENABLE ROW LEVEL SECURITY;
+
+-- Create RLS Policies
+
+-- Everyone can view products
+CREATE POLICY "Public can view products"
+ON grasscloth_products FOR SELECT
+USING (true);
+
+-- Everyone can create sample requests
+CREATE POLICY "Public can create sample requests"
+ON sample_requests FOR INSERT
+WITH CHECK (true);
+
+-- Only authenticated users can view sample requests
+CREATE POLICY "Authenticated can view requests"
+ON sample_requests FOR SELECT
+USING (auth.role() = 'authenticated');
+
+-- Insert initial Phillipe Romano products
+INSERT INTO grasscloth_products (name, sku, color, description, price_per_yard, featured) VALUES
+('PR-101 Natural Sisal', 'PR-101', 'Natural Sisal', 'Natural sisal grasscloth with subtle texture and organic beauty', 125.00, true),
+('PR-102 Sage Green', 'PR-102', 'Sage Green', 'Soft sage green grasscloth with natural fiber variations', 125.00, true),
+('PR-103 Charcoal', 'PR-103', 'Charcoal', 'Sophisticated charcoal grasscloth with rich depth', 135.00, false),
+('PR-104 Ivory', 'PR-104', 'Ivory', 'Elegant ivory grasscloth with delicate weave pattern', 125.00, false),
+('PR-105 Navy Blue', 'PR-105', 'Navy Blue', 'Deep navy blue grasscloth with luxurious texture', 135.00, true),
+('PR-106 Taupe', 'PR-106', 'Taupe', 'Warm taupe grasscloth with natural variations', 125.00, false),
+('PR-107 Pearl', 'PR-107', 'Pearl', 'Lustrous pearl grasscloth with subtle shimmer', 145.00, false),
+('PR-108 Terracotta', 'PR-108', 'Terracotta', 'Warm terracotta grasscloth with earthy appeal', 125.00, false),
+('PR-109 Silver', 'PR-109', 'Silver', 'Contemporary silver grasscloth with metallic undertones', 145.00, true),
+('PR-110 Moss Green', 'PR-110', 'Moss Green', 'Rich moss green grasscloth inspired by nature', 125.00, false);
+
+-- Verify the setup
+SELECT COUNT(*) as product_count FROM grasscloth_products;
+SELECT * FROM grasscloth_products ORDER BY name LIMIT 5;
diff --git a/test-new-grid.html b/test-new-grid.html
new file mode 100644
index 0000000..73daa8d
--- /dev/null
+++ b/test-new-grid.html
@@ -0,0 +1,53 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <title>Test New Grid Layout</title>
+</head>
+<body>
+    <h1>Testing New Grid Layout</h1>
+    <p>Open browser console to see product loading...</p>
+
+    <script>
+        const SUPABASE_URL = 'https://fypvpcxyfmmdhtklgbrt.supabase.co';
+        const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZ5cHZwY3h5Zm1tZGh0a2xnYnJ0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTg4MTAxMjMsImV4cCI6MjA3NDM4NjEyM30.fzDf6VE7jyrqLIbebAwI1NHXi50pI0b_FgQVO-6QcAk';
+
+        async function test() {
+            console.log('🔍 Testing product loading...\n');
+
+            const response = await fetch(
+                `${SUPABASE_URL}/rest/v1/grasscloth_products?sku=ilike.GRS-%25&image_url=ilike.%25fypvpcxyfmmdhtklgbrt.supabase.co%25&order=sku.asc&limit=10`,
+                {
+                    headers: {
+                        'apikey': SUPABASE_KEY,
+                        'Authorization': `Bearer ${SUPABASE_KEY}`
+                    }
+                }
+            );
+
+            const products = await response.json();
+
+            console.log(`✅ Loaded ${products.length} products\n`);
+            console.log('Sample products:');
+
+            products.slice(0, 5).forEach(p => {
+                console.log(`  ${p.sku}: ${p.name}`);
+                console.log(`    Image: ${p.image_url}`);
+            });
+
+            // Test image loading
+            const testImg = new Image();
+            testImg.onload = () => {
+                console.log('\n✅ Image test successful!');
+                document.body.innerHTML += '<p style="color: green;">✅ Products and images are loading correctly!</p>';
+            };
+            testImg.onerror = () => {
+                console.log('\n❌ Image test failed');
+                document.body.innerHTML += '<p style="color: red;">❌ Image loading issue</p>';
+            };
+            testImg.src = products[0].image_url;
+        }
+
+        test();
+    </script>
+</body>
+</html>
\ No newline at end of file

(oldest)  ·  back to Grassclothwallpaper  ·  Harden gitignore (bak/output artifacts) and add null/error g 6067fde →