Initial commit

master
Jan Umbach 2024-02-24 09:03:25 +01:00
commit 263e2f94eb
56 changed files with 21962 additions and 0 deletions

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env.development
.env.production

4
Dockerfile Normal file
View File

@ -0,0 +1,4 @@
FROM nginx:1.25.3-alpine
COPY ./build /usr/share/nginx/html
COPY ./nginx.conf /etc/nginx/conf.d/default.conf

70
README.md Normal file
View File

@ -0,0 +1,70 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

26
build-docker.sh Executable file
View File

@ -0,0 +1,26 @@
DOCKER_REGISTRY_URL="dockreg.ex.umbach.dev"
DOCKER_IMAGE_NAME="zeitadler-terminplaner-embed-web"
# only allow to run this script as root
if [ "$EUID" -ne 0 ]
then echo "Please run as root"
exit
fi
echo "Starting static web build of $DOCKER_IMAGE_NAME"
npm run build
echo "Finished static web build of $DOCKER_IMAGE_NAME"
# rm images
docker image rm $DOCKER_IMAGE_NAME
# build images
docker build -t $DOCKER_IMAGE_NAME .
# tag images
docker image tag $DOCKER_IMAGE_NAME:latest $DOCKER_REGISTRY_URL/$DOCKER_IMAGE_NAME:latest
# push to self-hosted docker registry
docker push $DOCKER_REGISTRY_URL/$DOCKER_IMAGE_NAME
echo "Uploaded $DOCKER_IMAGE_NAME to $DOCKER_REGISTRY_URL"

22
config-overrides.js Normal file
View File

@ -0,0 +1,22 @@
module.exports = {
devServer: function (configFunction) {
// Return the replacement function for create-react-app to use to generate the Webpack
// Development Server config. "configFunction" is the function that would normally have
// been used to generate the Webpack Development server config - you can use it to create
// a starting configuration to then modify instead of having to create a config from scratch.
return function (proxy, allowedHost) {
// Create the default config by calling configFunction with the proxy/allowedHost parameters
const config = configFunction(proxy, allowedHost);
config.webSocketServer = config.webSocketServer || {}
config.webSocketServer.options = {
...config.webSocketServer.options,
path: process.env.WDS_SOCKET_PATH,
}
// Return your customised Webpack Development Server config.
return config;
};
},
}

7
env.development.example Normal file
View File

@ -0,0 +1,7 @@
PUBLIC_URL=https://myexample.dev/test/
WDS_SOCKET_PORT=443
WDS_SOCKET_PATH=/test/ws
PORT=80
REACT_APP_RECAPTCHA_SITE_KEY=
REACT_APP_BACKEND_URL=httpss://mybackend.dev

3
env.production.example Normal file
View File

@ -0,0 +1,3 @@
PUBLIC_URL=httpss://myexample
REACT_APP_RECAPTCHA_SITE_KEY=
REACT_APP_BACKEND_URL=httpss://mybackend

16
nginx.conf Normal file
View File

@ -0,0 +1,16 @@
server {
listen 80;
#location /api/ { # api server
# client_max_body_size 0;
# proxy_http_version 1.0;
# proxy_pass http://zeitadler-dashboard-api/;
#}
location / { # frontend
root /usr/share/nginx/html;
index index.html;
try_files $uri /index.html;
}
}

19770
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
package.json Normal file
View File

@ -0,0 +1,50 @@
{
"name": "terminplaner-embed",
"version": "0.1.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/material": "^5.15.3",
"@mui/x-date-pickers": "^6.18.7",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"antd": "^5.12.8",
"dayjs": "^1.11.10",
"email-validator": "^2.0.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-google-recaptcha": "^3.1.0",
"react-scripts": "5.0.1",
"virtua": "^0.20.3",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"react-app-rewired": "^2.2.1"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/favicon/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -0,0 +1,18 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M768 6984 c-289 -52 -538 -242 -672 -511 -40 -81 -54 -122 -77 -228
-16 -75 -17 -245 -16 -2770 l2 -2690 23 -80 c107 -367 377 -619 742 -690 109
-21 5347 -22 5456 0 300 58 533 232 672 500 40 77 58 129 82 235 19 83 19 152
17 2775 l-2 2690 -23 79 c-101 358 -365 609 -720 686 -84 18 -185 18 -2747 18
-2224 0 -2673 -2 -2737 -14z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 854 B

View File

@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

22
public/index.css Normal file
View File

@ -0,0 +1,22 @@
@font-face {
font-family: 'Roboto';
src: url(fonts/Roboto-Regular.ttf) ;
}
@font-face {
font-family: 'Roboto';
font-weight: 300;
src: url(fonts/Roboto-Thin.ttf) ;
}
body {
margin: 0;
padding: 0;
font-family: 'Roboto', sans-serif;
}
html, body, #root {
height: 100%;
}

25
public/index.html Normal file
View File

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="%PUBLIC_URL%/index.css" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="ZeitAdler Terminbuchung" />
<link rel="icon" href="%PUBLIC_URL%/faviconfavicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/favicon/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon/favicon-16x16.png" />
<link rel="manifest" href="%PUBLIC_URL%/favicon/site.webmanifest" />
<link rel="mask-icon" href="%PUBLIC_URL%/favicon/safari-pinned-tab.svg" color="#5bbad5" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>ZeitAdler Terminbuchung</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

103
public/lang.json Normal file
View File

