This commit is contained in:
nub31
2026-03-16 23:14:51 +01:00
parent 3bf281c7a7
commit 7aea328d4f
17 changed files with 913 additions and 356 deletions

12
package-lock.json generated
View File

@@ -7,6 +7,10 @@
"": { "": {
"name": "minibusservice.no", "name": "minibusservice.no",
"version": "0.0.0", "version": "0.0.0",
"dependencies": {
"leaflet": "^1.9.4",
"tailwind-merge": "^3.5.0"
},
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^0.0.0-insiders.aaaefe8", "@tailwindcss/vite": "^0.0.0-insiders.aaaefe8",
@@ -14,7 +18,6 @@
"@types/node": "^24.12.0", "@types/node": "^24.12.0",
"svelte": "^5.53.7", "svelte": "^5.53.7",
"svelte-check": "^4.4.5", "svelte-check": "^4.4.5",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^0.0.0-insiders.aaaefe8", "tailwindcss": "^0.0.0-insiders.aaaefe8",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^8.0.0" "vite": "^8.0.0"
@@ -1200,6 +1203,12 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1694,7 +1703,6 @@
"version": "3.5.0", "version": "3.5.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",

View File

@@ -16,9 +16,12 @@
"@types/node": "^24.12.0", "@types/node": "^24.12.0",
"svelte": "^5.53.7", "svelte": "^5.53.7",
"svelte-check": "^4.4.5", "svelte-check": "^4.4.5",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^0.0.0-insiders.aaaefe8", "tailwindcss": "^0.0.0-insiders.aaaefe8",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^8.0.0" "vite": "^8.0.0"
},
"dependencies": {
"leaflet": "^1.9.4",
"tailwind-merge": "^3.5.0"
} }
} }

View File

@@ -1,15 +1,40 @@
<script lang="ts"> <script lang="ts">
import Router, { route } from "./lib/components/Router.svelte"; import Router, { route } from "./lib/components/Router.svelte";
import "./app.css"; import "./app.css";
import HomePage from "./pages/HomePage.svelte"; 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("/", HomePage);
route("/taxi", TaxiPage);
route("/bus", BusPage);
route("/contact", ContactPage);
route("/accessibility", AccessibilityPage);
</script> </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> <Router>
{#snippet notfound()} {#snippet notfound()}
<h1>Not found</h1> <h1>Not found</h1>
<p>Sorry, the page you are looking for does not exist.</p> <p>
Sorry, the page you are looking for does not exist.
</p>
<p>Return to <a href="/">home</a>.</p> <p>Return to <a href="/">home</a>.</p>
{/snippet} {/snippet}
</Router> </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.svelte";
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>

View File

@@ -1,57 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { MessageResponseBodyData } from '$lib/types/dtos/message/MessageResponseBodyData'; import Link from "./link/Link.svelte";
import type { MinibusserviceResponseBody } from '$lib/types/dtos/MinibusserviceResponseBody'; import MapPoint from "./MapPoint.svelte";
import Button from './button/Button.svelte';
import Link from './link/Link.svelte';
import MapPoint from './MapPoint.svelte';
import { toast } from './toast/toast';
let buttonDisabled = false;
let name = '';
let email = '';
let phone = '';
let message = '';
let posting = false;
async function sendMessage() {
if (!(name && email && message)) {
toast.warning('Alle påkrevde felt må fyllest inn');
return;
}
posting = true;
buttonDisabled = true;
try {
let res = await fetch('/api/v1/message', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name,
email,
phone,
message
})
});
let data: MinibusserviceResponseBody<MessageResponseBodyData> = await res.json();
if (res.ok) {
toast.success(data.message);
} else {
console.error(data.message);
toast.error(data.message);
}
} catch (err) {
console.error(err);
buttonDisabled = false;
toast.error('En feil oppstod under sending av meldingen');
} finally {
posting = false;
}
}
</script> </script>
<div class="mt-2 flex flex-col gap-8 md:flex-row md:text-lg"> <div class="mt-2 flex flex-col gap-8 md:flex-row md:text-lg">
@@ -60,12 +9,19 @@
Spørsmål?<br />Kontakt oss via tlf, e-post eller via skjemaet under Spørsmål?<br />Kontakt oss via tlf, e-post eller via skjemaet under
</p> </p>
<p class="hidden pt-2 font-medium opacity-70 md:block"> <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 Spørsmål?<br />Kontakt oss via tlf, e-post eller via skjemaet til
høyre
</p> </p>
<Link class="w-fit font-bold text-accent hover:underline" href="tel:45256161"> <Link
class="w-fit font-bold text-accent hover:underline"
href="tel:45256161"
>
Tlf: +47 45 25 61 61 Tlf: +47 45 25 61 61
</Link> </Link>
<Link class="w-fit font-bold text-accent hover:underline" href="mailto:minibusstur@hotmail.com"> <Link
class="w-fit font-bold text-accent hover:underline"
href="mailto:minibusstur@hotmail.com"
>
E-post: minibusstur@hotmail.com E-post: minibusstur@hotmail.com
</Link> </Link>
@@ -74,49 +30,9 @@
coordinates={[ coordinates={[
{ {
latitude: 62.48303957042255, latitude: 62.48303957042255,
longitude: 6.8108274964451745 longitude: 6.8108274964451745,
} },
]} ]}
/> />
</div> </div>
<form
on:submit|preventDefault={sendMessage}
class="flex flex-1 flex-col gap-4 pt-4 text-xl md:pt-0"
>
<label>
<span class="text-xl font-semibold">
<span class="text-accent"> * </span>
Navn
</span>
<input required bind:value={name} placeholder="Navn" class="bg-primary-100" type="text" />
</label>
<label>
<span class="text-xl font-semibold">
<span class="text-accent"> * </span>
E-post
</span>
<input
required
bind:value={email}
placeholder="E-postadresse"
class="bg-primary-100"
type="email"
/>
</label>
<label>
<span class="text-xl font-semibold">Telefonnummer</span>
<input bind:value={phone} placeholder="Telefonnummer" class="bg-primary-100" type="tel" />
</label>
<label>
<span class="text-xl font-semibold">
<span class="text-accent"> * </span>
Melding
</span>
<textarea required bind:value={message} class="h-60 bg-primary-100" placeholder="Melding" />
</label>
<div>
<Button class="w-full" disabled={buttonDisabled} loading={posting} type="submit">Send</Button>
</div>
</form>
</div> </div>

