Got a project in mind? Fill in your details below and one of our team will be in touch. Fields marked * are required.
// ─── Bot Protection Setup ──────────────────────────────────────
const formLoadTime = Date.now(); // Track when form loaded for timing check
// ─── File Upload ───────────────────────────────────────────────
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const fileList = document.getElementById('fileList');
let selectedFiles = [];
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('dragover');
addFiles(Array.from(e.dataTransfer.files));
});
fileInput.addEventListener('change', () => {
addFiles(Array.from(fileInput.files));
fileInput.value = '';
});
function addFiles(newFiles) {
newFiles.forEach(f => {
if (!selectedFiles.find(x => x.name === f.name && x.size === f.size)) {
selectedFiles.push(f);
}
});
renderFileList();
}
function renderFileList() {
fileList.innerHTML = '';
selectedFiles.forEach((f, i) => {
const item = document.createElement('div');
item.className = 'file-item';
item.innerHTML = `
${f.name}
${formatSize(f.size)}
`;
fileList.appendChild(item);
});
fileList.querySelectorAll('.file-remove').forEach(btn => {
btn.addEventListener('click', () => {
selectedFiles.splice(parseInt(btn.dataset.index), 1);
renderFileList();
});
});
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B'; if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / 1048576).toFixed(1) + ' MB'; } // ─── Address Autocomplete (Google Places) ───────────────────── // NOTE: Replace YOUR_API_KEY below with your Google Maps API key // Get one free at: console.cloud.google.com → Maps JavaScript API const GOOGLE_MAPS_API_KEY = 'AIzaSyCuZJ_Zq2bIXz8B2Uyzzab2bd7ZB_udY7Q'; let placesService = null; let autocompleteService = null; let sessionToken = null; function loadGoogleMaps() { if (!GOOGLE_MAPS_API_KEY || GOOGLE_MAPS_API_KEY === 'YOUR_API_KEY') { console.warn('Google Maps API key not set — address autocomplete disabled. Manual entry still works.'); return; } const script = document.createElement('script'); script.src = `https://maps.googleapis.com/maps/api/js?key=${GOOGLE_MAPS_API_KEY}&libraries=places&callback=initAutocomplete`; script.async = true; document.head.appendChild(script); } window.initAutocomplete = function() { autocompleteService = new google.maps.places.AutocompleteService(); placesService = new google.maps.places.PlacesService(document.createElement('div')); sessionToken = new google.maps.places.AutocompleteSessionToken(); } const addressInput = document.getElementById('siteAddress'); const autocompleteList = document.getElementById('autocomplete-list'); let selectedPlaceId = null; addressInput.addEventListener('input', debounce(() => {
const val = addressInput.value.trim();
selectedPlaceId = null;
if (!val || !autocompleteService) { autocompleteList.classList.remove('open'); return; }
autocompleteService.getPlacePredictions({
input: val,
sessionToken,
componentRestrictions: { country: 'au' },
types: ['address']
}, (predictions, status) => {
autocompleteList.innerHTML = '';
if (status !== google.maps.places.PlacesServiceStatus.OK || !predictions) {
autocompleteList.classList.remove('open');
return;
}
predictions.slice(0, 5).forEach(p => {
const item = document.createElement('div');
item.className = 'autocomplete-item';
item.innerHTML = `
`;
item.addEventListener('mousedown', e => {
e.preventDefault();
addressInput.value = p.description;
selectedPlaceId = p.place_id;
autocompleteList.classList.remove('open');
sessionToken = new google.maps.places.AutocompleteSessionToken();
});
autocompleteList.appendChild(item);
});
autocompleteList.classList.add('open');
});
}, 300));
document.addEventListener('click', e => {
if (!autocompleteList.contains(e.target) && e.target !== addressInput) {
autocompleteList.classList.remove('open');
}
});
function debounce(fn, ms) {
let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
loadGoogleMaps();
// ─── Form Validation & Submission ────────────────────────────
const form = document.getElementById('inquiryForm');
const submitBtn = document.getElementById('submitBtn');
function validateEmail(v) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); }
function setError(fieldId, errorId, condition) {
const field = document.getElementById(fieldId);
const error = document.getElementById(errorId);
if (condition) {
field.classList.add('error');
error.classList.add('visible');
return false;
} else {
field.classList.remove('error');
error.classList.remove('visible');
return true;
}
}
// Live validation
['firstName','lastName','email','phone','siteAddress','scope'].forEach(id => {document.getElementById(id).addEventListener('blur', () => validateField(id));
});
function validateField(id) {
const val = document.getElementById(id).value.trim();
switch(id) {
case 'firstName': return setError('firstName','firstNameError', !val);
case 'lastName': return setError('lastName','lastNameError', !val);
case 'email': return setError('email','emailError', !validateEmail(val));
case 'phone': return setError('phone','phoneError', !val);
case 'siteAddress': return setError('siteAddress','siteAddressError', !val);
case 'scope': return setError('scope','scopeError', !val && !voiceBlob); // optional if voice memo recorded
}
}
form.addEventListener('submit', async e => {
e.preventDefault();
// ── Bot Check 1: Honeypot ──────────────────────────────────
// Real users never see or fill this field — bots always do
const honeypot = document.getElementById('website').value;
if (honeypot) {
// Silently fail — don't tell the bot it was caught
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
document.getElementById('formContent').classList.add('hidden');
const sc1 = document.getElementById('successCard');
sc1.style.setProperty('display', 'block', 'important');
sc1.classList.add('visible');
return;
}
// ── Bot Check 2: Timing ────────────────────────────────────
// Real humans take at least 5 seconds to fill a form; bots are instant
const elapsed = (Date.now() - formLoadTime) / 1000;
if (elapsed < 5) { console.warn('Submission too fast — likely bot. Elapsed:', elapsed + 's'); submitBtn.classList.remove('loading'); submitBtn.disabled = false; document.getElementById('formContent').classList.add('hidden'); const sc2 = document.getElementById('successCard'); sc2.style.setProperty('display', 'block', 'important'); sc2.classList.add('visible'); return; } const fields = ['firstName','lastName','email','phone','siteAddress','scope']; const valid = fields.map(id => validateField(id)).every(Boolean);
if (!valid) {
const firstError = form.querySelector('.error');
if (firstError) firstError.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
// Show loading
submitBtn.classList.add('loading');
submitBtn.disabled = true;
// Build form data
const firstName = document.getElementById('firstName').value.trim();
const lastName = document.getElementById('lastName').value.trim();
const email = document.getElementById('email').value.trim();
const phone = document.getElementById('phone').value.trim();
const siteAddress = document.getElementById('siteAddress').value.trim();
const scope = document.getElementById('scope').value.trim();
const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(siteAddress)}`;
const companyName = document.getElementById('companyName').value.trim();
// ── WEB3FORMS SUBMISSION ─────────────────────────────────
// TO SWAP EMAIL LATER: change the access_key value below only
const WEB3FORMS_KEY = '0b8a9e53-96e6-4ae6-ae7b-b9aa70412816';
syncWeb3Meta();
const fullName = document.getElementById('web3Name').value || `${firstName} ${lastName}`.trim() || 'Website Enquiry';
const formData = new FormData(form);
formData.set('access_key', WEB3FORMS_KEY);
formData.set('subject', fullName ? `New Milestone Enquiry - ${fullName}` : 'New Milestone Enquiry');
formData.set('from_name', fullName);
formData.set('name', fullName);
formData.set('replyto', email);
formData.set('message', scope || (voiceBlob ? 'Voice memo recorded by user.' : 'No scope provided.'));
formData.set('First_Name', firstName);
formData.set('Last_Name', lastName);
formData.set('Full_Name', fullName);
formData.set('Company', companyName || 'Not provided');
formData.set('Email', email);
formData.set('Phone', phone);
formData.set('Site_Address', siteAddress);
formData.set('Maps_Link', mapsUrl);
formData.set('Scope_of_Works', scope || 'Provided via voice memo');
formData.set('Has_Voice_Memo', voiceBlob ? 'Yes' : 'No');
formData.set('File_Count', String(selectedFiles.length));
formData.set('Submitted_At', new Date().toISOString());
formData.set('Form_Elapsed_Seconds', String(Math.round(elapsed)));
formData.set('botcheck', '');
try {
const response = await fetch('https://api.web3forms.com/submit', {
method: 'POST',
headers: { 'Accept': 'application/json' },
body: formData
});
const result = await response.json();
if (!result.success) throw new Error(result.message || 'Web3Forms error');
// Show success
const sc = document.getElementById('successCard');
sc.style.removeProperty('display');
sc.style.setProperty('display', 'block', 'important');
document.getElementById('formContent').classList.add('hidden');
sc.classList.add('visible');
window.scrollTo({ top: 0, behavior: 'smooth' });
} catch (err) {
console.error('Submission error:', err);
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
alert('Something went wrong submitting your enquiry. Please try again or give us a call directly.');
}
});
// ─── Voice Memo ────────────────────────────────────────────────────────────
let mediaRecorder = null;
let audioChunks = [];
let voiceBlob = null;
const recordBtn = document.getElementById('recordBtn');
const recordLabel = document.getElementById('recordLabel');
const voiceStatus = document.getElementById('voiceStatus');
const voicePlayback = document.getElementById('voicePlayback');
const voiceClearBtn = document.getElementById('voiceClearBtn');
recordBtn.addEventListener('click', async () => {
if (mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioChunks = [];
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = e => audioChunks.push(e.data);
mediaRecorder.onstop = () => {
voiceBlob = new Blob(audioChunks, { type: 'audio/webm' });
const url = URL.createObjectURL(voiceBlob);
voicePlayback.src = url;
voicePlayback.style.display = 'block';
voiceClearBtn.style.display = 'inline-block';
voiceStatus.textContent = 'Recording saved — play it back above.';
recordBtn.classList.remove('recording');
recordLabel.textContent = 'Record Again';
stream.getTracks().forEach(t => t.stop());
// Scope no longer required — show hint and clear any existing error
document.getElementById('scopeReq').style.display = 'none';
document.getElementById('scopeOptLabel').style.display = 'inline';
setError('scope', 'scopeError', false);
};
mediaRecorder.start();
recordBtn.classList.add('recording');
recordLabel.textContent = 'Stop Recording';
voiceStatus.textContent = '● Recording…';
} catch (err) {
voiceStatus.textContent = 'Microphone access was denied — please type your scope instead.';
}
});
voiceClearBtn.addEventListener('click', () => {
voiceBlob = null;
voicePlayback.style.display = 'none';
voicePlayback.src = '';
voiceClearBtn.style.display = 'none';
voiceStatus.textContent = '';
recordLabel.textContent = 'Tap to Record';
recordBtn.classList.remove('recording');
// Restore scope as required
document.getElementById('scopeReq').style.display = 'inline';
document.getElementById('scopeOptLabel').style.display = 'none';
});

