← back to Grassclothwallpaper
initial scaffold
a59a2f44b39dcd7e02f9adf4af54345a2938ed71 · 2026-05-13 05:27:35 -0700 · Steve
Files touched
A .gitignoreA crop-xgr-6201-bottom.jsA execute-sql-setup.jsA extract-and-save-colors.jsA force-crop-all-grs.jsA migrate-to-supabase-storage.jsA process-grs-20385-first.jsA supabase-setup.sqlA test-new-grid.html
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 →