515 lines
15 KiB
Markdown
515 lines
15 KiB
Markdown
# URL State Management Implementation
|
|
|
|
## Overview
|
|
This feature allows users to:
|
|
1. **Bookmark** their progress through the navigation flow
|
|
2. **Refresh** the page without losing their selections
|
|
3. **Share** URLs with specific products or navigation states
|
|
4. **Deep link** directly to products
|
|
|
|
## URL Parameter Structure
|
|
|
|
### Query Parameters
|
|
- `b` - Bitwise value representing all selections (integer)
|
|
- `q` - Current question ID (string)
|
|
- `p` - Product code for direct product view (string)
|
|
|
|
### Examples
|
|
```
|
|
# At start
|
|
https://yoursite.com/
|
|
|
|
# After selecting Door > Storm Door > Aluminum
|
|
https://yoursite.com/?b=16401&q=q-dimensions
|
|
|
|
# Viewing a specific product
|
|
https://yoursite.com/?p=BGIST&b=16401
|
|
|
|
# Bit value 16401 decodes to:
|
|
# - Door (bit 0)
|
|
# - Aluminum (bit 4)
|
|
# - Storm Door subtype (bit 14)
|
|
```
|
|
|
|
## Implementation Code
|
|
|
|
### 1. Add to Global Variables (top of script.js)
|
|
```javascript
|
|
// Add these to existing global variables
|
|
let accumulatedBitValue = 0;
|
|
let bitwiseData = {};
|
|
|
|
// Bit definitions (must match generate_bitwise_helper.py)
|
|
const BIT_DEFINITIONS = {
|
|
// Base Types
|
|
'base_door': 0, // 1
|
|
'base_window': 1, // 2
|
|
|
|
// Flags
|
|
'is_accessory': 2, // 4
|
|
'specific_item': 3, // 8
|
|
|
|
// Materials
|
|
'material_aluminum': 4, // 16
|
|
'material_vinyl': 5, // 32
|
|
|
|
// Colors
|
|
'color_black': 6, // 64
|
|
'color_white': 7, // 128
|
|
'color_bronze': 8, // 256
|
|
'color_tan': 9, // 512
|
|
'color_mill': 10, // 1024
|
|
'color_sandstone': 11, // 2048
|
|
|
|
// Subtypes (from bitwise_legend.json)
|
|
'subtype_patio_door': 12, // 4096
|
|
'subtype_primary_window': 13, // 8192
|
|
'subtype_storm_door': 14, // 16384
|
|
'subtype_storm_window': 15 // 32768
|
|
};
|
|
```
|
|
|
|
### 2. Update init() Function
|
|
```javascript
|
|
function init() {
|
|
// Load all data files (including bitwise)
|
|
Promise.all([
|
|
fetch('data/navigation.json').then(r => r.json()),
|
|
fetch('data/products.json').then(r => r.json()),
|
|
fetch('data/accessories.json').then(r => r.json()),
|
|
fetch('data/product_bitwise.json').then(r => r.json())
|
|
])
|
|
.then(([questions, products, accessories, bitwise]) => {
|
|
questionData = questions;
|
|
productData = products;
|
|
accessoryData = accessories;
|
|
|
|
// Index bitwise data by product code
|
|
bitwiseData = {};
|
|
bitwise.forEach(item => {
|
|
bitwiseData[item.PROD_CODE] = item.BIT_VALUE;
|
|
});
|
|
|
|
// Initialize from URL or start fresh
|
|
initFromURL();
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading data:', error);
|
|
showError('Failed to load application data.');
|
|
});
|
|
}
|
|
```
|
|
|
|
### 3. URL Initialization
|
|
```javascript
|
|
function initFromURL() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
|
|
// Priority 1: Direct product view
|
|
const productCode = params.get('p');
|
|
if (productCode) {
|
|
accumulatedBitValue = parseInt(params.get('b') || '0', 10);
|
|
showProductByCode(productCode);
|
|
return;
|
|
}
|
|
|
|
// Priority 2: Restore navigation state
|
|
const bitValue = params.get('b');
|
|
const questionKey = params.get('q');
|
|
|
|
if (bitValue && questionKey) {
|
|
accumulatedBitValue = parseInt(bitValue, 10);
|
|
restoreStateFromBitValue(accumulatedBitValue, questionKey);
|
|
return;
|
|
}
|
|
|
|
// Priority 3: Start fresh
|
|
loadContent('start');
|
|
}
|
|
|
|
function restoreStateFromBitValue(bitValue, questionKey) {
|
|
// Decode bit value back to user selections
|
|
|
|
// Base type
|
|
if (bitValue & 1) {
|
|
userAnswers['start'] = 'Door';
|
|
} else if (bitValue & 2) {
|
|
userAnswers['start'] = 'Window';
|
|
}
|
|
|
|
// Materials
|
|
if (bitValue & 16) {
|
|
userAnswers['q-material.material'] = 'Aluminum';
|
|
} else if (bitValue & 32) {
|
|
userAnswers['q-material.material'] = 'Vinyl';
|
|
}
|
|
|
|
// Colors
|
|
const colorMap = {
|
|
64: 'Black',
|
|
128: 'White',
|
|
256: 'Bronze',
|
|
512: 'Tan',
|
|
1024: 'Mill',
|
|
2048: 'Sandstone'
|
|
};
|
|
|
|
for (const [bit, color] of Object.entries(colorMap)) {
|
|
if (bitValue & parseInt(bit)) {
|
|
userAnswers['q-color.color'] = color;
|
|
break; // Only store first color found
|
|
}
|
|
}
|
|
|
|
// Subtypes
|
|
const subtypeMap = {
|
|
4096: 'Patio Door',
|
|
8192: 'Primary Window',
|
|
16384: 'Storm Door',
|
|
32768: 'Storm Window'
|
|
};
|
|
|
|
for (const [bit, subtype] of Object.entries(subtypeMap)) {
|
|
if (bitValue & parseInt(bit)) {
|
|
userAnswers['q-subtype'] = subtype;
|
|
break; // Only store first subtype found
|
|
}
|
|
}
|
|
|
|
// Navigate to the saved question
|
|
history = ['start'];
|
|
if (questionKey !== 'start') {
|
|
history.push(questionKey);
|
|
}
|
|
loadContent(questionKey);
|
|
}
|
|
```
|
|
|
|
### 4. Update URL on Each Selection
|
|
```javascript
|
|
function updateURL(questionKey = null) {
|
|
const params = new URLSearchParams();
|
|
|
|
if (accumulatedBitValue > 0) {
|
|
params.set('b', accumulatedBitValue.toString());
|
|
}
|
|
|
|
if (questionKey) {
|
|
params.set('q', questionKey);
|
|
} else if (history.length > 0) {
|
|
params.set('q', history[history.length - 1]);
|
|
}
|
|
|
|
const newURL = window.location.pathname + (params.toString() ? '?' + params.toString() : '');
|
|
window.history.replaceState(
|
|
{
|
|
bitValue: accumulatedBitValue,
|
|
questionKey: questionKey
|
|
},
|
|
'',
|
|
newURL
|
|
);
|
|
}
|
|
|
|
function updateBitValue(answerObject) {
|
|
if (!answerObject.filter) return;
|
|
|
|
const filter = answerObject.filter;
|
|
|
|
// Base type
|
|
if (filter.baseType === 'Door') {
|
|
accumulatedBitValue |= (1 << BIT_DEFINITIONS['base_door']);
|
|
} else if (filter.baseType === 'Window') {
|
|
accumulatedBitValue |= (1 << BIT_DEFINITIONS['base_window']);
|
|
}
|
|
|
|
// Materials
|
|
if (filter.material === 'Aluminum') {
|
|
accumulatedBitValue |= (1 << BIT_DEFINITIONS['material_aluminum']);
|
|
} else if (filter.material === 'Vinyl') {
|
|
accumulatedBitValue |= (1 << BIT_DEFINITIONS['material_vinyl']);
|
|
}
|
|
|
|
// Colors
|
|
const colorKey = filter.color ? 'color_' + filter.color.toLowerCase() : null;
|
|
if (colorKey && BIT_DEFINITIONS[colorKey]) {
|
|
accumulatedBitValue |= (1 << BIT_DEFINITIONS[colorKey]);
|
|
}
|
|
|
|
// Subtypes
|
|
const subtypeKey = filter.subType ? 'subtype_' + filter.subType.toLowerCase().replace(/ /g, '_') : null;
|
|
if (subtypeKey && BIT_DEFINITIONS[subtypeKey]) {
|
|
accumulatedBitValue |= (1 << BIT_DEFINITIONS[subtypeKey]);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5. Update handleAnswer Function
|
|
```javascript
|
|
function handleAnswer(currentKey, answerValue, answerIndex) {
|
|
const currentQuestion = questionData[currentKey];
|
|
const answerObject = currentQuestion.answers[answerIndex];
|
|
|
|
// Store the answer
|
|
userAnswers[currentKey] = answerValue;
|
|
|
|
// Update bit value based on selection
|
|
updateBitValue(answerObject);
|
|
|
|
// Resolve the next question (handles conditionals)
|
|
const nextKey = resolveNextQuestion(currentKey, answerValue, answerObject);
|
|
|
|
history.push(nextKey);
|
|
|
|
// Update URL with new state
|
|
updateURL(nextKey);
|
|
|
|
loadContent(nextKey);
|
|
}
|
|
```
|
|
|
|
### 6. Product View with URL
|
|
```javascript
|
|
function showProductByCode(productCode) {
|
|
const product = productData.find(p => p.productCode === productCode);
|
|
if (!product) {
|
|
showError(`Product ${productCode} not found`);
|
|
return;
|
|
}
|
|
|
|
showProductDetail(product);
|
|
}
|
|
|
|
function showProductDetail(product) {
|
|
// Update URL with product code
|
|
const params = new URLSearchParams();
|
|
params.set('p', product.productCode);
|
|
if (accumulatedBitValue > 0) {
|
|
params.set('b', accumulatedBitValue.toString());
|
|
}
|
|
|
|
window.history.pushState(
|
|
{ productCode: product.productCode },
|
|
'',
|
|
'?' + params.toString()
|
|
);
|
|
|
|
// Render product detail
|
|
const compatibleAccessories = accessoryData.filter(acc =>
|
|
product.compatibleAccessories && product.compatibleAccessories.includes(acc.id)
|
|
);
|
|
|
|
const contentDiv = document.getElementById('content');
|
|
let html = `
|
|
<div class="result-container">
|
|
<div class="result-title">${product.description}</div>
|
|
<div class="result-content">
|
|
<div class="result-details">
|
|
<p><strong>Product Code:</strong> ${product.productCode}</p>
|
|
<p><strong>Category:</strong> ${product.category}</p>
|
|
<p><strong>Base Type:</strong> ${product.baseType || 'N/A'}</p>
|
|
<p><strong>Materials:</strong> ${product.materials.join(', ') || 'N/A'}</p>
|
|
<p><strong>Colors:</strong> ${product.colors.join(', ') || 'N/A'}</p>
|
|
|
|
${compatibleAccessories.length > 0 ? `
|
|
<h3 style="margin-top: 20px;">Compatible Accessories</h3>
|
|
<div class="accessories-list">
|
|
${compatibleAccessories.map(acc => `
|
|
<div class="accessory-item">
|
|
<strong>${acc.description}</strong><br>
|
|
<small>Code: ${acc.accessoryCode} | ${acc.materials.join(', ')}</small>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="result-actions">
|
|
<button class="back-button" onclick="goBack()">← Go Back</button>
|
|
<button class="back-button" onclick="startOver()">Start Over</button>
|
|
<button class="back-button" onclick="shareCurrentPage()">🔗 Share Product</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
contentDiv.innerHTML = html;
|
|
}
|
|
```
|
|
|
|
### 7. Share Functionality
|
|
```javascript
|
|
function shareCurrentPage() {
|
|
const url = window.location.href;
|
|
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(url).then(() => {
|
|
showNotification('Link copied to clipboard!');
|
|
}).catch(() => {
|
|
showUrlPrompt(url);
|
|
});
|
|
} else {
|
|
showUrlPrompt(url);
|
|
}
|
|
}
|
|
|
|
function showUrlPrompt(url) {
|
|
const message = prompt('Copy this URL to share:', url);
|
|
}
|
|
|
|
function showNotification(message) {
|
|
const notification = document.createElement('div');
|
|
notification.className = 'notification';
|
|
notification.textContent = message;
|
|
notification.style.cssText = `
|
|
position: fixed;
|
|
top: 20px;
|
|
right: 20px;
|
|
background: #4CAF50;
|
|
color: white;
|
|
padding: 15px 20px;
|
|
border-radius: 4px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
z-index: 10000;
|
|
animation: slideIn 0.3s ease-out;
|
|
`;
|
|
|
|
document.body.appendChild(notification);
|
|
|
|
setTimeout(() => {
|
|
notification.style.animation = 'slideOut 0.3s ease-in';
|
|
setTimeout(() => notification.remove(), 300);
|
|
}, 3000);
|
|
}
|
|
```
|
|
|
|
### 8. Update startOver Function
|
|
```javascript
|
|
function startOver() {
|
|
history = ['start'];
|
|
|
|
// Clear all stored answers
|
|
for (const key in userAnswers) {
|
|
delete userAnswers[key];
|
|
}
|
|
|
|
// Clear bit value
|
|
accumulatedBitValue = 0;
|
|
|
|
// Clear URL (return to root)
|
|
window.history.replaceState({}, '', window.location.pathname);
|
|
|
|
loadContent('start');
|
|
}
|
|
```
|
|
|
|
### 9. Handle Browser Back Button
|
|
```javascript
|
|
// Add this to init() or as separate event listener
|
|
window.addEventListener('popstate', function(event) {
|
|
if (event.state) {
|
|
if (event.state.productCode) {
|
|
showProductByCode(event.state.productCode);
|
|
} else if (event.state.questionKey) {
|
|
accumulatedBitValue = event.state.bitValue || 0;
|
|
loadContent(event.state.questionKey);
|
|
} else {
|
|
startOver();
|
|
}
|
|
} else {
|
|
// No state, check URL
|
|
initFromURL();
|
|
}
|
|
});
|
|
```
|
|
|
|
### 10. Add CSS for Notification
|
|
```css
|
|
@keyframes slideIn {
|
|
from {
|
|
transform: translateX(400px);
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
transform: translateX(0);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
@keyframes slideOut {
|
|
from {
|
|
transform: translateX(0);
|
|
opacity: 1;
|
|
}
|
|
to {
|
|
transform: translateX(400px);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
|
|
.accessory-item {
|
|
padding: 10px;
|
|
margin: 5px 0;
|
|
background: #f5f5f5;
|
|
border-left: 3px solid #2196F3;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.product-card {
|
|
cursor: pointer;
|
|
transition: transform 0.2s, box-shadow 0.2s;
|
|
}
|
|
|
|
.product-card:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
|
}
|
|
```
|
|
|
|
## Testing Scenarios
|
|
|
|
### Test 1: Basic Navigation
|
|
1. Start quiz
|
|
2. Select Door → Storm Door → Aluminum → White
|
|
3. Check URL contains `?b=XXXX&q=YYYY`
|
|
4. Copy URL
|
|
5. Open in new tab → Should restore to same point
|
|
|
|
### Test 2: Product Deep Link
|
|
1. Navigate to product BGIST
|
|
2. Check URL contains `?p=BGIST&b=XXXX`
|
|
3. Copy URL
|
|
4. Open in new tab → Should show product directly
|
|
|
|
### Test 3: Browser Refresh
|
|
1. Navigate through several questions
|
|
2. Press F5 to refresh
|
|
3. Should restore to same question with selections intact
|
|
|
|
### Test 4: Browser Back Button
|
|
1. Navigate forward through questions
|
|
2. Press browser back button
|
|
3. Should step backward through questions
|
|
4. URL should update accordingly
|
|
|
|
### Test 5: Share Button
|
|
1. Complete navigation flow
|
|
2. Click "Share" button
|
|
3. Should copy URL to clipboard
|
|
4. Paste in new browser → Should restore state
|
|
|
|
## Benefits
|
|
|
|
1. **User Experience**: Users don't lose progress on refresh
|
|
2. **Shareability**: Users can share specific configurations
|
|
3. **Bookmarking**: Useful configurations can be saved
|
|
4. **SEO**: Product pages are directly linkable
|
|
5. **Analytics**: Track specific navigation paths via URL parameters
|
|
6. **Support**: Users can share URLs when asking for help
|
|
|
|
## URL Encoding Notes
|
|
|
|
- Bit values are stored as decimal integers (more compact than hex for small values)
|
|
- Product codes are URL-safe (no special encoding needed)
|
|
- Question IDs are alphanumeric (q-dimensions, etc.)
|
|
- Special characters in product codes should be URL-encoded if present
|