From 147b8c0afdb7d2fe6d86fb310fb7308dd2cebfe7 Mon Sep 17 00:00:00 2001
From: "$(pass /github/name)" <$(pass /github/email)>
Date: Sat, 7 Sep 2024 13:36:15 +0200
Subject: [PATCH] frontend: initial commit
---
frontend/README.md | 12 +
frontend/src/app.d.ts | 64 +++
frontend/src/app.html | 18 +
frontend/src/hooks.server.js | 58 ++
frontend/src/index.test.js | 7 +
frontend/src/lib/components/Footer.svelte | 24 +
frontend/src/lib/components/Header.svelte | 79 +++
frontend/src/lib/components/Transition.svelte | 14 +
frontend/src/lib/css/styles.css | 521 ++++++++++++++++++
frontend/src/lib/css/styles.min.css | 505 +++++++++++++++++
frontend/src/lib/utils/constants.js | 3 +
frontend/src/lib/utils/helpers.js | 105 ++++
frontend/src/lib/utils/utils.js | 105 ++++
frontend/src/routes/+layout.js | 5 +
frontend/src/routes/+layout.server.js | 6 +
frontend/src/routes/+layout.svelte | 17 +
frontend/src/routes/+page.svelte | 19 +
.../src/routes/auth/login/+page.server.js | 98 ++++
frontend/src/routes/auth/login/+page.svelte | 76 +++
.../src/routes/auth/logout/+page.server.js | 43 ++
frontend/svelte.config.js | 16 +
frontend/vite.config.js | 9 +
22 files changed, 1804 insertions(+)
create mode 100644 frontend/README.md
create mode 100644 frontend/src/app.d.ts
create mode 100644 frontend/src/app.html
create mode 100644 frontend/src/hooks.server.js
create mode 100644 frontend/src/index.test.js
create mode 100644 frontend/src/lib/components/Footer.svelte
create mode 100644 frontend/src/lib/components/Header.svelte
create mode 100644 frontend/src/lib/components/Transition.svelte
create mode 100644 frontend/src/lib/css/styles.css
create mode 100644 frontend/src/lib/css/styles.min.css
create mode 100644 frontend/src/lib/utils/constants.js
create mode 100644 frontend/src/lib/utils/helpers.js
create mode 100644 frontend/src/lib/utils/utils.js
create mode 100644 frontend/src/routes/+layout.js
create mode 100644 frontend/src/routes/+layout.server.js
create mode 100644 frontend/src/routes/+layout.svelte
create mode 100644 frontend/src/routes/+page.svelte
create mode 100644 frontend/src/routes/auth/login/+page.server.js
create mode 100644 frontend/src/routes/auth/login/+page.svelte
create mode 100644 frontend/src/routes/auth/logout/+page.server.js
create mode 100644 frontend/svelte.config.js
create mode 100644 frontend/vite.config.js
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..3a84f18
--- /dev/null
+++ b/frontend/README.md
@@ -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
+```
diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts
new file mode 100644
index 0000000..d9faa97
--- /dev/null
+++ b/frontend/src/app.d.ts
@@ -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 {};
diff --git a/frontend/src/app.html b/frontend/src/app.html
new file mode 100644
index 0000000..8705da0
--- /dev/null
+++ b/frontend/src/app.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/frontend/src/hooks.server.js b/frontend/src/hooks.server.js
new file mode 100644
index 0000000..f9c5ea9
--- /dev/null
+++ b/frontend/src/hooks.server.js
@@ -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);
+}
diff --git a/frontend/src/index.test.js b/frontend/src/index.test.js
new file mode 100644
index 0000000..e07cbbd
--- /dev/null
+++ b/frontend/src/index.test.js
@@ -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);
+ });
+});
diff --git a/frontend/src/lib/components/Footer.svelte b/frontend/src/lib/components/Footer.svelte
new file mode 100644
index 0000000..104da8b
--- /dev/null
+++ b/frontend/src/lib/components/Footer.svelte
@@ -0,0 +1,24 @@
+
+
+
diff --git a/frontend/src/lib/components/Header.svelte b/frontend/src/lib/components/Header.svelte
new file mode 100644
index 0000000..ea54c67
--- /dev/null
+++ b/frontend/src/lib/components/Header.svelte
@@ -0,0 +1,79 @@
+
+
+
diff --git a/frontend/src/lib/components/Transition.svelte b/frontend/src/lib/components/Transition.svelte
new file mode 100644
index 0000000..e7f2d51
--- /dev/null
+++ b/frontend/src/lib/components/Transition.svelte
@@ -0,0 +1,14 @@
+
+
+{#key key}
+
+
+
+{/key}
diff --git a/frontend/src/lib/css/styles.css b/frontend/src/lib/css/styles.css
new file mode 100644
index 0000000..366084f
--- /dev/null
+++ b/frontend/src/lib/css/styles.css
@@ -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);
+ }
+}
diff --git a/frontend/src/lib/css/styles.min.css b/frontend/src/lib/css/styles.min.css
new file mode 100644
index 0000000..4174e87
--- /dev/null
+++ b/frontend/src/lib/css/styles.min.css
@@ -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);
+ }
+}
diff --git a/frontend/src/lib/utils/constants.js b/frontend/src/lib/utils/constants.js
new file mode 100644
index 0000000..43db098
--- /dev/null
+++ b/frontend/src/lib/utils/constants.js
@@ -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;
diff --git a/frontend/src/lib/utils/helpers.js b/frontend/src/lib/utils/helpers.js
new file mode 100644
index 0000000..0c73b18
--- /dev/null
+++ b/frontend/src/lib/utils/helpers.js
@@ -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} 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;
+}
diff --git a/frontend/src/lib/utils/utils.js b/frontend/src/lib/utils/utils.js
new file mode 100644
index 0000000..0c73b18
--- /dev/null
+++ b/frontend/src/lib/utils/utils.js
@@ -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} 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;
+}
diff --git a/frontend/src/routes/+layout.js b/frontend/src/routes/+layout.js
new file mode 100644
index 0000000..88edb1d
--- /dev/null
+++ b/frontend/src/routes/+layout.js
@@ -0,0 +1,5 @@
+/** @type {import('./$types').LayoutLoad} */
+export async function load({ fetch, url, data }) {
+ const { user } = data;
+ return { fetch, url: url.pathname, user };
+}
diff --git a/frontend/src/routes/+layout.server.js b/frontend/src/routes/+layout.server.js
new file mode 100644
index 0000000..d76e6b4
--- /dev/null
+++ b/frontend/src/routes/+layout.server.js
@@ -0,0 +1,6 @@
+/** @type {import('./$types').LayoutServerLoad} */
+export async function load({ locals }) {
+ return {
+ user: locals.user,
+ };
+}
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
new file mode 100644
index 0000000..fbdbf8c
--- /dev/null
+++ b/frontend/src/routes/+layout.svelte
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte
new file mode 100644
index 0000000..4cd7eb3
--- /dev/null
+++ b/frontend/src/routes/+page.svelte
@@ -0,0 +1,19 @@
+
+
+
+
+
+ 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.
+
+
+
diff --git a/frontend/src/routes/auth/login/+page.server.js b/frontend/src/routes/auth/login/+page.server.js
new file mode 100644
index 0000000..b772319
--- /dev/null
+++ b/frontend/src/routes/auth/login/+page.server.js
@@ -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 || "/");
+ // },
+};
diff --git a/frontend/src/routes/auth/login/+page.svelte b/frontend/src/routes/auth/login/+page.svelte
new file mode 100644
index 0000000..937c55e
--- /dev/null
+++ b/frontend/src/routes/auth/login/+page.svelte
@@ -0,0 +1,76 @@
+
+
+
diff --git a/frontend/src/routes/auth/logout/+page.server.js b/frontend/src/routes/auth/logout/+page.server.js
new file mode 100644
index 0000000..fa01350
--- /dev/null
+++ b/frontend/src/routes/auth/logout/+page.server.js
@@ -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");
+ },
+};
diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js
new file mode 100644
index 0000000..54af965
--- /dev/null
+++ b/frontend/svelte.config.js
@@ -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;
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..37b6a84
--- /dev/null
+++ b/frontend/vite.config.js
@@ -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}']
+ }
+});