@ -0,0 +1,103 @@
{
"modalError": {
"title": "Es ist ein Fehler aufgetreten.",
"404": "Ihr Geschäft wurde nicht gefunden."
},
"Steps": {
"DSGVO": "Datenschutzerklärung",
"StepBack": "Zurück",
"StepNext": "Weiter",
"StepProgress": "Schritt {0} von {1}",
"StepOne": {
"Title": "Dienstleistung wählen",
"noServices": "Es wurden noch keine Dienstleistungen angelegt."
},
"StepTwo": {
"Title": "Tag wählen",
"allEmployees": "Beliebiger Mitarbeiter"
},
"StepThree": {
"Title": "Uhrzeit wählen",
"noFreeTime": "Keine freien Termine verfügbar"
},
"StepFour": {
"Title": "Termin bestätigen",
"info": "Um den Termin zu bestätigen, geben Sie bitte Ihre Kontaktdaten ein.",
"yourDate": "Ihr Termin:",
"what": "{0} bei {1},",
"date": "{day}, {dayNumber}. {month} {year}",
"time": "von {0} bis {1} Uhr",
"notConfirmed": "(noch nicht bestätigt)",
"name": "Name",
"exampleName": "Max Mustermann",
"email": "E-Mail",
"exampleEmail": "E-Mail",
"message": "Nachricht",
"exampleMessage": "Hinterlassen Sie uns eine Nachricht",
"pleaseFill": "Bitte füllen Sie dieses Feld aus.",
"invalidName": "Bitte geben Sie einen gültigen Namen ein.",
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
"inputTooLong": "Bitte geben Sie nicht mehr als {0} Zeichen ein.",
"inputTooShort": "Bitte geben Sie mindestens {0} Zeichen ein.",
"confirmButton": "Bestätigen",
"dsgvo": "Ich habe die {0} gelesen und akzeptiere diese.",
"dsgvoLink": "Datenschutzerklärung",
"dsgvoError": "Bitte akzeptieren Sie die Datenschutzerklärung.",
"errorTitle": "Es ist ein Fehler aufgetreten.",
"errorTryAgain": "Bitte versuchen Sie es erneut.",
"errorTooSlow": "Jemand war schneller als Sie und hat den Termin bereits gebucht. Bitte wählen Sie einen anderen Termin.",
"errorCaptcha": "Bitte bestätigen Sie, dass Sie kein Roboter sind."
},
"StepFive": {
"Title": "E-Mail bestätigen",
"confirmEmail": "Vielen Dank!\nIhr Termin wurde bestätigt. Sie erhalten in Kürze eine Bestätigung per E-Mail.\nBitte bestätigen Sie die E-Mail, um die Buchung fertigzustellen."
}
},
"service": {
"money": "{0} €",
"duration": "{0} Minuten"
},
"date": {
"timeFormat": "HH:mm",
"weekdays": {
"0": "Sonntag",
"1": "Montag",
"2": "Dienstag",
"3": "Mittwoch",
"4": "Donnerstag",
"5": "Freitag",
"6": "Samstag"
},
"months": {
"0": "Januar",
"1": "Februar",
"2": "März",
"3": "April",
"4": "Mai",
"5": "Juni",
"6": "Juli",
"7": "August",
"8": "September",
"9": "Oktober",
"10": "November",
"11": "Dezember"
}
},
"status": {
"404_1": "Ihr Termin wurde nicht gefunden oder existiert nicht mehr.",
"404_2": "Bitte buchen Sie einen neuen Termin.",
"confirmed": "Ihre Buchung ist bestätigt",
"pending": "Ihre Anfrage wird bearbeitet ...",
"error": "Es ist ein Fehler aufgetreten",
"cancelled": "Ihre Buchung wurde storniert",
"cancelButton": "Termin stornieren",
"cancelTitle": "Termin stornieren",
"cancelMessage": "Möchten Sie den Termin wirklich stornieren?",
"cancelCancelButton": "Termin behalten",
"notificationCancelled": "Sie erhalten keine weiteren Benachrichtigungen zu diesem Termin."
}
}

5
public/logo.svg Normal file
View File

@ -0,0 +1,5 @@
<svg width="438" height="130" viewBox="0 0 438 130" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="438" height="130" fill="white"/>
<path d="M53.2625 84.44H77.3265L76.8145 87H49.4225L49.8705 84.568L80.5265 44.696H57.4865L57.9985 42.072H84.3025L83.8545 44.568L53.2625 84.44ZM94.9815 44.44L91.6535 63.32H109.574L109.126 65.624H91.2695L87.8775 84.632H107.718L107.27 87H84.5495L92.5495 42.072H115.206L114.757 44.44H94.9815ZM126.867 42.072L118.867 87H115.987L123.987 42.072H126.867ZM162.193 42.072L161.745 44.44H148.945L141.457 87H138.577L146.065 44.44H133.265L133.713 42.072H162.193Z" fill="#242A56"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M393 30H169V99H393V30ZM194.312 79.064H211.08L212.36 87H223.944L215.624 42.072H202.952L178.76 87H190.216L194.312 79.064ZM207.176 54.104L209.8 70.744H198.6L207.176 54.104ZM264.348 44.312C261.361 42.8187 257.798 42.072 253.66 42.072H236.827L228.891 87H245.724C250.417 87 254.705 86.0613 258.588 84.184C262.513 82.3067 265.734 79.6613 268.252 76.248C270.812 72.8347 272.476 68.9307 273.243 64.536C273.542 62.6587 273.691 61.1013 273.691 59.864C273.691 56.28 272.881 53.1653 271.26 50.52C269.681 47.832 267.377 45.7627 264.348 44.312ZM256.988 74.2C254.257 76.504 250.822 77.656 246.684 77.656H241.499L246.107 51.416H251.355C254.897 51.416 257.628 52.2907 259.548 54.04C261.467 55.7467 262.428 58.1787 262.428 61.336C262.428 62.1893 262.321 63.256 262.107 64.536C261.425 68.6747 259.718 71.896 256.988 74.2ZM302.143 78.68H287.808L294.271 42.072H283.327L275.392 87H300.672L302.143 78.68ZM321.635 60.056L323.236 50.712H339.811L341.348 42.072H313.827L305.891 87H333.411L334.948 78.36H318.372L320.163 68.312H334.82L336.292 60.056H321.635ZM377.445 45.272C375.012 43.1387 371.471 42.072 366.82 42.072H348.452L340.516 87H351.46L354.469 70.04H357.092L363.428 87H375.781L368.549 69.208C372.047 68.2267 374.842 66.5627 376.932 64.216C379.023 61.8693 380.324 59.2027 380.837 56.216C381.007 55.3627 381.092 54.4453 381.092 53.464C381.092 50.0933 379.876 47.3627 377.445 45.272ZM369.764 55.32C369.764 55.5333 369.722 55.9813 369.637 56.664C369.295 58.456 368.527 59.8427 367.333 60.824C366.18 61.8053 364.602 62.296 362.596 62.296H355.812L357.797 51.032H364.581C366.287 51.032 367.567 51.416 368.421 52.184C369.316 52.9093 369.764 53.9547 369.764 55.32Z" fill="#9FABD5"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

25
public/manifest.json Normal file
View File

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

12
public/poweredByLogo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.6 KiB

3
public/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

11
src/App.css Normal file
View File

@ -0,0 +1,11 @@
.StepsContainer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
height: 100%;
}

132
src/App.js Normal file
View File