View File

@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from "svelte";
import 'leaflet/dist/leaflet.css'; import "leaflet/dist/leaflet.css";
import { env } from '$env/dynamic/public'; import { twMerge } from "tailwind-merge";
import { twMerge } from 'tailwind-merge';
interface MarkerPoint { interface MarkerPoint {
latitude: number; latitude: number;
@@ -16,29 +15,32 @@
let map: L.Map | null = null; let map: L.Map | null = null;
let markers: L.Marker<any>[] = []; let markers: L.Marker<any>[] = [];
var icon: L.Icon; var icon: L.Icon;
let L: typeof import('leaflet/index'); let L: typeof import("leaflet/index");
onMount(async () => { onMount(async () => {
L = await import('leaflet'); L = await import("leaflet");
icon = L.icon({ icon = L.icon({
iconUrl: '/map_marker.png', iconUrl: "/map_marker.png",
iconSize: [32, 32], iconSize: [32, 32],
iconAnchor: [16, 32], iconAnchor: [16, 32],
popupAnchor: [-3, -76] popupAnchor: [-3, -76],
}); });
map = L.map(mapElement); map = L.map(mapElement);
if (coordinates.length >= 1) { if (coordinates.length >= 1) {
map.setView([coordinates[0].latitude, coordinates[0].longitude], 15); map.setView(
[coordinates[0].latitude, coordinates[0].longitude],
15,
);
} }
L.tileLayer(env.PUBLIC_TILE_SERVER_URL, { L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 18, maxZoom: 18,
attribution: attribution:
'Map data &copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>', '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
id: 'base' id: "base",
}).addTo(map); }).addTo(map);
updateMarkers(); updateMarkers();
@@ -49,11 +51,13 @@
}); });
function addMarker(point: MarkerPoint) { function addMarker(point: MarkerPoint) {
let marker = L.marker([point.latitude, point.longitude], { icon: icon }); let marker = L.marker([point.latitude, point.longitude], {
icon: icon,
});
if (point.tooltip) if (point.tooltip)
marker.bindTooltip(point.tooltip, { marker.bindTooltip(point.tooltip, {
direction: 'bottom' direction: "bottom",
}); });
markers.push(marker); markers.push(marker);
@@ -68,7 +72,7 @@
coordinates.forEach(addMarker); coordinates.forEach(addMarker);
map?.flyToBounds(L.featureGroup(markers).getBounds(), { map?.flyToBounds(L.featureGroup(markers).getBounds(), {
duration: 1 duration: 1,
}); });
} }
} }
@@ -76,4 +80,7 @@
$: coordinates && updateMarkers(); $: coordinates && updateMarkers();
</script> </script>
<div bind:this={mapElement} class={twMerge('z-0 h-full w-full', $$restProps['class'])} /> <div
bind:this={mapElement}
class={twMerge("z-0 h-full w-full", $$restProps["class"])}
></div>

