Initial commit
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -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"
|
|
@ -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;
|
||||
};
|
||||
},
|
||||
}
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
PUBLIC_URL=httpss://myexample
|
||||
REACT_APP_RECAPTCHA_SITE_KEY=
|
||||
REACT_APP_BACKEND_URL=httpss://mybackend
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 7.0 KiB |
After Width: | Height: | Size: 19 KiB |
After Width: | Height: | Size: 6.7 KiB |
|
@ -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>
|
After Width: | Height: | Size: 840 B |
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 5.4 KiB |
After Width: | Height: | Size: 5.5 KiB |
After Width: | Height: | Size: 6.1 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 4.1 KiB |
|
@ -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 |
|
@ -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"
|
||||
}
|
|
@ -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%;
|
||||
}
|
|
@ -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>
|
|
@ -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."
|
||||
}
|
||||
}
|
|
@ -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 |
|
@ -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"
|
||||
}
|
After Width: | Height: | Size: 8.6 KiB |
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
|
@ -0,0 +1,11 @@
|
|||
.StepsContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
}
|
|
@ -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;
|
|
@ -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();
|
||||
});
|
|
@ -0,0 +1,5 @@
|
|||
import { createContext } from 'react';
|
||||
|
||||
export const LangContext = createContext();
|
||||
|
||||
export const BookingContext = createContext();
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
.myBookingWidget {
|
||||
background-color: var(--background-3);
|
||||
border-radius: 10px;
|
||||
padding: 10px 5px;
|
||||
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 };
|
|
@ -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 />
|
||||
|
||||
|
||||
|
||||
);
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|