A two-part excursion booking app built around a small REST API. It is split into a client side, where users browse excursions, add them to their order and submit it, and an admin panel, where excursions can be added, edited and removed. All communication with the database goes through fetch(), with JSON Server acting as the backend.
Main features:
- Browse excursions and build an order (cart) with live price calculation
- Form validation for both the order and the ticket-count inputs
- Full admin panel with create / edit / remove operations on excursions
- A single API layer (
Provider) shared between the client and the admin
The project uses node and npm. Having them installed, type into the terminal:
npm install
Then start the Webpack dev server:
npm start
The app needs the API running in parallel. Open a second terminal and start JSON Server:
npm run server
From now on:
- the client is available at
http://localhost:8080/index.html - the admin panel is available at
http://localhost:8080/admin.html - the API exposes two resources:
http://localhost:3000/excursionsandhttp://localhost:3000/orders
- One API layer, reused on both sides – the whole conversation with the backend lives in a single
Providerclass with a complete CRUD interface. The private_fetch()method is the only place that touchesfetch(), normalises the JSON response and rejects on a non-2xx status:
class Provider {
constructor(url) {
this.url = url;
}
loadData() {
return this._fetch(this.url);
}
addData(data) {
const options = {
method: "POST",
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' },
};
return this._fetch(this.url, options)
}
removeData(id) {
const options = { method: "DELETE" };
return this._fetch(`${this.url}/${id}`, options);
}
_fetch(url, options) {
return fetch(url, options)
.then((resp) => {
if (resp.ok) {
return resp.json();
}
return Promise.reject(resp);
})
}
}
- Resource-specific providers through inheritance –
ProviderExcursionsandProviderOrdersextendProviderand only declare their endpoint. The base URL is resolved fromwindow.location.hostname, so the same code works locally and inside a remote environment like Codespaces without changing a line:
import Provider from "./Provider";
class ProviderExcursions extends Provider {
constructor() {
super(`http://${window.location.hostname}:3000/excursions`);
}
}
export default ProviderExcursions
- Inline editing in the admin panel – the same button drives a two-step edit → save flow. The first click turns the relevant fields into
contentEditableand relabels the button; the second click reads the edited content and pushes it to the API:
if (submitDatasetID === 'edytuj' || submitDatasetID === 'zapisz') {
if (this._isEditable(editableList)) {
this._editExcursions(parentEl, formEl, id);
} else {
submitterEl.dataset.id = 'zapisz';
submitterEl.value = 'zapisz';
editableList.forEach((el) => {
el.contentEditable = true;
});
}
}
- Prototype-based rendering – instead of building HTML strings in JS, each item is cloned from a hidden
--prototypenode in the markup, filled with data and appended. The HTML structure stays in the HTML file, where it belongs:
const newLiEl = this.prototypeEl.cloneNode(true);
newLiEl.dataset.id = dataObj.id;
newLiEl.querySelector('.excursions__title').textContent = name;
newLiEl.querySelector('.excursions__description').textContent = description;
newLiEl.querySelector('.excursions__field-priceAdult').textContent = priceAdult;
newLiEl.querySelector('.excursions__field-priceChild').textContent = priceChild;
newLiEl.classList.remove('excursions__item--prototype');
this.panelEl.appendChild(newLiEl);
A few smaller decisions worth pointing out:
| Issue | Solution |
|---|---|
| Keep the client and admin bundles separate | Webpack entry chunks: client and admin, each wired to its own HTML via HtmlWebpackPlugin |
fetch() support in older browsers |
whatwg-fetch polyfill added at the top of every entry point |
| Recalculate the cart total after each change | data.forEach((order) => { total += order.total; }) |
The provider layer turned out to be the strongest part of this project — because all the API logic sits behind one class with a stable interface, the UI code never talks to fetch() directly. That separation makes the next two improvements feel natural rather than disruptive:
Right now the admin panel is open. A real deployment would put the create / edit / remove operations behind a login. Since every request already passes through Provider._fetch(), adding an Authorization header (or a token-refresh step) would be a change in one place rather than across the whole codebase.
The prototype-clone pattern is essentially a manual version of what a framework does for free. Each excursion card and each summary row is already a self-contained, data-driven unit — moving this to React would mean turning every _create...El method into a component and swapping the manual DOM updates for state. The provider classes could stay almost untouched and be reused inside hooks.
The current layout is desktop-first and built on fixed proportions — the panels split 70% / 30%, the cards sit at width: calc(50% - 20px), and there are no media queries. On narrow screens this breaks down quickly. The next iteration would add breakpoints so the side panel stacks under the excursion list and the cards collapse to a single column, ideally moving the grid sizing to flex/grid with minmax() instead of hard-coded percentages.
Write sth nice ;) Find me on LinkedIn: Łukasz Skrzypczyński.
Thanks to my Mentor – Mateusz Bogolubow (devmentor.pl) – for providing me with this task and for the code review.