@ -0,0 +1,132 @@
import './App.css';
import React, { useEffect, useState, useContext } from 'react';
import StepOne, { ServiceList } from './Steps/StepOne';
import StepTwo from './Steps/StepTwo';
import StepThree from './Steps/StepThree';
import StepFour from './Steps/StepFour';
import StepFive from './Steps/StepFive';
import { LangContext, BookingContext } from './Context';
import { Modal, notification, ConfigProvider } from 'antd';
import testItems from './testItems.json';
import getScreenType from './ScreenType';
import { fetchJSON, apiPaths } from './fetch';
function App() {
const [screenType, setScreen] = useState(getScreenType());
//create useContext for lang
const [langData, setLangData] = useState(null);
const [step, setStep] = useState(0);
const [serviceItems, setServiceItems] = useState(null);
const [bookingData, setBookingData] = useState({ currentStep: 1, selectedService: null, selectedDate: null, selectedTime: null, selectedUser: null });
const [modal, contextHolderModal] = Modal.useModal();
const [notificationAPI, contextHolderNotification] = notification.useNotification();
// fetch my lang.json
useEffect(() => {
// get url query parameters
const urlParams = new URLSearchParams(window.location.search);
const storeID = urlParams.get('id');
console.log(storeID);
fetch('lang.json?id=' + storeID)
.then((response) => response.json())
.then((_langData) => {
setLangData(_langData);
fetchJSON(apiPaths.services, {}, {}, { key: "services" }).then((response) => {
let _services = response.services;
let services = [];
for (let i = 0; i < _services.length; i++) {
let service = _services[i];
if (service.activities !== undefined && service.activities.length > 0) {
services.push(service);
}
}
setServiceItems({ "activities": services, "users": response.users, "maxDate": response.maxDate, "storeName": response.storeName });
}).catch((error) => {
console.log(error);
modal.error({
title: _langData.modalError.title,
content: _langData.modalError["404"],
footer: (_, { OkBtn, CancelBtn }) => (
<>
</>
),
});
});
});
}, []);
useEffect(() => {
window.addEventListener('resize', () => {
setScreen(getScreenType());
});
}, []);
if (!langData) return null;
console.log(bookingData);
return (<ConfigProvider
theme={{
token: {
fontFamily: "Roboto, sans-serif",
colorPrimary: "#6878d6",
colorInfo: "#6878d6",
},
}}
>
<LangContext.Provider value={langData}>
<BookingContext.Provider value={[bookingData, setBookingData]}>
<div className='StepsContainer'>
{bookingData.currentStep === 5 ? <StepFive screenType={screenType}
serviceItems={serviceItems}
isDisabled={bookingData.selectedDate === null}>Step 4</StepFive> :
bookingData.currentStep === 4 ? <StepFour screenType={screenType}
notificationAPI={notificationAPI}
serviceItems={serviceItems}
isDisabled={bookingData.selectedDate === null}>Step 4</StepFour> :
<>
{(screenType >= 2 || (screenType === 1 && bookingData.currentStep <= 2) || (bookingData.currentStep === 1)) ? <StepOne key="stepOneScreen"
screenType={screenType}
serviceItems={serviceItems}
canSubmit={bookingData.selectedService !== null} /> : null}
{(screenType >= 1 && bookingData.currentStep <= 3) || bookingData.currentStep === 2 ? <StepTwo key="stepTwoScreen"
screenType={screenType}
serviceItems={serviceItems}
isDisabled={bookingData.selectedService === null}
canSubmit={bookingData.selectedDate !== null} /> : null}
{screenType === 2 || bookingData.currentStep === 3 ? <StepThree key="stepThreeScreen"
screenType={screenType}
serviceItems={serviceItems}
isDisabled={bookingData.selectedDate === null} /> : null}
</>}
</div>
{contextHolderNotification}
{contextHolderModal}
</BookingContext.Provider>
</LangContext.Provider>
</ConfigProvider>
);
}
export default App;

8
src/App.test.js Normal file
View File

@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

5
src/Context.js Normal file
View File

@ -0,0 +1,5 @@
import { createContext } from 'react';
export const LangContext = createContext();
export const BookingContext = createContext();

14
src/ScreenType.js Normal file
View File

@ -0,0 +1,14 @@
function getScreenType() {
const width = window.innerWidth;
if (width < 768) {
return 0;
} else if (width < 992) {
return 1;
} else {
return 2;
}
}
export default getScreenType;

113
src/Steps/StepBasic.js Normal file
View File

@ -0,0 +1,113 @@
import './styles.css';
import React, { useContext, Image } from 'react';
import { LangContext } from '../Context';
import { Button, Flex, Typography, Spin } from 'antd';
import config from '../config';
function StepBasic({ children, screenType, titleText, headerColor, bg, stepNumber, currentStep, maxSteps, isDisabled, isLoading, canSubmit, onClickNext, onClickBack, storeName }) {
const lang = useContext(LangContext);
let displayNextButton = false;
let displayBackButton = false;
let displayDsgvo = false;
let displayLogo = false;
let displayLogoBesideButtons = false;
let displayStoreName = false;
if (stepNumber === 1) {
displayDsgvo = true;
}
if (stepNumber === 2) {
displayStoreName = true;
}
if (stepNumber === 4) {
displayNextButton = true;
displayBackButton = true;
displayLogo = true;
displayStoreName = true;
} else if (stepNumber === 5) {
displayNextButton = false;
displayBackButton = false;
displayLogo = true;
displayStoreName = true;
} else if (screenType === 0) {
displayStoreName = true;
displayNextButton = true;
displayLogo = true;
if (stepNumber > 1)
displayBackButton = true;
} else if (screenType === 1 && stepNumber === 1) {
displayStoreName = true;
} else if (screenType === 1 && stepNumber === 2) {
if (currentStep === 1) {
displayStoreName = false;
displayNextButton = true;
displayLogo = true;
}
if (currentStep === 3)
displayBackButton = true;
} else if (screenType === 1 && stepNumber === 3) {
displayNextButton = true;
displayLogo = true;
} else if (screenType === 2 && stepNumber === 3) {
displayLogo = true;
}
displayLogoBesideButtons = displayLogo && (displayNextButton || displayBackButton);
const isNextButtonDisabled = isDisabled || !canSubmit;
const buttonDivider = displayLogoBesideButtons ? <img className={"poweredByImageSmall"} src={config.publicURL + "/poweredByLogo.svg"} alt="Powered by ZeitAdler" /> : <div style={{ width: '1rem' }}></div>;
if (!storeName)
displayStoreName = true;
return (
<div className={'StepBasicContainer' + (stepNumber <= 3 ? " StepBasicContainerResize" : "")} style={{ backgroundColor: bg }}>
<div className='StepBasicHeader' style={{ backgroundColor: headerColor }}>
<Typography.Title level={3} style={{ opacity: displayStoreName ? 1 : 0 }} >{displayStoreName ? storeName : "|"}</Typography.Title>
<Typography.Title level={2} >{titleText}</Typography.Title>
{stepNumber < 5 ? <Typography >{lang.Steps.StepProgress.replaceAll("{0}", stepNumber).replaceAll("{1}", maxSteps)}</Typography> : null}
</div>
<div className={'StepBasicContent'}>
{isDisabled || isLoading ? <>
<div className='StepBasicContentDisabled'>{isLoading ? <Flex justify="center" align="center" style={{ height: "100%" }}><Spin /></Flex> : null}</div>
<div className='StepBasicContentInner'>{children}</div>
</> : <div className='StepBasicContentInner'>{children}</div>}
</div>
{displayLogo && !displayLogoBesideButtons ? <img className={"poweredByImage"} src={config.publicURL + "/poweredByLogo.svg"} alt="Powered by ZeitAdler" /> : null}
{displayBackButton || displayNextButton || displayDsgvo ? <div className='StepBasicFooter'>
{displayDsgvo ? <Typography.Link target='_blank' href={config.dsgvo_url} >{lang.Steps.DSGVO}</Typography.Link> : null}
{displayBackButton ? <Button type='primary' size='large' onClick={onClickBack}>{lang.Steps.StepBack}</Button> : null}
{buttonDivider}
{displayNextButton ? <Button type='primary' size='large' disabled={isNextButtonDisabled} onClick={onClickNext}>{lang.Steps.StepNext}</Button> : null}
</div> : null}
</div>
);
}
export default StepBasic;

79
src/Steps/StepFive.js Normal file
View File

@ -0,0 +1,79 @@
import config from "../config";
import StepBasic from './StepBasic';
import React, { useEffect, useState, useContext, useRef } from 'react';
import { LangContext, BookingContext } from '../Context';
import {
Spin, Card, Button, Checkbox, Flex, Typography, Form,
Input, notification
} from 'antd';
import dayjs, { Dayjs } from 'dayjs';
import { fetchJSON, apiPaths } from "../fetch";
import TextArea from "antd/es/input/TextArea";
import * as EmailValidator from 'email-validator';
import FinalBooking from "../components/FinalBooking";
function StepFive({ screenType, serviceItems, canSubmit, isDisabled }) {
const [bookingData, setBookingData] = useContext(BookingContext);
const lang = useContext(LangContext);
const [formData, setFormData] = useState({});
const dsgvoCheckbox = useRef(null);
const scrollToDsgvoCheckbox = () => dsgvoCheckbox.current.scrollIntoView({ behavior: 'smooth' });
const canSubmitForm = formData.Name !== undefined && formData.EMail !== undefined;
const [notificationAPI, contextHolder] = notification.useNotification();
let chosenUser = {};
if (bookingData.chosenUser !== null) {
const userid = bookingData.chosenUser;
const username = serviceItems.users[userid].name;
chosenUser = { id: userid, name: username };
}
return <StepBasic
screenType={screenType}
storeName={serviceItems?.storeName}
titleText={lang.Steps.StepFive.Title}
headerColor={"var(--primary)"}
bg={"var(--background)"}
stepNumber={5}
maxSteps={config.maxSteps}
canSubmit={canSubmitForm}
onClickNext={() => {
}}
onClickBack={() => {
bookingData.currentStep = 3;
setBookingData({ ...bookingData });
}}>
<Flex vertical="true" wrap="wrap" justify="space-evenly" align="center" style={{ marginTop: 10 }}>
<div style={{ width: "100%", maxWidth: 400, flexGrow: 1 }}>
<FinalBooking selectedUser={chosenUser} infoText={lang.Steps.StepFour.notConfirmed} infoColor={"warning"} />
</div>
{lang.Steps.StepFive.confirmEmail.split('\n').map((item, key) => {
return <Typography.Text level={5} key={key} strong>{item}</Typography.Text>
})}
</Flex>
</StepBasic>;
}
export default StepFive;

252
src/Steps/StepFour.js Normal file
View File

