Add nice non-restrictive phone number input formatting for booking
- New pure-vanilla progressive formatter that turns typed digits into
(123) 456-7890 (or 1 (800) 555-1212 for +1 numbers) on the fly.
- Works for free-form input: typing, pasting, editing anywhere, backspacing,
extra chars, etc. — no hard mask or input restrictions.
- Applied to:
- The Preact <rpt-booking> widget phone field (home page quick booking + details).
- The final Drupal confirmation form at /schedule/book (via lightweight
enhancer + .rpt-phone class).
- Prefills last_name / phone / comments on the confirmation form when the
widget was used to pick the slot (so collected phone isn't lost).
- No new dependencies whatsoever:
* Zero npm / package.json / composer changes.
* Zero additional CDN / external scripts (uses native String.replace,
regex, and input event + selection APIs only).
* The new js/phone-format.js is simply attached via the pre-existing
'app' library (already loaded on all riverside_pt.* routes).
* Formatter logic duplicated in the ESM component (tiny pure function).
- The two other modified files (calendar.css, calendar.js) were left
uncommitted as they are unrelated to this feature.
This commit is contained in:
parent
58988a4fe8
commit
e3c2e3e3a1
4 changed files with 138 additions and 25 deletions
|
|
@ -15,6 +15,23 @@ const CHECK = html`<svg width="14" height="11" viewBox="0 0 14 11" fill="none" x
|
||||||
|
|
||||||
const EMPTY_FORM = { lastName: "", phone: "", comments: "" };
|
const EMPTY_FORM = { lastName: "", phone: "", comments: "" };
|
||||||
|
|
||||||
|
function formatPhone(raw) {
|
||||||
|
let d = String(raw || "").replace(/\D/g, "");
|
||||||
|
if (d.length === 11 && d[0] === "1") {
|
||||||
|
// NANP with leading 1: show "1 (xxx) xxx-xxxx"
|
||||||
|
const rest = d.slice(1);
|
||||||
|
return "1 (" + rest.slice(0, 3) + ") " + rest.slice(3, 6) + "-" + rest.slice(6);
|
||||||
|
}
|
||||||
|
d = d.slice(0, 10);
|
||||||
|
if (d.length === 0) return "";
|
||||||
|
if (d.length <= 3) return d;
|
||||||
|
if (d.length <= 6) return "(" + d.slice(0, 3) + ") " + d.slice(3);
|
||||||
|
return "(" + d.slice(0, 3) + ") " + d.slice(3, 6) + "-" + d.slice(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedDate = null;
|
||||||
|
var selectedDateSlots = [];
|
||||||
|
|
||||||
function localDateStr(d) {
|
function localDateStr(d) {
|
||||||
return d.getFullYear() + "-" +
|
return d.getFullYear() + "-" +
|
||||||
String(d.getMonth() + 1).padStart(2, "0") + "-" +
|
String(d.getMonth() + 1).padStart(2, "0") + "-" +
|
||||||
|
|
@ -46,6 +63,7 @@ function Booking({ settings }) {
|
||||||
const initializedRef = useRef(false);
|
const initializedRef = useRef(false);
|
||||||
const prevServiceRef = useRef(null);
|
const prevServiceRef = useRef(null);
|
||||||
const autoAdvanceRef = useRef(0);
|
const autoAdvanceRef = useRef(0);
|
||||||
|
const fetchedRef = useRef(false);
|
||||||
const initDate = useMemo(nextBusinessDay, []);
|
const initDate = useMemo(nextBusinessDay, []);
|
||||||
|
|
||||||
function buildEventsUrl(svc) {
|
function buildEventsUrl(svc) {
|
||||||
|
|
@ -79,34 +97,51 @@ function Booking({ settings }) {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
fixedWeekCount: false,
|
fixedWeekCount: false,
|
||||||
|
showNonCurrentDates: false,
|
||||||
height: "auto",
|
height: "auto",
|
||||||
eventDisplay: "none",
|
eventDisplay: "none",
|
||||||
dayMaxEvents: false,
|
dayMaxEvents: false,
|
||||||
|
|
||||||
|
loading: function (isLoading) {
|
||||||
|
if (!isLoading) fetchedRef.current = true;
|
||||||
|
},
|
||||||
|
|
||||||
datesSet: function () {
|
datesSet: function () {
|
||||||
calEl.current.querySelectorAll(".fc-daygrid-day.is-selected").forEach(function (d) {
|
calEl.current.querySelectorAll(".fc-daygrid-day.is-selected").forEach(function (d) {
|
||||||
d.classList.remove("is-selected");
|
d.classList.remove("is-selected");
|
||||||
});
|
});
|
||||||
setSlots([]);
|
|
||||||
setSelectedSlotId(null);
|
setSelectedSlotId(null);
|
||||||
|
if (selectedDate) {
|
||||||
|
var dayEl = calEl.current.querySelector(".fc-daygrid-day[data-date=\"" + selectedDate + "\"]");
|
||||||
|
if (dayEl) {
|
||||||
|
dayEl.classList.add("is-selected");
|
||||||
|
setSlots(selectedDateSlots);
|
||||||
|
} else {
|
||||||
|
setSlots([]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSlots([]);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
eventsSet: function (events) {
|
eventsSet: function (events) {
|
||||||
markDays(events);
|
markDays(events);
|
||||||
if (!initializedRef.current) {
|
if (!initializedRef.current && fetchedRef.current) {
|
||||||
|
fetchedRef.current = false;
|
||||||
var dates = [...new Set(events.map(function (e) { return e.startStr.substring(0, 10); }))].sort();
|
var dates = [...new Set(events.map(function (e) { return e.startStr.substring(0, 10); }))].sort();
|
||||||
var firstDate = dates[0];
|
var firstDate = dates[0];
|
||||||
if (firstDate) {
|
if (firstDate) {
|
||||||
initializedRef.current = true;
|
initializedRef.current = true;
|
||||||
autoAdvanceRef.current = 0;
|
autoAdvanceRef.current = 0;
|
||||||
|
var firstSlots = events
|
||||||
|
.filter(function (e) { return e.startStr.startsWith(firstDate); })
|
||||||
|
.sort(function (a, b) { return a.start - b.start; });
|
||||||
|
selectedDate = firstDate;
|
||||||
|
selectedDateSlots = firstSlots;
|
||||||
var targetEl = calEl.current.querySelector(".fc-daygrid-day[data-date=\"" + firstDate + "\"]");
|
var targetEl = calEl.current.querySelector(".fc-daygrid-day[data-date=\"" + firstDate + "\"]");
|
||||||
if (targetEl) {
|
if (targetEl) {
|
||||||
targetEl.classList.add("is-selected");
|
targetEl.classList.add("is-selected");
|
||||||
setSlots(
|
setSlots(firstSlots);
|
||||||
events
|
|
||||||
.filter(function (e) { return e.startStr.startsWith(firstDate); })
|
|
||||||
.sort(function (a, b) { return a.start - b.start; })
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else if (autoAdvanceRef.current < 12) {
|
} else if (autoAdvanceRef.current < 12) {
|
||||||
autoAdvanceRef.current++;
|
autoAdvanceRef.current++;
|
||||||
|
|
@ -126,13 +161,14 @@ function Booking({ settings }) {
|
||||||
d.classList.remove("is-selected");
|
d.classList.remove("is-selected");
|
||||||
});
|
});
|
||||||
arg.dayEl.classList.add("is-selected");
|
arg.dayEl.classList.add("is-selected");
|
||||||
|
var daySlots = cal.getEvents()
|
||||||
|
.filter(function (e) { return e.startStr.startsWith(arg.dateStr); })
|
||||||
|
.sort(function (a, b) { return a.start - b.start; });
|
||||||
|
selectedDate = arg.dateStr;
|
||||||
|
selectedDateSlots = daySlots;
|
||||||
setSelectedSlotId(null);
|
setSelectedSlotId(null);
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
setSlots(
|
setSlots(daySlots);
|
||||||
cal.getEvents()
|
|
||||||
.filter(function (e) { return e.startStr.startsWith(arg.dateStr); })
|
|
||||||
.sort(function (a, b) { return a.start - b.start; })
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -147,11 +183,12 @@ function Booking({ settings }) {
|
||||||
|
|
||||||
var isInitial = prevServiceRef.current === null;
|
var isInitial = prevServiceRef.current === null;
|
||||||
prevServiceRef.current = service;
|
prevServiceRef.current = service;
|
||||||
serviceRef.current = service;
|
|
||||||
|
|
||||||
if (!isInitial) {
|
if (!isInitial) {
|
||||||
initializedRef.current = false;
|
initializedRef.current = false;
|
||||||
autoAdvanceRef.current = 0;
|
autoAdvanceRef.current = 0;
|
||||||
|
fetchedRef.current = false;
|
||||||
|
selectedDate = null;
|
||||||
|
selectedDateSlots = [];
|
||||||
setSlots([]);
|
setSlots([]);
|
||||||
setSelectedSlotId(null);
|
setSelectedSlotId(null);
|
||||||
setFormData(EMPTY_FORM);
|
setFormData(EMPTY_FORM);
|
||||||
|
|
@ -291,8 +328,13 @@ function Booking({ settings }) {
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
required
|
required
|
||||||
value=${formData.phone}
|
value=${formatPhone(formData.phone)}
|
||||||
onInput=${function (e) { handleFormChange("phone", e.target.value); }}
|
onInput=${function (e) {
|
||||||
|
// Compute from the tentative input value (supports free typing/paste/backspace anywhere).
|
||||||
|
// We store the formatted result so the email and prefill see it nicely displayed.
|
||||||
|
const next = formatPhone(e.target.value);
|
||||||
|
handleFormChange("phone", next);
|
||||||
|
}}
|
||||||
class=${inputClass}
|
class=${inputClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
66
web/modules/custom/riverside_pt/js/phone-format.js
Normal file
66
web/modules/custom/riverside_pt/js/phone-format.js
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
(function () {
|
||||||
|
function formatPhone(raw) {
|
||||||
|
let d = String(raw || "").replace(/\D/g, "");
|
||||||
|
if (d.length === 11 && d[0] === "1") {
|
||||||
|
// NANP with leading 1: show "1 (xxx) xxx-xxxx"
|
||||||
|
const rest = d.slice(1);
|
||||||
|
return "1 (" + rest.slice(0, 3) + ") " + rest.slice(3, 6) + "-" + rest.slice(6);
|
||||||
|
}
|
||||||
|
d = d.slice(0, 10);
|
||||||
|
if (d.length === 0) return "";
|
||||||
|
if (d.length <= 3) return d;
|
||||||
|
if (d.length <= 6) return "(" + d.slice(0, 3) + ") " + d.slice(3);
|
||||||
|
return "(" + d.slice(0, 3) + ") " + d.slice(3, 6) + "-" + d.slice(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enhancePhoneInput(input) {
|
||||||
|
if (input.dataset.phoneEnhanced) return;
|
||||||
|
input.dataset.phoneEnhanced = "true";
|
||||||
|
|
||||||
|
input.addEventListener("input", function () {
|
||||||
|
const oldValue = input.value;
|
||||||
|
const oldStart = input.selectionStart || 0;
|
||||||
|
|
||||||
|
const formatted = formatPhone(input.value);
|
||||||
|
if (oldValue !== formatted) {
|
||||||
|
input.value = formatted;
|
||||||
|
|
||||||
|
// Try to keep cursor relative to the digit sequence the user was editing.
|
||||||
|
const oldDigitsBefore = (oldValue.slice(0, oldStart).match(/\d/g) || []).length;
|
||||||
|
|
||||||
|
let newPos = 0;
|
||||||
|
let digitsSeen = 0;
|
||||||
|
for (; newPos < formatted.length; newPos++) {
|
||||||
|
if (/\d/.test(formatted[newPos])) {
|
||||||
|
digitsSeen++;
|
||||||
|
}
|
||||||
|
if (digitsSeen >= oldDigitsBefore) {
|
||||||
|
newPos++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newPos > formatted.length) newPos = formatted.length;
|
||||||
|
|
||||||
|
try {
|
||||||
|
input.setSelectionRange(newPos, newPos);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format any server-provided default value on load (e.g. prefilled from tempstore)
|
||||||
|
if (input.value) {
|
||||||
|
const f = formatPhone(input.value);
|
||||||
|
if (input.value !== f) input.value = f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scan() {
|
||||||
|
document.querySelectorAll("input.rpt-phone").forEach(enhancePhoneInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", scan);
|
||||||
|
} else {
|
||||||
|
scan();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -4,6 +4,7 @@ app:
|
||||||
css/app.css: {}
|
css/app.css: {}
|
||||||
js:
|
js:
|
||||||
js/nav.js: {}
|
js/nav.js: {}
|
||||||
|
js/phone-format.js: {}
|
||||||
js/components/rpt-toggle.js: { attributes: { type: module } }
|
js/components/rpt-toggle.js: { attributes: { type: module } }
|
||||||
js/components/rpt-carousel.js: { attributes: { type: module } }
|
js/components/rpt-carousel.js: { attributes: { type: module } }
|
||||||
js/components/rpt-testimonials.js: { attributes: { type: module } }
|
js/components/rpt-testimonials.js: { attributes: { type: module } }
|
||||||
|
|
|
||||||
|
|
@ -72,21 +72,25 @@ class BookingForm extends FormBase {
|
||||||
];
|
];
|
||||||
|
|
||||||
$form['last_name'] = [
|
$form['last_name'] = [
|
||||||
'#type' => 'textfield',
|
'#type' => 'textfield',
|
||||||
'#title' => $this->t('Last name'),
|
'#title' => $this->t('Last name'),
|
||||||
'#required' => TRUE,
|
'#required' => TRUE,
|
||||||
|
'#default_value' => $slot['last_name'] ?? '',
|
||||||
];
|
];
|
||||||
|
|
||||||
$form['phone'] = [
|
$form['phone'] = [
|
||||||
'#type' => 'tel',
|
'#type' => 'tel',
|
||||||
'#title' => $this->t('Phone number'),
|
'#title' => $this->t('Phone number'),
|
||||||
'#required' => TRUE,
|
'#required' => TRUE,
|
||||||
|
'#default_value' => $slot['phone'] ?? '',
|
||||||
|
'#attributes' => ['class' => ['rpt-phone']],
|
||||||
];
|
];
|
||||||
|
|
||||||
$form['comments'] = [
|
$form['comments'] = [
|
||||||
'#type' => 'textarea',
|
'#type' => 'textarea',
|
||||||
'#title' => $this->t('Comments'),
|
'#title' => $this->t('Comments'),
|
||||||
'#rows' => 4,
|
'#rows' => 4,
|
||||||
|
'#default_value' => $slot['comments'] ?? '',
|
||||||
];
|
];
|
||||||
|
|
||||||
$form['actions'] = ['#type' => 'actions'];
|
$form['actions'] = ['#type' => 'actions'];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue