This commit is contained in:
Philippe Torrel
2026-10-28 11:36:44 +01:00
parent c623e6f9d6
commit a2b463d491
59 changed files with 2201 additions and 10 deletions
Binary file not shown.
@@ -11,6 +11,7 @@
},
"dependencies": {
"bootstrap": "^5.3.8",
"joi": "^18.3.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-hook-form": "^7.91.0",
@@ -0,0 +1,159 @@
import { useState } from 'react';
import validate from './validate'; // joi validate schema object
const INITIAL_FORM_DATA = {
firstname: '', //
lastname: '',
email: '',
message: '',
};
const FormContact = (props) => {
// const { } = props;
const [formData, setFormData] = useState(INITIAL_FORM_DATA);
const [errors, setErrors] = useState({});
// EventHandlers =======
const handleSubmit = (e) => {
e.preventDefault(); // Standardverhalten (Versenden vom Formular) unterbinden.
const form = e.currentTarget;
const errors = validate(formData);
if (Object.keys(errors).length === 0) {
console.log('Form submitted ....');
// TODO: send over AJAX/Fetch
form.reset();
setFormData(INITIAL_FORM_DATA);
setErrors({});
} else {
console.warn('Form submit error');
setErrors(errors);
}
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((state) => ({ ...state, [name]: value }));
// console.log({ [e.target.name]: e.target.value });
// console.log('Formdata', formData);
};
const handleBlur = (e) => {
const { name, value } = e.target;
const validationErrors = validate({ ...formData, [name]: value });
setErrors((state) => {
const nextErrors = { ...state };
if (validationErrors[name]) {
nextErrors[name] = validationErrors[name];
} else {
delete nextErrors[name];
}
return nextErrors;
});
};
return (
<form className="m-form-contact form-contact" noValidate onSubmit={handleSubmit}>
<div className="row">
<div className="col-12 col-md">
<div className="mb-3">
<label htmlFor="input-firstname" className="form-label">
Firstname
</label>
<input
type="text"
name="firstname"
id="input-firstname"
className={`form-control input-firstname ${errors.firstname ? 'is-invalid' : ''}`}
value={formData.firstname}
onChange={handleChange}
onBlur={handleBlur}
/>
<div className="valid-feedback">OK</div>
<div className="invalid-feedback">{errors?.firstname}</div>
</div>
</div>
<div className="col-12 col-md">
<div className="mb-3">
<label htmlFor="input-lastname" className="form-label">
Lastname
</label>
<input
type="text"
name="lastname"
id="input-lastname"
className={`form-control input-lastname ${errors.lastname ? 'is-invalid' : ''}`}
value={formData.lastname}
onChange={handleChange}
onBlur={handleBlur}
/>
<div className="valid-feedback">OK</div>
<div className="invalid-feedback">{errors?.lastname}</div>
</div>
</div>
</div>
<div className="row">
<div className="col">
<div className="mb-3">
<label htmlFor="input-email" className="form-label">
E-Mail
</label>
<input
type="email"
name="email"
id="input-email"
className={`form-control input-email ${errors.email ? 'is-invalid' : ''}`}
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
/>
<div className="valid-feedback">OK</div>
<div className="invalid-feedback">{errors?.email}</div>
</div>
</div>
</div>
<div className="row">
<div className="col">
<div className="mb-3">
<label htmlFor="textarea-message" className="form-label">
Message
</label>
<textarea
name="message"
cols={4}
rows={6}
id="textarea-message"
className={`form-control textarea-message ${errors.message ? 'is-invalid' : ''}`}
value={formData.message}
onChange={handleChange}
onBlur={handleBlur}></textarea>
<div className="valid-feedback">OK</div>
<div className="invalid-feedback">{errors?.message}</div>
</div>
</div>
</div>
<div className="row">
<div className="col">
<div className="mb-3">
<button type="submit" className="btn btn-dark button-form-send">
Send Message
</button>
</div>
</div>
</div>
</form>
);
};
export default FormContact;
@@ -0,0 +1,67 @@
import Joi from 'joi';
const validate = (formData) => {
// const { } = props;
const schema = Joi.object({
firstname: Joi.string() //
.min(2)
.max(50)
.required()
.messages({
'string.min': 'Firstname must have a min length of 2 chars',
'string.empty': 'Firstname is required',
})
.label('Firstname'),
lastname: Joi.string()
.required()
.min(2)
.max(60)
.messages({
'string.min': 'Lastname must have a min length of 2 chars',
'string.max': 'Lastname must have a max length of 60 chars',
'string.empty': 'Lastname is required',
})
.label('Lastname'),
email: Joi.string()
.email({ tlds: { allow: false } })
.message('Email is invalid')
.required()
.label('Email'), // tld = top level domain is deactivated,
message: Joi.string()
.required()
.min(10)
.max(1000)
.messages({
'string.min': 'Message must have a min length of 10 chars',
'string.max': 'Message must have a max length of 1000 chars',
'string.empty': 'Message is required',
})
.label('Message'),
});
const { error } = schema.validate(formData, { abortEarly: false });
// Guard
if (!error) {
return {};
}
console.log(error);
console.log(error.details);
// [{
// context : {label: 'Firstname', value: '', key: 'firstname'}
// message : "\"Firstname\" is not allowed to be empty"
// path: ['firstname']
// type : "string.empty"}
// },...];
const errors = error.details.reduce((obj, current) => {
obj[current.path[0]] = current.message;
return obj;
}, {}); // InitialValue {}
// {'firstname': "\"Firstname\" is not allowed to be empty", 'lastname': "\"Lastname\" is not allowed to be empty" }
return errors;
};
export default validate;
@@ -1,4 +1,4 @@
import FormContact from '../components/forms/react-hook-form/FormContact';
import FormContact from '../components/forms/joi/FormContact';
function ContactPage() {
return (
@@ -1,4 +1,6 @@
import validator from 'validator';
import isEmail from 'validator/lib/isEmail';
import isLength from 'validator/lib/isLength';
import isEmpty from 'validator/lib/isEmpty';
const validate = (formData) => {
const errors = {};
@@ -7,27 +9,27 @@ const validate = (formData) => {
const email = formData.email.trim();
const message = formData.message.trim();
if (validator.isEmpty(firstname)) {
if (isEmpty(firstname)) {
errors.firstname = 'Firstname is required';
} else if (!validator.isLength(firstname, { min: 2, max: 50 })) {
} else if (!isLength(firstname, { min: 2, max: 50 })) {
errors.firstname = 'Firstname must be between 2 and 50 characters';
}
if (validator.isEmpty(lastname)) {
if (isEmpty(lastname)) {
errors.lastname = 'Lastname is required';
} else if (!validator.isLength(lastname, { min: 2, max: 60 })) {
} else if (!isLength(lastname, { min: 2, max: 60 })) {
errors.lastname = 'Lastname must be between 2 and 60 characters';
}
if (validator.isEmpty(email)) {
if (isEmpty(email)) {
errors.email = 'Email is required';
} else if (!validator.isEmail(email)) {
} else if (!isEmail(email)) {
errors.email = 'Email is invalid';
}
if (validator.isEmpty(message)) {
if (isEmpty(message)) {
errors.message = 'Message is required';
} else if (!validator.isLength(message, { min: 10, max: 1000 })) {
} else if (!isLength(message, { min: 10, max: 1000 })) {
errors.message = 'Message must be between 10 and 1000 characters';
}
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" />
<title>React API with hooks</title>
</head>
<body>
<noscript>
<p class="p-5">Die Seite benötigt JS.</p>
</noscript>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
@@ -0,0 +1,31 @@
{
"name": "react-api-with-hooks",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.21.0",
"bootstrap": "^5.3.8",
"joi": "^18.3.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-hook-form": "^7.91.0",
"react-icons": "^5.7.0",
"react-router-dom": "^7.18.5",
"validator": "^13.15.35"
},
"devDependencies": {
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"oxlint": "^1.90.0",
"sass-embedded": "^1.106.1",
"vite": "^8.3.5"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -0,0 +1,28 @@
import Navbar from './components/navs/Navbar';
import Router from './routes/Router';
import HeaderMain from './components/headers/HeaderMain';
import FooterMain from './components/footers/FooterMain';
import { Navigate, useNavigate } from 'react-router-dom';
import { useEffect } from 'react';
function App() {
// const navigate = useNavigate();
// useEffect(() => {
// navigate('/contact');
// }, []);
return (
<>
<HeaderMain />
<Navbar />
<Navigate to="/contact" />
<main id="main">
<Router />
</main>
<FooterMain />
</>
);
}
export default App;
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,22 @@
const Badge = (props) => {
const { text = '', variant = 'secondary', color } = props;
// const inline = typeof color !== 'undefined' ? { backgroundColor: color } : '';
// if (color) {
// return (
// <span className="badge m-1" style={{ backgroundColor: color }}>
// {text}
// </span>
// );
// } else {
// return <span className={`badge text-bg-${variant} m-1`}>{text}</span>;
// }
return (
<span className={`badge ${!color ? `text-bg-${variant}` : ''} m-1`} style={{ backgroundColor: color }}>
{text}
</span>
);
};
export default Badge;
@@ -0,0 +1,78 @@
const FooterMain = (props) => {
// const { } = props;
return (
<footer className="m-footer-main footer-main">
<div className="container">
<div className="row">
<div className="col-12 col-md-4">
<div className="mb-3">
<h3>Address</h3>
<address>
John Wick
<br />
Musterstr. 33
<br />
5555 Frankfurt
<br />
Germany
</address>
</div>
</div>
<div className="col-12 col-md-4">
<div className="mb-3">
<h3>Links</h3>
{/* nav.menu-footer>ul.list>li*2>a[href="#"]{Link $} */}
<nav className="menu-footer">
<ul className="list">
<li>
<a href="#">Link 1</a>
</li>
<li>
<a href="#">Link 2</a>
</li>
</ul>
</nav>
</div>
</div>
<div className="col-12 col-md-4">
<div className="mb-3">
<h3>Socials</h3>
{/* nav.menu-social>ul.list>li*5>a[href="#"]>span{Link $} */}
<nav className="menu-social">
<ul className="list">
<li>
<a href="#">
<span>X (Twitter)</span>
</a>
</li>
<li>
<a href="#">
<span>Instagram</span>
</a>
</li>
<li>
<a href="#">
<span>Medium</span>
</a>
</li>
<li>
<a href="#">
<span>Twitch</span>
</a>
</li>
<li>
<a href="#">
<span>Youtube</span>
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</footer>
);
};
export default FooterMain;
@@ -0,0 +1,159 @@
import { useState } from 'react';
import validate from '../../utils/validators/validate';
const INITIAL_FORM_DATA = {
firstname: '', //
lastname: '',
email: '',
message: '',
};
const FormContact = (props) => {
// const { } = props;
const [formData, setFormData] = useState(INITIAL_FORM_DATA);
const [errors, setErrors] = useState({});
// EventHandlers =======
const handleSubmit = (e) => {
e.preventDefault(); // Standardverhalten (Versenden vom Formular) unterbinden.
const form = e.currentTarget;
const errors = validate(formData);
if (Object.keys(errors).length === 0) {
console.log('Form submitted ....');
// TODO: send over AJAX/Fetch
form.reset();
setFormData(INITIAL_FORM_DATA);
setErrors({});
} else {
console.warn('Form submit error');
setErrors(errors);
}
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((state) => ({ ...state, [name]: value }));
// console.log({ [e.target.name]: e.target.value });
// console.log('Formdata', formData);
};
const handleBlur = (e) => {
const { name, value } = e.target;
const validationErrors = validate({ ...formData, [name]: value });
setErrors((state) => {
const nextErrors = { ...state };
if (validationErrors[name]) {
nextErrors[name] = validationErrors[name];
} else {
delete nextErrors[name];
}
return nextErrors;
});
};
return (
<form className="m-form-contact form-contact" noValidate onSubmit={handleSubmit}>
<div className="row">
<div className="col-12 col-md">
<div className="mb-3">
<label htmlFor="input-firstname" className="form-label">
Firstname
</label>
<input
type="text"
name="firstname"
id="input-firstname"
className={`form-control input-firstname ${errors.firstname ? 'is-invalid' : ''}`}
value={formData.firstname}
onChange={handleChange}
onBlur={handleBlur}
/>
<div className="valid-feedback">OK</div>
<div className="invalid-feedback">{errors?.firstname}</div>
</div>
</div>
<div className="col-12 col-md">
<div className="mb-3">
<label htmlFor="input-lastname" className="form-label">
Lastname
</label>
<input
type="text"
name="lastname"
id="input-lastname"
className={`form-control input-lastname ${errors.lastname ? 'is-invalid' : ''}`}
value={formData.lastname}
onChange={handleChange}
onBlur={handleBlur}
/>
<div className="valid-feedback">OK</div>
<div className="invalid-feedback">{errors?.lastname}</div>
</div>
</div>
</div>
<div className="row">
<div className="col">
<div className="mb-3">
<label htmlFor="input-email" className="form-label">
E-Mail
</label>
<input
type="email"
name="email"
id="input-email"
className={`form-control input-email ${errors.email ? 'is-invalid' : ''}`}
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
/>
<div className="valid-feedback">OK</div>
<div className="invalid-feedback">{errors?.email}</div>
</div>
</div>
</div>
<div className="row">
<div className="col">
<div className="mb-3">
<label htmlFor="textarea-message" className="form-label">
Message
</label>
<textarea
name="message"
cols={4}
rows={6}
id="textarea-message"
className={`form-control textarea-message ${errors.message ? 'is-invalid' : ''}`}
value={formData.message}
onChange={handleChange}
onBlur={handleBlur}></textarea>
<div className="valid-feedback">OK</div>
<div className="invalid-feedback">{errors?.message}</div>
</div>
</div>
</div>
<div className="row">
<div className="col">
<div className="mb-3">
<button type="submit" className="btn btn-dark button-form-send">
Send Message
</button>
</div>
</div>
</div>
</form>
);
};
export default FormContact;
@@ -0,0 +1,35 @@
const FormNewsletter = (props) => {
// const { } = props;
// EventHandler
const handleSubmit = (e) => {
e.preventDefault(); // Standardverhalten unterbinden
console.log('Event: ', e);
};
return (
<form className="form-newsletter form-inline" onSubmit={handleSubmit}>
<div className="row">
<div className="col">
<div className="mb-3">
<input
type="email"
name="email"
className="form-control input-email"
placeholder="Your email address"
aria-label="email"
/>
</div>
</div>
<div className="col">
<div className="mb-3">
<button type="submit" className="btn btn-primary button-submit">
Subscribe Now
</button>
</div>
</div>
</div>
</form>
);
};
export default FormNewsletter;
@@ -0,0 +1,159 @@
import { useState } from 'react';
import validate from './validate'; // joi validate schema object
const INITIAL_FORM_DATA = {
firstname: '', //
lastname: '',
email: '',
message: '',
};
const FormContact = (props) => {
// const { } = props;
const [formData, setFormData] = useState(INITIAL_FORM_DATA);
const [errors, setErrors] = useState({});
// EventHandlers =======
const handleSubmit = (e) => {
e.preventDefault(); // Standardverhalten (Versenden vom Formular) unterbinden.
const form = e.currentTarget;
const errors = validate(formData);
if (Object.keys(errors).length === 0) {
console.log('Form submitted ....');
// TODO: send over AJAX/Fetch
form.reset();
setFormData(INITIAL_FORM_DATA);
setErrors({});
} else {
console.warn('Form submit error');
setErrors(errors);
}
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((state) => ({ ...state, [name]: value }));
// console.log({ [e.target.name]: e.target.value });
// console.log('Formdata', formData);
};
const handleBlur = (e) => {
const { name, value } = e.target;
const validationErrors = validate({ ...formData, [name]: value });
setErrors((state) => {
const nextErrors = { ...state };
if (validationErrors[name]) {
nextErrors[name] = validationErrors[name];
} else {
delete nextErrors[name];
}
return nextErrors;
});
};
return (
<form className="m-form-contact form-contact" noValidate onSubmit={handleSubmit}>
<div className="row">
<div className="col-12 col-md">
<div className="mb-3">
<label htmlFor="input-firstname" className="form-label">
Firstname
</label>
<input
type="text"
name="firstname"
id="input-firstname"
className={`form-control input-firstname ${errors.firstname ? 'is-invalid' : ''}`}
value={formData.firstname}
onChange={handleChange}
onBlur={handleBlur}
/>
<div className="valid-feedback">OK</div>
<div className="invalid-feedback">{errors?.firstname}</div>
</div>
</div>
<div className="col-12 col-md">
<div className="mb-3">
<label htmlFor="input-lastname" className="form-label">
Lastname
</label>
<input
type="text"
name="lastname"
id="input-lastname"
className={`form-control input-lastname ${errors.lastname ? 'is-invalid' : ''}`}
value={formData.lastname}
onChange={handleChange}
onBlur={handleBlur}
/>
<div className="valid-feedback">OK</div>
<div className="invalid-feedback">{errors?.lastname}</div>
</div>
</div>
</div>
<div className="row">
<div className="col">
<div className="mb-3">
<label htmlFor="input-email" className="form-label">
E-Mail
</label>
<input
type="email"
name="email"
id="input-email"
className={`form-control input-email ${errors.email ? 'is-invalid' : ''}`}
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
/>
<div className="valid-feedback">OK</div>
<div className="invalid-feedback">{errors?.email}</div>
</div>
</div>
</div>
<div className="row">
<div className="col">
<div className="mb-3">
<label htmlFor="textarea-message" className="form-label">
Message
</label>
<textarea
name="message"
cols={4}
rows={6}
id="textarea-message"
className={`form-control textarea-message ${errors.message ? 'is-invalid' : ''}`}
value={formData.message}
onChange={handleChange}
onBlur={handleBlur}></textarea>
<div className="valid-feedback">OK</div>
<div className="invalid-feedback">{errors?.message}</div>
</div>
</div>
</div>
<div className="row">
<div className="col">
<div className="mb-3">
<button type="submit" className="btn btn-dark button-form-send">
Send Message
</button>
</div>
</div>
</div>
</form>
);
};
export default FormContact;
@@ -0,0 +1,67 @@
import Joi from 'joi';
const validate = (formData) => {
// const { } = props;
const schema = Joi.object({
firstname: Joi.string() //
.min(2)
.max(50)
.required()
.messages({
'string.min': 'Firstname must have a min length of 2 chars',
'string.empty': 'Firstname is required',
})
.label('Firstname'),
lastname: Joi.string()
.required()
.min(2)
.max(60)
.messages({
'string.min': 'Lastname must have a min length of 2 chars',
'string.max': 'Lastname must have a max length of 60 chars',
'string.empty': 'Lastname is required',
})
.label('Lastname'),
email: Joi.string()
.email({ tlds: { allow: false } })
.message('Email is invalid')
.required()
.label('Email'), // tld = top level domain is deactivated,
message: Joi.string()
.required()
.min(10)
.max(1000)
.messages({
'string.min': 'Message must have a min length of 10 chars',
'string.max': 'Message must have a max length of 1000 chars',
'string.empty': 'Message is required',
})
.label('Message'),
});
const { error } = schema.validate(formData, { abortEarly: false });
// Guard
if (!error) {
return {};
}
console.log(error);
console.log(error.details);
// [{
// context : {label: 'Firstname', value: '', key: 'firstname'}
// message : "\"Firstname\" is not allowed to be empty"
// path: ['firstname']
// type : "string.empty"}
// },...];
const errors = error.details.reduce((obj, current) => {
obj[current.path[0]] = current.message;
return obj;
}, {}); // InitialValue {}
// {'firstname': "\"Firstname\" is not allowed to be empty", 'lastname': "\"Lastname\" is not allowed to be empty" }
return errors;
};
export default validate;
@@ -0,0 +1,132 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
// import validate from '../../../utils/validators/validate';
const FormContact = (props) => {
// const { } = props;
const {
register,
handleSubmit,
formState: { errors },
} = useForm();
// EventHandlers =======
const onSubmit = (data) => {
console.log('submitted');
console.log('Form data:', data);
// fetch('/send', {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify(data),
// })
// .then((res) => res.json())
// .then((data) => console.log(data))
// .catch((err) => console.error(err));
};
if (errors) {
console.log(errors);
}
return (
<form className="m-form-contact form-contact" noValidate onSubmit={handleSubmit(onSubmit)}>
<div className="row">
<div className="col-12 col-md">
<div className="mb-3">
<label htmlFor="input-firstname" className="form-label">
Firstname
</label>
<input
type="text"
name="firstname"
id="input-firstname"
{...register('firstname', {
required: 'Firstname is invalid',
minLength: { value: 2, message: 'Firstname must have at least 2 Characters' },
maxLength: 60,
})}
className={`form-control input-firstname ${errors.firstname ? 'is-invalid' : ''}`}
/>
<div className="valid-feedback">OK</div>
<div className="invalid-feedback">{errors?.firstname?.message || 'Error'}</div>
</div>
</div>
<div className="col-12 col-md">
<div className="mb-3">
<label htmlFor="input-lastname" className="form-label">
Lastname
</label>
<input
type="text"
name="lastname"
id="input-lastname"
{...register('lastname', { required: 'Lastname is invalid' })}
className={`form-control input-lastname ${errors.lastname ? 'is-invalid' : ''}`}
/>
<div className="valid-feedback">OK</div>
<div className="invalid-feedback">{errors?.lastname?.message}</div>
</div>
</div>
</div>
<div className="row">
<div className="col">
<div className="mb-3">
<label htmlFor="input-email" className="form-label">
E-Mail
</label>
<input
type="email"
name="email"
id="input-email"
{...register('email', {
required: 'Email is required!!!',
pattern: { value: /^\S+@\S+\.\S+$/, message: 'Invalid email' },
})}
className={`form-control input-email ${errors.email ? 'is-invalid' : ''}`}
/>
<div className="valid-feedback">OK</div>
<div className="invalid-feedback">Email is required</div>
</div>
</div>
</div>
<div className="row">
<div className="col">
<div className="mb-3">
<label htmlFor="textarea-message" className="form-label">
Message
</label>
<textarea
name="message"
cols={4}
rows={6}
id="textarea-message"
{...register('message', {
required: 'Message invalid',
minLength: { value: 10, message: 'Message to short' },
})}
className={`form-control textarea-message ${errors.message ? 'is-invalid' : ''}`}></textarea>
<div className="valid-feedback">OK</div>
<div className="invalid-feedback">{errors?.message?.message}</div>
</div>
</div>
</div>
<div className="row">
<div className="col">
<div className="mb-3">
<button type="submit" className="btn btn-dark button-form-send">
Send Message
</button>
</div>
</div>
</div>
</form>
);
};
export default FormContact;
@@ -0,0 +1,12 @@
import LogoMain from '../logos/LogoMain';
const HeaderMain = () => {
return (
<header className="m-header-main header-main">
<div className="content-box">
<LogoMain />
</div>
</header>
);
};
export default HeaderMain;
@@ -0,0 +1,48 @@
const Logo = () => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlSpace="preserve"
fillRule="evenodd"
clipRule="evenodd"
strokeLinejoin="round"
strokeMiterlimit="2"
viewBox="0 0 642 483">
<path
d="M803.541 858.114h641.02v489.042h-641.02z"
fill="none"
transform="matrix(1 0 0 .98569 -803.541 -845.831)"
/>
<path
d="m200.993 79.566-1.106 1.106c-4.992 4.992-13.087 4.992-18.08 0L159.18 58.044c-4.992-4.993-4.992-13.087 0-18.08l1.106-1.105c4.993-4.993 13.087-4.993 18.08 0l22.627 22.627c4.992 4.993 4.992 13.088 0 18.08"
fill="#75cbcc"
fillRule="nonzero"
transform="translate(-222.359 -66.506)scale(3.5623)"
/>
<path
d="m178.918 101.6 21.522-21.522c5.298-5.298 5.298-13.887 0-19.185a13 13 0 0 0-.618-.577l-17.95-17.95c4.525 5.117 4.525 12.829.003 17.947q-.317.278-.621.58l-21.522 21.521c-5.298 5.298-5.298 13.888 0 19.186 5.299 5.298 13.888 5.298 19.186 0"
fill="#ace0e0"
fillRule="nonzero"
transform="translate(-222.359 -66.506)scale(3.5623)"
/>
<path
d="m99.555 61.446 1.106-1.106c4.992-4.993 13.087-4.993 18.08 0l22.628 22.628c4.992 4.993 4.992 13.087 0 18.08l-1.106 1.105c-4.993 4.993-13.087 4.993-18.08 0L99.555 79.525c-4.992-4.993-4.992-13.087 0-18.079"
fill="#75cbcc"
fillRule="nonzero"
transform="translate(-222.359 -66.506)scale(3.5623)"
/>
<path
d="m121.63 39.411-21.522 21.522c-5.298 5.298-5.298 13.888 0 19.185q.302.299.617.577l17.95 17.949c-4.524-5.117-4.525-12.829-.003-17.947a13 13 0 0 0 .621-.58l21.522-21.522c5.298-5.298 5.298-13.887 0-19.186-5.297-5.295-13.887-5.295-19.185.002"
fill="#ace0e0"
fillRule="nonzero"
transform="translate(-222.359 -66.506)scale(3.5623)"
/>
<path
d="M206.166 330.121q0 5-1.229 8.958t-3.625 6.688q-2.395 2.729-5.896 4.187t-8.041 1.458q-4.125 0-8.417-1.166.084-1.208.208-2.396.125-1.187.209-2.354.083-.708.52-1.146.438-.438 1.355-.438.75 0 1.979.375 1.228.375 3.312.375 2.751 0 4.917-.833a8.7 8.7 0 0 0 3.625-2.583q1.458-1.75 2.229-4.479t.771-6.48v-39.25h8.083zM251.333 300.371q-.375.625-.813.937-.436.313-1.062.313-.708 0-1.667-.709a29 29 0 0 0-2.396-1.562q-1.437-.853-3.458-1.563-2.02-.708-4.896-.708-2.708 0-4.771.729-2.062.73-3.458 1.979a8.1 8.1 0 0 0-2.104 2.938q-.708 1.687-.708 3.646 0 2.499 1.229 4.146 1.229 1.645 3.25 2.812t4.583 2.021a790 790 0 0 1 5.25 1.771 61 61 0 0 1 5.25 2.062 18.5 18.5 0 0 1 4.583 2.896q2.022 1.75 3.25 4.292 1.23 2.542 1.23 6.25 0 3.916-1.334 7.354a16.9 16.9 0 0 1-3.896 5.979q-2.562 2.542-6.291 4t-8.479 1.458q-5.834 0-10.584-2.104t-8.125-5.687l2.334-3.834a3.1 3.1 0 0 1 .812-.77q.48-.313 1.063-.313.541 0 1.229.438.687.436 1.562 1.104.876.666 2 1.458t2.563 1.458q1.437.667 3.271 1.105t4.125.437q2.875 0 5.125-.792 2.25-.79 3.812-2.229a9.66 9.66 0 0 0 2.396-3.437q.834-2 .833-4.459 0-2.707-1.229-4.437t-3.229-2.896-4.583-1.979a978 978 0 0 1-5.25-1.667 56 56 0 0 1-5.25-1.979 17 17 0 0 1-4.584-2.917q-2-1.791-3.229-4.479-1.229-2.687-1.229-6.646 0-3.166 1.229-6.125a15.7 15.7 0 0 1 3.583-5.25q2.355-2.291 5.792-3.666 3.438-1.375 7.896-1.375 5 0 9.104 1.583a21.5 21.5 0 0 1 7.229 4.583zM329.25 338.412q.666 0 1.166.542l3.209 3.458q-3.667 4.25-8.896 6.625t-12.646 2.375q-6.417 0-11.667-2.229t-8.958-6.25q-3.708-4.02-5.75-9.646-2.042-5.625-2.042-12.375t2.125-12.375 5.979-9.666q3.855-4.042 9.23-6.271 5.374-2.229 11.875-2.229 6.375 0 11.25 2.041 4.875 2.043 8.583 5.542l-2.667 3.708a2.2 2.2 0 0 1-.646.688q-.395.271-1.062.271-.75 0-1.833-.813-1.084-.812-2.834-1.812t-4.375-1.813-6.458-.812q-4.625 0-8.458 1.604a18.5 18.5 0 0 0-6.605 4.646q-2.77 3.042-4.312 7.416-1.542 4.376-1.542 9.875 0 5.584 1.604 9.959t4.375 7.396a18.5 18.5 0 0 0 6.542 4.604q3.77 1.583 8.146 1.583 2.667 0 4.812-.312a19.7 19.7 0 0 0 3.959-.98 16.6 16.6 0 0 0 3.375-1.687 24 24 0 0 0 3.104-2.438q.708-.625 1.417-.625M397 320.912q0 6.71-2.125 12.313t-6 9.646-9.313 6.271-12.021 2.229-12-2.229-9.291-6.271q-3.876-4.042-6-9.646t-2.125-12.313q0-6.708 2.125-12.312t6-9.667q3.875-4.062 9.291-6.312t12-2.25 12.021 2.25 9.313 6.312 6 9.667Q397 314.204 397 320.912m-8.292 0q0-5.5-1.5-9.875-1.5-4.374-4.25-7.395t-6.667-4.646q-3.916-1.625-8.75-1.625-4.791 0-8.708 1.625t-6.688 4.646q-2.77 3.021-4.27 7.395-1.5 4.376-1.5 9.875 0 5.5 1.5 9.855 1.5 4.353 4.27 7.375 2.772 3.02 6.688 4.625t8.708 1.604q4.834 0 8.75-1.604t6.667-4.625q2.75-3.022 4.25-7.375 1.5-4.355 1.5-9.855M431.583 344.329q3.708 0 6.625-1.25t4.937-3.5q2.021-2.25 3.084-5.375 1.062-3.125 1.062-6.875v-36.292h8.042v36.292q0 5.167-1.646 9.583-1.646 4.418-4.708 7.646-3.063 3.23-7.479 5.063t-9.917 1.833-9.917-1.833-7.5-5.063q-3.083-3.228-4.729-7.646-1.646-4.416-1.646-9.583v-36.292h8.042v36.25q0 3.75 1.062 6.875t3.084 5.375q2.02 2.25 4.958 3.521 2.938 1.272 6.646 1.271M486.375 319.954q3.5 0 6.187-.854t4.5-2.417a10 10 0 0 0 2.729-3.729q.917-2.166.917-4.792 0-5.333-3.5-8.041t-10.5-2.709h-8.833v22.542zm27.583 30.792h-7.167q-2.208 0-3.25-1.709l-15.5-21.333q-.708-1-1.521-1.437t-2.52-.438h-6.125v24.917h-8.042v-59.709h16.875q5.667 0 9.792 1.146t6.812 3.313 3.979 5.229 1.292 6.854q0 3.166-1 5.917a15.6 15.6 0 0 1-2.896 4.937q-1.896 2.188-4.625 3.729-2.729 1.542-6.187 2.334 1.54.875 2.666 2.541zM552.583 300.371q-.375.625-.813.937t-1.062.313q-.708 0-1.667-.709a29 29 0 0 0-2.396-1.562q-1.437-.853-3.458-1.563-2.02-.708-4.896-.708-2.708 0-4.771.729-2.062.73-3.458 1.979a8.1 8.1 0 0 0-2.104 2.938q-.708 1.687-.708 3.646 0 2.499 1.229 4.146 1.229 1.645 3.25 2.812t4.583 2.021a790 790 0 0 1 5.25 1.771 61 61 0 0 1 5.25 2.062 18.5 18.5 0 0 1 4.583 2.896q2.022 1.75 3.25 4.292 1.23 2.542 1.23 6.25 0 3.916-1.334 7.354a16.9 16.9 0 0 1-3.896 5.979q-2.562 2.542-6.291 4t-8.479 1.458q-5.834 0-10.584-2.104t-8.125-5.687l2.334-3.834a3.1 3.1 0 0 1 .812-.77q.48-.313 1.063-.313.541 0 1.229.438.687.436 1.562 1.104.875.666 2 1.458t2.563 1.458q1.437.667 3.271 1.105t4.125.437q2.875 0 5.125-.792 2.25-.79 3.812-2.229a9.66 9.66 0 0 0 2.396-3.437q.833-2 .833-4.459 0-2.707-1.229-4.437t-3.229-2.896-4.583-1.979a978 978 0 0 1-5.25-1.667 56 56 0 0 1-5.25-1.979 17 17 0 0 1-4.584-2.917q-2-1.791-3.229-4.479-1.229-2.687-1.229-6.646 0-3.166 1.229-6.125a15.7 15.7 0 0 1 3.583-5.25q2.355-2.291 5.792-3.666 3.438-1.375 7.896-1.375 5 0 9.104 1.583a21.5 21.5 0 0 1 7.229 4.583zM604.083 344.162l-.042 6.584H567.25v-59.709h36.791v6.584h-28.708v19.833h23.25v6.333h-23.25v20.375z"
fillRule="nonzero"
transform="translate(-78.559 53)"
/>
</svg>
);
};
export default Logo;
@@ -0,0 +1,61 @@
import { useState } from 'react';
import { FaMinus, FaPlus } from 'react-icons/fa6';
const InputAmount = (props) => {
const [amount, setAmount] = useState(0);
// const { } = props;
// Eventhandler
const handleChange = (e) => {
const value = Number(e.target.value); // e.target.valueAsNumber
// Guard
if (isNaN(value)) return;
setAmount(value);
};
const handleClick = (e) => {
console.log('Event: ', e);
console.log(e.currentTarget.name);
switch (e.currentTarget.name) {
case 'increase':
setAmount((prevState) => prevState + 1);
console.log('inc');
break;
case 'decrease':
setAmount(amount - 1);
console.log('dec');
break;
default: //nothing
}
};
return (
<div className="m-input-amount input-amount">
<div className="input-group mb-3">
<span className="input-group-text">
<button className="btn btn-dark button-dec" name="decrease" onClick={handleClick}>
<FaMinus />
<span className="visually-hidden">Decrease</span>
</button>
</span>
<input
type="number"
className="form-control input-amount"
aria-label="Amount"
value={amount}
onChange={handleChange}
/>
<span className="input-group-text">
<button className="btn btn-dark button-inc" name="increase" onClick={handleClick}>
<FaPlus />
<span className="visually-hidden">Increase</span>
</button>
</span>
</div>
</div>
);
};
export default InputAmount;
@@ -0,0 +1,15 @@
import Logo from '../icons/Logo';
// import Logo from '/logo-js.min.svg';
const LogoMain = (props) => {
// const { } = props;
return (
<figure className="logo-main">
{/* <img src={Logo} alt="JS Course" /> */}
<Logo />
<figcaption className="visually-hidden">Firmen Slogan</figcaption>
</figure>
);
};
export default LogoMain;
@@ -0,0 +1,99 @@
import { NavLink } from 'react-router-dom';
function Navbar() {
return (
<nav className="navbar navbar-expand-lg bg-dark" data-bs-theme="dark">
<div className="container-fluid">
<NavLink className="navbar-brand" to="/">
JS Course
</NavLink>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNav"
aria-controls="navbarNav"
aria-expanded="false"
aria-label="Navigation umschalten">
<span className="navbar-toggler-icon"></span>
</button>
<div className="collapse navbar-collapse" id="navbarNav">
<ul className="navbar-nav">
<li className="nav-item">
<NavLink
className={({ isActive }) =>
isActive
? 'nav-link active' //
: 'nav-link'
}
to="/">
Home
</NavLink>
</li>
<li className="nav-item">
<NavLink
className={({ isActive }) =>
isActive
? 'nav-link active' //
: 'nav-link'
}
to="/about">
About
</NavLink>
</li>
<li className="nav-item">
<NavLink
className={({ isActive }) =>
isActive
? 'nav-link active' //
: 'nav-link'
}
to="/contact"
style={({ isActive }) => (isActive ? { color: 'red' } : {})}>
Contact
</NavLink>
</li>
</ul>
<ul className="navbar-nav">
<li className="nav-item dropdown">
<NavLink
className={({ isActive }) =>
isActive
? 'nav-link dropdown-toggle active' //
: 'nav-link'
}
to="/user"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false">
User
</NavLink>
<ul className="dropdown-menu">
<li>
<NavLink className="dropdown-item" to="/user/profile">
Profile
</NavLink>
</li>
<li>
<NavLink className="dropdown-item" to="/user/dashboard">
Dashboard
</NavLink>
</li>
<li>
<hr className="dropdown-divider" />
</li>
<li>
<NavLink className="dropdown-item" to="/user/settings">
Settings
</NavLink>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
);
}
export default Navbar;
@@ -0,0 +1,20 @@
import { Outlet } from 'react-router-dom';
import Navbar from '../components/navs/Navbar';
const Layout = (props) => {
// const { } = props;
return (
<>
<header>Header</header>
<Navbar />
<main id="main">
<Outlet />
</main>
<footer className="align-items-center text-center">
<p>&copy; copyright 2026</p>
</footer>
</>
);
};
export default Layout;
@@ -0,0 +1,18 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx';
import 'bootstrap'; // js
import './styles/main.scss';
const root = createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
@@ -0,0 +1,14 @@
function AboutPage() {
return (
<div className="page-about container py-5">
<h2>About</h2>
<p>
sit amet consectetur adipisicing elit. Minima, voluptatibus nobis molestiae, dignissimos id dolores minus iste
quisquam harum vitae rem dolorem debitis natus asperiores amet rerum ratione laboriosam quam. Sit corporis
voluptate laboriosam doloribus incidunt voluptatibus repellat. Excepturi vitae perferendis odit
</p>
</div>
);
}
export default AboutPage;
@@ -0,0 +1,20 @@
import FormContact from '../components/forms/joi/FormContact';
function ContactPage() {
return (
<div className="page-contact container py-5">
<h2>Kontakt</h2>
<p>
Hast du Fragen? Schreib uns eine E-Mail:
<br />
<a href="mailto:kontakt@example.com">kontakt@example.com</a>
<br />
</p>
<hr />
<FormContact />
</div>
);
}
export default ContactPage;
@@ -0,0 +1,10 @@
function HomePage() {
return (
<div className="page-home container py-5">
<h2>Home</h2>
<p>Lorem, ipsum dolor sit amet consectetur adipisicing elit. Ad, voluptatibus.</p>
</div>
);
}
export default HomePage;
@@ -0,0 +1,24 @@
import { Outlet } from 'react-router-dom';
const UserPage = (props) => {
// const { } = props;
return (
<div className="page-user">
<div className="container py-5">
<header>
<h2>User</h2>
<p>The nested user site:</p>
</header>
<hr />
<section className="section-user">
<Outlet />
</section>
<footer className="footer-user align-items-center text-center text-bg-dark">
<p>&copy; copyright 2026</p>
</footer>
</div>
</div>
);
};
export default UserPage;
@@ -0,0 +1,19 @@
import { useParams } from 'react-router-dom';
import Badge from '../components/common/Badge';
function UserProfilePage() {
const { userId } = useParams();
return (
<div className="page-user-profile container py-5">
<h2>
User Profile <Badge text={userId} color="tomato" />
</h2>
<p>
sit amet consectetur adipisicing elit. Minima, voluptatibus nobis molestiae, dignissimos id dolores minus iste
</p>
</div>
);
}
export default UserProfilePage;
@@ -0,0 +1,77 @@
import { Navigate, Route, Routes } from 'react-router-dom';
//import { createBrowserRouter, RouterProvider } from 'react-router-dom';
// Pages Komponenten
import HomePage from '../pages/HomePage';
import AboutPage from '../pages/AboutPage';
import ContactPage from '../pages/ContactPage';
import UserProfilePage from '../pages/UserProfilePage';
import UserPage from '../pages/UserPage';
// import Layout from '../layouts/Layout';
// Alternative Definition von Routes über "createBrowserRouter" mit Objekten
// const router = createBrowserRouter([
// { path: '/', element: <HomePage /> },
// { path: '/about', element: <AboutPage /> },
// { path: '/contact', element: <ContactPage /> },
// ]);
// // App.jsx
// <RouterProvider router={router}>{/*<App />*/}</RouterProvider>;
const checkAuth = () => {
const token = localStorage.getItem('token');
return token && token === 'secret-token' ? true : false;
};
const ProtectedRoute = (props) => {
const { redirectTo = '/', children } = props;
const isAuthenticated = checkAuth(); // => true | false // Authentication - Prüfung
return isAuthenticated ? children : <Navigate to={redirectTo} replace={true} />;
};
const Router = () => {
// const { } = props;
return (
<Routes>
{/* Static Routes */}
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/contact" element={<ContactPage />} />
{/* Dynamic Routes */}
<Route path="/flights/:from/:to" element={<h2>Flights</h2>} />
<Route path="/user/:userId" element={<UserProfilePage />} />
{/* Nested Routes */}
{/* <Route path="/" element={<Layout />}>
<Route index element={<HomePage />} />
<Route path="about" element={<AboutPage />} />
<Route path="contact" element={<ContactPage />} />
</Route> */}
<Route path="/user" element={<UserPage />}>
<Route index element={<h5>Index</h5>} />
<Route path="profile" element={<h5>Profile</h5>} />
<Route path="dashboard" element={<h5>Dashboard</h5>} />
<Route path="settings" element={<h5>Settings</h5>} />
</Route>
{/* Protected Routes | Geschützte Routen */}
<Route
path="/protected"
element={
<ProtectedRoute redirectTo="/">
<div className="page-admin-dashboard container py-5">
<h2>Admin Dashboard Page</h2>
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Vero atque eveniet hic temporibus</p>
</div>
</ProtectedRoute>
}></Route>
</Routes>
);
};
export default Router;
@@ -0,0 +1,3 @@
// Bootstrap 5.3.8 or 6 (6.0.0-alpha.1) straight from node_modules.
// @forward 'bootstrap/scss/bootstrap-grid'
@forward 'bootstrap/scss/bootstrap';
@@ -0,0 +1,8 @@
// gloria-hallelujah-regular - latin
@font-face {
font-display: swap;
font-family: 'Gloria Hallelujah';
font-style: normal;
font-weight: 400;
src: url('../../assets/fonts/gloria-hallelujah-v24-latin/gloria-hallelujah-v24-latin-regular.woff2') format('woff2');
}
@@ -0,0 +1,3 @@
// @forward 'gloria-hallelujah-v24-latin';
// @forward 'lato-v25-latin';
// @forward 'source-sans-3-v19-latin';
@@ -0,0 +1,89 @@
// lato-100 - latin
@font-face {
font-display: swap;
font-family: 'Lato';
font-style: normal;
font-weight: 100;
src: url('../../assets/fonts/lato-v25-latin/lato-v25-latin-100.woff2') format('woff2');
}
// lato-100italic - latin
@font-face {
font-display: swap;
font-family: 'Lato';
font-style: italic;
font-weight: 100;
src: url('../../assets/fonts/lato-v25-latin/lato-v25-latin-100italic.woff2') format('woff2');
}
// lato-300 - latin
@font-face {
font-display: swap;
font-family: 'Lato';
font-style: normal;
font-weight: 300;
src: url('../../assets/fonts/lato-v25-latin/lato-v25-latin-300.woff2') format('woff2');
}
// lato-300italic - latin
@font-face {
font-display: swap;
font-family: 'Lato';
font-style: italic;
font-weight: 300;
src: url('../../assets/fonts/lato-v25-latin/lato-v25-latin-300italic.woff2') format('woff2');
}
// lato-regular - latin
@font-face {
font-display: swap;
font-family: 'Lato';
font-style: normal;
font-weight: 400;
src: url('../../assets/fonts/lato-v25-latin/lato-v25-latin-regular.woff2') format('woff2');
}
// lato-italic - latin
@font-face {
font-display: swap;
font-family: 'Lato';
font-style: italic;
font-weight: 400;
src: url('../../assets/fonts/lato-v25-latin/lato-v25-latin-italic.woff2') format('woff2');
}
// lato-700 - latin
@font-face {
font-display: swap;
font-family: 'Lato';
font-style: normal;
font-weight: 700;
src: url('../../assets/fonts/lato-v25-latin/lato-v25-latin-700.woff2') format('woff2');
}
// lato-700italic - latin
@font-face {
font-display: swap;
font-family: 'Lato';
font-style: italic;
font-weight: 700;
src: url('../../assets/fonts/lato-v25-latin/lato-v25-latin-700italic.woff2') format('woff2');
}
// lato-900 - latin
@font-face {
font-display: swap;
font-family: 'Lato';
font-style: normal;
font-weight: 900;
src: url('../../assets/fonts/lato-v25-latin/lato-v25-latin-900.woff2') format('woff2');
}
// lato-900italic - latin
@font-face {
font-display: swap;
font-family: 'Lato';
font-style: italic;
font-weight: 900;
src: url('../../assets/fonts/lato-v25-latin/lato-v25-latin-900italic.woff2') format('woff2');
}
@@ -0,0 +1,143 @@
// source-sans-3-200 - latin
@font-face {
font-display: swap;
font-family: 'Source Sans 3';
font-style: normal;
font-weight: 200;
src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-200.woff2') format('woff2');
}
// source-sans-3-200italic - latin
@font-face {
font-display: swap;
font-family: 'Source Sans 3';
font-style: italic;
font-weight: 200;
src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-200italic.woff2') format('woff2');
}
// source-sans-3-300 - latin
@font-face {
font-display: swap;
font-family: 'Source Sans 3';
font-style: normal;
font-weight: 300;
src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-300.woff2') format('woff2');
}
// source-sans-3-300italic - latin
@font-face {
font-display: swap;
font-family: 'Source Sans 3';
font-style: italic;
font-weight: 300;
src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-300italic.woff2') format('woff2');
}
// source-sans-3-regular - latin
@font-face {
font-display: swap;
font-family: 'Source Sans 3';
font-style: normal;
font-weight: 400;
src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-regular.woff2') format('woff2');
}
// source-sans-3-italic - latin
@font-face {
font-display: swap;
font-family: 'Source Sans 3';
font-style: italic;
font-weight: 400;
src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-italic.woff2') format('woff2');
}
// source-sans-3-500 - latin
@font-face {
font-display: swap;
font-family: 'Source Sans 3';
font-style: normal;
font-weight: 500;
src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-500.woff2') format('woff2');
}
// source-sans-3-500italic - latin
@font-face {
font-display: swap;
font-family: 'Source Sans 3';
font-style: italic;
font-weight: 500;
src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-500italic.woff2') format('woff2');
}
// source-sans-3-600 - latin
@font-face {
font-display: swap;
font-family: 'Source Sans 3';
font-style: normal;
font-weight: 600;
src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-600.woff2') format('woff2');
}
// source-sans-3-600italic - latin
@font-face {
font-display: swap;
font-family: 'Source Sans 3';
font-style: italic;
font-weight: 600;
src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-600italic.woff2') format('woff2');
}
// source-sans-3-700 - latin
@font-face {
font-display: swap;
font-family: 'Source Sans 3';
font-style: normal;
font-weight: 700;
src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-700.woff2') format('woff2');
}
// source-sans-3-700italic - latin
@font-face {
font-display: swap;
font-family: 'Source Sans 3';
font-style: italic;
font-weight: 700;
src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-700italic.woff2') format('woff2');
}
// source-sans-3-800 - latin
@font-face {
font-display: swap;
font-family: 'Source Sans 3';
font-style: normal;
font-weight: 800;
src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-800.woff2') format('woff2');
}
// source-sans-3-800italic - latin
@font-face {
font-display: swap;
font-family: 'Source Sans 3';
font-style: italic;
font-weight: 800;
src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-800italic.woff2') format('woff2');
}
// source-sans-3-900 - latin
@font-face {
font-display: swap;
font-family: 'Source Sans 3';
font-style: normal;
font-weight: 900;
src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-900.woff2') format('woff2');
}
// source-sans-3-900italic - latin
@font-face {
font-display: swap;
font-family: 'Source Sans 3';
font-style: italic;
font-weight: 900;
src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-900italic.woff2') format('woff2');
}
@@ -0,0 +1,13 @@
/* Color Theme Swatches in Hex */
$color-1: #5c4b51;
$color-2: #8cbeb2;
$color-3: #f2ebbf;
$color-4: #f3b562;
$color-5: #f06060;
$color-red: $color-5;
$color-white: white; // falls weiß geändert wird z.B.: #f8f8f8
$color-black: #101010;
$color-dark-blue: #283e4a;
$linear-gradient-dark: linear-gradient(180deg, rgba(40, 62, 74, 1) 0%, rgba(24, 32, 38, 1) 100%);
@@ -0,0 +1,2 @@
// Variables (colors, typography) are pulled in by the partials that need them via @use.
@forward 'theme';
@@ -0,0 +1,52 @@
@use 'typography' as *;
// Allgemeine Formatierung
*,
html {
box-sizing: border-box;
}
html,
body,
#root,
#app {
height: 100%;
}
// Sticky Footer
body,
#root,
#app {
display: flex;
flex-direction: column;
}
main,
#main {
flex: 1 0 auto;
}
body > footer,
.footer-main {
flex-shrink: 0;
}
// sticky end
body {
margin: 0;
padding: 0;
font-family: $font-copy;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: $font-headline;
font-weight: 600;
}
blockquote {
font-family: $font-quote;
}
@@ -0,0 +1,9 @@
$fallback-serif: Cambria, Cochin, Georgia, Times, 'Times New Roman', serif;
$fallback-sans-serif: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
$font-headline: 'Lato', $fallback-serif;
$font-subclaim: $fallback-sans-serif;
$font-menu: 'Source Sans 3', $fallback-sans-serif;
$font-copy: 'Source Sans 3', $fallback-sans-serif;
$font-quote: 'Gloria Hallelujah', $fallback-serif;
@@ -0,0 +1,7 @@
// @use '@fortawesome/fontawesome-free/scss/variables' with (
// $font-path: '@fa-webfonts'
// );
// @use '@fortawesome/fontawesome-free/scss/fontawesome';
// @use '@fortawesome/fontawesome-free/scss/solid';
// @use 'toastify-1.12/toastify';
@@ -0,0 +1,88 @@
/*!
* Toastify js 1.12.0
* https://github.com/apvarun/toastify-js
* @license MIT licensed
*
* Copyright (C) 2018 Varun A P
*/
.toastify {
padding: 12px 20px;
color: #ffffff;
display: inline-block;
box-shadow:
0 3px 6px -1px rgba(0, 0, 0, 0.12),
0 10px 36px -4px rgba(77, 96, 232, 0.3);
background: -webkit-linear-gradient(315deg, #73a5ff, #5477f5);
background: linear-gradient(135deg, #73a5ff, #5477f5);
position: fixed;
opacity: 0;
transition: all 0.4s cubic-bezier(0.215, 0.61, 0.355, 1);
border-radius: 2px;
cursor: pointer;
text-decoration: none;
max-width: calc(50% - 20px);
z-index: 2147483647;
}
.toastify.on {
opacity: 1;
}
.toast-close {
background: transparent;
border: 0;
color: white;
cursor: pointer;
font-family: inherit;
font-size: 1em;
opacity: 0.4;
padding: 0 5px;
}
.toastify-right {
right: 15px;
}
.toastify-left {
left: 15px;
}
.toastify-top {
top: -150px;
}
.toastify-bottom {
bottom: -150px;
}
.toastify-rounded {
border-radius: 25px;
}
.toastify-avatar {
width: 1.5em;
height: 1.5em;
margin: -7px 5px;
border-radius: 2px;
}
.toastify-center {
margin-left: auto;
margin-right: auto;
left: 0;
right: 0;
max-width: fit-content;
max-width: -moz-fit-content;
}
@media only screen and (max-width: 360px) {
.toastify-right,
.toastify-left {
margin-left: auto;
margin-right: auto;
left: 0;
right: 0;
max-width: fit-content;
}
}
@@ -0,0 +1,17 @@
button,
input {
&.btn-custom {
background-color: #f0f0f0;
color: #333;
border: 1px solid #ccc;
padding: 8px 16px;
border-radius: 0px;
margin: 3px;
&:hover,
&:focus {
background-color: #e0e0e0;
color: #333;
border: 1px solid #ccc;
}
}
}
@@ -0,0 +1,38 @@
@use '../03_base/colors' as *;
.chuck-norris-joke {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
min-height: calc(100vh - 3.5rem); // full screen below the navbar
background: #555 url('../../assets/img/concrete-bg.jpg') center no-repeat fixed;
background-size: cover;
&::after {
content: '';
position: absolute;
z-index: 1;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.8) 100%);
}
> * {
position: relative;
z-index: 2;
}
.joke-text {
color: $color-white;
text-align: center;
font-size: 3rem;
max-width: 80%;
padding: 2rem;
text-shadow: 2px 2px 5px rgba(black, 0.5);
}
}
@@ -0,0 +1,45 @@
@use '../03_base/colors' as *;
.footer-main {
background-color: $color-black;
background: $linear-gradient-dark;
padding: 3rem 0;
[class^='col'] {
color: $color-white;
a {
color: $color-white;
}
h2,
h3,
h4,
h5,
h6 {
font-size: 1.5rem;
color: $color-2;
padding: 0.5rem 0;
margin-bottom: 1rem;
letter-spacing: 2px;
}
}
// Mobile First
.row {
flex-direction: column;
> div {
&:nth-child(1) {
order: 3;
}
&:nth-child(2) {
order: 2;
}
&:nth-child(3) {
order: 1;
}
}
}
@media (min-width: 992px) {
// @include media-breakpoint-up(md) {
.row {
flex-direction: row;
}
}
}
@@ -0,0 +1,15 @@
.m-header-main {
width: 100%;
height: 400px;
// https://cssreference.io/property/background/
background: #efefef url('../../assets/img/header-bg.webp') center no-repeat border-box;
background-size: cover;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
border: 20px solid rgba(white, 0.25);
}
@@ -0,0 +1,8 @@
@forward 'button';
// @forward 'chuck-norris-joke';
@forward 'footerMain';
@forward 'header-main';
@forward 'input-amount';
@forward 'logo-main';
// @forward 'menu-main';
// @forward 'product-manager';
@@ -0,0 +1,27 @@
.m-input-amount {
.input-group {
.input-group-text {
padding: 0;
display: inline-block;
overflow: hidden;
button {
border-radius: 0;
}
}
input {
max-width: 85px;
text-align: center;
}
/* Für Chrome, Safari, Edge und Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Für Firefox */
input[type='number'] {
appearance: textfield;
}
}
}
@@ -0,0 +1,21 @@
.logo-main {
margin: 0;
img,
svg {
width: 320px;
height: auto;
max-width: 320px;
}
figcaption {
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
}
@@ -0,0 +1,20 @@
@use '../03_base/colors' as *;
@use '../03_base/typography' as *;
// Main navigation = Bootstrap 6 navbar (see includes/menu-main.html).
// Only the look from frontend-gulp is added here: black bar, uppercase links.
.menu-main {
background-color: $color-black;
font-family: $font-menu;
.nav-link {
text-transform: uppercase;
}
}
// Mobile drawer: Bootstrap 6 alpha keeps it white although the navbar is dark
// (data-bs-theme="dark"), which leaves the light link text invisible. Give it the bar's colour.
.menu-main .drawer {
background-color: $color-black;
color: $color-white;
}
@@ -0,0 +1,47 @@
.product-manager {
.nav-products-actions {
ul {
padding: 0;
margin: 0;
list-style: none;
display: flex;
justify-content: flex-end;
li {
button {
margin: 0.5rem 0 0.5rem 0.5rem;
}
}
}
}
table {
.th-actions {
width: 200px;
}
.th-dragger {
width: 50px;
}
td {
vertical-align: middle;
button {
margin: 0 2px;
}
}
tbody:empty::after {
content: 'loading products...';
width: max-content;
display: inline-block;
color: #999;
padding: 1rem;
}
tfoot .row > * {
margin-bottom: 0.5rem;
@media (min-width: 992px) {
margin-bottom: 0;
}
}
}
}
@@ -0,0 +1,13 @@
// Single stylesheet entry, linked in src/html/includes/head.html.
// Same folders as in frontend-gulp; @use instead of the deprecated @import.
// 01 Frameworks
@use '01_frameworks' as *;
// 02 Fonts
@use '02_fonts' as *;
// 03 Base
@use '03_base' as *;
// 04 Plugins
@use '04_plugins' as *;
// 05 Modules
@use '05_modules' as *;
@@ -0,0 +1,39 @@
import isEmail from 'validator/lib/isEmail';
import isLength from 'validator/lib/isLength';
import isEmpty from 'validator/lib/isEmpty';
const validate = (formData) => {
const errors = {};
const firstname = formData.firstname.trim();
const lastname = formData.lastname.trim();
const email = formData.email.trim();
const message = formData.message.trim();
if (isEmpty(firstname)) {
errors.firstname = 'Firstname is required';
} else if (!isLength(firstname, { min: 2, max: 50 })) {
errors.firstname = 'Firstname must be between 2 and 50 characters';
}
if (isEmpty(lastname)) {
errors.lastname = 'Lastname is required';
} else if (!isLength(lastname, { min: 2, max: 60 })) {
errors.lastname = 'Lastname must be between 2 and 60 characters';
}
if (isEmpty(email)) {
errors.email = 'Email is required';
} else if (!isEmail(email)) {
errors.email = 'Email is invalid';
}
if (isEmpty(message)) {
errors.message = 'Message is required';
} else if (!isLength(message, { min: 10, max: 1000 })) {
errors.message = 'Message must be between 10 and 1000 characters';
}
return errors;
};
export default validate;
@@ -0,0 +1,15 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
css: {
preprocessorOptions: {
scss: {
quietDeps: true, // Unix
silenceDeprecations: ['color-functions', 'global-builtin', 'import', 'legacy-js-api', 'if-function'],
},
},
},
});