Add premium configurator with split-screen layout
- Redesigned configurator page with split-screen interface - Left: Large visual preview with sticky positioning - Right: Premium white controls container with form steps - Added complete configurator wizard (5 steps) - Updated hero CTA to "Zelf ontwerpen" - Configured Shadcn UI with Slate theme - Added layout components (Navbar, Footer) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
87
scripts/get-images.js
Normal file
87
scripts/get-images.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const https = require("https");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const outDir = path.join(__dirname, "..", "public", "images");
|
||||
|
||||
const images = [
|
||||
{
|
||||
// Wide cinematic hero shot from homepage
|
||||
url: "https://static.aluwdoors.com/site/uploads/2025/07/01.jpg",
|
||||
name: "hero.jpg",
|
||||
},
|
||||
{
|
||||
// Stalen taatsdeur in brons - portrait shot
|
||||
url: "https://static.aluwdoors.com/site/uploads/2025/08/Stalen-dubbele-taatsdeur-in-het-brons-368x460.jpg",
|
||||
name: "taats.jpg",
|
||||
},
|
||||
{
|
||||
// Zwarte stalen scharnierdeur - portrait shot
|
||||
url: "https://static.aluwdoors.com/site/uploads/2025/07/Zwarte-stalen-scharnierdeur-1-768x960.jpg",
|
||||
name: "scharnier.jpg",
|
||||
},
|
||||
{
|
||||
// Bronzen schuifdeur (paneel/fixed panel) from homepage
|
||||
url: "https://static.aluwdoors.com/site/uploads/2025/10/Bronzen-schuifdeur-tussen-woonkamer-en-keuken-1.jpg",
|
||||
name: "paneel.jpg",
|
||||
},
|
||||
{
|
||||
// Interior shot for about section
|
||||
url: "https://static.aluwdoors.com/site/uploads/2025/07/02.jpg",
|
||||
name: "about.jpg",
|
||||
},
|
||||
];
|
||||
|
||||
function download(url, dest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsedUrl = new URL(url);
|
||||
const options = {
|
||||
hostname: parsedUrl.hostname,
|
||||
path: parsedUrl.pathname + parsedUrl.search,
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||
Accept: "image/*,*/*",
|
||||
Referer: "https://www.aluwdoors.com/",
|
||||
},
|
||||
};
|
||||
|
||||
https
|
||||
.get(options, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
download(res.headers.location, dest).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${res.statusCode} for ${url}`));
|
||||
return;
|
||||
}
|
||||
const file = fs.createWriteStream(dest);
|
||||
res.pipe(file);
|
||||
file.on("finish", () => {
|
||||
file.close();
|
||||
const size = fs.statSync(dest).size;
|
||||
console.log(
|
||||
` OK: ${path.basename(dest).padEnd(16)} ${(size / 1024).toFixed(0).padStart(5)} KB`
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
})
|
||||
.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Downloading images from aluwdoors.com...\n");
|
||||
for (const img of images) {
|
||||
const dest = path.join(outDir, img.name);
|
||||
try {
|
||||
await download(img.url, dest);
|
||||
} catch (err) {
|
||||
console.log(` FAIL: ${img.name} - ${err.message}`);
|
||||
}
|
||||
}
|
||||
console.log("\nDone.");
|
||||
}
|
||||
|
||||
main();
|
||||
259
scripts/scrape-assets.ts
Normal file
259
scripts/scrape-assets.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Scrape product images from proinn.nl for local development.
|
||||
*
|
||||
* Usage: npx tsx scripts/scrape-assets.ts
|
||||
*
|
||||
* Downloads hero, product, and detail images into public/images/.
|
||||
* Falls back to placeholder files with instructions if scraping fails.
|
||||
*/
|
||||
|
||||
import axios from "axios";
|
||||
import * as cheerio from "cheerio";
|
||||
import { writeFileSync, mkdirSync, existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
const BASE = "https://www.proinn.nl";
|
||||
const OUT_DIR = join(__dirname, "..", "public", "images");
|
||||
|
||||
const UA =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
||||
|
||||
const http = axios.create({
|
||||
headers: { "User-Agent": UA },
|
||||
timeout: 15_000,
|
||||
maxRedirects: 5,
|
||||
});
|
||||
|
||||
// Ensure output directory exists
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────
|
||||
function abs(url: string): string {
|
||||
if (url.startsWith("//")) return "https:" + url;
|
||||
if (url.startsWith("/")) return BASE + url;
|
||||
if (url.startsWith("http")) return url;
|
||||
return BASE + "/" + url;
|
||||
}
|
||||
|
||||
async function download(url: string, filename: string): Promise<boolean> {
|
||||
const dest = join(OUT_DIR, filename);
|
||||
try {
|
||||
console.log(` ↓ ${url}`);
|
||||
const res = await http.get(url, { responseType: "arraybuffer" });
|
||||
writeFileSync(dest, res.data);
|
||||
const kb = Math.round(Buffer.byteLength(res.data) / 1024);
|
||||
console.log(` ✓ Saved ${filename} (${kb} KB)`);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
console.log(` ✗ Failed: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writePlaceholder(filename: string) {
|
||||
const dest = join(OUT_DIR, filename);
|
||||
if (!existsSync(dest)) {
|
||||
writeFileSync(
|
||||
dest,
|
||||
`Drag a real image here to replace this placeholder.\nExpected: ${filename}\nSource: ${BASE}\n`
|
||||
);
|
||||
console.log(` → Created placeholder: ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Pick the largest image from a set of candidates (by URL heuristic or data-src)
|
||||
function pickBest(srcs: string[]): string | undefined {
|
||||
// Prefer larger sizes: sort descending by any numeric dimension in URL
|
||||
return srcs.sort((a, b) => {
|
||||
const numA = [...a.matchAll(/(\d{3,4})/g)].map(Number).sort((x, y) => y - x)[0] ?? 0;
|
||||
const numB = [...b.matchAll(/(\d{3,4})/g)].map(Number).sort((x, y) => y - x)[0] ?? 0;
|
||||
return numB - numA;
|
||||
})[0];
|
||||
}
|
||||
|
||||
// ─── Main ───────────────────────────────────────────────────
|
||||
async function main() {
|
||||
console.log("Scraping proinn.nl for assets…\n");
|
||||
|
||||
// ── Fetch homepage ──────────────────────────────────────
|
||||
let $home: cheerio.CheerioAPI;
|
||||
try {
|
||||
const { data } = await http.get(BASE);
|
||||
$home = cheerio.load(data);
|
||||
console.log("✓ Homepage loaded\n");
|
||||
} catch (err: any) {
|
||||
console.log(`✗ Could not load homepage: ${err.message}`);
|
||||
console.log(" Creating placeholder files instead.\n");
|
||||
["hero.jpg", "taats.jpg", "scharnier.jpg", "paneel.jpg", "about.jpg"].forEach(writePlaceholder);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Target 1: Hero / slider image ───────────────────────
|
||||
console.log("1) Hero image");
|
||||
let heroOk = false;
|
||||
// Look for common slider/banner patterns
|
||||
const heroSelectors = [
|
||||
".hero img",
|
||||
".banner img",
|
||||
".slider img",
|
||||
".swiper img",
|
||||
".carousel img",
|
||||
'[class*="hero"] img',
|
||||
'[class*="banner"] img',
|
||||
'[class*="slider"] img',
|
||||
"header img",
|
||||
".header-image img",
|
||||
// Lightspeed specific
|
||||
".homepage-slider img",
|
||||
".slideshow img",
|
||||
"#slideshow img",
|
||||
".rslides img",
|
||||
// Fallback: first large image on page
|
||||
];
|
||||
|
||||
for (const sel of heroSelectors) {
|
||||
const imgs = $home(sel);
|
||||
if (imgs.length) {
|
||||
const srcs: string[] = [];
|
||||
imgs.each((_, el) => {
|
||||
const s = $home(el).attr("data-src") || $home(el).attr("src");
|
||||
if (s) srcs.push(abs(s));
|
||||
});
|
||||
const best = pickBest(srcs);
|
||||
if (best) {
|
||||
heroOk = await download(best, "hero.jpg");
|
||||
if (heroOk) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broader fallback: any large image
|
||||
if (!heroOk) {
|
||||
const allImgs: string[] = [];
|
||||
$home("img").each((_, el) => {
|
||||
const s = $home(el).attr("data-src") || $home(el).attr("src");
|
||||
if (s && !s.includes("logo") && !s.includes("icon") && !s.includes("svg")) {
|
||||
allImgs.push(abs(s));
|
||||
}
|
||||
});
|
||||
const best = pickBest(allImgs);
|
||||
if (best) heroOk = await download(best, "hero.jpg");
|
||||
}
|
||||
if (!heroOk) writePlaceholder("hero.jpg");
|
||||
|
||||
// ── Target 2: Product images ────────────────────────────
|
||||
console.log("\n2) Product images");
|
||||
|
||||
// Try to find product pages / links first
|
||||
const productKeywords: Record<string, string> = {
|
||||
"taats.jpg": "taats",
|
||||
"scharnier.jpg": "scharnier",
|
||||
"paneel.jpg": "paneel|vast",
|
||||
};
|
||||
|
||||
// Collect all links + images from homepage
|
||||
const pageLinks: string[] = [];
|
||||
$home("a[href]").each((_, el) => {
|
||||
const href = $home(el).attr("href");
|
||||
if (href) pageLinks.push(abs(href));
|
||||
});
|
||||
|
||||
for (const [filename, pattern] of Object.entries(productKeywords)) {
|
||||
let ok = false;
|
||||
const re = new RegExp(pattern, "i");
|
||||
|
||||
// Check if any image on homepage matches
|
||||
const matchImgs: string[] = [];
|
||||
$home("img").each((_, el) => {
|
||||
const src = $home(el).attr("data-src") || $home(el).attr("src") || "";
|
||||
const alt = $home(el).attr("alt") || "";
|
||||
const title = $home(el).attr("title") || "";
|
||||
if (re.test(src) || re.test(alt) || re.test(title)) {
|
||||
matchImgs.push(abs(src));
|
||||
}
|
||||
});
|
||||
|
||||
if (matchImgs.length) {
|
||||
const best = pickBest(matchImgs);
|
||||
if (best) ok = await download(best, filename);
|
||||
}
|
||||
|
||||
// Try product listing pages
|
||||
if (!ok) {
|
||||
const productLinks = pageLinks.filter((l) => re.test(l));
|
||||
for (const link of productLinks.slice(0, 2)) {
|
||||
try {
|
||||
const { data: html } = await http.get(link);
|
||||
const $p = cheerio.load(html);
|
||||
const imgs: string[] = [];
|
||||
$p("img").each((_, el) => {
|
||||
const s = $p(el).attr("data-src") || $p(el).attr("src");
|
||||
if (s && !s.includes("logo") && !s.includes("icon")) {
|
||||
imgs.push(abs(s));
|
||||
}
|
||||
});
|
||||
const best = pickBest(imgs);
|
||||
if (best) {
|
||||
ok = await download(best, filename);
|
||||
if (ok) break;
|
||||
}
|
||||
} catch {
|
||||
// ignore page load failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) writePlaceholder(filename);
|
||||
}
|
||||
|
||||
// ── Target 3: About / detail shot ─────────────────────
|
||||
console.log("\n3) About / detail image");
|
||||
let aboutOk = false;
|
||||
|
||||
// Check common about pages
|
||||
const aboutLinks = pageLinks.filter(
|
||||
(l) => /over-ons|about|werkplaats|atelier|contact/i.test(l)
|
||||
);
|
||||
|
||||
for (const link of aboutLinks.slice(0, 3)) {
|
||||
try {
|
||||
const { data: html } = await http.get(link);
|
||||
const $a = cheerio.load(html);
|
||||
const imgs: string[] = [];
|
||||
$a("img").each((_, el) => {
|
||||
const s = $a(el).attr("data-src") || $a(el).attr("src");
|
||||
if (s && !s.includes("logo") && !s.includes("icon")) {
|
||||
imgs.push(abs(s));
|
||||
}
|
||||
});
|
||||
const best = pickBest(imgs);
|
||||
if (best) {
|
||||
aboutOk = await download(best, "about.jpg");
|
||||
if (aboutOk) break;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: grab a distinctive image from homepage
|
||||
if (!aboutOk) {
|
||||
const candidates: string[] = [];
|
||||
$home("img").each((_, el) => {
|
||||
const s = $home(el).attr("data-src") || $home(el).attr("src");
|
||||
if (s && !s.includes("logo") && !s.includes("icon") && !s.includes("svg")) {
|
||||
candidates.push(abs(s));
|
||||
}
|
||||
});
|
||||
// Skip first (likely hero), pick second
|
||||
if (candidates.length > 1) {
|
||||
aboutOk = await download(candidates[1], "about.jpg");
|
||||
}
|
||||
}
|
||||
|
||||
if (!aboutOk) writePlaceholder("about.jpg");
|
||||
|
||||
console.log("\n── Done ──");
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
Reference in New Issue
Block a user