View File

@@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import Button from "./button/Button.svelte";
import Button from '$lib/components/button/Button.svelte'; import { twMerge } from "tailwind-merge";
import { twMerge } from 'tailwind-merge';
export let maxItems: number; export let maxItems: number;
let pageParam = $page.url.searchParams.get('page'); let pageParam = $page.url.searchParams.get("page");
let currentPage = 1; let currentPage = 1;
if (pageParam) { if (pageParam) {
let pageParamAsNumber = Number(pageParam); let pageParamAsNumber = Number(pageParam);
@@ -15,7 +14,9 @@
} }
</script> </script>
<section class={twMerge('mt-8 flex justify-center gap-5', $$restProps['class'])}> <section
class={twMerge("mt-8 flex justify-center gap-5", $$restProps["class"])}
>
{#if currentPage > 1} {#if currentPage > 1}
<a <a
on:click={() => on:click={() =>

View File

@@ -3,6 +3,7 @@
import { onMount, type Component, type Snippet } from "svelte"; import { onMount, type Component, type Snippet } from "svelte";
type RouteInfo = { type RouteInfo = {
path: string;
pattern: RegExp; pattern: RegExp;
paramNames: string[]; paramNames: string[];
component: Component<{}>; component: Component<{}>;
@@ -44,7 +45,16 @@
}); });
function normalizePath(path: string): string { function normalizePath(path: string): string {
return path.replace(/\/+$/, ""); 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;
} }
/** /**
@@ -55,17 +65,13 @@
console.warn(`Route already registered for path: '${path}'.`); console.warn(`Route already registered for path: '${path}'.`);
} }
const params = Array.from( const params = Array.from(path.matchAll(/\[([^\]]+)\]/g)).map(
path.matchAll(/\[([^\]]+)\]/g).map((x) => x[1]), (x) => x[1],
); );
const normalizedPath = normalizePath(path);
const escapedPath = normalizedPath.replace(/[.*+?^${}()|\\/]/g, "\\$&");
const pathPattern = escapedPath.replace(/\[([^\]]+)\]/g, "([^/]+)");
const regexPattern = `^${pathPattern}\\/?$`;
routes.set(path, { routes.set(path, {
pattern: new RegExp(regexPattern), pattern: new RegExp(createRoutePattern(path)),
path: path,
paramNames: params, paramNames: params,
component: component, component: component,
}); });
@@ -75,6 +81,19 @@
export class MissingRouteError extends RouteError {} export class MissingRouteError extends RouteError {}
export class RouteFormatError 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. * Gets the value of a route parameter.
*/ */
@@ -178,8 +197,9 @@
function makeProxy(target: any) { function makeProxy(target: any) {
return new Proxy(target, { return new Proxy(target, {
apply: (target, thisArg, argArray) => { apply: (target, thisArg, argArray) => {
target.apply(thisArg, argArray); const result = target.apply(thisArg, argArray);
refresh(); refresh();
return result;
}, },
}); });
} }
@@ -197,21 +217,19 @@
return; return;
} }
if (e.target instanceof HTMLAnchorElement) { const anchor = (e.target as Element)?.closest("a");
if (e.target.target === "_blank") { if (!(anchor instanceof HTMLAnchorElement)) return;
return; if (anchor.target === "_blank") return;
}
const targetUrl = new URL(e.target.href, document.baseURI); const targetUrl = new URL(anchor.href, document.baseURI);
const documentUrl = new URL(document.baseURI); const documentUrl = new URL(document.baseURI);
if (targetUrl.origin == documentUrl.origin) { if (targetUrl.origin === documentUrl.origin) {
e.preventDefault(); e.preventDefault();
history.pushState({}, "", targetUrl); history.pushState({}, "", targetUrl);
currentUrl = targetUrl; currentUrl = targetUrl;
} }
} }
}
window.addEventListener("popstate", refresh); window.addEventListener("popstate", refresh);
document.addEventListener("click", handleClick); document.addEventListener("click", handleClick);

View File

