Compare commits

..

14 Commits

Author SHA1 Message Date
Matthieu Morin 5bf94b1db4 Merge pull request 'Recent Overhaul to content structure, simplifying, read README' (#1) from rand into master
Reviewed-on: #1
2024-03-20 15:23:10 +00:00
matthieu42morin 44ad99d5a7 JSON schema change, input item.duration in minutes and convert to hours 2024-03-20 16:17:33 +01:00
matthieu42morin 918a02e6d2 project description 2024-03-20 16:14:17 +01:00
matthieu42morin 6ac9bb8c03 funi error 2024-03-20 16:12:21 +01:00
matthieu42morin 449f67c2a0 Services OVERHAUL =>> using json with schemas and validators as database 2024-03-20 16:11:46 +01:00
matthieu42morin 15e7a32263 sentry, hooks, CSP update 2024-03-20 16:09:11 +01:00
matthieu42morin f6a2a7558e paths directly in tsconfig interfere with autogenerated aliases, I added both at the same time - instead you need to rerun dev server to autogenerate again. 2024-03-19 22:31:28 +01:00
matthieu42morin a34fd5b31e Spinner on Load 2024-03-19 22:29:52 +01:00
matthieu42morin c9221c357e REFACTOR - Server Hooks, csp, permissions and setting headers 2024-03-19 22:24:03 +01:00
matthieu42morin 64e1e015b0 reimplementing cspDirectives, adding future cartoCDN, Sentry, OSM support 2024-03-19 22:21:43 +01:00
matthieu42morin 63ce40cc46 Config - Adding aliases 2024-03-19 22:20:38 +01:00
matthieu42morin 758ab0aeae tsconfig, specifying compiler options to fix import issues, including paths 2024-03-19 22:19:52 +01:00
matthieu42morin a829327c28 .env.development needs --mode development in VITE to get recognized? I think 2024-03-19 22:18:37 +01:00
matthieu42morin 11ec893fa0 dafasdf 2024-03-16 10:08:05 +01:00
39 changed files with 1016 additions and 185 deletions

View File

@ -1,38 +1,78 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
This is the repo of the website [Kkosmetickysalon.cz](https://kkosmetickysalon.cz), my mom's business, I also did the logos and branding, which are located in `/static`
## Creating a project
## Webapp stack
If you're seeing this, you've probably already done this step. Congrats!
### [Sveltekit with advanced config](https://kit.svelte.dev/docs/introduction)
```bash
# create a new project in the current directory
npm create svelte@latest
Sveltekit is simple, modern metaframework for svelte. The docs are great, love the decisions and features, don't need anything more or less for websites with moderate complexity and PWAs.
# create a new project in my-app
npm create svelte@latest my-app
```
### [Skeleton UI library](https://skeleton.dev)
## Developing
This is perhaps the best UI library for sveltekit, their docs and their code is great
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
### Tailwind + PostCSS + Fontawesome local
```bash
npm run dev
Utility css is quick to develop, easy to remember, readable little boilerplate needed. Postcss as a dependency and for conditional css. Fontawesome free for local icons as fonts.
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
### Most importantly, SCREW CMS, use .md or json or yaml and parse them
## Building
#### Motivation
To create a production version of your app:
I have spent considerable amount of time researching and learning the bloatware that is on the internet caused by the nocode movement and enough is enough. It takes a fraction of a time, if any to understand how json works, if you know it's a way to represent and structure content, then you're set.
```bash
npm run build
```
- it's dead simple and you literally almost just import it
- You don't have to host strapi or a db on a 4GB RAM server in the cloud for making a post once a month
- Static makes better SEO
You can preview the production build with `npm run preview`.
#### Use case
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
I have a $content path (`./src/content`), where I have `./services/` and `./posts` in the former I store individual service categories (and their respective items) in json files, in the latter I have posts, where I store .md files, which are subsequently processed by MDSveX and the html outputted from MDSveX is relayed to `./sluzby/[id]`. It's very DRY, there are no lockins and it's reusable on a lot of platforms.
Everything has a schema (e.g. `./sluzby/schema#`) or a type or a generatable template.
The schema can be used with a [JSON editor](https://github.com/json-editor/json-editor) to help nontechies write it.
The json data is then validated by [ajv](https://ajv.js.org) to match the type Service in `$lib/types/service.d.ts`
## Features, Components and parts
A list of mostly
### [LibreMaps](https://svelte-maplibre.vercel.app/)
Screw Google Maps, I knew I wanted to use OSM, maybe Mapbox, because I had experience with it, but this was a great ready made, quality solution, repo is [here](https://github.com/dimfeld/svelte-maplibre).
### [Instagram feed](https://github.com/rodneylab/sveltekit-instagram-infinite-scroll)
### CSP, custom hooks, custom headers
Securing this app with the latest security features and web technologies.
### Service Worker
Why not?
## Admin/DevOps Tools
A list of mostly 3rd party useful tools this project uses.
### Gitea action CI/CD workflow
### hCaptcha
For forms, I may remove this in favor of something else, because it could be a privacy and GDPR issue. Also screw Google and their reCaptcha.
### Plausible self-hosted
That or coding some metrics and using some opinionated solution myself.
### Sentry - runtime prod & dev analysis
Sentry is cool, I will probably not use 80% of their features, but when doing CSP and all sorts of reporting, this came in very handy. I don't really see an alternative with sveltekit.
### Playwright - headless browser target testing
TODO, probably sometime, It can be useful with the service posts.
### Dockerfile
Selfhosting this is the only way. I used ansible and terraform to get this thing in the air together with the analytics platform. It's on AWS for now,

View File

@ -1,12 +1,15 @@
{
"name": "cosmeticstudio",
"name": "kkosmetickysalon",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"validate": "node ./tests/ValidateServices.js",
"dev": "pnpm run validate && vite dev --mode development",
"build": "pnpm run validate && vite build",
"build-dev": "pnpm run validate && vite build --mode development",
"preview": "vite preview",
"test": "npm run test:integration && npm run test:unit",
"test": "pnpm run test:integration && npm run test:unit",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
@ -22,7 +25,8 @@
"@fortawesome/fontawesome-free": "^6.5.1",
"@sentry/sveltekit": "^7.107.0",
"@sveltejs/adapter-node": "^5.0.1",
"@sveltejs/vite-plugin-svelte": "^3.0.2"
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"ajv": "^8.12.0"
},
"devDependencies": {
"@floating-ui/dom": "1.5.3",
@ -44,6 +48,7 @@
"prettier-plugin-svelte": "^2.10.1",
"svelte": "^4.2.12",
"svelte-check": "^3.6.7",
"svelte-maplibre": "^0.8.2",
"svelte-preprocess": "^5.1.3",
"tailwindcss": "^3.4.1",
"tslib": "^2.6.2",
@ -51,6 +56,5 @@
"vite": "^5.1.6",
"vite-plugin-tailwind-purgecss": "^0.2.0",
"vitest": "^0.32.4"
},
"type": "module"
}
}

View File

@ -0,0 +1,34 @@
{
"$schema": "/src/routes/sluzby/schema.json",
"category": "DALŠÍ VELMI OBLÍBENÉ SLUŽBY",
"items": [
{
"name": "Lifting řas booster (botox)",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "lifting-ras-booster",
"price": 500,
"duration": 1
},
{
"name": "Laminace obočí + výživa",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "laminace-oboci-vyziva",
"price": 500,
"duration": 1
},
{
"name": "Úprava obočí (tvar + barva)",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "uprava-oboci-tvar-barva",
"price": 250,
"duration": 1
},
{
"name": "Úprava obočí + řasy (tvar + barvení)",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "uprava-oboci-rasy-tvar-barveni",
"price": 300,
"duration": 1
}
]
}

View File

@ -0,0 +1,69 @@
{
"$schema": "/src/routes/sluzby/schema.json",
"category": "Depilace",
"items": [
{
"name": "Depilace Horní ret",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "depilace-horni-ret",
"price": 80,
"duration": 0.5
},
{
"name": "Depilace Brada",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "depilace-brada",
"price": 80,
"duration": 0.5
},
{
"name": "Depilace Obočí",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "depilace-oboci",
"price": 150,
"duration": 0.5
},
{
"name": "Depilace Tváře",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "depilace-tvare",
"price": 150,
"duration": 0.5
},
{
"name": "Depilace Podpaží",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "depilace-podpazi",
"price": 150,
"duration": 0.5
},
{
"name": "Depilace Předloktí",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "depilace-predlokti",
"price": 200,
"duration": 0.5
},
{
"name": "Depilace Celé ruce",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "depilace-cele-ruce",
"price": 350,
"duration": 1
},
{
"name": "Depilace Lýtka",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "depilace-lytka",
"price": 350,
"duration": 1
},
{
"name": "Depilace Celé nohy",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "depilace-cele-nohy",
"price": 500,
"duration": 1
}
]
}

View File

@ -0,0 +1,48 @@
{
"$schema": "/src/routes/sluzby/schema.json",
"category": "Kosmetické ošetření",
"items": [
{
"name": "ZÁKLADNÍ CALM",
"description": "Diagnostika pleti, odlíčení tonizace, enzymatický peeling, kavitační peeling -ultarzvuková špachtle, séra dle typu pleti, masky (tvář,krk,dekolt), závěrečná péče (oční a denní krém)",
"id": "zakladni-calm",
"price": 500,
"duration": 1
},
{
"name": "ZÁKLADNÍ + CALM PLUS",
"description": "Diagnostika pleti, odlíčení tonizace, úprava obočí (vosk+pinzeta), barvení řas a obočí, depilace horní ret/brada, enzymatický peeling, kavitační peeling -ultarzvuková špachtle, séra dle typu pleti, masky (tvář,krk,dekolt), závěrečná péče (oční a denní krém)",
"id": "zakladni-calm-plus",
"price": 600,
"duration": 1
},
{
"name": "RELAXAČNÍ",
"description": "Diagnostika pleti, odlíčení tonizace, úprava obočí (vosk+pinzeta), barvení řas a obočí, depilace horní ret/brada, enzymatický peeling, kavitační peeling -ultarzvuková špachtle, séra, masáž relaxační (tvář,krk dekolt), masky (tvář,krk,dekolt), závěrečná péče (oční a denní krém)",
"id": "relaxacni",
"price": 690,
"duration": 1.5
},
{
"name": "LIFTINGOVÉ - ANTI AGE",
"description": "Diagnostika pleti, odlíčení tonizace, úprava obočí (vosk+pinzeta), barvení řas a obočí, depilace horní ret/brada, enzymatický peeling, kavitační peeling -ultarzvuková špachtle, vacupres ošetření lifting obličeje krku a dekoltu, séra, masky (tvář,krk,dekolt), alginátová maska, závěrečná péče (oční a denní krém)",
"id": "liftingove-anti-age",
"price": 690,
"duration": 1.5
},
{
"name": "CLEAR + ANTI AKNÉ",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "clear-anti-akne",
"price": 690,
"duration": 1.5
},
{
"name": "Odlíčení + sérum + alginátová maska (PROJASNĚNÍ)",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "odliceni-serum-alginatova-maska",
"price": 300,
"duration": 1
}
]
}

View File

@ -0,0 +1,69 @@
{
"$schema": "$routes/sluzby/schema.json",
"category": "Permanentní make-up",
"items": [
{
"name": "Obočí Pudrové, Ombré",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "oboci",
"price": 3000,
"duration": 2.5
},
{
"name": "Horní linky - meziřasové přirozené",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "linky",
"price": 2000,
"duration": 2
},
{
"name": "Klasické linky - s ocáskem",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "classic-linky",
"price": 3000,
"duration": 2.5
},
{
"name": "Klasické linky - s ocáskem + spodní linky",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "classic-linky+spodni",
"price": 3500,
"duration": 2.5
},
{
"name": "Rty - kontura",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "rty",
"price": 2500,
"duration": 2
},
{
"name": "3D Rty (kontura a stínování), Full Lips (plné rty)",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "3d-rty",
"price": 3500,
"duration": 2.5
},
{
"name": "Aquarelle Lips (přirodní stínování, bez kontury)",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "aquarelle",
"price": 3000,
"duration": 2
},
{
"name": "První korekce po aplikaci pmu max. do 3 měsíců",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "korekce",
"price": 1000,
"duration": 1.5
},
{
"name": "Oprava práce obočí jiného salonu",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "oprava-oboci",
"price": "na domluvě",
"duration": "na domluvě"
}
]
}

View File

@ -0,0 +1,41 @@
{
"$schema": "/src/routes/sluzby/schema.json",
"category": "Vakuslim 48 - zeštíhlující procedura",
"items": [
{
"name": "Ošetření horních končetin",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "vakuslim-48-zestihlujici-procedura-horni-koncetiny",
"price": 600,
"duration": 2
},
{
"name": "1 ošetření spodní části těla (břicho, boky, dolní končetiny)",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "vakuslim-48-zestihlujici-procedura-spodni-cast-tela",
"price": 800,
"duration": 2
},
{
"name": "1 ošetření komplet horní-dolní části",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "vakuslim-48-zestihlujici-procedura-komplet-horni-dolni-cast",
"price": 1200,
"duration": 2
},
{
"name": "6 ošetření předplatné kompet",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "vakuslim-48-zestihlujici-procedura-6-o-setreni-predplatne-kompet",
"price": 6600,
"duration": 2
},
{
"name": "12 ošetření předplatné komplet",
"description": "Diagnostika pleti, odlíčení tonizace",
"id": "vakuslim-48-zestihlujici-procedura-12-o-setreni-predplatne-komplet",
"price": 11000,
"duration": 2
}
]
}

View File

@ -1,25 +1,30 @@
// https://gist.github.com/acoyfellow/d8e86979c66ebea25e1643594e38be73, Rodney Lab
import {
PUBLIC_DOMAIN,
PUBLIC_SENTRY_KEY,
PUBLIC_SENTRY_PROJECT_ID,
PUBLIC_WORKER_URL,
} from '$env/static/public';
PUBLIC_SENTRY_ORG_ID,
PUBLIC_WORKER_URL,
} from '$env/static/public';
const rootDomain = PUBLIC_DOMAIN; // or your server IP for dev
export const rootDomain = PUBLIC_DOMAIN; // or your server IP for dev
const directives = {
'base-uri': ["'self'"],
'child-src': ["'self'"],
'child-src': ["'self'", 'blob:'],
// 'connect-src': ["'self'", 'ws://localhost:*'],
'connect-src': [
"'self'",
'ws://localhost:*',
'https://*.sentry.io',
'https://hcaptcha.com',
'https://*.hcaptcha.com',
'https://*.cartocdn.com',
PUBLIC_DOMAIN,
PUBLIC_WORKER_URL,
],
'img-src': ["'self'", 'data:'],
'img-src': ["'self'", 'data:', 'https://images.unsplash.com'],
'font-src': ["'self'", 'data:'],
'form-action': ["'self'"],
'frame-ancestors': ["'self'"],
@ -30,6 +35,8 @@ const directives = {
// "https://*.facebook.net",
'https://hcaptcha.com',
'https://*.hcaptcha.com',
'https://www.openstreetmap.org',
'https://*.cartocdn.com'
],
'manifest-src': ["'self'"],
'media-src': ["'self'", 'data:'],
@ -61,13 +68,20 @@ const directives = {
'https://*.hcaptcha.com',
'https://*.sentry.io',
// 'https://polyfill.io',
'https://*.cartocdn.com'
],
'worker-src': ["'self'"],
// remove report-to & report-uri if you do not want to use Sentry reporting
'worker-src': [
"'self'",
'blob:'
],
//report-to can throw "Content-Security-Policy: Couldnt process unknown directive report-to", leave it for older browsers.
'report-to': ["'csp-endpoint'"],
'report-uri': [
`https://sentry.io/api/${PUBLIC_SENTRY_PROJECT_ID}/security/?sentry_key=${PUBLIC_SENTRY_KEY}`,
`https://${PUBLIC_SENTRY_ORG_ID}.ingest.us.sentry.io/api/${PUBLIC_SENTRY_PROJECT_ID}/security/?sentry_key=${PUBLIC_SENTRY_KEY}`,
],
};
export default directives;
export const csp = Object.entries(directives)
.map(([key, arr]) => key + ' ' + arr.join(' '))
.join('; ');

View File

@ -1,50 +1,47 @@
import type { Handle } from '@sveltejs/kit';
import { sequence } from "@sveltejs/kit/hooks";
import { handleErrorWithSentry, sentryHandle } from "@sentry/sveltekit";
import { cspDirectives } from './cspDirectives.js'
import * as Sentry from '@sentry/sveltekit';
import {
PUBLIC_SENTRY_KEY,
PUBLIC_SENTRY_PROJECT_ID,
PUBLIC_SENTRY_ORG_ID
} from '$env/static/public';
import { csp, rootDomain } from './cspDirectives';
Sentry.init({
dsn: 'https://962a7ed3891a335e112746e5c6c6bf42@o4505828687478784.ingest.us.sentry.io/4506871754326016',
tracesSampleRate: 1.0,
});
const csp = Object.entries(cspDirectives)
.map(([key, arr]) => key + ' ' + arr.join(' '))
.join('; ');
export const cspHandle = async ({ event, resolve }) => {
export const cspHandle: Handle = async ({ event, resolve }) => {
if (!csp) {
throw new Error('csp is undefined');
}
const response = await resolve(event);
response.headers.set('X-Frame-Options', 'SAMEORIGIN');
response.headers.set('Referrer-Policy', 'no-referrer');
response.headers.set(
'Permissions-Policy',
'accelerometer=(), autoplay=(), camera=(), document-domain=(), encrypted-media=(), fullscreen=(), gyroscope=(), interest-cohort=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), sync-xhr=(), usb=(), xr-spatial-tracking=(), geolocation=()',
);
response.headers.set('X-Content-Type-Options', 'nosniff');
/* Switch from Content-Security-Policy-Report-Only to Content-Security-Policy once you are satisifed policy is what you want
* on switch comment out the Report-Only line
*/
response.headers.set('Content-Security-Policy-Report-Only', csp);
// response.headers.set('Content-Security-Policy', csp);
response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
response.headers.set(
'Expect-CT',
`max-age=86400, report-uri="https://sentry.io/api/${PUBLIC_SENTRY_PROJECT_ID}/security/?sentry_key=${PUBLIC_SENTRY_KEY}"`,
);
response.headers.set(
'Report-To',
`{group: "csp-endpoint", "max_age": 10886400, "endpoints": [{"url": "https://sentry.io/api/${PUBLIC_SENTRY_PROJECT_ID}/security/?sentry_key=${PUBLIC_SENTRY_KEY}"}]}`,
);
// Permission fullscreen necessary for maps fullscreen
const headers = {
'X-Frame-Options': 'SAMEORIGIN',
'Referrer-Policy': 'no-referrer',
'Permissions-Policy': `accelerometer=(), autoplay=(), camera=(), document-domain=(self, 'js-profiling'), encrypted-media=(), fullscreen=(self ${rootDomain}), gyroscope=(), interest-cohort=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), sync-xhr=(), usb=(), xr-spatial-tracking=(), geolocation=()`,
'X-Content-Type-Options': 'nosniff',
// 'Content-Security-Policy-Report-Only': csp,
'Content-Security-Policy': csp,
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload',
'Expect-CT': `max-age=86400, report-uri="https://${PUBLIC_SENTRY_ORG_ID}.ingest.us.sentry.io/api/${PUBLIC_SENTRY_PROJECT_ID}/security/?sentry_key=${PUBLIC_SENTRY_KEY}"`,
'Report-To': `{group: "csp-endpoint", "max_age": 10886400, "endpoints": [{"url": "https://${PUBLIC_SENTRY_ORG_ID}.ingest.us.sentry.io/api/${PUBLIC_SENTRY_PROJECT_ID}/security/?sentry_key=${PUBLIC_SENTRY_KEY}/security/?sentry_key=${PUBLIC_SENTRY_KEY}"}]}`,
};
Object.entries(headers).forEach(([key, value]) => {
response.headers.set(key, value);
});
return response;
}
// If you have custom handlers, make sure to place them after `sentryHandle()` in the `sequence` function.
export const handle = sequence(sentryHandle(), cspHandle());
export const handle: Handle = sequence(sentryHandle(), cspHandle);
// If you have a custom error handler, pass it to `handleErrorWithSentry`
export const handleError = handleErrorWithSentry();

View File

@ -1,5 +1,5 @@
<script lang="ts">
export let clazz: string = "";
export let clazz = "w-8";
</script>
<svg

View File

@ -24,7 +24,7 @@
<section class=" sm:inline-flex space-x-1">
<a
class="brand-icon"
class="btn-icon hover:variant-soft-primary"
href="{config.instagram}"
target="_blank"
rel="noreferrer"
@ -32,7 +32,7 @@
<i class="fa-brands fa-instagram text-lg" />
</a>
<a
class="brand-icon"
class="btn-icon hover:variant-soft-primary"
href="{config.facebook}"
target="_blank"
rel="noreferrer"
@ -53,8 +53,4 @@
</AppBar>
<style lang="postcss">
.brand-icon {
@apply btn-icon hover:variant-soft-primary;
}
</style>

View File

@ -0,0 +1,57 @@
<script lang="ts">
//class="fill-primary-500 stroke-primary-500"
//fill="#FF156D" stroke="#FF156D"
</script>
<svg class="h-6 w-6 md:h-10 md:w-10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200"
><circle class="fill-primary-500 stroke-primary-500" stroke-width="15" r="15" cx="35" cy="100"
><animate
attributeName="cx"
calcMode="spline"
dur="2"
values="35;165;165;35;35"
keySplines="0 .1 .5 1;0 .1 .5 1;0 .1 .5 1;0 .1 .5 1"
repeatCount="indefinite"
begin="0"
/></circle
><circle class="fill-primary-500 stroke-primary-500" stroke-width="15" opacity=".8" r="15" cx="35" cy="100"
><animate
attributeName="cx"
calcMode="spline"
dur="2"
values="35;165;165;35;35"
keySplines="0 .1 .5 1;0 .1 .5 1;0 .1 .5 1;0 .1 .5 1"
repeatCount="indefinite"
begin="0.05"
/></circle
><circle class="fill-primary-500 stroke-primary-500" stroke-width="15" opacity=".6" r="15" cx="35" cy="100"
><animate
attributeName="cx"
calcMode="spline"
dur="2"
values="35;165;165;35;35"
keySplines="0 .1 .5 1;0 .1 .5 1;0 .1 .5 1;0 .1 .5 1"
repeatCount="indefinite"
begin=".1"
/></circle
><circle class="fill-primary-500 stroke-primary-500" stroke-width="15" opacity=".4" r="15" cx="35" cy="100"
><animate
attributeName="cx"
calcMode="spline"
dur="2"
values="35;165;165;35;35"
keySplines="0 .1 .5 1;0 .1 .5 1;0 .1 .5 1;0 .1 .5 1"
repeatCount="indefinite"
begin=".15"
/></circle
><circle class="fill-primary-500 stroke-primary-500" stroke-width="15" opacity=".2" r="15" cx="35" cy="100"
><animate
attributeName="cx"
calcMode="spline"
dur="2"
values="35;165;165;35;35"
keySplines="0 .1 .5 1;0 .1 .5 1;0 .1 .5 1;0 .1 .5 1"
repeatCount="indefinite"
begin=".2"
/></circle
></svg
>

View File

@ -1,12 +1,15 @@
<script lang="ts">
import { getImageLink } from "$lib/images";
import { getImageLink } from '$lib/images';
import type { Service } from '$lib/types/service';
import convertMinutesToHours from '$lib/utils/minToH';
export let item: Service['items'][number];
export let item: any = {};
</script>
<a href="/sluzby/{item.id}" class="w-72 card variant-glass-secondary mx-2 my-4 duration-500 hover:scale-105 hover:shadow-xl shadow-md">
<a href="/sluzby/{item.id}" id="{item.id}"class="w-72 card variant-glass-secondary mx-2 my-4 duration-500 hover:scale-105 hover:shadow-xl shadow-md">
<header>
<img src={getImageLink({id: item.id, w: 288, h: 320 })} class="bg-black/50 object-cover aspect-[9/10] rounded-t-xl" alt="Post" />
<img src={getImageLink({id: item.id, w: 288, h: 320 })} class="bg-black/50 object-cover aspect-[9/10] rounded-t-xl" alt="Post" loading="lazy" />
</header>
<!-- <div class="img" style="background-image: url('/images/services/{item.id}.jpg');"/> -->
<div class="flex flex-col px-4 py-3 w-72 h-full">
@ -29,7 +32,8 @@
<footer class="justify-self-end align-bottom">
<div class="flex flex-col md:flex-row md:justify-between items-center">
<p class=" text-lg text-surface-900 font-semibold text-right">
{typeof item.price === 'number' ? `${item.price},-` : item.price}{typeof item.duration === 'number' ? `/${item.duration}h` : ''}
{typeof item.price === 'number' ? `${item.price},-` : item.price}
{typeof item.duration === 'number' ? `/${convertMinutesToHours(item.duration)}` : ''}
</p>
<a
href="https://app.cal.com/kkosmetickysalon/{item.id}"
@ -40,4 +44,4 @@
</footer>
</div>
</a>
</a>

View File

@ -1,3 +1,4 @@
<script lang="ts">
import type { Service } from '$lib/types/service';
import ServiceCard from '$lib/components/services/ServiceCard.svelte';
@ -30,13 +31,13 @@
<style lang="postcss">
.services-container {
/* .services-container {
@apply grid md:grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 p-4;
}
}
@media (max-width: 768px) {
.services-container {
@apply grid-cols-1;
}
}
}*/
</style>
</style>

View File

@ -8,3 +8,18 @@ export const author = 'Klára Morinová';
export const email = 'klara@kkosmetickysalon.cz';
export const facebook = 'https://www.facebook.com/jack.morin.712';
export const instagram = 'https://www.instagram.com/kkosmetickysalon/';
// prettier-ignore
export const socialLinks = [
{ title: 'Instagram', href: 'https://www.instagram.com/kkosmetickysalon/', icon: 'fa-brands fa-linkedin'},
{ title: 'Phone', href: 'tel:+420792304497', icon: './MatrixLogo' },
{ title: 'Facebook', href: 'hhttps://www.facebook.com/jack.morin.712', icon: 'fa-brands fa-facebook'},
{ title: 'Email', href: 'klara@kkosmetickysalon.cz', icon: 'fa-regular fa-envelope'},
];
// Routes
export const NavRoutes = [
{ title: 'Home', href: '/' },
{ title: 'Sluzby', href: '/Sluzby' },
{ title: 'O-mne', href: '/o-mne' }
];

21
src/lib/stores.ts Normal file
View File

@ -0,0 +1,21 @@
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
import type { InstagramPost } from '../types/instagram';
import { derived } from "svelte/store";
import { navigating } from "$app/stores";
export const instagramFeed: Writable<InstagramPost[]> = writable([]);
let timer = null;
/** When navigating from to, time 500ms to be true, else false */
export const navigationIsDelayed = derived(navigating, (newValue, set) => {
if (timer) { clearTimeout(timer); }
if (newValue) {
timer = setTimeout(() => set(true), 500);
}
set(false);
});

20
src/lib/utils/minToH.cjs Normal file
View File

@ -0,0 +1,20 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
function convertMinutesToHours(minutes) {
try {
var hours = Math.floor(minutes / 60);
var mins = minutes % 60;
if (hours) {
console.log("".concat(hours, "h ").concat(mins, "m"));
return "".concat(hours, "h ").concat(mins, "m");
}
else
console.log("".concat(mins, "m"));
return "".concat(mins, "m");
}
catch (error) {
console.error("I don't think that's time: ".concat(error));
process.exit(1); // Exit with non-zero code to signal failure
}
}
exports.default = convertMinutesToHours;

16
src/lib/utils/minToH.ts Normal file
View File

@ -0,0 +1,16 @@
export default function convertMinutesToHours(minutes:number) {
try {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours) {
console.log(`${hours}h ${mins}m`)
return `${hours}h ${mins}m`
}
else console.log(`${mins}m`); return `${mins}m`
} catch (error) {
console.error(`I don't think that's time: ${error}`);
process.exit(1); // Exit with non-zero code to signal failure
}
}

36
src/routes/+error.svelte Normal file
View File

@ -0,0 +1,36 @@
<script>
import { page } from '$app/stores'
</script>
{#if $page.status === 404}
<div class="flex flex-col items-center m-4 py-8">
<div
class="relative mb-[3.33vh] flex h-24 w-24 md:h-64 md:w-64 items-center justify-center bg-surface-800"
>
<img
class="object-fill"
src="/animations/travolta_confused.webp"
alt="Travolta Confused gif"
/>
<h1 class="h1 absolute leading-[5rem] text-white">404</h1>
</div>
<h2 class="h4 mb-2">
Bože můj, co tu děláte?
</h2>
<p>Tato stránka neexistuje, buď byla odstraněna nebo se jednoduše vytratila z vesmíru.</p>
</div>
{:else}
<h2>{$page.status}</h2>
{#if $page.error}
<p class="subhead">{$page.error.message}</p>
{/if}
<p><strong>Promiňte!</strong> Stal se strašlivý omyl. Možná byste mohli zkusit nějaké z následujících stránek?</p>
<ul>
<li><a href="/">Domů</a></li>
<li><a href="/sluzby">Služby</a></li>
<li><a href="/kontakt">Projects</a></li>
</ul>
{/if}

View File

@ -1,7 +1,7 @@
<script lang="ts">
// Styleshit
import '../app.postcss';
import '$src/app.postcss';
// Font Gruesome
import '@fortawesome/fontawesome-free/css/fontawesome.css';
@ -9,7 +9,12 @@
import '@fortawesome/fontawesome-free/css/solid.css';
// Components & Utilities
import { AppShell } from '@skeletonlabs/skeleton';
import { AppShell, Drawer, initializeStores, getDrawerStore } from '@skeletonlabs/skeleton';
import { } from '@skeletonlabs/skeleton';
initializeStores();
const drawerStore = getDrawerStore();
$: positionClasses = $drawerStore.open ? 'translate-x-[50%]' : '';
import { page } from '$app/stores';
// Floating UI for Popups
@ -42,13 +47,21 @@
}
};
// Scroll heading into view
function scrollHeadingIntoView(): void {
if (!window.location.hash) return;
const elemTarget: HTMLElement | null = document.querySelector(window.location.hash);
if (elemTarget) elemTarget.scrollIntoView({ behavior: 'smooth' });
// Scroll to anchor
$: if ($page.url.pathname) {
// Workaround until https://github.com/sveltejs/kit/issues/2664 is fixed
if (typeof window !== 'undefined' && window.location.hash) {
const deepLinkedElement = document.getElementById(window.location.hash.substring(1));
if (deepLinkedElement) {
deepLinkedElement.scrollIntoView();
}
}
};
</script>
import { fade } from 'svelte/transition';
import Spinner from '$lib/components/Spinner.svelte';
import { navigationIsDelayed } from '$lib/stores';
</script>
<!-- SEO -->
<svelte:head>
@ -80,18 +93,27 @@
<!-- <Analytics /> -->
<!-- App Shell -->
<AppShell>
<svelte:fragment slot="header">
<MainHeader />
</svelte:fragment>
<!-- Page Route Content -->
<slot />
<Drawer />
{#if $navigationIsDelayed}
<div class="fixed w-full h-full z-10" in:fade={{ duration: 150 }}>
<div class="absolute w-full h-full flex justify-center items-center z-20">
<Spinner />
</div>
</div>
{:else}
<AppShell class="transition-transform {positionClasses}">
<svelte:fragment slot="header">
<MainHeader />
</svelte:fragment>
<!-- Page Route Content -->
<slot />
<svelte:fragment slot="footer">
<MainFooter />
</svelte:fragment>
</AppShell>
<svelte:fragment slot="footer">
<MainFooter />
</svelte:fragment>
</AppShell>
{/if}
<!-- <CookieConsent />
<Segment /> -->
<Segment /> -->

View File

@ -1,13 +1,20 @@
import { error } from '@sveltejs/kit';
import { IG_API_KEY } from '$env/static/private';
import { RequestHandler} from './$types';
export async function GET() {
try {
const response = await fetch(`https://graph.instagram.com/me/media?fields=id,media_type,media_url,caption,timestamp&access_token=${IG_API_KEY}`);
const data = await response.json();
return new Response(JSON.stringify(data));
} catch (err) {
console.log('Error: ', err);
throw error(500, 'Error retrieving Instagram data');
}
}
export const POST: RequestHandler = async ({ request }) => {
try {
const { next } = await request.json();
const response = await fetch(next, {
method: 'GET',
});
const data = await response.json();
return new Response(data, {
headers: {
'Content-Type': 'application/json'
}
});
} catch (err) {
console.log('Error: ', err);
error(500, 'Error retrieving data in /api/instagram.json');
}
}

View File

@ -0,0 +1,45 @@
// === THANKS TO https://github.com/rodneylab/sveltekit-hcaptcha-form/blob/main/src/routes/api/verify/%2Bserver.js
// I cleaned it and converted to TS
import { HCAPTCHA_SECRETKEY } from '$env/static/private';
import { PUBLIC_HCAPTCHA_SITEKEY } from '$env/static/public';
import { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request }) => {
try {
const { name, email, message, response: hCaptchaClientResponse } = await request.json();
// const secret = HCAPTCHA_SECRETKEY;
// const sitekey = PUBLIC_HCAPTCHA_SITEKEY;
const body = new URLSearchParams({ response: hCaptchaClientResponse, HCAPTCHA_SECRETKEY, PUBLIC_HCAPTCHA_SITEKEY });
const response = await fetch('https://hcaptcha.com/siteverify', {
method: 'POST',
credentials: 'omit',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body.toString(),
});
const data = await response.json();
const { success } = data;
console.log('data: ', data);
if (success) {
console.log('hCaptcha says yes!');
} else {
console.log('hCaptcha says no!');
}
// process name, email and message here e.g. email site admin with message details
console.log({ name, email, message });
return new Response('OK');
} catch (err) {
const error = `HCaptcha Error in /verify.json.js: ${err}`;
console.error(error);
return {
status: 500,
error,
};
}
}

View File

@ -0,0 +1,22 @@
import { error } from '@sveltejs/kit';
import { IG_ACCESS_TOKEN } from '$env/static/private';
import { PageServerLoad} from './$types';
export const load: PageServerLoad = async () => {
try {
const url = `https://graph.instagram.com/me/media?fields=caption,id,media_type,media_url,timestamp&access_token=${IG_ACCESS_TOKEN}`;
const response = await fetch(url, {
method: 'GET',
});
const data = await response.json();
return new Response(data, {
headers: {
'Content-Type': 'application/json'
}
});
} catch (err) {
console.log('Error: ', err);
throw error(500, 'Error retrieving Instagram data');
}
}

View File

@ -0,0 +1,5 @@
<script lang="ts">
import { PUBLIC_MAPBOX_ACCESS_TOKEN } from '$env/static/public';
</script>

View File

@ -0,0 +1,97 @@
<!--
This is just a very simple page with a button to throw an example error.
Feel free to delete this file and the entire sentry route.
-->
<script>
import * as Sentry from '@sentry/sveltekit';
function getSentryData() {
Sentry.startSpan({
name: 'Example Frontend Span',
op: 'test',
}, async () => {
const res = await fetch('/sentry-example');
if (!res.ok) {
throw new Error('Sentry Example Frontend Error');
}
});
}
</script>
<div>
<head>
<title>Sentry Onboarding</title>
<meta name="description" content="Test Sentry for your SvelteKit app!" />
</head>
<main>
<h1>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 44">
<path
fill="currentColor"
d="M124.32,28.28,109.56,9.22h-3.68V34.77h3.73V15.19l15.18,19.58h3.26V9.22h-3.73ZM87.15,23.54h13.23V20.22H87.14V12.53h14.93V9.21H83.34V34.77h18.92V31.45H87.14ZM71.59,20.3h0C66.44,19.06,65,18.08,65,15.7c0-2.14,1.89-3.59,4.71-3.59a12.06,12.06,0,0,1,7.07,2.55l2-2.83a14.1,14.1,0,0,0-9-3c-5.06,0-8.59,3-8.59,7.27,0,4.6,3,6.19,8.46,7.52C74.51,24.74,76,25.78,76,28.11s-2,3.77-5.09,3.77a12.34,12.34,0,0,1-8.3-3.26l-2.25,2.69a15.94,15.94,0,0,0,10.42,3.85c5.48,0,9-2.95,9-7.51C79.75,23.79,77.47,21.72,71.59,20.3ZM195.7,9.22l-7.69,12-7.64-12h-4.46L186,24.67V34.78h3.84V24.55L200,9.22Zm-64.63,3.46h8.37v22.1h3.84V12.68h8.37V9.22H131.08ZM169.41,24.8c3.86-1.07,6-3.77,6-7.63,0-4.91-3.59-8-9.38-8H154.67V34.76h3.8V25.58h6.45l6.48,9.2h4.44l-7-9.82Zm-10.95-2.5V12.6h7.17c3.74,0,5.88,1.77,5.88,4.84s-2.29,4.86-5.84,4.86Z M29,2.26a4.67,4.67,0,0,0-8,0L14.42,13.53A32.21,32.21,0,0,1,32.17,40.19H27.55A27.68,27.68,0,0,0,12.09,17.47L6,28a15.92,15.92,0,0,1,9.23,12.17H4.62A.76.76,0,0,1,4,39.06l2.94-5a10.74,10.74,0,0,0-3.36-1.9l-2.91,5a4.54,4.54,0,0,0,1.69,6.24A4.66,4.66,0,0,0,4.62,44H19.15a19.4,19.4,0,0,0-8-17.31l2.31-4A23.87,23.87,0,0,1,23.76,44H36.07a35.88,35.88,0,0,0-16.41-31.8l4.67-8a.77.77,0,0,1,1.05-.27c.53.29,20.29,34.77,20.66,35.17a.76.76,0,0,1-.68,1.13H40.6q.09,1.91,0,3.81h4.78A4.59,4.59,0,0,0,50,39.43a4.49,4.49,0,0,0-.62-2.28Z"
/>
</svg>
</h1>
<p>
Get Started with this <strong>simple Example:</strong>
</p>
<p>1. Send us a sample error:</p>
<button
type="button"
on:click={getSentryData}>
Throw error!
</button>
<p>
2. Look for the error on the
<a href="https://none-b0c3fadae.sentry.io/issues/?project=4506871754326016">Issues Page</a>.
</p>
<p style="margin-top: 24px;">
For more information, take a look at the
<a href="https://docs.sentry.io/platforms/javascript/guides/sveltekit/">
Sentry SvelteKit Documentation
</a>
</p>
</main>
</div>
<style>
main {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
h1 {
font-size: 4rem;
margin: 14px 0;
}
svg {
height: 1em;
}
button {
padding: 12px;
cursor: pointer;
background-color: rgb(54, 45, 89);
border-radius: 4px;
border: none;
color: white;
font-size: 1em;
margin: 1em;
transition: all 0.25s ease-in-out;
}
button:hover {
background-color: #8c5393;
box-shadow: 4px;
box-shadow: 0px 0px 15px 2px rgba(140, 83, 147, 0.5);
}
button:active {
background-color: #c73852;
}
</style>

View File

@ -0,0 +1,6 @@
// This is just a very simple API route that throws an example error.
// Feel free to delete this file and the entire sentry route.
export const GET = async () => {
throw new Error("Sentry Example API Route Error");
};

View File

@ -1,22 +1,22 @@
<script lang="ts">
import ServicesLayout from '$lib/components/services/ServicesLayout.svelte';
import type { Service } from '$lib/types/service';
let id: string;
export const id = '';
// Locally populated services
let services: Service[] = [
{
category: 'Permanentní make-up',
items: [
{ name: 'Obočí Pudrové, Ombré', id: 'oboci',price: 3000, duration: 2.5 },
{ name: 'Horní linky - meziřasové přirozené', id: 'linky', price: 2000, duration: 2 },
{ name: 'Klasické linky - s ocáskem', id: 'classic-linky', price: 3000, duration: 2.5 },
{ name: 'Klasické linky - s ocáskem + spodní linky', id: 'classic-linky+spodni', price: 3500, duration: 2.5 },
{ name: 'Rty - kontura', id: 'rty', price: 2500, duration: 2 },
{ name: '3D Rty (kontura a stínování), Full Lips (plné rty)', id: '3d-rty', price: 3500, duration: 2.5 },
{ name: 'Aquarelle Lips (přirodní stínování, bez kontury)', id: 'aquarelle', price: 3000, duration: 2 },
{ name: 'První korekce po aplikaci pmu max. do 3 měsíců', id: 'korekce', price: 1000, duration: 1.5 },
{ name: 'Oprava práce obočí jiného salonu', id: 'oprava-oboci', price: 'na domluvě', duration: 'na domluvě' }
{ name: 'Obočí Pudrové, Ombré', description: 'Diagnostika pleti, odlíčení tonizace', id: 'oboci',price: 3000, duration: 2.5 },
{ name: 'Horní linky - meziřasové přirozené', description: 'Diagnostika pleti, odlíčení tonizace', id: 'linky', price: 2000, duration: 2 },
{ name: 'Klasické linky - s ocáskem', description: 'Diagnostika pleti, odlíčení tonizace', id: 'classic-linky', price: 3000, duration: 2.5 },
{ name: 'Klasické linky - s ocáskem + spodní linky', description: 'Diagnostika pleti, odlíčení tonizace', id: 'classic-linky+spodni', price: 3500, duration: 2.5 },
{ name: 'Rty - kontura', description: 'Diagnostika pleti, odlíčení tonizace', id: 'rty', price: 2500, duration: 2 },
{ name: '3D Rty (kontura a stínování), Full Lips (plné rty)', description: 'Diagnostika pleti, odlíčení tonizace', id: '3d-rty', price: 3500, duration: 2.5 },
{ name: 'Aquarelle Lips (přirodní stínování, bez kontury)', description: 'Diagnostika pleti, odlíčení tonizace', id: 'aquarelle', price: 3000, duration: 2 },
{ name: 'První korekce po aplikaci pmu max. do 3 měsíců', description: 'Diagnostika pleti, odlíčení tonizace', id: 'korekce', price: 1000, duration: 1.5 },
{ name: 'Oprava práce obočí jiného salonu', description: 'Diagnostika pleti, odlíčení tonizace', id: 'oprava-oboci', price: 'na domluvě', duration: 'na domluvě' }
],
},
{
@ -26,41 +26,41 @@
{ name: 'ZÁKLADNÍ + CALM PLUS', description:'Diagnostika pleti, odlíčení tonizace, úprava obočí (vosk+pinzeta), barvení řas a obočí, depilace horní ret/brada, enzymatický peeling, kavitační peeling -ultarzvuková špachtle, séra dle typu pleti, masky (tvář,krk,dekolt), závěrečná péče (oční a denní krém)', id: 'zakladni-calm-plus', price: 600, duration: 1 },
{ name: 'RELAXAČNÍ', id: 'relaxacni', description:'Diagnostika pleti, odlíčení tonizace, úprava obočí (vosk+pinzeta), barvení řas a obočí, depilace horní ret/brada, enzymatický peeling, kavitační peeling -ultarzvuková špachtle, séra, masáž relaxační (tvář,krk dekolt), masky (tvář,krk,dekolt), závěrečná péče (oční a denní krém)', price: 690, duration: 1.5 },
{ name: 'LIFTINGOVÉ - ANTI AGE', description:'Diagnostika pleti, odlíčení tonizace, úprava obočí (vosk+pinzeta), barvení řas a obočí, depilace horní ret/brada, enzymatický peeling, kavitační peeling -ultarzvuková špachtle, vacupres ošetření lifting obličeje krku a dekoltu, séra, masky (tvář,krk,dekolt), alginátová maska, závěrečná péče (oční a denní krém)', id: 'liftingove-anti-age', price: 690, duration: 1.5 },
{ name: 'CLEAR + ANTI AKNÉ', id: 'clear-anti-akne', price: 690, duration: 1.5 },
{ name: 'Odlíčení + sérum + alginátová maska (PROJASNĚNÍ)', id: 'odliceni-serum-alginatova-maska', price: 300, duration: 1 },
{ name: 'CLEAR + ANTI AKNÉ', description: 'Diagnostika pleti, odlíčení tonizace', id: 'clear-anti-akne', price: 690, duration: 1.5 },
{ name: 'Odlíčení + sérum + alginátová maska (PROJASNĚNÍ)', description: 'Diagnostika pleti, odlíčení tonizace', id: 'odliceni-serum-alginatova-maska', price: 300, duration: 1 },
]
},
{
category: 'DALŠÍ VELMI OBLÍBENÉ SLUŽBY',
items: [
{ name: 'Lifting řas booster (botox)', id: 'lifting-ras-booster', price: 500, duration: 1 },
{ name: 'Laminace obočí + výživa', id: 'laminace-oboci-vyziva', price: 500, duration: 1 },
{ name: 'Úprava obočí (tvar + barva)', id: 'uprava-oboci-tvar-barva', price: 250, duration: 1 },
{ name: 'Úprava obočí + řasy (tvar + barvení)', id: 'uprava-oboci-rasy-tvar-barveni', price: 300, duration: 1 },
{ name: 'Lifting řas booster (botox)', description: 'Diagnostika pleti, odlíčení tonizace', id: 'lifting-ras-booster', price: 500, duration: 1 },
{ name: 'Laminace obočí + výživa', description: 'Diagnostika pleti, odlíčení tonizace', id: 'laminace-oboci-vyziva', price: 500, duration: 1 },
{ name: 'Úprava obočí (tvar + barva)', description: 'Diagnostika pleti, odlíčení tonizace', id: 'uprava-oboci-tvar-barva', price: 250, duration: 1 },
{ name: 'Úprava obočí + řasy (tvar + barvení)', description: 'Diagnostika pleti, odlíčení tonizace', id: 'uprava-oboci-rasy-tvar-barveni', price: 300, duration: 1 },
]
},
{
category: 'Depilace',
items: [
{ name: 'Depilace Horní ret', id: 'depilace-horni-ret', price: 80, duration: 0.5 },
{ name: 'Depilace Brada', id: 'depilace-brada', price: 80, duration: 0.5 },
{ name: 'Depilace Obočí', id: 'depilace-oboci', price: 150, duration: 0.5 },
{ name: 'Depilace Tváře', id: 'depilace-tvare', price: 150, duration: 0.5 },
{ name: 'Depilace Podpaží', id: 'depilace-podpazi', price: 150, duration: 0.5 },
{ name: 'Depilace Předloktí', id: 'depilace-predlokti', price: 200, duration: 0.5 },
{ name: 'Depilace Celé ruce', id: 'depilace-cele-ruce', price: 350, duration: 1 },
{ name: 'Depilace Lýtka', id: 'depilace-lytka', price: 350, duration: 1 },
{ name: 'Depilace Celé nohy', id: 'depilace-cele-nohy', price: 500, duration: 1 }
{ name: 'Depilace Horní ret', description: 'Diagnostika pleti, odlíčení tonizace', id: 'depilace-horni-ret', price: 80, duration: 0.5 },
{ name: 'Depilace Brada', description: 'Diagnostika pleti, odlíčení tonizace', id: 'depilace-brada', price: 80, duration: 0.5 },
{ name: 'Depilace Obočí', description: 'Diagnostika pleti, odlíčení tonizace', id: 'depilace-oboci', price: 150, duration: 0.5 },
{ name: 'Depilace Tváře', description: 'Diagnostika pleti, odlíčení tonizace', id: 'depilace-tvare', price: 150, duration: 0.5 },
{ name: 'Depilace Podpaží', description: 'Diagnostika pleti, odlíčení tonizace', id: 'depilace-podpazi', price: 150, duration: 0.5 },
{ name: 'Depilace Předloktí', description: 'Diagnostika pleti, odlíčení tonizace', id: 'depilace-predlokti', price: 200, duration: 0.5 },
{ name: 'Depilace Celé ruce', description: 'Diagnostika pleti, odlíčení tonizace', id: 'depilace-cele-ruce', price: 350, duration: 1 },
{ name: 'Depilace Lýtka', description: 'Diagnostika pleti, odlíčení tonizace', id: 'depilace-lytka', price: 350, duration: 1 },
{ name: 'Depilace Celé nohy', description: 'Diagnostika pleti, odlíčení tonizace', id: 'depilace-cele-nohy', price: 500, duration: 1 }
]
},
{
category: 'Vakuslim 48 - zeštíhlující procedura',
items: [
{ name: 'Ošetření horních končetin', id: 'vakuslim-48-zestihlujici-procedura-horni-koncetiny', price: 600, duration: 2 },
{ name: '1 ošetření spodní části těla (břicho, boky, dolní končetiny)', id: 'vakuslim-48-zestihlujici-procedura-spodni-cast-tela', price: 800, duration: 2 },
{ name: '1 ošetření komplet horní-dolní části', id: 'vakuslim-48-zestihlujici-procedura-komplet-horni-dolni-cast', price: 1200, duration: 2 },
{ name: '6 ošetření předplatné kompet', id: 'vakuslim-48-zestihlujici-procedura-6-o-setreni-predplatne-kompet', price: 6600, duration: 2 },
{ name: '12 ošetření předplatné komplet', id: 'vakuslim-48-zestihlujici-procedura-12-o-setreni-predplatne-komplet', price: 11000, duration: 2 }
{ name: 'Ošetření horních končetin', description: 'Diagnostika pleti, odlíčení tonizace', id: 'vakuslim-48-zestihlujici-procedura-horni-koncetiny', price: 600, duration: 2 },
{ name: '1 ošetření spodní části těla (břicho, boky, dolní končetiny)', description: 'Diagnostika pleti, odlíčení tonizace', id: 'vakuslim-48-zestihlujici-procedura-spodni-cast-tela', price: 800, duration: 2 },
{ name: '1 ošetření komplet horní-dolní části', description: 'Diagnostika pleti, odlíčení tonizace', id: 'vakuslim-48-zestihlujici-procedura-komplet-horni-dolni-cast', price: 1200, duration: 2 },
{ name: '6 ošetření předplatné kompet', description: 'Diagnostika pleti, odlíčení tonizace', id: 'vakuslim-48-zestihlujici-procedura-6-o-setreni-predplatne-kompet', price: 6600, duration: 2 },
{ name: '12 ošetření předplatné komplet', description: 'Diagnostika pleti, odlíčení tonizace', id: 'vakuslim-48-zestihlujici-procedura-12-o-setreni-predplatne-komplet', price: 11000, duration: 2 }
]
}
];

View File

View File

View File

@ -0,0 +1,74 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Service",
"type": "object",
"required": [
"category",
"items"
],
"properties": {
"category": {
"type": "string",
"description": "Name of the category of services",
"minLength": 4,
"default": "Permanentní makeup"
},
"items": {
"title": "Items under the category",
"type": "array",
"items": {
"type": "object",
"title": "Items",
"properties": {
"name": {
"type": "string",
"description": "Name of the service"
},
"description": {
"title": "Description",
"type": "string",
"description": "description :D",
"default": "A description of a description"
},
"id": {
"type": "string",
"description": "a short handle used in urls and on cal.com",
"default": "pmu",
"minLength": 3
},
"price": {
"title": "Price",
"description": "Price of the product",
"oneOf": [
{
"type": "number",
"description": "Price of the service as a number",
"default": 300
},
{
"type": "string",
"description": "Price of the service as a text description (e.g., 'Free')",
"default": "na dohode"
}
]
},
"duration": {
"title": "Duration",
"description": "How long will the procedure take? (can be on demand or specific amount)",
"oneOf": [
{
"type": "number",
"description": "Duration of the service in minutes (e.g., 60)",
"minimum": 15
},
{
"type": "string",
"description": "Duration of the service as a text description (e.g., 'on demand')"
}
]
}
}
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

35
svelte.config.js Normal file
View File

@ -0,0 +1,35 @@
import adapter from '@sveltejs/adapter-node';
import preprocess from 'svelte-preprocess';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://github.com/sveltejs/svelte-preprocess
// for more information about preprocessors
preprocess: [
preprocess({
postcss: true
}),
],
kit: {
adapter: adapter({
out: 'build',
precompress: false
}),
// Aliases need tsconfig explicit inclusion
alias: {
$lib: './src/lib',
$root: './',
$src: './src',
$routes: './src/routes',
},
env: {
publicPrefix: "PUBLIC_",
},
// https://kit.svelte.dev/docs/configuration#alias
},
};
export default config;

View File

@ -1,32 +0,0 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/kit/vite';
import preprocess from 'svelte-preprocess';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { Config } from '@sveltejs/kit';
const __dirname = dirname(fileURLToPath(import.meta.url));
const config: Config = {
// Consult https://github.com/sveltejs/svelte-preprocess
// for more information about preprocessors
preprocess: [
preprocess({
postcss: true
}),
vitePreprocess({
style: {
css: {
postcss: join(__dirname, 'postcss.config.cjs')
}
}
})
],
kit: {
adapter: adapter()
// https://kit.svelte.dev/docs/configuration#alias
}
};
export default config;

41
tests/ValidateServices.js Normal file
View File

@ -0,0 +1,41 @@
import Ajv from 'ajv';
import fs from 'fs';
import path from 'path';
const serviceSchema = JSON.parse(fs.readFileSync('./src/routes/sluzby/schema.json', 'utf-8'));
function validateFile(filePath) {
const ajv = new Ajv();
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
const validate = ajv.compile(serviceSchema);
const valid = validate(data);
if (!valid) {
throw new Error(`Invalid service data in ${filePath}: ${JSON.stringify(validate.errors)}`);
}
}
function scanDirectory(directory) {
const files = fs.readdirSync(directory);
for (const file of files) {
const filePath = path.join(directory, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
scanDirectory(filePath);
} else if (path.extname(filePath) === '.json') {
validateFile(filePath);
}
}
}
export function validateServices() {
try {
scanDirectory('./src/content/services');
console.log('All services validated successfully!');
} catch (error) {
console.error(`Error validating services: ${error}`);
process.exit(1); // Exit with non-zero code to signal failure
}
}
validateServices();

View File

@ -8,8 +8,27 @@
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true
}
"strict": true,
// custom compiler options
"noEmit": true,
"target": "ES2018",
"module": "ES2022",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
},
"include": [
"./scripts/**/*",
"./test/*.js",
"./*.js",
"./src/**/*.d.ts",
"./src/**/*.js",
"./src/**/*.svelte",
"./src/**/*.ts",
".svelte-kit/ambient.d.ts",
".svelte-kit/types/**/$types.d.ts",
"./csp-directives.ts"
],
"exclude": ["node_modules/*"]
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes

View File

@ -12,15 +12,23 @@ export default defineConfig({
envPrefix: "PUBLIC_",
plugins: [sentrySvelteKit({
sourceMapsUploadOptions: {
org: "none-b0c3fadae",
project: "javascript-sveltekit"
org: "mattmor",
project: "kkosmetickysalon",
//telemetry off
telemetry: false,
}
}), sveltekit(), purgeCss({
}),
sveltekit(),
purgeCss({
safelist: {
// any selectors that begin with "hljs-" will not be purged
greedy: [/^hljs-/],
},
})],
})],
define: {
'process.env.VITE_BUILD_TIME': JSON.stringify(new Date().toISOString()),
},
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
},