@ -0,0 +1,252 @@
import config from "../config";
import StepBasic from './StepBasic';
import ReCAPTCHA from "react-google-recaptcha";
import React, { useEffect, useState, useContext, useRef } from 'react';
import { LangContext, BookingContext } from '../Context';
import {
Spin, Card, Button, Checkbox, Flex, Typography, Form,
Input
} from 'antd';
import dayjs, { Dayjs } from 'dayjs';
import { fetchJSON, apiPaths } from "../fetch";
import TextArea from "antd/es/input/TextArea";
import * as EmailValidator from 'email-validator';
import FinalBooking from "../components/FinalBooking";
function StepFour({ screenType, serviceItems, canSubmit, isDisabled, notificationAPI }) {
const [bookingData, setBookingData] = useContext(BookingContext);
const lang = useContext(LangContext);
const [form] = Form.useForm();
const [formData, setFormData] = useState({});
const dsgvoCheckbox = useRef(null);
const scrollToDsgvoCheckbox = () => dsgvoCheckbox.current.scrollIntoView({ behavior: 'smooth' });
const canSubmitForm = formData.Name !== undefined && formData.EMail !== undefined;
const [isLoading, setIsLoading] = useState(false);
const [recaptchaValue, setRecaptchaValue] = useState(undefined);
let chosenUser = {};
if (bookingData.chosenUser !== null) {
const userid = bookingData.chosenUser;
const username = serviceItems.users[userid].name;
chosenUser = { id: userid, name: username };
}
console.log(process.env.REACT_APP_RECAPTCHA_SITE_KEY)
useEffect(() => {
if (recaptchaValue !== undefined) {
form.validateFields(['captcha']);
}
}, [recaptchaValue]);
return <> <StepBasic
screenType={screenType}
storeName={serviceItems?.storeName}
titleText={lang.Steps.StepFour.Title}
headerColor={"var(--primary)"}
bg={"var(--background)"}
stepNumber={4}
maxSteps={config.maxSteps}
canSubmit={true}
isLoading={isLoading}
onClickNext={() => {
form.submit();
return;
}}
onClickBack={() => {
bookingData.currentStep = 3;
setBookingData({ ...bookingData });
}}>
<Flex horizontal="true" wrap="wrap" justify="space-evenly" align="center" style={{ marginTop: 10 }}>
<Typography.Title level={5}>{lang.Steps.StepFour.info}</Typography.Title>
<div style={{ width: "100%", maxWidth: 400, flexGrow: 1 }}>
<FinalBooking selectedUser={chosenUser} infoText={lang.Steps.StepFour.notConfirmed} infoColor={"danger"} />
<Form
form={form}
labelCol={{ span: 6 }}
wrapperCol={{ flex: 1 }}
onSubmitCapture={(e) => {
e.preventDefault();
form.submit();
}}
onFinish={(formData) => {
console.log("onFinish", formData);
let name = formData.Name;
// remove all double spaces
while (name.indexOf(" ") !== -1) {
name = name.replaceAll(" ", " ");
}
// remove spaces at the beginning and end
name = name.trim();
setIsLoading(true);
fetchJSON(apiPaths.booking, {
activity_id: bookingData.selectedService.id,
time: bookingData.selectedTime.unix() * 1000,
name: formData.Name,
email: formData.EMail,
message: formData.Message !== undefined ? formData.Message : "",
captcha: recaptchaValue,
employee: bookingData.chosenUser,
}, {}, { key: "booking" }
).then((response) => {
if (response.status === "success") {
bookingData.currentStep = 5;
setBookingData({ ...bookingData });
} else if (response.status === "already booked") {
notificationAPI["error"]({
message: lang.Steps.StepFour.errorTitle,
description: lang.Steps.StepFour.errorTooSlow,
duration: 30,
});
bookingData.currentStep = 3;
bookingData.selectedTime = null;
setBookingData({ ...bookingData });
} else {
notificationAPI["error"]({
message: lang.Steps.StepFour.errorTitle,
description: lang.Steps.StepFour.errorTryAgain,
});
}
}).catch((error) => {
console.error(error);
notificationAPI["error"]({
message: lang.Steps.StepFour.errorTitle,
description: lang.Steps.StepFour.errorTryAgain,
});
}).finally(() => {
setIsLoading(false);
});
//bookingData.currentStep = 5;
//setBookingData({ ...bookingData });
}}
style={{ marginBottom: 60 }}
scrollToFirstError={{ behavior: 'smooth', block: 'center', focus: true }}
colon={false}
size="large"
>
<Form.Item name="Name" label={lang.Steps.StepFour.name}
validateFirst
validateDebounce={1000}
rules={[{ required: true, message: lang.Steps.StepFour.pleaseFill },
{ max: 30, message: lang.Steps.StepFour.inputTooLong.replaceAll("{0}", 30) },
{ min: 3, message: lang.Steps.StepFour.inputTooShort.replaceAll("{0}", 3) },
{
validator: (_, value) => {
let _value = value.replaceAll(" ", "");
// Allow letters, spaces, and special characters like hyphens and apostrophes
const isValidName = /^[\p{L} \-']+$/u.test(_value);
if (!isValidName) {
return Promise.reject(lang.Steps.StepFour.invalidName);
}
return Promise.resolve();
},
}]}
>
<Input placeholder={lang.Steps.StepFour.exampleName} autoComplete="name" field={"formData.Name?.value"} />
</Form.Item>
<Form.Item name="EMail" label={lang.Steps.StepFour.email}
validateDebounce={1000}
rules={[{
required: true, validator: (_, value) => {
if (value === undefined || value === "") {
return Promise.reject(lang.Steps.StepFour.pleaseFill);
}
if (!EmailValidator.validate(value)) {
return Promise.reject(lang.Steps.StepFour.invalidEmail);
}
return Promise.resolve();
}
}]}>
<Input type="email" placeholder={lang.Steps.StepFour.exampleEmail} autoComplete="email" />
</Form.Item>
<Form.Item name="Message" label={lang.Steps.StepFour.message}
rules={[{ max: 1500, message: lang.Steps.StepFour.inputTooLong.replaceAll("{0}", 1500) }]}>
<TextArea placeholder={lang.Steps.StepFour.exampleMessage} autoSize={{ minRows: 2 }} />
</Form.Item>
<Form.Item name="dsgvo" valuePropName="checked"
rules={[
{
validator: (_, checked) =>
checked ? Promise.resolve() : Promise.reject(new Error(lang.Steps.StepFour.dsgvoError)),
},
]}>
<Checkbox> {lang.Steps.StepFour.dsgvo.split("{0}")[0]}<Typography.Link href={config.dsgvo_url} target="_blank">{lang.Steps.StepFour.dsgvoLink}</Typography.Link>{lang.Steps.StepFour.dsgvo.split("{0}")[1]}</Checkbox>
</Form.Item>
<Form.Item name="captcha" label={lang.Steps.StepFour.captcha} style={{ alignSelf: "center" }}
rules={[
{
validator: (_, value) =>
recaptchaValue !== undefined && recaptchaValue !== null ? Promise.resolve() : Promise.reject(new Error(lang.Steps.StepFour.errorCaptcha)),
},
]}>
<ReCAPTCHA
sitekey={process.env.REACT_APP_RECAPTCHA_SITE_KEY}
onChange={(value) => {
setRecaptchaValue(value);
}}
/>
</Form.Item>
</Form>
</div>
</Flex>
</StepBasic></>;
}
export default StepFour;

118
src/Steps/StepOne.js Normal file
View File

@ -0,0 +1,118 @@
import config from "../config";
import StepBasic from './StepBasic';
import React, { useEffect, useState, useContext } from 'react';
import { LangContext, BookingContext } from '../Context';
import { Spin, Card, Collapse, Flex, Typography } from 'antd';
const { Meta } = Card;
const ServiceCollapse = ({ myData, isLastItem }) => {
return (
<Collapse
style={{
...(isLastItem ? {} : { marginBottom: "1rem" }),
backgroundColor: "var(--background-3-collapse)",
// text warp
whiteSpace: "normal",
}}
items={[{ key: "serviceWidgetCollapse-" + myData.id, label: <Typography>{myData.name}</Typography>, children: <ServiceList data={myData} /> }]}
>
</Collapse>
);
}
function ServiceList({ data }) {
const lang = useContext(LangContext);
const [bookingData, setBookingData] = useContext(BookingContext);
return (
<div style={{ width: "100%", height: "100%" }}>
{data.activities.map((myData, i) => {
const isLastItem = i === data.activities.length - 1;
if (myData.activities !== undefined) {
if (myData.activities.length === 0) return null;
return <ServiceCollapse
key={"serviceWidgetCollapse" + myData.id}
myData={myData}
isLastItem={isLastItem}
/>;
}
return (
<Card key={"serviceWidgetCard" + myData.id}
hoverable onClick={() => {
//bookingData.selectedService = { ...myData };
//setBookingData(bookingData);
setBookingData({ ...bookingData, selectedService: { ...myData } });
}} style={{
...(isLastItem ? {} : { marginBottom: "1rem" }),
backgroundColor: (bookingData.selectedService?.id === myData.id ? "var(--serviceWidget-background-selected)" : "var(--serviceWidget-background)")
}}
size="default"
>
<Typography style={{ fontWeight: "bold" }}>{myData.name}</Typography>
<Typography style={{ opacity: 0.6 }}>{myData.description}</Typography>
<Flex justify='space-around' style={{ marginTop: 10 }}>
<Typography>{lang.service.money.replaceAll("{0}", myData.price)}</Typography>
<Typography>{lang.service.duration.replaceAll("{0}", myData.duration)}</Typography>
</Flex>
</Card>
);
})}
</div>
);
}
function StepOne({ screenType, serviceItems, canSubmit }) {
const [bookingData, setBookingData] = useContext(BookingContext);
const lang = useContext(LangContext);
return <StepBasic
screenType={screenType}
storeName={serviceItems?.storeName}
titleText={lang.Steps.StepOne.Title}
headerColor={"var(--primary)"}
bg={"var(--background-3)"}
stepNumber={1}
maxSteps={config.maxSteps}
canSubmit={canSubmit}
onClickNext={() => {
bookingData.currentStep = 2;
setBookingData({ ...bookingData });
}}>
{serviceItems === null ? <Flex justify="center" align="center" style={{ height: "100%" }}><Spin /></Flex> :
serviceItems.activities.length === 0 ? <Flex justify="center" align="center" style={{ height: "100%" }}><Typography.Text type="secondary">{lang.Steps.StepOne.noServices}</Typography.Text></Flex> :
<div style={{ width: "100%", height: "100%", textAlign: "left" }}>
<div style={{ paddingBottom: 15 }}>
<ServiceList data={serviceItems} />
</div>
</div>
}
</StepBasic>
}
export { ServiceList };
export default StepOne;

289
src/Steps/StepThree.js Normal file
View File

@ -0,0 +1,289 @@
import config from "../config";
import StepBasic from './StepBasic';
import React, { useEffect, useState, useContext } from 'react';
import { LangContext, BookingContext } from '../Context';
import { Spin, Card, Button, Collapse, Flex, Typography } from 'antd';
import dayjs, { Dayjs } from 'dayjs';
import { fetchJSON, apiPaths } from "../fetch";
/**
* Mimic fetch with abort controller https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort
* No IE11 support
*/
function TimePaper({ data }) {
const [bookingData, setBookingData] = useContext(BookingContext);
return <Card onClick={() => {
setBookingData({ ...bookingData, currentStep: 4, selectedTime: data.time, chosenUser: data.user });
}}
size="small"
className="timePaper"
>
{data.time.format("HH:mm")}
{/*<Typography.Text style={{ marginLeft: "1rem" }}>{data.user}</Typography.Text>*/}
</Card>
}
function StepThree({ screenType, canSubmit, isDisabled, serviceItems }) {
const [selectedServiceAndDate, setSelectedServiceAndDate] = useState({ service: null, date: null }); // contains the service and selected date
const [bookingData, setBookingData] = useContext(BookingContext);
const lang = useContext(LangContext);
const requestAbortController = React.useRef(null);
const [freeTimeList, setFreeTimeList] = React.useState(null);
const [isLoading, setIsLoading] = React.useState(true);
const fetchHighlightedDays = (date) => {
const controller = new AbortController();
fetchJSON(apiPaths.freeAtDay, { "date": ((bookingData.selectedDate).unix() * 1000), "activity_id": bookingData.selectedService.id, "users": bookingData.selectedUser !== null ? [bookingData.selectedUser] : [] },
{ signal: controller.signal }, { key: "stepThree" }
)
.then((response) => {
console.log("freeTime", response);
//const daysToHighlight = response.events.map((day) => dayjs(day).date());
let events = response.events;
let _freeTimeList = [];
let normalFreeTimeList = [];
const normalTimeDistance = 30; // minutes
const maxOneTimeEvery = 15 - 1; // minutes
for (let i = 0; i < events.length; i++) {
let userEvents = events[i];
for (let j = 0; j < userEvents.events.length; j++) {
let _event = userEvents.events[j];
let userId = userEvents.userId;
let start = dayjs(_event.start);
let end = dayjs(_event.end);
//if start is before today 23:59
if (start.isBefore(dayjs().endOf("day"))) {
let cmebt = 15;
if (serviceItems !== undefined && serviceItems.users[userId] !== undefined && serviceItems.users[userId].cmebt !== undefined) {
cmebt = serviceItems.users[userId].cmebt;
console.log("cmebt", cmebt);
}
let earliestStart = dayjs().add(cmebt, "minute");
if (start.isBefore(earliestStart)) {
start = earliestStart;
if (start.isAfter(end)) {
continue;
}
}
}
let startCalc = dayjs(start);
// for loop that loops a whole day in normalTimeDistance steps
let startMinute = start.minute();
let remainder = startMinute % normalTimeDistance;
if (remainder !== 0) {
startCalc = startCalc.add(normalTimeDistance - remainder, "minute");
}
for (let k = startCalc; k < end; k = k.add(normalTimeDistance, "minute")) {
normalFreeTimeList.push({ time: k, user: userId });
}
_freeTimeList.push({ time: start, user: userId });
_freeTimeList.push({ time: end, user: userId });
}
}
// add normalFreeTimeList to _freeTimeList
for (let i = 0; i < normalFreeTimeList.length; i++) {
let _event = normalFreeTimeList[i];
let found = false;
for (let j = 0; j < _freeTimeList.length; j++) {
let _event2 = _freeTimeList[j];
if (_event.user === _event2.user && _event.time.isSame(_event2.time)) {
found = true;
break;
}
}
if (!found) {
_freeTimeList.push(_event);
}
}
_freeTimeList.sort((a, b) => {
return a.time.unix() - b.time.unix();
});
console.log("_freeTimeList", _freeTimeList);
// create a new clusterArray where when there are multiple times withhin maxOneTimeEvery minutes, they are in the same cluster
let clusterArray = [];
let currentCluster = [];
let lastTime = null;
for (let i = 0; i < _freeTimeList.length; i++) {
let _event = _freeTimeList[i];
if (lastTime === null) {
currentCluster.push(_event);
} else {
let diff = _event.time.diff(lastTime, "minute");
if (diff <= maxOneTimeEvery) {
currentCluster.push(_event);
} else {
clusterArray.push(currentCluster);
currentCluster = [];
currentCluster.push(_event);
}
}
lastTime = _event.time;
}
if (currentCluster.length > 0) {
clusterArray.push(currentCluster);
}
console.log("clusterArray", clusterArray);
// filter out duplicated users and use only the smallest time for that user
for (let i = 0; i < clusterArray.length; i++) {
let cluster = clusterArray[i];
let users = [];
for (let j = 0; j < cluster.length; j++) {
let _event = cluster[j];
if (users.indexOf(_event.user) === -1) {
users.push(_event.user);
}
}
let newCluster = [];
for (let j = 0; j < users.length; j++) {
let user = users[j];
let smallestTime = null;
for (let k = 0; k < cluster.length; k++) {
let _event = cluster[k];
if (_event.user === user) {
if (smallestTime === null || _event.time.isBefore(smallestTime)) {
smallestTime = _event.time;
}
}
}
newCluster.push({ time: smallestTime, user: user });
}
clusterArray[i] = newCluster;
}
console.log("clusterArray2", clusterArray);
// create a new freeTimeListFiltered where the time is a random time from the cluster
let freeTimeListFiltered = [];
for (let i = 0; i < clusterArray.length; i++) {
let cluster = clusterArray[i];
let randomIndex = Math.floor(Math.random() * cluster.length);
freeTimeListFiltered.push(cluster[randomIndex]);
}
setFreeTimeList(freeTimeListFiltered);
setIsLoading(false);
})
.catch((error) => {
setFreeTimeList([]);
setIsLoading(false);
// ignore the error if it's caused by `controller.abort`
if (error.name !== 'AbortError') {
throw error;
}
});
requestAbortController.current = controller;
};
React.useEffect(() => {
console.log("StepThree useEffect");
if (selectedServiceAndDate.service !== bookingData.selectedService || selectedServiceAndDate.date !== bookingData.selectedDate) {
if (bookingData.service !== null && bookingData.selectedDate !== null) {
fetchHighlightedDays(selectedServiceAndDate.selectedDate);
}
setSelectedServiceAndDate({ service: bookingData.selectedService, date: bookingData.date });
setIsLoading(true);
}
}, [bookingData]);
React.useEffect(() => {
// abort request on unmount
return () => requestAbortController.current?.abort();
}, []);
const loading = isLoading || freeTimeList === null;
return <StepBasic
screenType={screenType}
storeName={serviceItems?.storeName}
titleText={lang.Steps.StepThree.Title}
headerColor={"var(--primary-3)"}
bg={"var(--background)"}
stepNumber={3}
maxSteps={config.maxSteps}
isDisabled={isDisabled}
onClickBack={() => {
bookingData.currentStep = 2;
setBookingData({ ...bookingData });
}}>
{loading ? <Flex justify="center" align="center" style={{ height: "100%" }}><Spin /></Flex> : freeTimeList.length === 0 ?
<Typography>{lang.Steps.StepThree.noFreeTime}</Typography> : <div style={{ marginBottom: 15 }}>{freeTimeList.map((data, i) => {
return <TimePaper key={"freeTimeKey" + i} data={data} />;
})}</div>}
</StepBasic>;
}
export default StepThree;

249
src/Steps/StepTwo.js Normal file
View File

@ -0,0 +1,249 @@
import config from "../config";
import StepBasic from './StepBasic';
import React, { useEffect, useState, useContext } from 'react';
import { LangContext, BookingContext } from '../Context';
import { Spin, Button, Select, Flex, Typography } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import { DateCalendar } from '@mui/x-date-pickers/DateCalendar';
import { DayCalendarSkeleton } from '@mui/x-date-pickers/DayCalendarSkeleton';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { PickersDay } from '@mui/x-date-pickers/PickersDay';
import dayjs, { Dayjs } from 'dayjs';
import 'dayjs/locale/de';
import Badge from '@mui/material/Badge';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { fetchJSON, apiPaths } from "../fetch";
import { CheckCircleTwoTone } from '@ant-design/icons';
/**
* Mimic fetch with abort controller https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort
* No IE11 support
*/
const initialValue = dayjs(new Date());
let curMonth = initialValue;
function ServerDay(props) {
const { highlightedDays = [], day, outsideCurrentMonth, ...other } = props;
const isFreeTime =
!props.outsideCurrentMonth && highlightedDays.indexOf(props.day.date()) >= 0;
return (
<Badge
key={props.day.toString()}
style={{ pointerEvents: "none", overflow: "visible" }}
overlap="circular"
badgeContent={isFreeTime ? <CheckCircleTwoTone twoToneColor={"#0a0"} style={{ fontSize: 16 }} /> : undefined} >
<PickersDay {...other} disabled={!isFreeTime} outsideCurrentMonth={outsideCurrentMonth} day={day} style={{ backgroundColor: isFreeTime ? null : null, pointerEvents: 'auto' }} />
</Badge>
);
}
function StepTwo({ screenType, serviceItems, canSubmit, isDisabled }) {
const [bookingData, setBookingData] = useContext(BookingContext);
const lang = useContext(LangContext);
const selectedService = bookingData.selectedService;
const requestAbortController = React.useRef(null);
const [highlightedDays, setHighlightedDays] = React.useState([]);
const [isLoading, setIsLoading] = React.useState(true);
let userOptions = [];
if (selectedService !== null && serviceItems !== null && serviceItems.users !== undefined) {
if (selectedService.users === undefined || Object.keys(selectedService.users).length === 0) {
for (let userId in serviceItems.users) {
userOptions.push({ value: userId, label: serviceItems.users[userId].name });
}
} else {
for (let i = 0; i < selectedService.users.length; i++) {
const userId = selectedService.users[i];
let user = serviceItems.users[userId];
let name = "Unbekannt";
if (user)
name = user.name;
userOptions.push({ value: userId, label: name });
}
}
}
const fetchHighlightedDays = (date) => {
const controller = new AbortController();
console.log("bookingData.selectedUser", bookingData.selectedUser)
console.log("date", date);
console.log("date formated de", date.format("DD.MM.YYYY"));
fetchJSON(apiPaths.freeNextDays, { "date": (date.unix() * 1000), "activity_id": bookingData.selectedService.id, "users": (bookingData.selectedUser !== undefined && bookingData.selectedUser !== null) ? [bookingData.selectedUser] : [] }
, { signal: controller.signal }, { key: "stepTwo" }
)
.then((response) => {
console.log(response);
const daysToHighlight = response.freeDays.map((day) => dayjs(day).date());
setHighlightedDays(daysToHighlight);
setIsLoading(false);
//setSelectedService(bookingData.selectedService);
})
.catch((error) => {
// ignore the error if it's caused by `controller.abort`
if (error.name !== 'AbortError') {
throw error;
}
});
requestAbortController.current = controller;
};
function reloadDays() {
if (requestAbortController.current) {
// make sure that you are aborting useless requests
// because it is possible to switch between months pretty quickly
requestAbortController.current.abort();
}
setHighlightedDays([]);
const date = curMonth || bookingData.selectedDate || initialValue;
fetchHighlightedDays(date);
setIsLoading(true);
}
React.useEffect(() => {
if (bookingData.selectedUser !== null && userOptions.filter((user) => user.value === bookingData.selectedUser).length === 0) {
bookingData.selectedUser = null;
setBookingData({ ...bookingData });
}
});
React.useEffect(() => {
console.log("bookingData.selectedService!", bookingData.selectedService);
if (bookingData.selectedService !== undefined && bookingData.selectedService !== null) {
//setSelectedService(bookingData.selectedService);
console.log("bookingData.selectedDate", bookingData.selectedDate);
reloadDays();
}
}, [bookingData.selectedService]);
React.useEffect(() => {
// abort request on unmount
return () => requestAbortController.current?.abort();
}, []);
const handleMonthChange = (date) => {
curMonth = date;
reloadDays();
};
return <StepBasic
screenType={screenType}
storeName={serviceItems?.storeName}
titleText={lang.Steps.StepTwo.Title}
headerColor={"var(--primary-2)"}
bg={"var(--background-2)"}
stepNumber={2}
currentStep={bookingData.currentStep}
maxSteps={config.maxSteps}
isDisabled={isDisabled}
onClickBack={() => {
bookingData.currentStep = 1;
setBookingData({ ...bookingData });
}}
onClickNext={() => {
bookingData.currentStep = 3;
setBookingData({ ...bookingData });
}}
canSubmit={canSubmit}>
<Flex style={{ maxWidth: 300, alignSelf: "center", width: "100%", marginBottom: 20 }}>
<Select
style={{ flex: 1, flexGrow: 1 }}
value={bookingData.selectedUser}
placeholder={lang.Steps.StepTwo.allEmployees}
options={userOptions}
onChange={(value) => {
bookingData.selectedUser = value;
setBookingData({ ...bookingData });
reloadDays();
}}
/>
<Button disabled={bookingData.selectedUser === null || bookingData.selectedUser === undefined}
type="text"
shape="circle"
danger
icon={<CloseOutlined />}
style={{ marginLeft: 5 }}
onClick={() => {
bookingData.selectedUser = null;
setBookingData({ ...bookingData });
reloadDays();
}}
/>
</Flex>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="de">
<div style={{ display: "flex", justifyContent: "center", alignItems: "center" }}>
<DateCalendar
value={bookingData.selectedDate}
loading={bookingData.selectedService === null || isLoading}
onMonthChange={handleMonthChange}
onYearChange={handleMonthChange}
minDate={dayjs()}
maxDate={serviceItems ? dayjs(serviceItems.maxDate) : null}
renderLoading={() => <DayCalendarSkeleton />}
onChange={(newValue) => {
bookingData.selectedDate = newValue;
setBookingData({ ...bookingData });
}}
slots={{
day: ServerDay,
}}
slotProps={{
day: {
highlightedDays,
},
}}
/>
</div>
</LocalizationProvider>
</StepBasic>;
}
export default StepTwo;

179
src/Steps/styles.css Normal file
View File

@ -0,0 +1,179 @@
.poweredByImageSmall {
width: 0;
max-width: 200px;
flex: 1 1 auto;
pointer-events: none;
margin: 0 10px;
}
.poweredByImage {
position: absolute;
bottom: 10px;
right: 10px;
width: 200px;
height: auto;
margin: 0;
padding: 0;
z-index: 2;
pointer-events: none;
}
@media screen and (max-width: 768px) {
.StepBasicContainerResize {
max-width: 100%;
}
}
@media screen and (min-width: 768px) and (max-width: 992px) {
.StepBasicContainerResize {
max-width: 50%;
}
.poweredByImagee {
position: absolute;
bottom: 0;
right: 10px;
width: 200px;
height: auto;
margin: 0;
padding: 0;
z-index: 2;
}
}
@media screen and (min-width: 992px) {
.StepBasicContainerResize {
max-width: 33.4%;
}
.poweredByImagee {
position: absolute;
bottom: 0;
right: 10px;
width: 200px;
height: auto;
margin: 0;
padding: 0;
z-index: 2;
}
}
.StepBasicContainer {
flex: 1 1 auto;
align-self: stretch;
background-color: var(--background-3);
display: flex;
flex-direction: column;
position: relative;
}
.StepBasicHeader h3 {
color: var(--fontColorHeader);
font-weight: 400;
margin: 15px 5px 0 5px;
padding: 0;
}
.StepBasicHeader {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--primary);
position: relative;
text-align: center;
}
.StepBasicHeader h2 {
color: var(--fontColorHeader);
font-weight: 400;
margin: 0px 5px 20px 5px !important;
padding: 0;
}
.StepBasicHeader article {
color: var(--fontColorHeader-undertitle);
font-weight: 400;
margin: 0;
padding: 0;
position: absolute;
bottom: 0;
}
.StepBasicContent {
display: flex;
flex-direction: column;
flex-grow: 1;
position: relative;
overflow: hidden;
}
.StepBasicContentInner {
display: flex;
flex-direction: column;
flex-grow: 1;
position: relative;
padding: 15px 15px 0 15px;
overflow-y: auto;
}
.StepBasicContentDisabled {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.5);
z-index: 1;
backdrop-filter: blur(5px);
}
.StepBasicFooter {
display: flex;
flex-direction: row;
align-items: end;
justify-content: space-between;
text-align: center;
margin: 15px;
align-items: center;
}
.timePaper {
background-color: var(--serviceWidget-background-selected);
margin-bottom: 4px;
cursor: pointer;
transition: all 200ms ease-out;
}
.timePaper:hover {
background-color: var(--primary-3);
color: var(--fontColorHeader);
transform: scale(1.02);
}
.MuiDayCalendar-monthContainer {
overflow: visible !important;
}

18
src/colors.css Normal file
View File

@ -0,0 +1,18 @@
:root {
--background: #FFF;
--background-2: #F9F9F9;
--background-3: #f3f3f3;
--background-3-collapse: #ffffff;
--serviceWidget-background: #fafafa;
--serviceWidget-background-selected: #eff2ff;
--primary: #4d61d6;
--primary-2: #6878d6;
--primary-3: #7f8cd3;
--fontColorHeader: #fff;
--fontColorHeader-undertitle: #fffa;
}

View File

@ -0,0 +1,9 @@
.myBookingWidget {
background-color: var(--background-3);
border-radius: 10px;
padding: 10px 5px;
text-align: center;
margin-bottom: 20px;
}

View File

@ -0,0 +1,39 @@
import "./FinalBooking.css";
import React, { useContext } from 'react';
import { LangContext, BookingContext } from '../Context';
import {
Typography
} from 'antd';
import dayjs from 'dayjs';
function FinalBooking({ selectedUser, infoText, infoColor }) {
const [bookingData, setBookingData] = useContext(BookingContext);
const lang = useContext(LangContext);
let dateText = lang.Steps.StepFour.date;
dateText = dateText.replaceAll("{day}", lang.date.weekdays[bookingData.selectedDate.day().toString()]);
dateText = dateText.replaceAll("{dayNumber}", bookingData.selectedDate.date());
dateText = dateText.replaceAll("{month}", lang.date.months[bookingData.selectedDate.month().toString()]);
dateText = dateText.replaceAll("{year}", bookingData.selectedDate.year());
let timeText = lang.Steps.StepFour.time;
timeText = timeText.replaceAll("{0}", bookingData.selectedTime.format(lang.date.timeFormat));
timeText = timeText.replaceAll("{1}", dayjs(bookingData.selectedTime).add(bookingData.selectedService.duration * 60 * 1000).format(lang.date.timeFormat));
return <div className="myBookingWidget" >
<Typography.Text strong style={{ color: "var(--primary)" }}>{lang.Steps.StepFour.yourDate}</Typography.Text>
<Typography>{lang.Steps.StepFour.what.replaceAll("{0}", bookingData.selectedService.name).replaceAll("{1}", selectedUser.name)}</Typography>
<Typography>{dateText}</Typography>
<Typography>{timeText}</Typography>
<Typography.Text strong type={infoColor}>{infoText}</Typography.Text>
</div>;
}
export default FinalBooking;