@@ -1,37 +1,39 @@
<script lang="ts"> <script lang="ts">
import { toast, toasts } from '$lib/components/toast/toast'; import { toast, toasts } from "./toast";
import { fade } from 'svelte/transition'; import { fade } from "svelte/transition";
import InfoIcon from '../icons/severity/InfoIcon.svelte'; import InfoIcon from "../icons/severity/InfoIcon.svelte";
import WarningIcon from '../icons/severity/WarningIcon.svelte'; import WarningIcon from "../icons/severity/WarningIcon.svelte";
import ErrorIcon from '../icons/severity/ErrorIcon.svelte'; import ErrorIcon from "../icons/severity/ErrorIcon.svelte";
import SuccessIcon from '../icons/severity/SuccessIcon.svelte'; import SuccessIcon from "../icons/severity/SuccessIcon.svelte";
</script> </script>
{#if $toasts.length >= 1} {#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"> <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} {#each $toasts as toastItem}
<div <div
class:bg-red-500={toastItem.type === 'error'} class:bg-red-500={toastItem.type === "error"}
class:bg-green-500={toastItem.type === 'success'} class:bg-green-500={toastItem.type === "success"}
class:bg-orange-500={toastItem.type === 'warning'} class:bg-orange-500={toastItem.type === "warning"}
class:bg-blue-500={toastItem.type === 'info'} 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" class="card flex items-center gap-3 border-contrast-900 px-4 py-3 shadow-md dark:border-contrast-100"
transition:fade={{ duration: 200 }} transition:fade={{ duration: 200 }}
> >
<div <div
class:text-red-900={toastItem.type === 'error'} class:text-red-900={toastItem.type === "error"}
class:text-green-900={toastItem.type === 'success'} class:text-green-900={toastItem.type === "success"}
class:text-orange-900={toastItem.type === 'warning'} class:text-orange-900={toastItem.type === "warning"}
class:text-blue-900={toastItem.type === 'info'} class:text-blue-900={toastItem.type === "info"}
class="shrink-0" class="shrink-0"
> >
{#if toastItem.type === 'info'} {#if toastItem.type === "info"}
<InfoIcon class="w-7"></InfoIcon> <InfoIcon class="w-7"></InfoIcon>
{:else if toastItem.type === 'warning'} {:else if toastItem.type === "warning"}
<WarningIcon class="w-7"></WarningIcon> <WarningIcon class="w-7"></WarningIcon>
{:else if toastItem.type === 'error'} {:else if toastItem.type === "error"}
<ErrorIcon class="w-7"></ErrorIcon> <ErrorIcon class="w-7"></ErrorIcon>
{:else if toastItem.type === 'success'} {:else if toastItem.type === "success"}
<SuccessIcon class="w-7"></SuccessIcon> <SuccessIcon class="w-7"></SuccessIcon>
{/if} {/if}
</div> </div>
@@ -40,7 +42,11 @@
{toastItem.text} {toastItem.text}
</p> </p>
<button on:click={() => toast.dismiss(toastItem.id)} class="shrink-0"> <button
on:click={() => toast.dismiss(toastItem.id)}
class="shrink-0"
aria-label="dismiss"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
@@ -49,7 +55,11 @@
stroke="currentColor" stroke="currentColor"
class="w-6" class="w-6"
> >
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</button> </button>
</div> </div>

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],
},
];
}

View File

@@ -1,9 +1,7 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { browser } from "$app/environment";
function createThemeToggler() { function createThemeToggler() {
let defaultValue = false; let defaultValue = false;
if (browser) {
let savedValue = localStorage.getItem("dark_theme"); let savedValue = localStorage.getItem("dark_theme");
if (savedValue) { if (savedValue) {
@@ -16,10 +14,9 @@ function createThemeToggler() {
} }
if (defaultValue) { if (defaultValue) {
document.documentElement.classList.add("dark"); document.documentElement.style.colorScheme = "dark";
} else { } else {
document.documentElement.classList.remove("dark"); document.documentElement.style.colorScheme = "light";
}
} }
const { subscribe, update } = writable(defaultValue); const { subscribe, update } = writable(defaultValue);
@@ -28,15 +25,13 @@ function createThemeToggler() {
subscribe, subscribe,
set: (value: boolean) => { set: (value: boolean) => {
update(() => value); update(() => value);
if (browser) {
if (value) { if (value) {
document.documentElement.classList.add("dark"); document.documentElement.style.colorScheme = "dark";
} else { } else {
document.documentElement.classList.remove("dark"); document.documentElement.style.colorScheme = "light";
} }
localStorage.setItem("dark_theme", JSON.stringify(value)); localStorage.setItem("dark_theme", JSON.stringify(value));
}
}, },
}; };
} }

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 />

View File

@@ -1 +1,120 @@
Home <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>