frontend: initial commit
This commit is contained in:
12
frontend/README.md
Normal file
12
frontend/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Frontend
|
||||
|
||||
## Run locally
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
64
frontend/src/app.d.ts
vendored
Normal file
64
frontend/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
|
||||
interface Subscription {
|
||||
id: string;
|
||||
name: string;
|
||||
details: string;
|
||||
conditions: string | null;
|
||||
monthly_fee: number;
|
||||
hourly_rate: number;
|
||||
included_hours_per_year: number | null;
|
||||
included_hours_per_month: number | null;
|
||||
}
|
||||
|
||||
interface Membership {
|
||||
id: string;
|
||||
status: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
parent_member_id: number | null;
|
||||
subscription_model: Subscription | null;
|
||||
}
|
||||
|
||||
interface BankAccount {
|
||||
id: string;
|
||||
mandate_date_signed: string;
|
||||
bank: string;
|
||||
account_holder_name: string;
|
||||
iban: string;
|
||||
bic: string;
|
||||
mandate_reference: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone: string | null;
|
||||
notes: string;
|
||||
address: string;
|
||||
zip_code: string;
|
||||
city: string;
|
||||
status: number;
|
||||
id: string;
|
||||
role_id: number;
|
||||
date_of_birth: string;
|
||||
membership: Membership;
|
||||
bank_account: BankAccount;
|
||||
company: string | null;
|
||||
profile_picture: string | null;
|
||||
payment_status: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
user: User;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
18
frontend/src/app.html
Normal file
18
frontend/src/app.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Poiret+One&family=Quicksand:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
58
frontend/src/hooks.server.js
Normal file
58
frontend/src/hooks.server.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { BASE_API_URI } from "$lib/utils/constants.js";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Handle} */
|
||||
export async function handle({ event, resolve }) {
|
||||
if (event.locals.user) {
|
||||
// if there is already a user in session load page as normal
|
||||
return await resolve(event);
|
||||
}
|
||||
|
||||
// get cookies from browser
|
||||
const jwt = event.cookies.get("jwt");
|
||||
|
||||
if (!jwt) {
|
||||
// if there is no jwt load page as normal
|
||||
return await resolve(event);
|
||||
}
|
||||
const response = await fetch(`${BASE_API_URI}/users/backend/current-user`, {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Cookie: `jwt=${jwt}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Clear the invalid JWT cookie
|
||||
event.cookies.delete("jwt", { path: "/" });
|
||||
return await resolve(event);
|
||||
}
|
||||
// find the user based on the jwt
|
||||
|
||||
const userData = await response.json();
|
||||
|
||||
event.locals.user = userData;
|
||||
// event.locals.user = await response.json();
|
||||
if (event.locals.user.date_of_birth) {
|
||||
event.locals.user.date_of_birth =
|
||||
event.locals.user.date_of_birth.split("T")[0];
|
||||
}
|
||||
if (event.locals.user.membership) {
|
||||
if (event.locals.user.membership.start_date) {
|
||||
event.locals.user.membership.start_date =
|
||||
event.locals.user.membership.start_date.split("T")[0];
|
||||
}
|
||||
if (event.locals.user.membership.end_date) {
|
||||
event.locals.user.membership.end_date =
|
||||
event.locals.user.membership.end_date.split("T")[0];
|
||||
}
|
||||
}
|
||||
if (
|
||||
event.locals.user.bank_account &&
|
||||
event.locals.user.bank_account.mandate_date_signed
|
||||
) {
|
||||
event.locals.user.bank_account.mandate_date_signed =
|
||||
event.locals.user.bank_account.mandate_date_signed.split("T")[0];
|
||||
}
|
||||
|
||||
// load page as normal
|
||||
return await resolve(event);
|
||||
}
|
||||
7
frontend/src/index.test.js
Normal file
7
frontend/src/index.test.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('sum test', () => {
|
||||
it('adds 1 + 2 to equal 3', () => {
|
||||
expect(1 + 2).toBe(3);
|
||||
});
|
||||
});
|
||||
24
frontend/src/lib/components/Footer.svelte
Normal file
24
frontend/src/lib/components/Footer.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script>
|
||||
// import Developer from "$lib/img/hero-image.png";
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
</script>
|
||||
|
||||
<footer class="footer-container">
|
||||
<div class="footer-branding-container">
|
||||
<div class="footer-branding">
|
||||
<a class="footer-crafted-by-container" href="https://github.com/Sirneij">
|
||||
<span>Developed by</span>
|
||||
<!-- <img
|
||||
class="footer-branded-crafted-img"
|
||||
src={Developer}
|
||||
alt="Alexander Stölting"
|
||||
/> -->
|
||||
</a>
|
||||
|
||||
<span class="footer-copyright"
|
||||
>© {year} Alexander Stölting. All Rights Reserved.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
79
frontend/src/lib/components/Header.svelte
Normal file
79
frontend/src/lib/components/Header.svelte
Normal file
@@ -0,0 +1,79 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { applyAction, enhance } from "$app/forms";
|
||||
import { page } from "$app/stores";
|
||||
// import Developer from "$lib/img/hero-image.png";
|
||||
import Avatar from "$lib/img/teamavatar.png";
|
||||
onMount(() => {
|
||||
console.log("Page data in Header:", $page);
|
||||
});
|
||||
|
||||
$: {
|
||||
console.log("Page data updated:", $page);
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="header">
|
||||
<div class="header-container">
|
||||
<div class="header-left">
|
||||
<div class="header-crafted-by-container">
|
||||
<!-- <a href="https://tiny-bits.net/">
|
||||
<span>Developed by</span><img
|
||||
src={Developer}
|
||||
alt="Alexander Stölting"
|
||||
/> -->
|
||||
<!-- </a> -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="header-nav-item" class:active={$page.url.pathname === "/"}>
|
||||
<a href="/">home</a>
|
||||
</div>
|
||||
{#if !$page.data.user}
|
||||
<div
|
||||
class="header-nav-item"
|
||||
class:active={$page.url.pathname === "/auth/login"}
|
||||
>
|
||||
<a href="/auth/login">login</a>
|
||||
</div>
|
||||
<!-- <div
|
||||
class="header-nav-item"
|
||||
class:active={$page.url.pathname === "/auth/register"}
|
||||
>
|
||||
<a href="/auth/register">register</a>
|
||||
</div> -->
|
||||
{:else}
|
||||
<div class="header-nav-item">
|
||||
<a href="/auth/about/{$page.data.user.id}">
|
||||
<img
|
||||
src={$page.data.user.thumbnail
|
||||
? $page.data.user.thumbnail
|
||||
: Avatar}
|
||||
alt={`${$page.data.user.first_name} ${$page.data.user.last_name}`}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<!-- {#if $page.data.user.is_superuser}
|
||||
<div
|
||||
class="header-nav-item"
|
||||
class:active={$page.url.pathname.startsWith("/auth/admin")}
|
||||
>
|
||||
<a href="/auth/admin">admin</a>
|
||||
</div>
|
||||
{/if} -->
|
||||
<form
|
||||
class="header-nav-item"
|
||||
action="/auth/logout"
|
||||
method="POST"
|
||||
use:enhance={async () => {
|
||||
return async ({ result }) => {
|
||||
await applyAction(result);
|
||||
};
|
||||
}}
|
||||
>
|
||||
<button type="submit">logout</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
14
frontend/src/lib/components/Transition.svelte
Normal file
14
frontend/src/lib/components/Transition.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script>
|
||||
import { slide } from "svelte/transition";
|
||||
/** @type {string} */
|
||||
export let key;
|
||||
|
||||
/** @type {number} */
|
||||
export let duration = 300;
|
||||
</script>
|
||||
|
||||
{#key key}
|
||||
<div in:slide={{ duration, delay: duration }} out:slide={{ duration }}>
|
||||
<slot />
|
||||
</div>
|
||||
{/key}
|
||||
521
frontend/src/lib/css/styles.css
Normal file
521
frontend/src/lib/css/styles.css
Normal file
@@ -0,0 +1,521 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Mono";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url(https://fonts.gstatic.com/s/robotomono/v22/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_gPq_ROW9.ttf)
|
||||
format("truetype");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Roboto Mono";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(https://fonts.gstatic.com/s/robotomono/v22/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vq_ROW9.ttf)
|
||||
format("truetype");
|
||||
}
|
||||
|
||||
html {
|
||||
padding: 0 30px;
|
||||
background-color: black;
|
||||
color: #9b9b9b;
|
||||
font-family: "Quicksand", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
}
|
||||
body {
|
||||
max-width: 1200px;
|
||||
margin: 5em auto 0 auto;
|
||||
}
|
||||
pre,
|
||||
code {
|
||||
display: inline;
|
||||
font-family: "Roboto Mono", monospace;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
input {
|
||||
font-family: "Roboto Mono", monospace;
|
||||
color: white;
|
||||
border-style: none;
|
||||
height: 21px;
|
||||
font-size: 16px;
|
||||
}
|
||||
button {
|
||||
font-size: 16px;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 45px 0;
|
||||
color: #fff;
|
||||
font-size: 36px;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 2rem 0;
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 45px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
ul {
|
||||
margin: 0 0 32px;
|
||||
}
|
||||
a {
|
||||
transition: border 0.2s ease-in-out;
|
||||
border-bottom: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
color: #00b7ef;
|
||||
}
|
||||
a:hover {
|
||||
border-bottom-color: #00b7ef;
|
||||
}
|
||||
li {
|
||||
line-height: 1.8;
|
||||
}
|
||||
li strong {
|
||||
color: #fff;
|
||||
}
|
||||
.image {
|
||||
width: 100%;
|
||||
margin: 0 0 32px;
|
||||
padding: 0;
|
||||
}
|
||||
.image img {
|
||||
width: 100%;
|
||||
}
|
||||
.optanon-alert-box-wrapper {
|
||||
left: 0;
|
||||
}
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
.hide-mobile {
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 680px) {
|
||||
body {
|
||||
margin: 8em auto 0 auto;
|
||||
}
|
||||
.hide-mobile {
|
||||
display: initial;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 3em 0 0;
|
||||
background: black;
|
||||
}
|
||||
.header.top-banner-open {
|
||||
margin-top: 5px;
|
||||
transition: all 0.2s linear;
|
||||
}
|
||||
.header .header-container {
|
||||
width: 100%;
|
||||
max-width: calc(1200px + 10em);
|
||||
height: 5em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header .header-container .header-left {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.header .header-container .header-left .header-crafted-by-container {
|
||||
font-size: 18px;
|
||||
font-weight: 300;
|
||||
}
|
||||
.header .header-container .header-left .header-crafted-by-container a {
|
||||
display: flex;
|
||||
color: #9b9b9b;
|
||||
border: none;
|
||||
}
|
||||
.header .header-container .header-left .header-crafted-by-container a img {
|
||||
height: 28px;
|
||||
}
|
||||
.header .header-container .header-left .header-crafted-by-container a span {
|
||||
display: inline-block;
|
||||
margin: 2px 1ch 0 0;
|
||||
}
|
||||
.header .header-container .header-left .header-crafted-by-container .auth0 {
|
||||
margin-left: 1ch;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
.header .header-container .header-right {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
justify-content: space-between;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.header .header-container .header-right .header-nav-item {
|
||||
text-transform: uppercase;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.header .header-container .header-right .header-nav-item button {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
}
|
||||
.header .header-container .header-right .header-nav-item.active a,
|
||||
.header .header-container .header-right .header-nav-item.active button {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.header .header-container .header-right a img {
|
||||
margin-top: -0.4rem;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.header .header-container .header-right .header-nav-item a,
|
||||
.header .header-container .header-right .header-nav-item button {
|
||||
transition: color 0.3s ease-in-out;
|
||||
display: block;
|
||||
padding: 20px 0;
|
||||
border: none;
|
||||
color: #9b9b9b;
|
||||
}
|
||||
.header .header-container .header-right .header-nav-item:hover a,
|
||||
.header .header-container .header-right .header-nav-item:hover button {
|
||||
color: #fdfff5;
|
||||
}
|
||||
@media (min-width: 680px) {
|
||||
.header {
|
||||
padding: 3em 5rem 0;
|
||||
}
|
||||
.header.top-banner-open {
|
||||
margin-top: 48px;
|
||||
}
|
||||
.header .header-container {
|
||||
flex-direction: row;
|
||||
}
|
||||
.header .header-container .header-right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.header .header-container .header-right .header-nav-item {
|
||||
margin-left: 26px;
|
||||
}
|
||||
}
|
||||
.button-dark {
|
||||
transition:
|
||||
border-color 0.3s ease-in-out,
|
||||
background-color 0.3s ease-in-out;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
padding: 18px 28px;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
border: 1px solid #595b5c;
|
||||
}
|
||||
.button-dark:hover {
|
||||
border-color: #fff;
|
||||
}
|
||||
.button-colorful {
|
||||
transition:
|
||||
border-color 0.3s ease-in-out,
|
||||
background-color 0.3s ease-in-out;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
padding: 18px 28px;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
background-color: #d43aff;
|
||||
border: 1px solid #d43aff;
|
||||
}
|
||||
.button-colorful:hover {
|
||||
background-color: #c907ff;
|
||||
border-color: #c907ff;
|
||||
}
|
||||
.button-orange {
|
||||
transition:
|
||||
border-color 0.3s ease-in-out,
|
||||
background-color 0.3s ease-in-out;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
padding: 18px 28px;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
background-color: #eb5424;
|
||||
border: 1px solid #eb5424;
|
||||
}
|
||||
.button-orange:hover {
|
||||
background-color: #ca3f12;
|
||||
border-color: #ca3f12;
|
||||
}
|
||||
.button-colorful:disabled {
|
||||
transition:
|
||||
border-color 0.3s ease-in-out,
|
||||
background-color 0.3s ease-in-out;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
padding: 18px 28px;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
background-color: #9a9a9a;
|
||||
border: 1px solid #9a9a9a;
|
||||
}
|
||||
.hero-container {
|
||||
max-width: 795px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 0 auto 70px auto;
|
||||
}
|
||||
.hero-container .hero-logo {
|
||||
margin-top: 88px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.hero-container .hero-subtitle {
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
line-height: 32px;
|
||||
margin: 0 0 45px 0;
|
||||
}
|
||||
.hero-container .hero-buttons-container {
|
||||
display: flex;
|
||||
}
|
||||
.hero-container .hero-buttons-container button {
|
||||
margin: 0 8px;
|
||||
}
|
||||
@media (min-width: 680px) {
|
||||
.hero-container {
|
||||
margin: 0 auto 140px auto;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
color: white;
|
||||
letter-spacing: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.container .content {
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.container .content .step-title {
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
line-height: 86px;
|
||||
opacity: 1;
|
||||
}
|
||||
.container .content .step-subtitle {
|
||||
position: relative;
|
||||
top: -5px;
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.container .content {
|
||||
margin-top: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 680px) {
|
||||
.container {
|
||||
position: relative;
|
||||
left: 100px;
|
||||
display: flex;
|
||||
width: calc(100% - 100px);
|
||||
padding: 0;
|
||||
}
|
||||
.container .content {
|
||||
max-width: 795px;
|
||||
padding-left: 116px;
|
||||
}
|
||||
.container .content .step-title {
|
||||
font-size: 36px;
|
||||
}
|
||||
}
|
||||
.input-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 10px 0;
|
||||
padding: 0 20px;
|
||||
width: 100%;
|
||||
height: 73px;
|
||||
box-sizing: border-box;
|
||||
background-color: #2f2f2f;
|
||||
border-radius: 3px;
|
||||
font-family: "Roboto Mono", monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.input-box .label {
|
||||
text-transform: lowercase;
|
||||
margin: 0 1ch 0 0;
|
||||
}
|
||||
.input-box .input {
|
||||
background-color: #494848;
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
border: 3px solid #494848;
|
||||
width: 100%;
|
||||
max-width: 444px;
|
||||
font-size: 13px;
|
||||
}
|
||||
@media (min-width: 680px) {
|
||||
.input-box {
|
||||
padding: 0 30px;
|
||||
margin: 32px 0;
|
||||
}
|
||||
}
|
||||
.btn-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: first baseline;
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
.btn-container {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.btn-container p {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
.warning {
|
||||
margin: 20px 0;
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background-color: rgb(255 228 230);
|
||||
border: 1px solid rgb(225 29 72);
|
||||
border-radius: 6px;
|
||||
color: rgb(225 29 72);
|
||||
font-size: 16px;
|
||||
}
|
||||
.warning a {
|
||||
color: rgb(225 29 72);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.warning.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 10rem;
|
||||
padding: 30px 40px;
|
||||
background: #2f3132;
|
||||
color: #fff;
|
||||
}
|
||||
.error p {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.error p.intro {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
.error .button-colorful {
|
||||
display: inline-block;
|
||||
}
|
||||
@media (min-width: 680px) {
|
||||
.error {
|
||||
padding: 65px 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-branding-container {
|
||||
color: white;
|
||||
font-weight: 300;
|
||||
margin-bottom: 73px;
|
||||
}
|
||||
|
||||
.footer-branding-container .footer-branding {
|
||||
display: flex;
|
||||
width: 400px;
|
||||
}
|
||||
.footer-branding-container .footer-branding {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
margin: 30px 0 0;
|
||||
}
|
||||
.footer-branding-container .footer-branding .footer-crafted-by-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: white;
|
||||
}
|
||||
.footer-branding-container .footer-branding .footer-crafted-by-container span {
|
||||
display: inline-block;
|
||||
margin: 3px 1ch 0 0;
|
||||
}
|
||||
.footer-branding-container
|
||||
.footer-branding
|
||||
.footer-crafted-by-container
|
||||
.footer-branded-crafted-img {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.footer-branding-container .footer-branding .footer-copyright {
|
||||
color: #696969;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.footer-container {
|
||||
width: 100%;
|
||||
color: white;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (min-width: 680px) {
|
||||
.footer-container {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.simple-loader {
|
||||
--b: 20px; /* border thickness */
|
||||
--n: 15; /* number of dashes*/
|
||||
--g: 7deg; /* gap between dashes*/
|
||||
--c: #d43aff; /* the color */
|
||||
|
||||
width: 40px; /* size */
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
padding: 1px; /* get rid of bad outlines */
|
||||
background: conic-gradient(#0000, var(--c)) content-box;
|
||||
--_m: /* we use +/-1deg between colors to avoid jagged edges */ repeating-conic-gradient(
|
||||
#0000 0deg,
|
||||
#000 1deg calc(360deg / var(--n) - var(--g) - 1deg),
|
||||
#0000 calc(360deg / var(--n) - var(--g)) calc(360deg / var(--n))
|
||||
),
|
||||
radial-gradient(
|
||||
farthest-side,
|
||||
#0000 calc(98% - var(--b)),
|
||||
#000 calc(100% - var(--b))
|
||||
);
|
||||
-webkit-mask: var(--_m);
|
||||
mask: var(--_m);
|
||||
-webkit-mask-composite: destination-in;
|
||||
mask-composite: intersect;
|
||||
animation: load 1s infinite steps(var(--n));
|
||||
}
|
||||
@keyframes load {
|
||||
to {
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
505
frontend/src/lib/css/styles.min.css
vendored
Normal file
505
frontend/src/lib/css/styles.min.css
vendored
Normal file
@@ -0,0 +1,505 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Mono";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url(https://fonts.gstatic.com/s/robotomono/v22/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_gPq_ROW9.ttf)
|
||||
format("truetype");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Roboto Mono";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(https://fonts.gstatic.com/s/robotomono/v22/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vq_ROW9.ttf)
|
||||
format("truetype");
|
||||
}
|
||||
html {
|
||||
padding: 0 30px;
|
||||
background-color: #000;
|
||||
color: #9b9b9b;
|
||||
font-family: Quicksand, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
}
|
||||
body {
|
||||
max-width: 1200px;
|
||||
margin: 5em auto 0 auto;
|
||||
}
|
||||
code,
|
||||
pre {
|
||||
display: inline;
|
||||
font-family: "Roboto Mono", monospace;
|
||||
font-size: 16px;
|
||||
}
|
||||
input {
|
||||
font-family: "Roboto Mono", monospace;
|
||||
color: #fff;
|
||||
border-style: none;
|
||||
height: 21px;
|
||||
font-size: 16px;
|
||||
}
|
||||
button {
|
||||
font-size: 16px;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 45px 0;
|
||||
color: #fff;
|
||||
font-size: 36px;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 2rem 0;
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 45px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
ul {
|
||||
margin: 0 0 32px;
|
||||
}
|
||||
a {
|
||||
transition: border 0.2s ease-in-out;
|
||||
border-bottom: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
color: #00b7ef;
|
||||
}
|
||||
a:hover {
|
||||
border-bottom-color: #00b7ef;
|
||||
}
|
||||
li {
|
||||
line-height: 1.8;
|
||||
}
|
||||
li strong {
|
||||
color: #fff;
|
||||
}
|
||||
.image {
|
||||
width: 100%;
|
||||
margin: 0 0 32px;
|
||||
padding: 0;
|
||||
}
|
||||
.image img {
|
||||
width: 100%;
|
||||
}
|
||||
.optanon-alert-box-wrapper {
|
||||
left: 0;
|
||||
}
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
.hide-mobile {
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 680px) {
|
||||
body {
|
||||
margin: 8em auto 0 auto;
|
||||
}
|
||||
.hide-mobile {
|
||||
display: initial;
|
||||
}
|
||||
}
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 3em 0 0;
|
||||
background: #000;
|
||||
}
|
||||
.header.top-banner-open {
|
||||
margin-top: 5px;
|
||||
transition: all 0.2s linear;
|
||||
}
|
||||
.header .header-container {
|
||||
width: 100%;
|
||||
max-width: calc(1200px + 10em);
|
||||
height: 5em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header .header-container .header-left {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.header .header-container .header-left .header-crafted-by-container {
|
||||
font-size: 18px;
|
||||
font-weight: 300;
|
||||
}
|
||||
.header .header-container .header-left .header-crafted-by-container a {
|
||||
display: flex;
|
||||
color: #9b9b9b;
|
||||
border: none;
|
||||
}
|
||||
.header .header-container .header-left .header-crafted-by-container a img {
|
||||
height: 28px;
|
||||
}
|
||||
.header .header-container .header-left .header-crafted-by-container a span {
|
||||
display: inline-block;
|
||||
margin: 2px 1ch 0 0;
|
||||
}
|
||||
.header .header-container .header-left .header-crafted-by-container .auth0 {
|
||||
margin-left: 1ch;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
.header .header-container .header-right {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
justify-content: space-between;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.header .header-container .header-right .header-nav-item {
|
||||
text-transform: uppercase;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.header .header-container .header-right .header-nav-item button {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
}
|
||||
.header .header-container .header-right .header-nav-item.active a,
|
||||
.header .header-container .header-right .header-nav-item.active button {
|
||||
color: #fff;
|
||||
}
|
||||
.header .header-container .header-right a img {
|
||||
margin-top: -0.4rem;
|
||||
height: 28px;
|
||||
}
|
||||
.header .header-container .header-right .header-nav-item a,
|
||||
.header .header-container .header-right .header-nav-item button {
|
||||
transition: color 0.3s ease-in-out;
|
||||
display: block;
|
||||
padding: 20px 0;
|
||||
border: none;
|
||||
color: #9b9b9b;
|
||||
}
|
||||
.header .header-container .header-right .header-nav-item:hover a,
|
||||
.header .header-container .header-right .header-nav-item:hover button {
|
||||
color: #fdfff5;
|
||||
}
|
||||
@media (min-width: 680px) {
|
||||
.header {
|
||||
padding: 3em 5rem 0;
|
||||
}
|
||||
.header.top-banner-open {
|
||||
margin-top: 48px;
|
||||
}
|
||||
.header .header-container {
|
||||
flex-direction: row;
|
||||
}
|
||||
.header .header-container .header-right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.header .header-container .header-right .header-nav-item {
|
||||
margin-left: 26px;
|
||||
}
|
||||
}
|
||||
.button-dark {
|
||||
transition:
|
||||
border-color 0.3s ease-in-out,
|
||||
background-color 0.3s ease-in-out;
|
||||
color: #fff;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
padding: 18px 28px;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
border: 1px solid #595b5c;
|
||||
}
|
||||
.button-dark:hover {
|
||||
border-color: #fff;
|
||||
}
|
||||
.button-colorful {
|
||||
transition:
|
||||
border-color 0.3s ease-in-out,
|
||||
background-color 0.3s ease-in-out;
|
||||
color: #fff;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
padding: 18px 28px;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
background-color: #d43aff;
|
||||
border: 1px solid #d43aff;
|
||||
}
|
||||
.button-colorful:hover {
|
||||
background-color: #c907ff;
|
||||
border-color: #c907ff;
|
||||
}
|
||||
.button-orange {
|
||||
transition:
|
||||
border-color 0.3s ease-in-out,
|
||||
background-color 0.3s ease-in-out;
|
||||
color: #fff;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
padding: 18px 28px;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
background-color: #eb5424;
|
||||
border: 1px solid #eb5424;
|
||||
}
|
||||
.button-orange:hover {
|
||||
background-color: #ca3f12;
|
||||
border-color: #ca3f12;
|
||||
}
|
||||
.button-colorful:disabled {
|
||||
transition:
|
||||
border-color 0.3s ease-in-out,
|
||||
background-color 0.3s ease-in-out;
|
||||
color: #fff;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
padding: 18px 28px;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
background-color: #9a9a9a;
|
||||
border: 1px solid #9a9a9a;
|
||||
}
|
||||
.hero-container {
|
||||
max-width: 795px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 0 auto 70px auto;
|
||||
}
|
||||
.hero-container .hero-logo {
|
||||
margin-top: 88px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.hero-container .hero-subtitle {
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
line-height: 32px;
|
||||
margin: 0 0 45px 0;
|
||||
}
|
||||
.hero-container .hero-buttons-container {
|
||||
display: flex;
|
||||
}
|
||||
.hero-container .hero-buttons-container button {
|
||||
margin: 0 8px;
|
||||
}
|
||||
@media (min-width: 680px) {
|
||||
.hero-container {
|
||||
margin: 0 auto 140px auto;
|
||||
}
|
||||
}
|
||||
.container {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
color: #fff;
|
||||
letter-spacing: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
.container .content {
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.container .content .step-title {
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
line-height: 86px;
|
||||
opacity: 1;
|
||||
}
|
||||
.container .content .step-subtitle {
|
||||
position: relative;
|
||||
top: -5px;
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
.container .content {
|
||||
margin-top: 120px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 680px) {
|
||||
.container {
|
||||
position: relative;
|
||||
left: 100px;
|
||||
display: flex;
|
||||
width: calc(100% - 100px);
|
||||
padding: 0;
|
||||
}
|
||||
.container .content {
|
||||
max-width: 795px;
|
||||
padding-left: 116px;
|
||||
}
|
||||
.container .content .step-title {
|
||||
font-size: 36px;
|
||||
}
|
||||
}
|
||||
.input-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 10px 0;
|
||||
padding: 0 20px;
|
||||
width: 100%;
|
||||
height: 73px;
|
||||
box-sizing: border-box;
|
||||
background-color: #2f2f2f;
|
||||
border-radius: 3px;
|
||||
font-family: "Roboto Mono", monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.input-box .label {
|
||||
text-transform: lowercase;
|
||||
margin: 0 1ch 0 0;
|
||||
}
|
||||
.input-box .input {
|
||||
background-color: #494848;
|
||||
border-radius: 6px;
|
||||
outline: 0;
|
||||
border: 3px solid #494848;
|
||||
width: 100%;
|
||||
max-width: 444px;
|
||||
font-size: 13px;
|
||||
}
|
||||
@media (min-width: 680px) {
|
||||
.input-box {
|
||||
padding: 0 30px;
|
||||
margin: 32px 0;
|
||||
}
|
||||
}
|
||||
.btn-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: first baseline;
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
.btn-container {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.btn-container p {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
.warning {
|
||||
margin: 20px 0;
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background-color: rgb(255 228 230);
|
||||
border: 1px solid rgb(225 29 72);
|
||||
border-radius: 6px;
|
||||
color: rgb(225 29 72);
|
||||
font-size: 16px;
|
||||
}
|
||||
.warning a {
|
||||
color: rgb(225 29 72);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.warning.hidden {
|
||||
display: none;
|
||||
}
|
||||
.error {
|
||||
margin-top: 10rem;
|
||||
padding: 30px 40px;
|
||||
background: #2f3132;
|
||||
color: #fff;
|
||||
}
|
||||
.error p {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.error p.intro {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
.error .button-colorful {
|
||||
display: inline-block;
|
||||
}
|
||||
@media (min-width: 680px) {
|
||||
.error {
|
||||
padding: 65px 80px;
|
||||
}
|
||||
}
|
||||
.footer-branding-container {
|
||||
color: #fff;
|
||||
font-weight: 300;
|
||||
margin-bottom: 73px;
|
||||
}
|
||||
.footer-branding-container .footer-branding {
|
||||
display: flex;
|
||||
width: 400px;
|
||||
}
|
||||
.footer-branding-container .footer-branding {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
margin: 30px 0 0;
|
||||
}
|
||||
.footer-branding-container .footer-branding .footer-crafted-by-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
.footer-branding-container .footer-branding .footer-crafted-by-container span {
|
||||
display: inline-block;
|
||||
margin: 3px 1ch 0 0;
|
||||
}
|
||||
.footer-branding-container
|
||||
.footer-branding
|
||||
.footer-crafted-by-container
|
||||
.footer-branded-crafted-img {
|
||||
height: 28px;
|
||||
}
|
||||
.footer-branding-container .footer-branding .footer-copyright {
|
||||
color: #696969;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.footer-container {
|
||||
width: 100%;
|
||||
color: #fff;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
@media (min-width: 680px) {
|
||||
.footer-container {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
.simple-loader {
|
||||
--b: 20px;
|
||||
--n: 15;
|
||||
--g: 7deg;
|
||||
--c: #d43aff;
|
||||
width: 40px;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
padding: 1px;
|
||||
background: conic-gradient(#0000, var(--c)) content-box;
|
||||
--_m: repeating-conic-gradient(
|
||||
#0000 0deg,
|
||||
#000 1deg calc(360deg / var(--n) - var(--g) - 1deg),
|
||||
#0000 calc(360deg / var(--n) - var(--g)) calc(360deg / var(--n))
|
||||
),
|
||||
radial-gradient(
|
||||
farthest-side,
|
||||
#0000 calc(98% - var(--b)),
|
||||
#000 calc(100% - var(--b))
|
||||
);
|
||||
-webkit-mask: var(--_m);
|
||||
mask: var(--_m);
|
||||
-webkit-mask-composite: destination-in;
|
||||
mask-composite: intersect;
|
||||
animation: load 1s infinite steps(var(--n));
|
||||
}
|
||||
@keyframes load {
|
||||
to {
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
3
frontend/src/lib/utils/constants.js
Normal file
3
frontend/src/lib/utils/constants.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export const BASE_API_URI = import.meta.env.DEV
|
||||
? import.meta.env.VITE_BASE_API_URI_DEV
|
||||
: import.meta.env.VITE_BASE_API_URI_PROD;
|
||||
105
frontend/src/lib/utils/helpers.js
Normal file
105
frontend/src/lib/utils/helpers.js
Normal file
@@ -0,0 +1,105 @@
|
||||
// @ts-nocheck
|
||||
import { quintOut } from "svelte/easing";
|
||||
import { crossfade } from "svelte/transition";
|
||||
|
||||
export const [send, receive] = crossfade({
|
||||
duration: (d) => Math.sqrt(d * 200),
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
fallback(node, params) {
|
||||
const style = getComputedStyle(node);
|
||||
const transform = style.transform === "none" ? "" : style.transform;
|
||||
|
||||
return {
|
||||
duration: 600,
|
||||
easing: quintOut,
|
||||
css: (t) => `
|
||||
transform: ${transform} scale(${t});
|
||||
opacity: ${t}
|
||||
`,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates an email field
|
||||
* @file lib/utils/helpers/input.validation.ts
|
||||
* @param {string} email - The email to validate
|
||||
*/
|
||||
export const isValidEmail = (email) => {
|
||||
const EMAIL_REGEX =
|
||||
/[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/;
|
||||
return EMAIL_REGEX.test(email.trim());
|
||||
};
|
||||
/**
|
||||
* Validates a strong password field
|
||||
* @file lib/utils/helpers/input.validation.ts
|
||||
* @param {string} password - The password to validate
|
||||
*/
|
||||
export const isValidPasswordStrong = (password) => {
|
||||
const strongRegex = new RegExp(
|
||||
"^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})"
|
||||
);
|
||||
|
||||
return strongRegex.test(password.trim());
|
||||
};
|
||||
/**
|
||||
* Validates a medium password field
|
||||
* @file lib/utils/helpers/input.validation.ts
|
||||
* @param {string} password - The password to validate
|
||||
*/
|
||||
export const isValidPasswordMedium = (password) => {
|
||||
const mediumRegex = new RegExp(
|
||||
"^(((?=.*[a-z])(?=.*[A-Z]))|((?=.*[a-z])(?=.*[0-9]))|((?=.*[A-Z])(?=.*[0-9])))(?=.{6,})"
|
||||
);
|
||||
|
||||
return mediumRegex.test(password.trim());
|
||||
};
|
||||
|
||||
/**
|
||||
* Test whether or not an object is empty.
|
||||
* @param {Record<string, string>} obj - The object to test
|
||||
* @returns `true` or `false`
|
||||
*/
|
||||
|
||||
export function isEmpty(obj) {
|
||||
for (const _i in obj) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Test whether or not an object is empty.
|
||||
* @param {any} obj - The object to test
|
||||
* @returns `true` or `false`
|
||||
*/
|
||||
|
||||
export function formatError(obj) {
|
||||
const errors = [];
|
||||
if (typeof obj === "object" && obj !== null) {
|
||||
if (Array.isArray(obj)) {
|
||||
obj.forEach((/** @type {Object} */ error) => {
|
||||
Object.keys(error).map((k) => {
|
||||
errors.push({
|
||||
error: error[k],
|
||||
id: Math.random() * 1000,
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
Object.keys(obj).map((k) => {
|
||||
errors.push({
|
||||
error: obj[k],
|
||||
id: Math.random() * 1000,
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
errors.push({
|
||||
error: obj.charAt(0).toUpperCase() + obj.slice(1),
|
||||
id: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
105
frontend/src/lib/utils/utils.js
Normal file
105
frontend/src/lib/utils/utils.js
Normal file
@@ -0,0 +1,105 @@
|
||||
// @ts-nocheck
|
||||
import { quintOut } from "svelte/easing";
|
||||
import { crossfade } from "svelte/transition";
|
||||
|
||||
export const [send, receive] = crossfade({
|
||||
duration: (d) => Math.sqrt(d * 200),
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
fallback(node, params) {
|
||||
const style = getComputedStyle(node);
|
||||
const transform = style.transform === "none" ? "" : style.transform;
|
||||
|
||||
return {
|
||||
duration: 600,
|
||||
easing: quintOut,
|
||||
css: (t) => `
|
||||
transform: ${transform} scale(${t});
|
||||
opacity: ${t}
|
||||
`,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates an email field
|
||||
* @file lib/utils/helpers/input.validation.ts
|
||||
* @param {string} email - The email to validate
|
||||
*/
|
||||
export const isValidEmail = (email) => {
|
||||
const EMAIL_REGEX =
|
||||
/[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/;
|
||||
return EMAIL_REGEX.test(email.trim());
|
||||
};
|
||||
/**
|
||||
* Validates a strong password field
|
||||
* @file lib/utils/helpers/input.validation.ts
|
||||
* @param {string} password - The password to validate
|
||||
*/
|
||||
export const isValidPasswordStrong = (password) => {
|
||||
const strongRegex = new RegExp(
|
||||
"^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})"
|
||||
);
|
||||
|
||||
return strongRegex.test(password.trim());
|
||||
};
|
||||
/**
|
||||
* Validates a medium password field
|
||||
* @file lib/utils/helpers/input.validation.ts
|
||||
* @param {string} password - The password to validate
|
||||
*/
|
||||
export const isValidPasswordMedium = (password) => {
|
||||
const mediumRegex = new RegExp(
|
||||
"^(((?=.*[a-z])(?=.*[A-Z]))|((?=.*[a-z])(?=.*[0-9]))|((?=.*[A-Z])(?=.*[0-9])))(?=.{6,})"
|
||||
);
|
||||
|
||||
return mediumRegex.test(password.trim());
|
||||
};
|
||||
|
||||
/**
|
||||
* Test whether or not an object is empty.
|
||||
* @param {Record<string, string>} obj - The object to test
|
||||
* @returns `true` or `false`
|
||||
*/
|
||||
|
||||
export function isEmpty(obj) {
|
||||
for (const _i in obj) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Test whether or not an object is empty.
|
||||
* @param {any} obj - The object to test
|
||||
* @returns `true` or `false`
|
||||
*/
|
||||
|
||||
export function formatError(obj) {
|
||||
const errors = [];
|
||||
if (typeof obj === "object" && obj !== null) {
|
||||
if (Array.isArray(obj)) {
|
||||
obj.forEach((/** @type {Object} */ error) => {
|
||||
Object.keys(error).map((k) => {
|
||||
errors.push({
|
||||
error: error[k],
|
||||
id: Math.random() * 1000,
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
Object.keys(obj).map((k) => {
|
||||
errors.push({
|
||||
error: obj[k],
|
||||
id: Math.random() * 1000,
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
errors.push({
|
||||
error: obj.charAt(0).toUpperCase() + obj.slice(1),
|
||||
id: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
5
frontend/src/routes/+layout.js
Normal file
5
frontend/src/routes/+layout.js
Normal file
@@ -0,0 +1,5 @@
|
||||
/** @type {import('./$types').LayoutLoad} */
|
||||
export async function load({ fetch, url, data }) {
|
||||
const { user } = data;
|
||||
return { fetch, url: url.pathname, user };
|
||||
}
|
||||
6
frontend/src/routes/+layout.server.js
Normal file
6
frontend/src/routes/+layout.server.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @type {import('./$types').LayoutServerLoad} */
|
||||
export async function load({ locals }) {
|
||||
return {
|
||||
user: locals.user,
|
||||
};
|
||||
}
|
||||
17
frontend/src/routes/+layout.svelte
Normal file
17
frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script>
|
||||
import Footer from "$lib/components/Footer.svelte";
|
||||
import Header from "$lib/components/Header.svelte";
|
||||
import Transition from "$lib/components/Transition.svelte";
|
||||
import "$lib/css/styles.min.css";
|
||||
|
||||
/** @type {import('./$types').PageData} */
|
||||
export let data;
|
||||
</script>
|
||||
|
||||
<Transition key={data.url} duration={600}>
|
||||
<Header />
|
||||
|
||||
<slot />
|
||||
|
||||
<Footer />
|
||||
</Transition>
|
||||
19
frontend/src/routes/+page.svelte
Normal file
19
frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<!-- <script>
|
||||
import Developer from "$lib/img/hero-image.png";
|
||||
</script> -->
|
||||
|
||||
<div class="hero-container">
|
||||
<!-- <div class="hero-logo"><img src={Developer} alt="Alexander Stölting" /></div> -->
|
||||
<h3 class="hero-subtitle subtitle">
|
||||
This application is the demonstration of a series of tutorials on
|
||||
session-based authentication using Go at the backend and JavaScript
|
||||
(SvelteKit) on the front-end.
|
||||
</h3>
|
||||
<div class="hero-buttons-container">
|
||||
<a
|
||||
class="button-dark"
|
||||
href="https://dev.to/sirneij/series/23239"
|
||||
data-learn-more>Learn more</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
98
frontend/src/routes/auth/login/+page.server.js
Normal file
98
frontend/src/routes/auth/login/+page.server.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import { BASE_API_URI } from "$lib/utils/constants";
|
||||
import { formatError } from "$lib/utils/helpers";
|
||||
import { fail, redirect } from "@sveltejs/kit";
|
||||
|
||||
/** @type {import('./$types').PageServerLoad} */
|
||||
export async function load({ locals }) {
|
||||
// redirect user if logged in
|
||||
if (locals.user) {
|
||||
throw redirect(302, "/");
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import('./$types').Actions} */
|
||||
export const actions = {
|
||||
/**
|
||||
*
|
||||
* @param request - The request object
|
||||
* @param fetch - Fetch object from sveltekit
|
||||
* @param cookies - SvelteKit's cookie object
|
||||
* @returns Error data or redirects user to the home page or the previous page
|
||||
*/
|
||||
login: async ({ request, fetch, cookies }) => {
|
||||
const data = await request.formData();
|
||||
const email = String(data.get("email"));
|
||||
const password = String(data.get("password"));
|
||||
const next = String(data.get("next"));
|
||||
|
||||
/** @type {RequestInit} */
|
||||
const requestInitOptions = {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: email,
|
||||
password: password,
|
||||
}),
|
||||
};
|
||||
|
||||
const res = await fetch(`${BASE_API_URI}/users/login/`, requestInitOptions);
|
||||
|
||||
console.log("Login response status:", res.status);
|
||||
console.log("Login response headers:", Object.fromEntries(res.headers));
|
||||
|
||||
if (!res.ok) {
|
||||
let errorMessage = `HTTP error! status: ${res.status}`;
|
||||
try {
|
||||
const errorData = await res.json();
|
||||
errorMessage = errorData.error || errorMessage;
|
||||
} catch (parseError) {
|
||||
console.error("Failed to parse error response:", parseError);
|
||||
errorMessage = await res.text(); // Get the raw response text if JSON parsing fails
|
||||
}
|
||||
console.error("Login failed:", errorMessage);
|
||||
return fail(res.status, {
|
||||
errors: [{ error: errorMessage, id: Date.now() }],
|
||||
});
|
||||
}
|
||||
|
||||
const responseBody = await res.json();
|
||||
console.log("Login response body:", responseBody);
|
||||
|
||||
// Check for the cookie in the response headers
|
||||
const setCookieHeader = res.headers.get("set-cookie");
|
||||
console.log("Set-Cookie header:", setCookieHeader);
|
||||
|
||||
if (setCookieHeader) {
|
||||
// Parse the Set-Cookie header to get the JWT
|
||||
const jwtCookie = setCookieHeader.split(";")[0];
|
||||
const [cookieName, cookieValue] = jwtCookie.split("=");
|
||||
if (cookieName.trim() === "jwt") {
|
||||
console.log("JWT cookie found in response");
|
||||
cookies.set("jwt", cookieValue.trim(), {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
});
|
||||
} else {
|
||||
console.log("JWT cookie not found in response");
|
||||
}
|
||||
} else {
|
||||
console.log("No Set-Cookie header in response");
|
||||
}
|
||||
|
||||
console.log("Redirecting to:", next || "/");
|
||||
throw redirect(303, next || "/");
|
||||
},
|
||||
// if (!res.ok) {
|
||||
// const response = await res.json();
|
||||
// const errors = formatError(response.error);
|
||||
// return fail(400, { errors: errors });
|
||||
// }
|
||||
|
||||
// throw redirect(303, next || "/");
|
||||
// },
|
||||
};
|
||||
76
frontend/src/routes/auth/login/+page.svelte
Normal file
76
frontend/src/routes/auth/login/+page.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<script>
|
||||
import { applyAction, enhance } from "$app/forms";
|
||||
import { page } from "$app/stores";
|
||||
import { receive, send } from "$lib/utils/helpers";
|
||||
|
||||
/** @type {import('./$types').ActionData} */
|
||||
export let form;
|
||||
|
||||
/** @type {import('./$types').SubmitFunction} */
|
||||
const handleLogin = async () => {
|
||||
return async ({ result }) => {
|
||||
await applyAction(result);
|
||||
};
|
||||
};
|
||||
|
||||
let message = "";
|
||||
if ($page.url.searchParams.get("message")) {
|
||||
message = $page.url.search.split("=")[1].replaceAll("%20", " ");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<form
|
||||
class="content"
|
||||
method="POST"
|
||||
action="?/login"
|
||||
use:enhance={handleLogin}
|
||||
>
|
||||
<h1 class="step-title">Login User</h1>
|
||||
{#if form?.errors}
|
||||
{#each form?.errors as error (error.id)}
|
||||
<h4
|
||||
class="step-subtitle warning"
|
||||
in:receive={{ key: error.id }}
|
||||
out:send={{ key: error.id }}
|
||||
>
|
||||
{error.error}
|
||||
</h4>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if message}
|
||||
<h4 class="step-subtitle">{message}</h4>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="next"
|
||||
value={$page.url.searchParams.get("next")}
|
||||
/>
|
||||
<div class="input-box">
|
||||
<span class="label">Email:</span>
|
||||
<input
|
||||
class="input"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="Email address"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-box">
|
||||
<span class="label">Password:</span>
|
||||
<input
|
||||
class="input"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
/>
|
||||
<a href="/auth/password/request-change" style="margin-left: 1rem;"
|
||||
>Forgot password?</a
|
||||
>
|
||||
</div>
|
||||
<div class="btn-container">
|
||||
<button class="button-dark">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
43
frontend/src/routes/auth/logout/+page.server.js
Normal file
43
frontend/src/routes/auth/logout/+page.server.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { BASE_API_URI } from "$lib/utils/constants";
|
||||
import { fail, redirect } from "@sveltejs/kit";
|
||||
|
||||
/** @type {import('./$types').PageServerLoad} */
|
||||
export async function load({ locals }) {
|
||||
// redirect user if not logged in
|
||||
if (!locals.user) {
|
||||
throw redirect(302, `/auth/login?next=/auth/logout`);
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import('./$types').Actions} */
|
||||
export const actions = {
|
||||
default: async ({ fetch, cookies }) => {
|
||||
/** @type {RequestInit} */
|
||||
const requestInitOptions = {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: `jwt=${cookies.get("jwt")}`,
|
||||
},
|
||||
};
|
||||
|
||||
const res = await fetch(
|
||||
`${BASE_API_URI}/users/backend/logout/`,
|
||||
requestInitOptions
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const response = await res.json();
|
||||
const errors = [];
|
||||
errors.push({ error: response.error, id: 0 });
|
||||
return fail(400, { errors: errors });
|
||||
}
|
||||
|
||||
// eat the cookie
|
||||
cookies.delete("jwt", { path: "/" });
|
||||
|
||||
// redirect the user
|
||||
throw redirect(302, "/auth/login");
|
||||
},
|
||||
};
|
||||
16
frontend/svelte.config.js
Normal file
16
frontend/svelte.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// import adapter from '@sveltejs/adapter-auto';
|
||||
import adapter from '@sveltejs/adapter-vercel';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
|
||||
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
|
||||
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
||||
adapter: adapter({
|
||||
runtime: 'edge'
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
9
frontend/vite.config.js
Normal file
9
frontend/vite.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{js,ts}']
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user