3
src/config.js Normal file
View File

@ -0,0 +1,3 @@
let config = { maxSteps: 4, dsgvo_url: "https://zeitadler.de/datenschutzerklaerung", backend_url: process.env.REACT_APP_BACKEND_URL || "", publicURL: process.env.PUBLIC_URL };
export default config;

43
src/fetch.js Normal file
View File

@ -0,0 +1,43 @@
import config from "./config";
const apiPaths = {
"freeNextDays": "/freeNextDaysByActivity",
"freeAtDay": "/freeByActivity",
"services": "/services",
"booking": "/booking",
}
let fetchPart = {};
async function fetchJSON(url, data, options, { key }) {
const urlParams = new URLSearchParams(window.location.search);
const storeID = urlParams.get('id');
let timeOutTime = 500;
if (key === "services") {
timeOutTime = 50;
}
return new Promise((resolve, reject) => {
if (fetchPart[key] !== undefined) {
clearTimeout(fetchPart[key].delay);
}
const delay = setTimeout(() => {
resolve(fetch(config.backend_url + url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ store_id: storeID, ...data }),
...options
}).then(response => response.json()));
}, timeOutTime);
fetchPart[key] = { delay };
});
}
export { fetchJSON, apiPaths };

17
src/index.js Normal file
View File

