Files
lisilou-portfolio/src/index.html
T
jhodgkin 53677c9f6c
Deploy to Dev / Deploy & Smoke Test (push) Successful in 23s
Request Immich preview-size images instead of tiny default thumbnails
The default /thumbnail endpoint serves a 250x374 WebP, which is
visibly blurry on portfolio cards meant to show off real work.
size=preview returns a ~1440px-wide JPEG at a reasonable file size,
confirmed directly against the live Immich instance.

Fixes #25.
2026-07-20 04:23:09 +00:00

2683 lines
91 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title id="site-title">LisiLou Photography</title>
<meta name="description" id="site-description" content="">
<link rel="icon" type="image/x-icon" href="/images/favicon.ico">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400&family=Nunito+Sans:opsz,wght@6..12,300;6..12,400;6..12,500&display=swap" rel="stylesheet">
<style>
:root {
--color-primary: #8B7355;
--color-accent: #D4C5B5;
--color-text: #2C2C2C;
--color-text-light: #6B6B6B;
--color-bg: #FDFBF9;
--color-bg-warm: #F7F3EF;
--color-white: #FFFFFF;
--font-display: 'Cormorant Garamond', serif;
--font-body: 'Nunito Sans', sans-serif;
--transition-smooth: cubic-bezier(0.4, 0, 0.2, 1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-body);
background-color: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
overflow-x: hidden;
}
/* Elegant loading animation */
.page-loader {
position: fixed;
inset: 0;
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
transition: opacity 0.6s var(--transition-smooth), visibility 0.6s;
}
.page-loader.hidden {
opacity: 0;
visibility: hidden;
}
.loader-content {
text-align: center;
}
.loader-logo {
font-family: var(--font-display);
font-size: 2.5rem;
font-weight: 300;
letter-spacing: 0.3em;
color: var(--color-primary);
animation: fadeInUp 0.8s var(--transition-smooth);
}
.loader-line {
width: 60px;
height: 1px;
background: var(--color-primary);
margin: 1.5rem auto;
animation: expandLine 1.2s var(--transition-smooth) infinite;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes expandLine {
0%, 100% { transform: scaleX(0.3); opacity: 0.3; }
50% { transform: scaleX(1); opacity: 1; }
}
/* Header */
header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
padding: 1.5rem 3rem;
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.4s var(--transition-smooth), padding 0.4s var(--transition-smooth);
}
header.scrolled {
background: rgba(253, 251, 249, 0.95);
backdrop-filter: blur(10px);
padding: 1rem 3rem;
box-shadow: 0 1px 0 rgba(139, 115, 85, 0.1);
}
.logo {
font-family: var(--font-display);
font-size: 1.8rem;
font-weight: 300;
letter-spacing: 0.2em;
color: var(--color-primary);
text-decoration: none;
transition: opacity 0.3s;
}
.logo:hover {
opacity: 0.7;
}
nav {
display: flex;
gap: 2.5rem;
align-items: center;
}
nav a {
font-family: var(--font-body);
font-size: 0.85rem;
font-weight: 400;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--color-text);
text-decoration: none;
position: relative;
padding: 0.5rem 0;
}
nav a::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: 1px;
background: var(--color-primary);
transition: width 0.3s var(--transition-smooth);
}
nav a:hover::after {
width: 100%;
}
.nav-toggle {
display: none;
flex-direction: column;
gap: 6px;
cursor: pointer;
padding: 0.5rem;
}
.nav-toggle span {
width: 24px;
height: 1px;
background: var(--color-text);
transition: transform 0.3s, opacity 0.3s;
}
/* Hero Section */
.hero {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.hero-bg {
position: absolute;
inset: 0;
background: linear-gradient(135deg, var(--color-bg-warm) 0%, var(--color-bg) 50%, var(--color-accent) 100%);
opacity: 0.5;
}
/* Optional hero photo (config: site.heroImage) with a soft scrim for text legibility */
.hero-bg.hero-bg--image {
background-size: cover;
background-position: center;
opacity: 1;
}
.hero-bg.hero-bg--image::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(to bottom, rgba(255,255,255,0.5) 0%, rgba(255,255,255,0.72) 100%);
}
.hero-pattern {
position: absolute;
inset: 0;
background-image:
radial-gradient(circle at 20% 80%, rgba(139, 115, 85, 0.03) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(212, 197, 181, 0.05) 0%, transparent 50%);
}
.hero-content {
position: relative;
text-align: center;
padding: 2rem;
max-width: 800px;
animation: heroFadeIn 1.2s var(--transition-smooth) 0.3s both;
}
@keyframes heroFadeIn {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hero-eyebrow {
font-family: var(--font-body);
font-size: 0.75rem;
font-weight: 400;
letter-spacing: 0.4em;
text-transform: uppercase;
color: var(--color-primary);
margin-bottom: 1.5rem;
}
.hero-title {
font-family: var(--font-display);
font-size: clamp(3rem, 8vw, 6rem);
font-weight: 300;
line-height: 1.1;
color: var(--color-text);
margin-bottom: 1.5rem;
}
.hero-title em {
font-style: italic;
color: var(--color-primary);
}
.hero-tagline {
font-family: var(--font-display);
font-size: 1.3rem;
font-weight: 300;
font-style: italic;
color: var(--color-text-light);
margin-bottom: 2.5rem;
}
.hero-cta {
display: inline-flex;
gap: 1rem;
flex-wrap: wrap;
justify-content: center;
}
.btn {
font-family: var(--font-body);
font-size: 0.8rem;
font-weight: 400;
letter-spacing: 0.2em;
text-transform: uppercase;
padding: 1rem 2.5rem;
border: 1px solid var(--color-primary);
background: transparent;
color: var(--color-primary);
text-decoration: none;
cursor: pointer;
transition: all 0.4s var(--transition-smooth);
}
.btn:hover {
background: var(--color-primary);
color: var(--color-white);
}
.btn-filled {
background: var(--color-primary);
color: var(--color-white);
}
.btn-filled:hover {
background: transparent;
color: var(--color-primary);
}
/* Scroll indicator */
.scroll-indicator {
position: absolute;
bottom: 3rem;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: var(--color-text-light);
font-size: 0.7rem;
letter-spacing: 0.2em;
text-transform: uppercase;
animation: float 2s ease-in-out infinite;
}
.scroll-indicator svg {
width: 20px;
height: 20px;
stroke: var(--color-primary);
}
@keyframes float {
0%, 100% { transform: translateX(-50%) translateY(0); }
50% { transform: translateX(-50%) translateY(8px); }
}
/* Section styling */
section {
padding: 6rem 3rem;
}
.section-header {
text-align: center;
margin-bottom: 4rem;
}
.section-eyebrow {
font-family: var(--font-body);
font-size: 0.7rem;
font-weight: 400;
letter-spacing: 0.4em;
text-transform: uppercase;
color: var(--color-primary);
margin-bottom: 1rem;
}
.section-title {
font-family: var(--font-display);
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 300;
color: var(--color-text);
}
/* Portfolio Grid */
#portfolio {
background: var(--color-bg-warm);
}
.portfolio-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.portfolio-card {
position: relative;
aspect-ratio: 4/5;
overflow: hidden;
cursor: pointer;
background: var(--color-accent);
}
.portfolio-card img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.8s var(--transition-smooth), opacity 0.4s var(--transition-smooth);
}
.portfolio-card:hover img {
transform: scale(1.08);
}
.portfolio-card-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(44, 44, 44, 0.8) 0%, transparent 60%);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 2rem;
opacity: 0;
transition: opacity 0.4s var(--transition-smooth);
}
.portfolio-card:hover .portfolio-card-overlay {
opacity: 1;
}
.portfolio-card-title {
font-family: var(--font-display);
font-size: 1.8rem;
font-weight: 400;
color: var(--color-white);
margin-bottom: 0.5rem;
}
.portfolio-card-desc {
font-family: var(--font-body);
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.8);
letter-spacing: 0.1em;
}
/* Styled stand-in when a category cover image is missing (same idea as location cards) */
.portfolio-card-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--color-bg-warm) 0%, var(--color-accent) 100%);
font-family: var(--font-display);
font-style: italic;
font-size: 1.3rem;
color: var(--color-primary);
}
/* Touch devices have no hover — keep category titles visible */
@media (hover: none) {
.portfolio-card-overlay { opacity: 1; }
}
/* Client Access Section */
#client-access {
background: var(--color-bg);
}
.client-access-container {
max-width: 600px;
margin: 0 auto;
text-align: center;
}
.client-access-form {
display: flex;
gap: 1rem;
margin-top: 2rem;
flex-wrap: wrap;
justify-content: center;
}
.client-access-input {
flex: 1;
min-width: 250px;
padding: 1rem 1.5rem;
font-family: var(--font-body);
font-size: 0.9rem;
border: 1px solid var(--color-accent);
background: var(--color-white);
color: var(--color-text);
outline: none;
transition: border-color 0.3s;
}
.client-access-input:focus {
border-color: var(--color-primary);
}
.client-access-input::placeholder {
color: var(--color-text-light);
}
/* Social Section */
#connect {
background: var(--color-bg-warm);
}
.social-links {
display: flex;
justify-content: center;
gap: 2rem;
margin-top: 2rem;
flex-wrap: wrap;
}
.social-link {
display: flex;
align-items: center;
gap: 0.75rem;
font-family: var(--font-body);
font-size: 0.85rem;
letter-spacing: 0.1em;
color: var(--color-text);
text-decoration: none;
padding: 1rem 1.5rem;
border: 1px solid transparent;
transition: all 0.3s var(--transition-smooth);
}
.social-link:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.social-link svg {
width: 24px;
height: 24px;
fill: currentColor;
}
/* Footer */
footer {
padding: 3rem;
text-align: center;
border-top: 1px solid var(--color-accent);
}
.footer-logo {
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 300;
letter-spacing: 0.2em;
color: var(--color-primary);
margin-bottom: 1rem;
}
.footer-text {
font-family: var(--font-body);
font-size: 0.75rem;
color: var(--color-text-light);
letter-spacing: 0.1em;
}
/* Mobile Navigation */
.mobile-nav {
display: none;
position: fixed;
inset: 0;
background: var(--color-bg);
z-index: 99;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rem;
opacity: 0;
visibility: hidden;
transition: opacity 0.4s, visibility 0.4s;
}
.mobile-nav.active {
opacity: 1;
visibility: visible;
}
.mobile-nav a {
font-family: var(--font-display);
font-size: 2rem;
font-weight: 300;
color: var(--color-text);
text-decoration: none;
transition: color 0.3s;
}
.mobile-nav a:hover {
color: var(--color-primary);
}
/* Responsive */
@media (max-width: 768px) {
header {
padding: 1rem 1.5rem;
}
header.scrolled {
padding: 0.75rem 1.5rem;
}
nav {
display: none;
}
.nav-toggle {
display: flex;
}
.mobile-nav {
display: flex;
}
section {
padding: 4rem 1.5rem;
}
.hero-content {
padding: 1rem;
}
.portfolio-grid {
grid-template-columns: 1fr;
}
.client-access-form {
flex-direction: column;
}
.client-access-input {
min-width: auto;
}
}
/* ── Date Picker Calendar ───────────────────────────── */
.date-picker { user-select: none; }
.dp-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 0.75rem;
}
.dp-month-label {
font-family: var(--font-display); font-size: 1.15rem; font-weight: 400;
color: var(--color-text);
}
.dp-nav-btn {
background: none; border: 1px solid var(--color-accent); border-radius: 3px;
width: 30px; height: 30px; cursor: pointer; font-size: 1rem;
color: var(--color-text-light); display: flex; align-items: center; justify-content: center;
transition: border-color 0.15s, color 0.15s;
}
.dp-nav-btn:hover:not(:disabled) { border-color: var(--color-primary); color: var(--color-primary); }
.dp-nav-btn:disabled { opacity: 0.35; cursor: default; }
.dp-grid {
display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px;
}
.dp-dow {
text-align: center; font-size: 0.65rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.08em;
color: var(--color-text-light); padding: 0.3rem 0 0.5rem;
}
.dp-day {
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
font-size: 0.85rem; border-radius: 50%; cursor: pointer;
border: 1.5px solid transparent; transition: background 0.15s, border-color 0.15s, color 0.15s;
color: var(--color-text);
}
.dp-day:hover:not(.dp-day--disabled):not(.dp-day--selected) {
border-color: var(--color-primary); color: var(--color-primary);
}
.dp-day--other { color: var(--color-accent); cursor: default; }
.dp-day--past { color: var(--color-accent); cursor: not-allowed; }
.dp-day--busy {
color: var(--color-accent); cursor: not-allowed;
text-decoration: line-through; text-decoration-color: var(--color-accent);
}
.dp-day--disabled { cursor: not-allowed; }
.dp-day--today { border-color: var(--color-accent); }
.dp-day--selected {
background: var(--color-primary); color: var(--color-white) !important;
border-color: var(--color-primary); font-weight: 600;
}
.dp-legend {
display: flex; gap: 1rem; margin-top: 0.75rem; font-size: 0.7rem;
color: var(--color-text-light); flex-wrap: wrap;
}
.dp-legend-dot {
width: 8px; height: 8px; border-radius: 50%; display: inline-block;
vertical-align: middle; margin-right: 3px;
}
.dp-loading {
text-align: center; padding: 1.5rem 0; font-size: 0.82rem;
color: var(--color-text-light); font-style: italic;
}
.dp-status {
font-size: 0.75rem; color: var(--color-text-light); margin-top: 0.5rem;
min-height: 1.1em;
}
.dp-status.dp-status--ok { color: var(--color-primary); }
/* ── Booking Modal ─────────────────────────────────── */
.booking-overlay {
position: fixed;
inset: 0;
background: rgba(44, 44, 44, 0.6);
backdrop-filter: blur(4px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
opacity: 0;
visibility: hidden;
transition: opacity 0.4s var(--transition-smooth), visibility 0.4s;
}
.booking-overlay.active {
opacity: 1;
visibility: visible;
}
.booking-modal {
background: var(--color-bg);
width: 100%;
max-width: 680px;
max-height: 90vh;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
transform: translateY(20px);
transition: transform 0.4s var(--transition-smooth);
}
.booking-header, .booking-nav { flex-shrink: 0; }
.booking-overlay.active .booking-modal {
transform: translateY(0);
}
.booking-close {
position: absolute;
top: 1.25rem;
right: 1.5rem;
width: 32px;
height: 32px;
background: none;
border: none;
cursor: pointer;
color: var(--color-text-light);
font-size: 1.4rem;
line-height: 1;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.3s;
z-index: 1;
}
.booking-close:hover { color: var(--color-text); }
.booking-header {
padding: 2.5rem 3rem 1.5rem;
border-bottom: 1px solid var(--color-accent);
}
.booking-title {
font-family: var(--font-display);
font-size: 1.8rem;
font-weight: 300;
color: var(--color-text);
margin-bottom: 1.5rem;
}
/* Step indicator */
.booking-step-bar {
display: flex;
align-items: flex-start;
}
.booking-step-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
position: relative;
}
.booking-step-item:not(:last-child)::after {
content: '';
position: absolute;
top: 11px;
left: 50%;
right: -50%;
height: 1px;
background: var(--color-accent);
z-index: 0;
}
.booking-step-item.completed:not(:last-child)::after {
background: var(--color-primary);
}
.step-dot {
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid var(--color-accent);
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
color: var(--color-text-light);
position: relative;
z-index: 1;
transition: all 0.3s var(--transition-smooth);
}
.booking-step-item.active .step-dot,
.booking-step-item.completed .step-dot {
border-color: var(--color-primary);
background: var(--color-primary);
color: var(--color-white);
}
.step-label-text {
font-size: 0.58rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-text-light);
transition: color 0.3s;
}
.booking-step-item.active .step-label-text { color: var(--color-primary); }
/* Step panels — the content area scrolls; header and nav stay pinned */
.booking-content {
padding: 2.5rem 3rem;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.step-panel { display: none; animation: stepFadeIn 0.3s var(--transition-smooth); }
.step-panel.active { display: block; }
@keyframes stepFadeIn {
from { opacity: 0; transform: translateX(8px); }
to { opacity: 1; transform: translateX(0); }
}
.step-heading {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 300;
color: var(--color-text);
margin-bottom: 0.4rem;
}
.step-subheading {
font-size: 0.85rem;
color: var(--color-text-light);
margin-bottom: 2rem;
}
.booking-field { margin-bottom: 1.5rem; }
.booking-label {
display: block;
font-size: 0.72rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--color-text-light);
margin-bottom: 0.5rem;
}
.booking-input {
width: 100%;
padding: 0.9rem 1rem;
font-family: var(--font-body);
font-size: 0.9rem;
border: 1px solid var(--color-accent);
background: var(--color-white);
color: var(--color-text);
outline: none;
transition: border-color 0.3s;
}
.booking-input:focus { border-color: var(--color-primary); }
/* Option cards */
.option-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 1rem;
}
.option-card {
padding: 1.25rem 1rem;
border: 1px solid var(--color-accent);
background: var(--color-white);
cursor: pointer;
text-align: center;
transition: all 0.3s var(--transition-smooth);
user-select: none;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 6.5rem;
}
.option-card:hover { border-color: var(--color-primary); }
.option-card.selected {
border-color: var(--color-primary);
background: var(--color-bg-warm);
}
.option-card-title {
font-family: var(--font-display);
font-size: 1.1rem;
font-weight: 400;
color: var(--color-text);
margin-bottom: 0.25rem;
}
.option-card-desc { font-size: 0.75rem; color: var(--color-text-light); }
/* Length cards — larger variant with price */
.length-grid { grid-template-columns: 1fr 1fr; }
.length-card { padding: 2rem 1.25rem; }
.length-card-badge {
font-size: 0.62rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--color-primary);
margin-bottom: 0.6rem;
}
.length-card-price {
font-family: var(--font-display);
font-size: 2.2rem;
font-weight: 300;
color: var(--color-text);
line-height: 1;
margin: 0.4rem 0 0.6rem;
}
.length-card .option-card-desc { line-height: 1.5; margin-top: 0.5rem; }
/* "Other" free-text field in session type step */
.other-field {
margin-top: 1rem;
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-viewer {
border: 1px solid var(--color-accent);
max-height: 300px;
overflow-y: auto;
margin-bottom: 0.75rem;
background: #e8e8e8;
position: relative;
scroll-behavior: smooth;
}
.contract-viewer canvas {
display: block;
width: 100%;
margin-bottom: 2px;
}
.contract-loading {
padding: 2.5rem 1rem;
text-align: center;
font-style: italic;
font-size: 0.85rem;
color: var(--color-text-light);
background: var(--color-bg-warm);
}
.contract-fallback {
padding: 1.25rem 1.5rem;
font-size: 0.8rem;
line-height: 1.7;
color: var(--color-text);
background: var(--color-white);
white-space: pre-line;
}
.contract-fallback h4 {
font-family: var(--font-display);
font-size: 1.1rem;
font-weight: 400;
margin-bottom: 0.75rem;
letter-spacing: 0.05em;
}
.contract-scroll-notice {
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-primary);
text-align: center;
margin-bottom: 1rem;
transition: opacity 0.4s;
}
.contract-scroll-notice.hidden { opacity: 0; pointer-events: none; }
.sig-section { transition: opacity 0.3s; }
.sig-section.locked { opacity: 0.35; pointer-events: none; }
.sig-pad-wrapper {
position: relative;
border: 1px solid var(--color-accent);
background: #fff;
margin-bottom: 1rem;
cursor: crosshair;
}
.sig-pad-wrapper:focus-within { border-color: var(--color-primary); }
#sig-canvas {
display: block;
width: 100%;
height: 110px;
touch-action: none;
}
.sig-clear-btn {
position: absolute;
top: 0.4rem;
right: 0.4rem;
font-size: 0.65rem;
letter-spacing: 0.1em;
text-transform: uppercase;
background: none;
border: 1px solid var(--color-accent);
color: var(--color-text-light);
padding: 0.2rem 0.55rem;
cursor: pointer;
transition: border-color 0.2s, color 0.2s;
}
.sig-clear-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
.booking-checkbox-label {
display: flex;
align-items: flex-start;
gap: 0.75rem;
cursor: pointer;
font-size: 0.85rem;
color: var(--color-text);
line-height: 1.5;
}
.booking-checkbox-label input[type="checkbox"] {
margin-top: 3px;
accent-color: var(--color-primary);
flex-shrink: 0;
}
/* Payment step */
.payment-amount {
font-family: var(--font-display);
font-size: 2.5rem;
font-weight: 300;
color: var(--color-primary);
margin: 0.5rem 0 1.25rem;
text-align: center;
}
.payment-note {
background: var(--color-bg-warm);
border-left: 2px solid var(--color-primary);
padding: 0.9rem 1.25rem;
margin-bottom: 1.25rem;
font-size: 0.8rem;
color: var(--color-text);
line-height: 1.6;
}
.pay-summary {
border: 1px solid var(--color-accent);
margin-bottom: 1.25rem;
}
.pay-summary-row {
display: flex;
justify-content: space-between;
padding: 0.5rem 0.9rem;
font-size: 0.8rem;
border-bottom: 1px solid var(--color-accent);
}
.pay-summary-row:last-child { border-bottom: none; }
.pay-summary-row span:first-child { color: var(--color-text-light); }
.pay-summary-row span:last-child { font-weight: 500; color: var(--color-text); text-align: right; max-width: 60%; }
.venmo-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 0.85rem 1.5rem;
background: #3D95CE;
color: #fff;
text-decoration: none;
font-size: 0.9rem;
font-family: var(--font-body);
letter-spacing: 0.04em;
font-weight: 600;
margin-bottom: 1.25rem;
transition: background 0.2s;
}
.venmo-btn:hover { background: #2d7db0; }
.venmo-qr {
display: flex;
justify-content: center;
margin-bottom: 1.25rem;
}
.venmo-qr svg {
width: 160px;
height: 160px;
border: 1px solid var(--color-accent);
padding: 6px;
background: var(--color-bg-warm);
}
/* Confirm / summary */
.confirm-summary {
background: var(--color-bg-warm);
border: 1px solid var(--color-accent);
padding: 1.25rem 1.5rem;
margin-bottom: 2rem;
}
.confirm-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-accent);
font-size: 0.85rem;
}
.confirm-row:last-child { border-bottom: none; }
.confirm-label {
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-text-light);
}
.confirm-value { color: var(--color-text); }
/* Error / success */
.booking-error {
font-size: 0.8rem;
color: #b94040;
margin-top: 0.5rem;
}
.booking-success {
text-align: center;
padding: 3rem 1rem;
}
.booking-success-check {
width: 56px;
height: 56px;
border-radius: 50%;
border: 1px solid var(--color-primary);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1.5rem;
color: var(--color-primary);
font-size: 1.4rem;
}
.booking-success h3 {
font-family: var(--font-display);
font-size: 1.8rem;
font-weight: 300;
margin-bottom: 0.75rem;
}
.booking-success p { font-size: 0.9rem; color: var(--color-text-light); max-width: 400px; margin: 0 auto; }
/* Nav bar */
.booking-nav {
padding: 1.5rem 3rem;
border-top: 1px solid var(--color-accent);
display: flex;
justify-content: space-between;
align-items: center;
}
.booking-nav-right { display: flex; align-items: center; gap: 1rem; }
.step-counter { font-size: 0.72rem; color: var(--color-text-light); letter-spacing: 0.1em; }
/* Nav "Book" button */
.nav-book-btn {
font-family: var(--font-body);
font-size: 0.75rem;
font-weight: 400;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--color-primary);
border: 1px solid var(--color-primary);
background: transparent;
padding: 0.5rem 1.25rem;
cursor: pointer;
transition: all 0.3s var(--transition-smooth);
text-decoration: none;
}
.nav-book-btn:hover {
background: var(--color-primary);
color: var(--color-white);
}
@media (max-width: 768px) {
.booking-header { padding: 2rem 1.5rem 1.25rem; }
.booking-content { padding: 1.75rem 1.5rem; }
.booking-nav { padding: 1.25rem 1.5rem; }
.step-label-text { display: none; }
.booking-modal { max-width: 100%; max-height: 100vh; height: 100%; }
}
/* Reveal animations */
.reveal {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.8s var(--transition-smooth), transform 0.8s var(--transition-smooth);
}
.reveal.visible {
opacity: 1;
transform: translateY(0);
}
</style>
</head>
<body>
<!-- Page Loader -->
<div class="page-loader" id="loader">
<div class="loader-content">
<div class="loader-logo" id="loader-logo">LISILOU</div>
<div class="loader-line"></div>
</div>
</div>
<!-- Header -->
<header id="header">
<a href="#" class="logo" id="header-logo">LISILOU</a>
<nav id="main-nav">
<a href="#portfolio">Portfolio</a>
<a href="#client-access">Client Access</a>
<a href="/my-bookings">My Bookings</a>
<a href="#connect">Connect</a>
<button class="nav-book-btn" onclick="openBooking()">Book a Session</button>
</nav>
<div class="nav-toggle" id="nav-toggle">
<span></span>
<span></span>
<span></span>
</div>
</header>
<!-- Mobile Navigation -->
<div class="mobile-nav" id="mobile-nav">
<a href="#portfolio" class="mobile-nav-link">Portfolio</a>
<a href="#client-access" class="mobile-nav-link">Client Access</a>
<a href="/my-bookings" class="mobile-nav-link">My Bookings</a>
<a href="#connect" class="mobile-nav-link">Connect</a>
<a href="#" class="mobile-nav-link" onclick="mobileNav.classList.remove('active'); openBooking(); return false;">Book a Session</a>
</div>
<!-- Hero Section -->
<section class="hero">
<div class="hero-bg" id="hero-bg"></div>
<div class="hero-pattern"></div>
<div class="hero-content">
<p class="hero-eyebrow" id="hero-eyebrow">Photography</p>
<h1 class="hero-title" id="hero-title">LisiLou <em>Photography</em></h1>
<p class="hero-tagline" id="hero-tagline">Capturing life's beautiful moments</p>
<div class="hero-cta">
<a href="#portfolio" class="btn btn-filled">View Portfolio</a>
<a href="#client-access" class="btn">Client Access</a>
<button class="btn" onclick="openBooking()">Book a Session</button>
</div>
</div>
<div class="scroll-indicator">
<span>Scroll</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 5v14M19 12l-7 7-7-7"/>
</svg>
</div>
</section>
<!-- Portfolio Section -->
<section id="portfolio">
<div class="section-header reveal">
<p class="section-eyebrow">The Work</p>
<h2 class="section-title">Portfolio</h2>
</div>
<div class="portfolio-grid" id="portfolio-grid">
<!-- Portfolio items will be dynamically inserted -->
</div>
</section>
<!-- Client Access Section -->
<section id="client-access">
<div class="client-access-container reveal">
<div class="section-header">
<p class="section-eyebrow">Clients</p>
<h2 class="section-title" id="client-title">Gallery Access</h2>
</div>
<p id="client-description">Enter your gallery code to view and download your photos</p>
<form class="client-access-form" id="client-form">
<input type="text" class="client-access-input" id="gallery-code" placeholder="Enter your gallery code" required>
<button type="submit" class="btn btn-filled">View Gallery</button>
</form>
</div>
</section>
<!-- Connect Section -->
<section id="connect">
<div class="section-header reveal">
<p class="section-eyebrow">Get in Touch</p>
<h2 class="section-title">Let's Connect</h2>
</div>
<div class="social-links" id="social-links">
<!-- Social links will be dynamically inserted -->
</div>
</section>
<!-- Footer -->
<footer>
<div class="footer-logo" id="footer-logo">LISILOU</div>
<p class="footer-text">&copy; <span id="current-year"></span> LisiLou Photography. All rights reserved.</p>
</footer>
<!-- ── Booking Modal ───────────────────────────────────── -->
<div class="booking-overlay" id="booking-overlay" role="dialog" aria-modal="true" aria-label="Book a Session">
<div class="booking-modal">
<button class="booking-close" id="booking-close" aria-label="Close"></button>
<div class="booking-header">
<h2 class="booking-title">Book a Session</h2>
<div class="booking-step-bar" id="booking-step-bar">
<div class="booking-step-item" data-step="1"><div class="step-dot">1</div><span class="step-label-text">Date</span></div>
<div class="booking-step-item" data-step="2"><div class="step-dot">2</div><span class="step-label-text">Session</span></div>
<div class="booking-step-item" data-step="3"><div class="step-dot">3</div><span class="step-label-text">Length</span></div>
<div class="booking-step-item" data-step="4"><div class="step-dot">4</div><span class="step-label-text">Location</span></div>
<div class="booking-step-item" data-step="5"><div class="step-dot">5</div><span class="step-label-text">Info</span></div>
<div class="booking-step-item" data-step="6"><div class="step-dot">6</div><span class="step-label-text">Contract</span></div>
<div class="booking-step-item" data-step="7"><div class="step-dot">7</div><span class="step-label-text">Payment</span></div>
</div>
</div>
<div class="booking-content">
<!-- Step 1: Date -->
<div class="step-panel" id="step-1">
<h3 class="step-heading">Choose a Date</h3>
<p class="step-subheading">Select an available date for your session.</p>
<div class="date-picker" id="date-picker">
<div class="dp-header">
<button class="dp-nav-btn" id="dp-prev" aria-label="Previous month">&#8249;</button>
<span class="dp-month-label" id="dp-month-label"></span>
<button class="dp-nav-btn" id="dp-next" aria-label="Next month">&#8250;</button>
</div>
<div class="dp-grid" id="dp-grid"></div>
<div class="dp-legend">
<span><span class="dp-legend-dot" style="background:var(--color-primary)"></span>Selected</span>
<span><span class="dp-legend-dot" style="background:var(--color-accent)"></span>Unavailable</span>
</div>
<div class="dp-status" id="dp-status"></div>
</div>
<input type="hidden" id="session-date" value="">
</div>
<!-- Step 2: Session Type — rendered from site.json by renderSessionTypeCards() -->
<div class="step-panel" id="step-2">
<h3 class="step-heading">Session Type</h3>
<p class="step-subheading">What kind of session are you looking for?</p>
<div class="option-grid" id="session-type-options"></div>
<div class="other-field booking-field" id="other-type-field">
<label class="booking-label" for="other-type-text">Tell us about your session</label>
<input type="text" class="booking-input" id="other-type-text" placeholder="Describe what you have in mind">
</div>
</div>
<!-- Step 3: Session Length — rendered from site.json by renderSessionLengthCards() -->
<div class="step-panel" id="step-3">
<h3 class="step-heading">Session Length</h3>
<p class="step-subheading">How much time would you like?</p>
<div class="option-grid length-grid" id="session-length-options"></div>
</div>
<!-- Step 4: Location — rendered from site.json by renderLocationCards() -->
<div class="step-panel" id="step-4">
<h3 class="step-heading">Pick a Location</h3>
<p class="step-subheading">Choose where you'd like your session to take place.</p>
<div class="location-grid" id="location-options"></div>
<div class="location-detail" id="location-detail">
<div class="location-detail-inner">
<div class="location-gallery" id="location-detail-gallery"></div>
<div class="location-detail-name" id="location-detail-name"></div>
<p class="location-detail-desc" id="location-detail-desc"></p>
<div class="location-tags" id="location-detail-tags"></div>
</div>
</div>
</div>
<!-- Step 5: Contract -->
<!-- Step 5: Your Info -->
<div class="step-panel" id="step-5">
<h3 class="step-heading">Your Information</h3>
<p class="step-subheading">How should we reach you to confirm your session?</p>
<div class="booking-field">
<label class="booking-label" for="client-name">Full Name</label>
<input type="text" class="booking-input" id="client-name" placeholder="Your full name" autocomplete="name">
</div>
<div class="booking-field">
<label class="booking-label" for="client-email">Email Address</label>
<input type="email" class="booking-input" id="client-email" placeholder="your@email.com" autocomplete="email">
</div>
<div class="booking-field">
<label class="booking-label" for="client-phone">Phone Number</label>
<input type="tel" class="booking-input" id="client-phone" placeholder="(555) 000-0000" autocomplete="tel">
</div>
<div class="booking-error" id="info-error" style="display:none;"></div>
</div>
<!-- Step 6: Contract — filled by initContractStep() -->
<div class="step-panel" id="step-6">
<h3 class="step-heading">Model Release &amp; Contract</h3>
<p class="step-subheading">Read the full agreement below, then sign and type your name.</p>
<div class="contract-viewer" id="contract-viewer">
<div class="contract-loading" id="contract-loading">Loading contract…</div>
</div>
<div class="contract-scroll-notice" id="contract-scroll-notice">Scroll to the bottom to enable signing ↓</div>
<div class="sig-section locked" id="sig-section">
<div class="booking-label" style="margin-bottom:0.4rem;">Your Signature</div>
<div class="sig-pad-wrapper">
<canvas id="sig-canvas"></canvas>
<button type="button" class="sig-clear-btn" onclick="clearSignature()">Clear</button>
</div>
<div class="booking-field">
<label class="booking-label" for="contract-name">Full Name (type to confirm)</label>
<input type="text" class="booking-input" id="contract-name" placeholder="Your full legal name" autocomplete="name">
</div>
</div>
<div class="booking-error" id="contract-error" style="display:none;"></div>
</div>
<!-- Step 7: Payment — filled by initPaymentStep() -->
<div class="step-panel" id="step-7">
<h3 class="step-heading">Booking Fee</h3>
<p class="step-subheading">Send payment via Venmo to reserve your date.</p>
<div class="pay-summary" id="pay-summary">
<div class="pay-summary-row"><span>Date</span><span id="pay-summary-date"></span></div>
<div class="pay-summary-row"><span>Session</span><span id="pay-summary-type"></span></div>
<div class="pay-summary-row"><span>Length</span><span id="pay-summary-length"></span></div>
<div class="pay-summary-row"><span>Location</span><span id="pay-summary-location"></span></div>
</div>
<div class="payment-amount" id="payment-amount"></div>
<a class="venmo-btn" id="venmo-link" href="#" target="_blank" rel="noopener">
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor" style="vertical-align:middle;margin-right:0.4rem"><path d="M15.25 1C13.5 4.25 11.1 8.15 9.5 11H6.75L5 2.25l-4 .75 2.25 12.5h5.5C11.5 11.5 16.5 4.25 17 1h-1.75z"/></svg>
Pay on Venmo
</a>
<div class="venmo-qr" id="venmo-qr"></div>
<p class="payment-note">
Your session is reserved once we confirm receipt — usually within 24 hours.
No Venmo? Send the exact amount and session note via any method and check the box below.
</p>
<label class="booking-checkbox-label">
<input type="checkbox" id="payment-sent">
I've sent the booking fee
</label>
<div class="booking-error" id="payment-error" style="display:none;">Please confirm you've sent payment to continue.</div>
<div class="booking-error" id="confirm-error" style="display:none;"></div>
</div>
<!-- REMOVED step-7 confirm panel — client info now in step 5 -->
<!-- keep dummy IDs for any lingering JS references -->
<div style="display:none">
<span id="summary-date"></span><span id="summary-type"></span>
<span id="summary-length"></span><span id="summary-location"></span>
<input id="REMOVED_client-name">
<input id="client-email-removed">
</div>
<!-- Success -->
<div class="step-panel" id="step-success">
<div class="booking-success">
<div class="booking-success-check"></div>
<h3>You're Booked!</h3>
<p>Your booking request has been received. You'll hear from us within 24 hours to confirm your session once we verify your payment.</p>
</div>
</div>
</div><!-- .booking-content -->
<div class="booking-nav" id="booking-nav">
<button class="btn" id="btn-back" style="display:none;">Back</button>
<div class="booking-nav-right">
<span class="step-counter" id="step-counter">Step 1 of 7</span>
<button class="btn btn-filled" id="btn-next">Next</button>
</div>
</div>
</div><!-- .booking-modal -->
</div><!-- .booking-overlay -->
<script>
// Fetch all Immich album images via proxy
async function getImmichAlbumImages(baseUrl, prefix, albumSlug) {
try {
// Step 1: Fetch the share page to get the share key from og:image
const proxyUrl = `/api/fetch-immich${prefix}${albumSlug}`;
const response = await fetch(proxyUrl);
const html = await response.text();
// Extract share key from og:image URL
const ogMatch = html.match(/<meta\s+property="og:image"\s+content="[^"]+\?key=([^"]+)"/);
if (!ogMatch || !ogMatch[1]) {
console.log(`Could not find share key for album: ${albumSlug}`);
return [];
}
const shareKey = ogMatch[1];
// Step 2: Get shared link info
const sharedLinkResponse = await fetch(`/api/fetch-immich/api/shared-links/me?key=${shareKey}`);
const sharedLinkData = await sharedLinkResponse.json();
let assets = [];
// Handle both ALBUM and INDIVIDUAL share types
if (sharedLinkData.type === 'ALBUM' && sharedLinkData.album?.id) {
// For album shares, fetch the full album to get all assets
const albumResponse = await fetch(`/api/fetch-immich/api/albums/${sharedLinkData.album.id}?key=${shareKey}`);
const albumData = await albumResponse.json();
assets = albumData.assets || [];
} else if (sharedLinkData.assets && sharedLinkData.assets.length > 0) {
// For individual asset shares, assets are directly in the response
assets = sharedLinkData.assets;
}
if (assets.length === 0) {
console.log(`No assets found in: ${albumSlug}`);
return [];
}
// Immich's default thumbnail size is a 250px-wide WebP — too small for
// full-bleed portfolio cards. size=preview serves a ~1440px JPEG instead.
const thumbnailUrls = assets.map(asset =>
`${baseUrl}/api/assets/${asset.id}/thumbnail?key=${shareKey}&size=preview`
);
return thumbnailUrls;
} catch (error) {
console.log(`Could not fetch images for album: ${albumSlug}`, error);
}
return [];
}
// Swap a missing category cover for a styled placeholder (keeps the card elegant)
function portfolioCoverFallback(img) {
const fallback = document.createElement('div');
fallback.className = 'portfolio-card-fallback';
fallback.textContent = img.alt || 'Coming soon';
img.replaceWith(fallback);
}
// Start carousel for a portfolio card
function startCarousel(cardElement, images) {
if (images.length <= 1) return;
let currentIndex = 0;
const imgElement = cardElement.querySelector('img');
setInterval(() => {
currentIndex = (currentIndex + 1) % images.length;
imgElement.style.opacity = '0';
setTimeout(() => {
imgElement.src = images[currentIndex];
imgElement.style.opacity = '1';
}, 400);
}, 4000); // Change image every 4 seconds
}
// Configuration loader
async function loadConfig() {
try {
const response = await fetch('/config/site.json');
const config = await response.json();
await applyConfig(config);
} catch (error) {
console.log('Using default configuration');
}
}
async function applyConfig(config) {
// Store config globally: immich for thumbnails, siteConfig for the booking form
window.immichConfig = config.immich || {};
window.siteConfig = config;
// Apply theme colors
if (config.theme) {
const root = document.documentElement;
if (config.theme.primaryColor) root.style.setProperty('--color-primary', config.theme.primaryColor);
if (config.theme.accentColor) root.style.setProperty('--color-accent', config.theme.accentColor);
if (config.theme.textColor) root.style.setProperty('--color-text', config.theme.textColor);
if (config.theme.backgroundColor) root.style.setProperty('--color-bg', config.theme.backgroundColor);
}
// Apply site content
if (config.site) {
document.title = config.site.title || document.title;
const siteName = config.site.title?.replace(' Photography', '') || 'LISILOU';
document.getElementById('loader-logo').textContent = siteName.toUpperCase();
document.getElementById('header-logo').textContent = siteName.toUpperCase();
document.getElementById('footer-logo').textContent = siteName.toUpperCase();
document.getElementById('hero-title').innerHTML = `${siteName} <em>Photography</em>`;
if (config.site.tagline) {
document.getElementById('hero-tagline').textContent = config.site.tagline;
}
if (config.site.heroImage) {
// Only activate the photo treatment once the image actually loads
const probe = new Image();
probe.onload = () => {
const heroBg = document.getElementById('hero-bg');
heroBg.style.backgroundImage = `url('${config.site.heroImage}')`;
heroBg.classList.add('hero-bg--image');
};
probe.src = config.site.heroImage;
}
}
// Apply portfolio categories
if (config.portfolio?.categories) {
const grid = document.getElementById('portfolio-grid');
const baseUrl = window.immichConfig.baseUrl;
const prefix = window.immichConfig.publicAlbumPrefix || '/s/';
// First render the cards with placeholder/fallback images
grid.innerHTML = config.portfolio.categories.map(cat => `
<div class="portfolio-card reveal" data-album-id="${cat.immichAlbumId || ''}" onclick="openPortfolio('${cat.id}', '${cat.immichAlbumId || ''}')">
<img src="${cat.coverImage || '/images/placeholder.jpg'}" alt="${cat.name}" loading="lazy" onerror="portfolioCoverFallback(this)">
<div class="portfolio-card-overlay">
<h3 class="portfolio-card-title">${cat.name}</h3>
<p class="portfolio-card-desc">${cat.description || ''}</p>
</div>
</div>
`).join('');
observeRevealElements();
// Then fetch album images and start carousels for each category
config.portfolio.categories.forEach(async (cat, index) => {
if (cat.immichAlbumId && baseUrl) {
const images = await getImmichAlbumImages(baseUrl, prefix, cat.immichAlbumId);
if (images.length > 0) {
const card = grid.children[index];
let img = card.querySelector('img');
// The cover may have been swapped for the styled fallback — restore an <img>
if (!img) {
const fallback = card.querySelector('.portfolio-card-fallback');
img = document.createElement('img');
img.alt = cat.name;
if (fallback) fallback.replaceWith(img); else card.prepend(img);
}
// Set the first image
img.src = images[0];
// Start the carousel if there are multiple images
if (images.length > 1) {
startCarousel(card, images);
}
}
}
});
}
// Apply social links
if (config.social) {
const socialContainer = document.getElementById('social-links');
const socialIcons = {
instagram: `<svg viewBox="0 0 24 24"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/></svg>`,
facebook: `<svg viewBox="0 0 24 24"><path d="M9 8h-3v4h3v12h5v-12h3.642l.358-4h-4v-1.667c0-.955.192-1.333 1.115-1.333h2.885v-5h-3.808c-3.596 0-5.192 1.583-5.192 4.615v3.385z"/></svg>`,
pinterest: `<svg viewBox="0 0 24 24"><path d="M12 0c-6.627 0-12 5.372-12 12 0 5.084 3.163 9.426 7.627 11.174-.105-.949-.2-2.405.042-3.441.218-.937 1.407-5.965 1.407-5.965s-.359-.719-.359-1.782c0-1.668.967-2.914 2.171-2.914 1.023 0 1.518.769 1.518 1.69 0 1.029-.655 2.568-.994 3.995-.283 1.194.599 2.169 1.777 2.169 2.133 0 3.772-2.249 3.772-5.495 0-2.873-2.064-4.882-5.012-4.882-3.414 0-5.418 2.561-5.418 5.207 0 1.031.397 2.138.893 2.738.098.119.112.224.083.345l-.333 1.36c-.053.22-.174.267-.402.161-1.499-.698-2.436-2.889-2.436-4.649 0-3.785 2.75-7.262 7.929-7.262 4.163 0 7.398 2.967 7.398 6.931 0 4.136-2.607 7.464-6.227 7.464-1.216 0-2.359-.631-2.75-1.378l-.748 2.853c-.271 1.043-1.002 2.35-1.492 3.146 1.124.347 2.317.535 3.554.535 6.627 0 12-5.373 12-12 0-6.628-5.373-12-12-12z"/></svg>`,
tiktok: `<svg viewBox="0 0 24 24"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg>`
};
let links = '';
if (config.social.instagram) {
links += `<a href="https://instagram.com/${config.social.instagram}" target="_blank" class="social-link">${socialIcons.instagram}<span>@${config.social.instagram}</span></a>`;
}
if (config.social.facebook) {
links += `<a href="https://facebook.com/${config.social.facebook}" target="_blank" class="social-link">${socialIcons.facebook}<span>Facebook</span></a>`;
}
if (config.social.pinterest) {
links += `<a href="https://pinterest.com/${config.social.pinterest}" target="_blank" class="social-link">${socialIcons.pinterest}<span>Pinterest</span></a>`;
}
if (config.social.tiktok) {
links += `<a href="https://tiktok.com/@${config.social.tiktok}" target="_blank" class="social-link">${socialIcons.tiktok}<span>TikTok</span></a>`;
}
socialContainer.innerHTML = links;
}
// Apply client access config
if (config.clientAccess) {
if (config.clientAccess.title) {
document.getElementById('client-title').textContent = config.clientAccess.title;
}
if (config.clientAccess.description) {
document.getElementById('client-description').textContent = config.clientAccess.description;
}
if (config.clientAccess.placeholder) {
document.getElementById('gallery-code').placeholder = config.clientAccess.placeholder;
}
}
}
// Portfolio navigation
function openPortfolio(categoryId, albumId) {
if (albumId && window.immichConfig?.baseUrl) {
window.open(`${window.immichConfig.baseUrl}${window.immichConfig.publicAlbumPrefix || '/share/'}${albumId}`, '_blank');
}
}
// Client gallery access
document.getElementById('client-form').addEventListener('submit', function(e) {
e.preventDefault();
const code = document.getElementById('gallery-code').value.trim();
if (code && window.immichConfig?.baseUrl) {
window.location.href = `${window.immichConfig.baseUrl}${window.immichConfig.publicAlbumPrefix || '/share/'}${code}`;
}
});
// Header scroll effect
window.addEventListener('scroll', function() {
const header = document.getElementById('header');
if (window.scrollY > 50) {
header.classList.add('scrolled');
} else {
header.classList.remove('scrolled');
}
});
// Mobile navigation
const navToggle = document.getElementById('nav-toggle');
const mobileNav = document.getElementById('mobile-nav');
const mobileNavLinks = document.querySelectorAll('.mobile-nav-link');
navToggle.addEventListener('click', function() {
mobileNav.classList.toggle('active');
navToggle.classList.toggle('active');
});
mobileNavLinks.forEach(link => {
link.addEventListener('click', function() {
mobileNav.classList.remove('active');
navToggle.classList.remove('active');
});
});
// Reveal animations on scroll
function observeRevealElements() {
const reveals = document.querySelectorAll('.reveal');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
}
});
}, { threshold: 0.1 });
reveals.forEach(el => observer.observe(el));
}
// (window.siteConfig is set at the top of applyConfig for the booking form)
// ── Date Picker ───────────────────────────────────────
const _dpState = {
year: 0, month: 0, // currently displayed month
selectedDate: '', // YYYY-MM-DD
busyCache: {}, // "YYYY-MM" → Set of YYYY-MM-DD strings
loadingMonths: new Set(), // months currently being fetched
calendarConfigured: null, // null = unknown, true/false after first fetch
};
// Minimum selectable date: tomorrow (no same-day bookings)
function dpMinDate() {
const d = new Date();
d.setDate(d.getDate() + 1);
return d.toISOString().slice(0, 10);
}
function dpIsBusy(dateStr) {
const key = dateStr.slice(0, 7);
const set = _dpState.busyCache[key];
return set ? set.has(dateStr) : false;
}
function dpIsDisabled(dateStr) {
return dateStr < dpMinDate() || dpIsBusy(dateStr);
}
async function dpFetchMonth(year, month) {
const key = `${year}-${String(month).padStart(2, '0')}`;
if (_dpState.busyCache[key] !== undefined || _dpState.loadingMonths.has(key)) return;
_dpState.loadingMonths.add(key);
try {
const res = await fetch(`/api/availability?year=${year}&month=${month}`);
if (!res.ok) throw new Error(res.status);
const { busy, configured } = await res.json();
_dpState.busyCache[key] = new Set(busy);
if (_dpState.calendarConfigured === null) {
_dpState.calendarConfigured = configured;
}
} catch (e) {
// On error, treat month as having no busy dates (fail-open)
_dpState.busyCache[key] = new Set();
} finally {
_dpState.loadingMonths.delete(key);
// Re-render if this is the currently displayed month
const cur = `${_dpState.year}-${String(_dpState.month).padStart(2, '0')}`;
if (key === cur) dpRender();
}
}
function dpRender() {
const { year, month, selectedDate } = _dpState;
const key = `${year}-${String(month).padStart(2, '0')}`;
const loading = _dpState.loadingMonths.has(key);
const today = new Date().toISOString().slice(0, 10);
const minDate = dpMinDate();
// Update header
document.getElementById('dp-month-label').textContent =
new Date(year, month - 1, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
// Prev button: disable if we're already at the minimum month
const minMonth = minDate.slice(0, 7);
const curMonth = key;
document.getElementById('dp-prev').disabled = curMonth <= minMonth;
const grid = document.getElementById('dp-grid');
if (loading) {
grid.innerHTML = '<div class="dp-loading" style="grid-column:1/-1">Checking availability…</div>';
dpUpdateStatus(selectedDate);
return;
}
const dows = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
let html = dows.map(d => `<div class="dp-dow">${d}</div>`).join('');
const firstDay = new Date(year, month - 1, 1).getDay();
const daysInMonth = new Date(year, month, 0).getDate();
// Leading empty cells
for (let i = 0; i < firstDay; i++) {
html += '<div class="dp-day dp-day--other"></div>';
}
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const isPast = dateStr < minDate;
const isBusy = dpIsBusy(dateStr);
const isToday = dateStr === today;
const isSelected = dateStr === selectedDate;
const isDisabled = isPast || isBusy;
let cls = 'dp-day';
if (isSelected) cls += ' dp-day--selected';
else if (isBusy) cls += ' dp-day--busy dp-day--disabled';
else if (isPast) cls += ' dp-day--past dp-day--disabled';
else if (isToday) cls += ' dp-day--today';
const handler = isDisabled ? '' : `onclick="dpSelectDate('${dateStr}')"`;
html += `<div class="${cls}" ${handler} aria-label="${dateStr}"${isDisabled ? ' aria-disabled="true"' : ''}>${d}</div>`;
}
grid.innerHTML = html;
dpUpdateStatus(selectedDate);
}
function dpUpdateStatus(dateStr) {
const el = document.getElementById('dp-status');
if (!dateStr) { el.textContent = ''; el.className = 'dp-status'; return; }
const friendly = new Date(dateStr + 'T12:00').toLocaleDateString('en-US',
{ weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
el.textContent = 'Selected: ' + friendly;
el.className = 'dp-status dp-status--ok';
}
function dpSelectDate(dateStr) {
if (dpIsDisabled(dateStr)) return;
_dpState.selectedDate = dateStr;
document.getElementById('session-date').value = dateStr;
dpRender();
}
function dpNav(dir) {
let m = _dpState.month + dir;
let y = _dpState.year;
if (m > 12) { m = 1; y++; }
if (m < 1) { m = 12; y--; }
_dpState.month = m;
_dpState.year = y;
dpRender();
// Pre-fetch this month and the next
dpFetchMonth(y, m);
const nextM = m === 12 ? 1 : m + 1;
const nextY = m === 12 ? y + 1 : y;
dpFetchMonth(nextY, nextM);
}
function dpInit() {
const today = new Date();
// Open to current month, or next month if today is in the last week
let y = today.getFullYear();
let m = today.getMonth() + 1;
const daysLeft = new Date(y, m, 0).getDate() - today.getDate();
if (daysLeft < 3) { m++; if (m > 12) { m = 1; y++; } }
_dpState.year = y;
_dpState.month = m;
_dpState.selectedDate = '';
document.getElementById('session-date').value = '';
document.getElementById('dp-prev').onclick = () => dpNav(-1);
document.getElementById('dp-next').onclick = () => dpNav(1);
dpRender(); // Render immediately (may show loading spinner)
dpFetchMonth(y, m);
const nextM = m === 12 ? 1 : m + 1;
const nextY = m === 12 ? y + 1 : y;
dpFetchMonth(nextY, nextM);
}
// ── Booking Flow ───────────────────────────────────────
const BOOKING_STEPS = 7;
const bookingState = {
sessionDate: '', sessionType: '', sessionTypeOther: '', sessionLength: '',
sessionLocation: '', clientName: '', clientEmail: '', clientPhone: '',
contractScrolled: false, signatureDrawn: false,
};
let currentStep = 0;
function renderSessionTypeCards() {
const types = (window.siteConfig?.booking?.sessionTypes || [
{ id: 'individual', label: 'Individual Portrait', description: 'Portrait session' },
{ id: 'couple', label: 'Couple', description: 'Engagement & couples' },
{ id: 'family', label: 'Family', description: 'Family portraits' },
{ id: 'newborn', label: 'Newborn', description: 'Newborn & maternity' },
]).concat([{ id: 'other', label: 'Other', description: 'Something else entirely' }]);
document.getElementById('session-type-options').innerHTML = types.map(t => `
<div class="option-card" data-value="${t.id}" onclick="selectOption(this,'sessionType')">
<div class="option-card-title">${t.label}</div>
<div class="option-card-desc">${t.description || ''}</div>
</div>`).join('');
}
function renderSessionLengthCards() {
const pricing = window.siteConfig?.booking?.pricing || { mini: 75, full: 150 };
document.getElementById('session-length-options').innerHTML = `
<div class="option-card length-card" data-value="mini" onclick="selectOption(this,'sessionLength')">
<div class="length-card-badge">30 minutes</div>
<div class="option-card-title">Mini Session</div>
<div class="length-card-price">$${pricing.mini}</div>
<div class="option-card-desc">A focused session — great for individuals or a single look</div>
</div>
<div class="option-card length-card" data-value="full" onclick="selectOption(this,'sessionLength')">
<div class="length-card-badge">60 minutes</div>
<div class="option-card-title">Full Session</div>
<div class="length-card-price">$${pricing.full}</div>
<div class="option-card-desc">A relaxed session with time for multiple looks and locations</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=&quot;location-img-placeholder&quot;>${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');
}
}
// ── Contract step ──────────────────────────────────────────────────────────
async function initContractStep() {
const viewer = document.getElementById('contract-viewer');
const notice = document.getElementById('contract-scroll-notice');
const sigSection = document.getElementById('sig-section');
// Reset state
bookingState.contractScrolled = false;
bookingState.signatureDrawn = false;
notice.classList.remove('hidden');
sigSection.classList.add('locked');
// Clear any previous render
viewer.innerHTML = '<div class="contract-loading" id="contract-loading">Loading contract…</div>';
const hasPdfJs = typeof window.pdfjsLib !== 'undefined';
let pdfLoaded = false;
if (hasPdfJs) {
window.pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
try {
const loadingEl = document.getElementById('contract-loading');
const pdf = await window.pdfjsLib.getDocument('/api/contracts/template').promise;
if (loadingEl) loadingEl.remove();
for (let p = 1; p <= pdf.numPages; p++) {
const page = await pdf.getPage(p);
const viewport = page.getViewport({ scale: 1.4 });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
viewer.appendChild(canvas);
await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;
}
pdfLoaded = true;
} catch (e) {
// PDF not available or fetch failed — fall through to text fallback
}
}
if (!pdfLoaded) {
viewer.innerHTML = `<div class="contract-fallback">
<h4>Session Agreement &amp; Model Release</h4>
By proceeding you agree to the following terms with LisiLou Photography:
<strong>Session &amp; Payment</strong>
A non-refundable booking fee reserves your date. The remainder is due on the day of the session. Cancellations with less than 48 hours notice forfeit the booking fee. A $25 rescheduling fee applies for changes made within 48 hours.
<strong>Image Rights &amp; Model Release</strong>
You grant LisiLou Photography permission to display, reproduce, and use session images for portfolio, social media, and promotional purposes. Images will not be sold to third parties. You retain the right to print and share your own digital files.
<strong>Delivery</strong>
Edited digital images will be delivered within 23 weeks via a private online gallery. RAW files are not included.
<strong>Full Contract</strong>
A complete service agreement and model release will be provided at your session for signature. This electronic agreement acknowledges the key terms above.
</div>`;
}
setupScrollDetection(viewer);
setupSignaturePad();
}
function setupScrollDetection(viewer) {
function check() {
const atBottom = viewer.scrollHeight - viewer.scrollTop <= viewer.clientHeight + 60;
if (atBottom) {
bookingState.contractScrolled = true;
document.getElementById('contract-scroll-notice').classList.add('hidden');
document.getElementById('sig-section').classList.remove('locked');
viewer.removeEventListener('scroll', check);
}
}
viewer.addEventListener('scroll', check);
// Trigger immediately in case content is shorter than the container
check();
}
function setupSignaturePad() {
const canvas = document.getElementById('sig-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
// Size canvas pixels to its CSS display size
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, rect.width, rect.height);
ctx.strokeStyle = '#2c2c2c';
ctx.lineWidth = 1.8;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
let drawing = false;
function pos(e) {
const r = canvas.getBoundingClientRect();
const src = e.touches ? e.touches[0] : e;
return { x: src.clientX - r.left, y: src.clientY - r.top };
}
function start(e) {
e.preventDefault();
drawing = true;
const { x, y } = pos(e);
ctx.beginPath();
ctx.moveTo(x, y);
}
function move(e) {
if (!drawing) return;
e.preventDefault();
const { x, y } = pos(e);
ctx.lineTo(x, y);
ctx.stroke();
bookingState.signatureDrawn = true;
}
function stop() { drawing = false; }
canvas.addEventListener('mousedown', start);
canvas.addEventListener('mousemove', move);
canvas.addEventListener('mouseup', stop);
canvas.addEventListener('mouseleave', stop);
canvas.addEventListener('touchstart', start, { passive: false });
canvas.addEventListener('touchmove', move, { passive: false });
canvas.addEventListener('touchend', stop);
}
function clearSignature() {
const canvas = document.getElementById('sig-canvas');
const ctx = canvas.getContext('2d');
const rect = canvas.getBoundingClientRect();
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, rect.width, rect.height);
bookingState.signatureDrawn = false;
}
function initPaymentStep() {
const pricing = window.siteConfig?.booking?.pricing || { mini: 75, full: 150 };
const username = window.siteConfig?.booking?.venmoUsername || 'LisiLouPhoto';
const types = window.siteConfig?.booking?.sessionTypes || [];
const locations = window.siteConfig?.locations || [];
const amount = bookingState.sessionLength === 'mini' ? pricing.mini : pricing.full;
const typeLabel = (bookingState.sessionType === 'other' && bookingState.sessionTypeOther)
? `Other — ${bookingState.sessionTypeOther}`
: types.find(t => t.id === bookingState.sessionType)?.label || bookingState.sessionType;
const locLabel = locations.find(l => l.id === bookingState.sessionLocation)?.name || bookingState.sessionLocation;
const lenLabel = bookingState.sessionLength === 'mini' ? 'Mini — 30 min' : 'Full — 60 min';
document.getElementById('pay-summary-date').textContent = bookingState.sessionDate || '—';
document.getElementById('pay-summary-type').textContent = typeLabel || '—';
document.getElementById('pay-summary-length').textContent = lenLabel;
document.getElementById('pay-summary-location').textContent = locLabel || '—';
document.getElementById('payment-amount').textContent = `$${amount}`;
const note = `LisiLou Booking - ${typeLabel} on ${bookingState.sessionDate}`;
const venmoUrl = `https://venmo.com/${username}?txn=pay&amount=${amount}&note=${encodeURIComponent(note)}`;
document.getElementById('venmo-link').href = venmoUrl;
renderVenmoQr(venmoUrl);
}
async function renderVenmoQr(venmoUrl) {
const container = document.getElementById('venmo-qr');
container.innerHTML = '';
try {
const res = await fetch(`/api/venmo-qr?url=${encodeURIComponent(venmoUrl)}`);
if (!res.ok) throw new Error('qr failed');
container.innerHTML = await res.text();
} catch {
// Silent — the button still works without the QR
}
}
function openBooking() {
document.getElementById('booking-overlay').classList.add('active');
document.body.style.overflow = 'hidden';
Object.keys(bookingState).forEach(k => bookingState[k] = '');
document.getElementById('payment-sent').checked = false;
document.getElementById('client-name').value = '';
document.getElementById('client-email').value = '';
document.getElementById('client-phone').value = '';
document.getElementById('other-type-text').value = '';
document.getElementById('other-type-field').style.display = 'none';
document.getElementById('contract-name').value = '';
bookingState.contractScrolled = false;
bookingState.signatureDrawn = false;
dpInit();
renderSessionTypeCards();
renderSessionLengthCards();
renderLocationCards();
const locationDetail = document.getElementById('location-detail');
locationDetail.classList.remove('open');
locationDetail.dataset.locationId = '';
goToStep(1);
}
function closeBooking() {
document.getElementById('booking-overlay').classList.remove('active');
document.body.style.overflow = '';
}
function goToStep(step) {
currentStep = step;
document.querySelectorAll('.step-panel').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.booking-step-item').forEach((item, i) => {
item.classList.remove('active', 'completed');
if (i + 1 < step) item.classList.add('completed');
if (i + 1 === step) item.classList.add('active');
});
const nav = document.getElementById('booking-nav');
const btnBack = document.getElementById('btn-back');
const btnNext = document.getElementById('btn-next');
const counter = document.getElementById('step-counter');
if (step > BOOKING_STEPS) {
document.getElementById('step-success').classList.add('active');
nav.style.display = 'none';
return;
}
document.getElementById('step-' + step).classList.add('active');
nav.style.display = 'flex';
btnBack.style.display = step > 1 ? '' : 'none';
btnNext.textContent = step === BOOKING_STEPS ? 'Submit Booking' : 'Next';
btnNext.disabled = false;
counter.textContent = 'Step ' + step + ' of ' + BOOKING_STEPS;
if (step === 6) initContractStep();
if (step === 7) initPaymentStep();
}
function selectOption(card, field) {
card.closest('.option-grid').querySelectorAll('.option-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
bookingState[field] = card.dataset.value;
if (field === 'sessionType') {
const otherField = document.getElementById('other-type-field');
otherField.style.display = card.dataset.value === 'other' ? 'block' : 'none';
if (card.dataset.value !== 'other') bookingState.sessionTypeOther = '';
}
if (field === 'sessionLength') updatePaymentAmount();
}
function updatePaymentAmount() {
const pricing = window.siteConfig?.booking?.pricing || { mini: 75, full: 150 };
const amount = bookingState.sessionLength === 'mini' ? pricing.mini : pricing.full;
document.getElementById('payment-amount').textContent = '$' + amount;
}
function populateSummary() {
const types = window.siteConfig?.booking?.sessionTypes || [];
const pricing = window.siteConfig?.booking?.pricing || { mini: 75, full: 150 };
const typeLabel = types.find(t => t.id === bookingState.sessionType)?.label
|| bookingState.sessionType || '—';
const displayType = bookingState.sessionType === 'other' && bookingState.sessionTypeOther
? `Other — ${bookingState.sessionTypeOther}`
: typeLabel;
const lengthLabels = {
mini: `Mini Session — 30 min ($${pricing.mini})`,
full: `Full Session — 60 min ($${pricing.full})`,
};
document.getElementById('summary-date').textContent = bookingState.sessionDate || '—';
document.getElementById('summary-type').textContent = displayType;
document.getElementById('summary-length').textContent = lengthLabels[bookingState.sessionLength] || '—';
const loc = (window.siteConfig?.locations || []).find(l => l.id === bookingState.sessionLocation);
document.getElementById('summary-location').textContent = loc?.name || bookingState.sessionLocation || '—';
}
function validateStep(step) {
switch (step) {
case 1:
return !!document.getElementById('session-date').value;
case 2: {
if (!bookingState.sessionType) return false;
if (bookingState.sessionType === 'other') {
const txt = document.getElementById('other-type-text').value.trim();
if (!txt) {
document.getElementById('other-type-text').focus();
return false;
}
bookingState.sessionTypeOther = txt;
}
return true;
}
case 3:
return !!bookingState.sessionLength;
case 4:
return !!bookingState.sessionLocation;
case 5: {
// Your Info step
const name = document.getElementById('client-name').value.trim();
const email = document.getElementById('client-email').value.trim();
const errEl = document.getElementById('info-error');
if (!name || !email) {
errEl.textContent = 'Please enter your name and email address.';
errEl.style.display = 'block';
return false;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errEl.textContent = 'Please enter a valid email address.';
errEl.style.display = 'block';
return false;
}
errEl.style.display = 'none';
return true;
}
case 6: {
// Contract step
const errEl = document.getElementById('contract-error');
if (!bookingState.contractScrolled) {
errEl.textContent = 'Please scroll through the full agreement.';
errEl.style.display = 'block';
return false;
}
if (!bookingState.signatureDrawn) {
errEl.textContent = 'Please sign in the box above.';
errEl.style.display = 'block';
return false;
}
const sigName = document.getElementById('contract-name').value.trim();
if (!sigName) {
errEl.textContent = 'Please type your full name to confirm.';
errEl.style.display = 'block';
document.getElementById('contract-name').focus();
return false;
}
errEl.style.display = 'none';
return true;
}
case 7: {
// Payment step — checkbox + submit
const ok = document.getElementById('payment-sent').checked;
document.getElementById('payment-error').style.display = ok ? 'none' : 'block';
return ok;
}
default:
return true;
}
}
async function submitBooking() {
// Client info is now collected in step 5
bookingState.sessionDate = document.getElementById('session-date').value;
bookingState.clientName = document.getElementById('client-name').value.trim();
bookingState.clientEmail = document.getElementById('client-email').value.trim();
bookingState.clientPhone = document.getElementById('client-phone').value.trim();
const btnNext = document.getElementById('btn-next');
btnNext.textContent = 'Submitting…';
btnNext.disabled = true;
try {
// 1. Create booking — include payment_status since client confirmed sending
const res = await fetch('/api/bookings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: bookingState.clientName,
client_email: bookingState.clientEmail,
client_phone: bookingState.clientPhone,
session_date: bookingState.sessionDate,
session_type: bookingState.sessionType === 'other'
? 'other: ' + bookingState.sessionTypeOther
: bookingState.sessionType,
session_length: bookingState.sessionLength,
location: (window.siteConfig?.locations || []).find(l => l.id === bookingState.sessionLocation)?.name
|| bookingState.sessionLocation,
payment_status: 'pending_confirmation',
}),
});
if (!res.ok) throw new Error('server error');
const { id: bookingId } = await res.json();
// 2. Submit signature captured in step 6
if (bookingState.signatureDrawn && bookingId) {
const sigCanvas = document.getElementById('sig-canvas');
const contractName = document.getElementById('contract-name').value.trim();
await fetch(`/api/bookings/${bookingId}/sign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
signature_png: sigCanvas.toDataURL('image/png'),
full_name: contractName || bookingState.clientName,
}),
});
}
goToStep(BOOKING_STEPS + 1);
} catch {
const errEl = document.getElementById('confirm-error');
errEl.textContent = 'Something went wrong — please try again.';
errEl.style.display = 'block';
btnNext.textContent = 'Submit Booking';
btnNext.disabled = false;
}
}
document.getElementById('btn-next').addEventListener('click', async () => {
bookingState.sessionDate = document.getElementById('session-date').value;
if (!validateStep(currentStep)) return;
if (currentStep === BOOKING_STEPS) {
await submitBooking();
} else {
goToStep(currentStep + 1);
}
});
document.getElementById('btn-back').addEventListener('click', () => {
if (currentStep > 1) goToStep(currentStep - 1);
});
document.getElementById('booking-close').addEventListener('click', closeBooking);
document.getElementById('booking-overlay').addEventListener('click', e => {
if (e.target === e.currentTarget) closeBooking();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('booking-overlay').classList.contains('active')) closeBooking();
});
// Page load
window.addEventListener('load', function() {
document.getElementById('current-year').textContent = new Date().getFullYear();
loadConfig();
setTimeout(() => {
document.getElementById('loader').classList.add('hidden');
}, 1000);
observeRevealElements();
// Deep link: /#book opens the booking wizard (used by the client portal)
if (window.location.hash === '#book') {
setTimeout(openBooking, 400);
}
});
window.addEventListener('hashchange', function() {
if (window.location.hash === '#book') openBooking();
});
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js" crossorigin="anonymous"></script>
</body>
</html>