Find an open store nearby

Build a nearby branch list with approximate distances and opening status from your maintained schedules.

JavaScript (browser)Last tested Get the complete example ↓
On this page

Working example

HTML + JavaScript
Use this example

From a ZIP to a useful choice

Enter 02108 to see nearby demo branches, nearest first. Each row shows its approximate distance and opening status, such as Open until 5 PM. Choose a branch from the same list.

You supply the locations and maintained hours. The example combines a Postal lookup, the branch's local time, and its holiday calendar. ParseAPI does not discover a business's hours.

Choose Use this example above to prepare the HTML for your website or localhost. The setup provides a public key and run instructions. Keep secret keys on your server.

1. Supply your branches and hours

The download includes six fictional branches: five with coordinates and one whose location is unknown. Replace these records with your own public location data and maintained schedules.

JavaScript
const businessLocation = { zone: 'America/New_York', country: 'US', region: null };
const weekdayHours = { location: businessLocation, opens: 9 * 60, closes: 17 * 60, week: 'weekdays', closures: new Set(), holidayPolicy: 'public' };

// Replace these fictional branches with your own public location data.
const STORES = [
  { id: 'beacon', name: 'Beacon demo branch', area: 'Boston, MA', latitude: 42.358, longitude: -71.062, schedule: weekdayHours },
  { id: 'cambridge', name: 'Cambridge demo branch', area: 'Cambridge, MA', latitude: 42.366, longitude: -71.105, schedule: { ...weekdayHours, opens: 7 * 60, closes: 22 * 60, week: 'daily' } },
  { id: 'quincy', name: 'Quincy demo branch', area: 'Quincy, MA', latitude: 42.2529, longitude: -71.0023, schedule: { ...weekdayHours, opens: 10 * 60, closes: 18 * 60 } },
  { id: 'providence', name: 'Providence demo branch', area: 'Providence, RI', latitude: 41.824, longitude: -71.4128, schedule: weekdayHours },
  { id: 'manhattan', name: 'Manhattan demo branch', area: 'New York, NY', latitude: 40.754, longitude: -73.984, schedule: weekdayHours },
  { id: 'unlocated', name: 'Unlocated demo branch', area: 'Location pending', latitude: null, longitude: null }
];

A schedule provides a time zone, country, opening and closing minutes, weekdays or daily, extra closure dates, and an explicit holiday policy. All demo branches use holidayPolicy: 'public': they close on applicable public holidays. This is their stated policy, not something a calendar can decide for every business. Use 'none' only when your business stays open on those holidays, and maintain its exceptions in closures.

Keep stable branch IDs and verified coordinates. Zero is valid; missing, nonnumeric, or out-of-range coordinates exclude that branch from distance results. A missing schedule leaves the branch visible with Hours unavailable.

2. Ask for one ZIP

The visitor only supplies a US ZIP. Search radius and hours belong to your application's configuration.

HTML
<form id="locator-form">
  <div class="fields">
    <label for="zip">US ZIP code
      <input id="zip" name="postal" type="text" inputmode="numeric" autocomplete="postal-code" pattern="[0-9]{5}" maxlength="5" title="Enter a five-digit US ZIP code" value="02108" required>
    </label>
  </div>
  <button id="search" type="submit">Find branches</button>
  <p id="status" role="status" aria-live="polite" aria-atomic="true">Find demo branches within 25 miles.</p>
</form>

The initial 02108 is near the fictional Boston branches. Keep ZIPs as text so leading zeroes survive. Nothing is requested until the form is submitted.

JavaScript
async function lookupPostal(code, signal) {
  if (!API_KEY.startsWith('parse_public_')) {
    throw new Error('Add your public API key and allow this hostname in its domain list.');
  }
  const url = new URL('https://api.parseapi.com/postal/' + encodeURIComponent(code));
  url.search = new URLSearchParams({ country: 'US', key: API_KEY });
  const response = await fetch(url, { signal });
  if (!response.ok) {
    const messages = {
      400: 'Check the five-digit ZIP code.',
      401: 'Check your public API key.',
      403: 'Check your public key and its allowed hostnames.',
      404: 'This ZIP was not found in the covered data. Try another ZIP.',
      429: 'The request limit was reached. Try again later.'
    };
    throw new Error(messages[response.status] || 'The ZIP lookup is unavailable. Try again later.');
  }
  const data = await response.json();
  if (!data || data.postal !== code || data.country !== 'US') {
    throw new Error('The lookup returned an unexpected ZIP or country.');
  }
  if (!validCoordinates(data)) throw new Error('This ZIP has no usable coordinates for a distance search. Try another ZIP.');
  return data;
}

