Last active
December 15, 2024 15:19
-
-
Save piboistudios/72734605d736222bdead88884b4bb10c to your computer and use it in GitHub Desktop.
Just a Chill Reactive FlatPickr Service Scheduler w/ AlpineJS (Demo: https://lou.network/alpinejs-flatpickr-scheduler.html ; 12-16 is the only day with multiple prebooked appointments)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<html> | |
<head> | |
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script> | |
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css"> | |
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script> | |
<link rel="stylesheet" type="text/css" href="https://npmcdn.com/flatpickr/dist/themes/dark.css"> | |
</head> | |
<body> | |
<details> | |
<summary> | |
What do? | |
</summary> | |
<p>Basically, you select a date, select services, and select a timeslot.</p> | |
<p>The dates/timslots will update reactively based on the selected services to only show dates with available | |
timeslots that can accomodate the duration of the booked services.</p> | |
</details> | |
<details> | |
<summary> | |
Why do? | |
</summary> | |
<p>Red's Burritos won't pay for themselves</p> | |
</details> | |
<form x-data="foo" @submit.prevent> | |
<input type="date" x-init="doflatpickr" /> | |
<div> Selected:<br /> | |
<template x-if="duration && selected_date?.length"> | |
<div>Select a Timeslot: <template x-for="slot in selected_date"> | |
<button x-text="slot.start + ' - ' + slot.end" | |
@click="console.log('selected something', (selected = slot))"> | |
</button> | |
</template> | |
</div> | |
</template> | |
<!-- Day: <span x-text="selected_date"></span> --> | |
Timeslot: <span x-text="selected?.dates?.join?.(' - ')"></span> | |
<br /> | |
Appointment Duration: <span x-text="duration + ' minutes'"></span> | |
<template x-for="service in selected_services"> | |
<div> | |
<dl> | |
<dt x-text="service.title"></dt> | |
<dd><button @click="service.$selected = !service.$selected">Remove</button></dd> | |
</dl> | |
</div> | |
</template> | |
</div> | |
<template x-for="service in available_services"> | |
<div> | |
<dl> | |
<template x-for="(value, key) in service"> | |
<div> | |
<strong> | |
<dt x-text="key"></dt> | |
</strong> | |
<dd x-text="service[key]"></dd> | |
</div> | |
</template> | |
</dl> | |
<button x-data @click="service.$selected = !service.$selected"> | |
Select | |
</button> | |
</div> | |
</template> | |
</form> | |
</body> | |
<script> | |
let now; | |
let today; | |
let range; | |
const free = () => findNonOverlappingTimes(busy(), range); | |
const busy = () => JSON.parse(window.busy.textContent); | |
const freetimes = () => timeslots(free(), { begin: '04:00', end: '23:59' }) | |
const services = () => JSON.parse(window.services.textContent); | |
window.addEventListener('alpine:init', () => { | |
Alpine.data('foo', function () { | |
const self = this; | |
return { | |
services: services(), | |
get selected_services() { | |
return self.$data['services'].filter(s => s.$selected); | |
}, | |
get available_services() { | |
return self.$data['services'].filter(s => !s.$selected); | |
}, | |
freebusy: freetimes(), | |
selected: null, | |
selected_date: [], | |
get duration() { | |
const duration = this.selected_services.reduce((total, service) => { | |
return total + service.duration_minutes; | |
}, 0); | |
return duration; | |
}, | |
opts: { | |
// done | |
get services() { | |
return self.$data['selected_services']; | |
}, | |
// api route | |
get freebusy() { | |
return self.$data['freebusy'] | |
}, | |
// json [] | |
get selected_date() { | |
return self.$data['selected_date']; | |
}, | |
set selected_date(v) { | |
return self.$data['selected_date'] = v; | |
}, | |
// json null | |
get selected() { | |
return self.$data["selected"] | |
}, | |
set selected(v) { | |
return self.$data["selected"] = v; | |
}, | |
// sum aggregate filter | |
get duration() { | |
return self.$data["duration"]; | |
}, | |
}, | |
slots: {}, | |
doflatpickr() { | |
const el = this.$el; | |
const opts = this.opts; | |
let slots; | |
let cdateStr; | |
const pickr = flatpickr(el, { | |
onChange(selectedDates, dateStr, pickr) { | |
cdateStr = dateStr; | |
update(); | |
} | |
}); | |
Alpine.effect(update) | |
function update() { | |
opts.services.length; | |
const duration = opts.duration; | |
slots = /* opts.freebusy;// */filterByDuration(opts.freebusy, duration); | |
// console.log({ slots, duration }); | |
// console.log(slots) | |
const days = Object.entries(slots).filter(([s, e]) => e.length).map(s => s[0]); | |
pickr.set('enable', days); | |
opts.selected_date = slots[cdateStr] && slots[cdateStr] | |
.flatMap(e => { | |
function date(i) { | |
return cdateStr + 'T' + i | |
} | |
const range = [date(e.start), date(e.end)].map(v => new Date(v)); | |
let begin = range[0], end; | |
const slots = []; | |
if (duration > 0) | |
while (true) { | |
end = new Date(begin.getTime() + (1000 * 60 * duration)); | |
if (end > range[1]) { | |
break; | |
} | |
slots.push({ | |
start: time(begin), | |
end: time(end), | |
dates: [begin, end] | |
}); | |
begin = end; | |
} | |
else slots.push({ | |
start: time(begin), | |
end: time(range[1]), | |
dates: [begin, range[1]] | |
}); | |
return slots; | |
// const range = (e.dates[1].getTime() - e.dates[0].getTime()) / (60 * 1000); | |
// return range > opts.duration; | |
}); | |
// .filter(s => s.length); | |
// . | |
setTimeout(checkConstraints, 1); | |
} | |
function checkConstraints() { | |
const duration = opts.duration; | |
if (!opts.selected) return; | |
if (!opts.selected_date) { | |
opts.selected = undefined; | |
return; | |
} | |
const start = mins(opts.selected.start); | |
const end = mins(opts.selected.end); | |
opts.selected = opts.selected_date.find(slot => { | |
const slotStart = mins(slot.start); | |
const slotEnd = mins(slot.end); | |
return slotStart < start && slotStart < end && | |
slotEnd > end && slotEnd > start | |
}); | |
} | |
function filterByDuration(obj, durationMinutes) { | |
const filteredObj = {}; | |
for (const date in obj) { | |
const filteredEvents = obj[date].filter(event => { | |
const [startHour, startMinute] = event.start.split(':').map(Number); | |
const [endHour, endMinute] = event.end.split(':').map(Number); | |
const startTimeInMinutes = startHour * 60 + startMinute; | |
const endTimeInMinutes = endHour * 60 + endMinute; | |
const eventDuration = endTimeInMinutes - startTimeInMinutes; | |
return eventDuration > durationMinutes; | |
}); | |
if (filteredEvents.length > 0) { | |
filteredObj[date] = filteredEvents; | |
} | |
} | |
return filteredObj; | |
} | |
} | |
} | |
}) | |
}); | |
now = new Date(); | |
today = new Date([now.getFullYear(), now.getMonth() + 1, now.getDate()]) | |
range = [new Date(today.getTime() - 1000 * 60 * 60 * 12), new Date(today.getTime() + 1000 * 60 * 60 * 24 * 30)]; | |
function timeslots(freebusytimes, hours) { | |
hours ??= { | |
begin: '09:00', | |
end: '17:00' | |
} | |
const ret = freebusytimes.reduce((result, times) => { | |
const start = new Date(times.start); | |
const end = new Date(times.end); | |
for (const d of between(start, end)) { | |
const _day = day(d) | |
result[_day] ??= []; | |
let overlaps; | |
for (let i = 0; i < result[_day].length; i++) { | |
let range = result[_day][i]; | |
const rstart = new Date(_day + "T" + range.start + "Z"); | |
const rend = new Date(_day + "T" + range.end + "Z"); | |
if (rend > start && rend < end && rstart < start) { | |
overlaps ??= range.start = time(start); | |
} | |
if (rstart < end && rstart > start && rend > end) { | |
overlaps ??= range.end = time(end) | |
} | |
check(range); | |
} | |
if (!overlaps) { | |
const startday = day(start); | |
const startDay = new Date(startday); | |
const endday = day(end); | |
const endDay = new Date(endday); | |
let timestart, timeend; | |
if (_day === startday) { | |
timestart = time(start); | |
} else { | |
timestart = hours.begin; | |
} | |
if (_day === endday) { | |
timeend = time(end); | |
} else { | |
timeend = hours.end | |
} | |
const range = { | |
start: timestart, | |
end: timeend | |
}; | |
result[_day].push(range) | |
check(range); | |
} | |
function check(range) { | |
const startmins = mins(range.start); | |
const endmins = mins(range.end); | |
if (mins(hours.begin) > startmins) { | |
range.start = hours.begin; | |
} | |
if (mins(hours.end) < endmins) { | |
range.end = hours.end; | |
} | |
if (range.start > hours.end && range.start < hours.begin) range.start = hours.begin; | |
if (range.end > hours.end && range.end < hours.begin) range.end = hours.end; | |
if (mins(range.end) < mins(range.start)) { | |
result[_day].splice(result[_day].indexOf(range), 1); | |
} | |
} | |
} | |
return result; | |
}, {}); | |
for (const k in ret) { | |
if (!ret[k].length) delete ret[k]; | |
} | |
return ret; | |
} | |
function mins(time) { | |
const [hour, min] = time.split(':').map(Number); | |
return (hour * 60) + min; | |
} | |
function findNonOverlappingTimes(inputTimes, range) { | |
// Sort the input times by start time | |
inputTimes.sort((a, b) => new Date(a.start) - new Date(b.start)); | |
// Find the earliest start time and latest end time | |
const earliestStart = range[0]; | |
const latestEnd = range[1]; | |
// Initialize a list to store non-overlapping time slots | |
const nonOverlappingTimes = []; | |
// Iterate through the sorted input times | |
let currentEndTime = earliestStart; | |
for (const timeSlot of inputTimes) { | |
const startTime = new Date(timeSlot.start); | |
const endTime = new Date(timeSlot.end); | |
// If the current time slot starts after the previous end time, | |
// there's a non-overlapping time slot between them | |
if (startTime > currentEndTime) { | |
nonOverlappingTimes.push({ | |
start: currentEndTime.toISOString(), | |
end: startTime.toISOString() | |
}); | |
} | |
currentEndTime = endTime; | |
} | |
// Check for a non-overlapping time slot after the latest end time | |
if (latestEnd < new Date()) { | |
nonOverlappingTimes.push({ | |
start: latestEnd.toISOString(), | |
end: new Date().toISOString() | |
}); | |
} | |
return nonOverlappingTimes; | |
} | |
/** | |
* | |
* @param {Date} date | |
* @returns | |
*/ | |
function day(date) { | |
return [date.getFullYear().toString().padStart(4, '0'), ...[date.getMonth() + 1, date.getDate() + 1].map(p => p.toString().padStart(2, '0'))].join('-') | |
} | |
/** | |
* | |
* @param {Date} date | |
*/ | |
function time(date) { | |
return [date.getHours(), date.getMinutes()].map(p => p.toString().padStart(2, '0')).join(':'); | |
} | |
function between(startDate, endDate) { | |
const dates = []; | |
const currentDate = new Date(startDate); | |
while (currentDate <= endDate) { | |
dates.push(new Date(currentDate)); | |
currentDate.setDate(currentDate.getDate() + 1); | |
} | |
return dates; | |
} | |
</script> | |
<script type="application/json" id="busy"> | |
[ | |
{ | |
"start": "2024-12-16T00:15:00Z", | |
"end": "2024-12-16T01:00:00Z" | |
}, | |
{ | |
"start": "2024-12-16T01:15:00Z", | |
"end": "2024-12-16T01:45:00Z" | |
}, | |
{ | |
"start": "2024-12-16T03:15:00Z", | |
"end": "2024-12-16T03:30:00Z" | |
}, | |
{ | |
"start": "2024-12-16T03:55:00Z", | |
"end": "2024-12-16T09:45:00Z" | |
}, | |
{ | |
"start": "2024-12-18T00:15:00Z", | |
"end": "2024-12-18T02:15:00Z" | |
}, | |
{ | |
"start": "2024-12-19T00:30:00Z", | |
"end": "2024-12-19T02:30:00Z" | |
}, | |
{ | |
"start": "2024-12-23T00:15:00Z", | |
"end": "2024-12-23T01:00:00Z" | |
}, | |
{ | |
"start": "2024-12-25T00:15:00Z", | |
"end": "2024-12-25T02:15:00Z" | |
}, | |
{ | |
"start": "2024-12-26T00:30:00Z", | |
"end": "2024-12-26T02:30:00Z" | |
}, | |
{ | |
"start": "2024-12-30T00:15:00Z", | |
"end": "2024-12-30T01:00:00Z" | |
}, | |
{ | |
"start": "2025-01-01T00:15:00Z", | |
"end": "2025-01-01T02:15:00Z" | |
}, | |
{ | |
"start": "2025-01-02T00:30:00Z", | |
"end": "2025-01-02T02:30:00Z" | |
}, | |
{ | |
"start": "2025-01-06T00:15:00Z", | |
"end": "2025-01-06T01:00:00Z" | |
}, | |
{ | |
"start": "2025-01-08T00:15:00Z", | |
"end": "2025-01-08T02:15:00Z" | |
}, | |
{ | |
"start": "2025-01-09T00:30:00Z", | |
"end": "2025-01-09T02:30:00Z" | |
}, | |
{ | |
"start": "2025-01-13T00:15:00Z", | |
"end": "2025-01-13T01:00:00Z" | |
} | |
] | |
</script> | |
<script type="application/json" id="services"> | |
[ | |
{ | |
"id": 28, | |
"status": "draft", | |
"user_created": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_created": "2024-12-13T03:16:27.485Z", | |
"user_updated": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_updated": "2024-12-13T05:59:27.460Z", | |
"title": "Med Senegalese Twist", | |
"price_low": 120, | |
"price_high": null, | |
"short_desc": "", | |
"long_desc": null, | |
"overall_rating": 5, | |
"ratings": 1, | |
"price_range": "$120", | |
"duration": "3 hr 45 min", | |
"duration_minutes": 225, | |
"thumbnails": [], | |
"images": [] | |
}, | |
{ | |
"id": 29, | |
"status": "draft", | |
"user_created": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_created": "2024-12-13T03:16:27.487Z", | |
"user_updated": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_updated": "2024-12-13T05:59:27.460Z", | |
"title": "Small Knotless Box Braids", | |
"price_low": 200, | |
"price_high": null, | |
"short_desc": "Protective and trendy, this braid style parts sections of your hair in knotless boxes for a tiled pattern part and even braids.", | |
"long_desc": null, | |
"overall_rating": 5, | |
"ratings": 1, | |
"price_range": "$200", | |
"duration": "6 hr 0 min", | |
"duration_minutes": 360, | |
"thumbnails": [], | |
"images": [] | |
}, | |
{ | |
"id": 30, | |
"status": "draft", | |
"user_created": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_created": "2024-12-13T03:16:27.490Z", | |
"user_updated": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_updated": "2024-12-13T05:59:27.460Z", | |
"title": "Small Box Braids", | |
"price_low": 170, | |
"price_high": null, | |
"short_desc": "", | |
"long_desc": null, | |
"overall_rating": 5, | |
"ratings": 3, | |
"price_range": "$170", | |
"duration": "5 hr 0 min", | |
"duration_minutes": 300, | |
"thumbnails": [], | |
"images": [] | |
}, | |
{ | |
"id": 31, | |
"status": "draft", | |
"user_created": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_created": "2024-12-13T03:16:27.492Z", | |
"user_updated": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_updated": "2024-12-13T05:59:27.460Z", | |
"title": "Sew In", | |
"price_low": 120, | |
"price_high": null, | |
"short_desc": "With a sew in, hair is braided down, wefts are sewn on from ear to ear.", | |
"long_desc": null, | |
"overall_rating": 5, | |
"ratings": 2, | |
"price_range": "$120", | |
"duration": "2 hr 30 min", | |
"duration_minutes": 150, | |
"thumbnails": [], | |
"images": [] | |
}, | |
{ | |
"id": 32, | |
"status": "draft", | |
"user_created": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_created": "2024-12-13T03:16:27.495Z", | |
"user_updated": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_updated": "2024-12-13T05:59:27.460Z", | |
"title": "Loc Maintenance", | |
"price_low": 45, | |
"price_high": null, | |
"short_desc": "Incorporate new growth, and retwist any stray locs.", | |
"long_desc": null, | |
"overall_rating": 5, | |
"ratings": 9, | |
"price_range": "$45", | |
"duration": "1 hr 15 min", | |
"duration_minutes": 75, | |
"thumbnails": [], | |
"images": [] | |
}, | |
{ | |
"id": 33, | |
"status": "draft", | |
"user_created": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_created": "2024-12-13T03:16:27.498Z", | |
"user_updated": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_updated": "2024-12-13T05:59:27.460Z", | |
"title": "Loc Style", | |
"price_low": 15, | |
"price_high": null, | |
"short_desc": "Short, long, dread, wool — we'll style your locs to your liking.", | |
"long_desc": null, | |
"overall_rating": 5, | |
"ratings": 4, | |
"price_range": "$15", | |
"duration": "15 min", | |
"duration_minutes": 15, | |
"thumbnails": [], | |
"images": [] | |
}, | |
{ | |
"id": 34, | |
"status": "draft", | |
"user_created": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_created": "2024-12-13T03:16:27.500Z", | |
"user_updated": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_updated": "2024-12-13T05:59:27.460Z", | |
"title": "Spring Twist", | |
"price_low": 140, | |
"price_high": null, | |
"short_desc": "Get 4 boxes of spring twist hair of your color choice for individual spongy natural looking spring twist.", | |
"long_desc": null, | |
"overall_rating": 5, | |
"ratings": 1, | |
"price_range": "$140", | |
"duration": "4 hr 0 min", | |
"duration_minutes": 240, | |
"thumbnails": [], | |
"images": [] | |
}, | |
{ | |
"id": 35, | |
"status": "draft", | |
"user_created": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_created": "2024-12-13T03:16:27.502Z", | |
"user_updated": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_updated": "2024-12-13T05:59:27.460Z", | |
"title": "Women's Trim", | |
"price_low": 15, | |
"price_high": null, | |
"short_desc": "Maintain strong and healthy hair with a trim.", | |
"long_desc": null, | |
"overall_rating": 5, | |
"ratings": 7, | |
"price_range": "$15", | |
"duration": "30 min", | |
"duration_minutes": 30, | |
"thumbnails": [], | |
"images": [] | |
}, | |
{ | |
"id": 36, | |
"status": "draft", | |
"user_created": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_created": "2024-12-13T03:16:27.503Z", | |
"user_updated": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_updated": "2024-12-13T05:59:27.460Z", | |
"title": "Cornrows", | |
"price_low": 45, | |
"price_high": null, | |
"short_desc": "Cornrows braid the hair close to the scalp for chic raised braids.", | |
"long_desc": null, | |
"overall_rating": 5, | |
"ratings": 6, | |
"price_range": "$45", | |
"duration": "1 hr 45 min", | |
"duration_minutes": 105, | |
"thumbnails": [], | |
"images": [] | |
}, | |
{ | |
"id": 37, | |
"status": "draft", | |
"user_created": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_created": "2024-12-13T03:16:27.505Z", | |
"user_updated": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_updated": "2024-12-13T05:59:27.460Z", | |
"title": "Insta Loc Extensions", | |
"price_low": 400, | |
"price_high": null, | |
"short_desc": "", | |
"long_desc": null, | |
"overall_rating": 0, | |
"ratings": 0, | |
"price_range": "$400", | |
"duration": "5 hr 0 min", | |
"duration_minutes": 300, | |
"thumbnails": [], | |
"images": [] | |
}, | |
{ | |
"id": 38, | |
"status": "draft", | |
"user_created": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_created": "2024-12-13T03:16:27.507Z", | |
"user_updated": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_updated": "2024-12-13T06:08:28.935Z", | |
"title": "Butterfly Locs", | |
"price_low": 130, | |
"price_high": null, | |
"short_desc": "Protective and trendy, this braid style parts sections of your hair in boxes for a tiled pattern parts.", | |
"long_desc": null, | |
"overall_rating": 5, | |
"ratings": 2, | |
"price_range": "$130", | |
"duration": "3 hr 0 min", | |
"duration_minutes": 180, | |
"thumbnails": [ | |
1 | |
], | |
"images": [ | |
1 | |
] | |
}, | |
{ | |
"id": 39, | |
"status": "draft", | |
"user_created": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_created": "2024-12-13T03:16:27.508Z", | |
"user_updated": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_updated": "2024-12-13T07:21:04.459Z", | |
"title": "Large Box Braids", | |
"price_low": 95, | |
"price_high": null, | |
"short_desc": "", | |
"long_desc": null, | |
"overall_rating": 5, | |
"ratings": 1, | |
"price_range": "$95", | |
"duration": "2 hr 45 min", | |
"duration_minutes": 165, | |
"thumbnails": [ | |
3, | |
4 | |
], | |
"images": [ | |
3, | |
4 | |
] | |
}, | |
{ | |
"id": 40, | |
"status": "draft", | |
"user_created": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_created": "2024-12-13T03:16:27.510Z", | |
"user_updated": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_updated": "2024-12-13T07:21:22.577Z", | |
"title": "Med Knotless Box Braids", | |
"price_low": 150, | |
"price_high": null, | |
"short_desc": "", | |
"long_desc": null, | |
"overall_rating": 5, | |
"ratings": 2, | |
"price_range": "$150", | |
"duration": "4 hr 0 min", | |
"duration_minutes": 240, | |
"thumbnails": [ | |
5 | |
], | |
"images": [ | |
5 | |
] | |
}, | |
{ | |
"id": 41, | |
"status": "draft", | |
"user_created": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_created": "2024-12-13T03:16:27.512Z", | |
"user_updated": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_updated": "2024-12-13T07:20:34.315Z", | |
"title": "Crochet Braids", | |
"price_low": 85, | |
"price_high": null, | |
"short_desc": "Stylish and protective. Attaching extensions through your natural hair for a full-bodied look.", | |
"long_desc": null, | |
"overall_rating": 5, | |
"ratings": 39, | |
"price_range": "$85", | |
"duration": "2 hr 30 min", | |
"duration_minutes": 150, | |
"thumbnails": [ | |
2 | |
], | |
"images": [ | |
2 | |
] | |
}, | |
{ | |
"id": 42, | |
"status": "draft", | |
"user_created": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_created": "2024-12-13T03:16:27.513Z", | |
"user_updated": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_updated": "2024-12-13T05:59:27.460Z", | |
"title": "Havana Twist", | |
"price_low": 95, | |
"price_high": null, | |
"short_desc": "", | |
"long_desc": null, | |
"overall_rating": 5, | |
"ratings": 1, | |
"price_range": "$95", | |
"duration": "2 hr 45 min", | |
"duration_minutes": 165, | |
"thumbnails": [], | |
"images": [] | |
}, | |
{ | |
"id": 43, | |
"status": "draft", | |
"user_created": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_created": "2024-12-13T03:16:27.515Z", | |
"user_updated": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_updated": "2024-12-13T07:21:46.544Z", | |
"title": "Medium Box Braids", | |
"price_low": 120, | |
"price_high": null, | |
"short_desc": "", | |
"long_desc": null, | |
"overall_rating": 5, | |
"ratings": 9, | |
"price_range": "$120", | |
"duration": "3 hr 30 min", | |
"duration_minutes": 210, | |
"thumbnails": [ | |
6 | |
], | |
"images": [ | |
6 | |
] | |
}, | |
{ | |
"id": 44, | |
"status": "draft", | |
"user_created": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_created": "2024-12-13T03:16:27.517Z", | |
"user_updated": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_updated": "2024-12-13T07:22:06.458Z", | |
"title": "Faux Locs", | |
"price_low": 150, | |
"price_high": null, | |
"short_desc": "", | |
"long_desc": null, | |
"overall_rating": 5, | |
"ratings": 3, | |
"price_range": "$150", | |
"duration": "5 hr 0 min", | |
"duration_minutes": 300, | |
"thumbnails": [ | |
7 | |
], | |
"images": [ | |
7 | |
] | |
}, | |
{ | |
"id": 45, | |
"status": "draft", | |
"user_created": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_created": "2024-12-13T03:16:27.519Z", | |
"user_updated": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_updated": "2024-12-13T05:59:27.460Z", | |
"title": "Passion Twist", | |
"price_low": 140, | |
"price_high": null, | |
"short_desc": "9 packs of expressions twisted up passion water wave hair. Can also be found on Amazon", | |
"long_desc": null, | |
"overall_rating": 5, | |
"ratings": 3, | |
"price_range": "$140", | |
"duration": "4 hr 0 min", | |
"duration_minutes": 240, | |
"thumbnails": [], | |
"images": [] | |
}, | |
{ | |
"id": 46, | |
"status": "draft", | |
"user_created": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_created": "2024-12-13T03:16:27.521Z", | |
"user_updated": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_updated": "2024-12-13T07:22:35.574Z", | |
"title": "Soft Locs", | |
"price_low": 140, | |
"price_high": null, | |
"short_desc": "", | |
"long_desc": null, | |
"overall_rating": 5, | |
"ratings": 3, | |
"price_range": "$140", | |
"duration": "4 hr 0 min", | |
"duration_minutes": 240, | |
"thumbnails": [ | |
8, | |
9 | |
], | |
"images": [ | |
8, | |
9 | |
] | |
}, | |
{ | |
"id": 47, | |
"status": "draft", | |
"user_created": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_created": "2024-12-13T03:16:27.522Z", | |
"user_updated": "763841c4-bffb-4ca0-acad-ddd5eadbb07b", | |
"date_updated": "2024-12-13T05:59:27.460Z", | |
"title": "Small To Med Feedin Braids", | |
"price_low": 150, | |
"price_high": null, | |
"short_desc": "Feed in Cornrows braid the hair close to the scalp for chic raised braids.", | |
"long_desc": null, | |
"overall_rating": 0, | |
"ratings": 0, | |
"price_range": "$150", | |
"duration": "5 hr 0 min", | |
"duration_minutes": 300, | |
"thumbnails": [], | |
"images": [] | |
} | |
] | |
</script> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment