Add config-driven location picker with photo guide (issue #5)
Deploy to Dev / Deploy & Smoke Test (push) Successful in 17s
Deploy to Dev / Deploy & Smoke Test (push) Successful in 17s
- site.json: add locations array (River Bottoms, City Park, Downtown, Studio) with heroImage, galleryImages, tags, and description per location - public/images/locations/: directory skeleton with .gitkeep; photographer drops hero.jpg + numbered gallery images here without any rebuild - Step 4: location-grid renders cards from config, each with hero image, location name, and tag pills; missing images fall back to a styled placeholder - Clicking a card selects it (shows checkmark overlay) and opens an inline detail panel below the grid showing gallery images + full description + tags; clicking the same card again collapses the detail panel - populateSummary and submitBooking now resolve the location name from config rather than storing the raw ID Closes #5 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -57,6 +57,52 @@
|
|||||||
"description": "Enter your gallery code to view and download your photos",
|
"description": "Enter your gallery code to view and download your photos",
|
||||||
"placeholder": "Enter your gallery code"
|
"placeholder": "Enter your gallery code"
|
||||||
},
|
},
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "river-bottoms",
|
||||||
|
"name": "River Bottoms",
|
||||||
|
"description": "A stunning riverside setting with golden light filtering through the trees — perfect for that warm, natural feel.",
|
||||||
|
"tags": ["Outdoor", "Golden hour", "Nature"],
|
||||||
|
"heroImage": "/images/locations/river-bottoms/hero.jpg",
|
||||||
|
"galleryImages": [
|
||||||
|
"/images/locations/river-bottoms/1.jpg",
|
||||||
|
"/images/locations/river-bottoms/2.jpg"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "city-park",
|
||||||
|
"name": "City Park",
|
||||||
|
"description": "Lush green spaces with open fields, mature trees, and beautiful walking paths — great for families and couples.",
|
||||||
|
"tags": ["Outdoor", "Green space", "Versatile"],
|
||||||
|
"heroImage": "/images/locations/city-park/hero.jpg",
|
||||||
|
"galleryImages": [
|
||||||
|
"/images/locations/city-park/1.jpg",
|
||||||
|
"/images/locations/city-park/2.jpg"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "downtown",
|
||||||
|
"name": "Downtown",
|
||||||
|
"description": "Urban backdrops with brick walls, murals, and architectural details — ideal for seniors and creative portraits.",
|
||||||
|
"tags": ["Urban", "Architecture", "Creative"],
|
||||||
|
"heroImage": "/images/locations/downtown/hero.jpg",
|
||||||
|
"galleryImages": [
|
||||||
|
"/images/locations/downtown/1.jpg",
|
||||||
|
"/images/locations/downtown/2.jpg"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "studio",
|
||||||
|
"name": "Studio",
|
||||||
|
"description": "A clean, controlled environment with professional lighting — great for newborns, headshots, and consistent results.",
|
||||||
|
"tags": ["Indoor", "Controlled lighting", "Year-round"],
|
||||||
|
"heroImage": "/images/locations/studio/hero.jpg",
|
||||||
|
"galleryImages": [
|
||||||
|
"/images/locations/studio/1.jpg",
|
||||||
|
"/images/locations/studio/2.jpg"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"booking": {
|
"booking": {
|
||||||
"venmoUsername": "LisiLouPhoto",
|
"venmoUsername": "LisiLouPhoto",
|
||||||
"sessionTypes": [
|
"sessionTypes": [
|
||||||
|
|||||||
+231
-20
@@ -846,6 +846,160 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Location cards */
|
||||||
|
.location-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-card {
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
background: var(--color-white);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: border-color 0.3s var(--transition-smooth);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-card:hover { border-color: var(--color-primary); }
|
||||||
|
.location-card.selected { border-color: var(--color-primary); }
|
||||||
|
|
||||||
|
.location-card-image {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-bg-warm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-card-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.5s var(--transition-smooth);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-card:hover .location-card-image img { transform: scale(1.06); }
|
||||||
|
|
||||||
|
.location-img-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, var(--color-bg-warm), var(--color-accent));
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-card-check {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6rem;
|
||||||
|
right: 0.6rem;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-white);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
z-index: 1;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-card.selected .location-card-check { display: flex; }
|
||||||
|
|
||||||
|
.location-card-body { padding: 0.75rem 0.9rem 0.9rem; }
|
||||||
|
|
||||||
|
.location-card-name {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-tags { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||||
|
|
||||||
|
.location-tag {
|
||||||
|
font-size: 0.58rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-primary);
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail expand panel */
|
||||||
|
.location-detail {
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: max-height 0.4s var(--transition-smooth), opacity 0.3s, margin-top 0.3s;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-detail.open {
|
||||||
|
max-height: 500px;
|
||||||
|
opacity: 1;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-detail-inner {
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: var(--color-bg-warm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-gallery {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-gallery-item {
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-gallery-item img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
transition: transform 0.4s var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-gallery-item img:hover { transform: scale(1.05); }
|
||||||
|
|
||||||
|
.location-detail-name {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-detail-desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.65;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.location-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Contract step */
|
/* Contract step */
|
||||||
.contract-placeholder {
|
.contract-placeholder {
|
||||||
background: var(--color-bg-warm);
|
background: var(--color-bg-warm);
|
||||||
@@ -1155,29 +1309,19 @@
|
|||||||
<div class="option-grid length-grid" id="session-length-options"></div>
|
<div class="option-grid length-grid" id="session-length-options"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 4: Location -->
|
<!-- Step 4: Location — rendered from site.json by renderLocationCards() -->
|
||||||
<div class="step-panel" id="step-4">
|
<div class="step-panel" id="step-4">
|
||||||
<h3 class="step-heading">Pick a Location</h3>
|
<h3 class="step-heading">Pick a Location</h3>
|
||||||
<p class="step-subheading">Choose where you'd like your session to take place.</p>
|
<p class="step-subheading">Choose where you'd like your session to take place.</p>
|
||||||
<div class="option-grid" id="location-options">
|
<div class="location-grid" id="location-options"></div>
|
||||||
<div class="option-card" data-value="outdoor-park" onclick="selectOption(this,'sessionLocation')">
|
<div class="location-detail" id="location-detail">
|
||||||
<div class="option-card-title">Outdoor Park</div>
|
<div class="location-detail-inner">
|
||||||
<div class="option-card-desc">Natural setting</div>
|
<div class="location-gallery" id="location-detail-gallery"></div>
|
||||||
</div>
|
<div class="location-detail-name" id="location-detail-name"></div>
|
||||||
<div class="option-card" data-value="studio" onclick="selectOption(this,'sessionLocation')">
|
<p class="location-detail-desc" id="location-detail-desc"></p>
|
||||||
<div class="option-card-title">Studio</div>
|
<div class="location-tags" id="location-detail-tags"></div>
|
||||||
<div class="option-card-desc">Controlled lighting</div>
|
|
||||||
</div>
|
|
||||||
<div class="option-card" data-value="urban" onclick="selectOption(this,'sessionLocation')">
|
|
||||||
<div class="option-card-title">Urban</div>
|
|
||||||
<div class="option-card-desc">City backdrop</div>
|
|
||||||
</div>
|
|
||||||
<div class="option-card" data-value="client-choice" onclick="selectOption(this,'sessionLocation')">
|
|
||||||
<div class="option-card-title">Your Choice</div>
|
|
||||||
<div class="option-card-desc">Suggest a location</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Issue #5 will replace these with photo cards from config/site.json -->
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 5: Contract -->
|
<!-- Step 5: Contract -->
|
||||||
@@ -1549,6 +1693,67 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderLocationCards() {
|
||||||
|
const locations = window.siteConfig?.locations;
|
||||||
|
const grid = document.getElementById('location-options');
|
||||||
|
|
||||||
|
if (!locations || locations.length === 0) {
|
||||||
|
grid.innerHTML = '<p style="color:var(--color-text-light);font-size:0.85rem;">No locations configured yet.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.innerHTML = locations.map(loc => {
|
||||||
|
const imgHtml = loc.heroImage
|
||||||
|
? `<img src="${loc.heroImage}" alt="${loc.name}" loading="lazy" onerror="this.parentNode.innerHTML='<div class="location-img-placeholder">${loc.name}</div>'">`
|
||||||
|
: `<div class="location-img-placeholder">${loc.name}</div>`;
|
||||||
|
const tags = (loc.tags || []).map(t => `<span class="location-tag">${t}</span>`).join('');
|
||||||
|
return `
|
||||||
|
<div class="location-card" data-id="${loc.id}" onclick="selectLocation('${loc.id}')">
|
||||||
|
<div class="location-card-image">
|
||||||
|
${imgHtml}
|
||||||
|
<div class="location-card-check">✓</div>
|
||||||
|
</div>
|
||||||
|
<div class="location-card-body">
|
||||||
|
<div class="location-card-name">${loc.name}</div>
|
||||||
|
<div class="location-tags">${tags}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLocation(id) {
|
||||||
|
const locations = window.siteConfig?.locations || [];
|
||||||
|
const loc = locations.find(l => l.id === id);
|
||||||
|
if (!loc) return;
|
||||||
|
|
||||||
|
document.querySelectorAll('.location-card').forEach(c => c.classList.remove('selected'));
|
||||||
|
const card = document.querySelector(`.location-card[data-id="${id}"]`);
|
||||||
|
if (card) card.classList.add('selected');
|
||||||
|
bookingState.sessionLocation = id;
|
||||||
|
|
||||||
|
const detail = document.getElementById('location-detail');
|
||||||
|
const wasOpen = detail.classList.contains('open') && detail.dataset.locationId === id;
|
||||||
|
|
||||||
|
if (wasOpen) {
|
||||||
|
detail.classList.remove('open');
|
||||||
|
detail.dataset.locationId = '';
|
||||||
|
} else {
|
||||||
|
const gallery = (loc.galleryImages || []);
|
||||||
|
document.getElementById('location-detail-gallery').innerHTML = gallery.length
|
||||||
|
? gallery.map(src => `
|
||||||
|
<div class="location-gallery-item">
|
||||||
|
<img src="${src}" alt="" loading="lazy" onerror="this.parentNode.style.display='none'">
|
||||||
|
</div>`).join('')
|
||||||
|
: '';
|
||||||
|
document.getElementById('location-detail-name').textContent = loc.name;
|
||||||
|
document.getElementById('location-detail-desc').textContent = loc.description || '';
|
||||||
|
document.getElementById('location-detail-tags').innerHTML = (loc.tags || [])
|
||||||
|
.map(t => `<span class="location-tag">${t}</span>`).join('');
|
||||||
|
detail.dataset.locationId = id;
|
||||||
|
detail.classList.add('open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openBooking() {
|
function openBooking() {
|
||||||
document.getElementById('booking-overlay').classList.add('active');
|
document.getElementById('booking-overlay').classList.add('active');
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
@@ -1564,6 +1769,10 @@
|
|||||||
document.getElementById('session-date').value = '';
|
document.getElementById('session-date').value = '';
|
||||||
renderSessionTypeCards();
|
renderSessionTypeCards();
|
||||||
renderSessionLengthCards();
|
renderSessionLengthCards();
|
||||||
|
renderLocationCards();
|
||||||
|
const locationDetail = document.getElementById('location-detail');
|
||||||
|
locationDetail.classList.remove('open');
|
||||||
|
locationDetail.dataset.locationId = '';
|
||||||
goToStep(1);
|
goToStep(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1639,7 +1848,8 @@
|
|||||||
document.getElementById('summary-date').textContent = bookingState.sessionDate || '—';
|
document.getElementById('summary-date').textContent = bookingState.sessionDate || '—';
|
||||||
document.getElementById('summary-type').textContent = displayType;
|
document.getElementById('summary-type').textContent = displayType;
|
||||||
document.getElementById('summary-length').textContent = lengthLabels[bookingState.sessionLength] || '—';
|
document.getElementById('summary-length').textContent = lengthLabels[bookingState.sessionLength] || '—';
|
||||||
document.getElementById('summary-location').textContent = bookingState.sessionLocation || '—';
|
const loc = (window.siteConfig?.locations || []).find(l => l.id === bookingState.sessionLocation);
|
||||||
|
document.getElementById('summary-location').textContent = loc?.name || bookingState.sessionLocation || '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateStep(step) {
|
function validateStep(step) {
|
||||||
@@ -1720,7 +1930,8 @@
|
|||||||
? 'other: ' + bookingState.sessionTypeOther
|
? 'other: ' + bookingState.sessionTypeOther
|
||||||
: bookingState.sessionType,
|
: bookingState.sessionType,
|
||||||
session_length: bookingState.sessionLength,
|
session_length: bookingState.sessionLength,
|
||||||
location: bookingState.sessionLocation,
|
location: (window.siteConfig?.locations || []).find(l => l.id === bookingState.sessionLocation)?.name
|
||||||
|
|| bookingState.sessionLocation,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('server error');
|
if (!res.ok) throw new Error('server error');
|
||||||
|
|||||||
Reference in New Issue
Block a user