Use country=US explicitly because postal codes overlap across countries. Check the returned ZIP, country, and usable latitude and longitude. Missing coordinates are an unavailable starting point, not an empty branch list.

3. Find nearby branches

One Postal request supplies the starting point. Measure the distance to each located branch, keep matches within the configured 25 miles, and sort nearest first:

JavaScript
function distanceKm(from, to) {
  const radians = (degrees) => degrees * Math.PI / 180;
  const lat1 = radians(from.latitude);
  const lat2 = radians(to.latitude);
  const deltaLat = lat2 - lat1;
  const deltaLon = radians(to.longitude - from.longitude);
  const h = Math.sin(deltaLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
  return 2 * 6371.0088 * Math.asin(Math.sqrt(Math.min(1, Math.max(0, h))));
}

function nearestStores(origin, list, radiusKm) {
  return list
    .map((store) => ({ ...store, distance: distanceKm(origin, store) }))
    .filter((store) => store.distance <= radiusKm)
    .sort((a, b) => a.distance - b.distance || a.id.localeCompare(b.id));
}

Filter with the full numeric distance and round only the display. These are approximate straight-line distances from a ZIP centroid. They are not road mileage, travel time, or the visitor's exact location.

4. Add opening status automatically

The nearby list appears as soon as distances are ready. Each branch's hours then fill in without another button or replacing the visitor's selection:

JavaScript
async function loadOpenings(matches, instant, signal) {
  // Branches in one zone share the time and calendar requests for this search.
  const requests = new Map();
  const requestJson = (path, params) => {
    const key = path + '?' + new URLSearchParams(params);
    if (!requests.has(key)) requests.set(key, lookupOpening(path, params, signal));
    return requests.get(key);
  };
  return new Map(await Promise.all(matches.map(async store => {
    try {
      if (!store.schedule) throw new Error('Missing maintained hours.');
      const answer = await checkBusiness({ ...store.schedule, instant }, signal, requestJson);
      return [store.id, answer];
    } catch {
      return [store.id, { state: 'Unknown', reason: 'Opening hours could not be checked.' }];
    }
  })));
}

Capture one instant for the search. Branches in the same zone share a time lookup, and branches sharing a country and year reuse the holiday response. The Boston example normally makes three pooled requests in total: one Postal, one Timezone, and one Holiday. Additional zones or calendar years require their own lookups.

Opening is inclusive and closing is exclusive. The helper supports overnight shifts and checks the adjacent calendar year when needed. A closure on the following day ends an overnight opening at midnight. Unknown or malformed time/calendar data produces Hours unavailable while preserving the branch and its distance.

The opening-status tutorial explains that same schedule policy on a single business page.

Choose this branch records a local selection. Adapt its handler to pass the stable store.id into your application. Revalidate that ID and current availability on your server before accepting an order or reservation.

Editing the ZIP clears the old list and selection and cancels pending requests. A revision check prevents old location or hours responses from repainting a newer search. A ten-second timeout restores the search button; if hours fail, known distances remain visible. Opening status is a snapshot, refreshed by a new search.

Try 02108, choose a branch, and change the ZIP. The selection should disappear. Try 33139 for a successful search with no nearby demo branch. Also test a missing calendar, missing postal coordinates, and an edit during a slow hours request.

The downloadable HTML includes the full rendering and validation. Any branch data in it is public to visitors. For a large catalog, move loading and search into your application rather than downloading every branch.

Complete example

One HTML file with the markup, styles, and JavaScript. Replace YOUR_PUBLIC_API_KEY with your public key, then run it on a domain you have added to that key.

Download HTML
View and copy the complete source
store-locator.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Find a nearby branch that is open</title>
  <style>
    :root { color-scheme: light dark; --bg: #fff; --ink: #152029; --muted: #5e6b75; --line: #d9e2e7; --field: #f6f9fa; --accent: #007c91; --button: #c5f6ff; --error: #b42335; }
    @media (prefers-color-scheme: dark) {
      :root:not([data-theme="light"]) { --bg: #0b1015; --ink: #eff7fa; --muted: #94a6b2; --line: #2c3942; --field: #121b23; --accent: #67e8f9; --button: #67e8f9; --error: #fda4af; }
    }
    :root[data-theme="dark"] { color-scheme: dark; --bg: #0b1015; --ink: #eff7fa; --muted: #94a6b2; --line: #2c3942; --field: #121b23; --accent: #67e8f9; --button: #67e8f9; --error: #fda4af; }
    :root[data-theme="light"] { color-scheme: light; }
    * { box-sizing: border-box; }
    [hidden] { display: none !important; }
    body { margin: 0; padding: 24px; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, sans-serif; }
    main { max-width: 580px; margin: 0 auto; }
    h2 { margin: 0; font-size: 17px; font-weight: 600; }
    p { margin: 0; }
    .detail { color: var(--muted); }
    label { display: block; min-width: 0; font-size: 13px; font-weight: 550; }
    input, select, button { font: inherit; }
    input, select { display: block; width: 100%; min-width: 0; max-width: 100%; margin-top: 7px; padding: 11px 12px; border: 1px solid var(--line); border-radius: 7px; background: var(--field); color: var(--ink); }
    :focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
    .fields { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; }
    button { min-height: 44px; padding: 10px 14px; border: 0; border-radius: 7px; background: var(--button); color: #07313c; cursor: pointer; font-size: 14px; font-weight: 650; }
    button:disabled { opacity: .55; cursor: wait; }
    #search { margin-top: 18px; width: 100%; }
    #status { margin-top: 12px; min-height: 44px; color: var(--muted); font-size: 13px; }
    #status[data-error="true"] { color: var(--error); }
    #results { margin-top: 22px; }
    #rows { list-style: none; padding: 0; margin: 8px 0 0; }
    #rows li { padding: 16px 0; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
    .store-name { font-weight: 600; }
    .detail { font-size: 13px; margin: 3px 0 10px; }
    .choose { background: var(--field); color: var(--accent); border: 1px solid var(--line); font-weight: 500; }
    .choose[aria-pressed="true"] { border-color: var(--accent); }
    .opening { margin: 5px 0 12px; font-size: 14px; color: var(--muted); }
    .opening[data-state="Open"] { color: var(--accent); font-weight: 600; }
    .footnote { color: var(--muted); font-size: 12px; margin: 16px 0 0; }
    #selection { margin-top: 16px; overflow-wrap: anywhere; font-size: 14px; }
    @media (max-width: 380px) { body { padding: 18px; } .fields { grid-template-columns: 1fr; } }
  </style>
</head>
<body>
  <main>
    <!-- example:form -->
    <form id="locator-form">
      <div class="fields">
        <label for="zip">US ZIP code
          <input id="zip" name="postal" type="text" inputmode="numeric" autocomplete="postal-code" pattern="[0-9]{5}" maxlength="5" title="Enter a five-digit US ZIP code" value="02108" required>
        </label>
      </div>
      <button id="search" type="submit">Find branches</button>
      <p id="status" role="status" aria-live="polite" aria-atomic="true">Find demo branches within 25 miles.</p>
    </form>
    <!-- /example:form -->
    <section id="results" aria-labelledby="summary" hidden>
      <h2 id="summary"></h2>
      <ol id="rows"></ol>
      <p class="footnote">Fictional branches. Hours are maintained for this demo; all close on US observed federal holidays.</p>
    </section>
    <p id="selection" role="status" aria-live="polite"></p>
  </main>
  <script>
    // Use a public key restricted to your development and production hostnames.
    const API_KEY = 'YOUR_PUBLIC_API_KEY';

    // example:stores
    const businessLocation = { zone: 'America/New_York', country: 'US', region: null };
    const weekdayHours = { location: businessLocation, opens: 9 * 60, closes: 17 * 60, week: 'weekdays', closures: new Set(), holidayPolicy: 'public' };

    // Replace these fictional branches with your own public location data.
    const STORES = [
      { id: 'beacon', name: 'Beacon demo branch', area: 'Boston, MA', latitude: 42.358, longitude: -71.062, schedule: weekdayHours },
      { id: 'cambridge', name: 'Cambridge demo branch', area: 'Cambridge, MA', latitude: 42.366, longitude: -71.105, schedule: { ...weekdayHours, opens: 7 * 60, closes: 22 * 60, week: 'daily' } },
      { id: 'quincy', name: 'Quincy demo branch', area: 'Quincy, MA', latitude: 42.2529, longitude: -71.0023, schedule: { ...weekdayHours, opens: 10 * 60, closes: 18 * 60 } },
      { id: 'providence', name: 'Providence demo branch', area: 'Providence, RI', latitude: 41.824, longitude: -71.4128, schedule: weekdayHours },
      { id: 'manhattan', name: 'Manhattan demo branch', area: 'New York, NY', latitude: 40.754, longitude: -73.984, schedule: weekdayHours },
      { id: 'unlocated', name: 'Unlocated demo branch', area: 'Location pending', latitude: null, longitude: null }
    ];
    // /example:stores

    const form = document.querySelector('#locator-form');
    const zip = document.querySelector('#zip');
    const SEARCH_RADIUS_MILES = 25;
    const search = document.querySelector('#search');
    const status = document.querySelector('#status');
    const results = document.querySelector('#results');
    const summary = document.querySelector('#summary');
    const rows = document.querySelector('#rows');
    const selection = document.querySelector('#selection');
    let controller;
    let revision = 0;

    function setStatus(message, error = false) {
      status.textContent = message;
      status.dataset.error = String(error);
    }

    function validCoordinates(point) {
      return point && Number.isFinite(point.latitude) && Math.abs(point.latitude) <= 90 &&
        Number.isFinite(point.longitude) && Math.abs(point.longitude) <= 180;
    }

    function readStores(list) {
      if (!Array.isArray(list)) throw new Error('Check your branch list.');
      const ids = new Set();
      const located = [];
      for (const store of list) {
        if (!store || typeof store.id !== 'string' || !store.id.trim() || ids.has(store.id) ||
            typeof store.name !== 'string' || !store.name.trim() || typeof store.area !== 'string') {
          throw new Error('Check branch IDs, names, and areas in your location list.');
        }
        ids.add(store.id);
        if (validCoordinates(store)) located.push(store);
      }
      return { located, omitted: list.length - located.length };
    }

    function readSearch() {
      const code = zip.value.trim();
      if (!/^[0-9]{5}$/.test(code)) throw new Error('Enter a five-digit US ZIP code.');
      return { code, radiusKm: SEARCH_RADIUS_MILES * 1.609344 };
    }

    // example:request
    async function lookupPostal(code, signal) {
      if (!API_KEY.startsWith('parse_public_')) {
        throw new Error('Add your public API key and allow this hostname in its domain list.');
      }
      const url = new URL('https://api.parseapi.com/postal/' + encodeURIComponent(code));
      url.search = new URLSearchParams({ country: 'US', key: API_KEY });
      const response = await fetch(url, { signal });
      if (!response.ok) {
        const messages = {
          400: 'Check the five-digit ZIP code.',
          401: 'Check your public API key.',
          403: 'Check your public key and its allowed hostnames.',
          404: 'This ZIP was not found in the covered data. Try another ZIP.',
          429: 'The request limit was reached. Try again later.'
        };
        throw new Error(messages[response.status] || 'The ZIP lookup is unavailable. Try again later.');
      }
      const data = await response.json();
      if (!data || data.postal !== code || data.country !== 'US') {
        throw new Error('The lookup returned an unexpected ZIP or country.');
      }
      if (!validCoordinates(data)) throw new Error('This ZIP has no usable coordinates for a distance search. Try another ZIP.');
      return data;
    }
    // /example:request

    // example:distance
    function distanceKm(from, to) {
      const radians = (degrees) => degrees * Math.PI / 180;
      const lat1 = radians(from.latitude);
      const lat2 = radians(to.latitude);
      const deltaLat = lat2 - lat1;
      const deltaLon = radians(to.longitude - from.longitude);
      const h = Math.sin(deltaLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
      return 2 * 6371.0088 * Math.asin(Math.sqrt(Math.min(1, Math.max(0, h))));
    }

    function nearestStores(origin, list, radiusKm) {
      return list
        .map((store) => ({ ...store, distance: distanceKm(origin, store) }))
        .filter((store) => store.distance <= radiusKm)
        .sort((a, b) => a.distance - b.distance || a.id.localeCompare(b.id));
    }
    // /example:distance

    const DAY = 86400000, MINUTE = 60000;
    function dateMs(date) {
      if (typeof date !== 'string' || !/^20\d{2}-\d{2}-\d{2}$/.test(date)) throw new Error('Choose a date between 2000 and 2099.');
      const ms = Date.parse(`${date}T00:00:00Z`);
      if (!Number.isFinite(ms) || new Date(ms).toISOString().slice(0, 10) !== date) throw new Error('Choose a valid calendar date.');
      return ms;
    }
    function clockMinutes(value) {
      if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value)) throw new Error('Use times in HH:MM format.');
      return Number(value.slice(0, 2)) * 60 + Number(value.slice(3));
    }
    function offsetMinutes(value) {
      const match = typeof value === 'string' && value.match(/^([+-])(\d{2}):([0-5]\d)$/);
      if (!match) throw new Error('The time-zone offset could not be verified.');
      const minutes = (Number(match[2]) * 60 + Number(match[3])) * (match[1] === '-' ? -1 : 1);
      if (Math.abs(minutes) > 840) throw new Error('The time-zone offset is out of range.');
      return minutes;
    }
    function offsetText(minutes) {
      return `${minutes < 0 ? '-' : '+'}${String(Math.floor(Math.abs(minutes) / 60)).padStart(2, '0')}:${String(Math.abs(minutes) % 60).padStart(2, '0')}`;
    }

    function displayTime(minutes) {
      if (minutes === 0) return 'midnight';
      const hour = Math.floor(minutes / 60), rest = minutes % 60;
      return `${hour % 12 || 12}${rest ? ':' + String(rest).padStart(2, '0') : ''} ${hour < 12 ? 'AM' : 'PM'}`;
    }
    function readLocal(data, zone, instant) {
      const target = data?.to;
      const match = typeof target?.at === 'string' && target.at.match(/^(\d{4}-\d{2}-\d{2})T((?:[01]\d|2[0-3]):[0-5]\d):([0-5]\d)([+-]\d{2}:[0-5]\d)$/);
      if (data?.timezone !== 'UTC' || data.at !== instant.replace('Z', '+00:00') || target?.timezone !== zone || !match
        || !Number.isInteger(target.offset_minutes) || offsetMinutes(target.offset) !== target.offset_minutes
        || target.offset !== match[4] || Date.parse(target.at) !== Date.parse(instant)) throw new Error('The local time could not be verified.');
      dateMs(match[1]);
      return { date: match[1], time: match[2], minutes: clockMinutes(match[2]), at: target.at, zone };
    }
    function readCalendar(data, location, year) {
      const fail = () => new Error(`The ${year} holiday calendar could not be verified.`);
      if (!data || data.country !== location.country || data.year !== year || !Array.isArray(data.holidays) || !data.holidays.length) throw fail();
      const closed = new Map();
      for (const row of data.holidays) {
        if (!row || typeof row.name !== 'string' || !row.name.trim() || !['public', 'observance'].includes(row.type)
          || !(row.regions === null || Array.isArray(row.regions) && row.regions.length > 0 && row.regions.every(region => typeof region === 'string' && /^[A-Z0-9-]+$/.test(region)))) throw fail();
        try { dateMs(row.date); } catch { throw fail(); }
        if (Number(row.date.slice(0, 4)) !== year) throw fail();
        if (row.type !== 'public' || row.regions !== null && !row.regions.includes(location.region)) continue;
        closed.set(row.date, [...(closed.get(row.date) || []), row.name]);
      }
      return closed;
    }
    async function checkBusiness(config, signal, requestJson) {
      const { location, instant, opens, closes, week, closures } = config;
      if (!location || typeof location.zone !== 'string' || !location.zone || !/^[A-Z]{2}$/.test(location.country) ||
          (location.region !== null && typeof location.region !== 'string') ||
          ![opens, closes].every(value => Number.isInteger(value) && value >= 0 && value < 1440) || opens === closes ||
          !['daily', 'weekdays'].includes(week) || Object.prototype.toString.call(closures) !== '[object Set]' || !['public', 'none'].includes(config.holidayPolicy)) {
        throw new Error('Opening hours need a maintained schedule and explicit holiday policy.');
      }
      for (const date of closures) dateMs(date);
      const data = await requestJson('/timezone/UTC', { to: location.zone, at: instant }, signal);
      if (signal.aborted) throw new Error('The opening-hours check was cancelled.');
      const local = readLocal(data, location.zone, instant);
      const previous = new Date(dateMs(local.date) - DAY).toISOString().slice(0, 10);
      const next = new Date(dateMs(local.date) + DAY).toISOString().slice(0, 10);
      const dates = closes < opens ? local.minutes < closes ? [previous, local.date] : [local.date, next] : [local.date];
      const years = [...new Set(dates.map(date => Number(date.slice(0, 4))))];
      let holidays;
      try {
        const calendars = config.holidayPolicy === 'none' ? [] : await Promise.all(years.map(async year => readCalendar(
          await requestJson(`/holiday/${location.country}`, { year: String(year) }, signal), location, year)));
        holidays = new Map(calendars.flatMap(calendar => [...calendar]));
      } catch (error) {
        if (signal.aborted) throw error;
        return { state: 'Unknown', reason: 'The required holiday calendar is unavailable. No opening decision was made.', local, instant };
      }
      return { ...openingStatus(local, { opens, closes, week, closures }, holidays), local, instant };
    }

    function openingStatus(local, config, holidays) {
      const { opens, closes, week, closures } = config;
      const previous = new Date(dateMs(local.date) - DAY).toISOString().slice(0, 10);
      const openingDay = date => week === 'daily' || ![0, 6].includes(new Date(dateMs(date)).getUTCDay());
      const closure = date => closures.has(date) ? 'Your extra closure' : holidays.has(date) ? holidays.get(date).join(' · ') : null;
      const todayClosure = closure(local.date);
      if (todayClosure) return { state: 'Closed', reason: `${local.date}: ${todayClosure}.` };
      let startDay = null;
      if (closes > opens) {
        if (openingDay(local.date) && local.minutes >= opens && local.minutes < closes) startDay = local.date;
      } else {
        if (openingDay(local.date) && local.minutes >= opens) startDay = local.date;
        else if (openingDay(previous) && local.minutes < closes) startDay = previous;
      }
      if (!startDay) return { state: 'Closed', reason: 'Outside the configured weekly opening hours.' };
      const startClosure = closure(startDay);
      if (startClosure) return { state: 'Closed', reason: `The shift's opening date is closed: ${startClosure}.` };
      const next = new Date(dateMs(local.date) + DAY).toISOString().slice(0, 10);
      const until = closes < opens && startDay === local.date && closure(next) ? 0 : closes;
      return { state: 'Open', until, reason: `Open until ${displayTime(until)} local time.` };
    }

    async function lookupOpening(path, params, signal) {
      const url = new URL(path, 'https://api.parseapi.com');
      url.search = new URLSearchParams({ ...params, key: API_KEY });
      const response = await fetch(url, { signal });
      if (!response.ok) throw new Error('Opening hours are unavailable.');
      return response.json();
    }

    // example:opening
    async function loadOpenings(matches, instant, signal) {
      // Branches in one zone share the time and calendar requests for this search.
      const requests = new Map();
      const requestJson = (path, params) => {
        const key = path + '?' + new URLSearchParams(params);
        if (!requests.has(key)) requests.set(key, lookupOpening(path, params, signal));
        return requests.get(key);
      };
      return new Map(await Promise.all(matches.map(async store => {
        try {
          if (!store.schedule) throw new Error('Missing maintained hours.');
          const answer = await checkBusiness({ ...store.schedule, instant }, signal, requestJson);
          return [store.id, answer];
        } catch {
          return [store.id, { state: 'Unknown', reason: 'Opening hours could not be checked.' }];
        }
      })));
    }
    // /example:opening

    function cancelRequest() {
      revision += 1;
      controller?.abort();
      controller = undefined;
      search.disabled = false;
      form.setAttribute('aria-busy', 'false');
    }

    function clearResults() {
      results.hidden = true;
      rows.replaceChildren();
      summary.textContent = '';
      selection.textContent = '';
    }

    // example:result
    function showStores(origin, matches, omitted) {
      const resultRevision = revision;
      const openingNodes = new Map();
      const omittedNote = omitted ? ` ${omitted} branch${omitted === 1 ? '' : 'es'} without usable coordinates omitted.` : '';
      summary.textContent = `Within ${SEARCH_RADIUS_MILES} miles of ${origin.postal}`;
      rows.replaceChildren();
      for (const store of matches) {
        const row = document.createElement('li');
        const name = document.createElement('p');
        name.className = 'store-name';
        name.textContent = store.name;
        const detail = document.createElement('p');
        detail.className = 'detail';
        const distance = store.distance / 1.609344;
        detail.textContent = `${store.area} · ${distance.toFixed(1)} miles approx.`;
        const opening = document.createElement('p');
        opening.className = 'opening';
        opening.textContent = 'Checking hours…';
        opening.setAttribute('aria-live', 'polite');
        openingNodes.set(store.id, opening);
        const choose = document.createElement('button');
        choose.type = 'button';
        choose.className = 'choose';
        choose.textContent = 'Choose this branch';
        choose.setAttribute('aria-label', 'Choose ' + store.name);
        choose.setAttribute('aria-pressed', 'false');
        choose.addEventListener('click', () => {
          if (resultRevision !== revision) return;
          for (const button of rows.querySelectorAll('button')) button.setAttribute('aria-pressed', String(button === choose));
          selection.textContent = 'Selected: ' + store.name;
          // Pass store.id to your application's next step here.
        });
        row.append(name, detail, opening, choose);
        rows.append(row);
      }
      results.hidden = false;
      setStatus(matches.length
        ? `${matches.length} matching demo branch${matches.length === 1 ? '' : 'es'}, nearest first.${omittedNote}`
        : `No located demo branches are within this radius. Try another ZIP.${omittedNote}`);
      return openingNodes;
    }
    // /example:result

    form.addEventListener('input', () => {
      cancelRequest();
      clearResults();
      setStatus('Search again to use the changed values.');
    });

    form.addEventListener('submit', async (event) => {
      event.preventDefault();
      cancelRequest();
      clearResults();
      if (!form.reportValidity()) return;
      let settings;
      let branches;
      try { settings = readSearch(); branches = readStores(STORES); }
      catch (error) { setStatus(error.message, true); return; }
      const current = revision;
      controller = new AbortController();
      const request = controller;
      let timedOut = false;
      const timeout = setTimeout(() => { timedOut = true; request.abort(); }, 10000);
      search.disabled = true;
      form.setAttribute('aria-busy', 'true');
      setStatus('Finding the ZIP and measuring distances...');
      try {
        const origin = await lookupPostal(settings.code, request.signal);
        if (current !== revision) return;
        if (request.signal.aborted) throw new Error('The lookup was cancelled.');
        const matches = nearestStores(origin, branches.located, settings.radiusKm);
        const openingNodes = showStores(origin, matches, branches.omitted);
        const instant = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
        const openings = await loadOpenings(matches, instant, request.signal);
        if (current !== revision) return;
        for (const [id, node] of openingNodes) {
          const answer = request.signal.aborted ? null : openings.get(id);
          node.textContent = answer?.state === 'Open' ? `Open until ${displayTime(answer.until)}`
            : answer?.state === 'Closed' ? 'Closed' : 'Hours unavailable';
          node.dataset.state = answer?.state ?? 'Unknown';
          if (answer?.local) node.setAttribute('title', `${answer.reason} Checked at ${answer.local.at}.`);
        }
        if (request.signal.aborted) throw new Error('The opening-hours check was cancelled.');
      } catch (error) {
        if (current !== revision) return;
        const message = timedOut ? 'The lookup timed out. Search again to retry.'
          : error instanceof TypeError ? 'Could not reach ParseAPI. Check your connection and search again.'
          : error instanceof SyntaxError ? 'The response could not be read. Search again to retry.' : error.message;
        setStatus(message, true);
      } finally {
        clearTimeout(timeout);
        if (current === revision) {
          controller = undefined;
          search.disabled = false;
          form.setAttribute('aria-busy', 'false');
        }
      }
    });
  </script>
</body>
</html>

Need a key? Set up this browser example. For every field and parameter, see Postal API reference and Timezone API reference and Holiday API reference.

Build something else

All tutorials →