480 lines
18 KiB
HTML
480 lines
18 KiB
HTML
{% extends "base.html" %}
|
|
{% block content %}
|
|
<style>
|
|
/* ── Calendar ───────────────────────────────────────────────────── */
|
|
.cal-wrap { margin-top: .5rem; }
|
|
|
|
.cal-nav {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
margin-bottom: .9rem;
|
|
}
|
|
.cal-nav-btn {
|
|
flex-shrink: 0; width: 2rem; height: 2rem;
|
|
border: 1px solid #e2e8f0; border-radius: 50%;
|
|
background: #fff; cursor: pointer;
|
|
font-size: 1.3rem; line-height: 1; color: #4a5568;
|
|
display: flex; align-items: center; justify-content: center;
|
|
transition: background .12s;
|
|
}
|
|
.cal-nav-btn:hover:not(:disabled) { background: #f0f4f8; }
|
|
.cal-nav-btn:disabled { opacity: .3; cursor: default; }
|
|
|
|
#cal-titles {
|
|
display: flex; flex: 1; gap: 0;
|
|
margin: 0 .5rem;
|
|
font-weight: 600; font-size: .9rem; color: #1a202c;
|
|
}
|
|
#cal-titles span { flex: 1; text-align: center; }
|
|
|
|
#cal-months { display: flex; gap: 2rem; }
|
|
.cal-month { flex: 1; min-width: 0; }
|
|
|
|
.cal-grid {
|
|
display: grid; grid-template-columns: repeat(7, 1fr);
|
|
}
|
|
.cal-dow {
|
|
text-align: center; font-size: .72rem; font-weight: 600;
|
|
color: #718096; padding: .4rem 0 .25rem;
|
|
}
|
|
.cal-cell { padding: 1px 0; } /* background set inline for range */
|
|
|
|
.cal-btn {
|
|
width: 100%; aspect-ratio: 1;
|
|
max-width: 2.4rem; max-height: 2.4rem;
|
|
border: none; background: none; cursor: pointer;
|
|
font-size: .875rem; border-radius: 50%;
|
|
display: flex; align-items: center; justify-content: center;
|
|
margin: 0 auto;
|
|
transition: background .1s;
|
|
font-family: inherit; color: #1a202c;
|
|
}
|
|
.cal-btn:hover:not(:disabled):not(.cal-selected) { background: #e2e8f0; }
|
|
.cal-btn.cal-past { color: #d1d5db !important; cursor: default; }
|
|
.cal-btn.cal-today { color: #00539f; font-weight: 700; }
|
|
.cal-btn.cal-selected {
|
|
background: #00539f !important; color: #fff !important; font-weight: 600;
|
|
}
|
|
/* subtly darken the in-range hover */
|
|
.cal-cell.cal-in-range .cal-btn:hover:not(:disabled) { background: #bfdbfe; }
|
|
|
|
.cal-hint { margin-top: .75rem; font-size: .88rem; color: #4a5568; min-height: 1.4rem; }
|
|
.cal-cta { color: #00539f; font-weight: 600; }
|
|
|
|
@media (max-width: 620px) {
|
|
#cal-months { flex-direction: column; gap: 1.25rem; }
|
|
#cal-titles { gap: 0; }
|
|
}
|
|
</style>
|
|
|
|
<div class="card">
|
|
<h2>Plan your journey</h2>
|
|
<form method="get" action="{{ url_for('search') }}" id="search-form">
|
|
|
|
<div class="form-group-lg">
|
|
<label for="station_crs" class="field-label">Departure point</label>
|
|
<select id="station_crs" name="station_crs" class="form-control">
|
|
{% for name, crs in stations %}
|
|
<option value="{{ crs }}" {% if crs == 'BRI' %}selected{% endif %}>{{ name }} ({{ crs }})</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<span class="field-label">Journey type</span>
|
|
<div class="destination-grid" role="radiogroup" aria-label="Journey type">
|
|
<div class="destination-option">
|
|
<input type="radio" id="journey-outbound" name="journey_type" value="outbound" checked>
|
|
<label for="journey-outbound"><strong>Out</strong><span>UK to Europe</span></label>
|
|
</div>
|
|
<div class="destination-option">
|
|
<input type="radio" id="journey-inbound" name="journey_type" value="inbound">
|
|
<label for="journey-inbound"><strong>Back</strong><span>Europe to UK</span></label>
|
|
</div>
|
|
<div class="destination-option">
|
|
<input type="radio" id="journey-return" name="journey_type" value="return">
|
|
<label for="journey-return"><strong>Return</strong><span>Out and back</span></label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<span class="field-label">Eurostar destination</span>
|
|
<div class="destination-grid destination-grid--eurostar" role="radiogroup" aria-label="Eurostar destination">
|
|
{% for destination in destination_options %}
|
|
<div class="destination-option">
|
|
<input type="radio" id="dest-{{ destination.slug }}" name="destination" value="{{ destination.slug }}"
|
|
{% if loop.first %}checked{% endif %} required>
|
|
<label for="dest-{{ destination.slug }}"><strong>{{ destination.city }}</strong><span>{{ destination.destination }}</span></label>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Calendar date picker ──────────────────────────────────────── -->
|
|
<div class="form-group-lg">
|
|
<span class="field-label">Travel dates</span>
|
|
<div class="cal-wrap">
|
|
<div class="cal-nav">
|
|
<button type="button" id="cal-prev" class="cal-nav-btn" aria-label="Previous month">‹</button>
|
|
<div id="cal-titles"></div>
|
|
<button type="button" id="cal-next" class="cal-nav-btn" aria-label="Next month">›</button>
|
|
</div>
|
|
<div id="cal-months"></div>
|
|
<div id="cal-hint" class="cal-hint"></div>
|
|
</div>
|
|
<!-- Hidden inputs submitted with the form -->
|
|
<input type="hidden" id="travel_date" name="travel_date">
|
|
<input type="hidden" id="return_date" name="">
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="min_connection" class="field-label">
|
|
Minimum connection time (Paddington → St Pancras)
|
|
</label>
|
|
<select id="min_connection" name="min_connection" class="form-control">
|
|
{% for mins in valid_min_connections %}
|
|
<option value="{{ mins }}" {% if mins == default_min_connection %}selected{% endif %}>{{ mins }} min</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="max_connection" class="field-label">
|
|
Maximum connection time (Paddington → St Pancras)
|
|
</label>
|
|
<select id="max_connection" name="max_connection" class="form-control">
|
|
{% for mins in valid_max_connections %}
|
|
<option value="{{ mins }}" {% if mins == default_max_connection %}selected{% endif %}>{{ mins }} min</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit" class="btn-primary">Search journeys</button>
|
|
</form>
|
|
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
|
|
const TODAY = new Date('{{ today }}T00:00:00');
|
|
|
|
/* ── state ───────────────────────────────────────────────────────── */
|
|
let viewYear = TODAY.getFullYear();
|
|
let viewMonth = TODAY.getMonth(); // 0-based
|
|
let outDate = new Date(TODAY); // default: today selected
|
|
let retDate = null;
|
|
let hoverDate = null;
|
|
let isReturn = false;
|
|
let retPhase = false; // true while waiting for return-date click
|
|
|
|
/* ── helpers ─────────────────────────────────────────────────────── */
|
|
|
|
function toYMD(d) {
|
|
if (!d) return '';
|
|
return d.getFullYear() + '-' +
|
|
String(d.getMonth() + 1).padStart(2, '0') + '-' +
|
|
String(d.getDate()).padStart(2, '0');
|
|
}
|
|
|
|
function dispDate(d) {
|
|
return d ? d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) : '';
|
|
}
|
|
|
|
function sameDay(a, b) {
|
|
return a && b && a.toDateString() === b.toDateString();
|
|
}
|
|
|
|
function advMonth(y, m, delta) {
|
|
m += delta;
|
|
while (m < 0) { m += 12; y--; }
|
|
while (m > 11) { m -= 12; y++; }
|
|
return [y, m];
|
|
}
|
|
|
|
/* ── sync hidden inputs ──────────────────────────────────────────── */
|
|
|
|
function syncInputs() {
|
|
document.getElementById('travel_date').value = toYMD(outDate);
|
|
var ri = document.getElementById('return_date');
|
|
if (isReturn && retDate) {
|
|
ri.value = toYMD(retDate);
|
|
ri.name = 'return_date';
|
|
} else {
|
|
ri.value = '';
|
|
ri.name = '';
|
|
}
|
|
}
|
|
|
|
/* ── day click ───────────────────────────────────────────────────── */
|
|
|
|
function onDayClick(d) {
|
|
if (!isReturn) {
|
|
outDate = d;
|
|
} else if (!outDate || !retPhase) {
|
|
/* start fresh: set outbound, await return */
|
|
outDate = d;
|
|
retDate = null;
|
|
retPhase = true;
|
|
} else {
|
|
/* selecting return date */
|
|
if (sameDay(d, outDate)) {
|
|
/* tapped same day → reset */
|
|
outDate = null;
|
|
retDate = null;
|
|
retPhase = false;
|
|
} else if (d < outDate) {
|
|
/* earlier than outbound → new outbound, keep retPhase */
|
|
outDate = d;
|
|
} else {
|
|
retDate = d;
|
|
retPhase = false;
|
|
}
|
|
}
|
|
hoverDate = null;
|
|
syncInputs();
|
|
render();
|
|
}
|
|
|
|
/* ── render ──────────────────────────────────────────────────────── */
|
|
|
|
function render() {
|
|
var fmt = { month: 'long', year: 'numeric' };
|
|
var t0 = new Date(viewYear, viewMonth, 1).toLocaleDateString('en-GB', fmt);
|
|
var titleHtml = '<span>' + t0 + '</span>';
|
|
if (isReturn) {
|
|
var next = advMonth(viewYear, viewMonth, 1);
|
|
var t1 = new Date(next[0], next[1], 1).toLocaleDateString('en-GB', fmt);
|
|
titleHtml += '<span>' + t1 + '</span>';
|
|
}
|
|
document.getElementById('cal-titles').innerHTML = titleHtml;
|
|
|
|
document.getElementById('cal-prev').disabled =
|
|
(viewYear === TODAY.getFullYear() && viewMonth === TODAY.getMonth());
|
|
|
|
renderMonths();
|
|
renderHint();
|
|
}
|
|
|
|
function renderMonths() {
|
|
var cont = document.getElementById('cal-months');
|
|
cont.innerHTML = '';
|
|
var n = isReturn ? 2 : 1;
|
|
for (var i = 0; i < n; i++) {
|
|
var ym = advMonth(viewYear, viewMonth, i);
|
|
cont.appendChild(buildMonth(ym[0], ym[1]));
|
|
}
|
|
}
|
|
|
|
function buildMonth(year, month) {
|
|
/* effective range (includes hover preview) */
|
|
var rangeA = null, rangeB = null;
|
|
if (outDate && retDate) {
|
|
rangeA = outDate < retDate ? outDate : retDate;
|
|
rangeB = outDate < retDate ? retDate : outDate;
|
|
} else if (outDate && retPhase && hoverDate && hoverDate > outDate) {
|
|
rangeA = outDate;
|
|
rangeB = hoverDate;
|
|
}
|
|
|
|
var wrap = document.createElement('div');
|
|
wrap.className = 'cal-month';
|
|
|
|
var grid = document.createElement('div');
|
|
grid.className = 'cal-grid';
|
|
|
|
/* day-of-week headers, Mon first */
|
|
['Mo','Tu','We','Th','Fr','Sa','Su'].forEach(function (lbl) {
|
|
var el = document.createElement('div');
|
|
el.className = 'cal-dow';
|
|
el.textContent = lbl;
|
|
grid.appendChild(el);
|
|
});
|
|
|
|
/* offset: Mon=0 … Sun=6 */
|
|
var firstDay = new Date(year, month, 1);
|
|
var offset = firstDay.getDay() - 1;
|
|
if (offset < 0) offset = 6;
|
|
|
|
var lastDate = new Date(year, month + 1, 0).getDate();
|
|
|
|
/* leading empty cells */
|
|
for (var p = 0; p < offset; p++) {
|
|
grid.appendChild(Object.assign(document.createElement('div'), { className: 'cal-cell' }));
|
|
}
|
|
|
|
/* day cells */
|
|
for (var d = 1; d <= lastDate; d++) {
|
|
var date = new Date(year, month, d);
|
|
var past = date < TODAY;
|
|
var isTod = sameDay(date, TODAY);
|
|
var isOut = sameDay(date, outDate);
|
|
var isRet = sameDay(date, retDate);
|
|
var col = (d - 1 + offset) % 7; /* 0=Mon, 6=Sun */
|
|
var inRange = rangeA && rangeB && date > rangeA && date < rangeB;
|
|
var isRA = rangeA && sameDay(date, rangeA);
|
|
var isRB = rangeB && sameDay(date, rangeB);
|
|
|
|
var cell = document.createElement('div');
|
|
cell.className = 'cal-cell' + (inRange ? ' cal-in-range' : '');
|
|
|
|
/* range background: gradient at endpoints, solid between */
|
|
if (isRA && rangeB) {
|
|
cell.style.background = col < 6
|
|
? 'linear-gradient(to right,transparent 50%,#dbeafe 50%)'
|
|
: 'transparent'; /* Sunday: no gradient needed */
|
|
} else if (isRB && rangeA) {
|
|
cell.style.background = col > 0
|
|
? 'linear-gradient(to left,transparent 50%,#dbeafe 50%)'
|
|
: 'transparent'; /* Monday: no gradient needed */
|
|
} else if (inRange) {
|
|
cell.style.background = '#dbeafe';
|
|
}
|
|
|
|
var btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
var cls = 'cal-btn';
|
|
if (past) cls += ' cal-past';
|
|
else if (isTod && !isOut && !isRet) cls += ' cal-today';
|
|
if (isOut || isRet) cls += ' cal-selected';
|
|
btn.className = cls;
|
|
btn.textContent = d;
|
|
btn.disabled = past;
|
|
btn.setAttribute('aria-label',
|
|
date.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }));
|
|
|
|
cell.setAttribute('data-date', toYMD(date));
|
|
cell.setAttribute('data-col', col);
|
|
|
|
if (!past) {
|
|
(function (dt) {
|
|
btn.addEventListener('click', function () { onDayClick(dt); });
|
|
btn.addEventListener('mouseenter', function () {
|
|
if (isReturn && retPhase && (!hoverDate || !sameDay(hoverDate, dt))) {
|
|
hoverDate = dt;
|
|
applyHoverStyles(outDate, dt);
|
|
}
|
|
});
|
|
})(new Date(date));
|
|
}
|
|
|
|
cell.appendChild(btn);
|
|
grid.appendChild(cell);
|
|
}
|
|
|
|
grid.addEventListener('mouseleave', function () {
|
|
if (hoverDate) {
|
|
hoverDate = null;
|
|
applyHoverStyles(
|
|
outDate && retDate ? (outDate < retDate ? outDate : retDate) : null,
|
|
outDate && retDate ? (outDate < retDate ? retDate : outDate) : null
|
|
);
|
|
}
|
|
});
|
|
|
|
wrap.appendChild(grid);
|
|
return wrap;
|
|
}
|
|
|
|
/* ── hover range: update cell styles in-place (no DOM rebuild) ───── */
|
|
|
|
function applyHoverStyles(rangeA, rangeB) {
|
|
document.querySelectorAll('.cal-cell[data-date]').forEach(function (cell) {
|
|
var d = new Date(cell.getAttribute('data-date') + 'T00:00:00');
|
|
var col = parseInt(cell.getAttribute('data-col'), 10);
|
|
var inRange = rangeA && rangeB && d > rangeA && d < rangeB;
|
|
var isRA = rangeA && rangeB && sameDay(d, rangeA);
|
|
var isRB = rangeA && rangeB && sameDay(d, rangeB);
|
|
cell.classList.toggle('cal-in-range', !!inRange);
|
|
if (isRA) {
|
|
cell.style.background = col < 6
|
|
? 'linear-gradient(to right,transparent 50%,#dbeafe 50%)' : '';
|
|
} else if (isRB) {
|
|
cell.style.background = col > 0
|
|
? 'linear-gradient(to left,transparent 50%,#dbeafe 50%)' : '';
|
|
} else if (inRange) {
|
|
cell.style.background = '#dbeafe';
|
|
} else {
|
|
cell.style.background = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderHint() {
|
|
var el = document.getElementById('cal-hint');
|
|
if (!el) return;
|
|
if (!isReturn) {
|
|
el.innerHTML = outDate
|
|
? 'Date: <strong>' + dispDate(outDate) + '</strong>'
|
|
: '<span class="cal-cta">Select a travel date</span>';
|
|
} else if (!outDate) {
|
|
el.innerHTML = '<span class="cal-cta">Select departure date</span>';
|
|
} else if (retPhase) {
|
|
el.innerHTML = 'Depart: <strong>' + dispDate(outDate) +
|
|
'</strong> · <span class="cal-cta">Now select return date</span>';
|
|
} else {
|
|
el.innerHTML = 'Depart: <strong>' + dispDate(outDate) +
|
|
'</strong> · Return: <strong>' + dispDate(retDate) + '</strong>';
|
|
}
|
|
}
|
|
|
|
/* ── navigation ──────────────────────────────────────────────────── */
|
|
|
|
document.getElementById('cal-prev').addEventListener('click', function () {
|
|
var ym = advMonth(viewYear, viewMonth, -1);
|
|
viewYear = ym[0]; viewMonth = ym[1];
|
|
render();
|
|
});
|
|
document.getElementById('cal-next').addEventListener('click', function () {
|
|
var ym = advMonth(viewYear, viewMonth, 1);
|
|
viewYear = ym[0]; viewMonth = ym[1];
|
|
render();
|
|
});
|
|
|
|
/* ── journey type ────────────────────────────────────────────────── */
|
|
|
|
document.querySelectorAll('input[name="journey_type"]').forEach(function (r) {
|
|
r.addEventListener('change', function () {
|
|
var wasReturn = isReturn;
|
|
isReturn = this.value === 'return';
|
|
if (isReturn && !wasReturn) {
|
|
retDate = null;
|
|
retPhase = outDate !== null;
|
|
} else if (!isReturn) {
|
|
retDate = null;
|
|
retPhase = false;
|
|
}
|
|
syncInputs();
|
|
render();
|
|
});
|
|
});
|
|
|
|
/* ── form submit ─────────────────────────────────────────────────── */
|
|
|
|
document.getElementById('search-form').addEventListener('submit', function (e) {
|
|
if (!outDate) {
|
|
e.preventDefault();
|
|
alert('Please select a travel date.');
|
|
return;
|
|
}
|
|
if (isReturn && !retDate) {
|
|
e.preventDefault();
|
|
alert('Please select a return date.');
|
|
return;
|
|
}
|
|
syncInputs();
|
|
});
|
|
|
|
/* ── init ────────────────────────────────────────────────────────── */
|
|
|
|
var initType = document.querySelector('input[name="journey_type"]:checked');
|
|
isReturn = initType && initType.value === 'return';
|
|
retPhase = isReturn && outDate !== null;
|
|
|
|
syncInputs();
|
|
render();
|
|
}());
|
|
</script>
|
|
</div>
|
|
{% endblock %}
|