See Your Artwork in the Perfect Frame
Upload your own artwork or photograph and experiment with different
frames, mounts and finishes. When you find a combination you love,
send us your design and we’ll help you create the finished piece.
Request a Framing Quote
your enquiry.
/* ============================================================ FRAME DATA ============================================================ */
const defaultFrames = [ { id: "oak", name: "Classic Oak", css: "linear-gradient(135deg, #a97843, #d1a66d 30%, #8c5d31 60%, #bc8950)" }, { id: "dark-oak", name: "Dark Oak", css: "linear-gradient(135deg, #39291e, #6b4930 35%, #241a14 70%, #533725)" }, { id: "black", name: "Classic Black", css: "linear-gradient(135deg, #111, #333 35%, #080808 70%, #292929)" }, { id: "white", name: "Soft White", css: "linear-gradient(135deg, #f5f4ef, #ffffff 35%, #d7d5cf 70%, #f2f1ed)" }, { id: "gold", name: "Antique Gold", css: "linear-gradient(135deg, #806b36, #d0b766 35%, #806c35 70%, #b39a51)" }, { id: "silver", name: "Silver", css: "linear-gradient(135deg, #777, #e0e0e0 35%, #8d8d8d 70%, #c9c9c9)" } ];
const defaultMounts = [ { id: "white", name: "Warm White", colour: "#f4f1e8" }, { id: "pure-white", name: "Pure White", colour: "#ffffff" }, { id: "cream", name: "Cream", colour: "#e9dfc9" }, { id: "grey", name: "Soft Grey", colour: "#b9b8b3" }, { id: "black", name: "Black", colour: "#1d1d1d" }, { id: "charcoal", name: "Charcoal", colour: "#454442" } ];
const STORAGE_KEY = "phoenix-home-frame-visualiser-options-v1";
function cloneOptions(value) { return JSON.parse(JSON.stringify(value)); }
function loadOptions() { try { const saved = JSON.parse(localStorage.getItem(STORAGE_KEY)); if ( saved && Array.isArray(saved.frames) && saved.frames.length && Array.isArray(saved.mounts) && saved.mounts.length ) { return saved; } } catch (error) { console.warn("Could not load saved visualiser options.", error); }
return { frames: cloneOptions(defaultFrames), mounts: cloneOptions(defaultMounts) }; }
const options = loadOptions(); let frames = options.frames; let mounts = options.mounts;
function saveOptions() { localStorage.setItem( STORAGE_KEY, JSON.stringify({ frames, mounts }) ); }
function makeId(prefix, name) { const slug = name .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") || "option";
let id = `${prefix}-${slug}`; let number = 2;
while ( frames.some(item => item.id === id) || mounts.some(item => item.id === id) ) { id = `${prefix}-${slug}-${number++}`; }
return id; }
/* ============================================================ STATE ============================================================ */
let selectedFrame = frames[0]; let selectedMount = mounts[0]; let selectedGlazing = "none";
let zoom = 1;
let artworkX = 50; let artworkY = 50;
let designNumber = "AF-" + Math.floor(10000 + Math.random() * 90000);
/* ============================================================ DOM ============================================================ */
const artwork = document.getElementById("artwork");
const frameOuter = document.getElementById("frameOuter");
const mount = document.getElementById("mount");
const glazing = document.getElementById("glazing");
const frameChoices = document.getElementById("frameChoices");
const mountChoices = document.getElementById("mountChoices");
const summary = document.getElementById("summary");
const imageUpload = document.getElementById("imageUpload");
const zoomInput = document.getElementById("zoom");
const zoomLabel = document.getElementById("zoomLabel");
const mountWidth = document.getElementById("mountWidth");
const mountWidthLabel = document.getElementById("mountWidthLabel");
const glazingSelect = document.getElementById("glazingSelect");
const frameEditorList = document.getElementById("frameEditorList");
const mountEditorList = document.getElementById("mountEditorList");
const newFrameName = document.getElementById("newFrameName");
const newFrameCSS = document.getElementById("newFrameCSS");
const newMountName = document.getElementById("newMountName");
const newMountColour = document.getElementById("newMountColour");
const optionsFileInput = document.getElementById("optionsFileInput");
/* ============================================================ VISUALISER EDITOR ============================================================ */
function renderEditorLists() { frameEditorList.innerHTML = "";
frames.forEach(frame => { const row = document.createElement("div"); row.className = "editor-item"; row.innerHTML = `
`;
row.querySelector(".editor-item-swatch").style.background = frame.css; row.querySelector(".editor-item-name").textContent = frame.name; row.querySelector(".editor-item-id").textContent = frame.id;
row.querySelector(".edit-frame").onclick = () => { const name = prompt("Frame name:", frame.name); if (name === null) return;
const css = prompt("CSS background:", frame.css); if (css === null) return;
const cleanName = name.trim(); const cleanCSS = css.trim();
if (!cleanName || !cleanCSS) { alert("Please provide both a frame name and CSS background."); return; }
frame.name = cleanName; frame.css = cleanCSS;
if (selectedFrame.id === frame.id) { selectedFrame = frame; }
saveOptions(); renderFrames(); renderEditorLists(); updateVisualiser(); };
row.querySelector(".delete-frame").onclick = () => { if (frames.length <= 1) { alert("You need at least one frame choice."); return; } if (!confirm(`Delete "${frame.name}"?`)) return; const wasSelected = selectedFrame.id === frame.id; frames = frames.filter(item => item.id !== frame.id);
if (wasSelected) { selectedFrame = frames[0]; }
saveOptions(); renderFrames(); renderEditorLists(); updateVisualiser(); };
frameEditorList.appendChild(row); });
mountEditorList.innerHTML = "";
mounts.forEach(item => { const row = document.createElement("div"); row.className = "editor-item"; row.innerHTML = `
`;
row.querySelector(".editor-item-swatch").style.background = item.colour; row.querySelector(".editor-item-name").textContent = item.name; row.querySelector(".editor-item-id").textContent = item.id;
row.querySelector(".edit-mount").onclick = () => { const name = prompt("Mount name:", item.name); if (name === null) return;
const colour = prompt("Mount colour or CSS background:", item.colour); if (colour === null) return;
const cleanName = name.trim(); const cleanColour = colour.trim();
if (!cleanName || !cleanColour) { alert("Please provide both a mount name and colour."); return; }
item.name = cleanName; item.colour = cleanColour;
if (selectedMount.id === item.id) { selectedMount = item; }
saveOptions(); renderMounts(); renderEditorLists(); updateVisualiser(); };
row.querySelector(".delete-mount").onclick = () => { if (mounts.length <= 1) { alert("You need at least one mount choice."); return; } if (!confirm(`Delete "${item.name}"?`)) return; const wasSelected = selectedMount.id === item.id; mounts = mounts.filter(mount => mount.id !== item.id);
if (wasSelected) { selectedMount = mounts[0]; }
saveOptions(); renderMounts(); renderEditorLists(); updateVisualiser(); };
mountEditorList.appendChild(row); }); }
document.querySelectorAll(".editor-tab").forEach(tab => { tab.addEventListener("click", () => { document.querySelectorAll(".editor-tab").forEach(item => { item.classList.remove("active"); });
document.querySelectorAll(".editor-pane").forEach(item => { item.classList.remove("active"); });
tab.classList.add("active");
const paneId = tab.dataset.editorTab === "frames" ? "frameEditorPane" : "mountEditorPane";
document.getElementById(paneId).classList.add("active"); }); });
document.getElementById("addFrameButton").addEventListener("click", () => { const name = newFrameName.value.trim(); const css = newFrameCSS.value.trim();
if (!name || !css) { alert("Enter a frame name and CSS background first."); return; }
const frame = { id: makeId("frame", name), name, css };
frames.push(frame); selectedFrame = frame;
newFrameName.value = ""; newFrameCSS.value = "";
saveOptions(); renderFrames(); renderEditorLists(); updateVisualiser(); });
document.getElementById("addMountButton").addEventListener("click", () => { const name = newMountName.value.trim(); const colour = newMountColour.value.trim();
if (!name || !colour) { alert("Enter a mount name and colour first."); return; }
const mountItem = { id: makeId("mount", name), name, colour };
mounts.push(mountItem); selectedMount = mountItem;
newMountName.value = ""; newMountColour.value = "";
saveOptions(); renderMounts(); renderEditorLists(); updateVisualiser(); });
document.getElementById("exportOptionsButton").addEventListener("click", () => { const payload = JSON.stringify({ frames, mounts }, null, 2); const blob = new Blob([payload], { type: "application/json" }); const url = URL.createObjectURL(blob); const link = document.createElement("a");
link.href = url; link.download = "frame-visualiser-options.json"; link.click();
URL.revokeObjectURL(url); });
document.getElementById("importOptionsButton").addEventListener("click", () => { optionsFileInput.click(); });
optionsFileInput.addEventListener("change", event => { const file = event.target.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = () => { try { const imported = JSON.parse(reader.result);
if ( !Array.isArray(imported.frames) || !imported.frames.length || !Array.isArray(imported.mounts) || !imported.mounts.length ) { throw new Error("Invalid option file."); }
frames = imported.frames; mounts = imported.mounts; selectedFrame = frames[0]; selectedMount = mounts[0];
saveOptions(); renderFrames(); renderMounts(); renderEditorLists(); updateVisualiser();
alert("Visualiser options imported successfully."); } catch (error) { alert("Could not import that file. Please use a valid visualiser JSON export."); }
optionsFileInput.value = ""; };
reader.readAsText(file); });
document.getElementById("restoreOptionsButton").addEventListener("click", () => { if (!confirm("Restore the original frame and mount choices? Custom choices will be removed from this browser.")) { return; }
frames = cloneOptions(defaultFrames); mounts = cloneOptions(defaultMounts); selectedFrame = frames[0]; selectedMount = mounts[0];
saveOptions(); renderFrames(); renderMounts(); renderEditorLists(); updateVisualiser(); });
/* ============================================================ RENDER FRAME OPTIONS ============================================================ */
function renderFrames() {
frameChoices.innerHTML = "";
frames.forEach(frame => {
const button = document.createElement("button");
button.type = "button"; button.className = "choice" + (frame.id === selectedFrame.id ? " active" : "");
button.innerHTML = `
`;
button.onclick = () => {
selectedFrame = frame;
renderFrames(); updateVisualiser();
};
frameChoices.appendChild(button);
}); }
/* ============================================================ RENDER MOUNTS ============================================================ */
function renderMounts() {
mountChoices.innerHTML = "";
mounts.forEach(item => {
const button = document.createElement("button");
button.type = "button"; button.className = "choice mount-choice" + (item.id === selectedMount.id ? " active" : "");
button.innerHTML = `
`;
button.onclick = () => {
selectedMount = item;
renderMounts(); updateVisualiser();
};
mountChoices.appendChild(button);
}); }
/* ============================================================ UPDATE VISUALISER ============================================================ */
function updateVisualiser() {
frameOuter.style.background = selectedFrame.css;
mount.style.background = selectedMount.colour;
mount.style.padding = mountWidth.value + "%";
artwork.style.transform = ` translate( calc(-50% + ${(artworkX - 50) * 1}%), calc(-50% + ${(artworkY - 50) * 1}%) ) scale(${zoom}) `;
glazing.className = "glazing " + ( selectedGlazing === "none" ? "" : selectedGlazing );
mountWidthLabel.textContent = mountWidth.value + "%";
zoomLabel.textContent = Math.round(zoom * 100) + "%";
updateSummary(); }
/* ============================================================ SUMMARY ============================================================ */
function updateSummary() {
const glazingName = glazingSelect.options[ glazingSelect.selectedIndex ].text;
summary.innerHTML = ` Design: ${designNumber}
Frame: ${selectedFrame.name}
Mount: ${selectedMount.name}
Mount width: ${mountWidth.value}%
Glazing: ${glazingName} `; }
/* ============================================================ IMAGE UPLOAD ============================================================ */
imageUpload.addEventListener( "change", function(event) {
const file = event.target.files[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
alert( "Please choose an image file." );
return; }
const reader = new FileReader();
reader.onload = function(e) {
artwork.src = e.target.result;
artworkX = 50; artworkY = 50; zoom = 1;
zoomInput.value = 100;
updateVisualiser();
};
reader.readAsDataURL(file); } );
/* ============================================================ ZOOM ============================================================ */
zoomInput.addEventListener( "input", function() {
zoom = Number(this.value) / 100;
updateVisualiser();
} );
/* ============================================================ MOUNT WIDTH ============================================================ */
mountWidth.addEventListener( "input", updateVisualiser );
/* ============================================================ GLAZING ============================================================ */
glazingSelect.addEventListener( "change", function() {
selectedGlazing = this.value;
updateVisualiser();
} );
/* ============================================================ DRAG ARTWORK ============================================================ */
let dragging = false; let startX = 0; let startY = 0;
const artworkContainer = document.querySelector(".artwork-container");
artworkContainer.addEventListener("pointerdown", event => { if (!artwork.src) return;
dragging = true; startX = event.clientX; startY = event.clientY; artworkContainer.setPointerCapture(event.pointerId); });
artworkContainer.addEventListener("pointermove", event => { if (!dragging) return;
const dx = event.clientX - startX; const dy = event.clientY - startY;
artworkX = Math.max(20, Math.min(80, artworkX + dx / 4)); artworkY = Math.max(20, Math.min(80, artworkY + dy / 4));
startX = event.clientX; startY = event.clientY;
updateVisualiser(); });
artworkContainer.addEventListener("pointerup", () => { dragging = false; });
artworkContainer.addEventListener("pointercancel", () => { dragging = false; });
/* ============================================================ DOWNLOAD / SAVE DESIGN ============================================================ */
document .getElementById("downloadButton") .addEventListener( "click", function() {
/* For a production version, this should use a proper canvas renderer so the frame, mount and artwork are exported together.
This prototype opens a printable version. */
const printWindow = window.open( "", "_blank" );
const frameStyle = selectedFrame.css;
printWindow.document.write(`
Art & Framing at Phoenix Home
Frame Visualiser Design ${designNumber}
Frame: ${selectedFrame.name}
Mount: ${selectedMount.name}
`);
printWindow.document.close();
} );
/* ============================================================ QUOTE MODAL ============================================================ */
const quoteModal = document.getElementById("quoteModal");
document .getElementById("quoteButton") .addEventListener( "click", function() {
document.getElementById( "modalSummary" ).innerHTML = summary.innerHTML;
quoteModal.classList.add( "open" );
} );
document .getElementById("closeModal") .addEventListener( "click", function() {
quoteModal.classList.remove( "open" );
} );
quoteModal.addEventListener( "click", function(event) {
if ( event.target === quoteModal ) {
quoteModal.classList.remove( "open" );
}
} );
/* ============================================================ QUOTE FORM ============================================================ */
document .getElementById("quoteForm") .addEventListener( "submit", function(event) {
event.preventDefault();
const name = document.getElementById( "customerName" ).value;
const email = document.getElementById( "customerEmail" ).value;
const phone = document.getElementById( "customerPhone" ).value;
const size = document.getElementById( "artworkSize" ).value;
const message = document.getElementById( "customerMessage" ).value;
/* Prototype only: In production this information should be sent to WordPress/WooCommerce or your email system via a secure backend. */
alert( "Thank you, " + name + ". Your design " + designNumber + " has been prepared for enquiry." );
console.log({
designNumber,
customer: { name, email, phone },
artworkSize: size,
message,
framing: {
frame: selectedFrame.name,
mount: selectedMount.name,
mountWidth: mountWidth.value,
glazing: glazingSelect.options[ glazingSelect.selectedIndex ].text
}
});
quoteModal.classList.remove( "open" );
} );
/* ============================================================ RESET ============================================================ */
document .getElementById("resetButton") .addEventListener( "click", function() {
artwork.src = "";
selectedFrame = frames[0];
selectedMount = mounts[0];
selectedGlazing = "none";
glazingSelect.value = "none";
zoom = 1;
zoomInput.value = 100;
mountWidth.value = 8;
artworkX = 50;
artworkY = 50;
designNumber = "AF-" + Math.floor( 10000 + Math.random() * 90000 );
renderFrames(); renderMounts(); renderEditorLists(); updateVisualiser();
} );
/* ============================================================ INITIALISE ============================================================ */
renderFrames(); renderMounts(); renderEditorLists(); updateVisualiser();
