Skip to content

Instantly share code, notes, and snippets.

@bryanwoods
Last active March 25, 2025 16:29
Show Gist options
  • Save bryanwoods/a870bb5e5dfc2c996ca1e5024f675b61 to your computer and use it in GitHub Desktop.
Save bryanwoods/a870bb5e5dfc2c996ca1e5024f675b61 to your computer and use it in GitHub Desktop.
<!-- new flow! all client side js with cloudinary widget -->
<div class="page-width">
<div class="form-container">
<!-- Dynamic heading container that will change for each step -->
<div id="step-heading-container">
<h2 id="form-title">What's your child's name?</h2>
<h3 id="form-subtitle">This will be the name of the main character in your story</h3>
</div>
<!-- Progress bar with integrated checkmark for final step -->
<div style="position: relative; margin-bottom: 2rem;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td id="step1" style="background-color: white; height: 8px; width: 20%;"></td>
<td id="step2" style="background-color: rgba(255, 255, 255, 0.3); height: 8px; width: 20%;"></td>
<td id="step3" style="background-color: rgba(255, 255, 255, 0.3); height: 8px; width: 20%;"></td>
<td id="step4" style="background-color: rgba(255, 255, 255, 0.3); height: 8px; width: 20%;"></td>
<td id="step5" style="background-color: rgba(255, 255, 255, 0.3); height: 8px; width: 20%; position: relative;"></td>
</tr>
</table>
<!-- Checkmark positioned at the end of the progress bar -->
<div id="final-step-indicator" style="display: none; position: absolute; top: -14px; right: -14px;">
<span style="background-color: #ffffff; color: #3d3085; border-radius: 50%; width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; font-weight: bold; font-size: 16px; box-shadow: 0 2px 4px rgba(0,0,0,0.2);">✓</span>
</div>
</div>
<form id="story-builder-form" class="story-form" enctype="multipart/form-data" action="javascript:void(0);" method="post">
<div class="step-container">
<div class="form-step active" data-step="1">
<div class="form-group">
<label for="child_name">Child's Name</label>
<input type="text" id="child_name" name="properties[Child Name]" placeholder="Jesse" data-clarity-unmask="True">
</div>
<button type="button" class="continue-button">Continue</button>
</div>
<div class="form-step" data-step="2">
<div class="form-group">
<label for="age">Age</label>
<input type="number" id="age" name="properties[Age]" min="0" max="18" placeholder="2-18" data-clarity-unmask="True">
</div>
<div class="button-group">
<button type="button" class="back-button">Back</button>
<button type="button" class="continue-button">Continue</button>
</div>
</div>
<div class="form-step" data-step="3">
<div class="form-group">
<label for="hobby_1">Favorite Hobbies</label>
<input type="text" id="hobby_1" name="properties[Hobby 1]" placeholder="Drawing, acting, etc." data-clarity-unmask="True">
</div>
<div class="button-group">
<button type="button" class="back-button">Back</button>
<button type="button" class="continue-button">Continue</button>
</div>
</div>
<div class="form-step" data-step="4">
<div class="form-group">
<label for="favorite_song">Favorite Song</label>
<input type="text" id="favorite_song" name="properties[Favorite Song]" placeholder="Song Name - Artist Name" data-clarity-unmask="True">
</div>
<div class="button-group">
<button type="button" class="back-button">Back</button>
<button type="button" class="continue-button">Continue</button>
</div>
</div>
<div class="form-step" data-step="5">
<div class="form-group">
<label for="child_photo" class="pd-form__label">Photo of the child</label>
<input type="hidden" id="child_photo_url" name="properties[Child Photo URL]" data-clarity-unmask="True">
<div id="upload-container">
<button type="button" id="upload_photo_button" class="upload-button">Upload Photo</button>
<div id="upload-status" style="display: none;">
<div class="upload-feedback">
<span id="upload-message"></span>
<div id="upload-preview" style="display: none;">
<img id="preview-image" src="" alt="Preview" style="max-width: 100%; max-height: 200px; margin-top: 10px;">
</div>
</div>
</div>
</div>
<p class="hint-text">No photo right now? No problem! You can upload it later.</p>
</div>
<div class="button-group">
<button type="button" class="back-button">Back</button>
<button type="button" id="add-to-cart-button" class="add-to-cart-button">Continue</button>
</div>
</div>
</div>
</form>
</div>
</div>
<style>
.continue-button, .back-button, .submit-button, .add-to-cart-button, .upload-button {
background-color: #ffffff;
color: black;
padding: 0.75rem 1.5rem;
border: 1px solid #000;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
}
.continue-button:hover, .back-button:hover, .submit-button:hover, .add-to-cart-button:hover, .upload-button:hover {
background-color: #f0f0f0;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.continue-button:disabled, .back-button:disabled, .add-to-cart-button:disabled, .upload-button:disabled {
opacity: 0.7;
cursor: not-allowed;
background-color: #e0e0e0;
transform: none;
box-shadow: none;
}
.form-container {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
}
#step-heading-container {
margin-bottom: 2.5rem;
text-align: center;
}
#form-title {
margin-bottom: 0.75rem;
font-size: 2.8rem;
line-height: 1.2;
}
#form-subtitle {
font-weight: normal;
font-size: 1.5rem;
line-height: 1.5;
margin-top: 0;
opacity: 0.85;
letter-spacing: 0.01em;
max-width: 90%;
margin-left: auto;
margin-right: auto;
}
/* Special styling for final step */
.final-step-glow {
box-shadow: 0 0 8px 2px rgba(255, 255, 255, 0.7);
animation: pulse 2s infinite ease-in-out;
}
@keyframes pulse {
0% { box-shadow: 0 0 8px 2px rgba(255, 255, 255, 0.7); }
50% { box-shadow: 0 0 12px 4px rgba(255, 255, 255, 0.9); }
100% { box-shadow: 0 0 8px 2px rgba(255, 255, 255, 0.7); }
}
.story-form {
display: flex;
flex-direction: column;
}
.step-container {
position: relative;
overflow: hidden;
min-height: 200px;
}
.form-step {
display: none;
opacity: 0;
transform: translateY(20px);
transition: opacity 0.4s ease, transform 0.4s ease;
position: absolute;
width: 100%;
}
.form-step.active {
display: block;
opacity: 1;
transform: translateY(0);
position: relative;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.form-group label {
font-weight: 500;
}
.form-group input {
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
}
.button-group {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.continue-button, .back-button, .submit-button, .final-button, .upload-button {
background-color: #ffffff;
color: black;
padding: 0.75rem 1.5rem;
border: 1px solid #000;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
}
.continue-button:hover, .back-button:hover, .submit-button:hover, .final-button:hover, .upload-button:hover {
background-color: #f0f0f0;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.continue-button:disabled, .back-button:disabled, .final-button:disabled, .upload-button:disabled {
opacity: 0.7;
cursor: not-allowed;
background-color: #e0e0e0;
transform: none;
box-shadow: none;
}
.upload-button {
width: 100%;
margin-bottom: 10px;
}
.hint-text {
font-size: 14px;
color: #666;
margin-top: 5px;
}
.upload-feedback {
margin-top: 10px;
padding: 10px;
border-radius: 4px;
}
/* Feedback message styling */
.form-feedback-message {
margin: 10px 0;
padding: 10px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
text-align: center;
}
.error-message {
background-color: #ffebee;
color: #c62828;
border: 1px solid #ef9a9a;
}
.success-message {
background-color: #e8f5e9;
color: #2e7d32;
border: 1px solid #a5d6a7;
}
/* Better visual feedback for processing state */
.continue-button.processing, .add-to-cart-button.processing, .upload-button.processing {
position: relative;
padding-left: 2.5rem;
}
.continue-button.processing:before, .add-to-cart-button.processing:before, .upload-button.processing:before {
content: "";
position: absolute;
left: 12px;
top: 50%;
width: 18px;
height: 18px;
margin-top: -9px;
border: 2px solid rgba(0, 0, 0, 0.2);
border-top-color: #000;
border-radius: 50%;
animation: spinner 0.8s linear infinite;
}
@keyframes spinner {
to {transform: rotate(360deg);}
}
/* Focus states for better accessibility */
.continue-button:focus, .back-button:focus, .final-button:focus, input:focus, .upload-button:focus {
outline: 2px solid #3d3085;
outline-offset: 2px;
}
.input-error {
border-color: #c62828 !important;
background-color: #ffebee !important;
}
/* Responsive adjustments */
@media (max-width: 768px) {
#form-title {
font-size: 2.2rem;
}
#form-subtitle {
font-size: 1.3rem;
}
/* Make clickable areas larger on mobile */
.continue-button, .back-button, .final-button, .upload-button {
padding: 12px 24px;
font-size: 16px;
}
input {
padding: 12px;
font-size: 16px;
}
}
</style>
<!-- Include Cloudinary Upload Widget script -->
<script src="https://upload-widget.cloudinary.com/global/all.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () => {
const form = document.querySelector("form[novalidate]");
form && form.removeAttribute("novalidate");
const steps = document.querySelectorAll('.form-step');
const totalSteps = steps.length;
let currentStep = 1;
const stepPaths = [
'childs-name',
'childs-age',
'favorite-hobby-1',
'favorite-song',
'childs-photo'
];
// Define step-specific headings and subheadings
const stepHeadings = {
1: {
title: "Tell us about the hero of your story!",
subtitle: "Enter the child's first name and we'll make them the main character."
},
2: {
title: "How old is the child?",
subtitle: "We'll tailor the adventure to match their age for the perfect fit!"
},
3: {
title: "What's their favorite hobby?",
subtitle: "Their favorite hobby becomes part of the story—making it even more special!"
},
4: {
title: "What's your child's favorite song?",
subtitle: "A song they love makes their adventure even more personal!"
},
5: {
title: "Upload a photo",
subtitle: "No photo right now? No problem! You can upload it later. We use their photo to create a character that looks just like them!"
}
};
// Set up Cloudinary widget
const cloudinaryWidget = cloudinary.createUploadWidget(
{
cloudName: 'hsloxib24',
uploadPreset: 'customer_photos', // Your Cloudinary collection name
sources: ['local', 'camera', 'url'],
multiple: false,
maxFiles: 1,
resourceType: 'image',
styles: {
palette: {
window: "#FFFFFF",
windowBorder: "#90A0B3",
tabIcon: "#0078FF",
menuIcons: "#5A616A",
textDark: "#000000",
textLight: "#FFFFFF",
link: "#0078FF",
action: "#FF620C",
inactiveTabIcon: "#0E2F5A",
error: "#F44235",
inProgress: "#0078FF",
complete: "#20B832",
sourceBg: "#E4EBF1"
}
}
},
(error, result) => {
const uploadButton = document.getElementById('upload_photo_button');
const uploadStatus = document.getElementById('upload-status');
const uploadMessage = document.getElementById('upload-message');
const uploadPreview = document.getElementById('upload-preview');
const previewImage = document.getElementById('preview-image');
const photoUrlInput = document.getElementById('child_photo_url');
if (error) {
// Handle error
uploadStatus.style.display = 'block';
uploadMessage.textContent = 'Upload failed: ' + error.message;
uploadMessage.style.color = '#c62828';
// Re-enable upload button
uploadButton.disabled = false;
uploadButton.textContent = 'Try Again';
uploadButton.classList.remove('processing');
console.error('Cloudinary upload error:', error);
return;
}
if (result.event === 'success') {
// Handle successful upload
const secureUrl = result.info.secure_url;
// Store URL in hidden input
photoUrlInput.value = secureUrl;
// Update UI to show success
uploadStatus.style.display = 'block';
uploadMessage.textContent = 'Photo uploaded successfully!';
uploadMessage.style.color = '#2e7d32';
// Show preview
previewImage.src = secureUrl;
uploadPreview.style.display = 'block';
// Change button text
uploadButton.textContent = 'Change Photo';
uploadButton.disabled = false;
uploadButton.classList.remove('processing');
console.log('Photo uploaded:', secureUrl);
} else if (result.event === 'abort') {
// Handle upload cancellation
uploadButton.textContent = 'Upload Photo';
uploadButton.disabled = false;
uploadButton.classList.remove('processing');
} else if (result.event === 'start') {
// Set button to processing state
uploadButton.textContent = 'Uploading...';
uploadButton.disabled = true;
uploadButton.classList.add('processing');
}
}
);
// Connect upload button to Cloudinary widget
document.getElementById('upload_photo_button').addEventListener('click', () => {
cloudinaryWidget.open();
});
// Adding visual feedback for submission state
function addFeedbackMessage(message, isError = false) {
// Remove any existing feedback messages
const existingMessages = document.querySelectorAll('.form-feedback-message');
existingMessages.forEach(el => el.remove());
// Create and insert feedback message
const messageElement = document.createElement('div');
messageElement.className = `form-feedback-message ${isError ? 'error-message' : 'success-message'}`;
messageElement.textContent = message;
// Insert after the active step
const activeStep = document.querySelector('.form-step.active');
if (activeStep) {
// Insert inside the button group or after the input
const buttonGroup = activeStep.querySelector('.button-group');
if (buttonGroup) {
buttonGroup.insertAdjacentElement('beforebegin', messageElement);
} else {
const input = activeStep.querySelector('input');
if (input) {
input.insertAdjacentElement('afterend', messageElement);
}
}
}
}
// Function to update the heading and subheading
function updateHeadings(step) {
const headingInfo = stepHeadings[step];
document.getElementById('form-title').textContent = headingInfo.title;
document.getElementById('form-subtitle').textContent = headingInfo.subtitle;
}
// Enhanced progress update function with special final step treatment
function updateProgressBar(step) {
// First reset all steps to inactive
for (let i = 1; i <= 5; i++) {
const stepElement = document.getElementById('step' + i);
stepElement.style.backgroundColor = 'rgba(255, 255, 255, 0.3)';
stepElement.classList.remove('final-step-glow');
}
// Then set completed steps to active
for (let i = 1; i <= step; i++) {
document.getElementById('step' + i).style.backgroundColor = 'white';
}
// Special handling for final step
if (step === 5) {
// Add glowing effect to the final step
const finalStep = document.getElementById('step5');
finalStep.classList.add('final-step-glow');
// Show the checkmark indicator
document.getElementById('final-step-indicator').style.display = 'block';
} else {
// Hide the checkmark for non-final steps
document.getElementById('final-step-indicator').style.display = 'none';
}
// Update headings for this step
updateHeadings(step);
}
function updateURL() {
const childName = document.getElementById('child_name').value;
const age = document.getElementById('age').value;
const hobby1 = document.getElementById('hobby_1').value;
const favoriteSong = document.getElementById('favorite_song').value;
let params = new URLSearchParams(window.location.search);
if (currentStep > 1 && childName) params.set('childsName', childName);
if (currentStep > 2 && age) params.set('childsAge', age);
if (currentStep > 3 && hobby1) params.set('hobby1', hobby1);
if (currentStep > 4 && favoriteSong) params.set('favoriteSong', favoriteSong);
const urlParams = new URLSearchParams(window.location.search);
const variantId = urlParams.get('variant');
if (variantId) params.set('variant', variantId);
const basePath = '/pages/create-their-story/';
const newPath = basePath + stepPaths[currentStep - 1];
const newURL = newPath + '?' + params.toString();
window.history.pushState({ step: currentStep }, '', newURL);
}
// Update the nextStep function with improved error handling and feedback
function nextStep() {
if (currentStep >= 5) {
return;
}
// Get the current step element and continue button
const currentStepElement = document.querySelector(`.form-step[data-step="${currentStep}"]`);
const continueButton = currentStepElement.querySelector('.continue-button');
// Remove any existing feedback messages
const existingMessages = currentStepElement.querySelectorAll('.form-feedback-message');
existingMessages.forEach(el => el.remove());
// Validate input field if present
const input = currentStepElement.querySelector('input');
if (input && input.required && !input.value.trim()) {
addFeedbackMessage(`Please enter your ${input.placeholder || 'information'}`, true);
input.focus();
return;
}
// Disable the button to prevent multiple submissions
if (continueButton) {
continueButton.disabled = true;
continueButton.textContent = "Processing...";
continueButton.classList.add('processing');
}
// Proceed with transition
setTimeout(() => {
currentStepElement.classList.remove('active');
currentStep++;
const nextStepElement = document.querySelector(`.form-step[data-step="${currentStep}"]`);
nextStepElement.classList.add('active');
updateProgressBar(currentStep);
updateURL();
// Focus on the input field in the new step
const newInput = nextStepElement.querySelector('input');
if (newInput) {
newInput.focus();
}
// Re-enable the previous button for back navigation
if (continueButton) {
continueButton.disabled = false;
continueButton.textContent = "Continue";
continueButton.classList.remove('processing');
}
}, 200);
}
function prevStep() {
// Remove any error messages when going back
const currentStepElement = document.querySelector(`.form-step[data-step="${currentStep}"]`);
const existingMessages = currentStepElement.querySelectorAll('.form-feedback-message');
existingMessages.forEach(el => el.remove());
currentStepElement.classList.remove('active');
currentStep--;
const prevStepElement = document.querySelector(`.form-step[data-step="${currentStep}"]`);
setTimeout(() => {
prevStepElement.classList.add('active');
updateProgressBar(currentStep);
updateURL();
// Focus on the input field in the previous step
const prevInput = prevStepElement.querySelector('input');
if (prevInput) {
prevInput.focus();
}
// Make sure the continue button in this step is enabled
const prevContinueButton = prevStepElement.querySelector('.continue-button');
if (prevContinueButton) {
prevContinueButton.disabled = false;
prevContinueButton.textContent = "Continue";
prevContinueButton.classList.remove('processing');
}
}, 200);
}
function addToCart() {
// Get the add to cart button
const addToCartBtn = document.getElementById('add-to-cart-button');
// Clear any existing feedback
const existingMessages = document.querySelectorAll('.form-feedback-message');
existingMessages.forEach(el => el.remove());
// Disable the button to prevent multiple submissions
if (addToCartBtn) {
addToCartBtn.disabled = true;
addToCartBtn.textContent = "Adding to Cart...";
addToCartBtn.classList.add('processing');
}
// Add a processing message
addFeedbackMessage('Adding to cart...');
// Get form values
const childName = document.getElementById('child_name').value;
const age = document.getElementById('age').value;
const hobby1 = document.getElementById('hobby_1').value;
const favoriteSong = document.getElementById('favorite_song').value;
const childPhotoUrl = document.getElementById('child_photo_url').value;
// Get the variant ID from URL
const urlParams = new URLSearchParams(window.location.search);
const variantId = urlParams.get('variant');
if (!variantId) {
addFeedbackMessage('Missing product information. Please try again from the product page.', true);
// Re-enable the button
if (addToCartBtn) {
addToCartBtn.disabled = false;
addToCartBtn.textContent = "Add to Cart";
addToCartBtn.classList.remove('processing');
}
return;
}
// Create line item properties (these become attached to the specific product)
const properties = {};
if (childName) properties['Child Name'] = childName;
if (age) properties['Child Age'] = age;
if (hobby1) properties['Hobby 1'] = hobby1;
if (favoriteSong) properties['Favorite Song'] = favoriteSong;
if (childPhotoUrl) properties['Child Photo URL'] = childPhotoUrl;
console.log('Adding item to cart with properties:', properties);
// Set up a timeout to detect slow responses
let processingTimeout = setTimeout(() => {
addFeedbackMessage('This is taking longer than expected. Please wait a moment...', false);
}, 5000);
// Similar to the gist implementation - use fetch to add to cart
fetch('/cart/add.js', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: variantId,
quantity: 1,
properties: properties
})
})
.then(response => {
if (!response.ok) {
return response.json().then(error => {
throw new Error(error.description || 'Failed to add item to cart');
});
}
return response.json();
})
.then(item => {
// Clear the timeout as we've successfully processed
clearTimeout(processingTimeout);
// Display success message briefly before redirect
addFeedbackMessage('Successfully added to cart!');
console.log('Item added to cart:', item);
// Redirect to cart page (standard Shopify flow)
setTimeout(() => {
window.location.href = '/cart';
}, 500);
})
.catch(error => {
// Clear the timeout
clearTimeout(processingTimeout);
// Log the error
console.error('Add to cart error:', error);
// Show user-friendly error message
addFeedbackMessage(`Something went wrong: ${error.message}. Please try again.`, true);
// Re-enable the button
if (addToCartBtn) {
addToCartBtn.disabled = false;
addToCartBtn.textContent = "Add to Cart";
addToCartBtn.classList.remove('processing');
}
});
}
// Add event listeners for continue and back buttons
document.querySelectorAll('.continue-button').forEach(button => {
button.addEventListener('click', nextStep);
});
document.querySelector('#add-to-cart-button').addEventListener('click', addToCart);
document.querySelectorAll('.back-button').forEach(button => {
button.addEventListener('click', prevStep);
});
// Add improved keyboard support
document.addEventListener('keydown', function(event) {
if (event.key === 'Enter') {
// Prevent default form submission
event.preventDefault();
const activeStep = document.querySelector('.form-step.active');
const stepNumber = parseInt(activeStep.getAttribute('data-step'));
// Check if buttons are already in processing state
const continueButton = activeStep.querySelector('.continue-button');
if (stepNumber === totalSteps) {
const addToCartBtn = activeStep.querySelector('#add-to-cart-button');
if (addToCartBtn && !addToCartBtn.disabled && !addToCartBtn.classList.contains('processing')) {
addToCart();
}
} else {
if (continueButton && !continueButton.disabled && !continueButton.classList.contains('processing')) {
nextStep();
}
}
}
});
// Handle network connectivity issues
window.addEventListener('online', function() {
const processingButtons = document.querySelectorAll('.processing');
if (processingButtons.length > 0) {
addFeedbackMessage('Your internet connection is back. Please try again.', false);
// Re-enable any processing buttons
processingButtons.forEach(button => {
button.disabled = false;
button.textContent = button.classList.contains('final-button') ? "Continue to Checkout" : "Continue";
button.classList.remove('processing');
});
}
});
window.addEventListener('offline', function() {
addFeedbackMessage('You appear to be offline. Please check your internet connection.', true);
});
// Focus on the first input when the page loads
const firstInput = document.querySelector('.form-step.active input');
if (firstInput) {
firstInput.focus();
}
// Set initial progress bar state and headings
updateProgressBar(1);
updateURL();
// Helper function to show input validation errors
function showInputError(input, message) {
// Remove any existing feedback messages
const existingMessages = document.querySelectorAll('.form-feedback-message');
existingMessages.forEach(el => el.remove());
// Add error class to input
input.classList.add('input-error');
// Add error message if provided
if (message) {
addFeedbackMessage(message, true);
}
// Focus on the input
input.focus();
}
function clearInputError(input) {
// Remove error class
input.classList.remove('input-error');
// Remove any existing feedback messages
const existingMessages = document.querySelectorAll('.form-feedback-message');
existingMessages.forEach(el => el.remove());
}
// Add event listeners to clear validation errors on input
document.querySelectorAll('input').forEach(input => {
input.addEventListener('input', () => {
clearInputError(input);
});
});
});
// Check for variant ID in URL params
const urlParams = new URLSearchParams(window.location.search);
if (!urlParams.get('variant')) {
console.error('No variant ID provided in URL');
}
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment