Compare commits

..

6 Commits

Author SHA1 Message Date
nub31
8ad6fda412 ... 2026-03-16 23:35:26 +01:00
nub31
7aea328d4f pages 2026-03-16 23:14:51 +01:00
nub31
3bf281c7a7 ... 2026-03-16 22:00:24 +01:00
nub31
d677582757 images 2026-03-16 21:49:08 +01:00
nub31
ede682a047 ... 2026-03-16 21:49:02 +01:00
nub31
199868af38 init 2026-03-16 21:37:16 +01:00
72 changed files with 4510 additions and 0 deletions

24
.gitignore vendored
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

20
index.html Normal file
View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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=Inter:wght@400;500;600;700;800;900&family=Roboto+Slab:wght@400;500;600;700;800;900&display=swap"
rel="stylesheet"
/>
<title>Minibusservice</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1886
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "minibusservice.no",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^0.0.0-insiders.aaaefe8",
"@tsconfig/svelte": "^5.0.8",
"@types/node": "^24.12.0",
"svelte": "^5.53.7",
"svelte-check": "^4.4.5",
"tailwindcss": "^0.0.0-insiders.aaaefe8",
"typescript": "~5.9.3",
"vite": "^8.0.0"
},
"dependencies": {
"leaflet": "^1.9.4",
"tailwind-merge": "^3.5.0"
}
}

10
public/favicon.svg Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
y="0px" viewBox="0 0 1920 1920" style="enable-background:new 0 0 1920 1920;" xml:space="preserve">
<g>
<path fill="#ff1d25"
d="M780.1,1364.81l88.49-508.71l-323.41,419.08h-88.42L280.44,867l-87.78,497.81H8.56l149.5-847.85h162.3 l224.43,526.88l405.4-526.88h161.09l-147.08,847.85H780.1z" />
<path fill="#ff1d25"
d="M1113.38,1281.23l92.67-147.77c62.28,52.08,162.46,88.42,264.2,88.42c116.28,0,170.35-38.76,179.53-90.84 c27.98-158.67-484.21-49.66-428.68-364.58c25.42-144.14,162.83-264.05,403.87-264.05c106.59,0,211.11,25.44,281.08,75.1 l-86.83,148.98c-70.83-44.81-148.13-66.62-223.23-66.62c-116.28,0-168.78,43.6-178.18,96.9 c-27.55,156.25,484.42,48.45,429.53,359.73c-24.99,141.71-163.83,262.83-406.08,262.83 C1306.83,1379.34,1178.22,1339.37,1113.38,1281.23z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 955 B

BIN
public/map_marker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

40
src/App.svelte Normal file
View File