@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './colors.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<App />
);

150
src/testItems.json Normal file
View File

@ -0,0 +1,150 @@
{
"items": [
{
"id": 1,
"name": "Für Männer",
"items": [
{
"id": 2,
"name": "Haarschnitt",
"description": "Mache dein Haarschnitt mit einem unserer Profis. Wir bieten dir eine große Auswahl an verschiedenen Haarschnitten an. Von kurz bis lang, von klassisch bis modern. Wir haben für jeden etwas dabei.",
"price": 25,
"duration": 30
},
{
"id": 3,
"name": "Bart",
"price": 15,
"duration": 15
},
{
"id": 4,
"name": "Haarschnitt & Bart",
"price": 35,
"duration": 45
}
]
},
{
"id": 5,
"name": "Für Frauen",
"items": [
{
"id": 6,
"name": "Haarschnitt",
"price": 35,
"duration": 45
},
{
"id": 7,
"name": "Haare färben",
"price": 45,
"duration": 60
},
{
"id": 8,
"name": "Haare färben & Haarschnitt",
"price": 55,
"duration": 75
}
]
},
{
"id": 9,
"name": "Für Kinder",
"items": [
{
"id": 10,
"name": "Haarschnitt",
"price": 15,
"duration": 30
},
{
"id": 11,
"name": "Haarschnitt & Bart",
"price": 25,
"duration": 45
}
]
},
{
"id": 80,
"name": "Haarwachs auftragen",
"price": 5,
"duration": 5
},
{
"id": 81,
"name": "Kaufen",
"items": [
{
"id": 82,
"name": "Haarwachs",
"price": 10,
"duration": 5
},
{
"id": 83,
"name": "Haarwachs & Haarschnitt",
"items": [
{
"id": 84,
"name": "Haarwachs",
"price": 10,
"duration": 5
},
{
"id": 85,
"name": "Haarschnitt",
"price": 25,
"duration": 300
}
]
}
]
},
{
"id": 12,
"name": "Extras",
"items": [
{
"id": 13,
"name": "Haare waschen",
"price": 5,
"duration": 15
},
{
"id": 14,
"name": "Haare föhnen",
"price": 10,
"duration": 15
},
{
"id": 15,
"name": "Haare glätten",
"price": 15,
"duration": 30
}
]
}
]
}