@use 'sprucecss/scss/spruce' as *;
.delivery-estimate {
border: config('border-width-base', $display) solid color('border');
border-radius: radius('lg');
padding: spacer('m');
@include layout-stack('s');
&__title {
align-items: center;
display: flex;
font-size: font-size('h6');
gap: spacer('xs');
svg {
block-size: 1.25rem;
color: color('primary');
inline-size: 1.25rem;
}
}
&__cutoff {
background-color: color-alpha('success', 0.1, 'alert');
border-radius: radius('md');
color: color('heading');
font-size: config('font-size-sm', $typography);
padding: spacer('xs') spacer('s');
strong {
color: color('success', 'alert');
}
&[hidden] {
display: none;
}
}
&__form {
@include layout-stack('xxs');
}
&__row {
display: flex;
gap: spacer('xs');
.form-control {
flex: 1 1 auto;
min-inline-size: 0;
}
}
&__error {
color: color('invalid', 'form');
font-size: config('font-size-sm', $typography);
&[hidden] {
display: none;
}
}
&__options {
font-size: config('font-size-sm', $typography);
list-style: none;
padding: 0;
@include layout-stack('xs');
&[hidden] {
display: none;
}
li {
align-items: baseline;
display: flex;
gap: spacer('s');
justify-content: space-between;
}
li > span:first-child {
color: color('muted');
}
strong {
color: color('heading');
display: block;
}
li > span:last-child {
color: color('heading');
font-weight: 600;
text-align: end;
}
}
&__returns {
border-block-start: config('border-width-base', $display) solid color('border');
font-size: config('font-size-sm', $typography);
padding-block-start: spacer('s');
}
}
<section class="delivery-estimate" aria-labelledby="delivery-estimate-title" data-delivery-estimate data-cutoff="16">
<h2 class="delivery-estimate__title" id="delivery-estimate-title">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 7h11v9H3z"/><path d="M14 10h4l3 3v3h-7z"/><circle cx="7" cy="17.5" r="1.75"/><circle cx="17" cy="17.5" r="1.75"/></svg>
Delivery
</h2>
<p class="delivery-estimate__cutoff" data-delivery-cutoff hidden></p>
<form class="delivery-estimate__form" action="#" method="get" novalidate data-delivery-form>
<label class="form-label" for="delivery-zip">ZIP code</label>
<div class="delivery-estimate__row">
<input class="form-control form-control--sm" id="delivery-zip" name="zip" type="text" inputmode="numeric" autocomplete="postal-code" pattern="[0-9]{5}" maxlength="5" placeholder="97205" aria-describedby="delivery-zip-error">
<button class="btn btn--secondary btn--sm" type="submit">Check</button>
</div>
<p class="delivery-estimate__error" id="delivery-zip-error" hidden data-delivery-error>Enter a 5-digit ZIP code.</p>
</form>
<div aria-live="polite" data-delivery-results-region>
<ul class="delivery-estimate__options" role="list" hidden data-delivery-results>
<li><span><strong>Standard</strong> Free over $50</span> <span data-delivery-days="5">—</span></li>
<li><span><strong>Express</strong> $12.90</span> <span data-delivery-days="2">—</span></li>
<li><span><strong>Pick up in store</strong> Free</span> <span data-delivery-days="0">—</span></li>
</ul>
</div>
<p class="delivery-estimate__returns"><a href="#">Free 30-day returns</a></p>
</section>
(() => {
const format = new Intl.DateTimeFormat(document.documentElement.lang || 'en', { weekday: 'long', month: 'short', day: 'numeric' });
const addBusinessDays = (date, days) => {
const result = new Date(date);
let added = 0;
while (added < days) {
result.setDate(result.getDate() + 1);
if (result.getDay() !== 0 && result.getDay() !== 6) added += 1;
}
return result;
};
document.querySelectorAll('[data-delivery-estimate]').forEach((section) => {
const form = section.querySelector('[data-delivery-form]');
const zip = form.elements.zip;
const error = section.querySelector('[data-delivery-error]');
const results = section.querySelector('[data-delivery-results]');
const cutoff = section.querySelector('[data-delivery-cutoff]');
const cutoffHour = Number(section.dataset.cutoff);
const updateCutoff = () => {
const now = new Date();
const end = new Date(now);
end.setHours(cutoffHour, 0, 0, 0);
const minutes = Math.round((end - now) / 60000);
cutoff.hidden = minutes <= 0 || now.getDay() === 0 || now.getDay() === 6;
cutoff.innerHTML = `Order within <strong>${Math.floor(minutes / 60)} h ${minutes % 60} min</strong> to ship today.`;
return minutes > 0;
};
form.addEventListener('submit', (event) => {
event.preventDefault();
const isValid = /^[0-9]{5}$/.test(zip.value.trim());
error.hidden = isValid;
zip.setAttribute('aria-invalid', String(!isValid));
if (!isValid) {
results.hidden = true;
zip.focus();
return;
}
const start = updateCutoff() ? new Date() : addBusinessDays(new Date(), 1);
results.querySelectorAll('[data-delivery-days]').forEach((cell) => {
const days = Number(cell.dataset.deliveryDays);
cell.textContent = days === 0 ? 'Ready in 2 hours' : format.format(addBusinessDays(start, days));
});
results.hidden = false;
});
updateCutoff();
setInterval(updateCutoff, 60000);
});
})();