@@ -0,0 +1,40 @@
<script lang="ts">
import Router, { route } from "./lib/components/Router.svelte";
import "./app.css";
import HomePage from "./pages/HomePage.svelte";
import ToastProvider from "./lib/components/toast/ToastProvider.svelte";
import Navigation from "./Navigation.svelte";
import Footer from "./Footer.svelte";
import TaxiPage from "./pages/TaxiPage.svelte";
import BusPage from "./pages/BusPage.svelte";
import ContactPage from "./pages/ContactPage.svelte";
import AccessibilityPage from "./pages/AccessibilityPage.svelte";
route("/", HomePage);
route("/taxi", TaxiPage);
route("/bus", BusPage);
route("/contact", ContactPage);
route("/accessibility", AccessibilityPage);
</script>
<div class="flex min-h-screen flex-col bg-primary-200 text-contrast-900">
<Navigation>
<main class="grow px-4 pb-16 pt-8">
<div class="mx-auto max-w-7xl">
<Router>
{#snippet notfound()}
<h1>Not found</h1>
<p>
Sorry, the page you are looking for does not exist.
</p>
<p>Return to <a href="/">home</a>.</p>
{/snippet}
</Router>
</div>
</main>
<Footer />
</Navigation>
</div>
<ToastProvider />

31
src/Footer.svelte Normal file
View File

@@ -0,0 +1,31 @@
<script lang="ts">
import Link from "./lib/components/link/Link.svelte";
import { Routes } from "./lib/global/routes";
</script>
<footer
class="flex flex-col items-center border-t-2 border-contrast-900 bg-contrast-900 px-2 py-8 text-primary-200 dark:border-primary-300 dark:bg-primary-100 dark:text-contrast-900"
>
<div
class="flex flex-row flex-wrap justify-center gap-8 text-center md:text-lg"
>
{#each Routes.footerRoutes as section}
<ul class="flex-1">
<li>
<h6 class="font-semibold">{section.text}</h6>
</li>
{#each section.routes as route}
<li>
<Link target={route.target} href={route.url}>
{route.text}
</Link>
</li>
{/each}
</ul>
{/each}
</div>
<div class="mt-8 text-center font-semibold">Org nr: 816 230 942</div>
<div class="mt-2 text-center font-semibold">
© 2026 minibusservice.no - All Rights Reserved.
</div>
</footer>

138
src/Navigation.svelte Normal file
View File

@@ -0,0 +1,138 @@
<script lang="ts">
import Logo from "./lib/components/icons/logo/Logo.svelte";
import sunIcon from "./assets/icons/sun.svg";
import moonIcon from "./assets/icons/moon.svg";
import { slide } from "svelte/transition";
import Toggle from "./lib/components/Toggle.svelte";
import { darkTheme } from "./lib/stores/theme";
import { Routes } from "./lib/global/routes";
import { isCurrentPath } from "./lib/components/Router.svelte";
let sidebarOpen = false;
function toggleMenuOpen() {
sidebarOpen = !sidebarOpen;
}
</script>
<header
class="sticky top-0 z-20 flex h-16 justify-center border-b-2 border-contrast-100 bg-primary-100 shadow-lg"
>
<div
class="flex w-full max-w-7xl flex-row items-center justify-between p-4"
>
<a class="h-full grow" href="/">
<Logo />
</a>
<ul
class="hidden flex-row justify-end gap-10 font-medium uppercase md:text-lg lg:flex"
>
{#each Routes.topbarRoutes as route}
<li>
<a
class:border-b-2={isCurrentPath(route.url)}
class="border-b-0 border-accent transition-colors hover:text-accent"
href={route.url}
target={route.target}
>
{route.text}
</a>
</li>
{/each}
</ul>
<div
class="ml-12 flex aspect-square h-full items-center justify-center p-1"
>
<button
class="hamburger-icon relative inline-block h-full w-full cursor-pointer text-contrast-100"
aria-label="open sidebar"
class:open={sidebarOpen}
on:click={toggleMenuOpen}
>
<div
class="line absolute top-0 h-0.5 w-full origin-top-left rounded-full bg-contrast-900 transition-all"
></div>
<div
class="line middle absolute top-1/2 h-0.5 w-full -translate-y-1/2 rounded-full bg-contrast-900 transition-all delay-100"
></div>
<div
class="line absolute bottom-0 h-0.5 w-full origin-bottom-left rounded-full bg-contrast-900 transition-all"
></div>
</button>
</div>
</div>
{#if sidebarOpen}
<aside>
<nav
transition:slide={{ axis: "x" }}
class="fixed bottom-14 right-0 top-16 z-10 flex max-w-full flex-col overflow-auto border-l-2 border-contrast-100 bg-primary-100 px-4 py-8 shadow-lg sm:bottom-0"
>
<ul
class="flex grow flex-col gap-2 pr-20 text-lg font-medium lg:text-xl"
>
{#each Routes.sidebarRoutes as route}
<li>
<a
class:border-b-2={`/${window.location.pathname.split("/")[1]}` ==
route.url}
class="border-b-0 border-accent transition-colors hover:text-accent"
href={route.url}
target={route.target}
>
{route.text}
</a>
</li>
{/each}
</ul>
<div class="flex w-fit flex-col items-center gap-3">
<Toggle
invertColor={true}
checked={$darkTheme}
leftIcon={sunIcon}
rightIcon={moonIcon}
on:change={() => {
$darkTheme = $darkTheme = !$darkTheme;
}}
/>
</div>
</nav>
</aside>
{/if}
</header>
<slot />
<!-- Mobile navigation -->
<nav
class="sticky bottom-0 z-10 flex h-14 flex-row items-center justify-around border-t-2 border-contrast-100 bg-primary-100 px-2 text-xs shadow-t-lg sm:hidden"
>
{#each Routes.topbarRoutes as route}
<a
href={route.url}
target={route.target}
class={`flex flex-col items-center decoration-accent transition-opacity ${
`/${window.location.pathname.split("/")[1]}` == route.url
? "underline opacity-100"
: "opacity-50"
}`}
>
<img src={route.icon} alt={route.text} class="h-6 dark:invert" />
<span> {route.text} </span>
</a>
{/each}
</nav>
<style>
.hamburger-icon.open .line:first-child {
transform: rotate(45deg) translateX(15%);
}
.hamburger-icon.open .line:last-child {
transform: rotate(-45deg) translateX(15%);
}
.hamburger-icon.open .line.middle {
width: 0;
transition: transform 0.1s;
}
</style>

150
src/app.css Normal file
View File

@@ -0,0 +1,150 @@
@import "tailwindcss";
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
:root {
--mb-color-primary-100: rgb(250 250 250);
--mb-color-primary-200: rgb(240 240 240);
--mb-color-primary-300: rgb(230 230 230);
--mb-color-primary-400: rgb(220 220 220);
--mb-color-primary-500: rgb(210 210 210);
--mb-color-primary-600: rgb(200 200 200);
--mb-color-primary-700: rgb(190 190 190);
--mb-color-primary-800: rgb(180 180 180);
--mb-color-primary-900: rgb(170 170 170);
--mb-color-contrast-100: rgb(220 220 220);
--mb-color-contrast-200: rgb(200 200 200);
--mb-color-contrast-300: rgb(180 180 180);
--mb-color-contrast-400: rgb(160 160 160);
--mb-color-contrast-500: rgb(140 140 140);
--mb-color-contrast-600: rgb(110 110 110);
--mb-color-contrast-700: rgb(80 80 80);
--mb-color-contrast-800: rgb(60 60 60);
--mb-color-contrast-900: rgb(30 30 30);
}
[data-theme="dark"] {
--mb-color-primary-100: rgb(28 28 28);
--mb-color-primary-200: rgb(32 32 32);
--mb-color-primary-300: rgb(38 38 38);
--mb-color-primary-400: rgb(44 44 44);
--mb-color-primary-500: rgb(50 50 50);
--mb-color-primary-600: rgb(56 56 56);
--mb-color-primary-700: rgb(62 62 62);
--mb-color-primary-800: rgb(68 68 68);
--mb-color-primary-900: rgb(74 74 74);
--mb-color-contrast-100: rgb(25 25 25);
--mb-color-contrast-200: rgb(50 50 50);
--mb-color-contrast-300: rgb(80 80 80);
--mb-color-contrast-400: rgb(110 110 110);
--mb-color-contrast-500: rgb(140 140 140);
--mb-color-contrast-600: rgb(160 160 160);
--mb-color-contrast-700: rgb(180 180 180);
--mb-color-contrast-800: rgb(200 200 200);
--mb-color-contrast-900: rgb(220 220 220);
}
@theme inline {
--color-accent: rgb(255 29 37);
--color-primary-100: var(--mb-color-primary-100);
--color-primary-200: var(--mb-color-primary-200);
--color-primary-300: var(--mb-color-primary-300);
--color-primary-400: var(--mb-color-primary-400);
--color-primary-500: var(--mb-color-primary-500);
--color-primary-600: var(--mb-color-primary-600);
--color-primary-700: var(--mb-color-primary-700);
--color-primary-800: var(--mb-color-primary-800);
--color-primary-900: var(--mb-color-primary-900);
--color-contrast-100: var(--mb-color-contrast-100);
--color-contrast-200: var(--mb-color-contrast-200);
--color-contrast-300: var(--mb-color-contrast-300);
--color-contrast-400: var(--mb-color-contrast-400);
--color-contrast-500: var(--mb-color-contrast-500);
--color-contrast-600: var(--mb-color-contrast-600);
--color-contrast-700: var(--mb-color-contrast-700);
--color-contrast-800: var(--mb-color-contrast-800);
--color-contrast-900: var(--mb-color-contrast-900);
--shadow-t-lg: 0px -4px 6px -1px rgba(0, 0, 0, 0.1);
}
@layer base {
*,
*::before,
*::after {
font-family: Inter, sans-serif;
box-sizing: border-box;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family:
Roboto Slab,
serif;
}
body {
@apply bg-primary-100;
text-rendering: optimizeSpeed;
line-height: 1.5;
}
a:not([class]) {
text-decoration-skip-ink: auto;
}
img,
picture,
video,
svg {
max-width: 100%;
display: block;
}
input,
button,
textarea,
select {
font: inherit;
}
@media (prefers-reduced-motion: reduce) {
html:focus-within {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
pre {
@apply rounded-md bg-cyan-900 p-8 text-gray-200;
}
.card {
@apply rounded-md border-2 border-contrast-100 bg-primary-100;
}
input,
select,
textarea {
@apply mb-3 mt-1 w-full rounded-md border-2 border-contrast-100 bg-primary-100 px-3 py-2;
}
label > span {
@apply text-base text-contrast-800 md:text-lg;
}
}

View File

@@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="bus" class="svg-inline--fa fa-bus text-2xl" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 0C390.4 0 480 35.2 480 80V96l0 32c17.7 0 32 14.3 32 32v64c0 17.7-14.3 32-32 32l0 160c0 17.7-14.3 32-32 32v32c0 17.7-14.3 32-32 32H384c-17.7 0-32-14.3-32-32V448H160v32c0 17.7-14.3 32-32 32H96c-17.7 0-32-14.3-32-32l0-32c-17.7 0-32-14.3-32-32l0-160c-17.7 0-32-14.3-32-32V160c0-17.7 14.3-32 32-32h0V96h0V80C32 35.2 121.6 0 256 0zM96 160v96c0 17.7 14.3 32 32 32H240V128H128c-17.7 0-32 14.3-32 32zM272 288H384c17.7 0 32-14.3 32-32V160c0-17.7-14.3-32-32-32H272V288zM112 400c17.7 0 32-14.3 32-32s-14.3-32-32-32s-32 14.3-32 32s14.3 32 32 32zm288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32s-32 14.3-32 32s14.3 32 32 32zM352 80c0-8.8-7.2-16-16-16H176c-8.8 0-16 7.2-16 16s7.2 16 16 16H336c8.8 0 16-7.2 16-16z"></path></svg>

After

Width:  |  Height:  |  Size: 923 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path fill-rule="evenodd" d="M5.337 21.718a6.707 6.707 0 01-.533-.074.75.75 0 01-.44-1.223 3.73 3.73 0 00.814-1.686c.023-.115-.022-.317-.254-.543C3.274 16.587 2.25 14.41 2.25 12c0-5.03 4.428-9 9.75-9s9.75 3.97 9.75 9c0 5.03-4.428 9-9.75 9-.833 0-1.643-.097-2.417-.279a6.721 6.721 0 01-4.246.997z" clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 429 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>

After

Width:  |  Height:  |  Size: 247 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path
d="M11.47 3.84a.75.75 0 011.06 0l8.69 8.69a.75.75 0 101.06-1.06l-8.689-8.69a2.25 2.25 0 00-3.182 0l-8.69 8.69a.75.75 0 001.061 1.06l8.69-8.69z"
/>
<path
d="M12 5.432l8.159 8.159c.03.03.06.058.091.086v6.198c0 1.035-.84 1.875-1.875 1.875H15a.75.75 0 01-.75-.75v-4.5a.75.75 0 00-.75-.75h-3a.75.75 0 00-.75.75V21a.75.75 0 01-.75.75H5.625a1.875 1.875 0 01-1.875-1.875v-6.198a2.29 2.29 0 00.091-.086L12 5.43z"
/>
</svg>

After

Width:  |  Height:  |  Size: 547 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path fill-rule="evenodd" d="M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.701-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z" clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 379 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path fill-rule="evenodd" d="M4.125 3C3.089 3 2.25 3.84 2.25 4.875V18a3 3 0 003 3h15a3 3 0 01-3-3V4.875C17.25 3.839 16.41 3 15.375 3H4.125zM12 9.75a.75.75 0 000 1.5h1.5a.75.75 0 000-1.5H12zm-.75-2.25a.75.75 0 01.75-.75h1.5a.75.75 0 010 1.5H12a.75.75 0 01-.75-.75zM6 12.75a.75.75 0 000 1.5h7.5a.75.75 0 000-1.5H6zm-.75 3.75a.75.75 0 01.75-.75h7.5a.75.75 0 010 1.5H6a.75.75 0 01-.75-.75zM6 6.75a.75.75 0 00-.75.75v3c0 .414.336.75.75.75h3a.75.75 0 00.75-.75v-3A.75.75 0 009 6.75H6z" clip-rule="evenodd" />
<path d="M18.75 6.75h1.875c.621 0 1.125.504 1.125 1.125V18a1.5 1.5 0 01-3 0V6.75z" />
</svg>

After

Width:  |  Height:  |  Size: 701 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M3 6c-1.11 0-2 .89-2 2v7h2a3 3 0 0 0 3 3a3 3 0 0 0 3-3h6a3 3 0 0 0 3 3a3 3 0 0 0 3-3h2V8c0-1.11-.89-2-2-2H3m-.5 1.5h4V10h-4V7.5m5.5 0h4V10H8V7.5m5.5 0h4V10h-4V7.5m5.5 0h2.5V13L19 11V7.5m-13 6A1.5 1.5 0 0 1 7.5 15A1.5 1.5 0 0 1 6 16.5A1.5 1.5 0 0 1 4.5 15A1.5 1.5 0 0 1 6 13.5m12 0a1.5 1.5 0 0 1 1.5 1.5a1.5 1.5 0 0 1-1.5 1.5a1.5 1.5 0 0 1-1.5-1.5a1.5 1.5 0 0 1 1.5-1.5Z"/></svg>

After

Width:  |  Height:  |  Size: 490 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="m16 6l3 4h2c1.11 0 2 .89 2 2v3h-2a3 3 0 0 1-3 3a3 3 0 0 1-3-3H9a3 3 0 0 1-3 3a3 3 0 0 1-3-3H1v-3c0-1.11.89-2 2-2l3-4h10m-5.5 1.5H6.75L4.86 10h5.64V7.5m1.5 0V10h5.14l-1.89-2.5H12m-6 6A1.5 1.5 0 0 0 4.5 15A1.5 1.5 0 0 0 6 16.5A1.5 1.5 0 0 0 7.5 15A1.5 1.5 0 0 0 6 13.5m12 0a1.5 1.5 0 0 0-1.5 1.5a1.5 1.5 0 0 0 1.5 1.5a1.5 1.5 0 0 0 1.5-1.5a1.5 1.5 0 0 0-1.5-1.5Z"/></svg>

After

Width:  |  Height:  |  Size: 481 B

3
src/assets/icons/sun.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path d="M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z" />
</svg>

After

Width:  |  Height:  |  Size: 767 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path fill-rule="evenodd" d="M1.5 6.375c0-1.036.84-1.875 1.875-1.875h17.25c1.035 0 1.875.84 1.875 1.875v3.026a.75.75 0 01-.375.65 2.249 2.249 0 000 3.898.75.75 0 01.375.65v3.026c0 1.035-.84 1.875-1.875 1.875H3.375A1.875 1.875 0 011.5 17.625v-3.026a.75.75 0 01.374-.65 2.249 2.249 0 000-3.898.75.75 0 01-.374-.65V6.375zm15-1.125a.75.75 0 01.75.75v.75a.75.75 0 01-1.5 0V6a.75.75 0 01.75-.75zm.75 4.5a.75.75 0 00-1.5 0v.75a.75.75 0 001.5 0v-.75zm-.75 3a.75.75 0 01.75.75v.75a.75.75 0 01-1.5 0v-.75a.75.75 0 01.75-.75zm.75 4.5a.75.75 0 00-1.5 0V18a.75.75 0 001.5 0v-.75zM6 12a.75.75 0 01.75-.75H12a.75.75 0 010 1.5H6.75A.75.75 0 016 12zm.75 2.25a.75.75 0 000 1.5h3a.75.75 0 000-1.5h-3z" clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 815 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

BIN
src/assets/images/taxis.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

BIN
src/assets/images/van.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import Link from "./link/Link.svelte";
import MapPoint from "./MapPoint.svelte";
</script>
<div class="mt-2 flex flex-col gap-8 md:flex-row md:text-lg">
<div class="flex flex-1 flex-col">
<p class="block pt-2 font-medium opacity-70 md:hidden">
Spørsmål?<br />Kontakt oss via tlf, e-post eller via skjemaet under
</p>
<p class="hidden pt-2 font-medium opacity-70 md:block">
Spørsmål?<br />Kontakt oss via tlf, e-post eller via skjemaet til
høyre
</p>
<Link
class="w-fit font-bold text-accent hover:underline"
href="tel:45256161"
>
Tlf: +47 45 25 61 61
</Link>
<Link
class="w-fit font-bold text-accent hover:underline"
href="mailto:minibusstur@hotmail.com"
>
E-post: minibusstur@hotmail.com
</Link>
<MapPoint
class="card mt-5 min-h-140 grow overflow-hidden rounded-md border-2"
coordinates={[
{
latitude: 62.48303957042255,
longitude: 6.8108274964451745,
},
]}
/>
</div>
</div>

View File

@@ -0,0 +1,86 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import "leaflet/dist/leaflet.css";
import { twMerge } from "tailwind-merge";
interface MarkerPoint {
latitude: number;
longitude: number;
tooltip?: string;
}
export let coordinates: MarkerPoint[];
let mapElement: HTMLDivElement;
let map: L.Map | null = null;
let markers: L.Marker<any>[] = [];
var icon: L.Icon;
let L: typeof import("leaflet/index");
onMount(async () => {
L = await import("leaflet");
icon = L.icon({
iconUrl: "/map_marker.png",
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [-3, -76],
});
map = L.map(mapElement);
if (coordinates.length >= 1) {
map.setView(
[coordinates[0].latitude, coordinates[0].longitude],
15,
);
}
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 18,
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
id: "base",
}).addTo(map);
updateMarkers();
});
onDestroy(async () => {
map?.remove();
});
function addMarker(point: MarkerPoint) {
let marker = L.marker([point.latitude, point.longitude], {
icon: icon,
});
if (point.tooltip)
marker.bindTooltip(point.tooltip, {
direction: "bottom",
});
markers.push(marker);
map?.addLayer(marker);
}
function updateMarkers() {
if (L && map) {
markers.forEach((point) => map?.removeLayer(point));
markers = [];
coordinates.forEach(addMarker);
map?.flyToBounds(L.featureGroup(markers).getBounds(), {
duration: 1,
});
}
}
$: coordinates && updateMarkers();
</script>
<div
bind:this={mapElement}
class={twMerge("z-0 h-full w-full", $$restProps["class"])}
></div>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import Button from "./button/Button.svelte";
import { twMerge } from "tailwind-merge";
export let maxItems: number;
let pageParam = $page.url.searchParams.get("page");
let currentPage = 1;
if (pageParam) {
let pageParamAsNumber = Number(pageParam);
if (pageParamAsNumber) {
currentPage = pageParamAsNumber;
}
}
</script>
<section
class={twMerge("mt-8 flex justify-center gap-5", $$restProps["class"])}
>
{#if currentPage > 1}
<a
on:click={() =>
setTimeout(() => {
currentPage = currentPage - 1;
}, 10)}
href={`?page=${currentPage - 1}`}
>
<Button>Forrige</Button>
</a>
{/if}
{#if (currentPage + 1) * 10 - 10 < maxItems}
<a
on:click={() =>
setTimeout(() => {
currentPage = currentPage + 1;
}, 10)}
href={`?page=${currentPage + 1}`}
>
<Button>Neste</Button>
</a>
{/if}
</section>

View File

@@ -0,0 +1,249 @@
<script module lang="ts">
import { SvelteMap } from "svelte/reactivity";
import { onMount, type Component, type Snippet } from "svelte";
type RouteInfo = {
path: string;
pattern: RegExp;
paramNames: string[];
component: Component<{}>;
};
const routes = new SvelteMap<string, RouteInfo>();
let currentUrl = $state.raw(new URL(window.location.href));
const currentRoute = $derived.by(() => {
const normalizedPath = normalizePath(currentUrl.pathname);
for (const [_, routeInfo] of routes) {
if (normalizedPath.match(routeInfo.pattern)) {
return routeInfo;
}
}
return null;
});
const currentParams = $derived.by(() => {
if (!currentRoute) {
return {};
}
const match = currentUrl.pathname.match(currentRoute.pattern);
if (!match) {
return {};
}
const result: Record<string, string> = {};
currentRoute.paramNames.forEach((name, index) => {
result[name] = match[index + 1];
});
return result;
});
function normalizePath(path: string): string {
return path.replace(/\/+$/, "").trim();
}
function createRoutePattern(path: string): string {
const normalizedPath = normalizePath(path);
const escapedPath = normalizedPath.replace(/[.*+?^${}()|\\/]/g, "\\$&");
const pathPattern = escapedPath.replace(/\[([^\]]+)\]/g, "([^/]+)");
const regexPattern = `^${pathPattern}\\/?$`;
return regexPattern;
}
/**
* Registers a new route with the router.
*/
export function route(path: string, component: Component<{}>): void {
if (routes.has(path)) {
console.warn(`Route already registered for path: '${path}'.`);
}
const params = Array.from(path.matchAll(/\[([^\]]+)\]/g)).map(
(x) => x[1],
);
routes.set(path, {
pattern: new RegExp(createRoutePattern(path)),
path: path,
paramNames: params,
component: component,
});
}
export class RouteError extends Error {}
export class MissingRouteError extends RouteError {}
export class RouteFormatError extends RouteError {}
export function isCurrentPath(path: string): boolean {
if (!currentRoute) {
return false;
}
const match = path.match(currentRoute.pattern);
if (!match) {
return false;
}
return true;
}
/**
* Gets the value of a route parameter.
*/
export function paramString(name: string): string {
const value = currentParams[name];
if (!value) {
throw new MissingRouteError(
`Route does not have a parameter matching the name '${name}''`,
);
}
return value;
}
/**
* Gets the value of a route parameter as an integer.
*/
export function paramNumber(name: string): number {
const value = paramString(name);
const parsed = parseInt(value, 10);
if (Number.isNaN(parsed)) {
throw new RouteFormatError(
`parameter ${value} could not be parsed as an integer`,
);
}
return parsed;
}
/**
* Gets the value of a query parameter.
* If the query parameter is not present, the default value (if provided) will be used instead, otherwise, null.
*/
export function queryString(name: string): string | null;
export function queryString(name: string, defaultValue: string): string;
export function queryString(
name: string,
defaultValue?: string,
): string | null {
const value = currentUrl.searchParams.get(name);
if (value) {
return value;
}
return defaultValue ?? null;
}
/**
* Gets the value of a query parameter as an integer.
* If the query parameter cannot be parsed, the default value (if provided) will be used instead, otherwise, null.
*/
export function queryNumber(name: string): number | null;
export function queryNumber(name: string, defaultValue: number): number;
export function queryNumber(
name: string,
defaultValue?: number,
): number | null {
const value = currentUrl.searchParams.get(name);
if (value) {
const parsed = parseInt(value, 10);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
return defaultValue ?? null;
}
</script>
<script lang="ts">
interface Props {
notfound: Snippet;
}
let { notfound }: Props = $props();
onMount(() => {
if (window.navigation) {
function handleNavigate(event: NavigateEvent) {
event.intercept({
handler: async () => {
currentUrl = new URL(event.destination.url);
},
});
}
window.navigation.addEventListener("navigate", handleNavigate);
return () => {
window.navigation.removeEventListener(
"navigate",
handleNavigate,
);
};
} else {
function refresh() {
currentUrl = new URL(window.location.href);
}
function makeProxy(target: any) {
return new Proxy(target, {
apply: (target, thisArg, argArray) => {
const result = target.apply(thisArg, argArray);
refresh();
return result;
},
});
}
window.history.pushState = makeProxy(window.history.pushState);
window.history.replaceState = makeProxy(
window.history.replaceState,
);
window.history.go = makeProxy(window.history.go);
window.history.forward = makeProxy(window.history.forward);
window.history.back = makeProxy(window.history.back);
function handleClick(e: MouseEvent) {
if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) {
return;
}
const anchor = (e.target as Element)?.closest("a");
if (!(anchor instanceof HTMLAnchorElement)) return;
if (anchor.target === "_blank") return;
const targetUrl = new URL(anchor.href, document.baseURI);
const documentUrl = new URL(document.baseURI);
if (targetUrl.origin === documentUrl.origin) {
e.preventDefault();
history.pushState({}, "", targetUrl);
currentUrl = targetUrl;
}
}
window.addEventListener("popstate", refresh);
document.addEventListener("click", handleClick);
return () => {
window.removeEventListener("popstate", refresh);
document.removeEventListener("click", handleClick);
};
}
});
</script>
{#if currentRoute}
<currentRoute.component />
{:else}
{@render notfound()}
{/if}

View File

@@ -0,0 +1,34 @@
<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
<svg {...$$restProps} width="38" height="38" viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient x1="8.042%" y1="0%" x2="65.682%" y2="23.865%" id="a">
<stop stop-color="currentColor" stop-opacity="0" offset="0%" />
<stop stop-color="currentColor" stop-opacity=".631" offset="63.146%" />
<stop stop-color="currentColor" offset="100%" />
</linearGradient>
</defs>
<g fill="none" fill-rule="evenodd">
<g transform="translate(1 1)">
<path d="M36 18c0-9.94-8.06-18-18-18" id="Oval-2" stroke="url(#a)" stroke-width="2">
<animateTransform
attributeName="transform"
type="rotate"
from="0 18 18"
to="360 18 18"
dur="0.9s"
repeatCount="indefinite"
/>
</path>
<circle fill="currentColor" cx="36" cy="18" r="1">
<animateTransform
attributeName="transform"
type="rotate"
from="0 18 18"
to="360 18 18"
dur="0.9s"
repeatCount="indefinite"
/>
</circle>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,322 @@
<script lang="ts">
import Link from './link/Link.svelte';
</script>
<h2 class="mb-1 text-xl font-semibold">Innledning</h2>
<p class="mb-2">
Dette kjøpet er regulert av de nedenstående standard salgsbetingelser for forbrukerkjøp av varer
over Internett. Forbrukerkjøp over internett reguleres hovedsakelig av avtaleloven,
forbrukerkjøpsloven, markedsføringsloven, angrerettloven og ehandelsloven, og disse lovene gir
forbrukeren ufravikelige rettigheter. Lovene er tilgjengelig på
<Link href="www.lovdata.no">www.lovdata.no</Link>. Vilkårene i denne avtalen skal ikke forstås som
noen begrensning i de lovbestemte rettighetene, men oppstiller partenes viktigste rettigheter og
plikter for handelen.
</p>
<p class="mb-2">
Salgsbetingelsene er utarbeidet og anbefalt av Forbrukertilsynet. For en bedre forståelse av disse
salgsbetingelsene, se Forbrukertilsynets veileder her.
</p>
<h2 class="mb-1 text-xl font-semibold">1. Avtalen</h2>
<p class="mb-2">
Avtalen består av disse salgsbetingelsene, opplysninger gitt i bestillingsløsningen og eventuelt
særskilt avtalte vilkår. Ved eventuell motstrid mellom opplysningene, går det som særskilt er
avtalt mellom partene foran, så fremt det ikke strider mot ufravikelig lovgivning.
</p>
<p class="mb-2">
Avtalen vil i tillegg bli utfylt av relevante lovbestemmelser som regulerer kjøp av varer mellom
næringsdrivende og forbrukere.
</p>
<h2 class="mb-1 text-xl font-semibold">2. Partene</h2>
<p class="mb-2">
Selger er Tor Stian Stene, adresse: Solnørdalsvegen 40, 6240 Ørskog, e-post:
minibusservice@hotmail.com, tlf: 45266161, org.nr: 816 230 942, og betegnes i det følgende som
selgeren.
</p>
<p class="mb-2">
Kjøper er den forbrukeren som foretar bestillingen, og betegnes i det følgende som kjøperen.
</p>
<h2 class="mb-1 text-xl font-semibold">3. Pris</h2>
<p class="mb-2">
Den oppgitte prisen for varen og tjenester er den totale prisen kjøper skal betale. Denne prisen
inkluderer alle avgifter og tilleggskostnader. Ytterligere kostnader som selger før kjøpet ikke
har informert om, skal kjøper ikke bære.
</p>
<h2 class="mb-1 text-xl font-semibold">4. Avtaleinngåelse</h2>
<p class="mb-2">
Avtalen er bindende for begge parter når kjøperen har sendt sin bestilling til selgeren.
</p>
<p class="mb-2">
Avtalen er likevel ikke bindende hvis det har forekommet skrive- eller tastefeil i tilbudet fra
selgeren i bestillingsløsningen i nettbutikken eller i kjøperens bestilling, og den annen part
innså eller burde ha innsett at det forelå en slik feil.
</p>
<h2 class="mb-1 text-xl font-semibold">5. Betalingen</h2>
<p class="mb-2">
Selgeren kan kreve betaling for varen fra det tidspunkt den blir sendt fra selgeren til kjøperen.
</p>
<p class="mb-2">
Dersom kjøperen bruker kredittkort eller debetkort ved betaling, kan selgeren reservere
kjøpesummen på kortet ved bestilling. Kortet blir belastet samme dag som varen sendes.
</p>
<p class="mb-2">
Ved betaling med faktura, blir fakturaen til kjøperen utstedt ved forsendelse av varen.
Betalingsfristen fremgår av fakturaen og er på minimum 14 dager fra mottak.
</p>
<p class="mb-2">Kjøpere under 18 år kan ikke betale med etterfølgende faktura.</p>
<h2 class="mb-1 text-xl font-semibold">6. Levering</h2>
<p class="mb-2">Levering er skjedd når kjøperen, eller hans representant, har overtatt tingen.</p>
<p class="mb-2">
Hvis ikke leveringstidspunkt fremgår av bestillingsløsningen, skal selgeren levere varen til
kjøper uten unødig opphold og senest 30 dager etter bestillingen fra kunden. Varen skal leveres
hos kjøperen med mindre annet er særskilt avtalt mellom partene.
</p>
<h2 class="mb-1 text-xl font-semibold">7. Risikoen for varen</h2>
<p class="mb-2">
Risikoen for varen går over på kjøper når han, eller kjøpers representant, har fått varene levert
i tråd med punkt 6.
</p>
<h2 class="mb-1 text-xl font-semibold">8. Angrerett</h2>
<p class="mb-2">
Med mindre avtalen er unntatt fra angrerett, kan kjøperen angre kjøpet av varen i henhold til
angrerettloven.
</p>
<p class="mb-2">
Kjøperen må gi selger melding om bruk av angreretten innen 14 dager fra fristen begynner å løpe. I
fristen inkluderes alle kalenderdager. Dersom fristen ender på en lørdag, helligdag eller
høytidsdag forlenges fristen til nærmeste virkedag.
</p>
<p class="mb-2">
Angrefristen anses overholdt dersom melding er sendt før utløpet av fristen. Kjøper har
bevisbyrden for at angreretten er blitt gjort gjeldende, og meldingen bør derfor skje skriftlig
(angrerettskjema, e-post eller brev).
</p>
<p class="mb-2">Angrefristen begynner å løpe:</p>
<ul>
<li>
Ved kjøp av enkeltstående varer vil angrefristen løpe fra dagen etter varen(e) er mottatt.
</li>
<li>
Selges et abonnement, eller innebærer avtalen regelmessig levering av identiske varer, løper
fristen fra dagen etter første forsendelse er mottatt.
</li>
<li>
Består kjøpet av flere leveranser, vil angrefristen løpe fra dagen etter siste leveranse er
mottatt.
</li>
</ul>
<p class="mb-2">
Angrefristen utvides til 12 måneder etter utløpet av den opprinnelige fristen dersom selger ikke
før avtaleinngåelsen opplyser om at det foreligger angrerett og standardisert angreskjema.
Tilsvarende gjelder ved manglende opplysning om vilkår, tidsfrister og fremgangsmåte for å benytte
angreretten. Sørger den næringsdrivende for å gi opplysningene i løpet av disse 12 månedene,
utløper angrefristen likevel 14 dager etter den dagen kjøperen mottok opplysningene.
</p>
<p class="mb-2">
Ved bruk av angreretten må varen leveres tilbake til selgeren uten unødig opphold og senest 14
dager fra melding om bruk av angreretten er gitt. Kjøper dekker de direkte kostnadene ved å
returnere varen, med mindre annet er avtalt eller selger har unnlatt å opplyse om at kjøper skal
dekke returkostnadene. Selgeren kan ikke fastsette gebyr for kjøperens bruk av angreretten.
</p>
<p class="mb-2">
Kjøper kan prøve eller teste varen på en forsvarlig måte for å fastslå varens art, egenskaper og
funksjon, uten at angreretten faller bort. Dersom prøving eller test av varen går utover hva som
er forsvarlig og nødvendig, kan kjøperen bli ansvarlig for eventuell redusert verdi på varen.
</p>
<p class="mb-2">
Selgeren er forpliktet til å tilbakebetale kjøpesummen til kjøperen uten unødig opphold, og senest
14 dager fra selgeren fikk melding om kjøperens beslutning om å benytte angreretten. Selger har
rett til å holde tilbake betalingen til han/hun har mottatt varene fra kjøperen, eller til kjøper
har lagt frem dokumentasjon for at varene er sendt tilbake.
</p>
<h2 class="mb-1 text-xl font-semibold">
9. Forsinkelse og manglende levering - kjøpernes rettigheter og frist for å melde krav
</h2>
<p class="mb-2">
Dersom selgeren ikke leverer varen eller leverer den for sent i henhold til avtalen mellom
partene, og dette ikke skyldes kjøperen eller forhold på kjøperens side, kan kjøperen i henhold
til reglene i forbrukerkjøpslovens kapittel 5 etter omstendighetene holde kjøpesummen tilbake,
kreve oppfyllelse, heve avtalen og/eller kreve erstatning fra selgeren.
</p>
<p class="mb-2">
Ved krav om misligholdsbeføyelser bør meldingen av bevishensyn være skriftlig (for eksempel
e-post).
</p>
<h3 class="mt-1 text-lg font-semibold">Oppfyllelse</h3>
<p class="mb-2">
Kjøper kan fastholde kjøpet og kreve oppfyllelse fra selger. Kjøper kan imidlertid ikke kreve
oppfyllelse dersom det foreligger en hindring som selgeren ikke kan overvinne, eller dersom
oppfyllelse vil medføre en så stor ulempe eller kostnad for selger at det står i vesentlig
misforhold til kjøperens interesse i at selgeren oppfyller. Skulle vanskene falle bort innen
rimelig tid, kan kjøper likevel kreve oppfyllelse.
</p>
<p class="mb-2">
Kjøperen taper sin rett til å kreve oppfyllelse om han eller hun venter urimelig lenge med å
fremme kravet.
</p>
<h3 class="mt-1 text-lg font-semibold">Heving</h3>
<p class="mb-2">
Dersom selgeren ikke leverer varen på leveringstidspunktet, skal kjøperen oppfordre selger til å
levere innen en rimelig tilleggsfrist for oppfyllelse. Dersom selger ikke leverer varen innen
tilleggsfristen, kan kjøperen heve kjøpet.
</p>
<p class="mb-2">
Kjøper kan imidlertid heve kjøpet umiddelbart hvis selger nekter å levere varen. Tilsvarende
gjelder dersom levering til avtalt tid var avgjørende for inngåelsen av avtalen, eller dersom
kjøperen har underrettet selger om at leveringstidspunktet er avgjørende.
</p>
<p class="mb-2">
Leveres tingen etter tilleggsfristen forbrukeren har satt eller etter leveringstidspunktet som var
avgjørende for inngåelsen av avtalen, må krav om heving gjøres gjeldende innen rimelig tid etter
at kjøperen fikk vite om leveringen.
</p>
<h3 class="mt-1 text-lg font-semibold">Erstatning</h3>
<p class="mb-2">
Kjøperen kan kreve erstatning for lidt tap som følge av forsinkelsen. Dette gjelder imidlertid
ikke dersom selgeren godtgjør at forsinkelsen skyldes hindring utenfor selgers kontroll som ikke
med rimelighet kunne blitt tatt i betraktning på avtaletiden, unngått, eller overvunnet følgene
av.
</p>
<h2 class="mb-1 text-xl font-semibold">
10. Mangel ved varen - kjøperens rettigheter og reklamasjonsfrist
</h2>
<p class="mb-2">
Hvis det foreligger en mangel ved varen må kjøper innen rimelig tid etter at den ble oppdaget
eller burde ha blitt oppdaget, gi selger melding om at han eller hun vil påberope seg mangelen.
Kjøper har alltid reklamert tidsnok dersom det skjer innen 2 mnd. fra mangelen ble oppdaget eller
burde blitt oppdaget. Reklamasjon kan skje senest to år etter at kjøper overtok varen. Dersom
varen eller deler av den er ment å vare vesentlig lenger enn to år, er reklamasjonsfristen fem år.
</p>
<p class="mb-2">
Dersom varen har en mangel og dette ikke skyldes kjøperen eller forhold på kjøperens side, kan
kjøperen i henhold til reglene i forbrukerkjøpsloven kapittel 6 etter omstendighetene holde
kjøpesummen tilbake, velge mellom retting og omlevering, kreve prisavslag, kreve avtalen hevet
og/eller kreve erstatning fra selgeren.
</p>
<p class="mb-2">Reklamasjon til selgeren bør skje skriftlig.</p>
<h3 class="mt-1 text-lg font-semibold">Retting eller omlevering</h3>
<p class="mb-2">
Kjøperen kan velge mellom å kreve mangelen rettet eller levering av tilsvarende ting. Selger kan
likevel motsette seg kjøperens krav dersom gjennomføringen av kravet er umulig eller volder
selgeren urimelige kostnader. Retting eller omlevering skal foretas innen rimelig tid. Selger har
i utgangspunktet ikke rett til å foreta mer enn to avhjelpsforsøk for samme mangel.
</p>
<h3 class="mt-1 text-lg font-semibold">Prisavslag</h3>
<p class="mb-2">
Kjøper kan kreve et passende prisavslag dersom varen ikke blir rettet eller omlevert. Dette
innebærer at forholdet mellom nedsatt og avtalt pris svarer til forholdet mellom tingens verdi i
mangelfull og kontraktsmessig stand. Dersom særlige grunner taler for det, kan prisavslaget i
stedet settes lik mangelens betydning for kjøperen.
</p>
<h3 class="mt-1 text-lg font-semibold">Heving</h3>
<p class="mb-2">
Dersom varen ikke er rettet eller omlevert, kan kjøperen også heve kjøpet når mangelen ikke er
uvesentlig.
</p>
<h3 class="mt-1 text-lg font-semibold">11. Selgerens rettigheter ved kjøperens mislighold</h3>
<p class="mb-2">
Dersom kjøperen ikke betaler eller oppfyller de øvrige pliktene etter avtalen eller loven, og
dette ikke skyldes selgeren eller forhold på selgerens side, kan selgeren i henhold til reglene i
forbrukerkjøpsloven kapittel 9 etter omstendighetene holde varen tilbake, kreve oppfyllelse av
avtalen, kreve avtalen hevet samt kreve erstatning fra kjøperen. Selgeren vil også etter
omstendighetene kunne kreve renter ved forsinket betaling, inkassogebyr og et rimelig gebyr ved
uavhentede varer.
</p>
<h3 class="mt-1 text-lg font-semibold">Oppfyllelse</h3>
<p class="mb-2">
Selger kan fastholde kjøpet og kreve at kjøperen betaler kjøpesummen. Er varen ikke levert, taper
selgeren sin rett dersom han venter urimelig lenge med å fremme kravet.
</p>
<h3 class="mt-1 text-lg font-semibold">Heving</h3>
<p class="mb-2">
Selger kan heve avtalen dersom det foreligger vesentlig betalingsmislighold eller annet vesentlig
mislighold fra kjøperens side. Selger kan likevel ikke heve dersom hele kjøpesummen er betalt.
Fastsetter selger en rimelig tilleggsfrist for oppfyllelse og kjøperen ikke betaler innen denne
fristen, kan selger heve kjøpet.
</p>
<h3 class="mt-1 text-lg font-semibold">Renter ved forsinket betaling/inkassogebyr</h3>
<p class="mb-2">
Dersom kjøperen ikke betaler kjøpesummen i henhold til avtalen, kan selger kreve renter av
kjøpesummen etter forsinkelsesrenteloven. Ved manglende betaling kan kravet, etter forutgående
varsel, bli sendt til inkasso. Kjøper kan da bli holdt ansvarlig for gebyr etter inkassoloven.
</p>
<h3 class="mt-1 text-lg font-semibold">Gebyr ved uavhentede ikke-forskuddsbetalte varer</h3>
<p class="mb-2">
Dersom kjøperen unnlater å hente ubetalte varer, kan selger belaste kjøper med et gebyr. Gebyret
skal maksimalt dekke selgerens faktiske utlegg for å levere varen til kjøperen. Et slikt gebyr kan
ikke belastes kjøpere under 18 år.
</p>
<h2 class="mb-1 text-xl font-semibold">12. Garanti</h2>
<p class="mb-2">
Garanti som gis av selgeren eller produsenten, gir kjøperen rettigheter i tillegg til de kjøperen
allerede har etter ufravikelig lovgivning. En garanti innebærer dermed ingen begrensninger i
kjøperens rett til reklamasjon og krav ved forsinkelse eller mangler etter punkt 9 og 10.
</p>
<h2 class="mb-1 text-xl font-semibold">13. Personopplysninger</h2>
<p class="mb-2">
Behandlingsansvarlig for innsamlede personopplysninger er selger. Med mindre kjøperen samtykker
til noe annet, kan selgeren, i tråd med personopplysningsloven, kun innhente og lagre de
personopplysninger som er nødvendig for at selgeren skal kunne gjennomføre forpliktelsene etter
avtalen. Kjøperens personopplysninger vil kun bli utlevert til andre hvis det er nødvendig for at
selger skal få gjennomført avtalen med kjøperen, eller i lovbestemte tilfelle.
</p>
<h2 class="mb-1 text-xl font-semibold">14. Konfliktløsning</h2>
<p class="mb-2">
Klager rettes til selger innen rimelig tid, jf. punkt 9 og 10. Partene skal forsøke å løse
eventuelle tvister i minnelighet. Dersom dette ikke lykkes, kan kjøperen ta kontakt med
Forbrukertilsynet for mekling. Forbrukertilsynet er tilgjengelig på telefon 23 400 600 eller
<Link href="www.forbrukertilsynet.no">www.forbrukertilsynet.no</Link>.
</p>
<p class="mb-2">
Europa-Kommisjonens klageportal kan også brukes hvis du ønsker å inngi en klage. Det er særlig
relevant, hvis du er forbruker bosatt i et annet EU-land. Klagen inngis her:
<Link href="http://ec.europa.eu/odr">http://ec.europa.eu/odr</Link>.
</p>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
export let checked: boolean;
export let leftIcon: string | null = null;
export let rightIcon: string | null = null;
export let invertColor = false;
</script>
<div class="flex items-center gap-1">
{#if leftIcon}
<img src={leftIcon} class={`h-6 ${invertColor && 'dark:invert'}`} alt="" />
{/if}
<label class="relative inline-flex cursor-pointer items-center">
<input on:change {checked} type="checkbox" value="" class="peer sr-only" />
<div
class="h-6 w-11 rounded-full border-2 border-contrast-100 outline-2 transition-colors after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border-2 after:border-contrast-100 after:bg-contrast-900 after:transition-all after:content-[''] peer-checked:bg-accent peer-checked:after:translate-x-full dark:peer-focus:ring-accent"
/>
</label>
{#if rightIcon}
<img src={rightIcon} class={`h-6 ${invertColor && 'dark:invert'}`} alt="" />
{/if}
</div>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import Spinner from '../Spinner.svelte';
import { twMerge } from 'tailwind-merge';
/** @description Disallow clicking and reduce opacity **/
export let disabled = false;
/** @description Show a loading spinner **/
export let loading = false;
</script>
<button
on:click
{...$$restProps}
disabled={disabled || loading}
class:opacity-50={disabled || loading}
class:cursor-not-allowed={disabled || loading}
class={twMerge(
'relative rounded-md border-2 border-contrast-200 bg-primary-300 px-4 py-2 transition-colors hover:border-contrast-900 dark:border-contrast-100 dark:hover:border-accent',
$$restProps['class']
)}
>
<span class:opacity-0={loading}>
<slot />
</span>
{#if loading}
<span class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<Spinner class="h-6" />
</span>
{/if}
</button>

View File

@@ -0,0 +1,61 @@
<script lang="ts" generics="T">
import { twMerge } from 'tailwind-merge';
export let items: T[];
/** @description Tailwind classes for styling the button colors */
export let buttonClass = '';
let index = 0;
const next = () => {
index = (index + 1) % items.length;
};
const prev = () => {
index = (index - 1 + items.length) % items.length;
};
</script>
<div
style={`--index: ${index}`}
{...$$restProps}
class={twMerge('inline-flex w-full gap-5 overflow-auto sm:overflow-hidden', $$restProps['class'])}
>
{#each items as item, index}
<div class="carousel transition-transform">
<slot {item} {index} />
</div>
{/each}
</div>
<div class="hidden sm:block">
{#if items.length > 1}
<div class="mt-2 flex justify-between md:text-lg">
<button
class={twMerge(
'rounded-md border-2 border-contrast-100 bg-primary-300 px-4 py-2 transition-colors hover:border-contrast-200 dark:hover:border-accent',
buttonClass
)}
on:click={prev}
>
Forrige
</button>
<button
class={twMerge(
'rounded-md border-2 border-contrast-100 bg-primary-300 px-4 py-2 transition-colors hover:border-contrast-200 dark:hover:border-accent',
buttonClass
)}
on:click={next}
>
Neste
</button>
</div>
{/if}
</div>
<style>
.carousel {
transform: translateX(calc((-100% - 20px) * var(--index)));
}
</style>

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge';
export let layout: 'narrow' | 'wide' = 'wide';
</script>
{#if layout === 'wide'}
<svg
xmlns="http://www.w3.org/2000/svg"
class={twMerge('h-full', $$restProps['class'])}
{...$$restProps}
viewBox="0 0 1840.81 395.57"
>
<path
fill="currentColor"
d="M102,153.89,50.09,81.21V191.75H.34V6.59H50.09l64.66,94.79L179.42,6.59h49.74V191.75H179.42V81.21l-51.95,72.68Z"
/>
<path fill="currentColor" d="M336.11,6.59V191.75H286.36V6.59Z" />
<path
fill="currentColor"
d="M535.35,125.42V6.59H585.1V191.75H535.35l-97-118.83V191.75H388.61V6.59h49.74Z"
/>
<path fill="currentColor" d="M692,6.59V191.75H642.3V6.59Z" />
<path
fill="currentColor"
d="M845.69,1.07h7.74c61.9,0,79,24.87,79,55.27v5.52c0,16.31-8,30.4-32.88,37,29,6.64,38.41,21.28,38.41,37.59V142c0,30.39-18.24,55.27-84.56,55.27h-7.74a927.22,927.22,0,0,1-101.14-5.53V6.59A927.25,927.25,0,0,1,845.69,1.07Zm-51.4,46.7V78.44h49.19c44.77,0,44.77-7.18,44.77-16.58,0-9.12-1.38-16.58-41.45-16.58C833.25,45.28,812.53,46.11,794.29,47.77Zm0,74.89v27.91c18.24,1.66,38.69,2.49,53.33,2.49,44.22,0,46.16-7.46,46.16-16.58,0-7.74,0-13.82-50.3-13.82Z"
/>
<path
fill="currentColor"
d="M1185.32,6.59V114.65c0,45.32-20.73,82.63-94.51,82.63h-10c-73.79,0-94.51-37.31-94.51-82.63V6.59h49.74V107.46c0,24,0,45.6,49.74,45.6s49.75-21.56,49.75-45.6V6.59Z"
/>
<path
fill="currentColor"
d="M1369.37,137c0-13-11.61-14.92-43.67-15.75-28.18-.83-89.53-2.76-89.53-56.65V61c0-31.78,20.45-60,90.64-60h10a290.93,290.93,0,0,1,72.12,9.12V55.78c-26.8-6.35-53.05-10.22-80.14-10.22-40.07,0-42.83,9.12-42.83,15.47,0,13.27,12.16,15.2,43.39,16,26.25.56,89.81,2.49,89.81,56.93v3.59c0,31.51-20.45,59.7-90.92,59.7h-10a400.9,400.9,0,0,1-82.9-8.85V142c29.84,6.63,63.28,11.05,91.47,11.05C1366.33,153.06,1369.37,143.94,1369.37,137Z"
/>
<path
fill="#ff1d25"
d="M846.11,350.74c0-9.9-8.84-11.37-33.26-12-21.48-.63-68.22-2.11-68.22-43.16v-2.74c0-24.21,15.58-45.69,69.06-45.69h7.58a222,222,0,0,1,55,6.95v34.74c-20.42-4.84-40.43-7.79-61.06-7.79-30.53,0-32.63,6.95-32.63,11.79,0,10.11,9.26,11.58,33.05,12.21,20,.42,68.43,1.9,68.43,43.37v2.74c0,24-15.58,45.48-69.27,45.48h-7.58A305.66,305.66,0,0,1,744,389.9V354.53c22.74,5.05,48.21,8.42,69.69,8.42C843.79,363,846.11,356,846.11,350.74Z"
/>
<path
fill="#ff1d25"
d="M1056.87,285.05H959.81V306.1h76v33.69h-76v18.95h97.06v33.69h-135V251.36h135Z"
/>
<path
fill="#ff1d25"
d="M1201.09,392.43l-28.21-44.22c-15,0-29.06,0-42.53-2.1v46.32h-37.9V251.36a707.77,707.77,0,0,1,77.06-4.21h5.89c47.17,0,60.22,20.85,60.22,46.32v8.42c0,17.69-5.9,33.06-24,40.85l26.1,41.26v8.43Zm-70.74-109.7v29.9a266.16,266.16,0,0,0,33.05,1.9c33.69,0,34.32-5.9,34.32-19.16,0-7.58-.21-14.53-27.79-14.53C1160.25,280.84,1144.25,281.47,1130.35,282.73Z"
/>
<path
fill="#ff1d25"
d="M1426,251.36v8.43l-62.75,132.64h-37.89l-62.74-132.64v-8.43h34.1l47.58,107.38,47.59-107.38Z"
/>
<path fill="#ff1d25" d="M1496.49,251.36V392.43h-37.9V251.36Z" />
<path
fill="#ff1d25"
d="M1534.39,316.42c0-38.11,16.63-69.27,76.22-69.27h7.57a218.19,218.19,0,0,1,54.32,6.95v34.32c-19.37-5.69-41-7.79-55.37-7.79-44,0-44,17.26-44,41.26s0,41.48,44,41.48a212.15,212.15,0,0,0,55.37-8V389.9a228.46,228.46,0,0,1-54.32,6.74h-7.57c-59.59,0-76.22-31.16-76.22-69.27Z"
/>
<path
fill="#ff1d25"
d="M1841.15,285.05h-97.06V306.1h76v33.69h-76v18.95h97.06v33.69h-135V251.36h135Z"
/>
<polygon
fill="currentColor"
points="1458.66 108.93 1458.66 83.93 1565.16 96.43 1671.66 108.93 1565.16 121.43 1458.66 133.93 1458.66 108.93"
/>
</svg>
{:else if layout === 'narrow'}
<svg class={$$restProps['class']} {...$$restProps} viewBox="0 0 1920 1920">
<g>
<path
fill="#ff1d25"
d="M780.1,1364.81l88.49-508.71l-323.41,419.08h-88.42L280.44,867l-87.78,497.81H8.56l149.5-847.85h162.3 l224.43,526.88l405.4-526.88h161.09l-147.08,847.85H780.1z"
/>
<path
fill="#ff1d25"
d="M1113.38,1281.23l92.67-147.77c62.28,52.08,162.46,88.42,264.2,88.42c116.28,0,170.35-38.76,179.53-90.84 c27.98-158.67-484.21-49.66-428.68-364.58c25.42-144.14,162.83-264.05,403.87-264.05c106.59,0,211.11,25.44,281.08,75.1 l-86.83,148.98c-70.83-44.81-148.13-66.62-223.23-66.62c-116.28,0-168.78,43.6-178.18,96.9 c-27.55,156.25,484.42,48.45,429.53,359.73c-24.99,141.71-163.83,262.83-406.08,262.83 C1306.83,1379.34,1178.22,1339.37,1113.38,1281.23z"
/>
</g>
</svg>
{/if}

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge';
</script>
<svg
class={twMerge('h-full', $$restProps['class'])}
{...$$restProps}
fill="currentColor"
version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 493.488 493.488"
xml:space="preserve"
>
<g>
<g>
<path
d="M492.358,143.652L350.266,1.304c-0.736-0.736-1.716-1.136-2.74-1.136L146.406,0c-1.012,0-1.996,0.4-2.716,1.108
L1.358,143.216c-0.732,0.712-1.148,1.692-1.148,2.724L0.03,347.052c0,1.02,0.404,2,1.12,2.72l142.1,142.336
c0.72,0.712,1.692,1.168,2.716,1.168l201.12,0.212h0.008c1.02,0,1.996-0.44,2.704-1.16l142.36-142.1
c0.736-0.716,1.136-1.704,1.136-2.724l0.164-201.128C493.458,145.352,493.062,144.368,492.358,143.652z M257.318,373.324
c-2.864,2.856-6.848,4.476-10.9,4.476c-4.068,0-8.052-1.636-10.928-4.5c-2.86-2.872-4.424-6.848-4.416-10.936
c0-4.048,1.728-8.016,4.572-10.872c2.905-2.853,7.191-4.51,11.024-4.532c4.005,0.029,7.866,1.678,10.744,4.556
c2.872,2.856,4.456,6.816,4.456,10.88C261.862,366.492,260.206,370.456,257.318,373.324z M261.894,300.728
c-0.008,8.484-6.916,15.408-15.428,15.408c-8.512-0.008-15.428-6.94-15.42-15.436l0.216-169.668
c0.008-8.512,7.776-15.428,15.488-15.428v-0.006c4.099,0.019,7.926,1.624,10.816,4.538c2.912,2.916,4.496,6.796,4.496,10.908
L261.894,300.728z"
/>
</g>
</g>
</svg>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge';
</script>
<svg
class={twMerge('h-full', $$restProps['class'])}
{...$$restProps}
fill="currentColor"
version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 493.636 493.636"
xml:space="preserve"
>
<g>
<g>
<path
d="M421.428,72.476C374.868,25.84,312.86,0.104,246.724,0.044C110.792,0.044,0.112,110.624,0,246.548
c-0.068,65.912,25.544,127.944,72.1,174.584c46.564,46.644,108.492,72.46,174.4,72.46h0.58v-0.048
c134.956,0,246.428-110.608,246.556-246.532C493.7,181.12,468,119.124,421.428,72.476z M257.516,377.292
c-2.852,2.856-6.844,4.5-10.904,4.5c-4.052,0-8.044-1.66-10.932-4.516c-2.856-2.864-4.496-6.852-4.492-10.916
c0.004-4.072,1.876-8.044,4.732-10.884c2.884-2.86,7.218-4.511,11.047-4.542c3.992,0.038,7.811,1.689,10.677,4.562
c2.872,2.848,4.46,6.816,4.456,10.884C262.096,370.46,260.404,374.432,257.516,377.292z M262.112,304.692
c-0.008,8.508-6.928,15.404-15.448,15.404c-8.5-0.008-15.42-6.916-15.416-15.432L231.528,135
c0.004-8.484,3.975-15.387,15.488-15.414c4.093,0.021,7.895,1.613,10.78,4.522c2.912,2.916,4.476,6.788,4.472,10.912
L262.112,304.692z"
/>
</g>
</g>
</svg>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge';
</script>
<svg
class={twMerge('h-full', $$restProps['class'])}
{...$$restProps}
fill="currentColor"
version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 493.464 493.464"
xml:space="preserve"
>
<g>
<g>
<path
d="M246.736,0C110.692,0,0.004,110.68,0.004,246.732c0,136.06,110.688,246.732,246.732,246.732
c136.048,0,246.724-110.672,246.724-246.732C493.456,110.68,382.78,0,246.736,0z M360.524,208.716L230.98,338.268
c-2.82,2.824-7.816,2.824-10.64,0l-86.908-86.912c-1.412-1.416-2.192-3.3-2.192-5.324c0.004-2.016,0.784-3.912,2.192-5.336
l11.108-11.104c1.412-1.408,3.3-2.18,5.328-2.18c2.016,0,3.908,0.772,5.316,2.18l67.752,67.752c1.5,1.516,3.94,1.516,5.444,0
l110.392-110.392c2.824-2.824,7.828-2.824,10.644,0l11.108,11.124c1.412,1.4,2.208,3.304,2.208,5.308
C362.732,205.412,361.936,207.3,360.524,208.716z"
/>
</g>
</g>
</svg>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge';
</script>
<svg
class={twMerge('h-full', $$restProps['class'])}
{...$$restProps}
fill="currentColor"
version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512"
xml:space="preserve"
>
<g>
<g>
<path
d="M507.494,426.066L282.864,53.537c-5.677-9.415-15.87-15.172-26.865-15.172c-10.995,0-21.188,5.756-26.865,15.172
L4.506,426.066c-5.842,9.689-6.015,21.774-0.451,31.625c5.564,9.852,16.001,15.944,27.315,15.944h449.259
c11.314,0,21.751-6.093,27.315-15.944C513.508,447.839,513.336,435.755,507.494,426.066z M256.167,167.227
c12.901,0,23.817,7.278,23.817,20.178c0,39.363-4.631,95.929-4.631,135.292c0,10.255-11.247,14.554-19.186,14.554
c-10.584,0-19.516-4.3-19.516-14.554c0-39.363-4.63-95.929-4.63-135.292C232.021,174.505,242.605,167.227,256.167,167.227z
M256.498,411.018c-14.554,0-25.471-11.908-25.471-25.47c0-13.893,10.916-25.47,25.471-25.47c13.562,0,25.14,11.577,25.14,25.47
C281.638,399.11,270.06,411.018,256.498,411.018z"
/>
</g>
</g>
</svg>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge';
export let href: string;
</script>
<a
{...$$restProps}
{href}
class={twMerge(
'font-medium transition-colors hover:text-accent hover:underline',
$$restProps['class']
)}
>
<slot />
</a>

View File

@@ -0,0 +1,123 @@
<script lang="ts" generics="T">
import ListboxItem from './ListboxItem.svelte';
import { writable, type Writable } from 'svelte/store';
import { setListboxContext, type ListboxItemAbstract } from './context';
import { twMerge } from 'tailwind-merge';
/** @description Determine if multiple items can be selected */
export let allowMultiple = true;
/** @description Determine if multiple items can be selected */
export let items: T[];
/** @description Determine if multiple items can be selected */
export let defaultSelectedItems: T[];
/** @description Function called when the selected items changes */
export let onChange: (items: T[]) => void;
let selectedItems: Writable<ListboxItemAbstract<T>[]> = writable([]);
let registeredItems: Writable<ListboxItemAbstract<T>[]> = writable([]);
let focusedItem: Writable<ListboxItemAbstract<T> | null> = writable(null);
function toggleItemSelected(item: ListboxItemAbstract<T>): void {
if (item) {
setFocus(item);
if ($selectedItems.includes(item)) {
if (allowMultiple) {
$selectedItems = $selectedItems.filter((x) => x !== item);
} else {
$selectedItems = [];
}
} else {
if (allowMultiple) {
$selectedItems.push(item);
$selectedItems = $selectedItems;
} else {
$selectedItems = [item];
}
}
}
onChange($selectedItems.map((x) => x.item));
}
function setFocus(item: ListboxItemAbstract<T>) {
const itemEl = document.getElementById(item.id);
$focusedItem = item;
itemEl?.focus();
}
function onKeyDown(e: KeyboardEvent, item: ListboxItemAbstract<T>) {
const index = $registeredItems.findIndex((x) => x === item);
if (index === -1) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if ($registeredItems.length - 1 === index) {
setFocus($registeredItems[0]);
} else {
setFocus($registeredItems[index + 1]);
}
break;
case 'ArrowUp':
e.preventDefault();
if (index === 0) {
setFocus($registeredItems[$registeredItems.length - 1]);
} else {
setFocus($registeredItems[index - 1]);
}
break;
case 'Enter':
case ' ':
e.preventDefault();
toggleItemSelected(item);
break;
case 'Home':
setFocus($registeredItems[0]);
break;
case 'End':
setFocus($registeredItems[$registeredItems.length - 1]);
break;
}
}
function registerItem(item: ListboxItemAbstract<T>): void {
$registeredItems.push(item);
$registeredItems = $registeredItems;
if (defaultSelectedItems.includes(item.item)) {
$selectedItems.push(item);
$selectedItems = $selectedItems;
}
if ($registeredItems.length === 1) {
$focusedItem = $registeredItems[0];
}
}
function unregisterItem(item: ListboxItemAbstract<T>): void {
$registeredItems = $registeredItems.filter((x) => x.id !== item.id);
$selectedItems = $selectedItems.filter((x) => x.id !== item.id);
}
setListboxContext<T>({
registerItem: registerItem,
unregisterItem: unregisterItem,
selectedItems: selectedItems,
focusedItem: focusedItem
});
</script>
<ul
class={twMerge('flex flex-col gap-2 p-4', $$restProps['class'])}
role="listbox"
aria-activedescendant={$focusedItem?.id}
aria-multiselectable={allowMultiple}
tabindex={0}
>
{#each items as item}
<ListboxItem {onKeyDown} onClick={(e, x) => toggleItemSelected(x)} {item}>
<slot {item} />
</ListboxItem>
{/each}
</ul>

View File

@@ -0,0 +1,51 @@
<script lang="ts" generics="T">
import { onMount } from 'svelte';
import { getListboxContext, type ListboxItemAbstract } from './context';
import { twMerge } from 'tailwind-merge';
export let item: T;
export let onClick: (e: MouseEvent, item: ListboxItemAbstract<T>) => void;
export let onKeyDown: (e: KeyboardEvent, item: ListboxItemAbstract<T>) => void;
const id: string = `listbox-item-${
crypto.randomUUID
? crypto.randomUUID()
: Math.floor(Date.now() * Math.random() * 100).toString()
}`;
const itemAbstract: ListboxItemAbstract<T> = { id: id, item: item };
let {
selectedItems: selectedItems,
registerItem: register,
unregisterItem: unregister,
focusedItem: focusedItem
} = getListboxContext<T>();
onMount(() => {
register(itemAbstract);
return () => {
unregister(itemAbstract);
};
});
</script>
<li
{id}
role="option"
aria-selected={$selectedItems.includes(itemAbstract)}
on:click={(e) => onClick(e, itemAbstract)}
on:keydown={(e) => onKeyDown(e, itemAbstract)}
tabindex={$focusedItem && $focusedItem === itemAbstract ? 0 : -1}
class={twMerge(
`cursor-pointer overflow-hidden rounded-md border-2 border-contrast-200 opacity-80 ${
$selectedItems.includes(itemAbstract)
? 'border-contrast-900 opacity-100 dark:border-accent'
: ''
}`,
$$restProps['class']
)}
>
<slot />
</li>

View File

@@ -0,0 +1,22 @@
import { getContext, setContext } from 'svelte';
import type { Writable } from 'svelte/store';
type ListboxContext<T> = {
registerItem: (item: ListboxItemAbstract<T>) => void;
unregisterItem: (item: ListboxItemAbstract<T>) => void;
selectedItems: Writable<ListboxItemAbstract<T>[]>;
focusedItem: Writable<ListboxItemAbstract<T> | null>;
};
export type ListboxItemAbstract<T> = {
id: string;
item: T;
};
export function setListboxContext<T>(context: ListboxContext<T>): void {
setContext('listbox', context);
}
export function getListboxContext<T>(): ListboxContext<T> {
return getContext('listbox') as ListboxContext<T>;
}

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import { scale } from 'svelte/transition';
import { twMerge } from 'tailwind-merge';
export const toggle = () => {
if (visible) {
close();
} else {
show();
}
};
export const show = () => {
modalRef.showModal();
onShow();
visible = true;
};
export const close = () => {
modalRef.close();
onClose();
visible = false;
};
export let onShow = () => {};
export let onClose = () => {};
let visible = false;
let modalRef: HTMLDialogElement;
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<dialog
bind:this={modalRef}
on:close={() => (visible = false)}
transition:scale={{ duration: 200 }}
class={twMerge(
`fixed inset-0 z-40 flex max-h-[calc(100vh-16rem)] flex-col rounded-md border-2 border-contrast-100 bg-primary-100 p-6 text-contrast-900 lg:w-3/4`,
$$restProps['class'],
visible ? '' : 'hidden'
)}
{...$$restProps}
>
<div class="mb-4 flex">
<h1 class="grow text-2xl font-semibold">
<slot name="header" />
</h1>
</div>
<div class="grow overflow-auto rounded-md bg-primary-300 p-4 shadow-inner">
<slot />
</div>
<div class="mt-4 flex justify-end gap-4">
<slot name="button-row" />
</div>
</dialog>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getTabsContext } from './context';
import { twMerge } from 'tailwind-merge';
export let label: string;
const id: string = `tab-${
crypto.randomUUID
? crypto.randomUUID()
: Math.floor(Date.now() * Math.random() * 100).toString()
}`;
const context = getTabsContext();
const { selectedTab } = context;
onMount(() => {
context.register({
id: id,
label: label
});
return () => {
context.unregister(id);
};
});
</script>
{#if $selectedTab === id}
<div
{...$$restProps}
class={twMerge('rounded-md bg-primary-300 p-4 shadow-inner', $$restProps['class'])}
role="tabpanel"
aria-labelledby={id}
>
<slot />
</div>
{/if}

View File

@@ -0,0 +1,102 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge';
import { setTabsContext, type TabAbstract } from './context';
import { writable, type Writable } from 'svelte/store';
export let buttonClass = '';
let tabs: TabAbstract[] = [];
let selectedTab: Writable<string | null> = writable(null);
function setTab(id: string): void {
const tab = tabs.find((x) => x.id == id);
if (tab) {
$selectedTab = tab.id;
}
}
function registerTab(tab: TabAbstract): void {
tabs.push(tab);
tabs = tabs;
if (tabs.length === 1) {
$selectedTab = tabs[0].id;
}
}
function unregisterTab(id: string): void {
tabs = tabs.filter((x) => x.id !== id);
}
function setFocus(tab: TabAbstract) {
const tabEl = document.getElementById(tab.id);
tabEl?.focus();
}
function onKeyDown(e: KeyboardEvent, tab: TabAbstract) {
const index = tabs.findIndex((x) => x.id === tab.id);
if (index === -1) return;
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
if (tabs.length - 1 === index) {
setFocus(tabs[0]);
} else {
setFocus(tabs[index + 1]);
}
break;
case 'ArrowLeft':
e.preventDefault();
if (index === 0) {
setFocus(tabs[tabs.length - 1]);
} else {
setFocus(tabs[index - 1]);
}
break;
case 'Home':
e.preventDefault();
setFocus(tabs[0]);
break;
case 'End':
e.preventDefault();
setFocus(tabs[tabs.length - 1]);
break;
}
}
setTabsContext({
register: registerTab,
unregister: unregisterTab,
selectedTab: selectedTab
});
</script>
<div {...$$restProps} class={twMerge('flex flex-col gap-4', $$restProps['class'])}>
<div
role="tablist"
aria-labelledby="tablist"
class="flex gap-4 overflow-auto rounded-md bg-primary-300 p-4 shadow-inner"
>
{#each tabs as tab, i}
<button
role="tab"
type="button"
id={tab.id}
tabindex={$selectedTab === tab.id || ($selectedTab === null && i === 0) ? 0 : -1}
aria-selected={$selectedTab === tab.id}
aria-controls={tab.id}
class={twMerge(
`rounded-md border-2 border-contrast-200 bg-primary-100 px-4 py-2 hover:border-contrast-900 dark:hover:border-accent ${$selectedTab === tab.id ? 'border-contrast-900 dark:border-accent' : ''}`,
buttonClass
)}
on:click={() => setTab(tab.id)}
on:keydown={(e) => onKeyDown(e, tab)}
>
{tab.label}
</button>
{/each}
</div>
<slot />
</div>

View File

@@ -0,0 +1,21 @@
import { getContext, setContext } from 'svelte';
import type { Writable } from 'svelte/store';
type TabsContext = {
register: (tab: TabAbstract) => void;
unregister: (id: string) => void;
selectedTab: Writable<string | null>;
};
export type TabAbstract = {
id: string;
label: string;
};
export function setTabsContext(context: TabsContext): void {
setContext('tabs', context);
}
export function getTabsContext(): TabsContext {
return getContext('tabs') as TabsContext;
}

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import { toast, toasts } from "./toast";
import { fade } from "svelte/transition";
import InfoIcon from "../icons/severity/InfoIcon.svelte";
import WarningIcon from "../icons/severity/WarningIcon.svelte";
import ErrorIcon from "../icons/severity/ErrorIcon.svelte";
import SuccessIcon from "../icons/severity/SuccessIcon.svelte";
</script>
{#if $toasts.length >= 1}
<div
class="fixed bottom-0 right-0 z-50 flex w-full flex-col gap-2 p-4 text-white sm:w-96"
>
{#each $toasts as toastItem}
<div
class:bg-red-500={toastItem.type === "error"}
class:bg-green-500={toastItem.type === "success"}
class:bg-orange-500={toastItem.type === "warning"}
class:bg-blue-500={toastItem.type === "info"}
class="card flex items-center gap-3 border-contrast-900 px-4 py-3 shadow-md dark:border-contrast-100"
transition:fade={{ duration: 200 }}
>
<div
class:text-red-900={toastItem.type === "error"}
class:text-green-900={toastItem.type === "success"}
class:text-orange-900={toastItem.type === "warning"}
class:text-blue-900={toastItem.type === "info"}
class="shrink-0"
>
{#if toastItem.type === "info"}
<InfoIcon class="w-7"></InfoIcon>
{:else if toastItem.type === "warning"}
<WarningIcon class="w-7"></WarningIcon>
{:else if toastItem.type === "error"}
<ErrorIcon class="w-7"></ErrorIcon>
{:else if toastItem.type === "success"}
<SuccessIcon class="w-7"></SuccessIcon>
{/if}
</div>
<p class="grow">
{toastItem.text}
</p>
<button
on:click={() => toast.dismiss(toastItem.id)}
class="shrink-0"
aria-label="dismiss"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,46 @@
import { writable } from 'svelte/store';
export const toasts = writable<Toast[]>([]);
type Toast = {
id: string;
type: 'success' | 'error' | 'info' | 'warning';
text: string;
};
function addToast(
text: string,
type: 'success' | 'error' | 'warning' | 'info' = 'info',
dismissible: boolean
) {
const id: string = `toast-${
crypto.randomUUID
? crypto.randomUUID()
: Math.floor(Date.now() * Math.random() * 100).toString()
}`;
toasts.update((all) => [
...all,
{
id: id,
text: text,
dismissible: dismissible,
type: type
}
]);
}
function dismiss(id: string) {
toasts.update((all) => all.filter((x) => x.id !== id));
}
export const toast = {
clear: () => toasts.set([]),
error: (text: string | null, dismissible = true) =>
addToast(text ?? 'En feil oppstod', 'error', dismissible),
info: (text: string, dismissible = true) => addToast(text, 'info', dismissible),
warning: (text: string, dismissible = true) => addToast(text, 'warning', dismissible),
success: (text: string | null, dismissible = true) =>
addToast(text ?? 'Suksess', 'success', dismissible),
dismiss: dismiss
};

99
src/lib/global/routes.ts Normal file
View File

@@ -0,0 +1,99 @@
import homeIcon from "../../assets/icons/home_icon.svg";
import contactIcon from "../../assets/icons/contact_icon.svg";
import busIcon from "../../assets/icons/bus_icon.svg";
type Route = {
url: string;
text: string;
icon: string | null;
target: string | null;
};
export class Routes {
static home: Route = {
url: "/",
text: "Hjem",
icon: homeIcon,
target: null,
};
static bus: Route = {
url: "/bus",
text: "Buss",
icon: busIcon,
target: null,
};
static taxi: Route = {
url: "/taxi",
text: "Taxi",
icon: null,
target: null,
};
static accessibility: Route = {
url: "/accessibility",
text: "Tilgjengelighetsfunksjoner",
icon: null,
target: null,
};
static contact: Route = {
url: "/contact",
text: "Ta kontakt",
icon: contactIcon,
target: null,
};
static facebook: Route = {
url: "https://www.facebook.com/MinibusserviceSteneAS/",
text: "Facebook",
icon: null,
target: "_blank",
};
static email: Route = {
url: "mailto:minibusstur@hotmail.com",
text: "E-post",
icon: null,
target: null,
};
static phone: Route = {
url: "tel:45256161",
text: "Telefon",
icon: null,
target: null,
};
static topbarRoutes: Route[] = [Routes.home, Routes.bus, Routes.contact];
static sidebarRoutes: Route[] = [
Routes.home,
Routes.bus,
Routes.taxi,
Routes.accessibility,
Routes.contact,
];
static footerRoutes: { text: string; routes: Route[] }[] = [
{
text: "HJELP",
routes: [Routes.contact],
},
{
text: "SIDER",
routes: [
Routes.home,
Routes.bus,
Routes.taxi,
Routes.accessibility,
Routes.contact,
],
},
{
text: "MINIBUSSERVICE",
routes: [Routes.facebook, Routes.email, Routes.phone],
},
];
}

39
src/lib/stores/theme.ts Normal file
View File

@@ -0,0 +1,39 @@
import { writable } from "svelte/store";
function createThemeToggler() {
let defaultValue = false;
let savedValue = localStorage.getItem("dark_theme");
if (savedValue) {
defaultValue = JSON.parse(savedValue);
} else if (
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
) {
defaultValue = true;
}
if (defaultValue) {
document.body.dataset.theme = "dark";
} else {
document.body.dataset.theme = "light";
}
const { subscribe, update } = writable(defaultValue);
return {
subscribe,
set: (value: boolean) => {
update(() => value);
if (value) {
document.body.dataset.theme = "dark";
} else {
document.body.dataset.theme = "light";
}
localStorage.setItem("dark_theme", JSON.stringify(value));
},
};
}
export const darkTheme = createThemeToggler();

8
src/lib/util/debounce.ts Normal file
View File

@@ -0,0 +1,8 @@
export function debounce<T extends Function>(cb: T, wait = 500) {
let h: any;
let callable = () => {
clearTimeout(h);
h = setTimeout(() => cb(), wait);
};
return callable;
}

8
src/main.ts Normal file
View File

@@ -0,0 +1,8 @@
import { mount } from "svelte";
import App from "./App.svelte";
const app = mount(App, {
target: document.getElementById("app")!,
});
export default app;

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import singleRampLeftClosed from "../assets/images/single_ramp_left_closed.jpg";
import singleRampRightClosed from "../assets/images/single_ramp_right_closed.jpg";
import threeRampsOpen from "../assets/images/three_ramps_open.jpg";
import Carousel from "../lib/components/carousel/Carousel.svelte";
let rampCarouselItems: string[] = [
singleRampLeftClosed,
singleRampRightClosed,
threeRampsOpen,
];
</script>
<h1 class="text-xl font-semibold md:text-2xl">Tilgjengelighetsfunksjoner</h1>
<section
class="mt-4 justify-between gap-5 rounded-md bg-primary-300 p-4 shadow-inner md:flex"
>
<div>
<h2 class="grow text-lg font-semibold md:text-xl">
Sitter du i rullestol?
</h2>
<p class="md:text-lg">
Vi har flere busser med rullestolstøtte, med kapasitet opp til 2
rullestoler per buss.
<br />Total kapasitet er 5 rullestilbrukere fordelt på 3 busser
</p>
</div>
<div class="md:w-96">
<Carousel
items={rampCarouselItems}
buttonClass="bg-primary-200"
let:item
>
<div class="h-full w-96 overflow-hidden shadow-sm">
<img class="card w-full object-cover" src={item} alt="" />
</div>
</Carousel>
</div>
</section>

81
src/pages/BusPage.svelte Normal file
View File

@@ -0,0 +1,81 @@
<script lang="ts">
import threeBusses from "../assets/images/three_busses.jpg";
import twoBusses from "../assets/images/two_busses.jpg";
import fiveBusses from "../assets/images/five_busses.jpg";
import singleRampLeftClosed from "../assets/images/single_ramp_left_closed.jpg";
import threeRampsOpen from "../assets/images/three_ramps_open.jpg";
import twoTourBuses from "../assets/images/two_tour_busses.jpg";
import tourBusSide from "../assets/images/tour_bus_side.jpg";
import tourBusSideLogo from "../assets/images/tour_bus_side_logo.jpg";
import Carousel from "../lib/components/carousel/Carousel.svelte";
let minibusCarouselItems: string[] = [
threeBusses,
twoBusses,
fiveBusses,
singleRampLeftClosed,
threeRampsOpen,
];
let tourBusCarouselItems: string[] = [
twoTourBuses,
tourBusSide,
tourBusSideLogo,
];
</script>
<h1 class="text-xl font-semibold md:text-2xl">Buss</h1>
<section
id="minibus"
class="mt-4 justify-between gap-5 rounded-md bg-primary-300 p-4 shadow-inner md:flex"
>
<div>
<h2 class="grow text-lg font-semibold md:text-xl">Minibusser</h2>
<p class="md:text-lg">
Vi har idag fire minibusser, to Mercedes Sprinter og to Ford
Transit. Vi har også en fullelektrisk Mercedes EQV og en Mitsubishi
Outlander.
<br />Vi finner løsninger for grupper fra 5 - 32 passasjerer med
støtte for inntil 3 rullestolbrukere samtidig
</p>
</div>
<div class="md:w-96">
<Carousel
items={minibusCarouselItems}
buttonClass="bg-primary-200"
let:item
>
<div class="w-96 overflow-hidden shadow-sm">
<img class="card w-full object-cover" src={item} alt="" />
</div>
</Carousel>
</div>
</section>
<section
id="tour"
class="mt-12 justify-between gap-5 rounded-md bg-primary-300 p-4 shadow-inner md:flex"
>
<div>
<h2 class="grow text-lg font-semibold md:text-xl">Turbusser</h2>
<p class="md:text-lg">
Vi er medeier i Storfjord Turbuss AS som har tre turbusser.
<br />Storfjord Turbuss AS ble etablert i 2018, og vi utfører
turkjøring og lager pakkeløsninger etter dine ønsker og behov.
<br />Vår målsetning er å alltid være det naturlige førstevalget av
transportør
</p>
</div>
<div class="md:w-96">
<Carousel
items={tourBusCarouselItems}
buttonClass="bg-primary-200"
let:item
>
<div class="w-96 overflow-hidden shadow-sm">
<img class="card w-full object-cover" src={item} alt="" />
</div>
</Carousel>
</div>
</section>

View File

@@ -0,0 +1,6 @@
<script>
import ContactForm from "../lib/components/ContactForm.svelte";
</script>
<h1 class="text-xl font-semibold md:text-2xl">Kontakt oss</h1>
<ContactForm />

120
src/pages/HomePage.svelte Normal file
View File

@@ -0,0 +1,120 @@
<script lang="ts">
import header_image from "../assets/images/header_image.jpg";
import Carousel from "../lib/components/carousel/Carousel.svelte";
import ContactForm from "../lib/components/ContactForm.svelte";
import threeBusses from "../assets/images/three_busses.jpg";
import taxis from "../assets/images/taxis.jpg";
import wheelchairRamps from "../assets/images/three_ramps_open.jpg";
import twoTourBusses from "../assets/images/two_tour_busses.jpg";
import van from "../assets/images/van.webp";
import Link from "../lib/components/link/Link.svelte";
import { Routes } from "../lib/global/routes";
type CardCarouselItem = {
header: string;
text: string;
url: string;
image: string | null;
};
let carouselItems: CardCarouselItem[] = [
{
image: threeBusses,
header: "Minibusstransport",
text: "Vi har 4 minibusser i ulike størrelser",
url: `${Routes.bus.url}#minibus`,
},
{
image: taxis,
header: "Taxikjøring",
text: "Vi kjører taxi med kapasitet opptil 8 personer",
url: `${Routes.taxi.url}#person`,
},
{
image: wheelchairRamps,
header: "Rullestoltransport",
text: "Fire av våre biler er rullestoltilpasset med plass til inntil 3 rullestoler",
url: `${Routes.accessibility.url}`,
},
{
image: twoTourBusses,
header: "Turbuss",
text: "Vi er medeier i Storfjord Turbuss AS, som har tre moderne turbusser for inntil 50 personer",
url: `${Routes.bus.url}#tour`,
},
{
image: van,
header: "Pakketaxi",
text: "Vi har tilbud om pakketaxi. Både småpakker og volumkrevende gods kan transporteres",
url: `${Routes.taxi.url}#package`,
},
];
</script>
<div class="flex gap-8">
<div class="overflow-hidden">
<div>
<img
src={header_image}
class="rounded-md border-2 border-contrast-100"
alt="Minibusses lined up in a row"
/>
<h1 class="mt-4 text-xl font-semibold md:text-2xl">
Med komfort og sikkerhet i førersetet<br />Bli med på en trygg
og behagelig tur
</h1>
<p class="mt-2 font-medium opacity-70 md:text-lg">
Minibusservice Stene AS driver personbefordring med taxi,
minibuss og busser.
<br />Vi har lang erfaring og finner gode løsninger for kundene
våre.
<br />Kontraktskjøring for helseforetak, busselskap og kommune
utgjør størsteparten av oppdragene våre, men vi tilbyr også
turer for privatpersoner, bedrifter og turister.
<br />Vi er godkjent lærebedrift og setter kunden og miljøet i
fokus. Vi har både elektriske, hybride og dieselkjøretøy i
flåten
</p>
</div>
<div class="mt-12">
<h1 class="text-xl font-semibold md:text-2xl">Våre tjenester</h1>
<Carousel
class="mt-2 rounded-md border-2 border-contrast-100 bg-primary-300 p-4 shadow-inner"
items={carouselItems}
let:item
>
<div
class="card flex h-full w-64 flex-col overflow-hidden border-contrast-100 shadow-sm transition-transform"
>
{#if item.image}
<img
class="h-44 w-full border-b-2 border-contrast-100 object-cover"
src={item.image}
alt=""
/>
{/if}
<div class="flex grow flex-col p-4">
<h3 class="font-semibold">
{item.header}
</h3>
<p class="grow whitespace-normal">
{item.text}
</p>
<Link
class="hi mt-2 w-fit font-semibold"
href={item.url}>Les mer...</Link
>
</div>
</div>
</Carousel>
</div>
<div class="mt-12">
<h1 class="text-xl font-semibold md:text-2xl">Kontakt oss</h1>
<ContactForm />
</div>
</div>
</div>

60
src/pages/TaxiPage.svelte Normal file
View File

@@ -0,0 +1,60 @@
<script lang="ts">
import taxis from "../assets/images/taxis.jpg";
import van from "../assets/images/van.webp";
import Carousel from "../lib/components/carousel/Carousel.svelte";
let taxiCarouselItems: string[] = [taxis];
let packageTaxiCarouselItems: string[] = [van];
</script>
<h1 class="text-xl font-semibold md:text-2xl">Taxi</h1>
<section
id="person"
class="mt-4 justify-between gap-5 rounded-md bg-primary-300 p-4 shadow-inner md:flex"
>
<div>
<h2 class="grow text-lg font-semibold md:text-xl">Persontaxi</h2>
<p class="md:text-lg">
Vi har taxier som dekker de fleste behov.
<br />Rullestoltransport, firehjulstrekk og helelektrisk bil er noen
av tilbudene våre.
<br />Taxiene våre kan ta opp til 8 passasjerer
</p>
</div>
<div class="md:w-96">
<Carousel
items={taxiCarouselItems}
buttonClass="bg-primary-200"
let:item
>
<div class="card w-96 overflow-hidden shadow-sm">
<img class="w-full object-cover" src={item} alt="" />
</div>
</Carousel>
</div>
</section>
<section
id="package"
class="mt-4 justify-between gap-5 rounded-md bg-primary-300 p-4 shadow-inner md:flex"
>
<div>
<h2 class="grow text-lg font-semibold md:text-xl">Pakketaxi</h2>
<p class="md:text-lg">
Vi har tilbud om pakketaxi. Både småpakker og volumkrevende gods kan
transporteres
</p>
</div>
<div class="md:w-96">
<Carousel
items={packageTaxiCarouselItems}
buttonClass="bg-primary-200"
let:item
>
<div class="card w-96 overflow-hidden">
<img class="w-full object-cover" src={item} alt="" />
</div>
</Carousel>
</div>
</section>

2
svelte.config.js Normal file
View File

@@ -0,0 +1,2 @@
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
export default {}

21
tsconfig.app.json Normal file
View File

@@ -0,0 +1,21 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"module": "ESNext",
"types": ["svelte", "vite/client"],
"noEmit": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"moduleDetection": "force"
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

8
vite.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [svelte(), tailwindcss()],
});